Merge pull request #829 from l0ng-ai/refactor/drop-conversation-outline

refactor(panel): drop the agent conversation outline
This commit is contained in:
l0ng-ai
2026-09-09 18:40:22 +08:00
committed by GitHub
10 changed files with 18 additions and 1150 deletions
+10
View File
@@ -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
-709
View File
@@ -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
View File
@@ -1,4 +1,3 @@
pub(crate) mod agent_marks;
mod boxdraw;
mod cmd_editor;
mod completion;
+7 -138
View File
@@ -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();
-135
View File
@@ -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);
-7
View File
@@ -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.",
-7
View File
@@ -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 が古く、ポートを列挙できません。",
-3
View File
@@ -783,9 +783,6 @@ l10n_keys! {
PanelNoChangesHint,
PanelMoreChangedFiles,
PanelSessionSubtitle,
PanelConversationSubtitle,
PanelTurnAltScreenNow,
PanelTurnNoScrollback,
PanelProcessesSubtitle,
PanelPortsSubtitle,
PanelPortsUnsupported,
-3
View File
@@ -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
View File
@@ -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!(