mirror of
https://github.com/l0ng-ai/tty7.git
synced 2026-09-21 16:02:20 +00:00
Merge pull request #829 from l0ng-ai/refactor/drop-conversation-outline
refactor(panel): drop the agent conversation outline
This commit is contained in:
@@ -5,6 +5,16 @@ All notable changes to tty7 are documented in this file.
|
||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
||||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Removed
|
||||
|
||||
- **The Info panel's CONVERSATION outline is gone** (#703, #759). The list of an
|
||||
agent's turns, and the click that scrolled a pane back to where one started,
|
||||
are both taken out, along with the anchors the client kept for them. The OSC
|
||||
777 events the hooks send still drive the tab's status dot; nothing else read
|
||||
the rows the outline was built on.
|
||||
|
||||
## [26.9.1] - 2026-09-07
|
||||
|
||||
### Fixed
|
||||
|
||||
@@ -1,709 +0,0 @@
|
||||
//! 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,4 +1,3 @@
|
||||
pub(crate) mod agent_marks;
|
||||
mod boxdraw;
|
||||
mod cmd_editor;
|
||||
mod completion;
|
||||
|
||||
+7
-138
@@ -12,7 +12,6 @@ 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,16 +63,6 @@ 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>>,
|
||||
@@ -93,10 +82,6 @@ struct ReaderSignals {
|
||||
images: crate::terminal::images::ImageStore,
|
||||
clipboard_writes: Arc<Mutex<VecDeque<tty7_core::core::clipboard::ClipboardWrite>>>,
|
||||
clipboard_write_busy: Arc<AtomicBool>,
|
||||
/// 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)]
|
||||
@@ -539,9 +524,6 @@ pub struct RemoteTerminal {
|
||||
images: crate::terminal::images::ImageStore,
|
||||
clipboard_writes: Arc<Mutex<VecDeque<tty7_core::core::clipboard::ClipboardWrite>>>,
|
||||
clipboard_write_busy: Arc<AtomicBool>,
|
||||
/// 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<()>>,
|
||||
@@ -833,10 +815,6 @@ 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(
|
||||
@@ -861,7 +839,6 @@ impl RemoteTerminal {
|
||||
images: self.images.clone(),
|
||||
clipboard_writes: self.clipboard_writes.clone(),
|
||||
clipboard_write_busy: self.clipboard_write_busy.clone(),
|
||||
turns: self.turns.clone(),
|
||||
},
|
||||
);
|
||||
self.reader_thread = Some(reader);
|
||||
@@ -915,7 +892,6 @@ impl RemoteTerminal {
|
||||
let images = crate::terminal::images::ImageStore::new();
|
||||
let clipboard_writes = Arc::new(Mutex::new(VecDeque::new()));
|
||||
let clipboard_write_busy = Arc::new(AtomicBool::new(false));
|
||||
let turns = AgentTurns::new();
|
||||
|
||||
let reader_quit = Arc::new(AtomicBool::new(false));
|
||||
let reader_thread = Self::spawn_reader(
|
||||
@@ -940,7 +916,6 @@ impl RemoteTerminal {
|
||||
images: images.clone(),
|
||||
clipboard_writes: clipboard_writes.clone(),
|
||||
clipboard_write_busy: clipboard_write_busy.clone(),
|
||||
turns: turns.clone(),
|
||||
},
|
||||
);
|
||||
|
||||
@@ -976,7 +951,6 @@ impl RemoteTerminal {
|
||||
images,
|
||||
clipboard_writes,
|
||||
clipboard_write_busy,
|
||||
turns,
|
||||
route: PaneRoute::Local,
|
||||
proxy,
|
||||
reader_thread: Some(reader_thread),
|
||||
@@ -1061,7 +1035,6 @@ impl RemoteTerminal {
|
||||
images,
|
||||
clipboard_writes,
|
||||
clipboard_write_busy,
|
||||
turns,
|
||||
} = signals;
|
||||
crate::core::threads::promote_to_user_interactive();
|
||||
let mut stream = read_half;
|
||||
@@ -1071,7 +1044,6 @@ 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
|
||||
@@ -1122,25 +1094,14 @@ impl RemoteTerminal {
|
||||
macro_rules! flush_batch {
|
||||
() => {
|
||||
if !out_batch.is_empty() {
|
||||
// Each scanner reports an offset one past the
|
||||
// sequence it matched, in ascending order, so the
|
||||
// batch splits at each of them: advance the
|
||||
// The scanner reports an offset one past each
|
||||
// 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, Cut)> = Vec::new();
|
||||
let mut cuts: Vec<(usize, CursorCut)> = Vec::new();
|
||||
if Self::REPAIR_PARKED_CURSOR {
|
||||
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);
|
||||
cursor_scan.feed(&out_batch, |off, c| cuts.push((off, c)));
|
||||
}
|
||||
{
|
||||
let t0 = trace.then(std::time::Instant::now);
|
||||
@@ -1156,10 +1117,7 @@ impl RemoteTerminal {
|
||||
for (off, cut) in cuts {
|
||||
processor.advance(&mut *term, &out_batch[at..off]);
|
||||
at = off;
|
||||
match cut {
|
||||
Cut::Cursor(c) => parked_cursor.apply(&mut term, c),
|
||||
Cut::Turn(t) => turns.apply(&term, t),
|
||||
}
|
||||
parked_cursor.apply(&mut term, cut);
|
||||
}
|
||||
processor.advance(&mut *term, &out_batch[at..]);
|
||||
}
|
||||
@@ -1278,27 +1236,13 @@ 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;
|
||||
}
|
||||
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..]);
|
||||
processor.advance(&mut *term, &bytes);
|
||||
if processor.sync_timeout().sync_timeout().is_some() {
|
||||
processor.stop_sync(&mut *term);
|
||||
}
|
||||
@@ -1702,12 +1646,6 @@ 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)
|
||||
}
|
||||
@@ -5518,75 +5456,6 @@ 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();
|
||||
|
||||
@@ -1801,50 +1801,6 @@ 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.
|
||||
@@ -3485,9 +3441,6 @@ 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();
|
||||
@@ -12671,94 +12624,6 @@ 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);
|
||||
|
||||
@@ -1063,13 +1063,6 @@ 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::PanelTurnAltScreenNow => {
|
||||
"Nowhere to jump while a full-screen program owns this pane."
|
||||
}
|
||||
L10nKey::PanelTurnNoScrollback => {
|
||||
"This turn was drawn on the alternate screen, so the scrollback never kept it."
|
||||
}
|
||||
L10nKey::PanelProcessesSubtitle => "Processes",
|
||||
L10nKey::PanelPortsSubtitle => "Ports",
|
||||
L10nKey::PanelPortsUnsupported => "That machine's tty7-server is too old to list ports.",
|
||||
|
||||
@@ -1127,13 +1127,6 @@ pub fn translate_ja(key: L10nKey) -> Option<&'static str> {
|
||||
L10nKey::PanelNoChanges => "未コミットの変更はありません",
|
||||
L10nKey::PanelNoChangesHint => "ワーキングツリーはクリーンです",
|
||||
L10nKey::PanelSessionSubtitle => "セッション",
|
||||
L10nKey::PanelConversationSubtitle => "会話",
|
||||
L10nKey::PanelTurnAltScreenNow => {
|
||||
"全画面プログラムがこのペインを占有している間は、戻る先がありません"
|
||||
}
|
||||
L10nKey::PanelTurnNoScrollback => {
|
||||
"このターンは代替画面に描かれたため、スクロールバックに残っていません"
|
||||
}
|
||||
L10nKey::PanelProcessesSubtitle => "プロセス",
|
||||
L10nKey::PanelPortsSubtitle => "ポート",
|
||||
L10nKey::PanelPortsUnsupported => "リモートの tty7-server が古く、ポートを列挙できません。",
|
||||
|
||||
@@ -783,9 +783,6 @@ l10n_keys! {
|
||||
PanelNoChangesHint,
|
||||
PanelMoreChangedFiles,
|
||||
PanelSessionSubtitle,
|
||||
PanelConversationSubtitle,
|
||||
PanelTurnAltScreenNow,
|
||||
PanelTurnNoScrollback,
|
||||
PanelProcessesSubtitle,
|
||||
PanelPortsSubtitle,
|
||||
PanelPortsUnsupported,
|
||||
|
||||
@@ -1018,9 +1018,6 @@ pub fn translate_zh(key: L10nKey) -> Option<&'static str> {
|
||||
L10nKey::PanelNoChanges => "没有未提交的变更。",
|
||||
L10nKey::PanelNoChangesHint => "worktree 是干净的。",
|
||||
L10nKey::PanelSessionSubtitle => "会话",
|
||||
L10nKey::PanelConversationSubtitle => "对话",
|
||||
L10nKey::PanelTurnAltScreenNow => "全屏程序占着此窗格,没有 scrollback 可跳回。",
|
||||
L10nKey::PanelTurnNoScrollback => "这一轮画在 alt screen 上,scrollback 里没有留下它。",
|
||||
L10nKey::PanelProcessesSubtitle => "进程",
|
||||
L10nKey::PanelPortsSubtitle => "端口",
|
||||
L10nKey::PanelPortsUnsupported => "对端的 tty7-server 太旧,列不出端口。",
|
||||
|
||||
+1
-147
@@ -723,9 +723,6 @@ 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 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) {
|
||||
@@ -767,7 +764,6 @@ impl Tty7App {
|
||||
rows.push(InfoRow::text(t(L10nKey::PanelSsh), ssh.host.clone()).copyable());
|
||||
}
|
||||
git = view.git_status(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
|
||||
@@ -817,7 +813,6 @@ 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(ctx.as_ref(), cx))
|
||||
.into_any_element();
|
||||
@@ -1073,118 +1068,6 @@ 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 — when there is one to go back to.
|
||||
///
|
||||
/// 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;
|
||||
}
|
||||
// A full-screen program owns the whole drawing surface, so there is no
|
||||
// scrollback under it to land in — while one is up every jump is a
|
||||
// no-op, whatever anchor the turn is carrying. An agent that renders
|
||||
// that way (Claude Code's `/tui fullscreen`, Codex) puts every row in
|
||||
// this section here.
|
||||
let alt_now = leaf.read(cx).on_alt_screen();
|
||||
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;
|
||||
let jumpable = turn_is_jumpable(turn.row, alt_now);
|
||||
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);
|
||||
});
|
||||
}))
|
||||
})
|
||||
// A row that goes nowhere says why on hover. Muted text is
|
||||
// the whole of what it says otherwise, and grey reads as
|
||||
// "less important" long before it reads as "not a link".
|
||||
.when(!jumpable, |this| {
|
||||
let tip = t(match alt_now {
|
||||
true => L10nKey::PanelTurnAltScreenNow,
|
||||
false => L10nKey::PanelTurnNoScrollback,
|
||||
});
|
||||
this.tooltip(move |window, cx| {
|
||||
gpui_component::tooltip::Tooltip::new(tip).build(window, 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 {
|
||||
@@ -1897,19 +1780,9 @@ fn compact_path(path: &std::path::Path, home: Option<&std::path::Path>) -> Strin
|
||||
crate::ui::path_display::abbreviate_home(&path.to_string_lossy(), home).into_owned()
|
||||
}
|
||||
|
||||
/// Whether a turn's row is a link back into the scrollback, or only a label.
|
||||
///
|
||||
/// Both halves have to hold, and they are the same two conditions
|
||||
/// [`TerminalView::scroll_to_agent_turn`](crate::terminal::view::TerminalView)
|
||||
/// refuses on — deliberately, because a row that draws as a link and then does
|
||||
/// nothing is worse than one that never offered. Keep the two in step.
|
||||
fn turn_is_jumpable(row: Option<i64>, alt_now: bool) -> bool {
|
||||
row.is_some() && !alt_now
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{InfoRow, InfoValue, forwards_port, turn_is_jumpable};
|
||||
use super::{InfoRow, InfoValue, forwards_port};
|
||||
use crate::daemon::protocol::{ForwardStatus, ManagedForward, SshForwardKind};
|
||||
|
||||
fn forward(kind: SshForwardKind, target_host: &str, target_port: u16) -> ManagedForward {
|
||||
@@ -1996,25 +1869,6 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_turn_offers_the_jump_only_where_the_jump_would_land() {
|
||||
assert!(
|
||||
turn_is_jumpable(Some(42), false),
|
||||
"a turn anchored in the scrollback of a pane on the normal screen"
|
||||
);
|
||||
assert!(
|
||||
!turn_is_jumpable(None, false),
|
||||
"a turn that began on the alt screen was never written down"
|
||||
);
|
||||
// The one this pair exists for: the anchor survives the switch into a
|
||||
// full-screen renderer, and the row it points at does not. Before, the
|
||||
// row kept its pointer and its hover fill and swallowed every click.
|
||||
assert!(
|
||||
!turn_is_jumpable(Some(42), true),
|
||||
"and an anchor is no use while a full-screen program owns the pane"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn counts_are_a_button_only_when_there_is_a_diff_to_open() {
|
||||
assert!(
|
||||
|
||||
Reference in New Issue
Block a user