Watch
1
0
Fork
You've already forked souveraine
0

feat: sensorium ambient-context injection, wheel-scroll, click-to-copy fix

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.
This commit is contained in:
Fimeg 2026-05-18 11:03:03 -04:00
commit 1a95139261
5 changed files with 62 additions and 19 deletions

View file

@ -664,20 +664,24 @@ impl Backend for LocalBackend {
.get(conversation_id) .get(conversation_id)
.map(|s| s.agent_id.clone()); .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 user_text = if let Some(agent_id) = session_agent_id {
let surfacings = drain_intrusive_surfacings(&self.server, &agent_id).await; let surfacings = drain_intrusive_surfacings(&self.server, &agent_id).await;
if surfacings.is_empty() { if surfacings.is_empty() {
text.to_string() format!("{}\n{}", ambient, text)
} else { } else {
let prelude = surfacings let prelude = surfacings
.iter() .iter()
.map(|line| line.as_str()) .map(|line| line.as_str())
.collect::<Vec<_>>() .collect::<Vec<_>>()
.join("\n"); .join("\n");
format!("{}\n{}", prelude, text) format!("{}\n{}\n{}", ambient, prelude, text)
} }
} else { } else {
text.to_string() format!("{}\n{}", ambient, text)
}; };
self.server.sessions.add_message( self.server.sessions.add_message(

View file

@ -17,6 +17,17 @@
use tokio::sync::mpsc; use tokio::sync::mpsc;
use tracing::debug; 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 /// Bandwidth classification for interface capability
/// Higher bandwidth = richer telemetry and animation /// Higher bandwidth = richer telemetry and animation
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]

View file

@ -482,15 +482,19 @@ Resolve entries: `[YYYY-MM-DD HH:MM] RESOLVED — note`"#;
.map(|a| a.name) .map(|a| a.name)
.unwrap_or_else(|_| "the primary".to_string()); .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() { let user_content = if user_message.is_empty() {
format!( format!(
"{} responded:\n\n{}", "{}\n{} responded:\n\n{}",
primary_name, primary_response ambient, primary_name, primary_response
) )
} else { } else {
format!( format!(
"User said:\n{}\n\n{} responded:\n{}", "{}\nUser said:\n{}\n\n{} responded:\n{}",
user_message, primary_name, primary_response ambient, user_message, primary_name, primary_response
) )
}; };

View file

@ -515,18 +515,25 @@ impl App {
}; };
} }
Event::Mouse(m) => { Event::Mouse(m) => {
// Left-click on a message bubble copies it to the use crossterm::event::{MouseButton, MouseEventKind};
// clipboard (the ⧉ in the bubble title is the cue). if self.current_screen == Screen::Chat {
if self.current_screen == Screen::Chat
&& matches!(
m.kind,
crossterm::event::MouseEventKind::Down(
crossterm::event::MouseButton::Left
)
)
{
if let Some(chat) = self.chat.as_mut() { 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);
}
_ => {}
}
} }
} }
} }

View file

@ -2276,8 +2276,25 @@ fn draw_messages(f: &mut Frame, state: &ChatState, area: Rect) {
// notices, raw text, anything that bypassed bubble pre-wrap) gets // notices, raw text, anything that bypassed bubble pre-wrap) gets
// wrapped here. After this, `lines.len()` equals the visible line // wrapped here. After this, `lines.len()` equals the visible line
// count — Paragraph's wrap becomes a no-op and scroll math holds. // 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 visible_width = area.width.saturating_sub(0) as usize;
let lines = markdown::wrap_lines(lines, visible_width); let mut wrap_remap: Vec<usize> = Vec::with_capacity(lines.len() + 1);
let mut wrapped_lines: Vec<Line<'static>> = 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 // Trim trailing empty lines from the count (each bubble appends a
// blank separator; the last one shouldn't push the final real line // blank separator; the last one shouldn't push the final real line