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:
parent
ebb0bf4e65
commit
1a95139261
5 changed files with 62 additions and 19 deletions
|
|
@ -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(
|
||||||
|
|
|
||||||
|
|
@ -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)]
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
)
|
)
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue