From 1a951392618a3f3e89eb765233c086f9a56dc239 Mon Sep 17 00:00:00 2001 From: Fimeg Date: Mon, 18 May 2026 11:03:03 -0400 Subject: [PATCH] feat: sensorium ambient-context injection, wheel-scroll, click-to-copy fix MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Sensorium: a new sensorium::ambient_line() builds a one-line ambient sense — current date/time and the connecting user — prepended to the user message every turn, for both the primary (send_with_signals) and the subconscious (subconscious_tool_loop). The subconscious no longer guesses the date; ledger entries get stamped with the real time. TUI scroll-wheel: the chat viewport now responds to mouse ScrollUp/ ScrollDown, routed through the same scroll field the arrow keys move (3 lines per notch). TUI click-to-copy fix: copy_message_at was hit-testing against stale line indices — span_spans recorded message positions against the pre-wrap buffer, but wrap_lines reflows it and shifts every index past a wrap point. draw_messages now builds a prefix-sum remap from pre- to post-wrap indices and translates the spans before storing them. arboard itself was fine; the bug was the bookkeeping. --- src/backend/local.rs | 10 +++++++--- src/core/sensorium/mod.rs | 11 +++++++++++ src/server/consciousness_engine.rs | 12 ++++++++---- src/ui/app.rs | 29 ++++++++++++++++++----------- src/ui/chat.rs | 19 ++++++++++++++++++- 5 files changed, 62 insertions(+), 19 deletions(-) diff --git a/src/backend/local.rs b/src/backend/local.rs index 4f45b8a..8e7d799 100644 --- a/src/backend/local.rs +++ b/src/backend/local.rs @@ -664,20 +664,24 @@ impl Backend for LocalBackend { .get(conversation_id) .map(|s| s.agent_id.clone()); + // Ambient sense rides in front of every turn — the date/time and who + // is present — so she is never guessing what year it is. + let ambient = crate::core::sensorium::ambient_line(); + let user_text = if let Some(agent_id) = session_agent_id { let surfacings = drain_intrusive_surfacings(&self.server, &agent_id).await; if surfacings.is_empty() { - text.to_string() + format!("{}\n{}", ambient, text) } else { let prelude = surfacings .iter() .map(|line| line.as_str()) .collect::>() .join("\n"); - format!("{}\n{}", prelude, text) + format!("{}\n{}\n{}", ambient, prelude, text) } } else { - text.to_string() + format!("{}\n{}", ambient, text) }; self.server.sessions.add_message( diff --git a/src/core/sensorium/mod.rs b/src/core/sensorium/mod.rs index 80f6482..a10da30 100644 --- a/src/core/sensorium/mod.rs +++ b/src/core/sensorium/mod.rs @@ -17,6 +17,17 @@ use tokio::sync::mpsc; use tracing::debug; +/// A single line of ambient sense the agent receives with every turn: +/// what time it is, who is present. Prepended to the user message so +/// both the primary and the subconscious are oriented in time. +pub fn ambient_line() -> String { + let datetime = chrono::Local::now().format("%Y-%m-%d %H:%M").to_string(); + let user = std::env::var("USER") + .or_else(|_| std::env::var("USERNAME")) + .unwrap_or_else(|_| "someone".to_string()); + format!("[ ambient sense - {} - {} is here ]", datetime, user) +} + /// Bandwidth classification for interface capability /// Higher bandwidth = richer telemetry and animation #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] diff --git a/src/server/consciousness_engine.rs b/src/server/consciousness_engine.rs index 05fabd2..ba08dc6 100644 --- a/src/server/consciousness_engine.rs +++ b/src/server/consciousness_engine.rs @@ -482,15 +482,19 @@ Resolve entries: `[YYYY-MM-DD HH:MM] RESOLVED — note`"#; .map(|a| a.name) .unwrap_or_else(|_| "the primary".to_string()); + // Ambient sense rides in front of the subconscious turn too — same + // date/time and presence orientation the primary receives. + let ambient = crate::core::sensorium::ambient_line(); + let user_content = if user_message.is_empty() { format!( - "{} responded:\n\n{}", - primary_name, primary_response + "{}\n{} responded:\n\n{}", + ambient, primary_name, primary_response ) } else { format!( - "User said:\n{}\n\n{} responded:\n{}", - user_message, primary_name, primary_response + "{}\nUser said:\n{}\n\n{} responded:\n{}", + ambient, user_message, primary_name, primary_response ) }; diff --git a/src/ui/app.rs b/src/ui/app.rs index c79eab8..406e963 100644 --- a/src/ui/app.rs +++ b/src/ui/app.rs @@ -515,18 +515,25 @@ impl App { }; } Event::Mouse(m) => { - // Left-click on a message bubble copies it to the - // clipboard (the ⧉ in the bubble title is the cue). - if self.current_screen == Screen::Chat - && matches!( - m.kind, - crossterm::event::MouseEventKind::Down( - crossterm::event::MouseButton::Left - ) - ) - { + use crossterm::event::{MouseButton, MouseEventKind}; + if self.current_screen == Screen::Chat { if let Some(chat) = self.chat.as_mut() { - chat.copy_message_at(m.column, m.row); + match m.kind { + // Left-click on a message bubble copies it + // to the clipboard (the cue in the title). + MouseEventKind::Down(MouseButton::Left) => { + chat.copy_message_at(m.column, m.row); + } + // Wheel rides the same scroll field the + // arrow keys move — a notch is three lines. + MouseEventKind::ScrollUp => { + chat.scroll = chat.scroll.saturating_add(3); + } + MouseEventKind::ScrollDown => { + chat.scroll = chat.scroll.saturating_sub(3); + } + _ => {} + } } } } diff --git a/src/ui/chat.rs b/src/ui/chat.rs index 09fc819..22b93f3 100644 --- a/src/ui/chat.rs +++ b/src/ui/chat.rs @@ -2276,8 +2276,25 @@ fn draw_messages(f: &mut Frame, state: &ChatState, area: Rect) { // notices, raw text, anything that bypassed bubble pre-wrap) gets // wrapped here. After this, `lines.len()` equals the visible line // count — Paragraph's wrap becomes a no-op and scroll math holds. + // + // Wrapping can split one pre-wrap line into several, which shifts every + // line index that follows. `span_spans` was recorded against the + // pre-wrap buffer, so build a prefix-sum remap from pre-wrap index to + // post-wrap index and translate the spans — otherwise a mouse click + // lands on the wrong bubble (or none). let visible_width = area.width.saturating_sub(0) as usize; - let lines = markdown::wrap_lines(lines, visible_width); + let mut wrap_remap: Vec = Vec::with_capacity(lines.len() + 1); + let mut wrapped_lines: Vec> = Vec::with_capacity(lines.len()); + for line in lines { + wrap_remap.push(wrapped_lines.len()); + wrapped_lines.extend(markdown::wrap_line(line, visible_width)); + } + wrap_remap.push(wrapped_lines.len()); + let lines = wrapped_lines; + for (_, s, e) in span_spans.iter_mut() { + *s = wrap_remap.get(*s).copied().unwrap_or(*s); + *e = wrap_remap.get(*e).copied().unwrap_or(*e); + } // Trim trailing empty lines from the count (each bubble appends a // blank separator; the last one shouldn't push the final real line