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)
.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::<Vec<_>>()
.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(

View file

@ -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)]

View file

@ -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
)
};

View file

@ -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);
}
_ => {}
}
}
}
}

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
// 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<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
// blank separator; the last one shouldn't push the final real line