feat(agent): outline a coding agent's conversation, and jump back to a turn (#703)

* feat(agent): outline a coding agent's conversation, and jump back to a turn

The hooks tty7 installs into Claude Code already announce every turn over the
pty as an OSC 777, and the daemon reads those for the pane's status dot. The
same bytes reach the client, where 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 scrollback row
that turn began on. That is an outline of the conversation, and a way back into
it — which is the one thing a long agent session in a terminal has never had.

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.
Clicking a row scrolls the pane so that turn's prompt is the top line.

Not a fourth right-panel tab. `RightPanelTab` says out loud why there is no
room for one at 260px, and a fourth variant would drop anyone who rolled back
to an older build onto Info. This is a fact about the pane, like its shell and
its cwd, so it sits with them.

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
when the hook's bytes land is wherever the last repaint left it — inside the
live region, a few rows from where the prompt's echo comes to rest. And once
the scrollback limit starts discarding lines, every anchor slides by the discard
count at once.

So the anchor is a hint, and the prompt's own text is the correction: at click
time (by which point it has long been drawn) the row is looked for around the
anchor, exact match first — the row that *is* `> hi`, marker stripped — and only
then by containment, which keeps its length floor because `hi` appears inside
half the rows of any answer. The row that is found is written back, so a second
click does not search again and cannot land somewhere else.

Claude Code keeps a JSONL transcript, and reading it would give the assistant's
side too. It would also only work for Claude, only when the agent runs on this
machine, and only for a path this process may read. An OSC comes back through
the pty from wherever the agent actually runs — over ssh, in 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.

- `OscTokenizer::feed_at` reports each payload's end offset. The client already
  tokenized OSC 777 on every batch to keep agent events out of desktop
  notifications, so the scan is free; only a real event now costs a cut, which
  is what #404 was right to object to about the old per-command mark scanner.
- `Cut` is a two-variant enum again (cursor repair, agent turn). Two ascending
  runs concatenated are not one, so a batch carrying both kinds is sorted —
  and only such a batch pays for it.
- A replayed ring is cut the same way, so reattaching to a pane rebuilds the
  outline from its own history rather than losing it with the old client.
- Turn anchors are dropped where image placements are: `clear_scrollback`, and
  the grid reset in `adopt_relink`.
- A turn that began on the alt screen is listed but not clickable — there is no
  scrollback behind it to return to.
- A turn announced twice is one turn. Hooks are not guaranteed to fire once,
  and what makes it the same turn is that the one before it never ended: a real
  repeat can only come after an answer, and an answer brings a `stop`.
- The hook forwards the prompt's first line, clamped to 200 characters. The
  tokenizer *abandons* a payload past 8 KiB rather than truncating it, so a
  pasted file would otherwise cost the whole event; and a needle spanning a line
  break matches no single row.

No protocol change: the prompt rides in the OSC the hook already sent, and an
older client ignores the field.

* refactor(panel): drop the Info panel's agent row

It said `Claude Code · working` behind a status dot — the same name and the
same dot the tab chip and its sidebar row were already wearing, restated two
panels away from either of them. The CONVERSATION section that now sits under
it says what the agent is doing in a form the row never could: which turns
there were, which one is still running, and a way back to each.

`InfoValue::Agent` and `status_pip` went with it — the dot was the row's only
caller — and `PanelAgent` / `PanelAgentIdle` with those. The remaining three
status labels stay: the tray menu still names them.

`Tab::agent_row` stays too. `agent_status` is that pair's status and the tab
strip's badge reads it, which is the one-leaf rule #543 put there.
This commit is contained in:
l0ng-ai
2026-08-20 21:56:34 +08:00
committed by GitHub
parent d8ac3ef454
commit 07e3b26434
13 changed files with 1266 additions and 146 deletions
+20
View File
@@ -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
+106
View File
@@ -78,9 +78,41 @@ fn build_hook_sequence(agent: &str, event: &str, stdin_json: &str) -> Vec<u8> {
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<String> {
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");
+13
View File
@@ -708,6 +708,13 @@ pub struct AgentEvent {
pub session_id: Option<String>,
pub message: Option<String>,
pub cwd: Option<std::path::PathBuf>,
/// 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<String>,
}
pub fn parse_agent_event(payload: &[u8]) -> Option<AgentEvent> {
@@ -729,6 +736,8 @@ pub fn parse_agent_event(payload: &[u8]) -> Option<AgentEvent> {
message: Option<String>,
#[serde(default)]
cwd: Option<String>,
#[serde(default)]
prompt: Option<String>,
}
let w: Wire = serde_json::from_slice(json).ok()?;
@@ -740,6 +749,7 @@ pub fn parse_agent_event(payload: &[u8]) -> Option<AgentEvent> {
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();
+38 -4
View File
@@ -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!(
+709
View File
@@ -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<i64>,
/// 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<Mutex<Inner>>);
#[derive(Default)]
struct Inner {
turns: Vec<AgentTurn>,
next_id: u64,
}
impl AgentTurns {
pub fn new() -> Self {
Self::default()
}
pub fn list(&self) -> Vec<AgentTurn> {
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<T: EventListener>(&self, term: &Term<T>, 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<i64>, 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<T: EventListener>(term: &Term<T>, 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<T: EventListener>(
self,
term: &Term<T>,
anchor: i64,
needle: &str,
lo: i64,
hi: i64,
) -> Option<i64> {
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<T: EventListener>(self, term: &Term<T>, 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<T: EventListener>(term: &Term<T>, row: i64) -> Option<String> {
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<String>,
},
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<u8> {
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<AgentTurn> {
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<VoidListener> {
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");
}
}
+1
View File
@@ -1,3 +1,4 @@
pub(crate) mod agent_marks;
mod boxdraw;
mod cmd_editor;
mod completion;
+136 -5
View File
@@ -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<Mutex<Option<PathBuf>>>,
shell: Arc<Mutex<ShellState>>,
@@ -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<JoinHandle<()>>,
@@ -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<Mutex<Option<SshPhase>>> = 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<u8> = 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<u8> {
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<crate::terminal::agent_marks::AgentTurn> {
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();
+135
View File
@@ -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<crate::terminal::agent_marks::AgentTurn> {
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<Self>,
) -> 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::<String>()
.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);
+1 -2
View File
@@ -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",
+1 -2
View File
@@ -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 => "完了",
+3 -5
View File
@@ -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.
+1 -2
View File
@@ -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 => "已完成",
+102 -126
View File
@@ -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<u32>,
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<crate::terminal::git_status::GitStatus> = 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<crate::terminal::view::TerminalView>>,
cx: &mut Context<Self>,
) -> Option<AnyElement> {
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::<crate::ui::presets::Surfaces>().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<u64>, cx: &mut Context<Self>) -> Option<AnyElement> {
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) {