diff --git a/CHANGELOG.md b/CHANGELOG.md index 425504e9..16e2f1c9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -77,8 +77,28 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 thing that cannot come along is an ad-hoc `-J` hop, and that is said out loud rather than saved broken (#438). +- **A coding agent's conversation is an outline, and a way back into it.** The + Info panel grows a **CONVERSATION** section: one row per turn, the prompt's + first line as its label, a dot that says whether the turn is still running. + Click a row and the pane scrolls so that turn's prompt is the top line — a + long agent session in a terminal has never had a way back to "what did I ask + an hour ago". It rides on the OSC 777 the hooks already send for the tab's + status dot, so it costs a cut only when an agent event actually arrives, and + it works wherever the agent runs — over ssh, in a container, in a remote + workspace — rather than only where a transcript file happens to be readable. + Reattaching to a pane rebuilds the outline from its own replayed history. A + turn that began on the alt screen is listed but not clickable, because there + is no scrollback behind it to return to. Agents whose hooks do not report + prompt text (Codex, Copilot, Grok) are left out rather than drawn as a column + of anonymous dots. + ### Changed +- **The Info panel's `agent` row is gone.** It said `Claude Code · working` + beside a status dot — the same name and the same dot the tab and its sidebar + row were already wearing, two panels away from neither of them. The + CONVERSATION section below now says what that agent is doing in a form the + row never could, and the dot stays where it was learned. - **A zsh or fish you gave your own arguments to is no longer injected into.** Custom arguments have always been the line where tty7 backs off — the bash, PowerShell and WSL setups checked for them — but the zsh and fish setups did diff --git a/crates/tty7-core/src/core/agent_hooks.rs b/crates/tty7-core/src/core/agent_hooks.rs index 6ceeea62..6458c227 100644 --- a/crates/tty7-core/src/core/agent_hooks.rs +++ b/crates/tty7-core/src/core/agent_hooks.rs @@ -78,9 +78,41 @@ fn build_hook_sequence(agent: &str, event: &str, stdin_json: &str) -> Vec { body[key] = serde_json::Value::String(v.to_string()); } } + if let Some(prompt) = ["prompt", "userPrompt", "user_prompt"] + .iter() + .find_map(|k| payload.get(*k)) + .and_then(|v| v.as_str()) + .and_then(prompt_label) + { + body["prompt"] = serde_json::Value::String(prompt); + } format!("\x1b]777;notify;{AGENT_EVENT_SENTINEL};{body}\x07").into_bytes() } +/// How much of a prompt rides back to the terminal. +/// +/// Two reasons it is short. The payload goes out as an OSC, and the tokenizer +/// reading it *abandons* anything past 8 KiB rather than truncating — a pasted +/// file would silently cost the whole event, not just its tail. And what the +/// client does with this is label one row of a list and look for that text in +/// the scrollback, neither of which can use more than a line. +const PROMPT_LABEL_MAX: usize = 200; + +/// The first line of what the user typed, which is both the label an outline +/// row shows and the needle that finds the turn again in the scrollback. +/// +/// A line rather than the whole prompt because the terminal wrapped it across +/// rows: a needle spanning a line break matches no single row, so the later +/// lines would only make the search fail. +fn prompt_label(text: &str) -> Option { + let line = text.lines().map(str::trim).find(|l| !l.is_empty())?; + let end = line + .char_indices() + .nth(PROMPT_LABEL_MAX) + .map_or(line.len(), |(i, _)| i); + Some(line[..end].to_string()) +} + #[cfg(unix)] fn write_to_controlling_tty(bytes: &[u8]) -> bool { if write_dev(std::path::Path::new("/dev/tty"), bytes) { @@ -1417,6 +1449,80 @@ mod tests { assert_eq!(ev.cwd.as_deref(), Some(std::path::Path::new("/w"))); } + /// Parses a built sequence back the way the terminal's scanner does. + fn round_trip( + agent: &str, + event: &str, + stdin_json: &str, + ) -> crate::core::cli_agent::AgentEvent { + let seq = build_hook_sequence(agent, event, stdin_json); + crate::core::cli_agent::parse_agent_event(&seq[2..seq.len() - 1]).expect("parses") + } + + #[test] + fn a_submitted_prompt_rides_back_as_the_turns_label() { + let ev = round_trip( + "claude", + "prompt-submit", + r#"{"prompt":"restore the outline","session_id":"s-1"}"#, + ); + assert_eq!(ev.prompt.as_deref(), Some("restore the outline")); + assert_eq!( + ev.message, None, + "a prompt is not a message; the turn starts with nothing said back" + ); + } + + #[test] + fn a_prompt_is_cut_to_its_first_line() { + let ev = round_trip( + "claude", + "prompt-submit", + r#"{"prompt":"\n\n what did we decide \nand then some more\nand more"}"#, + ); + assert_eq!( + ev.prompt.as_deref(), + Some("what did we decide"), + "later lines wrapped when they were drawn and would only fail the search" + ); + } + + #[test] + fn a_pasted_file_cannot_cost_the_whole_event() { + let prompt = "x".repeat(64 * 1024); + let ev = round_trip( + "claude", + "prompt-submit", + &serde_json::json!({ "prompt": prompt }).to_string(), + ); + assert_eq!( + ev.prompt.map(|p| p.chars().count()), + Some(PROMPT_LABEL_MAX), + "the tokenizer abandons an oversized payload rather than truncating it" + ); + } + + #[test] + fn a_prompt_of_wide_characters_is_cut_on_a_character_boundary() { + let prompt = "把大纲恢复一下".repeat(100); + let ev = round_trip( + "claude", + "prompt-submit", + &serde_json::json!({ "prompt": prompt }).to_string(), + ); + assert_eq!(ev.prompt.map(|p| p.chars().count()), Some(PROMPT_LABEL_MAX)); + } + + #[test] + fn an_agent_that_reports_no_prompt_carries_none() { + assert_eq!(round_trip("codex", "stop", "{}").prompt, None); + assert_eq!( + round_trip("claude", "prompt-submit", r#"{"prompt":" "}"#).prompt, + None, + "whitespace is not a label" + ); + } + #[test] fn grok_run_hooks_are_relabeled_to_grok() { assert_eq!(effective_agent("claude", true), "grok"); diff --git a/crates/tty7-core/src/core/cli_agent.rs b/crates/tty7-core/src/core/cli_agent.rs index 37a8ba6a..c4c67ea0 100644 --- a/crates/tty7-core/src/core/cli_agent.rs +++ b/crates/tty7-core/src/core/cli_agent.rs @@ -708,6 +708,13 @@ pub struct AgentEvent { pub session_id: Option, pub message: Option, pub cwd: Option, + /// What the user typed, on a `PromptSubmit` — already clamped to a label's + /// worth of text by the hook that sent it, since this rides an OSC payload + /// the tokenizer abandons rather than truncates past 8 KiB. + /// + /// Separate from `message`, which carries what the *agent* said and is + /// deliberately cleared when a turn starts. + pub prompt: Option, } pub fn parse_agent_event(payload: &[u8]) -> Option { @@ -729,6 +736,8 @@ pub fn parse_agent_event(payload: &[u8]) -> Option { message: Option, #[serde(default)] cwd: Option, + #[serde(default)] + prompt: Option, } let w: Wire = serde_json::from_slice(json).ok()?; @@ -740,6 +749,7 @@ pub fn parse_agent_event(payload: &[u8]) -> Option { session_id: nonempty(w.session_id), message: nonempty(w.message), cwd: nonempty(w.cwd).map(std::path::PathBuf::from), + prompt: nonempty(w.prompt), }) } @@ -1054,6 +1064,7 @@ mod tests { session_id: id.map(String::from), message: msg.map(String::from), cwd: None, + prompt: None, }; s.apply_event(&ev(AgentEventKind::SessionStart, None, Some("sid-1"))); @@ -1109,6 +1120,7 @@ mod tests { session_id: None, message: None, cwd: None, + prompt: None, }; let mut s = AgentSessionState::default(); @@ -1144,6 +1156,7 @@ mod tests { session_id: None, message: None, cwd: cwd.map(PathBuf::from), + prompt: None, }; let mut s = AgentSessionState::default(); diff --git a/crates/tty7-core/src/core/osc.rs b/crates/tty7-core/src/core/osc.rs index f6431cd2..83504084 100644 --- a/crates/tty7-core/src/core/osc.rs +++ b/crates/tty7-core/src/core/osc.rs @@ -27,6 +27,15 @@ impl OscTokenizer { } pub fn feed(&mut self, bytes: &[u8], mut on_payload: impl FnMut(&[u8])) { + self.feed_at(bytes, |_, payload| on_payload(payload)); + } + + /// [`feed`](Self::feed), but also reporting where each payload ended: an + /// offset one past its terminator, in ascending order, so a reader can + /// advance an emulator to exactly there and read the state the sequence + /// left behind. A payload split across two feeds is reported against the + /// batch its terminator landed in. + pub fn feed_at(&mut self, bytes: &[u8], mut on_payload: impl FnMut(usize, &[u8])) { let mut i = 0; while i < bytes.len() { match self.state { @@ -64,7 +73,7 @@ impl OscTokenizer { _ => self.state = State::Ground, }, State::Osc => match b { - 0x07 => self.finish(&mut on_payload), + 0x07 => self.finish(i + 1, &mut on_payload), 0x1b => self.state = State::OscEsc, _ => { self.buf.push(b); @@ -75,7 +84,7 @@ impl OscTokenizer { } }, State::OscEsc => match b { - b'\\' => self.finish(&mut on_payload), + b'\\' => self.finish(i + 1, &mut on_payload), 0x1b => {} b']' => { self.buf.clear(); @@ -107,8 +116,8 @@ impl OscTokenizer { } } - fn finish(&mut self, on_payload: &mut impl FnMut(&[u8])) { - on_payload(&self.buf); + fn finish(&mut self, at: usize, on_payload: &mut impl FnMut(usize, &[u8])) { + on_payload(at, &self.buf); self.buf.clear(); self.state = State::Ground; } @@ -237,6 +246,31 @@ mod tests { ); } + #[test] + fn offsets_land_one_past_the_terminator() { + let mut tok = OscTokenizer::new(&[b"9"]); + let mut got = Vec::new(); + let stream = b"ab\x1b]9;bel\x07cd\x1b]9;st\x1b\\"; + tok.feed_at(stream, |at, payload| got.push((at, payload.to_vec()))); + assert_eq!( + got, + vec![(10, b"9;bel".to_vec()), (20, b"9;st".to_vec())], + "a cut must point just past its sequence" + ); + assert_eq!(&stream[10..12], b"cd"); + assert_eq!(stream.len(), 20, "the ST-terminated one ends the stream"); + } + + #[test] + fn an_offset_is_reported_against_the_batch_its_terminator_lands_in() { + let mut tok = OscTokenizer::new(&[b"777"]); + let mut got = Vec::new(); + tok.feed_at(b"out\x1b]777;no", |at, p| got.push((at, p.to_vec()))); + assert!(got.is_empty(), "unterminated, so nothing to report yet"); + tok.feed_at(b"tify;x\x07tail", |at, p| got.push((at, p.to_vec()))); + assert_eq!(got, vec![(7, b"777;notify;x".to_vec())]); + } + #[test] fn esc_runs_and_non_osc_escapes_do_not_confuse_the_scanner() { assert_eq!( diff --git a/src/terminal/agent_marks.rs b/src/terminal/agent_marks.rs new file mode 100644 index 00000000..d8ec2b49 --- /dev/null +++ b/src/terminal/agent_marks.rs @@ -0,0 +1,709 @@ +//! Where each turn of a coding agent's conversation sits in the scrollback. +//! +//! The hooks tty7 installs into Claude Code and friends already announce a +//! turn's start and end over the pty, as an OSC 777 the daemon reads for the +//! pane's status dot. Those same bytes arrive at the client, and *here* they +//! are worth something else: the byte offset a `prompt-submit` lands on is a +//! position in the stream, so advancing the emulator to exactly there and +//! reading the cursor gives the row that turn began on. That is an outline of +//! the conversation, and a place to scroll back to. +//! +//! # Why the anchor is not the whole answer +//! +//! The hook is a subprocess writing to the controlling tty while the agent's +//! own renderer writes to it too. Claude Code repaints in place with ink, so +//! the cursor at the moment the hook's bytes land sits wherever the last +//! repaint left it — inside the live region at the bottom, a few rows off from +//! where the prompt's echo finally comes to rest. The anchor is close, not +//! exact. +//! +//! So the anchor is a *hint*, and [`AgentTurn::text`] is the correction: the +//! first line of what the user typed, which the view looks for around the +//! anchor when the jump happens (by then it has long been drawn). The anchor +//! narrows the search and orders the list; the text lands the jump. +//! +//! # Why rows and not the transcript file +//! +//! Claude Code keeps a JSONL transcript, and reading it would give the +//! assistant's side of the conversation too. It would also only work for +//! Claude, only for a pane whose agent runs on this machine, and only for a +//! path this process is allowed to read. An OSC comes back through the pty +//! from wherever the agent actually runs — over ssh, inside a container, in a +//! remote workspace — with no file access and no per-agent format. What is +//! lost is the assistant's text; what is kept is every host tty7 supports. + +use std::sync::{Arc, Mutex}; + +use alacritty_terminal::event::EventListener; +use alacritty_terminal::grid::Dimensions as _; +use alacritty_terminal::term::{Term, TermMode}; + +use crate::core::cli_agent::{AgentEventKind, parse_agent_event}; +use crate::core::osc::OscTokenizer; + +/// Turns kept per pane. A long agent session is tens of turns, not hundreds; +/// this is the bound that keeps a runaway hook from growing the list without +/// end, not a number anyone should reach. +const MAX_TURNS: usize = 500; + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct AgentTurn { + /// Absolute scrollback row: `history_size + grid line`, counting from the + /// oldest line the emulator still holds. `None` when the turn began on the + /// alt screen, which has no scrollback to point into. + /// + /// The count it is relative to shrinks once the scrollback limit starts + /// discarding lines, which slides every anchor by the same amount. That is + /// what `text` is for. + pub row: Option, + /// The first line of the prompt, as the hook clamped it. Empty when the + /// agent's hook does not report prompts. + pub text: String, + /// The turn ended (a `stop` event arrived). + pub done: bool, + /// Identity for the UI, so a list that grows underneath a click still + /// points at the same turn. + pub id: u64, +} + +#[derive(Clone, Default)] +pub struct AgentTurns(Arc>); + +#[derive(Default)] +struct Inner { + turns: Vec, + next_id: u64, +} + +impl AgentTurns { + pub fn new() -> Self { + Self::default() + } + + pub fn list(&self) -> Vec { + let Ok(inner) = self.0.lock() else { + return Vec::new(); + }; + inner.turns.clone() + } + + /// Drop everything, for the same reason the image store is dropped: the + /// rows these point into are gone (`clear_scrollback`, a relink that resets + /// the grid) or belong to a different conversation (a fresh session). + pub fn clear(&self) { + if let Ok(mut inner) = self.0.lock() { + inner.turns.clear(); + } + } + + /// Move a turn onto the row the view found its text on. Jumping twice + /// should not search twice, and should not land in two different places. + pub fn recenter(&self, id: u64, row: i64) { + let Ok(mut inner) = self.0.lock() else { return }; + if let Some(turn) = inner.turns.iter_mut().find(|t| t.id == id) { + turn.row = Some(row); + } + } + + /// Read the row the emulator is on and record the turn there. Called with + /// the emulator advanced to exactly the byte after the event's terminator. + pub fn apply(&self, term: &Term, cut: TurnCut) { + match cut { + TurnCut::Begin { text } => { + // The alt screen is a scratch surface with no history behind + // it, so there is no row to come back to. The turn still + // belongs in the list — an agent that renders there (Codex + // does) has a conversation like any other — it just cannot be + // jumped to. + let row = (!term.mode().contains(TermMode::ALT_SCREEN)).then(|| { + let grid = term.grid(); + grid.history_size() as i64 + i64::from(grid.cursor.point.line.0) + }); + self.begin(row, text.unwrap_or_default()); + } + TurnCut::End => self.finish(), + TurnCut::Reset => self.clear(), + } + } + + fn begin(&self, row: Option, text: String) { + let Ok(mut inner) = self.0.lock() else { return }; + // One turn, announced twice. Hooks are not guaranteed to fire once — + // an agent reading two settings sources runs the same command twice, + // and a resumed session re-announces the turn it is resuming. What + // makes it the same turn is that the one before it never ended: a real + // repeat of a prompt can only come after the answer to the first, and + // an answer always brings a `stop`. + if let Some(last) = inner.turns.last_mut() + && !last.done + && last.text == text + { + // The earlier anchor is the better one — it was taken before the + // agent had drawn anything in reply — but take a row over nothing. + if last.row.is_none() { + last.row = row; + } + return; + } + let id = inner.next_id; + inner.next_id += 1; + inner.turns.push(AgentTurn { + row, + text, + done: false, + id, + }); + let overflow = inner.turns.len().saturating_sub(MAX_TURNS); + if overflow > 0 { + inner.turns.drain(..overflow); + } + } + + fn finish(&self) { + let Ok(mut inner) = self.0.lock() else { return }; + if let Some(last) = inner.turns.last_mut() { + last.done = true; + } + } +} + +/// Longest needle taken from a prompt. The point of a cap is that the text has +/// to sit on *one* grid row to be findable, and a prompt longer than the pane +/// is wide wrapped when it was drawn. +const NEEDLE_MAX: usize = 32; + +/// Shortest needle worth searching for *by containment*. Below this, `y` or +/// `go` occurs inside half the rows on screen and the closest match to the +/// anchor would be noise dressed up as precision. The exact match below has no +/// such floor: a row that *is* the prompt is the prompt however short it is. +const NEEDLE_MIN: usize = 3; + +/// What a TUI draws in front of what the user typed. Stripping one of these +/// turns the row Claude Code renders — `> restore the outline` — back into the +/// prompt the hook reported, so the two can be compared as equals. +const PROMPT_MARKERS: &[char] = &[ + '>', '❯', '›', '»', '〉', '⟩', '▶', '●', '•', '│', '|', '$', '#', '*', +]; + +/// Rows to look at either side of the anchor before widening to the whole +/// scrollback. Two screens covers the live region an agent repaints in, which +/// is the distance the anchor can be off by. +const NEAR: i64 = 120; + +/// The row a turn's prompt was actually drawn on, or `anchor` when there is +/// nothing to go on. +/// +/// The anchor says where the cursor was when the hook fired, which is a few +/// rows from where the prompt's echo settled (see the module docs), and drifts +/// further once the scrollback limit starts discarding lines out from under +/// every anchor at once. The prompt's own text does not drift, so it is the +/// better answer whenever it can be found — near the anchor first, since a +/// prompt asked twice should resolve to the turn that was clicked. +pub fn locate(term: &Term, anchor: i64, text: &str) -> i64 { + let Some(needle) = needle(text) else { + return anchor; + }; + let grid = term.grid(); + let last = grid.history_size() as i64 + grid.screen_lines() as i64 - 1; + let near = NEAR.max(2 * grid.screen_lines() as i64); + let (lo, hi) = ((anchor - near).max(0), (anchor + near).min(last)); + // Exact first, everywhere, before settling for containment: the row that + // *is* the prompt beats a row that merely mentions it, even when the + // mention is closer. `hi` appears inside a dozen rows of any answer; only + // one row is `> hi`. + Match::Exact + .nearest(term, anchor, needle, lo, hi) + .or_else(|| Match::Exact.nearest(term, anchor, needle, 0, last)) + .or_else(|| Match::Loose.nearest(term, anchor, needle, lo, hi)) + .or_else(|| Match::Loose.nearest(term, anchor, needle, 0, last)) + .unwrap_or(anchor) +} + +/// How hard a row has to try to count as the one the prompt was drawn on. +#[derive(Clone, Copy, PartialEq, Eq)] +enum Match { + /// The row, minus the marker the TUI drew in front of it, starts with the + /// prompt. This is what an agent's echo of the user's line looks like. + Exact, + /// The row mentions the prompt somewhere. The fallback for a TUI that + /// frames its messages some other way — and the reason for [`NEEDLE_MIN`]. + Loose, +} + +fn needle(text: &str) -> Option<&str> { + let text = text.trim(); + let end = text + .char_indices() + .nth(NEEDLE_MAX) + .map_or(text.len(), |(i, _)| i); + Some(&text[..end]).filter(|n| !n.is_empty()) +} + +impl Match { + /// The row closest to `anchor` in `lo..=hi` that matches, found by walking + /// outwards so the first hit is the answer. + fn nearest( + self, + term: &Term, + anchor: i64, + needle: &str, + lo: i64, + hi: i64, + ) -> Option { + if lo > hi || (self == Match::Loose && needle.chars().count() < NEEDLE_MIN) { + return None; + } + let reach = (anchor - lo).max(hi - anchor).max(0); + for step in 0..=reach { + let probes = [anchor - step, anchor + step]; + for &row in &probes[..if step == 0 { 1 } else { 2 }] { + if (lo..=hi).contains(&row) && self.holds(term, row, needle) { + return Some(row); + } + } + } + None + } + + fn holds(self, term: &Term, row: i64, needle: &str) -> bool { + let Some(text) = row_text(term, row) else { + return false; + }; + match self { + Match::Loose => text.contains(needle), + Match::Exact => { + let line = text.trim(); + let line = match line.chars().next() { + Some(c) if PROMPT_MARKERS.contains(&c) => &line[c.len_utf8()..], + _ => line, + }; + line.trim_start().starts_with(needle) + } + } + } +} + +/// One scrollback row as text, with the spacer cells that follow double-width +/// glyphs left out — keeping them would break every CJK needle in half. +fn row_text(term: &Term, row: i64) -> Option { + use alacritty_terminal::index::{Column, Line}; + use alacritty_terminal::term::cell::Flags; + + let grid = term.grid(); + let line = row - grid.history_size() as i64; + if line < -(grid.history_size() as i64) || line >= grid.screen_lines() as i64 { + return None; + } + let line = i32::try_from(line).ok()?; + let row = &grid[Line(line)]; + let mut text = String::with_capacity(grid.columns()); + for col in 0..grid.columns() { + let cell = &row[Column(col)]; + if cell + .flags + .intersects(Flags::WIDE_CHAR_SPACER | Flags::LEADING_WIDE_CHAR_SPACER) + { + continue; + } + text.push(cell.c); + if let Some(zerowidth) = cell.zerowidth() { + text.extend(zerowidth); + } + } + Some(text) +} + +/// What an agent event means for the outline, reported at the offset one past +/// the sequence that carried it. +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum TurnCut { + Begin { + text: Option, + }, + End, + /// A session started: whatever is in the list belongs to a conversation + /// that is over. Claude Code sends this on `/clear` and on a resume, both + /// of which repaint the pane from scratch. + Reset, +} + +/// Byte scanner over the pty stream, reporting the agent events an outline +/// cares about, in ascending offset order — the same shape (and the same +/// contract) as [`ParkedCursorScanner`](crate::terminal::parked_cursor::ParkedCursorScanner). +pub struct AgentTurnScanner { + tok: OscTokenizer, +} + +impl Default for AgentTurnScanner { + fn default() -> Self { + Self { + tok: OscTokenizer::new(&[b"777"]), + } + } +} + +impl AgentTurnScanner { + pub fn new() -> Self { + Self::default() + } + + /// Forgets a sequence in progress. A replayed snapshot is a new stream, and + /// half an OSC from the old one must not join up with it. + pub fn reset(&mut self) { + *self = Self::default(); + } + + pub fn feed(&mut self, bytes: &[u8], mut on_cut: impl FnMut(usize, TurnCut)) { + self.tok.feed_at(bytes, |at, payload| { + let Some(ev) = parse_agent_event(payload) else { + return; + }; + match ev.kind { + AgentEventKind::PromptSubmit => on_cut(at, TurnCut::Begin { text: ev.prompt }), + AgentEventKind::Stop => on_cut(at, TurnCut::End), + AgentEventKind::SessionStart => on_cut(at, TurnCut::Reset), + // The rest move the status dot, not the conversation: a + // permission prompt or a tool completion happens *inside* a + // turn that is already in the list. + _ => {} + } + }); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use alacritty_terminal::event::VoidListener; + use alacritty_terminal::vte::ansi::Processor; + + fn event(kind: &str, prompt: Option<&str>) -> Vec { + let mut body = serde_json::json!({ + "v": 1, + "agent": "claude", + "event": kind, + }); + if let Some(p) = prompt { + body["prompt"] = serde_json::Value::String(p.to_string()); + } + format!( + "\x1b]777;notify;{};{body}\x07", + crate::core::cli_agent::AGENT_EVENT_SENTINEL + ) + .into_bytes() + } + + fn cuts(chunks: &[&[u8]]) -> Vec<(usize, TurnCut)> { + let mut scanner = AgentTurnScanner::new(); + let mut got = Vec::new(); + for chunk in chunks { + scanner.feed(chunk, |at, cut| got.push((at, cut))); + } + got + } + + /// Drives a stream through the emulator the way the reader does — advance + /// to each cut, act on it, carry on — and reports the turns it recorded. + fn turns_after(stream: &[u8]) -> Vec { + let mut term = Term::new( + alacritty_terminal::term::Config { + scrolling_history: 1000, + ..Default::default() + }, + &crate::terminal::size::TermSize::new(80, 24), + VoidListener, + ); + let mut parser: Processor = Processor::new(); + let mut scanner = AgentTurnScanner::new(); + let turns = AgentTurns::new(); + + let mut cuts = Vec::new(); + scanner.feed(stream, |off, cut| cuts.push((off, cut))); + let mut at = 0; + for (off, cut) in cuts { + parser.advance(&mut term, &stream[at..off]); + at = off; + turns.apply(&term, cut); + } + parser.advance(&mut term, &stream[at..]); + turns.list() + } + + #[test] + fn a_prompt_and_its_stop_are_one_turn() { + let mut stream = event("prompt-submit", Some("restore the outline")); + stream.extend_from_slice(&event("stop", None)); + let got = turns_after(&stream); + assert_eq!(got.len(), 1); + assert_eq!(got[0].text, "restore the outline"); + assert!(got[0].done); + } + + #[test] + fn a_turn_anchors_to_the_row_the_prompt_arrived_on() { + let mut stream = b"one\r\ntwo\r\nthree\r\n".to_vec(); + stream.extend_from_slice(&event("prompt-submit", Some("go"))); + assert_eq!( + turns_after(&stream)[0].row, + Some(3), + "three lines written, so the cursor is on the fourth row" + ); + } + + #[test] + fn an_anchor_counts_from_the_oldest_line_still_held() { + // Fill past a screen so lines are in history, and the anchor has to + // count them rather than the visible rows. + let mut stream = "x\r\n".repeat(30).into_bytes(); + stream.extend_from_slice(&event("prompt-submit", Some("go"))); + assert_eq!( + turns_after(&stream)[0].row, + Some(30), + "30 rows written: 7 scrolled into history, cursor on screen line 23" + ); + } + + #[test] + fn a_turn_on_the_alt_screen_has_no_row_to_return_to() { + let mut stream = b"\x1b[?1049h".to_vec(); + stream.extend_from_slice(&event("prompt-submit", Some("go"))); + let got = turns_after(&stream); + assert_eq!(got.len(), 1, "still a turn, still listed"); + assert_eq!(got[0].row, None, "nothing behind the alt screen to jump to"); + } + + #[test] + fn a_session_start_drops_the_previous_conversation() { + let mut stream = event("prompt-submit", Some("first")); + stream.extend_from_slice(&event("stop", None)); + stream.extend_from_slice(&event("session-start", None)); + stream.extend_from_slice(&event("prompt-submit", Some("second"))); + let got = turns_after(&stream); + assert_eq!(got.len(), 1); + assert_eq!(got[0].text, "second"); + } + + #[test] + fn events_that_only_move_the_status_dot_are_not_turns() { + for kind in ["notification", "permission-request", "tool-complete"] { + assert!( + cuts(&[&event(kind, None)]).is_empty(), + "{kind} happens inside a turn, it does not start one" + ); + } + } + + #[test] + fn other_osc_777_notifications_are_left_alone() { + assert!( + cuts(&[b"\x1b]777;notify;Build;finished\x07"]).is_empty(), + "a plain desktop notification is not an agent event" + ); + } + + #[test] + fn a_cut_lands_one_past_its_sequence() { + let ev = event("stop", None); + let mut stream = b"before".to_vec(); + stream.extend_from_slice(&ev); + stream.extend_from_slice(b"after"); + let got = cuts(&[&stream]); + assert_eq!(got.len(), 1); + assert_eq!( + &stream[got[0].0..], + b"after", + "the emulator must be advanced to just past the event" + ); + } + + #[test] + fn an_event_split_across_reads_still_arrives() { + let ev = event("prompt-submit", Some("split me")); + let (head, tail) = ev.split_at(ev.len() / 2); + assert_eq!( + cuts(&[head, tail]), + vec![( + tail.len(), + TurnCut::Begin { + text: Some("split me".into()) + } + )], + "the cut is attributed to the batch its terminator lands in" + ); + } + + /// A pane holding `lines`, one per row, with the first of them at row 0. + fn painted(lines: &[&str]) -> Term { + let mut term = Term::new( + alacritty_terminal::term::Config { + scrolling_history: 1000, + ..Default::default() + }, + &crate::terminal::size::TermSize::new(80, 24), + VoidListener, + ); + let mut parser: Processor = Processor::new(); + parser.advance(&mut term, lines.join("\r\n").as_bytes()); + term + } + + #[test] + fn a_prompt_is_found_a_few_rows_off_its_anchor() { + let mut lines = vec!["boot"; 40]; + lines[30] = "> restore the outline please"; + let term = painted(&lines); + assert_eq!( + locate(&term, 33, "restore the outline please"), + 30, + "the anchor lands in the live region; the text says where the turn is" + ); + } + + #[test] + fn the_same_prompt_twice_resolves_to_the_one_that_was_clicked() { + let mut lines = vec!["boot"; 200]; + lines[20] = "> run the tests"; + lines[150] = "> run the tests"; + let term = painted(&lines); + assert_eq!(locate(&term, 22, "run the tests"), 20); + assert_eq!(locate(&term, 148, "run the tests"), 150); + } + + #[test] + fn a_prompt_far_from_its_anchor_is_still_found() { + // What a scrollback that has started discarding lines looks like: every + // anchor slid, and the near window no longer covers the distance. + let mut lines = vec!["boot"; 600]; + lines[80] = "> the anchor drifted away from me"; + let term = painted(&lines); + assert_eq!(locate(&term, 500, "the anchor drifted away from me"), 80); + } + + #[test] + fn a_wide_glyph_prompt_survives_the_spacer_cells() { + let mut lines = vec!["boot"; 40]; + lines[12] = "> 把大纲恢复一下"; + let term = painted(&lines); + assert_eq!( + locate(&term, 15, "把大纲恢复一下"), + 12, + "the cell after a double-width glyph is a spacer, not part of the text" + ); + } + + #[test] + fn a_two_letter_prompt_still_lands_on_its_own_row() { + // The row the agent echoed the prompt on is `> hi`; every row of the + // answer under it may well contain "hi" too. Exactness, not length, is + // what makes the short one safe. + let mut lines = vec!["boot"; 40]; + lines[12] = "> hi"; + lines[14] = "Hi! What can I help you with?"; + lines[16] = "I see you are on this branch"; + let term = painted(&lines); + assert_eq!(locate(&term, 15, "hi"), 12); + } + + #[test] + fn the_marker_a_tui_draws_in_front_does_not_hide_the_prompt() { + for marker in ["> ", "❯ ", "〉", "│ ", "▶ "] { + let mut lines = vec!["boot".to_string(); 40]; + lines[9] = format!("{marker}run the tests"); + let borrowed: Vec<&str> = lines.iter().map(String::as_str).collect(); + let term = painted(&borrowed); + assert_eq!(locate(&term, 13, "run the tests"), 9, "marker {marker:?}"); + } + } + + #[test] + fn a_row_that_is_the_prompt_beats_a_nearer_row_that_merely_mentions_it() { + let mut lines = vec!["boot"; 60]; + lines[20] = "> run the tests"; + lines[30] = "sure, I will run the tests now"; + let term = painted(&lines); + assert_eq!( + locate(&term, 31, "run the tests"), + 20, + "the echo is the turn; the sentence about it is the answer" + ); + } + + #[test] + fn text_too_short_to_be_distinctive_leaves_the_anchor_alone() { + let mut lines = vec!["y"; 40]; + lines[5] = "y"; + let term = painted(&lines); + assert_eq!( + locate(&term, 30, "y"), + 30, + "a needle that matches everywhere is worse than the anchor" + ); + } + + #[test] + fn a_prompt_that_was_never_drawn_leaves_the_anchor_alone() { + let term = painted(&vec!["boot"; 40]); + assert_eq!(locate(&term, 12, "nothing on screen says this"), 12); + } + + #[test] + fn a_turn_announced_twice_is_still_one_turn() { + // What a hook that fires from two settings sources produces: the same + // prompt twice, then the same stop twice. + let mut stream = event("prompt-submit", Some("hi")); + stream.extend_from_slice(&event("prompt-submit", Some("hi"))); + stream.extend_from_slice(&event("stop", None)); + stream.extend_from_slice(&event("stop", None)); + let got = turns_after(&stream); + assert_eq!(got.len(), 1, "one prompt, however many times announced"); + assert!(got[0].done); + } + + #[test] + fn the_first_anchor_of_a_repeated_announcement_is_the_one_kept() { + let mut stream = b"a\r\nb\r\n".to_vec(); + stream.extend_from_slice(&event("prompt-submit", Some("hi"))); + stream.extend_from_slice(b"c\r\nd\r\n"); + stream.extend_from_slice(&event("prompt-submit", Some("hi"))); + assert_eq!( + turns_after(&stream)[0].row, + Some(2), + "the earlier anchor was taken before the agent drew its reply" + ); + } + + #[test] + fn the_same_prompt_asked_again_after_an_answer_is_a_new_turn() { + let mut stream = event("prompt-submit", Some("hi")); + stream.extend_from_slice(&event("stop", None)); + stream.extend_from_slice(&event("prompt-submit", Some("hi"))); + let got = turns_after(&stream); + assert_eq!(got.len(), 2, "an answer came between them, so they differ"); + assert!(got[0].done && !got[1].done); + } + + #[test] + fn turns_are_capped_from_the_front() { + let turns = AgentTurns::new(); + for i in 0..(MAX_TURNS + 10) { + turns.begin(Some(i as i64), format!("turn {i}")); + } + let got = turns.list(); + assert_eq!(got.len(), MAX_TURNS); + assert_eq!( + got[0].text, "turn 10", + "the oldest aged out, not the newest" + ); + } + + #[test] + fn recentring_moves_the_anchor_by_id_not_by_position() { + let turns = AgentTurns::new(); + turns.begin(Some(10), "first".into()); + turns.begin(Some(20), "second".into()); + let id = turns.list()[0].id; + turns.recenter(id, 12); + assert_eq!(turns.list()[0].row, Some(12)); + assert_eq!(turns.list()[1].row, Some(20), "its neighbour is untouched"); + } +} diff --git a/src/terminal/mod.rs b/src/terminal/mod.rs index 59979e26..a09e113a 100644 --- a/src/terminal/mod.rs +++ b/src/terminal/mod.rs @@ -1,3 +1,4 @@ +pub(crate) mod agent_marks; mod boxdraw; mod cmd_editor; mod completion; diff --git a/src/terminal/remote.rs b/src/terminal/remote.rs index 48aadb58..4064c0cf 100644 --- a/src/terminal/remote.rs +++ b/src/terminal/remote.rs @@ -12,6 +12,7 @@ use alacritty_terminal::sync::FairMutex; use alacritty_terminal::term::{Config, Term, TermMode}; use alacritty_terminal::vte::ansi::{self, CursorShape, CursorStyle}; +use crate::terminal::agent_marks::{AgentTurnScanner, AgentTurns, TurnCut}; use crate::terminal::parked_cursor::{CursorCut, ParkedCursorRepair, ParkedCursorScanner}; use std::collections::VecDeque; @@ -64,6 +65,16 @@ struct ShellState { cycle: u64, } +/// A point in a batch of pty output where the emulator has to stop, because +/// something wants to read the state the sequence there left behind — the cell +/// a repaint hid the cursor on, or the row an agent turn began at. Both are +/// positions, and a position is only knowable by parsing up to it and no +/// further. +enum Cut { + Cursor(CursorCut), + Turn(TurnCut), +} + struct ReaderSignals { cwd: Arc>>, shell: Arc>, @@ -81,6 +92,10 @@ struct ReaderSignals { /// anchored to the grid for the paint path to blit. Shared with the reader, /// which places/deletes them as `DaemonMsg::Image`/`DeleteImage` frames land. images: crate::terminal::images::ImageStore, + /// Where each agent turn started, anchored to the grid the same way — see + /// [`crate::terminal::agent_marks`]. The daemon reads the same events for + /// the status dot, but only the client holds the rows they point into. + turns: AgentTurns, } #[derive(Clone, Debug, PartialEq)] @@ -208,6 +223,9 @@ pub struct RemoteTerminal { /// frames, read by the paint path — only the client holds the grid the /// anchors are relative to, so the store lives here rather than in the daemon. images: crate::terminal::images::ImageStore, + /// The conversation's shape, for the outline in the Info panel: one entry + /// per agent turn, anchored to the scrollback row it began on. + turns: AgentTurns, route: PaneRoute, proxy: EventProxy, reader_thread: Option>, @@ -484,6 +502,10 @@ impl RemoteTerminal { // Drop them; the daemon does not replay out-of-band image frames, so a // browser redraws on its next transmit (see issue #213's reattach note). self.images.clear(); + // Turn anchors point into the same grid. The replay that follows + // carries the agent's events with it, so the outline rebuilds itself + // from the bytes rather than being kept across the reset. + self.turns.clear(); let quit = Arc::new(AtomicBool::new(false)); let reader = Self::spawn_reader( @@ -506,6 +528,7 @@ impl RemoteTerminal { auth: self.auth_prompts.clone(), phase: self.ssh_phase.clone(), images: self.images.clone(), + turns: self.turns.clone(), }, ); if let Ok(mut writer) = self.writer.lock() { @@ -557,6 +580,7 @@ impl RemoteTerminal { Arc::new(Mutex::new(VecDeque::new())); let ssh_phase: Arc>> = Arc::new(Mutex::new(None)); let images = crate::terminal::images::ImageStore::new(); + let turns = AgentTurns::new(); let reader_quit = Arc::new(AtomicBool::new(false)); let reader_thread = Self::spawn_reader( @@ -579,6 +603,7 @@ impl RemoteTerminal { auth: auth_prompts.clone(), phase: ssh_phase.clone(), images: images.clone(), + turns: turns.clone(), }, ); @@ -607,6 +632,7 @@ impl RemoteTerminal { agent, agent_session, images, + turns, route: PaneRoute::Local, proxy, reader_thread: Some(reader_thread), @@ -680,6 +706,7 @@ impl RemoteTerminal { auth, phase, images, + turns, } = signals; crate::core::threads::promote_to_user_interactive(); let mut stream = read_half; @@ -689,6 +716,7 @@ impl RemoteTerminal { let mut zle_tok = OscTokenizer::new(&[b"133"]); let mut cursor_scan = ParkedCursorScanner::new(); let mut parked_cursor = ParkedCursorRepair::default(); + let mut turn_scan = AgentTurnScanner::new(); let mut pending: Vec = buffered; // Kitty-graphics decode runs on its own thread with newest-frame // coalescing (issue #213): inflating a full-window browser frame @@ -733,14 +761,25 @@ impl RemoteTerminal { macro_rules! flush_batch { () => { if !out_batch.is_empty() { - // The scanner reports an offset one past the + // Each scanner reports an offset one past the // sequence it matched, in ascending order, so the // batch splits at each of them: advance the // emulator to the cut, act on the state that // sequence left behind, carry on. - let mut cuts: Vec<(usize, CursorCut)> = Vec::new(); + let mut cuts: Vec<(usize, Cut)> = Vec::new(); if Self::REPAIR_PARKED_CURSOR { - cursor_scan.feed(&out_batch, |off, c| cuts.push((off, c))); + cursor_scan + .feed(&out_batch, |off, c| cuts.push((off, Cut::Cursor(c)))); + } + // Two ascending runs concatenated are not one + // ascending run, and a cut out of order would + // advance the emulator backwards — but only a + // batch carrying both kinds pays for the sort, + // and agent events are a handful per turn. + let cursor_cuts = cuts.len(); + turn_scan.feed(&out_batch, |off, c| cuts.push((off, Cut::Turn(c)))); + if cursor_cuts > 0 && cuts.len() > cursor_cuts { + cuts.sort_by_key(|(off, _)| *off); } { let t0 = trace.then(std::time::Instant::now); @@ -756,7 +795,10 @@ impl RemoteTerminal { for (off, cut) in cuts { processor.advance(&mut *term, &out_batch[at..off]); at = off; - parked_cursor.apply(&mut term, cut); + match cut { + Cut::Cursor(c) => parked_cursor.apply(&mut term, c), + Cut::Turn(t) => turns.apply(&term, t), + } } processor.advance(&mut *term, &out_batch[at..]); } @@ -875,13 +917,27 @@ impl RemoteTerminal { flush_batch!(); cursor_scan.reset(); parked_cursor.reset(); + turn_scan.reset(); proxy.replaying.store(true, Ordering::Relaxed); + // A replayed ring is the pane's own history + // coming back, agent events and all, so cut it + // the same way live output is cut: the outline + // of a conversation is rebuilt by reattaching + // to the pane, not lost with the old client. + let mut turn_cuts: Vec<(usize, TurnCut)> = Vec::new(); + turn_scan.feed(&bytes, |off, c| turn_cuts.push((off, c))); { let mut term = term.lock(); if quit.load(Ordering::SeqCst) { return; } - processor.advance(&mut *term, &bytes); + let mut at = 0usize; + for (off, cut) in turn_cuts { + processor.advance(&mut *term, &bytes[at..off]); + at = off; + turns.apply(&term, cut); + } + processor.advance(&mut *term, &bytes[at..]); if processor.sync_timeout().sync_timeout().is_some() { processor.stop_sync(&mut *term); } @@ -1269,6 +1325,12 @@ impl RemoteTerminal { self.agent_session.lock().ok().and_then(|g| g.clone()) } + /// This pane's agent turns, anchored to the scrollback. Same cheap handle + /// clone as [`images`](Self::images), shared with the reader thread. + pub fn agent_turns(&self) -> AgentTurns { + self.turns.clone() + } + pub fn zle_reading(&self) -> bool { self.zle_reading.load(Ordering::Relaxed) } @@ -4130,6 +4192,75 @@ mod tests { assert!(poll(""), "an unnamed command start does not inherit a name"); } + /// A `prompt-submit` the hook wrote into the middle of a batch of output. + fn prompt_event(prompt: &str) -> Vec { + format!( + "\x1b]777;notify;{};{{\"v\":1,\"agent\":\"claude\",\ + \"event\":\"prompt-submit\",\"prompt\":\"{prompt}\"}}\x07", + crate::core::cli_agent::AGENT_EVENT_SENTINEL + ) + .into_bytes() + } + + fn poll_turns(term: &RemoteTerminal) -> Vec { + for _ in 0..200 { + let turns = term.agent_turns().list(); + if !turns.is_empty() { + return turns; + } + std::thread::sleep(std::time::Duration::from_millis(5)); + } + Vec::new() + } + + #[test] + fn an_agent_turn_anchors_where_its_event_sits_in_the_batch() { + crate::core::config::pin_test_config_dir(); + let (client_side, mut daemon_side) = UnixStream::pair().unwrap(); + let term = RemoteTerminal::from_stream(client_side, TermSize::new(80, 24)).unwrap(); + + // One frame, so one batch: the event's row is only reachable by + // splitting the batch at it. Reading the cursor after the whole batch + // has been parsed would answer 5. + let mut out = b"a\r\nb\r\n".to_vec(); + out.extend_from_slice(&prompt_event("restore the outline")); + out.extend_from_slice(b"c\r\nd\r\ne\r\n"); + DaemonMsg::Output(out).encode(&mut daemon_side).unwrap(); + daemon_side.flush().unwrap(); + + let turns = poll_turns(&term); + assert_eq!(turns.len(), 1, "one prompt, one turn"); + assert_eq!( + turns[0].row, + Some(2), + "the anchor is the row the event arrived on, not the end of the batch" + ); + assert_eq!(turns[0].text, "restore the outline"); + assert!(!turns[0].done, "no stop yet"); + } + + #[test] + fn a_replayed_ring_brings_the_conversation_back_with_it() { + crate::core::config::pin_test_config_dir(); + let (client_side, mut daemon_side) = UnixStream::pair().unwrap(); + let term = RemoteTerminal::from_stream(client_side, TermSize::new(80, 24)).unwrap(); + + // What reattaching to a pane looks like: its history arrives as a + // snapshot, agent events and all. The outline has to be rebuilt from + // those bytes — nothing else carries it across a client restart. + let mut snapshot = b"older output\r\n".to_vec(); + snapshot.extend_from_slice(&prompt_event("what did we decide")); + DaemonMsg::Snapshot(snapshot) + .encode(&mut daemon_side) + .unwrap(); + daemon_side.flush().unwrap(); + + let turns = poll_turns(&term); + assert_eq!(turns.len(), 1); + assert_eq!(turns[0].row, Some(1)); + assert_eq!(turns[0].text, "what did we decide"); + } + #[test] fn shell_vi_mode_follows_live_prompt_mode_marks_without_disarming_zle() { let (client_side, mut daemon_side) = UnixStream::pair().unwrap(); diff --git a/src/terminal/view.rs b/src/terminal/view.rs index e0f7e6a4..9125a4a9 100644 --- a/src/terminal/view.rs +++ b/src/terminal/view.rs @@ -1550,6 +1550,50 @@ impl TerminalView { self.terminal.agent_session() } + /// One entry per turn of the agent's conversation, oldest first — see + /// [`crate::terminal::agent_marks`]. + pub fn agent_turns(&self) -> Vec { + self.terminal.agent_turns().list() + } + + /// Scroll back to where a turn began, putting that row at the top of the + /// viewport so the answer to it reads downwards from there. + /// + /// The stored anchor only says roughly where the agent was drawing when the + /// turn started, so the prompt's own text gets the final say; the row it is + /// found on is written back, and a second click on the same turn lands in + /// the same place without searching again. + pub fn scroll_to_agent_turn( + &mut self, + turn: &crate::terminal::agent_marks::AgentTurn, + cx: &mut Context, + ) -> bool { + let Some(anchor) = turn.row else { + return false; + }; + // Nothing behind the alt screen to scroll to, and `scroll_display` is a + // no-op there anyway — say so rather than pretending the click worked. + if self.on_alt_screen() { + return false; + } + self.cancel_scroll_anim(); + let row = { + let mut term = self.terminal.term.lock(); + use alacritty_terminal::grid::Dimensions as _; + let history = term.grid().history_size() as i64; + let row = crate::terminal::agent_marks::locate(&term, anchor, &turn.text); + let target = (history - row).clamp(0, history); + let delta = (target - term.grid().display_offset() as i64) + .clamp(i64::from(i32::MIN), i64::from(i32::MAX)) as i32; + term.scroll_display(Scroll::Delta(delta)); + row + }; + self.terminal.agent_turns().recenter(turn.id, row); + self.scroll_frac = 0.; + cx.notify(); + true + } + /// What this pane is in the middle of, when it can say so. `None` means /// either nothing is running or the shell never told us — and a terminal /// that guessed would raise this question on every single close. @@ -3069,6 +3113,9 @@ impl TerminalView { // not replay out-of-band image frames, so a browser redraws on its next // transmit (same reasoning as the reattach path in `adopt_relink`). self.terminal.images().clear(); + // Agent turn anchors are rows in the same discarded history, and unlike + // images they cannot be redrawn back into place. + self.terminal.agent_turns().clear(); self.scroll_frac = 0.; self.terminal.write(vec![0x0c_u8]); cx.notify(); @@ -10783,6 +10830,94 @@ mod gpui_tests { .unwrap(); } + /// The agent's prompt drawn on row 20, with enough output after it that the + /// row can actually be scrolled to the top of the viewport. + const CONVERSATION_ROW: i64 = 20; + + fn painted_conversation(view: &TerminalView) { + let mut parser: alacritty_terminal::vte::ansi::Processor = Default::default(); + let mut term = view.terminal.term.lock(); + for i in 0..300 { + let line = match i { + 20 => "> restore the outline\r\n".to_string(), + _ => format!("line {i}\r\n"), + }; + parser.advance(&mut *term, line.as_bytes()); + } + } + + fn viewport_top(view: &TerminalView) -> String { + use alacritty_terminal::index::{Column, Line}; + let term = view.terminal.term.lock(); + let grid = term.grid(); + let line = -(grid.display_offset() as i32); + (0..grid.columns()) + .map(|c| grid[Line(line)][Column(c)].c) + .collect::() + .trim_end() + .to_string() + } + + #[gpui::test] + fn jumping_to_a_turn_puts_the_prompt_at_the_top_of_the_viewport(cx: &mut TestAppContext) { + let (window, _daemon) = harness(cx); + window + .update(cx, |view, _w, cx| { + painted_conversation(view); + // Off by three, the way a hook firing into a live repaint is. + let turn = crate::terminal::agent_marks::AgentTurn { + row: Some(CONVERSATION_ROW + 3), + text: "restore the outline".into(), + done: true, + id: 1, + }; + assert!(view.scroll_to_agent_turn(&turn, cx)); + assert_eq!( + viewport_top(view), + "> restore the outline", + "the text corrected the anchor's three-row error" + ); + let history = { + use alacritty_terminal::grid::Dimensions as _; + view.terminal.term.lock().grid().history_size() as i64 + }; + assert_eq!( + display_offset(view) as i64, + history - CONVERSATION_ROW, + "the turn's row is the first one shown, not merely on screen" + ); + }) + .unwrap(); + } + + #[gpui::test] + fn a_turn_with_nowhere_to_go_reports_that_it_did_not_move(cx: &mut TestAppContext) { + let (window, _daemon) = harness(cx); + window + .update(cx, |view, _w, cx| { + painted_conversation(view); + let mut turn = crate::terminal::agent_marks::AgentTurn { + row: None, + text: "restore the outline".into(), + done: true, + id: 1, + }; + assert!( + !view.scroll_to_agent_turn(&turn, cx), + "a turn that began on the alt screen has no row" + ); + + turn.row = Some(CONVERSATION_ROW); + let mut parser: alacritty_terminal::vte::ansi::Processor = Default::default(); + parser.advance(&mut *view.terminal.term.lock(), b"\x1b[?1049h"); + assert!( + !view.scroll_to_agent_turn(&turn, cx), + "and a pane sitting on the alt screen has no scrollback to show" + ); + }) + .unwrap(); + } + #[gpui::test] fn turning_smooth_scrolling_off_restores_the_direct_path(cx: &mut TestAppContext) { let (window, _daemon) = harness(cx); diff --git a/src/ui/i18n/en.rs b/src/ui/i18n/en.rs index 5df37e30..d13d5d50 100644 --- a/src/ui/i18n/en.rs +++ b/src/ui/i18n/en.rs @@ -1032,6 +1032,7 @@ pub fn translate_en(key: L10nKey) -> &'static str { L10nKey::PanelNoChanges => "No uncommitted changes.", L10nKey::PanelNoChangesHint => "The working tree is clean.", L10nKey::PanelSessionSubtitle => "Session", + L10nKey::PanelConversationSubtitle => "Conversation", L10nKey::PanelProcessesSubtitle => "Processes", L10nKey::PanelPortsSubtitle => "Ports", L10nKey::PanelCwd => "cwd", @@ -1039,8 +1040,6 @@ pub fn translate_en(key: L10nKey) -> &'static str { L10nKey::PanelSsh => "ssh", L10nKey::PanelBranch => "branch", L10nKey::PanelChangesRow => "changes", - L10nKey::PanelAgent => "agent", - L10nKey::PanelAgentIdle => "idle", L10nKey::PanelAgentWorking => "working", L10nKey::PanelAgentWaiting => "waiting", L10nKey::PanelAgentDone => "done", diff --git a/src/ui/i18n/ja.rs b/src/ui/i18n/ja.rs index 507a8ac6..5feb9313 100644 --- a/src/ui/i18n/ja.rs +++ b/src/ui/i18n/ja.rs @@ -1096,6 +1096,7 @@ pub fn translate_ja(key: L10nKey) -> Option<&'static str> { L10nKey::PanelNoChanges => "未コミットの変更はありません", L10nKey::PanelNoChangesHint => "ワーキングツリーはクリーンです", L10nKey::PanelSessionSubtitle => "セッション", + L10nKey::PanelConversationSubtitle => "会話", L10nKey::PanelProcessesSubtitle => "プロセス", L10nKey::PanelPortsSubtitle => "ポート", L10nKey::PanelCwd => "作業ディレクトリ", @@ -1103,8 +1104,6 @@ pub fn translate_ja(key: L10nKey) -> Option<&'static str> { L10nKey::PanelSsh => "ssh", L10nKey::PanelBranch => "ブランチ", L10nKey::PanelChangesRow => "変更", - L10nKey::PanelAgent => "エージェント", - L10nKey::PanelAgentIdle => "アイドル", L10nKey::PanelAgentWorking => "作業中", L10nKey::PanelAgentWaiting => "待機中", L10nKey::PanelAgentDone => "完了", diff --git a/src/ui/i18n/mod.rs b/src/ui/i18n/mod.rs index f9d8e5af..1d8b6c5c 100644 --- a/src/ui/i18n/mod.rs +++ b/src/ui/i18n/mod.rs @@ -767,6 +767,7 @@ l10n_keys! { PanelNoChangesHint, PanelMoreChangedFiles, PanelSessionSubtitle, + PanelConversationSubtitle, PanelProcessesSubtitle, PanelPortsSubtitle, PanelCwd, @@ -774,8 +775,6 @@ l10n_keys! { PanelSsh, PanelBranch, PanelChangesRow, - PanelAgent, - PanelAgentIdle, PanelAgentWorking, PanelAgentWaiting, PanelAgentDone, @@ -1541,10 +1540,9 @@ mod tests { L10nKey::SettingsLanguageEnglish, L10nKey::SettingsLanguageChinese, L10nKey::SettingsLanguageJapanese, - // The pane-type labels are one set — shell / agent / ssh — and - // translating only the middle one would break the set. + // The pane-type labels: one names a program, the other a protocol, + // and no locale renames either. L10nKey::PanelShell, - L10nKey::PanelAgent, L10nKey::PanelSsh, // The zh copy calls the background process "server" throughout — // this heading is that word on its own. diff --git a/src/ui/i18n/zh.rs b/src/ui/i18n/zh.rs index 2adc7f53..a1f451e0 100644 --- a/src/ui/i18n/zh.rs +++ b/src/ui/i18n/zh.rs @@ -989,6 +989,7 @@ pub fn translate_zh(key: L10nKey) -> Option<&'static str> { L10nKey::PanelNoChanges => "没有未提交的变更。", L10nKey::PanelNoChangesHint => "worktree 是干净的。", L10nKey::PanelSessionSubtitle => "会话", + L10nKey::PanelConversationSubtitle => "对话", L10nKey::PanelProcessesSubtitle => "进程", L10nKey::PanelPortsSubtitle => "端口", L10nKey::PanelCwd => "工作目录", @@ -996,8 +997,6 @@ pub fn translate_zh(key: L10nKey) -> Option<&'static str> { L10nKey::PanelSsh => "ssh", L10nKey::PanelBranch => "分支", L10nKey::PanelChangesRow => "变更", - L10nKey::PanelAgent => "agent", - L10nKey::PanelAgentIdle => "空闲", L10nKey::PanelAgentWorking => "进行中", L10nKey::PanelAgentWaiting => "等待中", L10nKey::PanelAgentDone => "已完成", diff --git a/src/ui/right_panel.rs b/src/ui/right_panel.rs index 01a8aaba..ecfd467a 100644 --- a/src/ui/right_panel.rs +++ b/src/ui/right_panel.rs @@ -185,14 +185,6 @@ enum InfoValue { removed: u32, open: Option<(crate::ui::host_ops::HostId, PathBuf)>, }, - /// An agent and what it is doing, behind the status dot the sidebar draws - /// on the tab — `hollow` for Waiting, which is a different *shape* rather - /// than one more hue, for the same reason the tab's dot is. - Agent { - text: String, - dot: Option, - hollow: bool, - }, } /// One label/value line of the Session section. @@ -663,11 +655,9 @@ impl Tty7App { // off in both places rather than in one of them. let mut diff_target: Option<(crate::ui::host_ops::HostId, PathBuf)> = None; let mut git: Option = None; - // The agent row's name and status, read off one leaf (see below). - let mut agent_row: Option<( - crate::core::cli_agent::CLIAgent, - crate::core::cli_agent::AgentStatus, - )> = None; + // The leaf the CONVERSATION section reads its turns off — the same one + // every row above describes. + let mut detail_pane = None; if let Some(tab) = self.tabs.get(self.active) { if let Some(leaf) = tab.detail_pane(window, cx) { @@ -720,26 +710,7 @@ impl Tty7App { forwards_pane = Some(view.pane_id); } git = view.git_status(cx); - // Name and status come from the *same* leaf: the detail pane's - // own agent when it has one, and otherwise the tab's most - // urgent agent leaf — which still holds the row while focus - // sits on a plain shell, and still colours its dot the way the - // tab strip's badge does, but names the pane it took the - // status from. Pairing `tab.agent` with `tab.agent_status` - // would splice one pane's name onto another pane's status — a - // row no leaf ever had — because the two resolve - // independently (#543). Read here, where `view` is in scope; - // pushed beside the other rows below. - agent_row = match view.agent() { - Some(agent) => { - let status = view - .agent_session() - .map(|s| s.status) - .unwrap_or(crate::core::cli_agent::AgentStatus::Idle); - Some((agent, status)) - } - None => tab.agent_row(cx), - }; + detail_pane = Some(leaf); } // Read off the same pane the rows above describe, rather than off // `Tab::git_status`, which resolves a split tab to its *first* leaf @@ -764,20 +735,6 @@ impl Tty7App { reveal: None, }); } - // Name and status were read off one leaf above; push the row. - if let Some((agent, status)) = agent_row { - let name = agent.display_name(); - rows.push(InfoRow { - label: t(L10nKey::PanelAgent), - value: InfoValue::Agent { - text: format!("{name} · {}", agent_status_label(status)), - dot: status.dot_rgb(), - hollow: status == crate::core::cli_agent::AgentStatus::Waiting, - }, - copy: None, - reveal: None, - }); - } } if rows.is_empty() { @@ -806,6 +763,7 @@ impl Tty7App { let inner = v_flex() .child(self.panel_subtitle(t(L10nKey::PanelSessionSubtitle), false, None, cx)) .child(list) + .children(self.turns_section(detail_pane.as_ref(), cx)) .children(self.procs_section(pane_id, cx)) .children(self.ports_section(pane_id, local_pane, cx)) .children(self.forwards_section(forwards_pane, cx)) @@ -925,31 +883,6 @@ impl Tty7App { None => counts.into_any_element(), } } - // The dot hangs out of the flow rather than sitting in it. A - // childless box has no baseline of its own, so as a flex item it - // offers up its bottom edge instead — and the row, which aligns - // its label and its value on their shared baseline, then hoisted - // the whole value six pixels and left "agent" sitting under its - // own value. Out of flow it cannot be mistaken for the thing that - // sets the line. - InfoValue::Agent { text, dot, hollow } => div() - .flex_1() - .min_w_0() - .relative() - .child( - div() - .min_w_0() - .truncate() - .when(dot.is_some(), |d| d.pl(rems(PIP_SIZE + PIP_GAP))) - .text_size(rems(TEXT_MONO)) - .font_family(mono.clone()) - .text_color(cx.theme().foreground) - .child(text), - ) - .children(dot.map(|rgb| { - status_pip(rgb, hollow, crate::ui::theme::workspace_surface_color(cx)) - })) - .into_any_element(), }; // The strip is opaque and pinned to the row's right edge, so whatever @@ -1087,6 +1020,103 @@ impl Tty7App { .into_any_element() } + /// The agent's conversation, one row per turn, each a way back to where + /// that turn started in the scrollback. + /// + /// It sits under the session facts rather than in a tab of its own: this is + /// something *this pane* is, like its shell and its cwd, and the tab strip + /// has no room for a fourth tile at 260px. + fn turns_section( + &self, + leaf: Option<&gpui::Entity>, + cx: &mut Context, + ) -> Option { + let leaf = leaf?; + let turns = leaf.read(cx).agent_turns(); + // A turn the hook announced but could not name is a row with nothing on + // it. The status dot already says a turn is running. + let turns: Vec<_> = turns + .into_iter() + .filter(|t| !t.text.trim().is_empty()) + .collect(); + if turns.is_empty() { + return None; + } + let sf = cx.global::().sidebar; + let count = turns.len().to_string(); + let mut list = v_flex().px(px(CONTENT_INSET - 4.)).py(px(1.)).gap(px(1.)); + for turn in turns { + let id = turn.id; + // Only a turn that was drawn into the scrollback has somewhere to + // go: one that began on the alt screen is history the pane never + // kept, so its row reads as a label and not as a link. + let jumpable = turn.row.is_some(); + let dot = { + let d = div().flex_none().size(px(7.)).rounded_full(); + if turn.done { + d.border_1() + .border_color(cx.theme().muted_foreground.opacity(0.55)) + } else { + d.bg(cx.theme().muted_foreground) + } + }; + list = list.child( + h_flex() + .id(gpui::SharedString::from(format!("panel-turn-{id}"))) + .items_center() + .gap(px(8.)) + .px(px(4.)) + .py(px(3.)) + .rounded(px(5.)) + .when(jumpable, |this| { + let leaf = leaf.clone(); + let turn = turn.clone(); + this.cursor_pointer() + .hover(|s| s.bg(gpui::rgb(sf.hover))) + .on_click(cx.listener(move |_this, _, _window, cx| { + leaf.update(cx, |view, cx| { + view.scroll_to_agent_turn(&turn, cx); + }); + })) + }) + .child(dot) + .child( + div() + .flex_1() + .min_w_0() + .truncate() + .text_size(rems(TEXT)) + .text_color(if jumpable { + cx.theme().foreground + } else { + cx.theme().muted_foreground + }) + .child(turn.text), + ), + ); + } + Some( + v_flex() + .child( + self.panel_subtitle( + t(L10nKey::PanelConversationSubtitle), + true, + Some( + div() + .text_size(rems(META_MONO)) + .font_family(cx.theme().mono_font_family.clone()) + .text_color(cx.theme().muted_foreground.opacity(0.75)) + .child(count) + .into_any_element(), + ), + cx, + ), + ) + .child(list) + .into_any_element(), + ) + } + fn procs_section(&self, pane_id: Option, cx: &mut Context) -> Option { let procs = &self.procs(pane_id)?.procs; if procs.len() < 2 { @@ -1399,50 +1429,6 @@ pub(crate) fn git_badge(letter: &str, color: gpui::Hsla, mono: &gpui::SharedStri .into_any_element() } -/// Diameter of the agent dot in the Info panel, and the gap between it and the -/// word it qualifies. -/// -/// Seven sixteenths of a rem — seven pixels at the default interface size, -/// because a dot on a line of text has to survive being read at a glance -/// without becoming a bullet, and a rem rather than a pixel because the line it -/// sits in is sized in rems: pinned in pixels it slid towards the cap height of -/// its own row the moment the interface font scale moved off 100%. -const PIP_SIZE: f32 = 7. * STEP; -const PIP_GAP: f32 = 7. * STEP; - -/// How far down the value box the dot starts, again as a fraction of the text -/// it is centred in rather than a pixel count. -const PIP_TOP: f32 = 6. * STEP; - -/// The dot a tab wears for its agent's state, at the size a line of panel text -/// can carry it. -/// -/// Same colours and the same hollow-for-Waiting rule as the sidebar's, because -/// it is the same fact: a reader who has learned that amber-with-a-hole means -/// "it wants you" on a tab must not have to learn it a second time here. Same -/// *shape*, too — [`Tty7App::status_dot`] punches a small hole out of a filled -/// dot, so drawing this one as a thin ring would have been a second dialect of -/// the one rule the doc above promises is shared. `hole` is the colour behind -/// the dot, which is what a hole in it has to be painted in; the agent row is -/// never interactive, so that colour is the panel's own and does not move -/// under the pointer. -fn status_pip(rgb: u32, hollow: bool, hole: gpui::Hsla) -> AnyElement { - div() - .absolute() - .left_0() - .top(rems(PIP_TOP)) - .size(rems(PIP_SIZE)) - .rounded_full() - .bg(gpui::rgb(rgb)) - .when(hollow, |dot| { - dot.flex() - .items_center() - .justify_center() - .child(div().size(rems(PIP_SIZE * 0.36)).rounded_full().bg(hole)) - }) - .into_any_element() -} - /// A small filled pill around a mono token — a pid, a port number. /// /// The padding and the radius are derived from the text size: at @@ -1478,16 +1464,6 @@ pub fn reveal_label() -> &'static str { } } -fn agent_status_label(status: crate::core::cli_agent::AgentStatus) -> &'static str { - use crate::core::cli_agent::AgentStatus::*; - match status { - Idle => t(L10nKey::PanelAgentIdle), - Working => t(L10nKey::PanelAgentWorking), - Waiting => t(L10nKey::PanelAgentWaiting), - Done => t(L10nKey::PanelAgentDone), - } -} - /// Splits a path into everything-but-the-last-segment and the last segment, /// so a row can shrink the first and keep the second. fn split_path_leaf(s: &str) -> (String, String) {