diff --git a/CHANGELOG.md b/CHANGELOG.md index 2c83283d..3ad6bca7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/src/terminal/agent_marks.rs b/src/terminal/agent_marks.rs deleted file mode 100644 index d8ec2b49..00000000 --- a/src/terminal/agent_marks.rs +++ /dev/null @@ -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, - /// The first line of the prompt, as the hook clamped it. Empty when the - /// agent's hook does not report prompts. - pub text: String, - /// The turn ended (a `stop` event arrived). - pub done: bool, - /// Identity for the UI, so a list that grows underneath a click still - /// points at the same turn. - pub id: u64, -} - -#[derive(Clone, Default)] -pub struct AgentTurns(Arc>); - -#[derive(Default)] -struct Inner { - turns: Vec, - next_id: u64, -} - -impl AgentTurns { - pub fn new() -> Self { - Self::default() - } - - pub fn list(&self) -> Vec { - let Ok(inner) = self.0.lock() else { - return Vec::new(); - }; - inner.turns.clone() - } - - /// Drop everything, for the same reason the image store is dropped: the - /// rows these point into are gone (`clear_scrollback`, a relink that resets - /// the grid) or belong to a different conversation (a fresh session). - pub fn clear(&self) { - if let Ok(mut inner) = self.0.lock() { - inner.turns.clear(); - } - } - - /// Move a turn onto the row the view found its text on. Jumping twice - /// should not search twice, and should not land in two different places. - pub fn recenter(&self, id: u64, row: i64) { - let Ok(mut inner) = self.0.lock() else { return }; - if let Some(turn) = inner.turns.iter_mut().find(|t| t.id == id) { - turn.row = Some(row); - } - } - - /// Read the row the emulator is on and record the turn there. Called with - /// the emulator advanced to exactly the byte after the event's terminator. - pub fn apply(&self, term: &Term, cut: TurnCut) { - match cut { - TurnCut::Begin { text } => { - // The alt screen is a scratch surface with no history behind - // it, so there is no row to come back to. The turn still - // belongs in the list — an agent that renders there (Codex - // does) has a conversation like any other — it just cannot be - // jumped to. - let row = (!term.mode().contains(TermMode::ALT_SCREEN)).then(|| { - let grid = term.grid(); - grid.history_size() as i64 + i64::from(grid.cursor.point.line.0) - }); - self.begin(row, text.unwrap_or_default()); - } - TurnCut::End => self.finish(), - TurnCut::Reset => self.clear(), - } - } - - fn begin(&self, row: Option, text: String) { - let Ok(mut inner) = self.0.lock() else { return }; - // One turn, announced twice. Hooks are not guaranteed to fire once — - // an agent reading two settings sources runs the same command twice, - // and a resumed session re-announces the turn it is resuming. What - // makes it the same turn is that the one before it never ended: a real - // repeat of a prompt can only come after the answer to the first, and - // an answer always brings a `stop`. - if let Some(last) = inner.turns.last_mut() - && !last.done - && last.text == text - { - // The earlier anchor is the better one — it was taken before the - // agent had drawn anything in reply — but take a row over nothing. - if last.row.is_none() { - last.row = row; - } - return; - } - let id = inner.next_id; - inner.next_id += 1; - inner.turns.push(AgentTurn { - row, - text, - done: false, - id, - }); - let overflow = inner.turns.len().saturating_sub(MAX_TURNS); - if overflow > 0 { - inner.turns.drain(..overflow); - } - } - - fn finish(&self) { - let Ok(mut inner) = self.0.lock() else { return }; - if let Some(last) = inner.turns.last_mut() { - last.done = true; - } - } -} - -/// Longest needle taken from a prompt. The point of a cap is that the text has -/// to sit on *one* grid row to be findable, and a prompt longer than the pane -/// is wide wrapped when it was drawn. -const NEEDLE_MAX: usize = 32; - -/// Shortest needle worth searching for *by containment*. Below this, `y` or -/// `go` occurs inside half the rows on screen and the closest match to the -/// anchor would be noise dressed up as precision. The exact match below has no -/// such floor: a row that *is* the prompt is the prompt however short it is. -const NEEDLE_MIN: usize = 3; - -/// What a TUI draws in front of what the user typed. Stripping one of these -/// turns the row Claude Code renders — `> restore the outline` — back into the -/// prompt the hook reported, so the two can be compared as equals. -const PROMPT_MARKERS: &[char] = &[ - '>', '❯', '›', '»', '〉', '⟩', '▶', '●', '•', '│', '|', '$', '#', '*', -]; - -/// Rows to look at either side of the anchor before widening to the whole -/// scrollback. Two screens covers the live region an agent repaints in, which -/// is the distance the anchor can be off by. -const NEAR: i64 = 120; - -/// The row a turn's prompt was actually drawn on, or `anchor` when there is -/// nothing to go on. -/// -/// The anchor says where the cursor was when the hook fired, which is a few -/// rows from where the prompt's echo settled (see the module docs), and drifts -/// further once the scrollback limit starts discarding lines out from under -/// every anchor at once. The prompt's own text does not drift, so it is the -/// better answer whenever it can be found — near the anchor first, since a -/// prompt asked twice should resolve to the turn that was clicked. -pub fn locate(term: &Term, anchor: i64, text: &str) -> i64 { - let Some(needle) = needle(text) else { - return anchor; - }; - let grid = term.grid(); - let last = grid.history_size() as i64 + grid.screen_lines() as i64 - 1; - let near = NEAR.max(2 * grid.screen_lines() as i64); - let (lo, hi) = ((anchor - near).max(0), (anchor + near).min(last)); - // Exact first, everywhere, before settling for containment: the row that - // *is* the prompt beats a row that merely mentions it, even when the - // mention is closer. `hi` appears inside a dozen rows of any answer; only - // one row is `> hi`. - Match::Exact - .nearest(term, anchor, needle, lo, hi) - .or_else(|| Match::Exact.nearest(term, anchor, needle, 0, last)) - .or_else(|| Match::Loose.nearest(term, anchor, needle, lo, hi)) - .or_else(|| Match::Loose.nearest(term, anchor, needle, 0, last)) - .unwrap_or(anchor) -} - -/// How hard a row has to try to count as the one the prompt was drawn on. -#[derive(Clone, Copy, PartialEq, Eq)] -enum Match { - /// The row, minus the marker the TUI drew in front of it, starts with the - /// prompt. This is what an agent's echo of the user's line looks like. - Exact, - /// The row mentions the prompt somewhere. The fallback for a TUI that - /// frames its messages some other way — and the reason for [`NEEDLE_MIN`]. - Loose, -} - -fn needle(text: &str) -> Option<&str> { - let text = text.trim(); - let end = text - .char_indices() - .nth(NEEDLE_MAX) - .map_or(text.len(), |(i, _)| i); - Some(&text[..end]).filter(|n| !n.is_empty()) -} - -impl Match { - /// The row closest to `anchor` in `lo..=hi` that matches, found by walking - /// outwards so the first hit is the answer. - fn nearest( - self, - term: &Term, - anchor: i64, - needle: &str, - lo: i64, - hi: i64, - ) -> Option { - if lo > hi || (self == Match::Loose && needle.chars().count() < NEEDLE_MIN) { - return None; - } - let reach = (anchor - lo).max(hi - anchor).max(0); - for step in 0..=reach { - let probes = [anchor - step, anchor + step]; - for &row in &probes[..if step == 0 { 1 } else { 2 }] { - if (lo..=hi).contains(&row) && self.holds(term, row, needle) { - return Some(row); - } - } - } - None - } - - fn holds(self, term: &Term, row: i64, needle: &str) -> bool { - let Some(text) = row_text(term, row) else { - return false; - }; - match self { - Match::Loose => text.contains(needle), - Match::Exact => { - let line = text.trim(); - let line = match line.chars().next() { - Some(c) if PROMPT_MARKERS.contains(&c) => &line[c.len_utf8()..], - _ => line, - }; - line.trim_start().starts_with(needle) - } - } - } -} - -/// One scrollback row as text, with the spacer cells that follow double-width -/// glyphs left out — keeping them would break every CJK needle in half. -fn row_text(term: &Term, row: i64) -> Option { - use alacritty_terminal::index::{Column, Line}; - use alacritty_terminal::term::cell::Flags; - - let grid = term.grid(); - let line = row - grid.history_size() as i64; - if line < -(grid.history_size() as i64) || line >= grid.screen_lines() as i64 { - return None; - } - let line = i32::try_from(line).ok()?; - let row = &grid[Line(line)]; - let mut text = String::with_capacity(grid.columns()); - for col in 0..grid.columns() { - let cell = &row[Column(col)]; - if cell - .flags - .intersects(Flags::WIDE_CHAR_SPACER | Flags::LEADING_WIDE_CHAR_SPACER) - { - continue; - } - text.push(cell.c); - if let Some(zerowidth) = cell.zerowidth() { - text.extend(zerowidth); - } - } - Some(text) -} - -/// What an agent event means for the outline, reported at the offset one past -/// the sequence that carried it. -#[derive(Clone, Debug, PartialEq, Eq)] -pub enum TurnCut { - Begin { - text: Option, - }, - End, - /// A session started: whatever is in the list belongs to a conversation - /// that is over. Claude Code sends this on `/clear` and on a resume, both - /// of which repaint the pane from scratch. - Reset, -} - -/// Byte scanner over the pty stream, reporting the agent events an outline -/// cares about, in ascending offset order — the same shape (and the same -/// contract) as [`ParkedCursorScanner`](crate::terminal::parked_cursor::ParkedCursorScanner). -pub struct AgentTurnScanner { - tok: OscTokenizer, -} - -impl Default for AgentTurnScanner { - fn default() -> Self { - Self { - tok: OscTokenizer::new(&[b"777"]), - } - } -} - -impl AgentTurnScanner { - pub fn new() -> Self { - Self::default() - } - - /// Forgets a sequence in progress. A replayed snapshot is a new stream, and - /// half an OSC from the old one must not join up with it. - pub fn reset(&mut self) { - *self = Self::default(); - } - - pub fn feed(&mut self, bytes: &[u8], mut on_cut: impl FnMut(usize, TurnCut)) { - self.tok.feed_at(bytes, |at, payload| { - let Some(ev) = parse_agent_event(payload) else { - return; - }; - match ev.kind { - AgentEventKind::PromptSubmit => on_cut(at, TurnCut::Begin { text: ev.prompt }), - AgentEventKind::Stop => on_cut(at, TurnCut::End), - AgentEventKind::SessionStart => on_cut(at, TurnCut::Reset), - // The rest move the status dot, not the conversation: a - // permission prompt or a tool completion happens *inside* a - // turn that is already in the list. - _ => {} - } - }); - } -} - -#[cfg(test)] -mod tests { - use super::*; - use alacritty_terminal::event::VoidListener; - use alacritty_terminal::vte::ansi::Processor; - - fn event(kind: &str, prompt: Option<&str>) -> Vec { - let mut body = serde_json::json!({ - "v": 1, - "agent": "claude", - "event": kind, - }); - if let Some(p) = prompt { - body["prompt"] = serde_json::Value::String(p.to_string()); - } - format!( - "\x1b]777;notify;{};{body}\x07", - crate::core::cli_agent::AGENT_EVENT_SENTINEL - ) - .into_bytes() - } - - fn cuts(chunks: &[&[u8]]) -> Vec<(usize, TurnCut)> { - let mut scanner = AgentTurnScanner::new(); - let mut got = Vec::new(); - for chunk in chunks { - scanner.feed(chunk, |at, cut| got.push((at, cut))); - } - got - } - - /// Drives a stream through the emulator the way the reader does — advance - /// to each cut, act on it, carry on — and reports the turns it recorded. - fn turns_after(stream: &[u8]) -> Vec { - let mut term = Term::new( - alacritty_terminal::term::Config { - scrolling_history: 1000, - ..Default::default() - }, - &crate::terminal::size::TermSize::new(80, 24), - VoidListener, - ); - let mut parser: Processor = Processor::new(); - let mut scanner = AgentTurnScanner::new(); - let turns = AgentTurns::new(); - - let mut cuts = Vec::new(); - scanner.feed(stream, |off, cut| cuts.push((off, cut))); - let mut at = 0; - for (off, cut) in cuts { - parser.advance(&mut term, &stream[at..off]); - at = off; - turns.apply(&term, cut); - } - parser.advance(&mut term, &stream[at..]); - turns.list() - } - - #[test] - fn a_prompt_and_its_stop_are_one_turn() { - let mut stream = event("prompt-submit", Some("restore the outline")); - stream.extend_from_slice(&event("stop", None)); - let got = turns_after(&stream); - assert_eq!(got.len(), 1); - assert_eq!(got[0].text, "restore the outline"); - assert!(got[0].done); - } - - #[test] - fn a_turn_anchors_to_the_row_the_prompt_arrived_on() { - let mut stream = b"one\r\ntwo\r\nthree\r\n".to_vec(); - stream.extend_from_slice(&event("prompt-submit", Some("go"))); - assert_eq!( - turns_after(&stream)[0].row, - Some(3), - "three lines written, so the cursor is on the fourth row" - ); - } - - #[test] - fn an_anchor_counts_from_the_oldest_line_still_held() { - // Fill past a screen so lines are in history, and the anchor has to - // count them rather than the visible rows. - let mut stream = "x\r\n".repeat(30).into_bytes(); - stream.extend_from_slice(&event("prompt-submit", Some("go"))); - assert_eq!( - turns_after(&stream)[0].row, - Some(30), - "30 rows written: 7 scrolled into history, cursor on screen line 23" - ); - } - - #[test] - fn a_turn_on_the_alt_screen_has_no_row_to_return_to() { - let mut stream = b"\x1b[?1049h".to_vec(); - stream.extend_from_slice(&event("prompt-submit", Some("go"))); - let got = turns_after(&stream); - assert_eq!(got.len(), 1, "still a turn, still listed"); - assert_eq!(got[0].row, None, "nothing behind the alt screen to jump to"); - } - - #[test] - fn a_session_start_drops_the_previous_conversation() { - let mut stream = event("prompt-submit", Some("first")); - stream.extend_from_slice(&event("stop", None)); - stream.extend_from_slice(&event("session-start", None)); - stream.extend_from_slice(&event("prompt-submit", Some("second"))); - let got = turns_after(&stream); - assert_eq!(got.len(), 1); - assert_eq!(got[0].text, "second"); - } - - #[test] - fn events_that_only_move_the_status_dot_are_not_turns() { - for kind in ["notification", "permission-request", "tool-complete"] { - assert!( - cuts(&[&event(kind, None)]).is_empty(), - "{kind} happens inside a turn, it does not start one" - ); - } - } - - #[test] - fn other_osc_777_notifications_are_left_alone() { - assert!( - cuts(&[b"\x1b]777;notify;Build;finished\x07"]).is_empty(), - "a plain desktop notification is not an agent event" - ); - } - - #[test] - fn a_cut_lands_one_past_its_sequence() { - let ev = event("stop", None); - let mut stream = b"before".to_vec(); - stream.extend_from_slice(&ev); - stream.extend_from_slice(b"after"); - let got = cuts(&[&stream]); - assert_eq!(got.len(), 1); - assert_eq!( - &stream[got[0].0..], - b"after", - "the emulator must be advanced to just past the event" - ); - } - - #[test] - fn an_event_split_across_reads_still_arrives() { - let ev = event("prompt-submit", Some("split me")); - let (head, tail) = ev.split_at(ev.len() / 2); - assert_eq!( - cuts(&[head, tail]), - vec![( - tail.len(), - TurnCut::Begin { - text: Some("split me".into()) - } - )], - "the cut is attributed to the batch its terminator lands in" - ); - } - - /// A pane holding `lines`, one per row, with the first of them at row 0. - fn painted(lines: &[&str]) -> Term { - let mut term = Term::new( - alacritty_terminal::term::Config { - scrolling_history: 1000, - ..Default::default() - }, - &crate::terminal::size::TermSize::new(80, 24), - VoidListener, - ); - let mut parser: Processor = Processor::new(); - parser.advance(&mut term, lines.join("\r\n").as_bytes()); - term - } - - #[test] - fn a_prompt_is_found_a_few_rows_off_its_anchor() { - let mut lines = vec!["boot"; 40]; - lines[30] = "> restore the outline please"; - let term = painted(&lines); - assert_eq!( - locate(&term, 33, "restore the outline please"), - 30, - "the anchor lands in the live region; the text says where the turn is" - ); - } - - #[test] - fn the_same_prompt_twice_resolves_to_the_one_that_was_clicked() { - let mut lines = vec!["boot"; 200]; - lines[20] = "> run the tests"; - lines[150] = "> run the tests"; - let term = painted(&lines); - assert_eq!(locate(&term, 22, "run the tests"), 20); - assert_eq!(locate(&term, 148, "run the tests"), 150); - } - - #[test] - fn a_prompt_far_from_its_anchor_is_still_found() { - // What a scrollback that has started discarding lines looks like: every - // anchor slid, and the near window no longer covers the distance. - let mut lines = vec!["boot"; 600]; - lines[80] = "> the anchor drifted away from me"; - let term = painted(&lines); - assert_eq!(locate(&term, 500, "the anchor drifted away from me"), 80); - } - - #[test] - fn a_wide_glyph_prompt_survives_the_spacer_cells() { - let mut lines = vec!["boot"; 40]; - lines[12] = "> 把大纲恢复一下"; - let term = painted(&lines); - assert_eq!( - locate(&term, 15, "把大纲恢复一下"), - 12, - "the cell after a double-width glyph is a spacer, not part of the text" - ); - } - - #[test] - fn a_two_letter_prompt_still_lands_on_its_own_row() { - // The row the agent echoed the prompt on is `> hi`; every row of the - // answer under it may well contain "hi" too. Exactness, not length, is - // what makes the short one safe. - let mut lines = vec!["boot"; 40]; - lines[12] = "> hi"; - lines[14] = "Hi! What can I help you with?"; - lines[16] = "I see you are on this branch"; - let term = painted(&lines); - assert_eq!(locate(&term, 15, "hi"), 12); - } - - #[test] - fn the_marker_a_tui_draws_in_front_does_not_hide_the_prompt() { - for marker in ["> ", "❯ ", "〉", "│ ", "▶ "] { - let mut lines = vec!["boot".to_string(); 40]; - lines[9] = format!("{marker}run the tests"); - let borrowed: Vec<&str> = lines.iter().map(String::as_str).collect(); - let term = painted(&borrowed); - assert_eq!(locate(&term, 13, "run the tests"), 9, "marker {marker:?}"); - } - } - - #[test] - fn a_row_that_is_the_prompt_beats_a_nearer_row_that_merely_mentions_it() { - let mut lines = vec!["boot"; 60]; - lines[20] = "> run the tests"; - lines[30] = "sure, I will run the tests now"; - let term = painted(&lines); - assert_eq!( - locate(&term, 31, "run the tests"), - 20, - "the echo is the turn; the sentence about it is the answer" - ); - } - - #[test] - fn text_too_short_to_be_distinctive_leaves_the_anchor_alone() { - let mut lines = vec!["y"; 40]; - lines[5] = "y"; - let term = painted(&lines); - assert_eq!( - locate(&term, 30, "y"), - 30, - "a needle that matches everywhere is worse than the anchor" - ); - } - - #[test] - fn a_prompt_that_was_never_drawn_leaves_the_anchor_alone() { - let term = painted(&vec!["boot"; 40]); - assert_eq!(locate(&term, 12, "nothing on screen says this"), 12); - } - - #[test] - fn a_turn_announced_twice_is_still_one_turn() { - // What a hook that fires from two settings sources produces: the same - // prompt twice, then the same stop twice. - let mut stream = event("prompt-submit", Some("hi")); - stream.extend_from_slice(&event("prompt-submit", Some("hi"))); - stream.extend_from_slice(&event("stop", None)); - stream.extend_from_slice(&event("stop", None)); - let got = turns_after(&stream); - assert_eq!(got.len(), 1, "one prompt, however many times announced"); - assert!(got[0].done); - } - - #[test] - fn the_first_anchor_of_a_repeated_announcement_is_the_one_kept() { - let mut stream = b"a\r\nb\r\n".to_vec(); - stream.extend_from_slice(&event("prompt-submit", Some("hi"))); - stream.extend_from_slice(b"c\r\nd\r\n"); - stream.extend_from_slice(&event("prompt-submit", Some("hi"))); - assert_eq!( - turns_after(&stream)[0].row, - Some(2), - "the earlier anchor was taken before the agent drew its reply" - ); - } - - #[test] - fn the_same_prompt_asked_again_after_an_answer_is_a_new_turn() { - let mut stream = event("prompt-submit", Some("hi")); - stream.extend_from_slice(&event("stop", None)); - stream.extend_from_slice(&event("prompt-submit", Some("hi"))); - let got = turns_after(&stream); - assert_eq!(got.len(), 2, "an answer came between them, so they differ"); - assert!(got[0].done && !got[1].done); - } - - #[test] - fn turns_are_capped_from_the_front() { - let turns = AgentTurns::new(); - for i in 0..(MAX_TURNS + 10) { - turns.begin(Some(i as i64), format!("turn {i}")); - } - let got = turns.list(); - assert_eq!(got.len(), MAX_TURNS); - assert_eq!( - got[0].text, "turn 10", - "the oldest aged out, not the newest" - ); - } - - #[test] - fn recentring_moves_the_anchor_by_id_not_by_position() { - let turns = AgentTurns::new(); - turns.begin(Some(10), "first".into()); - turns.begin(Some(20), "second".into()); - let id = turns.list()[0].id; - turns.recenter(id, 12); - assert_eq!(turns.list()[0].row, Some(12)); - assert_eq!(turns.list()[1].row, Some(20), "its neighbour is untouched"); - } -} diff --git a/src/terminal/mod.rs b/src/terminal/mod.rs index efea92df..21d408ca 100644 --- a/src/terminal/mod.rs +++ b/src/terminal/mod.rs @@ -1,4 +1,3 @@ -pub(crate) mod agent_marks; mod boxdraw; mod cmd_editor; mod completion; diff --git a/src/terminal/remote.rs b/src/terminal/remote.rs index e546fec5..cd8cc2a1 100644 --- a/src/terminal/remote.rs +++ b/src/terminal/remote.rs @@ -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>>, shell: Arc>, @@ -93,10 +82,6 @@ struct ReaderSignals { images: crate::terminal::images::ImageStore, clipboard_writes: Arc>>, clipboard_write_busy: Arc, - /// 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>>, clipboard_write_busy: Arc, - /// 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>, @@ -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 = 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 { - format!( - "\x1b]777;notify;{};{{\"v\":1,\"agent\":\"claude\",\ - \"event\":\"prompt-submit\",\"prompt\":\"{prompt}\"}}\x07", - crate::core::cli_agent::AGENT_EVENT_SENTINEL - ) - .into_bytes() - } - - fn poll_turns(term: &RemoteTerminal) -> Vec { - for _ in 0..200 { - let turns = term.agent_turns().list(); - if !turns.is_empty() { - return turns; - } - std::thread::sleep(std::time::Duration::from_millis(5)); - } - Vec::new() - } - - #[test] - fn an_agent_turn_anchors_where_its_event_sits_in_the_batch() { - crate::core::config::pin_test_config_dir(); - let (client_side, mut daemon_side) = UnixStream::pair().unwrap(); - let term = RemoteTerminal::from_stream(client_side, TermSize::new(80, 24)).unwrap(); - - // One frame, so one batch: the event's row is only reachable by - // splitting the batch at it. Reading the cursor after the whole batch - // has been parsed would answer 5. - let mut out = b"a\r\nb\r\n".to_vec(); - out.extend_from_slice(&prompt_event("restore the outline")); - out.extend_from_slice(b"c\r\nd\r\ne\r\n"); - DaemonMsg::Output(out).encode(&mut daemon_side).unwrap(); - daemon_side.flush().unwrap(); - - let turns = poll_turns(&term); - assert_eq!(turns.len(), 1, "one prompt, one turn"); - assert_eq!( - turns[0].row, - Some(2), - "the anchor is the row the event arrived on, not the end of the batch" - ); - assert_eq!(turns[0].text, "restore the outline"); - assert!(!turns[0].done, "no stop yet"); - } - - #[test] - fn a_replayed_ring_brings_the_conversation_back_with_it() { - crate::core::config::pin_test_config_dir(); - let (client_side, mut daemon_side) = UnixStream::pair().unwrap(); - let term = RemoteTerminal::from_stream(client_side, TermSize::new(80, 24)).unwrap(); - - // What reattaching to a pane looks like: its history arrives as a - // snapshot, agent events and all. The outline has to be rebuilt from - // those bytes — nothing else carries it across a client restart. - let mut snapshot = b"older output\r\n".to_vec(); - snapshot.extend_from_slice(&prompt_event("what did we decide")); - DaemonMsg::Snapshot(snapshot) - .encode(&mut daemon_side) - .unwrap(); - daemon_side.flush().unwrap(); - - let turns = poll_turns(&term); - assert_eq!(turns.len(), 1); - assert_eq!(turns[0].row, Some(1)); - assert_eq!(turns[0].text, "what did we decide"); - } - #[test] fn shell_vi_mode_follows_live_prompt_mode_marks_without_disarming_zle() { let (client_side, mut daemon_side) = UnixStream::pair().unwrap(); diff --git a/src/terminal/view.rs b/src/terminal/view.rs index c29d0e59..50f6c259 100644 --- a/src/terminal/view.rs +++ b/src/terminal/view.rs @@ -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 { - self.terminal.agent_turns().list() - } - - /// Scroll back to where a turn began, putting that row at the top of the - /// viewport so the answer to it reads downwards from there. - /// - /// The stored anchor only says roughly where the agent was drawing when the - /// turn started, so the prompt's own text gets the final say; the row it is - /// found on is written back, and a second click on the same turn lands in - /// the same place without searching again. - pub fn scroll_to_agent_turn( - &mut self, - turn: &crate::terminal::agent_marks::AgentTurn, - cx: &mut Context, - ) -> bool { - let Some(anchor) = turn.row else { - return false; - }; - // Nothing behind the alt screen to scroll to, and `scroll_display` is a - // no-op there anyway — say so rather than pretending the click worked. - if self.on_alt_screen() { - return false; - } - self.cancel_scroll_anim(); - let row = { - let mut term = self.terminal.term.lock(); - use alacritty_terminal::grid::Dimensions as _; - let history = term.grid().history_size() as i64; - let row = crate::terminal::agent_marks::locate(&term, anchor, &turn.text); - let target = (history - row).clamp(0, history); - let delta = (target - term.grid().display_offset() as i64) - .clamp(i64::from(i32::MIN), i64::from(i32::MAX)) as i32; - term.scroll_display(Scroll::Delta(delta)); - row - }; - self.terminal.agent_turns().recenter(turn.id, row); - self.scroll_frac = 0.; - cx.notify(); - true - } - /// What this pane is in the middle of, when it can say so. `None` means /// either nothing is running or the shell never told us — and a terminal /// that guessed would raise this question on every single close. @@ -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::() - .trim_end() - .to_string() - } - - #[gpui::test] - fn jumping_to_a_turn_puts_the_prompt_at_the_top_of_the_viewport(cx: &mut TestAppContext) { - let (window, _daemon) = harness(cx); - window - .update(cx, |view, _w, cx| { - painted_conversation(view); - // Off by three, the way a hook firing into a live repaint is. - let turn = crate::terminal::agent_marks::AgentTurn { - row: Some(CONVERSATION_ROW + 3), - text: "restore the outline".into(), - done: true, - id: 1, - }; - assert!(view.scroll_to_agent_turn(&turn, cx)); - assert_eq!( - viewport_top(view), - "> restore the outline", - "the text corrected the anchor's three-row error" - ); - let history = { - use alacritty_terminal::grid::Dimensions as _; - view.terminal.term.lock().grid().history_size() as i64 - }; - assert_eq!( - display_offset(view) as i64, - history - CONVERSATION_ROW, - "the turn's row is the first one shown, not merely on screen" - ); - }) - .unwrap(); - } - - #[gpui::test] - fn a_turn_with_nowhere_to_go_reports_that_it_did_not_move(cx: &mut TestAppContext) { - let (window, _daemon) = harness(cx); - window - .update(cx, |view, _w, cx| { - painted_conversation(view); - let mut turn = crate::terminal::agent_marks::AgentTurn { - row: None, - text: "restore the outline".into(), - done: true, - id: 1, - }; - assert!( - !view.scroll_to_agent_turn(&turn, cx), - "a turn that began on the alt screen has no row" - ); - - turn.row = Some(CONVERSATION_ROW); - let mut parser: alacritty_terminal::vte::ansi::Processor = Default::default(); - parser.advance(&mut *view.terminal.term.lock(), b"\x1b[?1049h"); - assert!( - !view.scroll_to_agent_turn(&turn, cx), - "and a pane sitting on the alt screen has no scrollback to show" - ); - }) - .unwrap(); - } - #[gpui::test] fn turning_smooth_scrolling_off_restores_the_direct_path(cx: &mut TestAppContext) { let (window, _daemon) = harness(cx); diff --git a/src/ui/i18n/en.rs b/src/ui/i18n/en.rs index 42b04b3d..f3572312 100644 --- a/src/ui/i18n/en.rs +++ b/src/ui/i18n/en.rs @@ -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.", diff --git a/src/ui/i18n/ja.rs b/src/ui/i18n/ja.rs index 6560bf5a..b0aa4296 100644 --- a/src/ui/i18n/ja.rs +++ b/src/ui/i18n/ja.rs @@ -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 が古く、ポートを列挙できません。", diff --git a/src/ui/i18n/mod.rs b/src/ui/i18n/mod.rs index 0cc6d32d..ec9e2ac3 100644 --- a/src/ui/i18n/mod.rs +++ b/src/ui/i18n/mod.rs @@ -783,9 +783,6 @@ l10n_keys! { PanelNoChangesHint, PanelMoreChangedFiles, PanelSessionSubtitle, - PanelConversationSubtitle, - PanelTurnAltScreenNow, - PanelTurnNoScrollback, PanelProcessesSubtitle, PanelPortsSubtitle, PanelPortsUnsupported, diff --git a/src/ui/i18n/zh.rs b/src/ui/i18n/zh.rs index 83d55ae5..f4b90a1e 100644 --- a/src/ui/i18n/zh.rs +++ b/src/ui/i18n/zh.rs @@ -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 太旧,列不出端口。", diff --git a/src/ui/right_panel.rs b/src/ui/right_panel.rs index da037237..2cfa9679 100644 --- a/src/ui/right_panel.rs +++ b/src/ui/right_panel.rs @@ -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 = 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>, - cx: &mut Context, - ) -> Option { - let leaf = leaf?; - let turns = leaf.read(cx).agent_turns(); - // A turn the hook announced but could not name is a row with nothing on - // it. The status dot already says a turn is running. - let turns: Vec<_> = turns - .into_iter() - .filter(|t| !t.text.trim().is_empty()) - .collect(); - if turns.is_empty() { - return None; - } - // 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::().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, cx: &mut Context) -> Option { 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, 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!(