From 70476920f180b72e838c5c202f1b6a5d3c5d6d61 Mon Sep 17 00:00:00 2001 From: Christopher Sardegna Date: Sun, 19 Jul 2026 20:51:28 -0700 Subject: [PATCH 01/11] fix: land an open ?2026 frame before the exit scrape --- src/emulator.rs | 41 +++++++++++++++++++++++++++++++++++++++++ src/task.rs | 36 +++++++++++++++++++++++++++++++++++- 2 files changed, 76 insertions(+), 1 deletion(-) diff --git a/src/emulator.rs b/src/emulator.rs index d820686..6937085 100644 --- a/src/emulator.rs +++ b/src/emulator.rs @@ -234,6 +234,20 @@ impl Emulator { self.drain_allowed() } + /// Terminate an open `?2026` synchronized update regardless of its + /// timeout, landing the buffered frame in the grid; returns any + /// allowlisted probe replies the landed bytes generated. Exists for + /// reader EOF: every child fd is closed, so the closing ESU can never + /// arrive and `flush_expired_sync`'s deadline wait protects nothing — + /// the frame is landed, not torn. No-op when no sync is open. + pub fn finish_output(&mut self) -> Vec { + if self.parser.sync_timeout().sync_timeout().is_none() { + return Vec::new(); + } + self.parser.stop_sync(&mut self.term); + self.drain_allowed() + } + /// The visible screen as ANSI bytes, plus cursor position and whether the /// child hid the cursor. pub fn formatted(&self) -> (Vec, (u16, u16), bool) { @@ -483,6 +497,33 @@ mod tests { assert!(emu.contents().contains("and on")); } + /// The end-of-life landing `finish_output` exists for: BSU, a hint, no + /// ESU ever. The frame must land without waiting out the sync timeout, + /// and a clean emulator must pass through untouched. + #[test] + fn finish_output_lands_an_open_sync_frame() { + let mut emu = Emulator::new(4, 20, 0); + assert!( + emu.finish_output().is_empty(), + "no open frame: the parser must not be touched" + ); + + emu.process(b"before\x1b[?2026hafter"); + assert!( + !emu.text_with_history().contains("after"), + "premise: the unclosed frame buffers the text" + ); + emu.finish_output(); + assert!( + emu.text_with_history().contains("after"), + "finish_output must land the frame with the timeout still pending" + ); + + // The emulator parses normally after the landing. + emu.process(b" and on"); + assert!(emu.contents().contains("and on")); + } + /// Mouse modes follow the most recent DECSET, return to none /// when unset, and keep 1005 and 1006 mutually exclusive. #[test] diff --git a/src/task.rs b/src/task.rs index 6ebc0b8..94a1104 100644 --- a/src/task.rs +++ b/src/task.rs @@ -624,7 +624,15 @@ impl Task { return; } self.scraped = true; - let text = grid(&self.parser).text_with_history(); + let text = { + let mut emu = grid(&self.parser); + // The gate above holds: reader EOF means no ESU can ever close an + // open `?2026` frame, so land it before reading, or a hint printed + // inside the frame is invisible to the scrape. Probe replies are + // dropped because every slave fd is closed — nothing reads them. + let _ = emu.finish_output(); + emu.text_with_history() + }; if let Some(id) = h.scrape_exit(&text) { self.scraped_id = Some(id); } @@ -1839,6 +1847,32 @@ mod tests { let _ = std::fs::remove_dir_all(&dir); } + /// A child that dies with a `?2026` frame still open leaves its hint + /// buffered in the parser, and no ESU can ever arrive to release it: the + /// scrape must land the frame instead of reading pre-frame text. + #[test] + fn scrape_exit_hint_lands_an_open_sync_frame() { + const ID: &str = "7f3b9c1e-5a2d-4e8f-9b6a-0c4d2e8f1a3b"; + let cmd = format!( + "printf '\\033[?2026hResume this session with:\\nclaude --resume {ID}\\n'" + ); + let mut t = Task::spawn(21, &cmd, &cmd, &here(), 24, 80, &sh_env(), no_waker()).unwrap(); + t.harness = Some(&crate::harness::Claude); + assert!( + wait_until(Duration::from_secs(60), || { + t.poll_exit().unwrap(); + t.finished.is_some() && t.reader_done() + }), + "child never exited" + ); + assert!( + !grid(&t.parser).text_with_history().contains(ID), + "premise: the unclosed frame still buffers the hint at scrape time" + ); + t.scrape_exit_hint(); + assert_eq!(t.scraped_id.as_deref(), Some(ID)); + } + /// A child's cursor-position probe is answered on the wire: the reply /// crosses the reader thread → allowlist → writer worker → PTY, and only /// the advertised shape arrives. The child first sends secondary DA (a From 96f436444858dae5c193028b4ff6ec5b5a34a8fe Mon Sep 17 00:00:00 2001 From: Christopher Sardegna Date: Sun, 19 Jul 2026 20:58:46 -0700 Subject: [PATCH 02/11] refactor: group terminal reconstruction under terminal/ --- src/main.rs | 8 ++++---- src/{ => terminal}/ansi.rs | 4 ++-- src/{ => terminal}/emulator.rs | 0 src/{ => terminal}/format.rs | 0 src/{ => terminal}/frame.rs | 0 src/terminal/mod.rs | 7 +++++++ 6 files changed, 13 insertions(+), 6 deletions(-) rename src/{ => terminal}/ansi.rs (99%) rename src/{ => terminal}/emulator.rs (100%) rename src/{ => terminal}/format.rs (100%) rename src/{ => terminal}/frame.rs (100%) create mode 100644 src/terminal/mod.rs diff --git a/src/main.rs b/src/main.rs index 68584db..07409b8 100644 --- a/src/main.rs +++ b/src/main.rs @@ -8,13 +8,9 @@ #[cfg(not(unix))] compile_error!("fleetcom supports Unix platforms only."); -mod ansi; mod app; mod core; mod daemon; -mod emulator; -mod format; -mod frame; // Differential emulator tests over recorded PTY output. #[cfg(test)] mod golden; @@ -25,12 +21,16 @@ mod protocol; mod session; mod supervisor; mod task; +mod terminal; // Shared test scaffolds: scratch dirs, deadline polling, corpus fixtures. #[cfg(test)] mod testutil; mod transport; mod ui; +// Re-exported at the root so call sites keep their short `crate::ansi`-style paths. +pub(crate) use terminal::{ansi, emulator, format, frame}; + use std::{ io, sync::{ diff --git a/src/ansi.rs b/src/terminal/ansi.rs similarity index 99% rename from src/ansi.rs rename to src/terminal/ansi.rs index 4adad40..15fd3a5 100644 --- a/src/ansi.rs +++ b/src/terminal/ansi.rs @@ -561,7 +561,7 @@ mod tests { #[test] fn $name() { let source = parse( - include_bytes!(concat!("../tests/corpus/", $file)), + include_bytes!(concat!("../../tests/corpus/", $file)), CORPUS_LINES, CORPUS_COLS, ); @@ -585,7 +585,7 @@ mod tests { #[test] fn corpus_scrolled_viewport() { let mut source = parse( - include_bytes!("../tests/corpus/codex_resume.bin"), + include_bytes!("../../tests/corpus/codex_resume.bin"), CORPUS_LINES, CORPUS_COLS, ); diff --git a/src/emulator.rs b/src/terminal/emulator.rs similarity index 100% rename from src/emulator.rs rename to src/terminal/emulator.rs diff --git a/src/format.rs b/src/terminal/format.rs similarity index 100% rename from src/format.rs rename to src/terminal/format.rs diff --git a/src/frame.rs b/src/terminal/frame.rs similarity index 100% rename from src/frame.rs rename to src/terminal/frame.rs diff --git a/src/terminal/mod.rs b/src/terminal/mod.rs new file mode 100644 index 0000000..6a22f0a --- /dev/null +++ b/src/terminal/mod.rs @@ -0,0 +1,7 @@ +//! Terminal reconstruction: each task's screen is rebuilt from raw PTY bytes, +//! serialized back to ANSI, framed over the wire, and formatted for display. + +pub(crate) mod ansi; +pub(crate) mod emulator; +pub(crate) mod format; +pub(crate) mod frame; From d8cf63d2c7b61eb9a281e21e201329f673f38938 Mon Sep 17 00:00:00 2001 From: Christopher Sardegna Date: Sun, 19 Jul 2026 21:11:49 -0700 Subject: [PATCH 03/11] feat: capture titles, alt epochs, and screen revisions in the emulator --- src/terminal/emulator.rs | 422 +++++++++++++++++++++++++++++++++++++-- 1 file changed, 408 insertions(+), 14 deletions(-) diff --git a/src/terminal/emulator.rs b/src/terminal/emulator.rs index 6937085..7656a10 100644 --- a/src/terminal/emulator.rs +++ b/src/terminal/emulator.rs @@ -35,22 +35,47 @@ pub enum MouseProtocolEncoding { Sgr, } -/// Routes the backend's `Event::PtyWrite` probe responses into a buffer. The -/// listener fires inside `Processor::advance`, while the caller holds the -/// emulator lock, so it only appends, never blocks; the caller drains and -/// filters after `advance` returns. Every other backend event (title, -/// clipboard, color requests, bell) is discarded here: the default-deny probe -/// policy starts with what never gets buffered. -pub struct ProbeSink(Arc>>); +/// The last title event of an advance, if any: `Some(text)` for a set, +/// `None` for a reset (RIS or a title-stack pop). One slot, not a queue — +/// only the final title of a chunk can be displayed, so earlier ones carry +/// no information. Chunk-granular observation is the documented contract. +type PendingTitle = Option>; + +/// Routes the backend's `Event::PtyWrite` probe responses into a buffer and +/// records the advance's last title event. The listener fires inside +/// `Processor::advance`, while the caller holds the emulator lock, so it only +/// stores, never blocks; the caller drains, filters, and sanitizes after +/// `advance` returns. Every other backend event (clipboard, color requests, +/// bell) is discarded here: the default-deny probe policy starts with what +/// never gets buffered. +pub struct ProbeSink { + responses: Arc>>, + title_event: Arc>, +} impl EventListener for ProbeSink { fn send_event(&self, event: Event) { - if let Event::PtyWrite(text) = event { - let mut buf = self - .0 - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner); - buf.push(text); + match event { + Event::PtyWrite(text) => { + let mut buf = self + .responses + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + buf.push(text); + } + Event::Title(title) => { + *self + .title_event + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) = Some(Some(title)); + } + Event::ResetTitle => { + *self + .title_event + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) = Some(None); + } + _ => {} } } } @@ -106,6 +131,66 @@ fn is_digits(s: &str) -> bool { !s.is_empty() && s.bytes().all(|b| b.is_ascii_digit()) } +/// Sanitized-title cap in UTF-8 bytes. Truncation lands on a char boundary, +/// so the result can undershoot by up to three bytes. +const TITLE_MAX_BYTES: usize = 512; + +/// The bidi formatting controls stripped from titles: ALM, LRM/RLM, the +/// LRE/RLE/PDF/LRO/RLO embedding block, and the LRI/RLI/FSI/PDI isolate +/// block. A fixed set instead of a general-category `Cf` strip: `Cf` needs a +/// Unicode table and would also delete ZWJ (U+200D), mangling joined emoji. +fn is_bidi_control(c: char) -> bool { + matches!( + c, + '\u{061C}' | '\u{200E}' | '\u{200F}' | '\u{202A}'..='\u{202E}' | '\u{2066}'..='\u{2069}' + ) +} + +/// Sanitize child-controlled title text: strip C0/C1 controls and bidi +/// formatting controls, collapse each whitespace run to one space, trim the +/// ends, cap at [`TITLE_MAX_BYTES`] on a char boundary. Empty output means +/// the caller must unset its capture — a blank label is never displayed. +fn sanitize_title(raw: &str) -> String { + let mut out = String::new(); + for c in raw.chars() { + // `is_control` is category Cc: C0, DEL, and C1. + if c.is_control() || is_bidi_control(c) { + continue; + } + if c.is_whitespace() { + // Collapsing also trims the start: a leading run sees empty + // output and pushes nothing. + if !out.is_empty() && !out.ends_with(' ') { + out.push(' '); + } + continue; + } + out.push(c); + } + if out.len() > TITLE_MAX_BYTES { + let mut cut = TITLE_MAX_BYTES; + while !out.is_char_boundary(cut) { + cut -= 1; + } + out.truncate(cut); + } + // Runs are already collapsed, so at most one trailing space survives + // (possibly exposed by the truncation). + if out.ends_with(' ') { + out.pop(); + } + out +} + +/// A sanitized title plus the alt-screen epoch it was captured in. The title +/// is honored only while its epoch is current: each alt-screen entry starts a +/// new epoch, so a title from the shell (or a previous full-screen app) never +/// labels the app that replaced it. +struct CapturedTitle { + text: String, + alt_epoch: u64, +} + /// Maximum number of zero-width characters retained per cell. This bounds /// the otherwise unbounded vector created by repeated zero-width input. const MAX_ZEROWIDTH: usize = 16; @@ -120,6 +205,19 @@ pub struct Emulator { term: Term, parser: Processor, responses: Arc>>, + /// Written by the listener during an advance, consumed by + /// `observe_advance` afterwards. + title_event: Arc>, + captured_title: Option, + /// Count of alt-screen entries. Compared against + /// `CapturedTitle::alt_epoch` to expire titles at app boundaries. + alt_epoch: u64, + /// Alt bit as of the last bookkeeping step: entry detection needs the + /// previous value, and the mode register only holds the current one. + last_alt_screen: bool, + /// Bumped once per grid advance; cheap change detection for consumers + /// that poll the grid. + revision: u64, /// Bytes ingested since the last zero-width scan. bytes_since_sweep: usize, } @@ -179,6 +277,7 @@ impl Emulator { /// A fresh `rows`×`cols` grid retaining `scrollback` rows of history. pub fn new(rows: u16, cols: u16, scrollback: usize) -> Self { let responses = Arc::new(Mutex::new(Vec::new())); + let title_event = Arc::new(Mutex::new(None)); let config = Config { // Use fleetcom's per-task history limit instead of the backend // default. @@ -191,21 +290,62 @@ impl Emulator { lines: rows as usize, columns: cols as usize, }, - ProbeSink(Arc::clone(&responses)), + ProbeSink { + responses: Arc::clone(&responses), + title_event: Arc::clone(&title_event), + }, ); Self { term, parser: Processor::new(), responses, + title_event, + captured_title: None, + alt_epoch: 0, + last_alt_screen: false, + revision: 0, bytes_since_sweep: 0, } } + /// The shared bookkeeping step behind every grid advance — `process` and + /// both sync-frame landings — so all three paths observe identical state + /// transitions. Ordering is load-bearing: the epoch compare precedes + /// title stamping, so a title anywhere in a chunk that also enters the + /// alt screen lands in the new epoch (children emit the title bytes just + /// before DECSET 1049). + fn observe_advance(&mut self) { + self.revision += 1; + let alt = self.alternate_screen(); + if alt && !self.last_alt_screen { + self.alt_epoch += 1; + } + self.last_alt_screen = alt; + let pending = self + .title_event + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .take(); + if let Some(event) = pending { + // A reset and a title that sanitizes to nothing both unset the + // capture: `printf '\x1b]0;\x07'` clears a title, it does not + // freeze a stale one. + self.captured_title = event + .map(|raw| sanitize_title(&raw)) + .filter(|text| !text.is_empty()) + .map(|text| CapturedTitle { + text, + alt_epoch: self.alt_epoch, + }); + } + } + /// Parse raw child output into the grid. Returns the probe replies the /// backend generated that pass the allowlist, in generation order; the /// caller owns delivering them to the child. pub fn process(&mut self, bytes: &[u8]) -> Vec { self.parser.advance(&mut self.term, bytes); + self.observe_advance(); self.bytes_since_sweep = self.bytes_since_sweep.saturating_add(bytes.len()); if self.bytes_since_sweep >= SWEEP_INTERVAL_BYTES { self.bytes_since_sweep = 0; @@ -231,6 +371,7 @@ impl Emulator { return Vec::new(); } self.parser.stop_sync(&mut self.term); + self.observe_advance(); self.drain_allowed() } @@ -245,6 +386,7 @@ impl Emulator { return Vec::new(); } self.parser.stop_sync(&mut self.term); + self.observe_advance(); self.drain_allowed() } @@ -386,6 +528,68 @@ impl Emulator { } } +/// Capture accessors for the dashboard-preview resolution layer, which lands +/// in a later phase; the allow comes off with its first caller. +#[allow(dead_code)] +impl Emulator { + /// Monotonic count of grid advances. Bumps on every `process` call and + /// on each sync-frame landing; equal reads mean the grid did not advance + /// in between, so a poller can skip re-reading it. + pub fn revision(&self) -> u64 { + self.revision + } + + /// Count of alt-screen entries observed so far. + pub fn alt_epoch(&self) -> u64 { + self.alt_epoch + } + + /// The captured window title, honored only while its alt-screen epoch is + /// current: a title set before the child entered the alt screen, or + /// during a previous alt session, reads as `None`. + pub fn title(&self) -> Option<&str> { + self.captured_title + .as_ref() + .filter(|t| t.alt_epoch == self.alt_epoch) + .map(|t| t.text.as_str()) + } + + /// The last non-blank row of the live screen, trailing padding trimmed; + /// empty when the screen is blank. Ignores the scrollback view offset — + /// `contents` follows `display_offset`, which would make a scrolled-back + /// task preview historical rows instead of live output. + pub fn live_floor(&self) -> String { + let grid = self.term.grid(); + // Rows 0..screen_lines address the live viewport regardless of the + // display offset; only display iteration follows the offset. + for row in (0..grid.screen_lines() as i32).rev() { + let line = &grid[Line(row)]; + let mut text = String::new(); + for col in 0..grid.columns() { + let cell = &line[Column(col)]; + // Spacers have no glyph; terminal tabs occupy visible spaces. + if cell + .flags + .intersects(Flags::WIDE_CHAR_SPACER | Flags::LEADING_WIDE_CHAR_SPACER) + { + continue; + } + text.push(if cell.c == '\t' { ' ' } else { cell.c }); + if let Some(zerowidth) = cell.zerowidth() { + text.extend(zerowidth.iter()); + } + } + while text.ends_with(' ') { + text.pop(); + } + if !text.is_empty() { + return text; + } + } + String::new() + } +} + #[cfg(test)] mod tests { use std::time::Duration; @@ -871,4 +1075,194 @@ mod tests { assert_eq!(line_after, line, "row must not have moved"); assert_eq!(len_after, MAX_ZEROWIDTH); } + + /// C0, DEL, and C1 controls are stripped from titles. + #[test] + fn sanitize_strips_c0_and_c1_controls() { + assert_eq!(sanitize_title("a\x07b\x1bc\u{7f}d\u{9b}e"), "abcde"); + assert_eq!(sanitize_title("\x01\x02\x03"), ""); + } + + /// Each bidi formatting control is stripped individually — the fixed set + /// the sanitizer names, not a general `Cf` sweep. + #[test] + fn sanitize_strips_each_bidi_control() { + let bidi = [ + '\u{061C}', '\u{200E}', '\u{200F}', '\u{202A}', '\u{202B}', '\u{202C}', '\u{202D}', + '\u{202E}', '\u{2066}', '\u{2067}', '\u{2068}', '\u{2069}', + ]; + for c in bidi { + assert_eq!(sanitize_title(&format!("a{c}b")), "ab", "U+{:04X}", c as u32); + } + } + + /// ZWJ (U+200D) must survive: it is format-category like the bidi + /// controls, but stripping it would break joined emoji. + #[test] + fn sanitize_preserves_zwj_sequences() { + let technologist = "\u{1F469}\u{200D}\u{1F4BB}"; + assert_eq!(sanitize_title(technologist), technologist); + } + + /// Whitespace runs collapse to one space and the ends are trimmed. + #[test] + fn sanitize_collapses_and_trims_whitespace() { + assert_eq!(sanitize_title(" a \t\r\n b "), "a b"); + assert_eq!(sanitize_title(" \t "), ""); + } + + /// The byte cap cannot split a UTF-8 sequence: 512 is not a multiple of + /// three, so a stream of three-byte chars must cut at the previous + /// boundary. + #[test] + fn sanitize_caps_on_a_char_boundary() { + let long = "\u{20AC}".repeat(200); // 600 bytes of '€' + let out = sanitize_title(&long); + assert!(out.len() <= TITLE_MAX_BYTES); + assert_eq!(out.len(), 510); + assert_eq!(out.chars().count(), 170); + } + + /// Within one chunk only the last title event matters; an empty OSC title + /// and a ResetTitle (title-stack pop, CSI 23 t) both unset the capture + /// rather than freezing the previous one. + #[test] + fn empty_title_and_reset_unset_the_capture() { + let mut emu = Emulator::new(4, 20, 0); + emu.process(b"\x1b]0;first\x07\x1b]0;second\x07"); + assert_eq!(emu.title(), Some("second"), "last event of a chunk wins"); + emu.process(b"\x1b]0;\x07"); + assert_eq!(emu.title(), None, "an empty title clears, never blanks"); + + // CSI 22 t pushes the pre-title state (no title); popping it back + // with CSI 23 t makes the backend emit ResetTitle. + let mut emu = Emulator::new(4, 20, 0); + emu.process(b"\x1b[22t"); + emu.process(b"\x1b]0;named\x07"); + assert_eq!(emu.title(), Some("named")); + emu.process(b"\x1b[23t"); + assert_eq!(emu.title(), None, "ResetTitle must unset the capture"); + } + + /// A title in the same chunk that enters the alt screen stamps into the + /// new epoch: the epoch compare runs before title consumption, because + /// children emit the title bytes just before DECSET 1049. + #[test] + fn title_entering_alt_in_one_chunk_is_honored() { + let mut emu = Emulator::new(4, 20, 0); + emu.process(b"\x1b]0;app\x07\x1b[?1049h"); + assert_eq!(emu.alt_epoch(), 1); + assert_eq!(emu.title(), Some("app")); + } + + /// A title captured in an earlier chunk predates the alt entry and is + /// not honored once the child enters the alt screen. + #[test] + fn title_before_alt_entry_in_a_prior_chunk_expires() { + let mut emu = Emulator::new(4, 20, 0); + emu.process(b"\x1b]0;shell\x07"); + assert_eq!(emu.title(), Some("shell")); + emu.process(b"\x1b[?1049h"); + assert_eq!(emu.title(), None, "an epoch-0 title cannot label the app"); + } + + /// Each alt entry advances the epoch and expires prior titles; leaving + /// does not advance it, so a title stays honored across the exit. + #[test] + fn each_alt_entry_advances_the_epoch() { + let mut emu = Emulator::new(4, 20, 0); + emu.process(b"\x1b]0;one\x07\x1b[?1049h"); + assert_eq!(emu.alt_epoch(), 1); + emu.process(b"\x1b[?1049l"); + assert_eq!(emu.alt_epoch(), 1, "leaving must not advance the epoch"); + assert_eq!(emu.title(), Some("one"), "epoch still current after exit"); + emu.process(b"\x1b[?1049h"); + assert_eq!(emu.alt_epoch(), 2); + assert_eq!(emu.title(), None, "re-entry expires the previous title"); + } + + /// A title followed by leaving the alt screen in the same chunk reads as + /// primary: the exit keeps the epoch, so the title stamps as current. + #[test] + fn title_leaving_alt_in_one_chunk_reads_as_primary() { + let mut emu = Emulator::new(4, 20, 0); + emu.process(b"\x1b[?1049h"); + assert_eq!(emu.alt_epoch(), 1); + emu.process(b"\x1b]0;done\x07\x1b[?1049l"); + assert!(!emu.alternate_screen()); + assert_eq!(emu.title(), Some("done")); + } + + /// The end-of-life landing runs the same bookkeeping as `process`: a + /// title and alt entry buffered inside a never-closed ?2026 frame must + /// count when `finish_output` lands it. + #[test] + fn finish_output_runs_the_advance_bookkeeping() { + let mut emu = Emulator::new(4, 20, 0); + emu.process(b"\x1b[?2026h\x1b]0;app\x07\x1b[?1049hui"); + let rev = emu.revision(); + assert_eq!(emu.alt_epoch(), 0, "premise: the open frame buffers 1049h"); + assert_eq!(emu.title(), None, "premise: the open frame buffers OSC 0"); + emu.finish_output(); + assert_eq!(emu.revision(), rev + 1, "the landing is a grid advance"); + assert_eq!(emu.alt_epoch(), 1); + assert_eq!(emu.title(), Some("app")); + } + + /// The expired-timeout landing, same premise: sleeping past vte's 150 ms + /// deadline is a minimum-duration wait, so expiry is guaranteed, not + /// raced. + #[test] + fn flush_expired_sync_runs_the_advance_bookkeeping() { + let mut emu = Emulator::new(4, 20, 0); + emu.process(b"\x1b[?2026h\x1b]0;app\x07\x1b[?1049hui"); + let rev = emu.revision(); + std::thread::sleep(Duration::from_millis(200)); + emu.flush_expired_sync(); + assert_eq!(emu.revision(), rev + 1, "the landing is a grid advance"); + assert_eq!(emu.alt_epoch(), 1); + assert_eq!(emu.title(), Some("app")); + } + + /// The revision counts every `process` call; a landing hook that finds + /// no open frame did not advance the grid and must not bump it. + #[test] + fn revision_is_monotonic_and_noop_landings_do_not_bump() { + let mut emu = Emulator::new(4, 20, 0); + assert_eq!(emu.revision(), 0); + emu.process(b"a"); + assert_eq!(emu.revision(), 1); + emu.process(b"b"); + assert_eq!(emu.revision(), 2); + emu.flush_expired_sync(); + assert_eq!(emu.revision(), 2, "no open frame: nothing advanced"); + emu.finish_output(); + assert_eq!(emu.revision(), 2, "no open frame: nothing advanced"); + } + + /// `live_floor` reads the live grid's last non-blank row even while the + /// viewport is scrolled back; `contents` follows the offset instead. + #[test] + fn live_floor_ignores_the_scrollback_offset() { + let mut emu = Emulator::new(4, 10, 100); + for i in 0..12 { + emu.process(format!("l{i}\r\n").as_bytes()); + } + emu.process(b"latest"); + assert_eq!(emu.live_floor(), "latest"); + emu.set_scrollback(usize::MAX); + assert!( + emu.contents().starts_with("l0"), + "premise: the view shows history" + ); + assert!(!emu.contents().contains("latest")); + assert_eq!(emu.live_floor(), "latest", "the floor must not follow the view"); + } + + /// A blank screen has no floor. + #[test] + fn live_floor_of_a_blank_screen_is_empty() { + let emu = Emulator::new(4, 10, 0); + assert_eq!(emu.live_floor(), ""); + } } From 019c037c43832d6c1d6a0756917495785518dd71 Mon Sep 17 00:00:00 2001 From: Christopher Sardegna Date: Sun, 19 Jul 2026 21:43:19 -0700 Subject: [PATCH 04/11] feat: resolve dashboard previews through the provenance cascade --- src/main.rs | 2 + src/preview.rs | 656 +++++++++++++++++++++++++++++++++++++++ src/protocol.rs | 144 ++++++++- src/supervisor.rs | 49 +-- src/task.rs | 130 +++++++- src/terminal/emulator.rs | 5 +- src/ui.rs | 92 +++++- 7 files changed, 1023 insertions(+), 55 deletions(-) create mode 100644 src/preview.rs diff --git a/src/main.rs b/src/main.rs index 07409b8..6bd2387 100644 --- a/src/main.rs +++ b/src/main.rs @@ -17,6 +17,8 @@ mod golden; // Agent-CLI session capture: the supervisor instruments spawns through it. mod harness; mod path; +// Dashboard-preview resolution: the provenance cascade over emulator facts. +mod preview; mod protocol; mod session; mod supervisor; diff --git a/src/preview.rs b/src/preview.rs new file mode 100644 index 0000000..1d31fd8 --- /dev/null +++ b/src/preview.rs @@ -0,0 +1,656 @@ +//! Dashboard-preview resolution: one [`Preview`] per task, chosen by a +//! provenance cascade over facts the emulator already tracks and debounced +//! through hold timers so the column changes only when meaning changes. +//! Liveness and outcome stay with the glyph and age columns. + +use std::time::{Duration, Instant}; + +use crate::emulator::Emulator; + +// Both holds are wall-clock: tick spacing varies ≈25× (8 ms frame minimum +// under load, 200 ms idle backstop), so a tick-counted debounce would be +// load-dependent. + +/// Minimum spacing between rendered title changes: above the spinner cadence +/// of title-animating children, below reading annoyance. +pub const TITLE_MIN_HOLD: Duration = Duration::from_millis(500); + +/// Continuous absence of a rendered-rank-or-better candidate before a +/// demotion commits: more than one 200 ms quiet-tick gap with margin, so a +/// single-tick transient (a partial repaint, a held sync frame) never demotes +/// the rendered preview. +pub const DEMOTION_HOLD: Duration = Duration::from_millis(600); + +/// Preview text for an alternate-screen child with no usable title. +pub const MARKER: &str = "full-screen"; + +/// Where a preview's text came from. Declared in ascending authority so the +/// derived `Ord` ranks provenance directly: `Anchor > Title > Marker > +/// Floor`. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] +pub enum PreviewSource { + /// The last non-blank row of the live screen: unshadowable for + /// primary-screen programs, so a title never replaces live stream output. + Floor, + /// The alternate screen is active with no usable title. + Marker, + /// The child's window title, honored only on the alternate screen. + Title, + /// Normalized adapter output: the cascade's top tier. No producer exists + /// this phase; adapters land later and fill the slot. + Anchor, +} + +impl PreviewSource { + /// Lowercase label shared by the wire encoding and the peek footer. + pub fn label(self) -> &'static str { + match self { + PreviewSource::Floor => "floor", + PreviewSource::Marker => "marker", + PreviewSource::Title => "title", + PreviewSource::Anchor => "anchor", + } + } +} + +/// One resolved preview. `frozen` is mutability, orthogonal to `source`: a +/// finished task reports its last source with `frozen: true`, which a +/// `Frozen` variant would erase. `rule` is a fine-grained matcher id; no +/// producer sets it this phase (adapters come later), and it stays +/// daemon-side — the peek footer sees it only in-process, never over the +/// wire. +#[derive(Debug, Clone, PartialEq)] +pub struct Preview { + pub text: String, + pub source: PreviewSource, + pub rule: Option<&'static str>, + pub frozen: bool, +} + +impl Preview { + fn floor(text: String) -> Preview { + Preview { + text, + source: PreviewSource::Floor, + rule: None, + frozen: false, + } + } +} + +/// The emulator facts one resolution step reads. A trait so unit tests +/// resolve against synthetic screens without a PTY. +pub trait ScreenFacts { + fn revision(&self) -> u64; + fn alt_epoch(&self) -> u64; + fn alternate_screen(&self) -> bool; + fn title(&self) -> Option<&str>; + fn live_floor(&self) -> String; +} + +impl ScreenFacts for Emulator { + fn revision(&self) -> u64 { + Emulator::revision(self) + } + + fn alt_epoch(&self) -> u64 { + Emulator::alt_epoch(self) + } + + fn alternate_screen(&self) -> bool { + Emulator::alternate_screen(self) + } + + fn title(&self) -> Option<&str> { + Emulator::title(self) + } + + fn live_floor(&self) -> String { + Emulator::live_floor(self) + } +} + +/// The instantaneous candidate. Every branch is a fact the emulator tracks; +/// nothing is fabricated: +/// 1. [anchor slot — no producer this phase; adapters claim the top tier] +/// 2. alternate screen: the title while its epoch is current, else the marker +/// 3. primary screen: the live floor +fn cascade(screen: &impl ScreenFacts) -> Preview { + if screen.alternate_screen() { + return match screen.title() { + Some(text) => Preview { + text: text.to_string(), + source: PreviewSource::Title, + rule: None, + frozen: false, + }, + None => Preview { + text: MARKER.to_string(), + source: PreviewSource::Marker, + rule: None, + frozen: false, + }, + }; + } + Preview::floor(screen.live_floor()) +} + +/// Candidate-recompute key: `(revision, alt epoch, alt bit, title, finished)`. +/// Titles and the alt bit cannot change without a revision bump (the +/// emulator's advance bookkeeping guarantees it), but the composite is cheap +/// and self-documenting. +type ResolveKey = (u64, u64, bool, Option, bool); + +/// Per-task resolution state. Lives on `Task` and resets with it: a rerun +/// replaces the whole `Task`, so run-scoped state needs no external map. +#[derive(Debug)] +pub struct PreviewState { + rendered: Preview, + /// Last cascade output, carried while the resolution key is unchanged. + candidate: Preview, + /// Latest demoted candidate while the demotion hold runs. + pending_candidate: Option, + /// Start of the demotion hold: the first resolution whose candidate + /// ranked below the rendered source. Pending-candidate changes never + /// reset it — the timer measures continuous absence of + /// rendered-or-higher, so a flapping demoted candidate cannot postpone + /// the commit forever. + downgrade_pending_since: Option, + /// Instant of the last rendered title: the min-hold deadline base. + last_title_render: Option, + last_key: Option, + /// Alt bit at the most recent live resolution; `finalize`'s teardown + /// predicate compares it against the final screen. + last_resolve_alt: bool, + finalized: bool, +} + +impl Default for PreviewState { + fn default() -> PreviewState { + PreviewState::new() + } +} + +impl PreviewState { + pub fn new() -> PreviewState { + let empty = Preview::floor(String::new()); + PreviewState { + rendered: empty.clone(), + candidate: empty, + pending_candidate: None, + downgrade_pending_since: None, + last_title_render: None, + last_key: None, + last_resolve_alt: false, + finalized: false, + } + } + + /// Whether the preview froze: the caller's once-per-life finalize gate. + pub fn finalized(&self) -> bool { + self.finalized + } + + /// Resolve the rendered preview against the current screen. `now` is a + /// parameter, never read internally, so tests drive the holds with + /// synthetic instants. The candidate is recomputed only when the + /// resolution key changed; hold expiries commit the carried value + /// without a rescan. + pub fn resolve( + &mut self, + now: Instant, + finished: bool, + screen: &impl ScreenFacts, + ) -> &Preview { + if self.finalized { + return &self.rendered; + } + let key = ( + screen.revision(), + screen.alt_epoch(), + screen.alternate_screen(), + screen.title().map(str::to_owned), + finished, + ); + if self.last_key.as_ref() != Some(&key) { + self.candidate = cascade(screen); + self.last_key = Some(key); + } + self.last_resolve_alt = screen.alternate_screen(); + self.step(now); + &self.rendered + } + + /// One pass of the candidate-vs-rendered transition table. + fn step(&mut self, now: Instant) { + use std::cmp::Ordering; + match self.candidate.source.cmp(&self.rendered.source) { + Ordering::Greater => { + self.cancel_demotion(); + let cand = self.candidate.clone(); + self.render(cand, now); + } + Ordering::Equal => { + // Rank ≥ rendered: a pending demotion was a one-tick repaint + // miss; recover with no visible change. + self.cancel_demotion(); + if self.candidate == self.rendered { + return; + } + match self.candidate.source { + // Static text: same source, same value. + PreviewSource::Marker => {} + // Min-hold: at most one rendered title change per + // `TITLE_MIN_HOLD`, the deadline fixed from the last + // render; the newest carried candidate wins at expiry. + PreviewSource::Title => { + let held = self + .last_title_render + .is_some_and(|t| now.duration_since(t) < TITLE_MIN_HOLD); + if !held { + let cand = self.candidate.clone(); + self.render(cand, now); + } + } + // The floor is live output; anchor text changes are + // semantic (unreachable until adapters land). Both + // render immediately. + PreviewSource::Floor | PreviewSource::Anchor => { + let cand = self.candidate.clone(); + self.render(cand, now); + } + } + } + Ordering::Less => { + let since = *self.downgrade_pending_since.get_or_insert(now); + self.pending_candidate = Some(self.candidate.clone()); + if now.duration_since(since) >= DEMOTION_HOLD + && let Some(latest) = self.pending_candidate.take() + { + self.downgrade_pending_since = None; + self.render(latest, now); + } + } + } + } + + /// Commit `p` as the rendered preview; title renders stamp the min-hold + /// deadline base. + fn render(&mut self, p: Preview, now: Instant) { + if p.source == PreviewSource::Title { + self.last_title_render = Some(now); + } + self.rendered = p; + } + + fn cancel_demotion(&mut self) { + self.pending_candidate = None; + self.downgrade_pending_since = None; + } + + /// Freeze the preview once the task's output is complete. Re-resolves + /// the cascade against the final screen instead of freezing the last + /// rendered value: output can land between the last resolution tick and + /// output-complete (a stream's final `test result: ok` flush), and the + /// final resolve must see it. One carve-out, `alt_torn_down_at_exit`: + /// the task was on the alternate screen at its last live resolution and + /// the final screen is primary, so the exit's 1049l restored pre-launch + /// junk (alt-screen agent CLIs print nothing afterward) and the last + /// rendered preview freezes instead — a stale but meaningful line under + /// a truthful outcome glyph beats shell junk. Holds do not apply: + /// finalization overrides the whole transition table. Idempotent; later + /// resolutions short-circuit to the frozen value. + pub fn finalize(&mut self, screen: &impl ScreenFacts) { + if self.finalized { + return; + } + self.finalized = true; + self.cancel_demotion(); + let alt_torn_down_at_exit = self.last_resolve_alt && !screen.alternate_screen(); + if alt_torn_down_at_exit { + self.rendered.frozen = true; + return; + } + let mut fin = cascade(screen); + fin.frozen = true; + self.rendered = fin; + } +} + +#[cfg(test)] +mod tests { + use std::cell::Cell; + + use super::*; + + /// Synthetic screen facts with a floor-read counter for the + /// revision-gate test. + struct FakeScreen { + revision: u64, + alt_epoch: u64, + alt: bool, + title: Option, + floor: String, + floor_calls: Cell, + } + + impl FakeScreen { + fn primary(floor: &str) -> FakeScreen { + FakeScreen { + revision: 1, + alt_epoch: 0, + alt: false, + title: None, + floor: floor.into(), + floor_calls: Cell::new(0), + } + } + + /// Bump the revision alone: the emulator counts every grid advance. + fn advance(&mut self) { + self.revision += 1; + } + + fn enter_alt(&mut self) { + self.alt = true; + self.alt_epoch += 1; + self.advance(); + } + + fn leave_alt(&mut self) { + self.alt = false; + self.advance(); + } + + fn set_title(&mut self, t: &str) { + self.title = Some(t.into()); + self.advance(); + } + + fn clear_title(&mut self) { + self.title = None; + self.advance(); + } + + fn set_floor(&mut self, f: &str) { + self.floor = f.into(); + self.advance(); + } + } + + impl ScreenFacts for FakeScreen { + fn revision(&self) -> u64 { + self.revision + } + + fn alt_epoch(&self) -> u64 { + self.alt_epoch + } + + fn alternate_screen(&self) -> bool { + self.alt + } + + fn title(&self) -> Option<&str> { + self.title.as_deref() + } + + fn live_floor(&self) -> String { + self.floor_calls.set(self.floor_calls.get() + 1); + self.floor.clone() + } + } + + /// Each cascade tier maps one emulator fact to one source. + #[test] + fn cascade_maps_screen_facts_to_sources() { + let now = Instant::now(); + + let mut st = PreviewState::new(); + let s = FakeScreen::primary("last row"); + let p = st.resolve(now, false, &s).clone(); + assert_eq!( + (p.text.as_str(), p.source, p.frozen), + ("last row", PreviewSource::Floor, false) + ); + + let mut st = PreviewState::new(); + let mut s = FakeScreen::primary("shell"); + s.enter_alt(); + s.set_title("app"); + let p = st.resolve(now, false, &s).clone(); + assert_eq!((p.text.as_str(), p.source), ("app", PreviewSource::Title)); + + let mut st = PreviewState::new(); + let mut s = FakeScreen::primary("shell"); + s.enter_alt(); + let p = st.resolve(now, false, &s).clone(); + assert_eq!((p.text.as_str(), p.source), (MARKER, PreviewSource::Marker)); + + let mut st = PreviewState::new(); + let s = FakeScreen::primary(""); + let p = st.resolve(now, false, &s).clone(); + assert_eq!((p.text.as_str(), p.source), ("", PreviewSource::Floor)); + } + + /// Rank increases render on the very resolution that observes them. + #[test] + fn promotion_renders_immediately() { + let now = Instant::now(); + let mut st = PreviewState::new(); + let mut s = FakeScreen::primary("building"); + assert_eq!(st.resolve(now, false, &s).source, PreviewSource::Floor); + + s.enter_alt(); + let p = st.resolve(now, false, &s).clone(); + assert_eq!((p.text.as_str(), p.source), (MARKER, PreviewSource::Marker)); + + s.set_title("app"); + let p = st.resolve(now, false, &s).clone(); + assert_eq!((p.text.as_str(), p.source), ("app", PreviewSource::Title)); + } + + /// A demotion holds the rendered preview through `DEMOTION_HOLD` and + /// commits at expiry. + #[test] + fn demotion_commits_only_after_the_hold() { + let t0 = Instant::now(); + let mut st = PreviewState::new(); + let mut s = FakeScreen::primary("shell"); + s.enter_alt(); + s.set_title("app"); + st.resolve(t0, false, &s); + + s.clear_title(); + assert_eq!( + st.resolve(t0, false, &s).source, + PreviewSource::Title, + "a demotion must not render instantly" + ); + let inside = t0 + DEMOTION_HOLD - Duration::from_millis(1); + assert_eq!(st.resolve(inside, false, &s).source, PreviewSource::Title); + let p = st.resolve(t0 + DEMOTION_HOLD, false, &s).clone(); + assert_eq!((p.text.as_str(), p.source), (MARKER, PreviewSource::Marker)); + } + + /// A rank≥ candidate inside the hold cancels the pending demotion with + /// no visible change, and a fresh demotion gets its own full window. + #[test] + fn rank_recovery_inside_the_hold_cancels_pending() { + let t0 = Instant::now(); + let ms = Duration::from_millis; + let mut st = PreviewState::new(); + let mut s = FakeScreen::primary("shell"); + s.enter_alt(); + s.set_title("app"); + st.resolve(t0, false, &s); + + s.clear_title(); + st.resolve(t0, false, &s); + // The title returns inside the hold: cancel, no visible change. + s.set_title("app"); + let p = st.resolve(t0 + ms(300), false, &s).clone(); + assert_eq!((p.text.as_str(), p.source), ("app", PreviewSource::Title)); + + // The next demotion measures from its own start, not the old stamp. + s.clear_title(); + st.resolve(t0 + ms(400), false, &s); + assert_eq!( + st.resolve(t0 + ms(900), false, &s).source, + PreviewSource::Title, + "the canceled hold must not shorten the fresh one" + ); + assert_eq!( + st.resolve(t0 + ms(1_000), false, &s).source, + PreviewSource::Marker + ); + } + + /// A flapping pending candidate does not reset the demotion timer, and + /// the latest stored candidate commits at expiry. + #[test] + fn flapping_pending_keeps_the_timer_and_commits_the_latest() { + let t0 = Instant::now(); + let mut st = PreviewState::new(); + let mut s = FakeScreen::primary("shell"); + s.enter_alt(); + s.set_title("app"); + st.resolve(t0, false, &s); + + // First demoted candidate: the marker. + s.clear_title(); + st.resolve(t0, false, &s); + // The pending candidate flaps to a floor; the timer keeps t0. + s.leave_alt(); + s.set_floor("done 3 tests"); + assert_eq!( + st.resolve(t0 + Duration::from_millis(300), false, &s).source, + PreviewSource::Title + ); + let p = st.resolve(t0 + DEMOTION_HOLD, false, &s).clone(); + assert_eq!( + (p.text.as_str(), p.source), + ("done 3 tests", PreviewSource::Floor), + "the latest pending candidate commits, not the first" + ); + } + + /// Two title changes inside `TITLE_MIN_HOLD` render nothing; the newest + /// carried candidate wins at the deadline. + #[test] + fn title_changes_render_at_most_once_per_min_hold() { + let t0 = Instant::now(); + let ms = Duration::from_millis; + let mut st = PreviewState::new(); + let mut s = FakeScreen::primary("shell"); + s.enter_alt(); + s.set_title("one"); + assert_eq!(st.resolve(t0, false, &s).text, "one"); + + s.set_title("two"); + assert_eq!(st.resolve(t0 + ms(200), false, &s).text, "one"); + s.set_title("three"); + assert_eq!(st.resolve(t0 + ms(300), false, &s).text, "one"); + assert_eq!( + st.resolve(t0 + TITLE_MIN_HOLD, false, &s).text, + "three", + "the newest candidate wins at the deadline" + ); + } + + /// Same-source floor text is live output: it renders immediately. + #[test] + fn floor_text_renders_immediately() { + let t0 = Instant::now(); + let mut st = PreviewState::new(); + let mut s = FakeScreen::primary("compiling foo"); + assert_eq!(st.resolve(t0, false, &s).text, "compiling foo"); + s.set_floor("compiling bar"); + assert_eq!(st.resolve(t0, false, &s).text, "compiling bar"); + } + + /// An unchanged resolution key carries the candidate without re-reading + /// the grid; a revision bump recomputes. + #[test] + fn unchanged_key_skips_the_candidate_recompute() { + let t0 = Instant::now(); + let ms = Duration::from_millis; + let mut st = PreviewState::new(); + let mut s = FakeScreen::primary("steady"); + st.resolve(t0, false, &s); + assert_eq!(s.floor_calls.get(), 1); + + st.resolve(t0 + ms(200), false, &s); + assert_eq!(s.floor_calls.get(), 1, "unchanged key must not re-read"); + + s.advance(); + st.resolve(t0 + ms(400), false, &s); + assert_eq!(s.floor_calls.get(), 2, "a revision bump must recompute"); + } + + /// Primary-at-exit finalization re-resolves: output that landed after + /// the last resolution tick reaches the frozen preview. + #[test] + fn finalize_re_resolves_a_primary_screen() { + let t0 = Instant::now(); + let mut st = PreviewState::new(); + let mut s = FakeScreen::primary("running"); + st.resolve(t0, false, &s); + + s.set_floor("test result: ok"); + st.finalize(&s); + let p = st.resolve(t0, true, &s).clone(); + assert_eq!( + (p.text.as_str(), p.source, p.frozen), + ("test result: ok", PreviewSource::Floor, true) + ); + } + + /// Alt torn down at exit: the restored primary junk must not replace the + /// last rendered preview, and the frozen value never moves again. + #[test] + fn finalize_keeps_the_rendered_preview_across_alt_teardown() { + let t0 = Instant::now(); + let mut st = PreviewState::new(); + let mut s = FakeScreen::primary("prelaunch junk"); + s.enter_alt(); + s.set_title("agent: working"); + st.resolve(t0, false, &s); + + // The exit's 1049l lands with no live resolution in between. + s.leave_alt(); + st.finalize(&s); + let p = st.resolve(t0, true, &s).clone(); + assert_eq!( + (p.text.as_str(), p.source, p.frozen), + ("agent: working", PreviewSource::Title, true) + ); + + s.set_floor("stray"); + assert_eq!( + st.resolve(t0 + Duration::from_secs(5), true, &s).text, + "agent: working", + "resolution must short-circuit to the frozen value" + ); + } + + /// Death on the alt screen is not a teardown: the final alt screen is + /// authoritative and freezes as-is, catching a last-moment title change. + #[test] + fn finalize_on_the_alt_screen_freezes_the_final_cascade() { + let t0 = Instant::now(); + let mut st = PreviewState::new(); + let mut s = FakeScreen::primary("shell"); + s.enter_alt(); + s.set_title("step 1"); + st.resolve(t0, false, &s); + + s.set_title("step 2: done"); + st.finalize(&s); + let p = st.resolve(t0, true, &s).clone(); + assert_eq!( + (p.text.as_str(), p.source, p.frozen), + ("step 2: done", PreviewSource::Title, true) + ); + } +} diff --git a/src/protocol.rs b/src/protocol.rs index 738ef86..4f18575 100644 --- a/src/protocol.rs +++ b/src/protocol.rs @@ -9,7 +9,10 @@ use std::{ use base64::{Engine as _, engine::general_purpose::STANDARD as B64}; -use crate::frame::{KIND_CONTROL, KIND_HELLO, KIND_SCREEN}; +use crate::{ + frame::{KIND_CONTROL, KIND_HELLO, KIND_SCREEN}, + preview::PreviewSource, +}; /// Wire-protocol version; the handshake rejects mismatched peers. pub const PROTOCOL_VERSION: u32 = 8; @@ -221,6 +224,14 @@ pub struct TaskView { /// idle threshold; `false` once finished. pub parked: bool, pub preview: String, + /// Provenance of `preview` (see [`PreviewSource`]). + pub source: PreviewSource, + /// Whether the preview froze at output-complete and can no longer change. + pub frozen: bool, + /// Fine-grained matcher id behind an adapter-produced preview. Never + /// encoded: an in-process core hands it to the peek footer directly, and + /// a socket frame decodes it as `None`. + pub rule: Option<&'static str>, pub started_ago: Duration, /// Time since the last PTY output; `Some` only while the task is live. pub quiet_ago: Option, @@ -356,6 +367,17 @@ fn lifecycle_from(s: &str) -> Option { } } +// Encoding uses `PreviewSource::label`; only the decode direction lives here. +fn source_from(s: &str) -> Option { + match s { + "floor" => Some(PreviewSource::Floor), + "marker" => Some(PreviewSource::Marker), + "title" => Some(PreviewSource::Title), + "anchor" => Some(PreviewSource::Anchor), + _ => None, + } +} + /// Serialize a launch context as a `KIND_HELLO` frame. pub fn encode_hello(ctx: &LaunchContext) -> (u8, Vec) { let mut o = jzon::JsonValue::new_object(); @@ -731,6 +753,9 @@ pub fn encode_event(ev: &Event) -> (u8, Vec) { insert_opt_str(&mut o, "name", &tv.name); let _ = o.insert("life", lifecycle_str(tv.lifecycle)); let _ = o.insert("preview", tv.preview.as_str()); + // `rule` deliberately stays off the wire (see `TaskView`). + let _ = o.insert("src", tv.source.label()); + let _ = o.insert("frozen", tv.frozen); let _ = o.insert("started_ms", tv.started_ago.as_millis() as u64); let _ = o.insert("parked", tv.parked); // Each age exists in exactly one phase: `quiet_ms` while @@ -808,6 +833,19 @@ pub fn decode_event(kind: u8, payload: &[u8]) -> Option { } else { tv["parked"].as_bool()? }; + // Pre-preview daemons omit both keys: fall back to + // the floor default, never to a dropped frame, + // exactly like `parked` above. + let source = if tv["src"].is_null() { + PreviewSource::Floor + } else { + source_from(tv["src"].as_str()?)? + }; + let frozen = if tv["frozen"].is_null() { + false + } else { + tv["frozen"].as_bool()? + }; views.push(TaskView { id: tv["id"].as_u64()?, command: tv["command"].as_str()?.to_string(), @@ -819,6 +857,9 @@ pub fn decode_event(kind: u8, payload: &[u8]) -> Option { lifecycle, parked, preview: tv["preview"].as_str()?.to_string(), + source, + frozen, + rule: None, started_ago: Duration::from_millis(tv["started_ms"].as_u64()?), // Absent from pre-`parked` daemons: unknown, not zero. quiet_ago: opt_ms(&tv["quiet_ms"])?, @@ -1168,6 +1209,9 @@ mod tests { lifecycle: Lifecycle::Idle, parked: false, preview: "~ line".into(), + source: PreviewSource::Title, + frozen: false, + rule: None, started_ago: Duration::from_millis(4200), quiet_ago: Some(Duration::from_millis(700)), finished_ago: None, @@ -1183,6 +1227,9 @@ mod tests { lifecycle: Lifecycle::Active, parked: false, preview: String::new(), + source: PreviewSource::Floor, + frozen: false, + rule: None, started_ago: Duration::from_millis(10), quiet_ago: None, finished_ago: None, @@ -1256,7 +1303,7 @@ mod tests { #[test] fn tasks_frame_group_key_is_optional() { // "Lw==" is the base64 encoding of "/". - let ungrouped = r#"{"t":"tasks","tasks":[{"id":1,"command":"x","cwd":"Lw==","tagged":false,"life":"ok","preview":"","started_ms":0,"parked":false}]}"#; + let ungrouped = r#"{"t":"tasks","tasks":[{"id":1,"command":"x","cwd":"Lw==","tagged":false,"life":"ok","preview":"","src":"floor","frozen":false,"started_ms":0,"parked":false}]}"#; match decode_event(KIND_CONTROL, ungrouped.as_bytes()) { Some(Event::Tasks(v)) => assert_eq!(v[0].group, None), other => panic!("expected tasks event, got {other:?}"), @@ -1272,6 +1319,9 @@ mod tests { lifecycle: Lifecycle::Ok, parked: false, preview: String::new(), + source: PreviewSource::Floor, + frozen: false, + rule: None, started_ago: Duration::from_millis(0), quiet_ago: None, finished_ago: None, @@ -1290,7 +1340,7 @@ mod tests { #[test] fn tasks_frame_name_key_is_optional() { // "Lw==" is the base64 encoding of "/". - let unnamed = r#"{"t":"tasks","tasks":[{"id":1,"command":"x","cwd":"Lw==","tagged":false,"life":"ok","preview":"","started_ms":0,"parked":false}]}"#; + let unnamed = r#"{"t":"tasks","tasks":[{"id":1,"command":"x","cwd":"Lw==","tagged":false,"life":"ok","preview":"","src":"floor","frozen":false,"started_ms":0,"parked":false}]}"#; match decode_event(KIND_CONTROL, unnamed.as_bytes()) { Some(Event::Tasks(v)) => assert_eq!(v[0].name, None), other => panic!("expected tasks event, got {other:?}"), @@ -1306,6 +1356,9 @@ mod tests { lifecycle: Lifecycle::Ok, parked: false, preview: String::new(), + source: PreviewSource::Floor, + frozen: false, + rule: None, started_ago: Duration::from_millis(0), quiet_ago: None, finished_ago: None, @@ -1335,6 +1388,9 @@ mod tests { lifecycle: Lifecycle::Idle, parked: true, preview: String::new(), + source: PreviewSource::Floor, + frozen: false, + rule: None, started_ago: Duration::from_millis(60_000), quiet_ago: Some(Duration::from_millis(12_000)), finished_ago: None, @@ -1349,6 +1405,9 @@ mod tests { lifecycle: Lifecycle::Ok, parked: false, preview: String::new(), + source: PreviewSource::Floor, + frozen: false, + rule: None, started_ago: Duration::from_millis(60_000), quiet_ago: None, finished_ago: Some(Duration::from_millis(3_000)), @@ -1379,6 +1438,85 @@ mod tests { } } + /// Every preview source round-trips with its frozen flag, and `rule` + /// never crosses the wire: an encoded `Some` decodes as `None`. + #[test] + fn preview_source_and_frozen_round_trip() { + let base = TaskView { + id: 1, + command: "x".into(), + cwd: PathBuf::from("/"), + tagged: false, + group: None, + name: None, + lifecycle: Lifecycle::Active, + parked: false, + preview: "p".into(), + source: PreviewSource::Floor, + frozen: false, + rule: None, + started_ago: Duration::from_millis(0), + quiet_ago: Some(Duration::from_millis(1)), + finished_ago: None, + }; + let tasks = Event::Tasks(vec![ + base.clone(), + TaskView { + id: 2, + source: PreviewSource::Marker, + ..base.clone() + }, + TaskView { + id: 3, + source: PreviewSource::Title, + frozen: true, + ..base.clone() + }, + TaskView { + id: 4, + source: PreviewSource::Anchor, + ..base.clone() + }, + ]); + let (k, p) = encode_event(&tasks); + assert_eq!(decode_event(k, &p), Some(tasks)); + + // A daemon-side rule is dropped by encoding, not carried. + let ruled = Event::Tasks(vec![TaskView { + source: PreviewSource::Anchor, + rule: Some("claude-status"), + ..base.clone() + }]); + let (k, p) = encode_event(&ruled); + assert!(!String::from_utf8(p.clone()).unwrap().contains("claude-status")); + match decode_event(k, &p) { + Some(Event::Tasks(v)) => { + assert_eq!(v[0].source, PreviewSource::Anchor); + assert_eq!(v[0].rule, None, "rule must stay daemon-side"); + } + other => panic!("expected tasks event, got {other:?}"), + } + } + + /// A frame from a daemon predating the preview fields decodes with the + /// floor default, exactly like the `parked` fallback: skew degrades the + /// provenance, never the frame. + #[test] + fn tasks_frame_without_preview_keys_decodes_with_floor_defaults() { + let old = r#"{"t":"tasks","tasks":[{"id":1,"command":"x","cwd":"Lw==","tagged":false,"life":"active","preview":"p","started_ms":0,"parked":false}]}"#; + match decode_event(KIND_CONTROL, old.as_bytes()) { + Some(Event::Tasks(v)) => { + assert_eq!(v[0].source, PreviewSource::Floor); + assert!(!v[0].frozen); + assert_eq!(v[0].rule, None); + } + other => panic!("expected tasks event, got {other:?}"), + } + // A present source must be a known label. + let bad = r#"{"t":"tasks","tasks":[{"id":1,"command":"x","cwd":"Lw==","tagged":false,"life":"active","preview":"p","src":"vibes","frozen":false,"started_ms":0,"parked":false}]}"#; + assert_eq!(decode_event(KIND_CONTROL, bad.as_bytes()), None); + } + /// `Sessions` carries the picker's names verbatim: several names, an empty /// list, and names with spaces and non-ASCII all round-trip. #[test] diff --git a/src/supervisor.rs b/src/supervisor.rs index 8515b38..dc61293 100644 --- a/src/supervisor.rs +++ b/src/supervisor.rs @@ -354,6 +354,10 @@ impl Supervisor { // Scrape after process exit and reader EOF, when every child byte // is present in the grid (see `Task::scrape_exit_hint`). t.scrape_exit_hint(); + // Same gate: freeze the preview once output is complete, off the + // final screen rather than the last-rendered value (see + // `Task::finalize_preview`). + t.finalize_preview(); if t.overdue(now, self.kill_grace) { t.force_kill(); } @@ -409,32 +413,37 @@ impl Supervisor { /// `drain`ed events, never a `Task`. pub fn tick(&mut self) { self.reap(); + let now = Instant::now(); // vte re-checks its ?2026 sync timeout only when bytes arrive, so a // child that opens BSU and stalls would freeze its view. This tick is // the loop's only periodic path (the idle backstop guarantees one at // least every 200 ms), so an expired sync flushes here, before the - // snapshot below reads the grids, letting the same tick ship it. - for t in &self.tasks { - t.flush_expired_sync(); - } - let now = Instant::now(); - + // preview resolve reads the grid, letting the same tick ship it. + // Iteration is `&mut` because preview resolution mutates per-task + // hold state; every task resolves against the one `now` above. let views = self .tasks - .iter() - .map(|t| TaskView { - id: t.id, - command: t.command.clone(), - cwd: t.cwd.clone(), - tagged: t.tagged, - group: t.group.clone(), - name: t.name.clone(), - lifecycle: t.lifecycle(now, IDLE_AFTER), - parked: t.parked(now, SORT_IDLE_AFTER), - preview: t.preview(), - started_ago: now.duration_since(t.started), - quiet_ago: t.finished.is_none().then(|| t.quiet_for(now)), - finished_ago: t.finished.map(|f| now.duration_since(f)), + .iter_mut() + .map(|t| { + t.flush_expired_sync(); + let preview = t.resolve_preview(now); + TaskView { + id: t.id, + command: t.command.clone(), + cwd: t.cwd.clone(), + tagged: t.tagged, + group: t.group.clone(), + name: t.name.clone(), + lifecycle: t.lifecycle(now, IDLE_AFTER), + parked: t.parked(now, SORT_IDLE_AFTER), + preview: preview.text, + source: preview.source, + frozen: preview.frozen, + rule: preview.rule, + started_ago: now.duration_since(t.started), + quiet_ago: t.finished.is_none().then(|| t.quiet_for(now)), + finished_ago: t.finished.map(|f| now.duration_since(f)), + } }) .collect(); self.events.push(Event::Tasks(views)); diff --git a/src/task.rs b/src/task.rs index 94a1104..964352d 100644 --- a/src/task.rs +++ b/src/task.rs @@ -24,6 +24,7 @@ use rustix::process::{WaitId, WaitIdOptions, waitid}; use crate::{ core::{Wake, Waker}, emulator::Emulator, + preview::{Preview, PreviewState}, protocol::{Key, Lifecycle, Mods, MouseKind, ScrollAction, env_get}, }; @@ -350,6 +351,9 @@ pub struct Task { pub scraped_id: Option, /// Whether the one-shot full-history exit scrape has run. scraped: bool, + /// Dashboard-preview resolution state; resets with the task on rerun + /// because a rerun replaces the whole `Task`. + preview: PreviewState, /// Wall-clock spawn time used for filesystem correlation. pub spawned_at: SystemTime, exit_code: Option, @@ -579,6 +583,7 @@ impl Task { capture_file: None, scraped_id: None, scraped: false, + preview: PreviewState::new(), spawned_at: SystemTime::now(), exit_code: None, started: Instant::now(), @@ -613,14 +618,20 @@ impl Task { Ok(()) } + /// Whether the child exited and the PTY reader reached EOF, so no more + /// bytes can ever reach the grid. A missing reader handle counts as + /// complete. The reader ends on `Ok(0) | Err(_)` without distinguishing + /// clean EOF from a read error, so the name claims completeness, not + /// cleanliness. + pub(crate) fn output_complete(&self) -> bool { + self.finished.is_some() && self.handle.as_ref().is_none_or(JoinHandle::is_finished) + } + /// Scrape at most one exit hint after the process exits and the PTY reader - /// reaches EOF. A missing reader handle counts as complete. + /// reaches EOF (see [`Task::output_complete`]). pub(crate) fn scrape_exit_hint(&mut self) { let Some(h) = self.harness else { return }; - if self.scraped - || self.finished.is_none() - || self.handle.as_ref().is_some_and(|jh| !jh.is_finished()) - { + if self.scraped || !self.output_complete() { return; } self.scraped = true; @@ -730,15 +741,26 @@ impl Task { } } - /// The dashboard preview line: the last non-blank row of the live screen. - pub fn preview(&self) -> String { - grid(&self.parser) - .contents() - .lines() - .rev() - .find(|l| !l.trim().is_empty()) - .unwrap_or("") - .to_string() + /// The dashboard preview, resolved through the provenance cascade under + /// the grid lock (see [`crate::preview`]). `now` is the caller's tick + /// instant so every task in one snapshot resolves against the same clock. + pub fn resolve_preview(&mut self, now: Instant) -> Preview { + let finished = self.finished.is_some(); + let emu = grid(&self.parser); + self.preview.resolve(now, finished, &*emu).clone() + } + + /// Freeze the preview once per task life at `output_complete`. Lands any + /// open `?2026` frame first so the final resolve sees every byte; + /// `scrape_exit_hint` does the same for its own read, and whichever runs + /// second no-ops. + pub(crate) fn finalize_preview(&mut self) { + if self.preview.finalized() || !self.output_complete() { + return; + } + let mut emu = grid(&self.parser); + let _ = emu.finish_output(); + self.preview.finalize(&*emu); } /// Full screen as ANSI bytes for attached mode, plus cursor state so we can @@ -1028,7 +1050,7 @@ mod tests { let mut preview = String::new(); wait_until(Duration::from_secs(5), || { t.poll_exit().unwrap(); - preview = t.preview(); + preview = t.resolve_preview(Instant::now()).text; t.finished.is_some() && preview.contains("omega") }); assert_eq!(t.exit_code, Some(0)); @@ -1873,6 +1895,84 @@ mod tests { assert_eq!(t.scraped_id.as_deref(), Some(ID)); } + /// Primary-screen finalization re-resolves: a final line that lands + /// after the last resolution tick (here: after the only pre-exit + /// resolve) still reaches the frozen floor. + #[test] + fn finalize_preview_freezes_the_final_primary_line() { + use crate::preview::PreviewSource; + let dir = temp("task_final_primary"); + let flag = dir.join("flag"); + let cmd = format!( + "until [ -e '{}' ]; do sleep 0.05; done; printf 'test result: ok\\n'", + flag.display() + ); + let mut t = Task::spawn(40, &cmd, &cmd, &here(), 24, 80, &sh_env(), no_waker()).unwrap(); + // The last live resolution predates every byte of output. + let early = t.resolve_preview(Instant::now()); + assert!(!early.frozen); + std::fs::write(&flag, b"").unwrap(); + assert!( + wait_until(Duration::from_secs(60), || { + t.poll_exit().unwrap(); + t.output_complete() + }), + "child never completed" + ); + t.finalize_preview(); + let p = t.resolve_preview(Instant::now()); + assert_eq!( + (p.text.as_str(), p.source, p.frozen), + ("test result: ok", PreviewSource::Floor, true) + ); + let _ = std::fs::remove_dir_all(&dir); + } + + /// Alt teardown at exit: the child enters the alt screen, titles it, and + /// exits through 1049l. The restored primary junk must not replace the + /// last rendered preview; it freezes with its source preserved. + #[test] + fn finalize_preview_keeps_the_last_render_across_alt_teardown() { + use crate::preview::PreviewSource; + let dir = temp("task_final_alt"); + let flag = dir.join("flag"); + let cmd = format!( + "printf 'prelaunch junk\\n'; \ + printf '\\033[?1049h\\033]0;working\\007app body'; \ + until [ -e '{}' ]; do sleep 0.05; done; printf '\\033[?1049l'", + flag.display() + ); + let mut t = Task::spawn(41, &cmd, &cmd, &here(), 24, 80, &sh_env(), no_waker()).unwrap(); + // Resolve until the title renders, so the last live resolution sees + // the alt screen; no resolves run between the flag and EOF. + assert!( + wait_until(Duration::from_secs(5), || { + t.resolve_preview(Instant::now()).source == PreviewSource::Title + }), + "title never rendered" + ); + std::fs::write(&flag, b"").unwrap(); + assert!( + wait_until(Duration::from_secs(60), || { + t.poll_exit().unwrap(); + t.output_complete() + }), + "child never completed" + ); + t.finalize_preview(); + assert_eq!( + grid(&t.parser).live_floor(), + "prelaunch junk", + "premise: 1049l restored the pre-launch primary screen" + ); + let p = t.resolve_preview(Instant::now()); + assert_eq!( + (p.text.as_str(), p.source, p.frozen), + ("working", PreviewSource::Title, true) + ); + let _ = std::fs::remove_dir_all(&dir); + } + /// A child's cursor-position probe is answered on the wire: the reply /// crosses the reader thread → allowlist → writer worker → PTY, and only /// the advertised shape arrives. The child first sends secondary DA (a diff --git a/src/terminal/emulator.rs b/src/terminal/emulator.rs index 7656a10..a886a66 100644 --- a/src/terminal/emulator.rs +++ b/src/terminal/emulator.rs @@ -528,9 +528,8 @@ impl Emulator { } } -/// Capture accessors for the dashboard-preview resolution layer, which lands -/// in a later phase; the allow comes off with its first caller. -#[allow(dead_code)] +/// Capture accessors for the dashboard-preview resolution layer +/// (`crate::preview`). impl Emulator { /// Monotonic count of grid advances. Bumps on every `process` call and /// on each sync-frame landing; equal reads mean the grid did not advance diff --git a/src/ui.rs b/src/ui.rs index 124a5d4..2144875 100644 --- a/src/ui.rs +++ b/src/ui.rs @@ -13,6 +13,7 @@ use crossterm::{ use crate::{ app::{App, DirKind, GroupMode, Mode, Row}, format::{pad, rel_time, truncate}, + preview::PreviewSource, protocol::{Lifecycle, TaskView}, }; @@ -160,11 +161,14 @@ fn render_dashboard(out: &mut impl Write, app: &App) -> io::Result<()> { Row::Section(label) => dim(out, y, &format!(" {label}"), cols)?, Row::Task(ti) => { let v = &app.views[*ti]; - let line = task_row(v, cols); if app.selected_id == Some(v.id) { - rev(out, y, &line, cols)?; + rev(out, y, &task_row(v, cols), cols)?; + } else if v.source == PreviewSource::Marker { + // The marker is a placeholder, not output: dim the + // preview cell so it reads as metadata. + dim_preview_row(out, y, v, cols)?; } else { - put(out, y, &line, cols)?; + put(out, y, &task_row(v, cols), cols)?; } } } @@ -304,7 +308,10 @@ fn row_age(v: &TaskView) -> Duration { edge.unwrap_or(v.started_ago) } -fn task_row(v: &TaskView, cols: usize) -> String { +/// The row's three cells — everything left of the preview, the padded preview +/// cell, and the time column — split so `dim_preview_row` can restyle the +/// preview cell alone. +fn task_row_parts(v: &TaskView, cols: usize) -> (String, String, String) { let glyph = match v.lifecycle { Lifecycle::Active => "✻", Lifecycle::Idle => "∙", @@ -320,15 +327,35 @@ fn task_row(v: &TaskView, cols: usize) -> String { let used = 2 + 2 + 2 + title_w + 1 + 1 + time.chars().count(); let prev_w = cols.saturating_sub(used); let preview = truncate(&v.preview, prev_w); - format!( - " {g} {tg}{t: String { + let (lead, preview, time) = task_row_parts(v, cols); + format!("{lead}{preview}{time}") +} + +/// Paint one row with a dimmed preview cell, restyling the padded plain text +/// like the header does so truncation cannot desync the styled runs. +fn dim_preview_row(out: &mut impl Write, y: u16, v: &TaskView, cols: usize) -> io::Result<()> { + let (lead, preview, time) = task_row_parts(v, cols); + let display = pad(&format!("{lead}{preview}{time}"), cols); + let mut chars = display.chars(); + let lead: String = chars.by_ref().take(lead.chars().count()).collect(); + let preview: String = chars.by_ref().take(preview.chars().count()).collect(); + let rest: String = chars.collect(); + queue!( + out, + MoveTo(0, y), + Print(lead), + SetAttribute(Attribute::Dim), + Print(preview), + SetAttribute(Attribute::Reset), + Print(rest) ) } @@ -384,16 +411,36 @@ fn render_peek(out: &mut impl Write, app: &App) -> io::Result<()> { MoveTo(x0 as u16, by), Print(format!("└{}┘", "─".repeat(inner_w))) )?; + // Debug affordance: where the row's preview came from. `rule` is only + // ever present in-process (it does not cross the wire). + let footer = format!( + " space/esc close · enter attach · preview: {} ", + preview_provenance(v) + ); queue!( out, MoveTo((x0 + 2) as u16, by), SetAttribute(Attribute::Dim), - Print(truncate(" space/esc close · enter attach ", inner_w)), + Print(truncate(&footer, inner_w)), SetAttribute(Attribute::Reset) )?; Ok(()) } +/// The peek footer's provenance label: source, then the matcher rule when +/// one produced it, then the frozen flag — e.g. `title` or `floor (frozen)`. +fn preview_provenance(v: &TaskView) -> String { + let mut s = v.source.label().to_string(); + if let Some(rule) = v.rule { + s.push('/'); + s.push_str(rule); + } + if v.frozen { + s.push_str(" (frozen)"); + } + s +} + /// The varying content of a bottom-panel picker; `render_panel` owns the /// shared skeleton. struct Panel<'a> { @@ -684,6 +731,9 @@ mod tests { lifecycle: Lifecycle::Active, parked: false, preview: String::new(), + source: PreviewSource::Floor, + frozen: false, + rule: None, started_ago: std::time::Duration::from_secs(5), quiet_ago: None, finished_ago: None, @@ -748,6 +798,20 @@ mod tests { ); } + /// The peek footer's provenance label composes source, rule, and frozen. + #[test] + fn preview_provenance_label_shapes() { + let mut v = view(None); + assert_eq!(preview_provenance(&v), "floor"); + v.source = PreviewSource::Title; + v.frozen = true; + assert_eq!(preview_provenance(&v), "title (frozen)"); + v.source = PreviewSource::Anchor; + v.rule = Some("claude-status"); + v.frozen = false; + assert_eq!(preview_provenance(&v), "anchor/claude-status"); + } + /// The attached bar shows both the name and the command for a named task. #[test] fn attached_title_shows_name_and_command() { From e48052beba810c4010891c728e5961c7b48ee683 Mon Sep 17 00:00:00 2001 From: Christopher Sardegna Date: Mon, 20 Jul 2026 11:10:30 -0700 Subject: [PATCH 05/11] feat: anchor dashboard previews with per-agent summary adapters --- src/golden.rs | 74 ++ src/harness/mod.rs | 4 +- src/harness/summary.rs | 1075 +++++++++++++++++ src/preview.rs | 618 ++++++++-- src/protocol.rs | 6 +- src/supervisor_tests.rs | 10 +- src/task.rs | 159 ++- src/terminal/emulator.rs | 811 +++++++++++-- tests/corpus/README.md | 39 +- tests/corpus/preview_claude_action.bin | 40 + tests/corpus/preview_claude_approval.bin | 39 + tests/corpus/preview_claude_body_menu.bin | 40 + .../corpus/preview_claude_body_menu_idle.bin | 40 + tests/corpus/preview_claude_done.bin | 40 + tests/corpus/preview_claude_idle.bin | 40 + tests/corpus/preview_claude_working.bin | 40 + tests/corpus/preview_claude_working_tool.bin | 40 + tests/corpus/preview_codex_ran.bin | 17 + tests/corpus/preview_codex_scrollback.bin | 39 + tests/corpus/preview_codex_working.bin | 39 + .../corpus/preview_codex_working_over_ran.bin | 39 + tests/corpus/preview_grok_idle.bin | 39 + tests/corpus/preview_grok_splash.bin | 39 + tests/corpus/preview_grok_worked.bin | 39 + tests/corpus/preview_grok_working.bin | 39 + tests/corpus/preview_trunc_claude.bin | 7 + tests/corpus/preview_trunc_codex.bin | 7 + tests/corpus/preview_trunc_grok.bin | 7 + tests/corpus/preview_wrap_grok.bin | 6 + 29 files changed, 3204 insertions(+), 228 deletions(-) create mode 100644 src/harness/summary.rs create mode 100644 tests/corpus/preview_claude_action.bin create mode 100644 tests/corpus/preview_claude_approval.bin create mode 100644 tests/corpus/preview_claude_body_menu.bin create mode 100644 tests/corpus/preview_claude_body_menu_idle.bin create mode 100644 tests/corpus/preview_claude_done.bin create mode 100644 tests/corpus/preview_claude_idle.bin create mode 100644 tests/corpus/preview_claude_working.bin create mode 100644 tests/corpus/preview_claude_working_tool.bin create mode 100644 tests/corpus/preview_codex_ran.bin create mode 100644 tests/corpus/preview_codex_scrollback.bin create mode 100644 tests/corpus/preview_codex_working.bin create mode 100644 tests/corpus/preview_codex_working_over_ran.bin create mode 100644 tests/corpus/preview_grok_idle.bin create mode 100644 tests/corpus/preview_grok_splash.bin create mode 100644 tests/corpus/preview_grok_worked.bin create mode 100644 tests/corpus/preview_grok_working.bin create mode 100644 tests/corpus/preview_trunc_claude.bin create mode 100644 tests/corpus/preview_trunc_codex.bin create mode 100644 tests/corpus/preview_trunc_grok.bin create mode 100644 tests/corpus/preview_wrap_grok.bin diff --git a/src/golden.rs b/src/golden.rs index d28402f..1f19bfa 100644 --- a/src/golden.rs +++ b/src/golden.rs @@ -576,3 +576,77 @@ fn semantic_dec_scrollregion_charset_translation() { } assert_eq!(al.grid().cursor.point, Point::new(Line(39), Column(0))); } + +/// Differential canary for the Emulator's delegating [`Handler`] wrapper +/// (`ObservedTerm` in `terminal::emulator`): every corpus fixture replayed +/// through the Emulator must land on exactly the screen, cursor, and mode a +/// raw backend replay produces. All 71 Handler methods have empty defaults, +/// so a missed forward compiles silently and swallows escapes — this +/// comparison is what breaks loudly instead. The classic `alacritty()` +/// goldens above deliberately bypass the Emulator (they pin the raw +/// backend), so they cannot serve as that fence. +#[test] +fn emulator_wrapper_matches_the_raw_backend_on_every_fixture() { + let fixtures: [(&str, &[u8]); 12] = [ + ( + "tmux_split", + include_bytes!("../tests/corpus/tmux_split.bin"), + ), + ( + "vim_session", + include_bytes!("../tests/corpus/vim_session.bin"), + ), + ( + "less_altscreen", + include_bytes!("../tests/corpus/less_altscreen.bin"), + ), + ("top_live", include_bytes!("../tests/corpus/top_live.bin")), + ( + "shell_colors", + include_bytes!("../tests/corpus/shell_colors.bin"), + ), + ("build_log", include_bytes!("../tests/corpus/build_log.bin")), + ( + "claude_resume", + include_bytes!("../tests/corpus/claude_resume.bin"), + ), + ( + "codex_resume", + include_bytes!("../tests/corpus/codex_resume.bin"), + ), + ( + "grok_resume", + include_bytes!("../tests/corpus/grok_resume.bin"), + ), + ( + "wide_emoji", + include_bytes!("../tests/corpus/wide_emoji.bin"), + ), + ( + "dec_scrollregion", + include_bytes!("../tests/corpus/dec_scrollregion.bin"), + ), + ( + "topregion_scroll", + include_bytes!("../tests/corpus/topregion_scroll.bin"), + ), + ]; + for (name, bytes) in fixtures { + let al = alacritty(bytes); + let mut emu = crate::testutil::corpus_emulator(); + emu.process(bytes); + let (_, al_cursor, al_hidden) = ansi::formatted(&al); + let (_, emu_cursor, emu_hidden) = emu.formatted(); + assert_eq!(emu.contents(), ansi::contents(&al), "{name}: screen"); + assert_eq!( + (emu_cursor, emu_hidden), + (al_cursor, al_hidden), + "{name}: cursor" + ); + assert_eq!( + emu.alternate_screen(), + al.mode().contains(TermMode::ALT_SCREEN), + "{name}: alt bit" + ); + } +} diff --git a/src/harness/mod.rs b/src/harness/mod.rs index f1d5511..8911ca2 100644 --- a/src/harness/mod.rs +++ b/src/harness/mod.rs @@ -12,12 +12,14 @@ //! Every ID returned by `parse_capture`, `scrape_exit`, or `correlate_fs` //! eventually enters a shell command. These methods must therefore return only //! strings accepted by [`is_uuid`]. Free-text names, paths, and malformed IDs -//! yield `None`. +//! yield `None`. The `summary` submodule is the one exemption: its output is +//! display-only and must never reach a command line (see its module docs). pub mod assets; mod claude; mod codex; mod grok; +pub mod summary; use std::{ ffi::OsString, diff --git a/src/harness/summary.rs b/src/harness/summary.rs new file mode 100644 index 0000000..a06bc88 --- /dev/null +++ b/src/harness/summary.rs @@ -0,0 +1,1075 @@ +//! Per-agent summary adapters: the Anchor tier of the dashboard-preview +//! cascade (see [`crate::preview`]). Each adapter reads one agent CLI's +//! bottom chrome from the live grid and extracts the CLI's own status words. +//! +//! # Display-only contract +//! +//! Adapter output is rendered in the dashboard preview column and nowhere +//! else. It never enters a shell command, so the harness module's +//! [`is_uuid`](super::is_uuid) insertion boundary does not apply here — and +//! no adapter output may ever be routed onto a path where it would. +//! +//! # Anchor discipline +//! +//! The dangerous failure is a false positive: codex scrollback holds +//! `• Ran …` rows from every prior turn, a conversation *about* a numbered +//! menu paints `❯ 1. Yes` into the body, and an agent cat-ing a document can +//! paint status-shaped rows anywhere on the grid. Every matcher therefore: +//! +//! 1. locates the chrome region structurally — claude's separator-pair input +//! box, codex's status bar and composer, grok's bordered input box — and +//! scans only rows pinned to it, never the whole grid; +//! 2. extracts only when the working structure sits at the pinned position. +//! A missing anchor returns `None` and the cascade degrades to +//! title/marker/floor, which is honest where a body match would lie; +//! 3. keys on the row head (glyph + verb prefix) and never requires trailing +//! components: the CLIs truncate their status rows at a word boundary +//! with an appended ellipsis at narrow widths, and the affordances and +//! suffixes that truncate first are exactly what normalization strips. A +//! truncated extraction keeps the CLI's own ellipsis verbatim. A window +//! narrow enough to wrap the ellipsis itself breaks the row structure, +//! which fails the pin and falls through. +//! +//! Normalization removes churn — spinner glyphs, elapsed counters, token and +//! throughput tickers, `esc to interrupt` and `/ps`/`/stop` affordances — +//! and never paraphrases: the preview shows the child's own words — a +//! placeholder verb, a task-derived phrase, a command line — verbatim. +//! The one synthesized label is `awaiting approval` for claude's approval +//! menu, whose literal text (`❯ 1. Yes`) is meaningless in a dashboard +//! column; keep it the only one. +//! +//! Matchers encode screens observed on claude 2.1.215, codex-cli 0.144.6, +//! and grok 0.2.102 (fixtures: `tests/corpus/README.md`). They share the +//! harness module's brittleness posture: each constant names the exact +//! screen it came from, and the corpus tests break loudly when a CLI +//! repaints its chrome. + +use std::path::Path; + +use crate::preview::ScreenFacts; + +/// One agent CLI's screen knowledge: live status extraction and the model +/// label, both display-only (module docs). +pub trait SummaryAdapter: Sync { + /// The normalized live status and the matcher id that produced it, when + /// the CLI's working structure is present at its pinned position. `None` + /// on any structural doubt: a missed anchor degrades to title/marker, + /// a guessed one lies. + fn live_preview(&self, screen: &dyn ScreenFacts) -> Option<(String, &'static str)>; + + /// Model label from a stable chrome row — claude's welcome box, codex's + /// status bar, grok's input-box border — never from user-configurable + /// rows (claude's statusline differs on every machine). The cascade + /// prepends `{label} · ` when `live_preview` fires. + fn model_label(&self, screen: &dyn ScreenFacts) -> Option; + + /// Synthetic exit line derived from retained terminal text, frozen as + /// the final preview when returned. The slot exists for synthetic exit + /// lines later; no v1 implementation returns `Some`. + fn exit_preview(&self, _retained_text: &str) -> Option { + None + } +} + +/// Select the adapter for a requested command: basename match on the first +/// whitespace-separated word. Deliberately wider than harness detection — +/// `claude --model opus` paints a claude screen even though its command line +/// is opaque to resume rewriting, and a wrong pick can only mis-read a +/// screen into `None`, never rewrite a command. Env prefixes (`FOO=bar +/// claude`) and compound shell commands select nothing: their first word is +/// not the program. Selection is also independent of [`super::detect`]: +/// `Task::harness` is set only when detection *and* capture-asset install +/// succeed, and an instrumentation failure must not kill summaries. +pub fn select(command: &str) -> Option<&'static dyn SummaryAdapter> { + let first = command.split_whitespace().next()?; + match Path::new(first).file_name()?.to_str()? { + "claude" => Some(&ClaudeSummary), + "codex" => Some(&CodexSummary), + "grok" => Some(&GrokSummary), + _ => None, + } +} + +/// Whether `row` is a full-width horizontal rule: nothing but `─`, long +/// enough that box borders and inline list rules never qualify. claude's +/// input box is fenced by two such rows. +fn is_rule_row(row: &str) -> bool { + let mut n = 0usize; + for c in row.trim().chars() { + if c != '─' { + return false; + } + n += 1; + } + n >= 40 +} + +// ---------------------------------------------------------------- claude -- + +/// Spinner frames observed in claude 2.1.215's working states (the +/// `claude_resume.bin` stream cycles exactly these). `·` is a frame, not +/// punctuation: the glyph alone never matches without the `…`-terminated +/// verb after it. +const CLAUDE_SPINNER: &[char] = &['·', '✢', '✳', '✶', '✻', '✽']; + +/// claude (alt screen). Working state: a column-0 spinner row directly above +/// the input box's top separator. Approval state: the dialog replaces the +/// input box entirely, which is what licenses the menu match. +pub struct ClaudeSummary; + +impl SummaryAdapter for ClaudeSummary { + fn live_preview(&self, screen: &dyn ScreenFacts) -> Option<(String, &'static str)> { + let rows = screen.live_rows(); + match claude_box_top(&rows) { + Some(top) => claude_spinner_status(&rows, top), + // No input box: only the approval dialog removes it, so only + // here may the menu shape mean anything. + None => claude_approval(&rows), + } + } + + fn model_label(&self, screen: &dyn ScreenFacts) -> Option { + claude_welcome_label(&screen.live_rows()) + } +} + +/// Index of the input box's top separator. The bottom-most full-width rule +/// is the box's bottom edge — only statusline rows render below it — a +/// second rule within six rows is its top edge, and a `❯`-headed row between +/// them is the input line. Body text above and statusline rows below never +/// enter the scan. +fn claude_box_top(rows: &[String]) -> Option { + let bottom = rows.iter().rposition(|r| is_rule_row(r))?; + let top = (bottom.saturating_sub(6)..bottom) + .rev() + .find(|&i| is_rule_row(&rows[i]))?; + rows[top + 1..bottom] + .iter() + .any(|r| r.starts_with('❯')) + .then_some(top) +} + +/// Scan the three rows above the input box for the spinner row. Hint rows +/// (the tmux focus-events notice, the right-aligned `● high · /effort`) are +/// indented while the spinner paints at column 0; a column-0 row that is not +/// spinner-shaped aborts the scan — it is body text reaching the chrome or +/// the wrapped tail of a status row too wide for the window, and both must +/// fail structurally rather than risk matching something status-shaped. +fn claude_spinner_status(rows: &[String], top: usize) -> Option<(String, &'static str)> { + for i in (top.saturating_sub(3)..top).rev() { + let row = &rows[i]; + if row.is_empty() || row.starts_with(' ') { + continue; + } + let verb = claude_spinner_text(row)?; + // The spinner confirms the working state; only then is the + // concrete-action row worth preferring over the rotating verb. + if let Some(action) = claude_action_row(rows, i) { + return Some((action, "claude:action-row")); + } + return Some((verb, "claude:spinner")); + } + None +} + +/// `✻ Hashing… (6s · ↓ 87 tokens)` → `Hashing…`: one spinner frame, a space, +/// the status phrase through its first `…`. The phrase is not always a +/// single placeholder verb — observed live: task-derived text with embedded +/// parens and digits (`✳ Overseeing phase 4 (adapters)…`) — and nothing here +/// keys on its shape. The trailing parenthetical (elapsed/token counters or +/// free-text progress) and any `esc to interrupt` affordance sit after the +/// `…` and drop; CLI-side truncation also ends at a word boundary with its +/// own `…`, so the same cut keeps a truncated phrase's ellipsis verbatim. +fn claude_spinner_text(row: &str) -> Option { + let mut chars = row.chars(); + if !CLAUDE_SPINNER.contains(&chars.next()?) || chars.next()? != ' ' { + return None; + } + let rest = chars.as_str(); + let text = &rest[..rest.find('…')? + '…'.len_utf8()]; + text.chars() + .next()? + .is_alphanumeric() + .then(|| text.to_string()) +} + +/// The concrete-action row above a confirmed spinner: skip the blank gap, +/// probe exactly one row. `⏺ Running 1 shell command…` names real work while +/// the spinner phrase rotates per request, so it wins when both are +/// present. The probe requires the `⏺` head and a single trailing `…` — +/// `⏺ ok`-style reply rows fail it — and anything else keeps the spinner +/// phrase: scanning further up would be a body hunt. +fn claude_action_row(rows: &[String], spinner: usize) -> Option { + let row = rows[..spinner].iter().rev().find(|r| !r.is_empty())?; + let text = row.strip_prefix("⏺ ")?.trim(); + let tail = text.len().checked_sub('…'.len_utf8())?; + (text.find('…') == Some(tail)).then(|| text.to_string()) +} + +/// The approval dialog's selector row, reachable only with the input box +/// gone: `❯ 1. …` with a `2. …` option below, pinned to the last nine rows +/// of painted content. Returns the one synthesized label — `❯ 1. Yes` is +/// meaningless in a dashboard column (module docs; keep it the only +/// paraphrase). +fn claude_approval(rows: &[String]) -> Option<(String, &'static str)> { + let last = rows.iter().rposition(|r| !r.is_empty())?; + let i = (last.saturating_sub(8)..=last).find(|&i| rows[i].trim_start().starts_with("❯ 1. "))?; + let next = rows[i + 1..].iter().find(|r| !r.is_empty())?; + next.trim_start() + .starts_with("2. ") + .then(|| ("awaiting approval".to_string(), "claude:approval-menu")) +} + +/// `Fable 5 with high effort` from the welcome box → `Fable 5 (high)`. The +/// box is the stable source: the statusline rows below the input box render +/// user-configured text (different on every machine) and must never be +/// read. The box scrolls away as the conversation grows and the label +/// simply drops off. +fn claude_welcome_label(rows: &[String]) -> Option { + let start = rows + .iter() + .take(4) + .position(|r| r.trim_start().starts_with("╭─── Claude Code"))?; + for row in &rows[start + 1..] { + if row.trim_start().starts_with('╰') { + break; + } + // First cell of the box row: the welcome pane, left of the divider. + let Some(cell) = row.split('│').nth(1) else { + continue; + }; + let head = cell.trim().split(" · ").next().unwrap_or(""); + if let Some(model_effort) = head.strip_suffix(" effort") + && let Some((model, effort)) = model_effort.rsplit_once(" with ") + && !model.is_empty() + && !effort.is_empty() + { + return Some(format!("{model} ({effort})")); + } + } + None +} + +// ----------------------------------------------------------------- codex -- + +/// codex (inline UI, primary screen). The pin is its status bar — the +/// bottom-most non-blank row — with the composer above it; status rows sit +/// above the composer, and scrollback beyond the first foreign row is out of +/// bounds. +pub struct CodexSummary; + +impl SummaryAdapter for CodexSummary { + fn live_preview(&self, screen: &dyn ScreenFacts) -> Option<(String, &'static str)> { + let rows = screen.live_rows(); + let composer = codex_composer(&rows)?; + codex_status(&rows, composer) + } + + fn model_label(&self, screen: &dyn ScreenFacts) -> Option { + let rows = screen.live_rows(); + let token = codex_token_line(&rows)?; + // `codex_token_line` guarantees a non-empty first segment. + Some(rows[token].trim().split(" · ").next()?.to_string()) + } +} + +/// codex's status bar is the bottom-most non-blank row of its inline UI: +/// `{model} · {…} in · {…} out`. Its absence — codex exited and left its +/// resume hint as the last row, or something else owns the screen — fails +/// the whole pin. +fn codex_token_line(rows: &[String]) -> Option { + let i = rows.iter().rposition(|r| !r.is_empty())?; + let segs: Vec<&str> = rows[i].trim().split(" · ").collect(); + (segs.len() >= 3 + && !segs[0].is_empty() + && segs[segs.len() - 2].ends_with(" in") + && segs[segs.len() - 1].ends_with(" out")) + .then_some(i) +} + +/// The composer row (`› …`, column 0) within three rows above the status +/// bar. Prompt echoes in scrollback share the `›` head but sit above the +/// composer, which is why the search runs bottom-up from the bar. +fn codex_composer(rows: &[String]) -> Option { + let token = codex_token_line(rows)?; + (token.saturating_sub(3)..token) + .rev() + .find(|&i| rows[i].starts_with('›')) +} + +/// Walk up from the composer through the status region: blanks and indented +/// rows (tool-output attachments like `└ ok`, wrapped continuations) are +/// skipped, and the first column-0 row decides. Only two heads extract — +/// `• Working (` and `• Ran ` — and any other column-0 row (a reply bullet, +/// a `⚠` notice, a turn separator) stops the scan: scrollback holds `• Ran` +/// rows from every prior turn, and skipping an unknown row to reach one +/// would resurface stale work as live status. +fn codex_status(rows: &[String], composer: usize) -> Option<(String, &'static str)> { + for row in rows[composer.saturating_sub(10)..composer].iter().rev() { + if row.is_empty() || row.starts_with(' ') { + continue; + } + if let Some(after_paren) = row.strip_prefix("• Working (") { + return Some((codex_working(after_paren), "codex:working")); + } + if let Some(cmd) = row.strip_prefix("• Ran ") + && !cmd.is_empty() + { + return Some((format!("Ran {cmd}"), "codex:ran")); + } + return None; + } + None +} + +/// `7s • esc to interrupt) · 1 background terminal running · /ps to view · +/// /stop to close` → `Working · 1 background terminal running`. The +/// parenthetical is the elapsed counter plus interrupt affordance, dropped +/// whole — an unclosed paren is CLI-side truncation mid-affordance and drops +/// to the end. Of the ` · ` suffixes, `/`-headed segments are key hints; +/// everything else is slow-moving state worth keeping, with its own ellipsis +/// when the CLI truncated it. +fn codex_working(after_paren: &str) -> String { + let mut out = String::from("Working"); + let tail = after_paren.find(')').map_or("", |i| &after_paren[i + 1..]); + for seg in tail.split(" · ") { + let seg = seg.trim(); + if seg.is_empty() || seg.starts_with('/') { + continue; + } + out.push_str(" · "); + out.push_str(seg); + } + out +} + +// ------------------------------------------------------------------ grok -- + +/// grok (alt screen). The pin is its bordered input box; the status row — +/// braille spinner while working, `Worked for {n}s` after a turn — is the +/// first painted row above the box's top border. +pub struct GrokSummary; + +impl SummaryAdapter for GrokSummary { + fn live_preview(&self, screen: &dyn ScreenFacts) -> Option<(String, &'static str)> { + let rows = screen.live_rows(); + let (top, _) = grok_input_box(&rows)?; + // One probe row: the first painted row above the box. The splash + // panel's hints and the session header land here in non-working + // states and match neither shape. + let probe = rows[..top].iter().rev().find(|r| !r.is_empty())?; + let t = probe.trim_start(); + if let Some(text) = grok_spinner_text(t) { + return Some((text, "grok:spinner")); + } + grok_worked(t).then(|| (t.to_string(), "grok:worked")) + } + + fn model_label(&self, screen: &dyn ScreenFacts) -> Option { + let rows = screen.live_rows(); + let (_, bottom) = grok_input_box(&rows)?; + grok_border_label(&rows[bottom]) + } +} + +/// grok's input box: the bottom-most `╰…╯` border (the splash panel's box +/// sits higher), a `╭…╮` top border within six rows above it, and at least +/// one `│`-headed row between. Returns `(top, bottom)` border indexes. +fn grok_input_box(rows: &[String]) -> Option<(usize, usize)> { + let bottom = rows.iter().rposition(|r| { + let t = r.trim(); + t.starts_with('╰') && t.ends_with('╯') + })?; + let top = (bottom.saturating_sub(6)..bottom).rev().find(|&i| { + let t = rows[i].trim(); + t.starts_with('╭') && t.ends_with('╮') + })?; + rows[top + 1..bottom] + .iter() + .any(|r| r.trim_start().starts_with('│')) + .then_some((top, bottom)) +} + +/// `⠼ Sleep 5 seconds then echo ok… 1.5s 2.8s ⇣14.2k [↓][stop]` → the label +/// through its `…`: a braille spinner frame, a space, text cut at the first +/// `…` — everything after it is elapsed/throughput ticker. A wrapped status +/// row leaves its `…` tail on the probe row with no spinner head, which +/// fails here and falls through. +fn grok_spinner_text(t: &str) -> Option { + let mut chars = t.chars(); + if !('\u{2800}'..='\u{28FF}').contains(&chars.next()?) || chars.next()? != ' ' { + return None; + } + let rest = chars.as_str(); + let text = &rest[..rest.find('…')? + '…'.len_utf8()]; + text.chars() + .next()? + .is_alphanumeric() + .then(|| text.to_string()) +} + +/// The completion row grok leaves above its box, kept verbatim: `Worked for +/// 8.7s`, with digits, `.`, and the `m`/`h`/space of longer durations +/// tolerated after a leading digit. +fn grok_worked(t: &str) -> bool { + t.strip_prefix("Worked for ") + .and_then(|r| r.strip_suffix('s')) + .is_some_and(|n| { + n.starts_with(|c: char| c.is_ascii_digit()) + && n.chars() + .all(|c| c.is_ascii_digit() || matches!(c, '.' | ' ' | 'm' | 'h')) + }) +} + +/// `╰──── Grok 4.5 (xhigh) · always-approve ─╯` → `Grok 4.5 (xhigh)`: the +/// text grok embeds in its bottom border, first ` · ` segment (the second is +/// the approval mode). A plain border has nothing after its last `─` and +/// yields no label. +fn grok_border_label(row: &str) -> Option { + let t = row.trim().strip_suffix('╯')?; + let t = t.trim_end_matches(['─', ' ']); + let text = &t[t.rfind('─')? + '─'.len_utf8()..]; + let label = text.trim().split(" · ").next()?.trim(); + (!label.is_empty()).then(|| label.to_string()) +} + +#[cfg(test)] +mod tests { + use std::time::Instant; + + use super::*; + use crate::{ + emulator::Emulator, + preview::{MARKER, Preview, PreviewSource, PreviewState}, + }; + + /// Synthetic screen: adapters read only `live_rows`, so the other facts + /// are inert defaults. + struct RowsScreen { + rows: Vec, + } + + fn rs(rows: &[&str]) -> RowsScreen { + RowsScreen { + rows: rows.iter().map(|s| s.to_string()).collect(), + } + } + + impl ScreenFacts for RowsScreen { + fn revision(&self) -> u64 { + 1 + } + + fn alt_epoch(&self) -> u64 { + 0 + } + + fn alternate_screen(&self) -> bool { + false + } + + fn title(&self) -> Option<&str> { + None + } + + fn live_floor(&self) -> String { + self.rows + .iter() + .rev() + .find(|r| !r.is_empty()) + .cloned() + .unwrap_or_default() + } + + fn live_rows(&self) -> Vec { + self.rows.clone() + } + + fn alt_leave_floor(&self) -> Option<&str> { + None + } + } + + /// Replay a corpus fixture and resolve one preview against its final + /// screen with `adapter` installed. + fn resolve_corpus(bytes: &[u8], adapter: &dyn SummaryAdapter, rows: u16, cols: u16) -> Preview { + let mut emu = Emulator::new(rows, cols, 2000); + emu.process(bytes); + let mut st = PreviewState::new(); + st.resolve(Instant::now(), false, &emu, Some(adapter)) + .clone() + } + + fn anchor(text: &str, rule: &'static str) -> (String, PreviewSource, Option<&'static str>) { + (text.to_string(), PreviewSource::Anchor, Some(rule)) + } + + fn parts(p: &Preview) -> (String, PreviewSource, Option<&'static str>) { + (p.text.clone(), p.source, p.rule) + } + + /// Selection is a basename match on the first word only: wider than + /// harness detection (arguments are tolerated), but env prefixes and + /// shell syntax glued to the word select nothing. + #[test] + fn select_matches_first_word_basenames_only() { + for cmd in [ + "claude", + "claude --model opus", + "/usr/local/bin/claude --resume abc", + "codex resume 'not-checked-here'", + "grok", + ] { + assert!(select(cmd).is_some(), "{cmd:?} must select an adapter"); + } + for cmd in ["vim", "FOO=bar claude", "claude|tee log", "codex; ls", ""] { + assert!(select(cmd).is_none(), "{cmd:?} must select nothing"); + } + } + + /// Each program word routes to its own CLI's matchers: the selected + /// adapter fires that CLI's rule on that CLI's screen shape. + #[test] + fn select_routes_to_the_matching_adapter() { + let sep = "─".repeat(80); + let claude = rs(&["✻ Hashing… (6s · ↓ 87 tokens)", &sep, "❯", &sep]); + assert_eq!( + select("claude").unwrap().live_preview(&claude).unwrap().1, + "claude:spinner" + ); + let codex = rs(&[ + "• Working (2s • esc to interrupt)", + "", + "› Write tests", + "", + " gpt-5.6-sol high · 0 in · 0 out", + ]); + assert_eq!( + select("codex").unwrap().live_preview(&codex).unwrap().1, + "codex:working" + ); + let grok = rs(&[ + " ⠼ Sleep 5 seconds then echo ok… 1.5s 2.8s ⇣14.2k [↓][stop]", + "", + " ╭──────────────────────╮", + " │ ❯ │", + " ╰── Grok 4.5 (xhigh) · always-approve ─╯", + ]); + assert_eq!( + select("grok").unwrap().live_preview(&grok).unwrap().1, + "grok:spinner" + ); + } + + /// The spinner phrase survives, the elapsed/token parenthetical drops, + /// and the concrete-action row wins over the spinner when present. + #[test] + fn claude_spinner_and_action_row() { + let sep = "─".repeat(120); + let spin = rs(&["✻ Hashing… (6s · ↓ 87 tokens)", &sep, "❯", &sep, " status"]); + assert_eq!( + ClaudeSummary.live_preview(&spin), + Some(("Hashing…".to_string(), "claude:spinner")) + ); + + let action = rs(&[ + "⏺ Running 1 shell command…", + "", + "· Hashing… (3s · ↓ 52 tokens)", + &sep, + "❯", + &sep, + ]); + assert_eq!( + ClaudeSummary.live_preview(&action), + Some(("Running 1 shell command…".to_string(), "claude:action-row")) + ); + + // An indented attachment above the spinner is not the action row. + let attach = rs(&[ + " Running 1 shell command…", + " ⎿ $ sleep 5 && echo ok", + "✻ Hashing… (6s)", + &sep, + "❯", + &sep, + ]); + assert_eq!( + ClaudeSummary.live_preview(&attach), + Some(("Hashing…".to_string(), "claude:spinner")) + ); + + // A `⏺` reply row without a trailing ellipsis is not the action row. + let reply = rs(&["⏺ ok", "", "✻ Hashing… (2s)", &sep, "❯", &sep]); + assert_eq!( + ClaudeSummary.live_preview(&reply), + Some(("Hashing…".to_string(), "claude:spinner")) + ); + } + + /// Observed live (claude 2.1.215, 2026-07-20): the spinner row carried + /// a task-derived phrase — multi-word, embedded parens and digits — + /// with a free-text parenthetical after it, not a token counter. The + /// structural cut (glyph + space + text through the first `…`) + /// extracts it verbatim; nothing may key on a single-verb shape. + #[test] + fn claude_spinner_extracts_task_derived_phrases() { + let sep = "─".repeat(120); + let s = rs(&[ + "✳ Overseeing phase 4 (adapters)… (54s · almost done thinking with high effort)", + &sep, + "❯", + &sep, + ]); + assert_eq!( + ClaudeSummary.live_preview(&s), + Some(( + "Overseeing phase 4 (adapters)…".to_string(), + "claude:spinner" + )) + ); + } + + /// A column-0 row in the chrome window that is not spinner-shaped aborts: + /// a wrapped status tail and body text touching the chrome both refuse. + #[test] + fn claude_aborts_on_foreign_column_zero_rows() { + let sep = "─".repeat(120); + let wrapped = rs(&["✻ Hashing… (6s · ↓ 87 to", "kens)", &sep, "❯", &sep]); + assert_eq!(ClaudeSummary.live_preview(&wrapped), None); + + // A menu quoted in the body, reaching the window with the input box + // intact, refuses rather than synthesizing approval. + let menu = rs(&["❯ 1. Yes", " 2. No", &sep, "❯", &sep]); + assert_eq!(ClaudeSummary.live_preview(&menu), None); + } + + /// The approval menu synthesizes its label only with the input box gone, + /// and requires the `2.` sibling below the selector. + #[test] + fn claude_approval_requires_the_dialog_shape() { + let dialog = rs(&[ + " Do you want to create word.txt?", + " ❯ 1. Yes", + " 2. Yes, allow all edits during this session (shift+tab)", + " 3. No", + "", + " Esc to cancel · Tab to amend", + ]); + assert_eq!( + ClaudeSummary.live_preview(&dialog), + Some(("awaiting approval".to_string(), "claude:approval-menu")) + ); + + let lone = rs(&[" ❯ 1. Yes", "", " Esc to cancel"]); + assert_eq!(ClaudeSummary.live_preview(&lone), None); + } + + /// The model label comes from the welcome box and reads as + /// `{model} ({effort})`; no box, no label. + #[test] + fn claude_label_reads_the_welcome_box() { + let boxed = rs(&[ + "╭─── Claude Code v2.1.215 ────────────╮", + "│ Fable 5 with high effort · Claude Max · │ notes │", + "╰──────────────────────────────────────╯", + ]); + assert_eq!( + ClaudeSummary.model_label(&boxed), + Some("Fable 5 (high)".to_string()) + ); + assert_eq!(ClaudeSummary.model_label(&rs(&["no box here"])), None); + } + + /// Working-row normalization: parenthetical dropped (unclosed included), + /// `/`-hint suffixes dropped, slow suffixes kept — with the CLI's own + /// ellipsis when truncated. + #[test] + fn codex_working_normalization() { + let tail = [ + "", + "› Write tests for @filename", + "", + " gpt-5.6-sol high · 0 in · 0 out", + ]; + let full = "• Working (7s • esc to interrupt) · 1 background terminal running · /ps to view · /stop to close"; + for (row, want) in [ + (full, "Working · 1 background terminal running"), + ("• Working (2s • esc to interrupt)", "Working"), + ("• Working (7s • esc to…", "Working"), + ( + "• Working (7s • esc to interrupt) · 1 background termi…", + "Working · 1 background termi…", + ), + ] { + let mut rows = vec![row]; + rows.extend(tail); + assert_eq!( + CodexSummary.live_preview(&rs(&rows)), + Some((want.to_string(), "codex:working")), + "{row:?}" + ); + } + assert_eq!( + CodexSummary.model_label(&rs(&tail[1..])), + Some("gpt-5.6-sol high".to_string()) + ); + } + + /// `• Ran` extracts through its indented attachment, but never through a + /// foreign column-0 row: scrollback `• Ran` rows from prior turns sit + /// behind reply bullets and separators, and skipping those would + /// resurface stale work. + #[test] + fn codex_ran_stops_at_foreign_rows() { + let transient = rs(&[ + "• Ran sleep 5 && echo ok", + " └ ok", + "", + "› ", + "", + " gpt-5.6-sol high · 1 in · 2 out", + ]); + assert_eq!( + CodexSummary.live_preview(&transient), + Some(("Ran sleep 5 && echo ok".to_string(), "codex:ran")) + ); + + let sep = "─".repeat(120); + let behind_reply = rs(&[ + "• Ran sleep 5 && echo ok", + " └ ok", + "", + &sep, + "", + "• ok", + "", + "› ", + "", + " gpt-5.6-sol high · 1 in · 2 out", + ]); + assert_eq!(CodexSummary.live_preview(&behind_reply), None); + } + + /// Without the status bar as the bottom row (codex exited; its resume + /// hint owns the floor) the whole pin fails. + #[test] + fn codex_requires_the_status_bar_pin() { + let exited = rs(&[ + "• Ran sleep 5 && echo ok", + "", + "Token usage: total=14,353 input=14,063", + "To continue this session, run codex resume 0199-fake", + ]); + assert_eq!(CodexSummary.live_preview(&exited), None); + assert_eq!(CodexSummary.model_label(&exited), None); + } + + /// Spinner label cut at its `…`; the completion row kept verbatim, + /// longer durations included; free text above the box refuses. + #[test] + fn grok_status_shapes() { + let boxed = [ + " ╭──────────────────────╮", + " │ ❯ │", + " ╰── Grok 4.5 (xhigh) · always-approve ─╯", + ]; + let probe = |status: &str| { + let mut rows = vec![status, ""]; + rows.extend(boxed); + GrokSummary.live_preview(&rs(&rows)) + }; + assert_eq!( + probe(" ⠼ Sleep 5 seconds then echo ok… 1.5s 2.8s ⇣14.2k [↓][stop]"), + Some(("Sleep 5 seconds then echo ok…".to_string(), "grok:spinner")) + ); + assert_eq!( + probe(" ⠋ Thinking… 0.2s"), + Some(("Thinking…".to_string(), "grok:spinner")) + ); + assert_eq!( + probe(" Worked for 8.7s"), + Some(("Worked for 8.7s".to_string(), "grok:worked")) + ); + assert_eq!( + probe(" Worked for 1m 24s"), + Some(("Worked for 1m 24s".to_string(), "grok:worked")) + ); + assert_eq!(probe(" Worked for a while"), None); + assert_eq!( + probe(" Coming from Codex? Resume your session from 7m ago using ctrl+u"), + None + ); + + let mut rows = vec![" ⠋ Thinking… 0.2s", ""]; + rows.extend(boxed); + assert_eq!( + GrokSummary.model_label(&rs(&rows)), + Some("Grok 4.5 (xhigh)".to_string()) + ); + // A plain border carries no label. + let plain = rs(&[" ⠋ Thinking… 0.2s", "", "╭────╮", "│ ❯ │", "╰────╯"]); + assert_eq!(GrokSummary.model_label(&plain), None); + } + + // ------------------------------------------------------- corpus replay -- + + /// Positive per-state fixtures at capture geometry (40×120): exact + /// normalized text, Anchor provenance, and the matcher id. + #[test] + fn corpus_positive_states_anchor_exactly() { + struct Case( + &'static str, + &'static [u8], + &'static dyn SummaryAdapter, + &'static str, + &'static str, + ); + let cases = [ + Case( + "preview_claude_working", + include_bytes!("../../tests/corpus/preview_claude_working.bin"), + &ClaudeSummary, + "Fable 5 (high) · Concocting…", + "claude:spinner", + ), + Case( + "preview_claude_working_tool", + include_bytes!("../../tests/corpus/preview_claude_working_tool.bin"), + &ClaudeSummary, + "Fable 5 (high) · Hashing…", + "claude:spinner", + ), + Case( + "preview_claude_action", + include_bytes!("../../tests/corpus/preview_claude_action.bin"), + &ClaudeSummary, + "Fable 5 (high) · Running 1 shell command…", + "claude:action-row", + ), + Case( + "preview_claude_approval", + include_bytes!("../../tests/corpus/preview_claude_approval.bin"), + &ClaudeSummary, + "Fable 5 (high) · awaiting approval", + "claude:approval-menu", + ), + Case( + "preview_codex_working", + include_bytes!("../../tests/corpus/preview_codex_working.bin"), + &CodexSummary, + "gpt-5.6-sol high · Working · 1 background terminal running", + "codex:working", + ), + Case( + "preview_codex_ran", + include_bytes!("../../tests/corpus/preview_codex_ran.bin"), + &CodexSummary, + "gpt-5.6-sol high · Ran sleep 5 && echo ok", + "codex:ran", + ), + Case( + "preview_grok_working", + include_bytes!("../../tests/corpus/preview_grok_working.bin"), + &GrokSummary, + "Grok 4.5 (xhigh) · Sleep 5 seconds then echo ok…", + "grok:spinner", + ), + Case( + "preview_grok_worked", + include_bytes!("../../tests/corpus/preview_grok_worked.bin"), + &GrokSummary, + "Grok 4.5 (xhigh) · Worked for 8.7s", + "grok:worked", + ), + ]; + for Case(name, bytes, adapter, text, rule) in cases { + let p = resolve_corpus(bytes, adapter, 40, 120); + assert_eq!(parts(&p), anchor(text, rule), "{name}"); + } + } + + /// Honest-degradation fixtures: idle and post-turn screens carry no + /// anchor and fall through to the marker (alt screen, no title). + #[test] + fn corpus_idle_states_fall_through() { + let cases: [(&str, &[u8], &dyn SummaryAdapter); 4] = [ + ( + "preview_claude_idle", + include_bytes!("../../tests/corpus/preview_claude_idle.bin"), + &ClaudeSummary, + ), + ( + "preview_claude_done", + include_bytes!("../../tests/corpus/preview_claude_done.bin"), + &ClaudeSummary, + ), + ( + "preview_grok_idle", + include_bytes!("../../tests/corpus/preview_grok_idle.bin"), + &GrokSummary, + ), + ( + "preview_grok_splash", + include_bytes!("../../tests/corpus/preview_grok_splash.bin"), + &GrokSummary, + ), + ]; + for (name, bytes, adapter) in cases { + let p = resolve_corpus(bytes, adapter, 40, 120); + assert_eq!( + parts(&p), + (MARKER.to_string(), PreviewSource::Marker, None), + "{name}" + ); + } + } + + /// Negative fixtures: status-shaped text in the body never extracts. + /// The claude fixtures quote an approval menu in the conversation; the + /// codex fixtures hold `• Ran` in scrollback behind a finished turn. + #[test] + fn corpus_body_shaped_text_never_extracts() { + // Menu in the body, spinner live: the pinned spinner wins. + let p = resolve_corpus( + include_bytes!("../../tests/corpus/preview_claude_body_menu.bin"), + &ClaudeSummary, + 40, + 120, + ); + assert_eq!( + parts(&p), + anchor("Fable 5 (high) · Hashing…", "claude:spinner") + ); + + // Menu touching the chrome window on an idle screen: abort, marker. + let p = resolve_corpus( + include_bytes!("../../tests/corpus/preview_claude_body_menu_idle.bin"), + &ClaudeSummary, + 40, + 120, + ); + assert_eq!(parts(&p), (MARKER.to_string(), PreviewSource::Marker, None)); + + // Prior-turn `• Ran` in scrollback with the turn finished: the scan + // stops at the reply bullet and the floor tier reports the screen. + let p = resolve_corpus( + include_bytes!("../../tests/corpus/preview_codex_scrollback.bin"), + &CodexSummary, + 40, + 120, + ); + assert_eq!( + parts(&p), + ( + " gpt-5.6-sol high · 5.26K used · 28.2K in · 78 out".to_string(), + PreviewSource::Floor, + None + ) + ); + + // `• Ran` visible mid-turn with `• Working` at the pin: live wins. + let p = resolve_corpus( + include_bytes!("../../tests/corpus/preview_codex_working_over_ran.bin"), + &CodexSummary, + 40, + 120, + ); + assert_eq!( + parts(&p), + anchor("gpt-5.6-sol high · Working", "codex:working") + ); + } + + /// 80-column truncation: the CLIs cut their status rows at a word + /// boundary with their own ellipsis; head matching still extracts and + /// the kept suffix keeps that ellipsis verbatim. + #[test] + fn corpus_truncated_rows_still_anchor() { + let p = resolve_corpus( + include_bytes!("../../tests/corpus/preview_trunc_claude.bin"), + &ClaudeSummary, + 40, + 80, + ); + // No welcome box on the narrow screen: the label drops with it. + assert_eq!(parts(&p), anchor("Hashing…", "claude:spinner")); + + let p = resolve_corpus( + include_bytes!("../../tests/corpus/preview_trunc_codex.bin"), + &CodexSummary, + 40, + 80, + ); + assert_eq!( + parts(&p), + anchor( + "gpt-5.6-sol high · Working · 1 background terminal running", + "codex:working" + ) + ); + + let p = resolve_corpus( + include_bytes!("../../tests/corpus/preview_trunc_grok.bin"), + &GrokSummary, + 40, + 80, + ); + assert_eq!( + parts(&p), + anchor( + "Grok 4.5 (xhigh) · Sleep 5 seconds then echo…", + "grok:spinner" + ) + ); + } + + /// Pathological width: the window wraps the status row's own ellipsis + /// onto the probe row. The structure check fails and the preview falls + /// through — degradation, never a false positive. + #[test] + fn corpus_wrapped_ellipsis_falls_through() { + let p = resolve_corpus( + include_bytes!("../../tests/corpus/preview_wrap_grok.bin"), + &GrokSummary, + 40, + 30, + ); + assert_eq!(parts(&p), (MARKER.to_string(), PreviewSource::Marker, None)); + } + + /// Non-agent TUIs on the alternate screen resolve through the + /// title/marker tiers with an adapter installed exactly as without one: + /// the anchor tier never fires on foreign screens. + #[test] + fn corpus_non_agent_tuis_keep_their_tiers() { + for (name, bytes) in [ + ( + "vim_session", + &include_bytes!("../../tests/corpus/vim_session.bin")[..], + ), + ( + "less_altscreen", + &include_bytes!("../../tests/corpus/less_altscreen.bin")[..], + ), + ] { + // Cut before the final alt-screen exit so the TUI still owns the + // screen, as it does for the task's whole interactive life. + let cut = bytes + .windows(8) + .rposition(|w| w == b"\x1b[?1049l") + .expect("fixture exits the alt screen"); + let mut emu = Emulator::new(40, 120, 2000); + emu.process(&bytes[..cut]); + assert!(emu.alternate_screen(), "{name}: alt screen active at cut"); + let mut st = PreviewState::new(); + let with = st + .resolve(Instant::now(), false, &emu, Some(&ClaudeSummary)) + .clone(); + let mut st = PreviewState::new(); + let without = st.resolve(Instant::now(), false, &emu, None).clone(); + assert_eq!(with, without, "{name}: the adapter must change nothing"); + assert_eq!(with.source, PreviewSource::Marker, "{name}"); + } + } +} diff --git a/src/preview.rs b/src/preview.rs index 1d31fd8..88ce387 100644 --- a/src/preview.rs +++ b/src/preview.rs @@ -5,7 +5,7 @@ use std::time::{Duration, Instant}; -use crate::emulator::Emulator; +use crate::{emulator::Emulator, harness::summary::SummaryAdapter}; // Both holds are wall-clock: tick spacing varies ≈25× (8 ms frame minimum // under load, 200 ms idle backstop), so a tick-counted debounce would be @@ -36,8 +36,7 @@ pub enum PreviewSource { Marker, /// The child's window title, honored only on the alternate screen. Title, - /// Normalized adapter output: the cascade's top tier. No producer exists - /// this phase; adapters land later and fill the slot. + /// Normalized adapter output: the cascade's top tier. Anchor, } @@ -55,8 +54,8 @@ impl PreviewSource { /// One resolved preview. `frozen` is mutability, orthogonal to `source`: a /// finished task reports its last source with `frozen: true`, which a -/// `Frozen` variant would erase. `rule` is a fine-grained matcher id; no -/// producer sets it this phase (adapters come later), and it stays +/// `Frozen` variant would erase. `rule` is the summary-adapter matcher id +/// behind an Anchor preview (`None` for every other tier), and it stays /// daemon-side — the peek footer sees it only in-process, never over the /// wire. #[derive(Debug, Clone, PartialEq)] @@ -86,6 +85,12 @@ pub trait ScreenFacts { fn alternate_screen(&self) -> bool; fn title(&self) -> Option<&str>; fn live_floor(&self) -> String; + /// Every live-viewport row, trailing padding trimmed: the summary + /// adapters' structural scan input. + fn live_rows(&self) -> Vec; + /// The floor snapshotted at the most recent alt-screen exit — what the + /// 1049l restore left visible; `None` before the first exit. + fn alt_leave_floor(&self) -> Option<&str>; } impl ScreenFacts for Emulator { @@ -108,14 +113,38 @@ impl ScreenFacts for Emulator { fn live_floor(&self) -> String { Emulator::live_floor(self) } + + fn live_rows(&self) -> Vec { + Emulator::live_rows(self) + } + + fn alt_leave_floor(&self) -> Option<&str> { + Emulator::alt_leave_floor(self) + } } -/// The instantaneous candidate. Every branch is a fact the emulator tracks; -/// nothing is fabricated: -/// 1. [anchor slot — no producer this phase; adapters claim the top tier] +/// The instantaneous candidate. Every branch is a fact the emulator tracks +/// or an extraction from it; nothing is fabricated: +/// 1. summary adapter: the normalized live status when the CLI's working +/// structure is present, `{model label} · `-prefixed when the adapter +/// reads one from stable chrome /// 2. alternate screen: the title while its epoch is current, else the marker /// 3. primary screen: the live floor -fn cascade(screen: &impl ScreenFacts) -> Preview { +fn cascade(screen: &impl ScreenFacts, adapter: Option<&dyn SummaryAdapter>) -> Preview { + if let Some(a) = adapter + && let Some((text, rule)) = a.live_preview(screen) + { + let text = match a.model_label(screen) { + Some(label) => format!("{label} · {text}"), + None => text, + }; + return Preview { + text, + source: PreviewSource::Anchor, + rule: Some(rule), + frozen: false, + }; + } if screen.alternate_screen() { return match screen.title() { Some(text) => Preview { @@ -159,9 +188,14 @@ pub struct PreviewState { /// Instant of the last rendered title: the min-hold deadline base. last_title_render: Option, last_key: Option, - /// Alt bit at the most recent live resolution; `finalize`'s teardown - /// predicate compares it against the final screen. - last_resolve_alt: bool, + /// Screen mode that produced the rendered preview, stamped at every + /// render commit; `finalize`'s teardown predicate compares it against + /// the final screen. Per-render rather than per-resolve because the + /// exit's own 1049l output wakes the core, so a resolve routinely runs + /// between teardown and reader EOF — a live-resolve bit would flip + /// primary on that tick while the demotion hold still keeps the + /// alt-committed preview rendered. + rendered_under_alt: bool, finalized: bool, } @@ -181,7 +215,7 @@ impl PreviewState { downgrade_pending_since: None, last_title_render: None, last_key: None, - last_resolve_alt: false, + rendered_under_alt: false, finalized: false, } } @@ -195,12 +229,14 @@ impl PreviewState { /// parameter, never read internally, so tests drive the holds with /// synthetic instants. The candidate is recomputed only when the /// resolution key changed; hold expiries commit the carried value - /// without a rescan. + /// without a rescan. `adapter` is the task's summary adapter, fixed for + /// the task's life, so it needs no slot in the resolution key. pub fn resolve( &mut self, now: Instant, finished: bool, screen: &impl ScreenFacts, + adapter: Option<&dyn SummaryAdapter>, ) -> &Preview { if self.finalized { return &self.rendered; @@ -213,22 +249,23 @@ impl PreviewState { finished, ); if self.last_key.as_ref() != Some(&key) { - self.candidate = cascade(screen); + self.candidate = cascade(screen, adapter); self.last_key = Some(key); } - self.last_resolve_alt = screen.alternate_screen(); - self.step(now); + self.step(now, screen.alternate_screen()); &self.rendered } - /// One pass of the candidate-vs-rendered transition table. - fn step(&mut self, now: Instant) { + /// One pass of the candidate-vs-rendered transition table. `alt` is the + /// alt bit of the screen this resolution ran against; every commit — + /// demotion-hold expiries included — stamps it onto the render. + fn step(&mut self, now: Instant, alt: bool) { use std::cmp::Ordering; match self.candidate.source.cmp(&self.rendered.source) { Ordering::Greater => { self.cancel_demotion(); let cand = self.candidate.clone(); - self.render(cand, now); + self.render(cand, now, alt); } Ordering::Equal => { // Rank ≥ rendered: a pending demotion was a one-tick repaint @@ -249,15 +286,15 @@ impl PreviewState { .is_some_and(|t| now.duration_since(t) < TITLE_MIN_HOLD); if !held { let cand = self.candidate.clone(); - self.render(cand, now); + self.render(cand, now, alt); } } // The floor is live output; anchor text changes are - // semantic (unreachable until adapters land). Both + // semantic (a new verb, a new completion row). Both // render immediately. PreviewSource::Floor | PreviewSource::Anchor => { let cand = self.candidate.clone(); - self.render(cand, now); + self.render(cand, now, alt); } } } @@ -268,19 +305,20 @@ impl PreviewState { && let Some(latest) = self.pending_candidate.take() { self.downgrade_pending_since = None; - self.render(latest, now); + self.render(latest, now, alt); } } } } - /// Commit `p` as the rendered preview; title renders stamp the min-hold - /// deadline base. - fn render(&mut self, p: Preview, now: Instant) { + /// Commit `p` as the rendered preview under the screen mode that + /// produced it; title renders stamp the min-hold deadline base. + fn render(&mut self, p: Preview, now: Instant, alt: bool) { if p.source == PreviewSource::Title { self.last_title_render = Some(now); } self.rendered = p; + self.rendered_under_alt = alt; } fn cancel_demotion(&mut self) { @@ -289,29 +327,67 @@ impl PreviewState { } /// Freeze the preview once the task's output is complete. Re-resolves - /// the cascade against the final screen instead of freezing the last - /// rendered value: output can land between the last resolution tick and - /// output-complete (a stream's final `test result: ok` flush), and the - /// final resolve must see it. One carve-out, `alt_torn_down_at_exit`: - /// the task was on the alternate screen at its last live resolution and - /// the final screen is primary, so the exit's 1049l restored pre-launch - /// junk (alt-screen agent CLIs print nothing afterward) and the last - /// rendered preview freezes instead — a stale but meaningful line under - /// a truthful outcome glyph beats shell junk. Holds do not apply: - /// finalization overrides the whole transition table. Idempotent; later - /// resolutions short-circuit to the frozen value. - pub fn finalize(&mut self, screen: &impl ScreenFacts) { + /// the cascade — anchor tier included, which is how a primary-screen + /// agent's final completion row (codex's `• Ran …`) freezes — against + /// the final screen instead of freezing the last rendered value: output + /// can land between the last resolution tick and output-complete (a + /// stream's final `test result: ok` flush), and the final resolve must + /// see it. `exit_line` outranks everything when present: it is the + /// adapter's synthetic exit summary from retained text (a v1 dead slot; + /// see [`SummaryAdapter::exit_preview`]). One carve-out, + /// `alt_torn_down_at_exit`: the rendered preview was committed under + /// the alternate screen and the final screen is primary, so the exit's + /// 1049l restored pre-launch junk (alt-screen agent CLIs print nothing + /// afterward) and the held preview freezes instead — a stale but + /// meaningful line under a truthful outcome glyph beats shell junk. The + /// predicate reads the per-render stamp, not a latched ever-entered-alt + /// bit: a child that leaves the alt screen and then lives on the + /// primary screen has its demotion hold expire and commit the floor, + /// stamped primary, and there the floor IS the honest final value. A + /// sub-hold exit teardown keeps the alt-committed preview rendered + /// precisely because the demotion hold absorbs the flip. The carve-out + /// additionally demands that the final floor still equal the floor + /// snapshotted at the alt exit: a changed floor means the child wrote + /// real primary output after teardown (`tui; echo done`), and the + /// fresh cascade must pick it up even inside the hold. The + /// discriminator is visible content, never byte arrival — claude's + /// exit emits title-reset controls that can land in a chunk after the + /// 1049l, so bytes arrive while nothing visible changes, and a + /// byte/revision test would freeze restored junk for exactly the CLI + /// the carve-out serves. Control-only chunks do not move the floor; + /// written output does. Holds do not apply: finalization overrides the whole transition + /// table. Idempotent; later resolutions short-circuit to the frozen + /// value. + pub fn finalize( + &mut self, + screen: &impl ScreenFacts, + adapter: Option<&dyn SummaryAdapter>, + exit_line: Option, + ) { if self.finalized { return; } self.finalized = true; self.cancel_demotion(); - let alt_torn_down_at_exit = self.last_resolve_alt && !screen.alternate_screen(); + if let Some(text) = exit_line { + self.rendered = Preview { + text, + source: PreviewSource::Anchor, + rule: None, + frozen: true, + }; + return; + } + let alt_torn_down_at_exit = self.rendered_under_alt + && !screen.alternate_screen() + && screen + .alt_leave_floor() + .is_some_and(|snapshot| screen.live_floor() == snapshot); if alt_torn_down_at_exit { self.rendered.frozen = true; return; } - let mut fin = cascade(screen); + let mut fin = cascade(screen, adapter); fin.frozen = true; self.rendered = fin; } @@ -331,6 +407,9 @@ mod tests { alt: bool, title: Option, floor: String, + /// Floor at the last `leave_alt`, mirroring the emulator's + /// alt-exit snapshot. + alt_leave_floor: Option, floor_calls: Cell, } @@ -342,6 +421,7 @@ mod tests { alt: false, title: None, floor: floor.into(), + alt_leave_floor: None, floor_calls: Cell::new(0), } } @@ -359,6 +439,8 @@ mod tests { fn leave_alt(&mut self) { self.alt = false; + // The emulator snapshots the restored floor at the alt exit. + self.alt_leave_floor = Some(self.floor.clone()); self.advance(); } @@ -399,6 +481,134 @@ mod tests { self.floor_calls.set(self.floor_calls.get() + 1); self.floor.clone() } + + fn live_rows(&self) -> Vec { + vec![self.floor.clone()] + } + + fn alt_leave_floor(&self) -> Option<&str> { + self.alt_leave_floor.as_deref() + } + } + + /// Fixed-output adapter: the cascade tests here cover the slot's + /// plumbing (rank, label prefix, freeze), not extraction — that lives + /// with the adapters in `harness::summary`. + struct StubAdapter { + live: Option<(&'static str, &'static str)>, + label: Option<&'static str>, + } + + impl SummaryAdapter for StubAdapter { + fn live_preview(&self, _screen: &dyn ScreenFacts) -> Option<(String, &'static str)> { + self.live.map(|(text, rule)| (text.to_string(), rule)) + } + + fn model_label(&self, _screen: &dyn ScreenFacts) -> Option { + self.label.map(str::to_string) + } + } + + /// The anchor tier outranks the title, carries its rule id, and prepends + /// the model label when the adapter reads one. + #[test] + fn anchor_outranks_title_and_prepends_the_label() { + let now = Instant::now(); + let mut st = PreviewState::new(); + let mut s = FakeScreen::primary("shell"); + s.enter_alt(); + s.set_title("app"); + let adapter = StubAdapter { + live: Some(("Working", "stub:working")), + label: Some("model-x"), + }; + let p = st.resolve(now, false, &s, Some(&adapter)).clone(); + assert_eq!( + (p.text.as_str(), p.source, p.rule), + ( + "model-x · Working", + PreviewSource::Anchor, + Some("stub:working") + ) + ); + + // Without a label the anchor renders the bare status. + let bare = StubAdapter { + live: Some(("Working", "stub:working")), + label: None, + }; + let mut st = PreviewState::new(); + let p = st.resolve(now, false, &s, Some(&bare)).clone(); + assert_eq!(p.text, "Working"); + } + + /// A lost anchor is a demotion: the title returns only after the hold, + /// exactly like any other rank drop. + #[test] + fn anchor_loss_demotes_through_the_hold() { + let t0 = Instant::now(); + let mut st = PreviewState::new(); + let mut s = FakeScreen::primary("shell"); + s.enter_alt(); + s.set_title("app"); + let working = StubAdapter { + live: Some(("Working", "stub:working")), + label: None, + }; + st.resolve(t0, false, &s, Some(&working)); + + let idle = StubAdapter { + live: None, + label: None, + }; + s.advance(); + assert_eq!( + st.resolve(t0, false, &s, Some(&idle)).source, + PreviewSource::Anchor, + "a lost anchor must not demote instantly" + ); + let p = st + .resolve(t0 + DEMOTION_HOLD, false, &s, Some(&idle)) + .clone(); + assert_eq!((p.text.as_str(), p.source), ("app", PreviewSource::Title)); + } + + /// Finalization's re-resolve includes the anchor tier: a completion row + /// present on the final primary screen freezes as an Anchor preview. + #[test] + fn finalize_freezes_the_final_anchor() { + let t0 = Instant::now(); + let mut st = PreviewState::new(); + let mut s = FakeScreen::primary("building"); + let adapter = StubAdapter { + live: Some(("Ran echo ok", "stub:ran")), + label: None, + }; + st.resolve(t0, false, &s, None); + + s.advance(); + st.finalize(&s, Some(&adapter), None); + let p = st.resolve(t0, true, &s, Some(&adapter)).clone(); + assert_eq!( + (p.text.as_str(), p.source, p.rule, p.frozen), + ("Ran echo ok", PreviewSource::Anchor, Some("stub:ran"), true) + ); + } + + /// An adapter exit line outranks the final screen and freezes verbatim. + /// No v1 adapter produces one; the slot's plumbing is pinned here. + #[test] + fn finalize_prefers_an_adapter_exit_line() { + let t0 = Instant::now(); + let mut st = PreviewState::new(); + let s = FakeScreen::primary("junk floor"); + st.resolve(t0, false, &s, None); + st.finalize(&s, None, Some("session done".to_string())); + let p = st.resolve(t0, true, &s, None).clone(); + assert_eq!( + (p.text.as_str(), p.source, p.frozen), + ("session done", PreviewSource::Anchor, true) + ); } /// Each cascade tier maps one emulator fact to one source. @@ -408,7 +618,7 @@ mod tests { let mut st = PreviewState::new(); let s = FakeScreen::primary("last row"); - let p = st.resolve(now, false, &s).clone(); + let p = st.resolve(now, false, &s, None).clone(); assert_eq!( (p.text.as_str(), p.source, p.frozen), ("last row", PreviewSource::Floor, false) @@ -418,18 +628,18 @@ mod tests { let mut s = FakeScreen::primary("shell"); s.enter_alt(); s.set_title("app"); - let p = st.resolve(now, false, &s).clone(); + let p = st.resolve(now, false, &s, None).clone(); assert_eq!((p.text.as_str(), p.source), ("app", PreviewSource::Title)); let mut st = PreviewState::new(); let mut s = FakeScreen::primary("shell"); s.enter_alt(); - let p = st.resolve(now, false, &s).clone(); + let p = st.resolve(now, false, &s, None).clone(); assert_eq!((p.text.as_str(), p.source), (MARKER, PreviewSource::Marker)); let mut st = PreviewState::new(); let s = FakeScreen::primary(""); - let p = st.resolve(now, false, &s).clone(); + let p = st.resolve(now, false, &s, None).clone(); assert_eq!((p.text.as_str(), p.source), ("", PreviewSource::Floor)); } @@ -439,14 +649,17 @@ mod tests { let now = Instant::now(); let mut st = PreviewState::new(); let mut s = FakeScreen::primary("building"); - assert_eq!(st.resolve(now, false, &s).source, PreviewSource::Floor); + assert_eq!( + st.resolve(now, false, &s, None).source, + PreviewSource::Floor + ); s.enter_alt(); - let p = st.resolve(now, false, &s).clone(); + let p = st.resolve(now, false, &s, None).clone(); assert_eq!((p.text.as_str(), p.source), (MARKER, PreviewSource::Marker)); s.set_title("app"); - let p = st.resolve(now, false, &s).clone(); + let p = st.resolve(now, false, &s, None).clone(); assert_eq!((p.text.as_str(), p.source), ("app", PreviewSource::Title)); } @@ -459,17 +672,20 @@ mod tests { let mut s = FakeScreen::primary("shell"); s.enter_alt(); s.set_title("app"); - st.resolve(t0, false, &s); + st.resolve(t0, false, &s, None); s.clear_title(); assert_eq!( - st.resolve(t0, false, &s).source, + st.resolve(t0, false, &s, None).source, PreviewSource::Title, "a demotion must not render instantly" ); let inside = t0 + DEMOTION_HOLD - Duration::from_millis(1); - assert_eq!(st.resolve(inside, false, &s).source, PreviewSource::Title); - let p = st.resolve(t0 + DEMOTION_HOLD, false, &s).clone(); + assert_eq!( + st.resolve(inside, false, &s, None).source, + PreviewSource::Title + ); + let p = st.resolve(t0 + DEMOTION_HOLD, false, &s, None).clone(); assert_eq!((p.text.as_str(), p.source), (MARKER, PreviewSource::Marker)); } @@ -483,25 +699,25 @@ mod tests { let mut s = FakeScreen::primary("shell"); s.enter_alt(); s.set_title("app"); - st.resolve(t0, false, &s); + st.resolve(t0, false, &s, None); s.clear_title(); - st.resolve(t0, false, &s); + st.resolve(t0, false, &s, None); // The title returns inside the hold: cancel, no visible change. s.set_title("app"); - let p = st.resolve(t0 + ms(300), false, &s).clone(); + let p = st.resolve(t0 + ms(300), false, &s, None).clone(); assert_eq!((p.text.as_str(), p.source), ("app", PreviewSource::Title)); // The next demotion measures from its own start, not the old stamp. s.clear_title(); - st.resolve(t0 + ms(400), false, &s); + st.resolve(t0 + ms(400), false, &s, None); assert_eq!( - st.resolve(t0 + ms(900), false, &s).source, + st.resolve(t0 + ms(900), false, &s, None).source, PreviewSource::Title, "the canceled hold must not shorten the fresh one" ); assert_eq!( - st.resolve(t0 + ms(1_000), false, &s).source, + st.resolve(t0 + ms(1_000), false, &s, None).source, PreviewSource::Marker ); } @@ -515,19 +731,20 @@ mod tests { let mut s = FakeScreen::primary("shell"); s.enter_alt(); s.set_title("app"); - st.resolve(t0, false, &s); + st.resolve(t0, false, &s, None); // First demoted candidate: the marker. s.clear_title(); - st.resolve(t0, false, &s); + st.resolve(t0, false, &s, None); // The pending candidate flaps to a floor; the timer keeps t0. s.leave_alt(); s.set_floor("done 3 tests"); assert_eq!( - st.resolve(t0 + Duration::from_millis(300), false, &s).source, + st.resolve(t0 + Duration::from_millis(300), false, &s, None) + .source, PreviewSource::Title ); - let p = st.resolve(t0 + DEMOTION_HOLD, false, &s).clone(); + let p = st.resolve(t0 + DEMOTION_HOLD, false, &s, None).clone(); assert_eq!( (p.text.as_str(), p.source), ("done 3 tests", PreviewSource::Floor), @@ -545,14 +762,14 @@ mod tests { let mut s = FakeScreen::primary("shell"); s.enter_alt(); s.set_title("one"); - assert_eq!(st.resolve(t0, false, &s).text, "one"); + assert_eq!(st.resolve(t0, false, &s, None).text, "one"); s.set_title("two"); - assert_eq!(st.resolve(t0 + ms(200), false, &s).text, "one"); + assert_eq!(st.resolve(t0 + ms(200), false, &s, None).text, "one"); s.set_title("three"); - assert_eq!(st.resolve(t0 + ms(300), false, &s).text, "one"); + assert_eq!(st.resolve(t0 + ms(300), false, &s, None).text, "one"); assert_eq!( - st.resolve(t0 + TITLE_MIN_HOLD, false, &s).text, + st.resolve(t0 + TITLE_MIN_HOLD, false, &s, None).text, "three", "the newest candidate wins at the deadline" ); @@ -564,9 +781,9 @@ mod tests { let t0 = Instant::now(); let mut st = PreviewState::new(); let mut s = FakeScreen::primary("compiling foo"); - assert_eq!(st.resolve(t0, false, &s).text, "compiling foo"); + assert_eq!(st.resolve(t0, false, &s, None).text, "compiling foo"); s.set_floor("compiling bar"); - assert_eq!(st.resolve(t0, false, &s).text, "compiling bar"); + assert_eq!(st.resolve(t0, false, &s, None).text, "compiling bar"); } /// An unchanged resolution key carries the candidate without re-reading @@ -577,17 +794,36 @@ mod tests { let ms = Duration::from_millis; let mut st = PreviewState::new(); let mut s = FakeScreen::primary("steady"); - st.resolve(t0, false, &s); + st.resolve(t0, false, &s, None); assert_eq!(s.floor_calls.get(), 1); - st.resolve(t0 + ms(200), false, &s); + st.resolve(t0 + ms(200), false, &s, None); assert_eq!(s.floor_calls.get(), 1, "unchanged key must not re-read"); s.advance(); - st.resolve(t0 + ms(400), false, &s); + st.resolve(t0 + ms(400), false, &s, None); assert_eq!(s.floor_calls.get(), 2, "a revision bump must recompute"); } + /// A resize-shaped change — revision bumped, floor reflowed, alt bit, + /// epoch, and title untouched — invalidates the key and recomputes the + /// candidate (the emulator bumps its revision on resize for exactly + /// this). + #[test] + fn a_resize_shaped_revision_bump_recomputes_the_candidate() { + let t0 = Instant::now(); + let mut st = PreviewState::new(); + let mut s = FakeScreen::primary("a long row that fit"); + assert_eq!(st.resolve(t0, false, &s, None).text, "a long row that fit"); + + s.set_floor("a long row"); + assert_eq!( + st.resolve(t0, false, &s, None).text, + "a long row", + "the reflowed floor must render, not the carried candidate" + ); + } + /// Primary-at-exit finalization re-resolves: output that landed after /// the last resolution tick reaches the frozen preview. #[test] @@ -595,11 +831,11 @@ mod tests { let t0 = Instant::now(); let mut st = PreviewState::new(); let mut s = FakeScreen::primary("running"); - st.resolve(t0, false, &s); + st.resolve(t0, false, &s, None); s.set_floor("test result: ok"); - st.finalize(&s); - let p = st.resolve(t0, true, &s).clone(); + st.finalize(&s, None, None); + let p = st.resolve(t0, true, &s, None).clone(); assert_eq!( (p.text.as_str(), p.source, p.frozen), ("test result: ok", PreviewSource::Floor, true) @@ -615,12 +851,12 @@ mod tests { let mut s = FakeScreen::primary("prelaunch junk"); s.enter_alt(); s.set_title("agent: working"); - st.resolve(t0, false, &s); + st.resolve(t0, false, &s, None); // The exit's 1049l lands with no live resolution in between. s.leave_alt(); - st.finalize(&s); - let p = st.resolve(t0, true, &s).clone(); + st.finalize(&s, None, None); + let p = st.resolve(t0, true, &s, None).clone(); assert_eq!( (p.text.as_str(), p.source, p.frozen), ("agent: working", PreviewSource::Title, true) @@ -628,12 +864,232 @@ mod tests { s.set_floor("stray"); assert_eq!( - st.resolve(t0 + Duration::from_secs(5), true, &s).text, + st.resolve(t0 + Duration::from_secs(5), true, &s, None).text, "agent: working", "resolution must short-circuit to the frozen value" ); } + /// The likely interleaving, not the lucky one: the 1049l output itself + /// wakes the core, so a resolve routinely runs between alt teardown and + /// reader EOF. The teardown stamp travels with the rendered preview — + /// the demotion hold keeps the alt-committed title rendered through + /// that tick — so finalization must still keep it over the restored + /// primary junk. + #[test] + fn finalize_keeps_the_render_when_a_resolve_saw_the_teardown() { + let t0 = Instant::now(); + let mut st = PreviewState::new(); + let mut s = FakeScreen::primary("prelaunch junk"); + s.enter_alt(); + s.set_title("agent: working"); + st.resolve(t0, false, &s, None); + + // Teardown lands and a tick resolves before output completes. + s.leave_alt(); + let p = st.resolve(t0, false, &s, None).clone(); + assert_eq!( + p.source, + PreviewSource::Title, + "premise: the demotion hold keeps the title rendered" + ); + + st.finalize(&s, None, None); + let p = st.resolve(t0, true, &s, None).clone(); + assert_eq!( + (p.text.as_str(), p.source, p.frozen), + ("agent: working", PreviewSource::Title, true) + ); + } + + /// The stamp is per-render, not a latched ever-entered-alt bit: a child + /// that leaves the alt screen and lives on the primary screen long + /// enough for the demotion hold to commit gets a primary-stamped floor, + /// and finalization trusts the final screen — the floor is the honest + /// final value there. + #[test] + fn finalize_trusts_the_screen_after_a_primary_commit() { + let t0 = Instant::now(); + let mut st = PreviewState::new(); + let mut s = FakeScreen::primary("prelaunch junk"); + s.enter_alt(); + s.set_title("agent: working"); + st.resolve(t0, false, &s, None); + + // The child returns to the primary screen and keeps printing; the + // hold expires and commits the floor, stamped primary. + s.leave_alt(); + s.set_floor("wrote 12 files"); + st.resolve(t0, false, &s, None); + let p = st.resolve(t0 + DEMOTION_HOLD, false, &s, None).clone(); + assert_eq!( + (p.text.as_str(), p.source), + ("wrote 12 files", PreviewSource::Floor), + "premise: the hold committed the primary floor" + ); + + s.set_floor("exit summary"); + st.finalize(&s, None, None); + let p = st.resolve(t0 + DEMOTION_HOLD, true, &s, None).clone(); + assert_eq!( + (p.text.as_str(), p.source, p.frozen), + ("exit summary", PreviewSource::Floor, true), + "the primary-stamped render must not resurrect the title" + ); + } + + /// `tui; echo done`: the child leaves the alt screen, prints a real + /// final line, and exits inside the demotion hold. The changed floor + /// defeats the teardown carve-out — the fresh cascade freezes the line + /// instead of a stale title discarding it. + #[test] + fn finalize_freezes_primary_output_written_after_teardown() { + let t0 = Instant::now(); + let mut st = PreviewState::new(); + let mut s = FakeScreen::primary("prelaunch junk"); + s.enter_alt(); + s.set_title("agent: working"); + st.resolve(t0, false, &s, None); + + // Teardown observed (snapshot: "prelaunch junk"), title still held. + s.leave_alt(); + assert_eq!(st.resolve(t0, false, &s, None).source, PreviewSource::Title); + + // A real final line lands before exit, inside the hold. + s.set_floor("done"); + st.finalize(&s, None, None); + let p = st.resolve(t0, true, &s, None).clone(); + assert_eq!( + (p.text.as_str(), p.source, p.frozen), + ("done", PreviewSource::Floor, true), + "legitimate primary output must not be discarded" + ); + } + + /// Control-only chunks after teardown — claude's exit emits title + /// resets that can land after the 1049l — advance the revision without + /// moving the floor. The carve-out compares visible content, not byte + /// arrival, so the held preview still freezes. + #[test] + fn finalize_holds_through_control_only_output_after_teardown() { + let t0 = Instant::now(); + let mut st = PreviewState::new(); + let mut s = FakeScreen::primary("prelaunch junk"); + s.enter_alt(); + s.set_title("agent: working"); + st.resolve(t0, false, &s, None); + + s.leave_alt(); + assert_eq!(st.resolve(t0, false, &s, None).source, PreviewSource::Title); + + // A later advance changes the revision (and drops the title) but + // leaves the floor untouched: nothing visible moved. + s.clear_title(); + assert_eq!(st.resolve(t0, false, &s, None).source, PreviewSource::Title); + + st.finalize(&s, None, None); + let p = st.resolve(t0, true, &s, None).clone(); + assert_eq!( + (p.text.as_str(), p.source, p.frozen), + ("agent: working", PreviewSource::Title, true), + "control-only chunks must not defeat the carve-out" + ); + } + + /// One read coalescing the 1049l with the successor's line — routine + /// on a loaded machine, since PTY reads do not preserve write + /// boundaries. The alt-exit observer snapshots at the mode event, + /// before the same read's successor bytes parse, so the finalize + /// comparison sees the line as new output and freezes it: + /// deterministic in read boundaries. + #[test] + fn finalize_freezes_coalesced_output_after_teardown() { + let t0 = Instant::now(); + let mut st = PreviewState::new(); + let mut emu = Emulator::new(24, 80, 100); + emu.process(b"prelaunch junk\r\n"); + emu.process(b"\x1b[?1049h\x1b]0;working\x07app body"); + assert_eq!( + st.resolve(t0, false, &emu, None).source, + PreviewSource::Title, + "premise: the title rendered under the alt screen" + ); + + // Teardown and the real final line arrive in ONE read. + emu.process(b"\x1b[?1049ldone\r\n"); + assert_eq!( + emu.alt_leave_floor(), + Some("prelaunch junk"), + "premise: the snapshot is the restore, not the successor line" + ); + st.finalize(&emu, None, None); + let p = st.resolve(t0, true, &emu, None).clone(); + assert_eq!( + (p.text.as_str(), p.source, p.frozen), + ("done", PreviewSource::Floor, true) + ); + } + + /// A resize between teardown and finalize reflows the restored junk. + /// The emulator re-snapshots the reflowed floor (both comparison sides + /// move together), so the reflow does not read as post-teardown output + /// and the held title still freezes. + #[test] + fn finalize_holds_across_a_resize_after_teardown() { + let t0 = Instant::now(); + let mut st = PreviewState::new(); + let mut emu = Emulator::new(24, 40, 100); + emu.process(b"prelaunch junk that will wrap\r\n"); + emu.process(b"\x1b[?1049h\x1b]0;working\x07app body"); + assert_eq!( + st.resolve(t0, false, &emu, None).source, + PreviewSource::Title + ); + + emu.process(b"\x1b[?1049l"); + let before = emu.live_floor(); + emu.resize(24, 20); + assert_ne!( + emu.live_floor(), + before, + "premise: the reflow moved the floor" + ); + st.finalize(&emu, None, None); + let p = st.resolve(t0, true, &emu, None).clone(); + assert_eq!( + (p.text.as_str(), p.source, p.frozen), + ("working", PreviewSource::Title, true), + "a reflow is not post-teardown output" + ); + } + + /// The dirty variant: real output after teardown, then a resize. The + /// pre-resize floor already differs from the snapshot, the mismatch is + /// evidence and stands, and finalize freezes the output floor. + #[test] + fn finalize_freezes_output_across_a_resize_after_teardown() { + let t0 = Instant::now(); + let mut st = PreviewState::new(); + let mut emu = Emulator::new(24, 40, 100); + emu.process(b"prelaunch junk that will wrap\r\n"); + emu.process(b"\x1b[?1049h\x1b]0;working\x07app body"); + assert_eq!( + st.resolve(t0, false, &emu, None).source, + PreviewSource::Title + ); + + emu.process(b"\x1b[?1049l"); + emu.process(b"done\r\n"); + emu.resize(24, 20); + st.finalize(&emu, None, None); + let p = st.resolve(t0, true, &emu, None).clone(); + assert_eq!( + (p.text.as_str(), p.source, p.frozen), + ("done", PreviewSource::Floor, true), + "real post-teardown output must survive the resize" + ); + } + /// Death on the alt screen is not a teardown: the final alt screen is /// authoritative and freezes as-is, catching a last-moment title change. #[test] @@ -643,11 +1099,11 @@ mod tests { let mut s = FakeScreen::primary("shell"); s.enter_alt(); s.set_title("step 1"); - st.resolve(t0, false, &s); + st.resolve(t0, false, &s, None); s.set_title("step 2: done"); - st.finalize(&s); - let p = st.resolve(t0, true, &s).clone(); + st.finalize(&s, None, None); + let p = st.resolve(t0, true, &s, None).clone(); assert_eq!( (p.text.as_str(), p.source, p.frozen), ("step 2: done", PreviewSource::Title, true) diff --git a/src/protocol.rs b/src/protocol.rs index 4f18575..680eaae 100644 --- a/src/protocol.rs +++ b/src/protocol.rs @@ -1488,7 +1488,11 @@ mod tests { ..base.clone() }]); let (k, p) = encode_event(&ruled); - assert!(!String::from_utf8(p.clone()).unwrap().contains("claude-status")); + assert!( + !String::from_utf8(p.clone()) + .unwrap() + .contains("claude-status") + ); match decode_event(k, &p) { Some(Event::Tasks(v)) => { assert_eq!(v[0].source, PreviewSource::Anchor); diff --git a/src/supervisor_tests.rs b/src/supervisor_tests.rs index b7c6c8e..c1e3fef 100644 --- a/src/supervisor_tests.rs +++ b/src/supervisor_tests.rs @@ -2718,9 +2718,15 @@ fn config_toml_notify_chains_through_the_injected_script() { argv.iter().any(|a| a.starts_with("notify=[")), "a chained spawn must still inject the override; argv: {argv:?}" ); + // Existence is not completion: the stub's `>` creates the record when + // the shell opens it, before printf writes a byte, so a reader in that + // gap sees an empty file. Gate on the full expected content instead. + let expected = format!("turn-ended\n{payload}\n"); assert!( - reap_until(&mut s, Duration::from_secs(5), |_| record.exists()), - "the chained notifier never ran" + reap_until(&mut s, Duration::from_secs(5), |_| { + std::fs::read_to_string(&record).is_ok_and(|r| r == expected) + }), + "the chained notifier never wrote its complete record" ); assert_eq!( std::fs::read_to_string(dir.join("chainenv")).unwrap(), diff --git a/src/task.rs b/src/task.rs index 964352d..f70d444 100644 --- a/src/task.rs +++ b/src/task.rs @@ -339,6 +339,12 @@ pub struct Task { pub harness: Option<&'static dyn crate::harness::Harness>, /// Harness home resolved from this run's launch environment. pub harness_home: Option, + /// Summary adapter selected at spawn from the requested command, + /// deliberately not keyed off `harness`: that field is set only when + /// detection *and* capture-asset install succeed, and an + /// instrumentation failure must not silently kill dashboard summaries. + /// Adapter output is display-only (`harness::summary` module docs). + pub summary_adapter: Option<&'static dyn crate::harness::summary::SummaryAdapter>, /// Run number used to give each rerun a distinct capture path. pub run: u32, /// Session ID injected or recognized at spawn. Later capture data or an @@ -578,6 +584,7 @@ impl Task { name: None, harness: None, harness_home: None, + summary_adapter: crate::harness::summary::select(command), run: 0, resume_id: None, capture_file: None, @@ -747,20 +754,27 @@ impl Task { pub fn resolve_preview(&mut self, now: Instant) -> Preview { let finished = self.finished.is_some(); let emu = grid(&self.parser); - self.preview.resolve(now, finished, &*emu).clone() + self.preview + .resolve(now, finished, &*emu, self.summary_adapter) + .clone() } /// Freeze the preview once per task life at `output_complete`. Lands any /// open `?2026` frame first so the final resolve sees every byte; /// `scrape_exit_hint` does the same for its own read, and whichever runs - /// second no-ops. + /// second no-ops. The adapter's `exit_preview` slot reads retained text, + /// paid for only when an adapter exists (no v1 adapter returns one). pub(crate) fn finalize_preview(&mut self) { if self.preview.finalized() || !self.output_complete() { return; } let mut emu = grid(&self.parser); let _ = emu.finish_output(); - self.preview.finalize(&*emu); + let exit_line = self + .summary_adapter + .and_then(|a| a.exit_preview(&emu.text_with_history())); + self.preview + .finalize(&*emu, self.summary_adapter, exit_line); } /// Full screen as ANSI bytes for attached mode, plus cursor state so we can @@ -1875,9 +1889,8 @@ mod tests { #[test] fn scrape_exit_hint_lands_an_open_sync_frame() { const ID: &str = "7f3b9c1e-5a2d-4e8f-9b6a-0c4d2e8f1a3b"; - let cmd = format!( - "printf '\\033[?2026hResume this session with:\\nclaude --resume {ID}\\n'" - ); + let cmd = + format!("printf '\\033[?2026hResume this session with:\\nclaude --resume {ID}\\n'"); let mut t = Task::spawn(21, &cmd, &cmd, &here(), 24, 80, &sh_env(), no_waker()).unwrap(); t.harness = Some(&crate::harness::Claude); assert!( @@ -1930,28 +1943,47 @@ mod tests { /// Alt teardown at exit: the child enters the alt screen, titles it, and /// exits through 1049l. The restored primary junk must not replace the - /// last rendered preview; it freezes with its source preserved. + /// last rendered preview; it freezes with its source preserved. The + /// teardown is fenced behind a second flag so the test can force a + /// resolve on the torn-down screen before EOF — the interleaving the + /// core actually produces, since the 1049l output is what wakes it. #[test] fn finalize_preview_keeps_the_last_render_across_alt_teardown() { use crate::preview::PreviewSource; let dir = temp("task_final_alt"); - let flag = dir.join("flag"); + let teardown = dir.join("teardown"); + let exit = dir.join("exit"); let cmd = format!( "printf 'prelaunch junk\\n'; \ printf '\\033[?1049h\\033]0;working\\007app body'; \ - until [ -e '{}' ]; do sleep 0.05; done; printf '\\033[?1049l'", - flag.display() + until [ -e '{td}' ]; do sleep 0.05; done; printf '\\033[?1049l'; \ + until [ -e '{ex}' ]; do sleep 0.05; done", + td = teardown.display(), + ex = exit.display() ); let mut t = Task::spawn(41, &cmd, &cmd, &here(), 24, 80, &sh_env(), no_waker()).unwrap(); - // Resolve until the title renders, so the last live resolution sees - // the alt screen; no resolves run between the flag and EOF. assert!( wait_until(Duration::from_secs(5), || { t.resolve_preview(Instant::now()).source == PreviewSource::Title }), "title never rendered" ); - std::fs::write(&flag, b"").unwrap(); + std::fs::write(&teardown, b"").unwrap(); + assert!( + wait_until(Duration::from_secs(60), || { + !grid(&t.parser).alternate_screen() + }), + "teardown never reached the grid" + ); + // The killer tick: resolve against the torn-down screen while the + // child still lives. The demotion hold keeps the title rendered, + // and the render's alt stamp must survive this resolve. + assert_eq!( + t.resolve_preview(Instant::now()).source, + PreviewSource::Title, + "premise: the demotion hold keeps the title rendered" + ); + std::fs::write(&exit, b"").unwrap(); assert!( wait_until(Duration::from_secs(60), || { t.poll_exit().unwrap(); @@ -1973,6 +2005,107 @@ mod tests { let _ = std::fs::remove_dir_all(&dir); } + /// `tui; echo done` at the PTY level: teardown, then a real primary + /// line, then exit inside the demotion hold. The frozen preview is the + /// line, not the stale title. The 1049l and the line are written + /// together, without a separating sleep: the alt-exit observer + /// snapshots at the mode event, so the read boundary is immaterial — + /// coalesced or split, the result is the same. That is the fix's + /// point. + #[test] + fn finalize_preview_freezes_primary_output_after_alt_teardown() { + use crate::preview::PreviewSource; + let dir = temp("task_final_alt_output"); + let flag = dir.join("flag"); + let cmd = format!( + "printf 'prelaunch junk\\n'; \ + printf '\\033[?1049h\\033]0;working\\007app body'; \ + until [ -e '{}' ]; do sleep 0.05; done; \ + printf '\\033[?1049ldone\\n'", + flag.display() + ); + let mut t = Task::spawn(43, &cmd, &cmd, &here(), 24, 80, &sh_env(), no_waker()).unwrap(); + assert!( + wait_until(Duration::from_secs(5), || { + t.resolve_preview(Instant::now()).source == PreviewSource::Title + }), + "title never rendered" + ); + std::fs::write(&flag, b"").unwrap(); + assert!( + wait_until(Duration::from_secs(60), || { + t.poll_exit().unwrap(); + t.output_complete() + }), + "child never completed" + ); + t.finalize_preview(); + let p = t.resolve_preview(Instant::now()); + assert_eq!( + (p.text.as_str(), p.source, p.frozen), + ("done", PreviewSource::Floor, true), + "the post-teardown line must win over the stale title" + ); + let _ = std::fs::remove_dir_all(&dir); + } + + /// Full cascade end to end: an agent-shaped child paints a codex-shaped + /// working screen into a real PTY, repaints it with the completion row, + /// and exits; the summary adapter anchors the live preview and + /// finalization freezes the completion. The adapter is installed + /// manually because the child is `printf` under `$SHELL`, not `codex` — + /// no real agent CLI runs here. + #[test] + fn summary_adapter_anchors_live_and_freezes_completion_at_exit() { + use crate::preview::PreviewSource; + let dir = temp("task_anchor_e2e"); + let flag = dir.join("flag"); + let cmd = format!( + "printf '• Working (3s • esc to interrupt)\\n\\n› \\n synth-model high · 1 in · 2 out'; \ + until [ -e '{f}' ]; do sleep 0.05; done; \ + printf '\\033[H\\033[2J• Ran echo ok\\n\\n› \\n synth-model high · 2 in · 3 out'", + f = flag.display() + ); + let mut t = Task::spawn(42, &cmd, &cmd, &here(), 24, 80, &sh_env(), no_waker()).unwrap(); + assert!(t.summary_adapter.is_none(), "printf selects nothing"); + t.summary_adapter = crate::harness::summary::select("codex"); + assert!(t.summary_adapter.is_some()); + + let mut live = t.resolve_preview(Instant::now()); + assert!( + wait_until(Duration::from_secs(5), || { + live = t.resolve_preview(Instant::now()); + live.source == PreviewSource::Anchor + }), + "anchor never resolved, last preview {live:?}" + ); + assert_eq!( + (live.text.as_str(), live.rule, live.frozen), + ("synth-model high · Working", Some("codex:working"), false) + ); + + std::fs::write(&flag, b"").unwrap(); + assert!( + wait_until(Duration::from_secs(60), || { + t.poll_exit().unwrap(); + t.output_complete() + }), + "child never completed" + ); + t.finalize_preview(); + let p = t.resolve_preview(Instant::now()); + assert_eq!( + (p.text.as_str(), p.source, p.rule, p.frozen), + ( + "synth-model high · Ran echo ok", + PreviewSource::Anchor, + Some("codex:ran"), + true + ) + ); + let _ = std::fs::remove_dir_all(&dir); + } + /// A child's cursor-position probe is answered on the wire: the reply /// crosses the reader thread → allowlist → writer worker → PTY, and only /// the advertised shape arrives. The child first sends secondary DA (a diff --git a/src/terminal/emulator.rs b/src/terminal/emulator.rs index a886a66..3ad3f8a 100644 --- a/src/terminal/emulator.rs +++ b/src/terminal/emulator.rs @@ -15,7 +15,7 @@ use alacritty_terminal::{ Config, TermMode, cell::{Cell, Flags}, }, - vte::ansi::Processor, + vte::ansi::{self as vt, Handler, Processor}, }; /// Mouse event classes requested by the child through DECSET 1000/1002/1003. @@ -35,47 +35,26 @@ pub enum MouseProtocolEncoding { Sgr, } -/// The last title event of an advance, if any: `Some(text)` for a set, -/// `None` for a reset (RIS or a title-stack pop). One slot, not a queue — -/// only the final title of a chunk can be displayed, so earlier ones carry -/// no information. Chunk-granular observation is the documented contract. -type PendingTitle = Option>; - -/// Routes the backend's `Event::PtyWrite` probe responses into a buffer and -/// records the advance's last title event. The listener fires inside -/// `Processor::advance`, while the caller holds the emulator lock, so it only -/// stores, never blocks; the caller drains, filters, and sanitizes after -/// `advance` returns. Every other backend event (clipboard, color requests, -/// bell) is discarded here: the default-deny probe policy starts with what -/// never gets buffered. +/// Routes the backend's `Event::PtyWrite` probe responses into a buffer. +/// The listener fires inside `Processor::advance`, while the caller holds +/// the emulator lock, so it only stores, never blocks; the caller drains, +/// filters, and sanitizes after `advance` returns. Every other backend +/// event (titles, clipboard, color requests, bell) is discarded here: the +/// default-deny probe policy starts with what never gets buffered, and +/// titles are observed at their handler event by [`ObservedTerm`], not +/// through this listener. pub struct ProbeSink { responses: Arc>>, - title_event: Arc>, } impl EventListener for ProbeSink { fn send_event(&self, event: Event) { - match event { - Event::PtyWrite(text) => { - let mut buf = self - .responses - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner); - buf.push(text); - } - Event::Title(title) => { - *self - .title_event - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner) = Some(Some(title)); - } - Event::ResetTitle => { - *self - .title_event - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner) = Some(None); - } - _ => {} + if let Event::PtyWrite(text) = event { + let mut buf = self + .responses + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + buf.push(text); } } } @@ -182,10 +161,11 @@ fn sanitize_title(raw: &str) -> String { out } -/// A sanitized title plus the alt-screen epoch it was captured in. The title -/// is honored only while its epoch is current: each alt-screen entry starts a -/// new epoch, so a title from the shell (or a previous full-screen app) never -/// labels the app that replaced it. +/// A sanitized title plus the alt-screen epoch it was captured in — at the +/// title event itself, or by promotion from staging at the entry event (see +/// [`ObservedTerm::observe_title`]). The title is honored only while its +/// epoch is current: each alt-screen entry starts a new epoch, so a title +/// from a previous full-screen app never labels the app that replaced it. struct CapturedTitle { text: String, alt_epoch: u64, @@ -205,16 +185,9 @@ pub struct Emulator { term: Term, parser: Processor, responses: Arc>>, - /// Written by the listener during an advance, consumed by - /// `observe_advance` afterwards. - title_event: Arc>, - captured_title: Option, - /// Count of alt-screen entries. Compared against - /// `CapturedTitle::alt_epoch` to expire titles at app boundaries. - alt_epoch: u64, - /// Alt bit as of the last bookkeeping step: entry detection needs the - /// previous value, and the mode register only holds the current one. - last_alt_screen: bool, + /// Alt-screen and title facts, advanced at parser-event granularity by + /// [`ObservedTerm`] during the parse itself. + alt: AltScreen, /// Bumped once per grid advance; cheap change detection for consumers /// that poll the grid. revision: u64, @@ -277,7 +250,6 @@ impl Emulator { /// A fresh `rows`×`cols` grid retaining `scrollback` rows of history. pub fn new(rows: u16, cols: u16, scrollback: usize) -> Self { let responses = Arc::new(Mutex::new(Vec::new())); - let title_event = Arc::new(Mutex::new(None)); let config = Config { // Use fleetcom's per-task history limit instead of the backend // default. @@ -292,59 +264,36 @@ impl Emulator { }, ProbeSink { responses: Arc::clone(&responses), - title_event: Arc::clone(&title_event), }, ); Self { term, parser: Processor::new(), responses, - title_event, - captured_title: None, - alt_epoch: 0, - last_alt_screen: false, + alt: AltScreen::default(), revision: 0, bytes_since_sweep: 0, } } /// The shared bookkeeping step behind every grid advance — `process` and - /// both sync-frame landings — so all three paths observe identical state - /// transitions. Ordering is load-bearing: the epoch compare precedes - /// title stamping, so a title anywhere in a chunk that also enters the - /// alt screen lands in the new epoch (children emit the title bytes just - /// before DECSET 1049). + /// both sync-frame landings. Nothing but the revision bump remains: + /// epochs, teardown snapshots, and title ownership are all observed at + /// their parser events by [`ObservedTerm`], so no preview semantics + /// depend on where PTY reads split. fn observe_advance(&mut self) { self.revision += 1; - let alt = self.alternate_screen(); - if alt && !self.last_alt_screen { - self.alt_epoch += 1; - } - self.last_alt_screen = alt; - let pending = self - .title_event - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner) - .take(); - if let Some(event) = pending { - // A reset and a title that sanitizes to nothing both unset the - // capture: `printf '\x1b]0;\x07'` clears a title, it does not - // freeze a stale one. - self.captured_title = event - .map(|raw| sanitize_title(&raw)) - .filter(|text| !text.is_empty()) - .map(|text| CapturedTitle { - text, - alt_epoch: self.alt_epoch, - }); - } } /// Parse raw child output into the grid. Returns the probe replies the /// backend generated that pass the allowlist, in generation order; the /// caller owns delivering them to the child. pub fn process(&mut self, bytes: &[u8]) -> Vec { - self.parser.advance(&mut self.term, bytes); + let mut observed = ObservedTerm { + term: &mut self.term, + alt: &mut self.alt, + }; + self.parser.advance(&mut observed, bytes); self.observe_advance(); self.bytes_since_sweep = self.bytes_since_sweep.saturating_add(bytes.len()); if self.bytes_since_sweep >= SWEEP_INTERVAL_BYTES { @@ -370,7 +319,11 @@ impl Emulator { if !expired { return Vec::new(); } - self.parser.stop_sync(&mut self.term); + let mut observed = ObservedTerm { + term: &mut self.term, + alt: &mut self.alt, + }; + self.parser.stop_sync(&mut observed); self.observe_advance(); self.drain_allowed() } @@ -385,7 +338,11 @@ impl Emulator { if self.parser.sync_timeout().sync_timeout().is_none() { return Vec::new(); } - self.parser.stop_sync(&mut self.term); + let mut observed = ObservedTerm { + term: &mut self.term, + alt: &mut self.alt, + }; + self.parser.stop_sync(&mut observed); self.observe_advance(); self.drain_allowed() } @@ -512,10 +469,32 @@ impl Emulator { /// Resize the grid to `rows`×`cols`. pub fn resize(&mut self, rows: u16, cols: u16) { + // The teardown snapshot must survive the reflow: when the floor + // still equals it (nothing written since the alt exit), the resize + // rewraps one side of the finalize comparison, so re-snapshot the + // reflowed floor afterwards to keep both sides equal. When they + // already differ, real output arrived and the mismatch is evidence + // — leave it standing. Never simply clear: a cleared snapshot + // fails the teardown carve-out and freezes restored junk, the + // wrong direction. Exact equality in both branches; no heuristic. + let untouched = self + .alt + .leave_floor + .as_deref() + .is_some_and(|snapshot| live_floor_of(&self.term) == snapshot); self.term.resize(GridSize { lines: rows as usize, columns: cols as usize, }); + if untouched { + self.alt.leave_floor = Some(live_floor_of(&self.term)); + } + // A resize reflows the grid — wrapping, row positions — without any + // bytes arriving, so revision-keyed pollers must re-read. A bare + // bump, not `observe_advance`: that step consumes byte-driven state + // (a pending title event) that a resize never produces, and + // consuming it here would misattribute it. + self.revision += 1; } /// Grid size as `(rows, cols)`. @@ -540,16 +519,33 @@ impl Emulator { /// Count of alt-screen entries observed so far. pub fn alt_epoch(&self) -> u64 { - self.alt_epoch + self.alt.epoch + } + + /// The floor snapshotted at the most recent alt-screen exit (see the + /// field docs); `None` until the child first leaves the alt screen. + pub fn alt_leave_floor(&self) -> Option<&str> { + self.alt.leave_floor.as_deref() } - /// The captured window title, honored only while its alt-screen epoch is - /// current: a title set before the child entered the alt screen, or - /// during a previous alt session, reads as `None`. + /// The window title. On the alternate screen: the captured title, + /// honored only while its alt-screen epoch is current — a title from a + /// previous alt session reads as `None`. On the primary screen: a live + /// staged announce (a title not yet disclaimed by printed output) + /// surfaces first, then a still-current captured title. pub fn title(&self) -> Option<&str> { - self.captured_title + // On the primary screen a live staged announce surfaces, so shell + // titles read as before; the preview cascade never consults titles + // there. The captured title keeps its epoch gate unchanged. + if !self.alternate_screen() + && let Some(staged) = self.alt.staged_title.as_deref() + { + return Some(staged); + } + self.alt + .title .as_ref() - .filter(|t| t.alt_epoch == self.alt_epoch) + .filter(|t| t.alt_epoch == self.alt.epoch) .map(|t| t.text.as_str()) } @@ -558,34 +554,443 @@ impl Emulator { /// `contents` follows `display_offset`, which would make a scrolled-back /// task preview historical rows instead of live output. pub fn live_floor(&self) -> String { - let grid = self.term.grid(); - // Rows 0..screen_lines address the live viewport regardless of the - // display offset; only display iteration follows the offset. - for row in (0..grid.screen_lines() as i32).rev() { - let line = &grid[Line(row)]; - let mut text = String::new(); - for col in 0..grid.columns() { - let cell = &line[Column(col)]; - // Spacers have no glyph; terminal tabs occupy visible spaces. - if cell - .flags - .intersects(Flags::WIDE_CHAR_SPACER | Flags::LEADING_WIDE_CHAR_SPACER) - { - continue; - } - text.push(if cell.c == '\t' { ' ' } else { cell.c }); - if let Some(zerowidth) = cell.zerowidth() { - text.extend(zerowidth.iter()); - } - } - while text.ends_with(' ') { - text.pop(); - } - if !text.is_empty() { - return text; + live_floor_of(&self.term) + } + + /// Every live-viewport row, top to bottom, trailing padding trimmed: the + /// summary adapters' structural scan input. Ignores the scrollback view + /// offset for the same reason as [`Emulator::live_floor`]. + pub fn live_rows(&self) -> Vec { + (0..self.term.grid().screen_lines() as i32) + .map(|row| live_row_text_of(&self.term, row)) + .collect() + } +} + +/// The last non-blank row of `term`'s live screen, trailing padding +/// trimmed; empty when the screen is blank (see [`Emulator::live_floor`]). +/// Free over the term so the alt-exit observer can snapshot mid-advance, +/// while the `&mut Term` is borrowed as a handler. +fn live_floor_of(term: &Term) -> String { + for row in (0..term.grid().screen_lines() as i32).rev() { + let text = live_row_text_of(term, row); + if !text.is_empty() { + return text; + } + } + String::new() +} + +/// Plain text of one live-viewport row, trailing padding trimmed. Rows +/// `0..screen_lines` address live output regardless of the display +/// offset; only display iteration follows the offset. +fn live_row_text_of(term: &Term, row: i32) -> String { + let grid = term.grid(); + let line = &grid[Line(row)]; + let mut text = String::new(); + for col in 0..grid.columns() { + let cell = &line[Column(col)]; + // Spacers have no glyph; terminal tabs occupy visible spaces. + if cell + .flags + .intersects(Flags::WIDE_CHAR_SPACER | Flags::LEADING_WIDE_CHAR_SPACER) + { + continue; + } + text.push(if cell.c == '\t' { ' ' } else { cell.c }); + if let Some(zerowidth) = cell.zerowidth() { + text.extend(zerowidth.iter()); + } + } + while text.ends_with(' ') { + text.pop(); + } + text +} + +/// Alt-screen and title facts observed at parser-event granularity: every +/// field moves at its exact event, never at read boundaries, so no preview +/// semantics depend on where PTY reads split — a read that coalesces a +/// teardown with successor output, a leave and re-enter, or a title with a +/// following mode flip all observe identically however the reads land. +/// +/// The one accepted residual: a title emitted between two apps (after A's +/// 1049l, before B's 1049h) with no intervening glyphs stages into B — +/// indistinguishable from grok's legitimate pre-entry announce by any fact +/// held here. +#[derive(Default)] +struct AltScreen { + /// Count of alt-screen entries. Compared against + /// `CapturedTitle::alt_epoch` to expire titles at app boundaries. + epoch: u64, + /// Alt bit after the last observed mode event: transition detection + /// needs the previous value, and the mode register only holds the + /// current one. + last_alt: bool, + /// The live floor captured at each alt-screen exit, at the mode event + /// itself: successor bytes in the same read have not parsed yet, so + /// this is exactly what the restore left visible. Preview finalization + /// compares the final floor against it to tell restored pre-launch + /// junk from real primary output written after teardown. + leave_floor: Option, + /// Sanitized title owned by an alt session, epoch-stamped at its event. + title: Option, + /// Sanitized title announced on the primary screen, awaiting the next + /// alt entry: grok titles the window just before its 1049h, and the + /// entry event promotes this into the new epoch. Printable output + /// disclaims it (see the `input` forward); a reset clears it. + staged_title: Option, + /// Mirror of the backend's raw (unsanitized) current title, kept only + /// so the title-stack shadow pushes what the backend pushes. + raw_title: Option, + /// Mirror of the backend's title stack. A pop restores through the + /// backend's own internal `set_title`, which never re-enters the + /// wrapper, so the pop is replayed against this shadow instead. Same + /// bound and eviction as the backend (`TITLE_STACK_MAX_DEPTH`, pinned + /// `=0.26.0`). + title_stack: Vec>, +} + +/// The backend's `TITLE_STACK_MAX_DEPTH` (term/mod.rs, pinned `=0.26.0`): +/// the shadow stack must evict exactly when the backend does or a deep +/// stack would desynchronize pops. +const TITLE_STACK_SHADOW_MAX: usize = 4096; + +/// Delegating [`Handler`] that forwards every parser event to the wrapped +/// [`Term`] and observes alt-screen transitions the moment they happen. +/// +/// # Missed-forward hazard +/// +/// Every `Handler` method has an empty `{}` default, so a missing forward +/// compiles silently and swallows that escape. Two fences hold: the +/// backend is pinned `=0.26.0` in Cargo.toml, and +/// `golden::emulator_wrapper_matches_the_raw_backend_on_every_fixture` +/// replays every corpus fixture through this wrapper and diffs the full +/// screen, cursor, and mode against a raw backend replay — a swallowed +/// method breaks it loudly (the classic goldens alone cannot serve: they +/// drive the raw backend and never touch this path). The forwards below +/// are mechanically generated from the vte 0.15 trait: 71 methods, +/// count-verified against the trait definition. +/// +/// # Why this is sound under `?2026` +/// +/// The parser buffers a synchronized-update frame and drives the handler +/// only when the frame lands (in `advance` or `stop_sync` — both routed +/// through this wrapper), so these events fire exactly when the grid +/// moves: the observer can never see a transition the grid has not +/// performed, which no byte-scanner could guarantee. +struct ObservedTerm<'a> { + term: &'a mut Term, + alt: &'a mut AltScreen, +} + +impl ObservedTerm<'_> { + /// Compare the wrapped term's alt bit against the last observed value + /// after a delegated mode-touching event. Mode-number-agnostic by + /// design — no 1049/1047/47 literals: the bit compare tracks whatever + /// modes the backend maps to the alt screen, surviving backend + /// changes. + fn observe_alt(&mut self) { + let alt = self.term.mode().contains(TermMode::ALT_SCREEN); + if alt && !self.alt.last_alt { + self.alt.epoch += 1; + // Promote a staged primary-screen announce into the new epoch: + // the announce belongs to exactly this entry, so promotion + // consumes it. + if let Some(text) = self.alt.staged_title.take() { + self.alt.title = Some(CapturedTitle { + text, + alt_epoch: self.alt.epoch, + }); } } - String::new() + if !alt && self.alt.last_alt { + self.alt.leave_floor = Some(live_floor_of(self.term)); + } + self.alt.last_alt = alt; + } + + /// Title ownership at the event. A title set ON the alt screen labels + /// the current epoch, where it was spoken. A title set on the primary + /// screen is STAGED for the next alt entry — grok announces its title + /// just before its 1049h — and promoted at the entry event. A reset, + /// or a title that sanitizes to nothing, clears both: `printf + /// '\x1b]0;\x07'` un-titles the window, it does not freeze a stale + /// one. Staging is disclaimed by printable output (`input`), never by + /// control traffic: grok's gap between its title and 1049h is clears + /// and cursor moves, which must not disclaim, while a shell prompt + /// always prints glyphs, so a prompt-titling shell cannot leak its + /// title into the next app. Input-only is the deliberate, minimal + /// rule. + fn observe_title(&mut self, title: Option) { + self.alt.raw_title.clone_from(&title); + let text = title + .map(|raw| sanitize_title(&raw)) + .filter(|text| !text.is_empty()); + let Some(text) = text else { + self.alt.title = None; + self.alt.staged_title = None; + return; + }; + if self.term.mode().contains(TermMode::ALT_SCREEN) { + self.alt.title = Some(CapturedTitle { + text, + alt_epoch: self.alt.epoch, + }); + } else { + self.alt.staged_title = Some(text); + } + } +} + +/// Mechanical forwards. Five carry observations after delegating: +/// `set_private_mode`, `unset_private_mode`, and `reset_state` observe the +/// alt bit (RIS exits the alt screen too); `set_title` observes title +/// ownership; `input` disclaims a staged title. `push_title`/`pop_title` +/// maintain the shadow stack because the backend's pop restores through +/// its own internal `set_title`, which never re-enters this wrapper. +impl Handler for ObservedTerm<'_> { + fn set_title(&mut self, a0: Option) { + self.term.set_title(a0.clone()); + self.observe_title(a0); + } + fn set_cursor_style(&mut self, a0: Option) { + self.term.set_cursor_style(a0); + } + fn set_cursor_shape(&mut self, a0: vt::CursorShape) { + self.term.set_cursor_shape(a0); + } + fn input(&mut self, a0: char) { + self.term.input(a0); + // Printable output disclaims a staged title (rationale on + // `observe_title`). One branch, predictably not-taken: staging is + // only ever live between a primary-screen title and the next alt + // entry. + if self.alt.staged_title.is_some() { + self.alt.staged_title = None; + } + } + fn goto(&mut self, a0: i32, a1: usize) { + self.term.goto(a0, a1); + } + fn goto_line(&mut self, a0: i32) { + self.term.goto_line(a0); + } + fn goto_col(&mut self, a0: usize) { + self.term.goto_col(a0); + } + fn insert_blank(&mut self, a0: usize) { + self.term.insert_blank(a0); + } + fn move_up(&mut self, a0: usize) { + self.term.move_up(a0); + } + fn move_down(&mut self, a0: usize) { + self.term.move_down(a0); + } + fn identify_terminal(&mut self, a0: Option) { + self.term.identify_terminal(a0); + } + fn device_status(&mut self, a0: usize) { + self.term.device_status(a0); + } + fn move_forward(&mut self, a0: usize) { + self.term.move_forward(a0); + } + fn move_backward(&mut self, a0: usize) { + self.term.move_backward(a0); + } + fn move_down_and_cr(&mut self, a0: usize) { + self.term.move_down_and_cr(a0); + } + fn move_up_and_cr(&mut self, a0: usize) { + self.term.move_up_and_cr(a0); + } + fn put_tab(&mut self, a0: u16) { + self.term.put_tab(a0); + } + fn backspace(&mut self) { + self.term.backspace(); + } + fn carriage_return(&mut self) { + self.term.carriage_return(); + } + fn linefeed(&mut self) { + self.term.linefeed(); + } + fn bell(&mut self) { + self.term.bell(); + } + fn substitute(&mut self) { + self.term.substitute(); + } + fn newline(&mut self) { + self.term.newline(); + } + fn set_horizontal_tabstop(&mut self) { + self.term.set_horizontal_tabstop(); + } + fn scroll_up(&mut self, a0: usize) { + self.term.scroll_up(a0); + } + fn scroll_down(&mut self, a0: usize) { + self.term.scroll_down(a0); + } + fn insert_blank_lines(&mut self, a0: usize) { + self.term.insert_blank_lines(a0); + } + fn delete_lines(&mut self, a0: usize) { + self.term.delete_lines(a0); + } + fn erase_chars(&mut self, a0: usize) { + self.term.erase_chars(a0); + } + fn delete_chars(&mut self, a0: usize) { + self.term.delete_chars(a0); + } + fn move_backward_tabs(&mut self, a0: u16) { + self.term.move_backward_tabs(a0); + } + fn move_forward_tabs(&mut self, a0: u16) { + self.term.move_forward_tabs(a0); + } + fn save_cursor_position(&mut self) { + self.term.save_cursor_position(); + } + fn restore_cursor_position(&mut self) { + self.term.restore_cursor_position(); + } + fn clear_line(&mut self, a0: vt::LineClearMode) { + self.term.clear_line(a0); + } + fn clear_screen(&mut self, a0: vt::ClearMode) { + self.term.clear_screen(a0); + } + fn clear_tabs(&mut self, a0: vt::TabulationClearMode) { + self.term.clear_tabs(a0); + } + fn set_tabs(&mut self, a0: u16) { + self.term.set_tabs(a0); + } + fn reset_state(&mut self) { + self.term.reset_state(); + self.observe_alt(); + // RIS clears the backend's title and title stack directly, without + // a handler event (term/mod.rs `reset_state`): mirror both, and + // drop any staged announce with the rest of the pre-reset world. + // The captured title stays — it is epoch-gated and expires at the + // next entry, matching the pre-observer behavior. + self.alt.raw_title = None; + self.alt.title_stack.clear(); + self.alt.staged_title = None; + } + fn reverse_index(&mut self) { + self.term.reverse_index(); + } + fn terminal_attribute(&mut self, a0: vt::Attr) { + self.term.terminal_attribute(a0); + } + fn set_mode(&mut self, a0: vt::Mode) { + self.term.set_mode(a0); + } + fn unset_mode(&mut self, a0: vt::Mode) { + self.term.unset_mode(a0); + } + fn report_mode(&mut self, a0: vt::Mode) { + self.term.report_mode(a0); + } + fn set_private_mode(&mut self, a0: vt::PrivateMode) { + self.term.set_private_mode(a0); + self.observe_alt(); + } + fn unset_private_mode(&mut self, a0: vt::PrivateMode) { + self.term.unset_private_mode(a0); + self.observe_alt(); + } + fn report_private_mode(&mut self, a0: vt::PrivateMode) { + self.term.report_private_mode(a0); + } + fn set_scrolling_region(&mut self, a0: usize, a1: Option) { + self.term.set_scrolling_region(a0, a1); + } + fn set_keypad_application_mode(&mut self) { + self.term.set_keypad_application_mode(); + } + fn unset_keypad_application_mode(&mut self) { + self.term.unset_keypad_application_mode(); + } + fn set_active_charset(&mut self, a0: vt::CharsetIndex) { + self.term.set_active_charset(a0); + } + fn configure_charset(&mut self, a0: vt::CharsetIndex, a1: vt::StandardCharset) { + self.term.configure_charset(a0, a1); + } + fn set_color(&mut self, a0: usize, a1: vt::Rgb) { + self.term.set_color(a0, a1); + } + fn dynamic_color_sequence(&mut self, a0: String, a1: usize, a2: &str) { + self.term.dynamic_color_sequence(a0, a1, a2); + } + fn reset_color(&mut self, a0: usize) { + self.term.reset_color(a0); + } + fn clipboard_store(&mut self, a0: u8, a1: &[u8]) { + self.term.clipboard_store(a0, a1); + } + fn clipboard_load(&mut self, a0: u8, a1: &str) { + self.term.clipboard_load(a0, a1); + } + fn decaln(&mut self) { + self.term.decaln(); + } + fn push_title(&mut self) { + self.term.push_title(); + // Mirror the backend's bounded push of its current raw title. + if self.alt.title_stack.len() >= TITLE_STACK_SHADOW_MAX { + self.alt.title_stack.remove(0); + } + self.alt.title_stack.push(self.alt.raw_title.clone()); + } + fn pop_title(&mut self) { + self.term.pop_title(); + // Replay the pop against the shadow: the restored value is a title + // event in every sense (a popped `None` is a reset). + if let Some(popped) = self.alt.title_stack.pop() { + self.observe_title(popped); + } + } + fn text_area_size_pixels(&mut self) { + self.term.text_area_size_pixels(); + } + fn text_area_size_chars(&mut self) { + self.term.text_area_size_chars(); + } + fn set_hyperlink(&mut self, a0: Option) { + self.term.set_hyperlink(a0); + } + fn set_mouse_cursor_icon(&mut self, a0: vt::cursor_icon::CursorIcon) { + self.term.set_mouse_cursor_icon(a0); + } + fn report_keyboard_mode(&mut self) { + self.term.report_keyboard_mode(); + } + fn push_keyboard_mode(&mut self, a0: vt::KeyboardModes) { + self.term.push_keyboard_mode(a0); + } + fn pop_keyboard_modes(&mut self, a0: u16) { + self.term.pop_keyboard_modes(a0); + } + fn set_keyboard_mode(&mut self, a0: vt::KeyboardModes, a1: vt::KeyboardModesApplyBehavior) { + self.term.set_keyboard_mode(a0, a1); + } + fn set_modify_other_keys(&mut self, a0: vt::ModifyOtherKeys) { + self.term.set_modify_other_keys(a0); + } + fn report_modify_other_keys(&mut self) { + self.term.report_modify_other_keys(); + } + fn set_scp(&mut self, a0: vt::ScpCharPath, a1: vt::ScpUpdateMode) { + self.term.set_scp(a0, a1); } } @@ -1091,7 +1496,12 @@ mod tests { '\u{202E}', '\u{2066}', '\u{2067}', '\u{2068}', '\u{2069}', ]; for c in bidi { - assert_eq!(sanitize_title(&format!("a{c}b")), "ab", "U+{:04X}", c as u32); + assert_eq!( + sanitize_title(&format!("a{c}b")), + "ab", + "U+{:04X}", + c as u32 + ); } } @@ -1143,9 +1553,9 @@ mod tests { assert_eq!(emu.title(), None, "ResetTitle must unset the capture"); } - /// A title in the same chunk that enters the alt screen stamps into the - /// new epoch: the epoch compare runs before title consumption, because - /// children emit the title bytes just before DECSET 1049. + /// A title just before the alt entry stages and is promoted into the + /// new epoch at the entry event — children emit the title bytes just + /// before DECSET 1049, and no glyphs intervene to disclaim it. #[test] fn title_entering_alt_in_one_chunk_is_honored() { let mut emu = Emulator::new(4, 20, 0); @@ -1154,15 +1564,90 @@ mod tests { assert_eq!(emu.title(), Some("app")); } - /// A title captured in an earlier chunk predates the alt entry and is - /// not honored once the child enters the alt screen. + /// A primary-screen title followed by printed output is the shell + /// titling itself: the glyphs are what disclaim it now — a bare title + /// with only control traffic until the entry stays valid by the + /// staging rule, deliberately (that is grok's announce shape). #[test] fn title_before_alt_entry_in_a_prior_chunk_expires() { let mut emu = Emulator::new(4, 20, 0); emu.process(b"\x1b]0;shell\x07"); assert_eq!(emu.title(), Some("shell")); + emu.process(b"$ make\r\n"); emu.process(b"\x1b[?1049h"); - assert_eq!(emu.title(), None, "an epoch-0 title cannot label the app"); + assert_eq!( + emu.title(), + None, + "printed output disclaimed the staged title" + ); + } + + /// The maintainer's counterexample: app A titles itself inside alt + /// epoch 1, then bounces to app B. Whether the title, the 1049l, and + /// the 1049h share one read or split before the 1049l, the outcome is + /// identical — the title event stamped epoch 1 at the event, and the + /// bounce advanced to 2. + #[test] + fn in_alt_title_expires_across_a_bounce_on_any_read_boundary() { + for split in [false, true] { + let mut emu = Emulator::new(4, 20, 0); + emu.process(b"\x1b[?1049hui"); + assert_eq!(emu.alt_epoch(), 1); + if split { + emu.process(b"\x1b]0;first\x07"); + emu.process(b"\x1b[?1049l\x1b[?1049h"); + } else { + emu.process(b"\x1b]0;first\x07\x1b[?1049l\x1b[?1049h"); + } + assert_eq!(emu.alt_epoch(), 2, "split={split}"); + assert_eq!( + emu.title(), + None, + "split={split}: A's title must not label B" + ); + } + } + + /// grok's announce shape: a primary-screen title, a control-only gap + /// (clears and cursor moves), then the alt entry — honored on either + /// read boundary, because control traffic never disclaims staging. + #[test] + fn staged_title_survives_a_control_only_gap_into_the_entry() { + for split in [false, true] { + let mut emu = Emulator::new(4, 20, 0); + if split { + emu.process(b"\x1b]0;grok\x07\x1b[2J\x1b[H"); + emu.process(b"\x1b[?1049h"); + } else { + emu.process(b"\x1b]0;grok\x07\x1b[2J\x1b[H\x1b[?1049h"); + } + assert_eq!(emu.alt_epoch(), 1, "split={split}"); + assert_eq!(emu.title(), Some("grok"), "split={split}"); + } + } + + /// One printed glyph between a primary-screen title and the entry + /// disclaims the staging: a prompt-titling shell never leaks its title + /// into the next app. + #[test] + fn staged_title_is_disclaimed_by_a_single_glyph() { + let mut emu = Emulator::new(4, 20, 0); + emu.process(b"\x1b]0;shell\x07x\x1b[?1049h"); + assert_eq!(emu.alt_epoch(), 1); + assert_eq!(emu.title(), None); + } + + /// The accepted residual, pinned as documented behavior: a title + /// emitted between two apps — after A's 1049l, before B's 1049h — with + /// no intervening glyphs stages into B. No fact held here can tell it + /// from grok's legitimate pre-entry announce (`AltScreen` docs). + #[test] + fn inter_app_title_with_no_glyphs_stages_into_the_next_app() { + let mut emu = Emulator::new(4, 20, 0); + emu.process(b"\x1b[?1049hui A"); + emu.process(b"\x1b[?1049l\x1b]0;handoff\x07\x1b[?1049h"); + assert_eq!(emu.alt_epoch(), 2); + assert_eq!(emu.title(), Some("handoff")); } /// Each alt entry advances the epoch and expires prior titles; leaving @@ -1180,10 +1665,11 @@ mod tests { assert_eq!(emu.title(), None, "re-entry expires the previous title"); } - /// A title followed by leaving the alt screen in the same chunk reads as - /// primary: the exit keeps the epoch, so the title stamps as current. + /// A title followed by leaving the alt screen: the title event fires + /// while the alt screen is still active, capturing into the current + /// epoch, and the exit keeps the epoch — so it stays honored. #[test] - fn title_leaving_alt_in_one_chunk_reads_as_primary() { + fn title_just_before_alt_exit_stays_honored() { let mut emu = Emulator::new(4, 20, 0); emu.process(b"\x1b[?1049h"); assert_eq!(emu.alt_epoch(), 1); @@ -1239,6 +1725,69 @@ mod tests { assert_eq!(emu.revision(), 2, "no open frame: nothing advanced"); } + /// A resize reflows the grid with no bytes arriving: revision-keyed + /// pollers would otherwise carry a pre-resize snapshot indefinitely on + /// a quiet task. + #[test] + fn resize_bumps_the_revision_without_bytes() { + let mut emu = Emulator::new(4, 20, 0); + emu.process(b"hello\r\nworld"); + let before = emu.revision(); + emu.resize(6, 30); + assert_eq!(emu.revision(), before + 1); + } + + /// The alt-exit snapshot captures what the 1049l restore left visible, + /// at the mode event itself: text after the 1049l — in a later read OR + /// coalesced into the same one — moves the floor without touching the + /// snapshot. PTY reads do not preserve write boundaries, so the + /// coalesced case is routine on a loaded machine, not rare. + #[test] + fn alt_leave_floor_snapshots_the_restore() { + let mut emu = Emulator::new(4, 20, 0); + emu.process(b"junk\r\n"); + assert_eq!(emu.alt_leave_floor(), None, "no exit yet"); + emu.process(b"\x1b[?1049halt body"); + emu.process(b"\x1b[?1049l"); + assert_eq!(emu.alt_leave_floor(), Some("junk")); + + emu.process(b"done\r\n"); + assert_eq!( + emu.alt_leave_floor(), + Some("junk"), + "later chunks leave the snapshot alone" + ); + assert_eq!(emu.live_floor(), "done"); + + emu.process(b"\x1b[?1049halt again"); + emu.process(b"\x1b[?1049lcoalesced\r\n"); + assert_eq!( + emu.alt_leave_floor(), + Some("done"), + "a coalesced read still snapshots at the mode event" + ); + assert_eq!(emu.live_floor(), "coalesced"); + } + + /// The maintainer's epoch regression: a leave and re-enter inside one + /// read must advance the epoch and expire the previous app's title — + /// read boundaries are not allowed to decide title expiry. + #[test] + fn same_read_alt_bounce_advances_the_epoch_and_expires_the_title() { + let mut emu = Emulator::new(4, 20, 0); + emu.process(b"\x1b[?1049h\x1b]0;first app\x07ui"); + assert_eq!(emu.alt_epoch(), 1); + assert_eq!(emu.title(), Some("first app"), "premise: title honored"); + + emu.process(b"\x1b[?1049l\x1b[?1049h"); + assert_eq!(emu.alt_epoch(), 2, "the bounce is two transitions"); + assert_eq!( + emu.title(), + None, + "the old app's title must not survive the swap" + ); + } + /// `live_floor` reads the live grid's last non-blank row even while the /// viewport is scrolled back; `contents` follows the offset instead. #[test] @@ -1255,7 +1804,11 @@ mod tests { "premise: the view shows history" ); assert!(!emu.contents().contains("latest")); - assert_eq!(emu.live_floor(), "latest", "the floor must not follow the view"); + assert_eq!( + emu.live_floor(), + "latest", + "the floor must not follow the view" + ); } /// A blank screen has no floor. diff --git a/tests/corpus/README.md b/tests/corpus/README.md index 78d15cc..31cd764 100644 --- a/tests/corpus/README.md +++ b/tests/corpus/README.md @@ -28,6 +28,42 @@ feed the bytes to the emulator verbatim. | `dec_scrollregion.bin` | `printf` output with DEC line drawing (`ESC ( 0`) and `CSI 5;20r` | DEC charset translation; the non-top-anchored region does not scroll, and the later full-screen scroll retains one row | | `topregion_scroll.bin` | `printf` output with `CSI 1;20r`, 34 newlines through the bottom margin, and an isolation line below the region | top-anchored-region retention pinned at 35: 34 region scrolls plus the row preserved by `ESC[2J` | +## Preview fixtures + +The `preview_*.bin` fixtures pin the summary adapters (`src/harness/summary.rs`): +per-state agent-CLI screens whose extraction, normalization, and refusal +behavior the adapter tests assert exactly. Unlike the raw recordings above, +each is a constructed repaint stream — an optional alt-screen entry (claude +and grok run on the alternate screen; codex is inline), clear, home, then the +captured screen's rows joined with CRLF — trimmed from per-state snapshots of +claude 2.1.215, codex-cli 0.144.6, and grok 0.2.102. All identifying content +(names, account identifiers, filesystem paths, MCP server names, and every +user-configured statusline row) is replaced with same-length synthetic values, +so box borders stay column-aligned. Geometry is 40×120 unless noted. + +| Fixture | Scenario | Coverage | +| --- | --- | --- | +| `preview_claude_working.bin` | claude spinner with the tmux focus-events hint row | `claude:spinner` extraction through an indented hint row | +| `preview_claude_working_tool.bin` | claude spinner over an indented tool-attachment row | `claude:spinner`; the attachment row is not the action row | +| `preview_claude_action.bin` | claude `⏺ Running 1 shell command…` above the spinner | `claude:action-row` preferred over the rotating verb | +| `preview_claude_approval.bin` | claude file-write approval dialog, input box replaced | `claude:approval-menu` synthesizes `awaiting approval` | +| `preview_claude_idle.bin` | claude idle with the `Try "…"` placeholder | fall-through to the marker; the placeholder never anchors | +| `preview_claude_done.bin` | claude after a finished turn (`✻ Crunched for 4s` in the body) | fall-through; body completion rows are out of the pinned window | +| `preview_claude_body_menu.bin` | approval-menu text quoted in the body while the spinner runs | negative: the pinned spinner wins over body menu shapes | +| `preview_claude_body_menu_idle.bin` | approval-menu text touching the chrome window, input box intact | negative: a foreign column-0 row aborts to fall-through | +| `preview_codex_working.bin` | codex `• Working (7s • esc to interrupt) · 1 background terminal running · /ps to view · /stop to close` | `codex:working` normalization: affordances stripped, slow suffix kept | +| `preview_codex_working_over_ran.bin` | codex working with a `• Ran` row higher in the same turn | `codex:working` wins at the pin; the stale row never surfaces | +| `preview_codex_scrollback.bin` | codex finished turn, `• Ran` from the prior turn in scrollback | negative: the scan stops at the reply bullet; floor tier reports | +| `preview_codex_ran.bin` | codex transient completion (synthetic: no raw capture holds it) | `codex:ran` extraction through the `└` attachment row | +| `preview_grok_working.bin` | grok braille spinner with elapsed/throughput ticker | `grok:spinner` cut at the label's `…`; border label read | +| `preview_grok_worked.bin` | grok `Worked for 8.7s` completion row above the box | `grok:worked` kept verbatim | +| `preview_grok_idle.bin` | grok idle session | fall-through to the marker | +| `preview_grok_splash.bin` | grok launch splash with resume hint above the box | fall-through; distinct views never anchor | +| `preview_trunc_claude.bin` | synthetic 40×80: spinner row truncated inside its parenthetical | head match still extracts `Hashing…` | +| `preview_trunc_codex.bin` | synthetic 40×80: working row truncated inside the `/ps` hint | head match still extracts; dropped suffix was strippable anyway | +| `preview_trunc_grok.bin` | synthetic 40×80: spinner label truncated with the CLI's ellipsis | extraction keeps the CLI's own `…` verbatim | +| `preview_wrap_grok.bin` | synthetic 40×30: the status row wraps its ellipsis onto the next row | pathological width fails the structure check and falls through | + ## What the fixtures prove The fixtures provide evidence for three distinct boundaries: @@ -43,4 +79,5 @@ The fixtures provide evidence for three distinct boundaries: `src/golden.rs` contains the absolute display and parser expectations. `src/harness/claude.rs`, `src/harness/codex.rs`, and `src/harness/grok.rs` -contain the agent-resume scrape expectations. +contain the agent-resume scrape expectations. `src/harness/summary.rs` +contains the preview-fixture expectations. diff --git a/tests/corpus/preview_claude_action.bin b/tests/corpus/preview_claude_action.bin new file mode 100644 index 0000000..7dce920 --- /dev/null +++ b/tests/corpus/preview_claude_action.bin @@ -0,0 +1,40 @@ +[?1049h +╭─── Claude Code v2.1.215 ─────────────────────────────────────────────────────────────────────────────────────────────╮ +│ │ Tips for getting started │ +│ Welcome back Fleet Pilot! │ Run /init to create a CLAUDE.md file with instructions for Cla… │ +│ │ ─────────────────────────────────────────────────────────────── │ +│ ▐▛███▜▌ │ What's new │ +│ ▝▜█████▛▘ │ Claude no longer runs the `/verify` and `/code-review` skills … │ +│ ▘▘ ▝▝ │ Fixed single-segment `dir/**` allow rules like `Edit(src/**)` … │ +│ Fable 5 with high effort · Claude Max · │ Fixed a permission-check bypass affecting commands run in Wind… │ +│ anonymized-account0@example.invalid's Organization │ /release-notes for more │ +│ ~/Projects/fixture-sources │ │ +╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ + + +❯ run: sleep 5 && echo ok + +⏺ Running 1 shell command… + + + + + + + + + + + + + + + + +· Hashing… (3s · ↓ 52 tokens) + tmux focus-events off · add 'set -g focus-events on' to ~/.tmux.conf and reattach for focus tracking +──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── +❯  +──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── + (statusline scrubbed: user-configured row) + ⏸ manual mode on · ← 2 agents \ No newline at end of file diff --git a/tests/corpus/preview_claude_approval.bin b/tests/corpus/preview_claude_approval.bin new file mode 100644 index 0000000..4b2a9ce --- /dev/null +++ b/tests/corpus/preview_claude_approval.bin @@ -0,0 +1,39 @@ +[?1049h +╭─── Claude Code v2.1.215 ─────────────────────────────────────────────────────────────────────────────────────────────╮ +│ │ Tips for getting started │ +│ Welcome back Fleet Pilot! │ Run /init to create a CLAUDE.md file with instructions for Cla… │ +│ │ ─────────────────────────────────────────────────────────────── │ +│ ▐▛███▜▌ │ What's new │ +│ ▝▜█████▛▘ │ Claude no longer runs the `/verify` and `/code-review` skills … │ +│ ▘▘ ▝▝ │ Fixed single-segment `dir/**` allow rules like `Edit(src/**)` … │ +│ Fable 5 with high effort · Claude Max · │ Fixed a permission-check bypass affecting commands run in Wind… │ +│ anonymized-account0@example.invalid's Organization │ /release-notes for more │ +│ ~/Projects/fixture-sources │ │ +╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ + + +❯ echo ok + + Ran 1 shell command + +⏺ ok + +✻ Crunched for 4s + +❯ write a file to the current directory with an interesting word as its content + +⏺ Write(word.txt) + +──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── + Create file + word.txt +╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌ + 1 petrichor +╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌ + Do you want to create word.txt? + ❯ 1. Yes + 2. Yes, allow all edits during this session (shift+tab) + 3. No + + Esc to cancel · Tab to amend + diff --git a/tests/corpus/preview_claude_body_menu.bin b/tests/corpus/preview_claude_body_menu.bin new file mode 100644 index 0000000..a8a0c81 --- /dev/null +++ b/tests/corpus/preview_claude_body_menu.bin @@ -0,0 +1,40 @@ +[?1049h +╭─── Claude Code v2.1.215 ─────────────────────────────────────────────────────────────────────────────────────────────╮ +│ │ Tips for getting started │ +│ Welcome back Fleet Pilot! │ Run /init to create a CLAUDE.md file with instructions for Cla… │ +│ │ ─────────────────────────────────────────────────────────────── │ +│ ▐▛███▜▌ │ What's new │ +│ ▝▜█████▛▘ │ Claude no longer runs the `/verify` and `/code-review` skills … │ +│ ▘▘ ▝▝ │ Fixed single-segment `dir/**` allow rules like `Edit(src/**)` … │ +│ Fable 5 with high effort · Claude Max · │ Fixed a permission-check bypass affecting commands run in Wind… │ +│ anonymized-account0@example.invalid's Organization │ /release-notes for more │ +│ ~/Projects/fixture-sources │ │ +╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ + + +❯ run: sleep 5 && echo ok + + Running 1 shell command… + ⎿ $ sleep 5 && echo ok +❯ 1. Yes + 2. No + + + + + + + + + + + + + +✻ Hashing… (6s · ↓ 87 tokens) + +──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── +❯  +──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── + (statusline scrubbed: user-configured row) + ⏸ manual mode on · ← 2 agents \ No newline at end of file diff --git a/tests/corpus/preview_claude_body_menu_idle.bin b/tests/corpus/preview_claude_body_menu_idle.bin new file mode 100644 index 0000000..19ae209 --- /dev/null +++ b/tests/corpus/preview_claude_body_menu_idle.bin @@ -0,0 +1,40 @@ +[?1049h +╭─── Claude Code v2.1.215 ─────────────────────────────────────────────────────────────────────────────────────────────╮ +│ │ Tips for getting started │ +│ Welcome back Fleet Pilot! │ Run /init to create a CLAUDE.md file with instructions for Cla… │ +│ │ ─────────────────────────────────────────────────────────────── │ +│ ▐▛███▜▌ │ What's new │ +│ ▝▜█████▛▘ │ Claude no longer runs the `/verify` and `/code-review` skills … │ +│ ▘▘ ▝▝ │ Fixed single-segment `dir/**` allow rules like `Edit(src/**)` … │ +│ Fable 5 with high effort · Claude Max · │ Fixed a permission-check bypass affecting commands run in Wind… │ +│ anonymized-account0@example.invalid's Organization │ /release-notes for more │ +│ ~/Projects/fixture-sources │ │ +╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ + + +❯ echo ok + + Ran 1 shell command + +⏺ ok + +✻ Crunched for 4s + + + + + + + + + + + +❯ 1. Yes + 2. No + tmux focus-events off · add 'set -g focus-events on' to ~/.tmux.conf and reattach for focus tracking +──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── +❯  +──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── + (statusline scrubbed: user-configured row) + ⏸ manual mode on · ← 2 agents \ No newline at end of file diff --git a/tests/corpus/preview_claude_done.bin b/tests/corpus/preview_claude_done.bin new file mode 100644 index 0000000..a153ba9 --- /dev/null +++ b/tests/corpus/preview_claude_done.bin @@ -0,0 +1,40 @@ +[?1049h +╭─── Claude Code v2.1.215 ─────────────────────────────────────────────────────────────────────────────────────────────╮ +│ │ Tips for getting started │ +│ Welcome back Fleet Pilot! │ Run /init to create a CLAUDE.md file with instructions for Cla… │ +│ │ ─────────────────────────────────────────────────────────────── │ +│ ▐▛███▜▌ │ What's new │ +│ ▝▜█████▛▘ │ Claude no longer runs the `/verify` and `/code-review` skills … │ +│ ▘▘ ▝▝ │ Fixed single-segment `dir/**` allow rules like `Edit(src/**)` … │ +│ Fable 5 with high effort · Claude Max · │ Fixed a permission-check bypass affecting commands run in Wind… │ +│ anonymized-account0@example.invalid's Organization │ /release-notes for more │ +│ ~/Projects/fixture-sources │ │ +╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ + + +❯ echo ok + + Ran 1 shell command + +⏺ ok + +✻ Crunched for 4s + + + + + + + + + + + + + + tmux focus-events off · add 'set -g focus-events on' to ~/.tmux.conf and reattach for focus tracking +──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── +❯  +──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── + (statusline scrubbed: user-configured row) + ⏸ manual mode on · ← 2 agents \ No newline at end of file diff --git a/tests/corpus/preview_claude_idle.bin b/tests/corpus/preview_claude_idle.bin new file mode 100644 index 0000000..95604ab --- /dev/null +++ b/tests/corpus/preview_claude_idle.bin @@ -0,0 +1,40 @@ +[?1049h +╭─── Claude Code v2.1.215 ─────────────────────────────────────────────────────────────────────────────────────────────╮ +│ │ Tips for getting started │ +│ Welcome back Fleet Pilot! │ Run /init to create a CLAUDE.md file with instructions for Cla… │ +│ │ ─────────────────────────────────────────────────────────────── │ +│ ▐▛███▜▌ │ What's new │ +│ ▝▜█████▛▘ │ Claude no longer runs the `/verify` and `/code-review` skills … │ +│ ▘▘ ▝▝ │ Fixed single-segment `dir/**` allow rules like `Edit(src/**)` … │ +│ Fable 5 with high effort · Claude Max · │ Fixed a permission-check bypass affecting commands run in Wind… │ +│ anonymized-account0@example.invalid's Organization │ /release-notes for more │ +│ ~/Projects/fixture-sources │ │ +╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ + + + + + + + + + + + + + + + + + + + + + + + ● high · /effort +──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── +❯ Try "refactor " +──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── + (statusline scrubbed: user-configured row) + ⏸ manual mode on · ← 2 agents \ No newline at end of file diff --git a/tests/corpus/preview_claude_working.bin b/tests/corpus/preview_claude_working.bin new file mode 100644 index 0000000..8805269 --- /dev/null +++ b/tests/corpus/preview_claude_working.bin @@ -0,0 +1,40 @@ +[?1049h +╭─── Claude Code v2.1.215 ─────────────────────────────────────────────────────────────────────────────────────────────╮ +│ │ Tips for getting started │ +│ Welcome back Fleet Pilot! │ Run /init to create a CLAUDE.md file with instructions for Cla… │ +│ │ ─────────────────────────────────────────────────────────────── │ +│ ▐▛███▜▌ │ What's new │ +│ ▝▜█████▛▘ │ Claude no longer runs the `/verify` and `/code-review` skills … │ +│ ▘▘ ▝▝ │ Fixed single-segment `dir/**` allow rules like `Edit(src/**)` … │ +│ Fable 5 with high effort · Claude Max · │ Fixed a permission-check bypass affecting commands run in Wind… │ +│ anonymized-account0@example.invalid's Organization │ /release-notes for more │ +│ ~/Projects/fixture-sources │ │ +╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ + + +❯ run: sleep 5 && echo ok + + + + + + + + + + + + + + + + + + +✽ Concocting… + tmux focus-events off · add 'set -g focus-events on' to ~/.tmux.conf and reattach for focus tracking +──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── +❯  +──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── + (statusline scrubbed: user-configured row) + ⏸ manual mode on · ← 2 agents \ No newline at end of file diff --git a/tests/corpus/preview_claude_working_tool.bin b/tests/corpus/preview_claude_working_tool.bin new file mode 100644 index 0000000..154deda --- /dev/null +++ b/tests/corpus/preview_claude_working_tool.bin @@ -0,0 +1,40 @@ +[?1049h +╭─── Claude Code v2.1.215 ─────────────────────────────────────────────────────────────────────────────────────────────╮ +│ │ Tips for getting started │ +│ Welcome back Fleet Pilot! │ Run /init to create a CLAUDE.md file with instructions for Cla… │ +│ │ ─────────────────────────────────────────────────────────────── │ +│ ▐▛███▜▌ │ What's new │ +│ ▝▜█████▛▘ │ Claude no longer runs the `/verify` and `/code-review` skills … │ +│ ▘▘ ▝▝ │ Fixed single-segment `dir/**` allow rules like `Edit(src/**)` … │ +│ Fable 5 with high effort · Claude Max · │ Fixed a permission-check bypass affecting commands run in Wind… │ +│ anonymized-account0@example.invalid's Organization │ /release-notes for more │ +│ ~/Projects/fixture-sources │ │ +╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ + + +❯ run: sleep 5 && echo ok + + Running 1 shell command… + ⎿ $ sleep 5 && echo ok + + + + + + + + + + + + + + + +✻ Hashing… (6s · ↓ 87 tokens) + +──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── +❯  +──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── + (statusline scrubbed: user-configured row) + ⏸ manual mode on · ← 2 agents \ No newline at end of file diff --git a/tests/corpus/preview_codex_ran.bin b/tests/corpus/preview_codex_ran.bin new file mode 100644 index 0000000..fca1c1c --- /dev/null +++ b/tests/corpus/preview_codex_ran.bin @@ -0,0 +1,17 @@ +╭────────────────────────────────────────────────╮ +│ >_ OpenAI Codex (v0.144.6) │ +│ │ +│ model: gpt-5.6-sol high /model to change │ +│ directory: ~/Projects/fixture-sources │ +╰────────────────────────────────────────────────╯ + +› run: sleep 5 && echo ok + +• Running it now. + +• Ran sleep 5 && echo ok + └ ok + +› Write tests for @filename + + gpt-5.6-sol high · 5.26K used · 28.2K in · 78 out \ No newline at end of file diff --git a/tests/corpus/preview_codex_scrollback.bin b/tests/corpus/preview_codex_scrollback.bin new file mode 100644 index 0000000..b0450ee --- /dev/null +++ b/tests/corpus/preview_codex_scrollback.bin @@ -0,0 +1,39 @@ +╭────────────────────────────────────────────────╮ +│ >_ OpenAI Codex (v0.144.6) │ +│ │ +│ model: gpt-5.6-sol high /model to change │ +│ directory: ~/Projects/fixture-sources │ +╰────────────────────────────────────────────────╯ + + Tip: New Use /fast to enable our fastest inference with increased plan usage. + +⚠ MCP client for `demo-mcp-unresolved` failed to start: MCP startup failed: No such file or directory (os error 2) + +• You have 3 usage limit resets available. Run /usage to use one. + +⚠ MCP startup incomplete (failed: demo-mcp-unresolved) + + +› run: sleep 5 && echo ok + + +⚠ MCP client for `demo-mcp-unresolved` failed to start: MCP startup failed: No such file or directory (os error 2) + +⚠ MCP startup incomplete (failed: demo-mcp-unresolved) + +• I’m running that command now. + +• Ran sleep 5 && echo ok + └ ok + +──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── + +• ok + +──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── + + +› Write tests for @filename + + gpt-5.6-sol high · 5.26K used · 28.2K in · 78 out + diff --git a/tests/corpus/preview_codex_working.bin b/tests/corpus/preview_codex_working.bin new file mode 100644 index 0000000..99e061c --- /dev/null +++ b/tests/corpus/preview_codex_working.bin @@ -0,0 +1,39 @@ +╭────────────────────────────────────────────────╮ +│ >_ OpenAI Codex (v0.144.6) │ +│ │ +│ model: gpt-5.6-sol high /model to change │ +│ directory: ~/Projects/fixture-sources │ +╰────────────────────────────────────────────────╯ + + Tip: New Use /fast to enable our fastest inference with increased plan usage. + +⚠ MCP client for `demo-mcp-unresolved` failed to start: MCP startup failed: No such file or directory (os error 2) + +• You have 3 usage limit resets available. Run /usage to use one. + +⚠ MCP startup incomplete (failed: demo-mcp-unresolved) + + +› run: sleep 5 && echo ok + + +⚠ MCP client for `demo-mcp-unresolved` failed to start: MCP startup failed: No such file or directory (os error 2) + +⚠ MCP startup incomplete (failed: demo-mcp-unresolved) + +• I’m running that command now. + +• Working (7s • esc to interrupt) · 1 background terminal running · /ps to view · /stop to close + + +› Write tests for @filename + + gpt-5.6-sol high · 0 in · 0 out + + + + + + + + diff --git a/tests/corpus/preview_codex_working_over_ran.bin b/tests/corpus/preview_codex_working_over_ran.bin new file mode 100644 index 0000000..1fe2aa2 --- /dev/null +++ b/tests/corpus/preview_codex_working_over_ran.bin @@ -0,0 +1,39 @@ +╭────────────────────────────────────────────────╮ +│ >_ OpenAI Codex (v0.144.6) │ +│ │ +│ model: gpt-5.6-sol high /model to change │ +│ directory: ~/Projects/fixture-sources │ +╰────────────────────────────────────────────────╯ + + Tip: New Use /fast to enable our fastest inference with increased plan usage. + +⚠ MCP client for `demo-mcp-unresolved` failed to start: MCP startup failed: No such file or directory (os error 2) + +• You have 3 usage limit resets available. Run /usage to use one. + +⚠ MCP startup incomplete (failed: demo-mcp-unresolved) + + +› run: sleep 5 && echo ok + + +⚠ MCP client for `demo-mcp-unresolved` failed to start: MCP startup failed: No such file or directory (os error 2) + +⚠ MCP startup incomplete (failed: demo-mcp-unresolved) + +• I’m running that command now. + +• Ran sleep 5 && echo ok + └ ok + +──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── + +• ok + +• Working (14s • esc to interrupt) + + +› Write tests for @filename + + gpt-5.6-sol high · 5.26K used · 28.2K in · 78 out + diff --git a/tests/corpus/preview_grok_idle.bin b/tests/corpus/preview_grok_idle.bin new file mode 100644 index 0000000..2d737ce --- /dev/null +++ b/tests/corpus/preview_grok_idle.bin @@ -0,0 +1,39 @@ +[?1049h + ~/Projects/fixture-sources 3.0K / 500K + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ╭──────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ + │ ❯ │ + ╰────────────────────────────────────────────────────────────────────────────── Grok 4.5 (xhigh) · always-approve ─╯ + + Shift+Tab:mode │ Ctrl+x:shortcuts diff --git a/tests/corpus/preview_grok_splash.bin b/tests/corpus/preview_grok_splash.bin new file mode 100644 index 0000000..09bdeb9 --- /dev/null +++ b/tests/corpus/preview_grok_splash.bin @@ -0,0 +1,39 @@ +[?1049h + ~/Projects/fixture-sources + + + + + + ╭────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ + │ │ + │ ⠀⠀⠀⠀⠀⠀⣀⣀⡀⠀⠀⠀⢀⠄ Grok Build Beta 0.2.106 │ + │ ⠀⠀⠀⣠⣾⠿⠛⠛⠛⠛⢀⡴⠁⠀ │ + │ ⠀⠀⣼⡟⠁⠀⠀⠀⢀⡴⠻⣿⡀⠀ Grok 4.5 is here! │ + │ ⠀⠀⣿⡇⠀⠀⠀⠔⠁⠀⠀⣿⡇⠀ Grok 4.5 is now available. Try it out in the /model picker. │ + │ ⠀⠀⢹⣷⠀⠀⠀⠀⠀⢀⣴⡿⠀⠀ │ + │ ⠀⢀⠞⠁⠠⢶⣶⣶⣶⠿⠋⠀⠀⠀ New worktree ctrl+w │ + │ ⠐⠁⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀ Resume session ctrl+s │ + │ Changelog │ + │ Quit ctrl+q │ + │ │ + ╰────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ + + + + + + + + + + + + + Coming from Codex? Resume your session from 7m ago using ctrl+u + + ╭──────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ + │ ❯ │ + ╰────────────────────────────────────────────────────────────────────────────── Grok 4.5 (xhigh) · always-approve ─╯ + + [stable] diff --git a/tests/corpus/preview_grok_worked.bin b/tests/corpus/preview_grok_worked.bin new file mode 100644 index 0000000..f7f77e8 --- /dev/null +++ b/tests/corpus/preview_grok_worked.bin @@ -0,0 +1,39 @@ +[?1049h + ~/Projects/fixture-sources 14K / 500K + + + ❯ run: sleep 5 && echo ok 7:34 PM + + + ◆ Thought for 0.3s + ❙ ◆ Run Sleep 5 seconds then echo ok + ◆ Thought for 0.0s + + Command finished successfully (exit 0): 7:34 PM + + ok + + Worked for 8.7s + + + + + + + + + + + + + + + + + + + ╭──────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ + │ ❯ │ + ╰────────────────────────────────────────────────────────────────────────────── Grok 4.5 (xhigh) · always-approve ─╯ + + Shift+Tab:mode │ Ctrl+x:shortcuts diff --git a/tests/corpus/preview_grok_working.bin b/tests/corpus/preview_grok_working.bin new file mode 100644 index 0000000..b8960e0 --- /dev/null +++ b/tests/corpus/preview_grok_working.bin @@ -0,0 +1,39 @@ +[?1049h + ~/Projects/fixture-sources 14K / 500K + + + ❯ run: sleep 5 && echo ok 7:34 PM + + + ◆ Thought for 0.3s + ┃ ◆ Run Sleep 5 seconds then echo ok + + + + + + + + + + + + + + + + + + + + + + + + ⠼ Sleep 5 seconds then echo ok… 1.5s 2.8s ⇣14.2k [↓][stop] + + ╭──────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ + │ ❯ │ + ╰────────────────────────────────────────────────────────────────────────────── Grok 4.5 (xhigh) · always-approve ─╯ + + Shift+Tab:mode │ Ctrl+c:cancel │ Ctrl+g:send to bg │ Ctrl+x:shortcuts diff --git a/tests/corpus/preview_trunc_claude.bin b/tests/corpus/preview_trunc_claude.bin new file mode 100644 index 0000000..04612d8 --- /dev/null +++ b/tests/corpus/preview_trunc_claude.bin @@ -0,0 +1,7 @@ +[?1049h❯ run: hash the corpus + +✻ Hashing… (6s · ↓ 87… +──────────────────────────────────────────────────────────────────────────────── +❯ +──────────────────────────────────────────────────────────────────────────────── + (statusline scrubbed: user-configured row) \ No newline at end of file diff --git a/tests/corpus/preview_trunc_codex.bin b/tests/corpus/preview_trunc_codex.bin new file mode 100644 index 0000000..9d641b4 --- /dev/null +++ b/tests/corpus/preview_trunc_codex.bin @@ -0,0 +1,7 @@ +› run: sleep 5 && echo ok + +• Working (7s • esc to interrupt) · 1 background terminal running · /ps to… + +› Write tests for @filename + + gpt-5.6-sol high · 0 in · 0 out \ No newline at end of file diff --git a/tests/corpus/preview_trunc_grok.bin b/tests/corpus/preview_trunc_grok.bin new file mode 100644 index 0000000..4544e6f --- /dev/null +++ b/tests/corpus/preview_trunc_grok.bin @@ -0,0 +1,7 @@ +[?1049h ❯ run: sleep 5 && echo ok + + ⠼ Sleep 5 seconds then echo… 1.2s ⇣4.1k [stop] + + ╭────────────────────────────────────────────────────────────────────────╮ + │ ❯ │ + ╰──────────────────────────────────── Grok 4.5 (xhigh) · always-approve ─╯ \ No newline at end of file diff --git a/tests/corpus/preview_wrap_grok.bin b/tests/corpus/preview_wrap_grok.bin new file mode 100644 index 0000000..dba398b --- /dev/null +++ b/tests/corpus/preview_wrap_grok.bin @@ -0,0 +1,6 @@ +[?1049h⠼ Sleep 5 seconds then +echo ok… 1.5s + +╭────────────────────────╮ +│ ❯ │ +╰─ Grok 4.5 ─────────────╯ \ No newline at end of file From 2a358976a9447a2db305bc81c8c7a4d6812e61ff Mon Sep 17 00:00:00 2001 From: Christopher Sardegna Date: Mon, 20 Jul 2026 11:24:21 -0700 Subject: [PATCH 06/11] fix: trim leading indentation off floor previews --- src/harness/summary.rs | 4 +++- src/preview.rs | 31 ++++++++++++++++++++++++++++++- 2 files changed, 33 insertions(+), 2 deletions(-) diff --git a/src/harness/summary.rs b/src/harness/summary.rs index a06bc88..394ddb7 100644 --- a/src/harness/summary.rs +++ b/src/harness/summary.rs @@ -962,7 +962,9 @@ mod tests { assert_eq!( parts(&p), ( - " gpt-5.6-sol high · 5.26K used · 28.2K in · 78 out".to_string(), + // The floor trims the status bar's self-indentation + // (layout, not meaning; see the cascade's floor arm). + "gpt-5.6-sol high · 5.26K used · 28.2K in · 78 out".to_string(), PreviewSource::Floor, None ) diff --git a/src/preview.rs b/src/preview.rs index 88ce387..ac76663 100644 --- a/src/preview.rs +++ b/src/preview.rs @@ -161,7 +161,18 @@ fn cascade(screen: &impl ScreenFacts, adapter: Option<&dyn SummaryAdapter>) -> P }, }; } - Preview::floor(screen.live_floor()) + // Leading indentation is layout, not meaning: codex's status bar (an + // inline UI's bottom-most row, the floor of an idle codex task) indents + // itself, and the spaces waste preview width. Trimmed here, not in + // `live_floor` — the emulator's row stays a faithful fact because it + // doubles as the teardown-snapshot comparator. + let floor = screen.live_floor(); + let trimmed = floor.trim_start(); + Preview::floor(if trimmed.len() == floor.len() { + floor + } else { + trimmed.to_string() + }) } /// Candidate-recompute key: `(revision, alt epoch, alt bit, title, finished)`. @@ -1109,4 +1120,22 @@ mod tests { ("step 2: done", PreviewSource::Title, true) ); } + + /// The floor drops leading indentation: an idle codex task's floor is + /// its self-indented status bar, and the spaces waste preview width. + /// Layout, not meaning. + #[test] + fn floor_preview_trims_leading_indentation() { + let mut st = PreviewState::new(); + let t0 = Instant::now(); + let s = FakeScreen::primary(" gpt-5.6-sol high · fleetcom · 89.9K used"); + let p = st.resolve(t0, false, &s, None).clone(); + assert_eq!( + (p.text.as_str(), p.source), + ( + "gpt-5.6-sol high · fleetcom · 89.9K used", + PreviewSource::Floor + ) + ); + } } From adeba94414f8b503198c5d12fd62de64c70442e9 Mon Sep 17 00:00:00 2001 From: Christopher Sardegna Date: Mon, 20 Jul 2026 12:11:48 -0700 Subject: [PATCH 07/11] Refactor and clarify code documentation across multiple modules --- src/golden.rs | 12 +-- src/harness/mod.rs | 4 +- src/harness/summary.rs | 165 ++++++++++++-------------------- src/main.rs | 2 +- src/preview.rs | 170 +++++++++++---------------------- src/protocol.rs | 20 ++-- src/supervisor.rs | 9 +- src/supervisor_tests.rs | 5 +- src/task.rs | 55 ++++------- src/terminal/emulator.rs | 200 +++++++++++++-------------------------- src/ui.rs | 13 +-- tests/corpus/README.md | 27 +++--- 12 files changed, 233 insertions(+), 449 deletions(-) diff --git a/src/golden.rs b/src/golden.rs index 1f19bfa..72ee8ea 100644 --- a/src/golden.rs +++ b/src/golden.rs @@ -577,14 +577,10 @@ fn semantic_dec_scrollregion_charset_translation() { assert_eq!(al.grid().cursor.point, Point::new(Line(39), Column(0))); } -/// Differential canary for the Emulator's delegating [`Handler`] wrapper -/// (`ObservedTerm` in `terminal::emulator`): every corpus fixture replayed -/// through the Emulator must land on exactly the screen, cursor, and mode a -/// raw backend replay produces. All 71 Handler methods have empty defaults, -/// so a missed forward compiles silently and swallows escapes — this -/// comparison is what breaks loudly instead. The classic `alacritty()` -/// goldens above deliberately bypass the Emulator (they pin the raw -/// backend), so they cannot serve as that fence. +/// Verify that `ObservedTerm` forwards parser events by comparing each corpus +/// replay through `Emulator` with a replay through the raw backend. The +/// comparison covers the screen, cursor, and alternate-screen mode; the other +/// golden tests exercise only the raw backend. #[test] fn emulator_wrapper_matches_the_raw_backend_on_every_fixture() { let fixtures: [(&str, &[u8]); 12] = [ diff --git a/src/harness/mod.rs b/src/harness/mod.rs index 8911ca2..dc841f6 100644 --- a/src/harness/mod.rs +++ b/src/harness/mod.rs @@ -12,8 +12,8 @@ //! Every ID returned by `parse_capture`, `scrape_exit`, or `correlate_fs` //! eventually enters a shell command. These methods must therefore return only //! strings accepted by [`is_uuid`]. Free-text names, paths, and malformed IDs -//! yield `None`. The `summary` submodule is the one exemption: its output is -//! display-only and must never reach a command line (see its module docs). +//! yield `None`. Summary adapters are display-only and do not return session +//! IDs. pub mod assets; mod claude; diff --git a/src/harness/summary.rs b/src/harness/summary.rs index 394ddb7..cf957bb 100644 --- a/src/harness/summary.rs +++ b/src/harness/summary.rs @@ -1,85 +1,54 @@ -//! Per-agent summary adapters: the Anchor tier of the dashboard-preview -//! cascade (see [`crate::preview`]). Each adapter reads one agent CLI's -//! bottom chrome from the live grid and extracts the CLI's own status words. +//! Display-only summary adapters for the Anchor tier of the dashboard preview. +//! Each adapter extracts status text from an agent CLI's bottom chrome. //! //! # Display-only contract //! -//! Adapter output is rendered in the dashboard preview column and nowhere -//! else. It never enters a shell command, so the harness module's -//! [`is_uuid`](super::is_uuid) insertion boundary does not apply here — and -//! no adapter output may ever be routed onto a path where it would. +//! Adapter output is rendered in the dashboard and never enters a shell +//! command. It is therefore outside the session-ID validation boundary in +//! [`is_uuid`](super::is_uuid). //! //! # Anchor discipline //! -//! The dangerous failure is a false positive: codex scrollback holds -//! `• Ran …` rows from every prior turn, a conversation *about* a numbered -//! menu paints `❯ 1. Yes` into the body, and an agent cat-ing a document can -//! paint status-shaped rows anywhere on the grid. Every matcher therefore: +//! Status-shaped text can also appear in scrollback or conversation content. +//! To avoid treating it as live status, every matcher: //! -//! 1. locates the chrome region structurally — claude's separator-pair input -//! box, codex's status bar and composer, grok's bordered input box — and +//! 1. locates the chrome region structurally (claude's separator-pair input +//! box, codex's status bar and composer, grok's bordered input box) and //! scans only rows pinned to it, never the whole grid; -//! 2. extracts only when the working structure sits at the pinned position. -//! A missing anchor returns `None` and the cascade degrades to -//! title/marker/floor, which is honest where a body match would lie; -//! 3. keys on the row head (glyph + verb prefix) and never requires trailing -//! components: the CLIs truncate their status rows at a word boundary -//! with an appended ellipsis at narrow widths, and the affordances and -//! suffixes that truncate first are exactly what normalization strips. A -//! truncated extraction keeps the CLI's own ellipsis verbatim. A window -//! narrow enough to wrap the ellipsis itself breaks the row structure, -//! which fails the pin and falls through. +//! 2. returns `None` when the expected structure is absent or inconsistent; +//! 3. matches row prefixes so status rows truncated with an ellipsis at narrow +//! widths remain recognizable. A wrapped row fails the structural check. //! -//! Normalization removes churn — spinner glyphs, elapsed counters, token and -//! throughput tickers, `esc to interrupt` and `/ps`/`/stop` affordances — -//! and never paraphrases: the preview shows the child's own words — a -//! placeholder verb, a task-derived phrase, a command line — verbatim. -//! The one synthesized label is `awaiting approval` for claude's approval -//! menu, whose literal text (`❯ 1. Yes`) is meaningless in a dashboard -//! column; keep it the only one. -//! -//! Matchers encode screens observed on claude 2.1.215, codex-cli 0.144.6, -//! and grok 0.2.102 (fixtures: `tests/corpus/README.md`). They share the -//! harness module's brittleness posture: each constant names the exact -//! screen it came from, and the corpus tests break loudly when a CLI -//! repaints its chrome. +//! Normalization removes spinner glyphs, elapsed counters, throughput data, +//! and key hints while preserving the CLI's status text. The only synthesized +//! status is `awaiting approval` for claude's approval menu. Corpus fixtures +//! in `tests/corpus` pin the supported screen structures. use std::path::Path; use crate::preview::ScreenFacts; -/// One agent CLI's screen knowledge: live status extraction and the model -/// label, both display-only (module docs). +/// Display-only status and model-label extraction for one agent CLI. pub trait SummaryAdapter: Sync { - /// The normalized live status and the matcher id that produced it, when - /// the CLI's working structure is present at its pinned position. `None` - /// on any structural doubt: a missed anchor degrades to title/marker, - /// a guessed one lies. + /// Return normalized live status and its matcher ID when the expected + /// chrome structure is present. fn live_preview(&self, screen: &dyn ScreenFacts) -> Option<(String, &'static str)>; - /// Model label from a stable chrome row — claude's welcome box, codex's - /// status bar, grok's input-box border — never from user-configurable - /// rows (claude's statusline differs on every machine). The cascade - /// prepends `{label} · ` when `live_preview` fires. + /// Return a model label from stable CLI chrome. The preview cascade + /// prepends it to live status as `{label} · `. fn model_label(&self, screen: &dyn ScreenFacts) -> Option; - /// Synthetic exit line derived from retained terminal text, frozen as - /// the final preview when returned. The slot exists for synthetic exit - /// lines later; no v1 implementation returns `Some`. + /// Return an optional final summary derived from retained terminal text. + /// The default implementation produces no summary. fn exit_preview(&self, _retained_text: &str) -> Option { None } } -/// Select the adapter for a requested command: basename match on the first -/// whitespace-separated word. Deliberately wider than harness detection — -/// `claude --model opus` paints a claude screen even though its command line -/// is opaque to resume rewriting, and a wrong pick can only mis-read a -/// screen into `None`, never rewrite a command. Env prefixes (`FOO=bar -/// claude`) and compound shell commands select nothing: their first word is -/// not the program. Selection is also independent of [`super::detect`]: -/// `Task::harness` is set only when detection *and* capture-asset install -/// succeed, and an instrumentation failure must not kill summaries. +/// Select an adapter by the basename of the command's first +/// whitespace-separated word. Arguments are accepted; environment prefixes +/// and compound shell commands do not select an adapter. Selection is +/// independent of session-capture instrumentation. pub fn select(command: &str) -> Option<&'static dyn SummaryAdapter> { let first = command.split_whitespace().next()?; match Path::new(first).file_name()?.to_str()? { @@ -106,10 +75,8 @@ fn is_rule_row(row: &str) -> bool { // ---------------------------------------------------------------- claude -- -/// Spinner frames observed in claude 2.1.215's working states (the -/// `claude_resume.bin` stream cycles exactly these). `·` is a frame, not -/// punctuation: the glyph alone never matches without the `…`-terminated -/// verb after it. +/// Accepted claude spinner frames. A frame matches only when followed by a +/// space and an `…`-terminated status phrase. const CLAUDE_SPINNER: &[char] = &['·', '✢', '✳', '✶', '✻', '✽']; /// claude (alt screen). Working state: a column-0 spinner row directly above @@ -122,8 +89,7 @@ impl SummaryAdapter for ClaudeSummary { let rows = screen.live_rows(); match claude_box_top(&rows) { Some(top) => claude_spinner_status(&rows, top), - // No input box: only the approval dialog removes it, so only - // here may the menu shape mean anything. + // Consider approval menus only when the normal input box is absent. None => claude_approval(&rows), } } @@ -134,7 +100,7 @@ impl SummaryAdapter for ClaudeSummary { } /// Index of the input box's top separator. The bottom-most full-width rule -/// is the box's bottom edge — only statusline rows render below it — a +/// is the box's bottom edge (only statusline rows render below it); a /// second rule within six rows is its top edge, and a `❯`-headed row between /// them is the input line. Body text above and statusline rows below never /// enter the scan. @@ -152,9 +118,9 @@ fn claude_box_top(rows: &[String]) -> Option { /// Scan the three rows above the input box for the spinner row. Hint rows /// (the tmux focus-events notice, the right-aligned `● high · /effort`) are /// indented while the spinner paints at column 0; a column-0 row that is not -/// spinner-shaped aborts the scan — it is body text reaching the chrome or -/// the wrapped tail of a status row too wide for the window, and both must -/// fail structurally rather than risk matching something status-shaped. +/// spinner-shaped aborts the scan: body text reaching the chrome, or the +/// wrapped tail of a status row too wide for the window. Both must fail +/// structurally rather than risk matching something status-shaped. fn claude_spinner_status(rows: &[String], top: usize) -> Option<(String, &'static str)> { for i in (top.saturating_sub(3)..top).rev() { let row = &rows[i]; @@ -172,14 +138,9 @@ fn claude_spinner_status(rows: &[String], top: usize) -> Option<(String, &'stati None } -/// `✻ Hashing… (6s · ↓ 87 tokens)` → `Hashing…`: one spinner frame, a space, -/// the status phrase through its first `…`. The phrase is not always a -/// single placeholder verb — observed live: task-derived text with embedded -/// parens and digits (`✳ Overseeing phase 4 (adapters)…`) — and nothing here -/// keys on its shape. The trailing parenthetical (elapsed/token counters or -/// free-text progress) and any `esc to interrupt` affordance sit after the -/// `…` and drop; CLI-side truncation also ends at a word boundary with its -/// own `…`, so the same cut keeps a truncated phrase's ellipsis verbatim. +/// Extract the text through the first `…` after a claude spinner frame. +/// Task-derived phrases may contain spaces, parentheses, and digits. Text +/// after the ellipsis, including counters and key hints, is discarded. fn claude_spinner_text(row: &str) -> Option { let mut chars = row.chars(); if !CLAUDE_SPINNER.contains(&chars.next()?) || chars.next()? != ' ' { @@ -196,8 +157,8 @@ fn claude_spinner_text(row: &str) -> Option { /// The concrete-action row above a confirmed spinner: skip the blank gap, /// probe exactly one row. `⏺ Running 1 shell command…` names real work while /// the spinner phrase rotates per request, so it wins when both are -/// present. The probe requires the `⏺` head and a single trailing `…` — -/// `⏺ ok`-style reply rows fail it — and anything else keeps the spinner +/// present. The probe requires the `⏺` head and a single trailing `…` +/// (`⏺ ok`-style reply rows fail it); anything else keeps the spinner /// phrase: scanning further up would be a body hunt. fn claude_action_row(rows: &[String], spinner: usize) -> Option { let row = rows[..spinner].iter().rev().find(|r| !r.is_empty())?; @@ -206,11 +167,9 @@ fn claude_action_row(rows: &[String], spinner: usize) -> Option { (text.find('…') == Some(tail)).then(|| text.to_string()) } -/// The approval dialog's selector row, reachable only with the input box -/// gone: `❯ 1. …` with a `2. …` option below, pinned to the last nine rows -/// of painted content. Returns the one synthesized label — `❯ 1. Yes` is -/// meaningless in a dashboard column (module docs; keep it the only -/// paraphrase). +/// Match an approval selector only when the input box is absent: `❯ 1. …` +/// with a `2. …` option below, within the last nine painted rows. Returns the +/// synthesized label `awaiting approval`. fn claude_approval(rows: &[String]) -> Option<(String, &'static str)> { let last = rows.iter().rposition(|r| !r.is_empty())?; let i = (last.saturating_sub(8)..=last).find(|&i| rows[i].trim_start().starts_with("❯ 1. "))?; @@ -252,8 +211,8 @@ fn claude_welcome_label(rows: &[String]) -> Option { // ----------------------------------------------------------------- codex -- -/// codex (inline UI, primary screen). The pin is its status bar — the -/// bottom-most non-blank row — with the composer above it; status rows sit +/// codex (inline UI, primary screen). The pin is its status bar (the +/// bottom-most non-blank row) with the composer above it; status rows sit /// above the composer, and scrollback beyond the first foreign row is out of /// bounds. pub struct CodexSummary; @@ -274,8 +233,8 @@ impl SummaryAdapter for CodexSummary { } /// codex's status bar is the bottom-most non-blank row of its inline UI: -/// `{model} · {…} in · {…} out`. Its absence — codex exited and left its -/// resume hint as the last row, or something else owns the screen — fails +/// `{model} · {…} in · {…} out`. Its absence (codex exited and left its +/// resume hint as the last row, or something else owns the screen) fails /// the whole pin. fn codex_token_line(rows: &[String]) -> Option { let i = rows.iter().rposition(|r| !r.is_empty())?; @@ -299,8 +258,8 @@ fn codex_composer(rows: &[String]) -> Option { /// Walk up from the composer through the status region: blanks and indented /// rows (tool-output attachments like `└ ok`, wrapped continuations) are -/// skipped, and the first column-0 row decides. Only two heads extract — -/// `• Working (` and `• Ran ` — and any other column-0 row (a reply bullet, +/// skipped, and the first column-0 row decides. Only two heads extract +/// (`• Working (` and `• Ran `); any other column-0 row (a reply bullet, /// a `⚠` notice, a turn separator) stops the scan: scrollback holds `• Ran` /// rows from every prior turn, and skipping an unknown row to reach one /// would resurface stale work as live status. @@ -325,7 +284,7 @@ fn codex_status(rows: &[String], composer: usize) -> Option<(String, &'static st /// `7s • esc to interrupt) · 1 background terminal running · /ps to view · /// /stop to close` → `Working · 1 background terminal running`. The /// parenthetical is the elapsed counter plus interrupt affordance, dropped -/// whole — an unclosed paren is CLI-side truncation mid-affordance and drops +/// whole: an unclosed paren is CLI-side truncation mid-affordance and drops /// to the end. Of the ` · ` suffixes, `/`-headed segments are key hints; /// everything else is slow-moving state worth keeping, with its own ellipsis /// when the CLI truncated it. @@ -345,8 +304,8 @@ fn codex_working(after_paren: &str) -> String { // ------------------------------------------------------------------ grok -- -/// grok (alt screen). The pin is its bordered input box; the status row — -/// braille spinner while working, `Worked for {n}s` after a turn — is the +/// grok (alt screen). The pin is its bordered input box; the status row +/// (braille spinner while working, `Worked for {n}s` after a turn) is the /// first painted row above the box's top border. pub struct GrokSummary; @@ -392,7 +351,7 @@ fn grok_input_box(rows: &[String]) -> Option<(usize, usize)> { /// `⠼ Sleep 5 seconds then echo ok… 1.5s 2.8s ⇣14.2k [↓][stop]` → the label /// through its `…`: a braille spinner frame, a space, text cut at the first -/// `…` — everything after it is elapsed/throughput ticker. A wrapped status +/// `…`. Everything after it is elapsed/throughput ticker. A wrapped status /// row leaves its `…` tail on the probe row with no spinner head, which /// fails here and falls through. fn grok_spinner_text(t: &str) -> Option { @@ -607,11 +566,8 @@ mod tests { ); } - /// Observed live (claude 2.1.215, 2026-07-20): the spinner row carried - /// a task-derived phrase — multi-word, embedded parens and digits — - /// with a free-text parenthetical after it, not a token counter. The - /// structural cut (glyph + space + text through the first `…`) - /// extracts it verbatim; nothing may key on a single-verb shape. + /// Task-derived spinner phrases may contain spaces, parentheses, and + /// digits; extraction keeps everything through the first ellipsis. #[test] fn claude_spinner_extracts_task_derived_phrases() { let sep = "─".repeat(120); @@ -682,7 +638,7 @@ mod tests { } /// Working-row normalization: parenthetical dropped (unclosed included), - /// `/`-hint suffixes dropped, slow suffixes kept — with the CLI's own + /// `/`-hint suffixes dropped, slow suffixes kept, with the CLI's own /// ellipsis when truncated. #[test] fn codex_working_normalization() { @@ -889,8 +845,8 @@ mod tests { } } - /// Honest-degradation fixtures: idle and post-turn screens carry no - /// anchor and fall through to the marker (alt screen, no title). + /// Idle and post-turn screens have no anchor and resolve to the + /// alternate-screen marker. #[test] fn corpus_idle_states_fall_through() { let cases: [(&str, &[u8], &dyn SummaryAdapter); 4] = [ @@ -925,7 +881,7 @@ mod tests { } } - /// Negative fixtures: status-shaped text in the body never extracts. + /// Status-shaped conversation text does not extract. /// The claude fixtures quote an approval menu in the conversation; the /// codex fixtures hold `• Ran` in scrollback behind a finished turn. #[test] @@ -1026,9 +982,8 @@ mod tests { ); } - /// Pathological width: the window wraps the status row's own ellipsis - /// onto the probe row. The structure check fails and the preview falls - /// through — degradation, never a false positive. + /// At 30 columns, a wrapped status ellipsis fails the structure check and + /// resolves to the alternate-screen marker. #[test] fn corpus_wrapped_ellipsis_falls_through() { let p = resolve_corpus( diff --git a/src/main.rs b/src/main.rs index 6bd2387..31c8fe7 100644 --- a/src/main.rs +++ b/src/main.rs @@ -30,7 +30,7 @@ mod testutil; mod transport; mod ui; -// Re-exported at the root so call sites keep their short `crate::ansi`-style paths. +// Expose terminal modules through the crate root. pub(crate) use terminal::{ansi, emulator, format, frame}; use std::{ diff --git a/src/preview.rs b/src/preview.rs index ac76663..ed47aab 100644 --- a/src/preview.rs +++ b/src/preview.rs @@ -1,24 +1,19 @@ -//! Dashboard-preview resolution: one [`Preview`] per task, chosen by a -//! provenance cascade over facts the emulator already tracks and debounced -//! through hold timers so the column changes only when meaning changes. +//! Dashboard-preview resolution: one [`Preview`] per task, selected from +//! emulator state and stabilized with hold timers. //! Liveness and outcome stay with the glyph and age columns. use std::time::{Duration, Instant}; use crate::{emulator::Emulator, harness::summary::SummaryAdapter}; -// Both holds are wall-clock: tick spacing varies ≈25× (8 ms frame minimum -// under load, 200 ms idle backstop), so a tick-counted debounce would be -// load-dependent. +// Holds use elapsed time because tick intervals range from the 8 ms frame +// minimum to the 200 ms idle backstop. -/// Minimum spacing between rendered title changes: above the spinner cadence -/// of title-animating children, below reading annoyance. +/// Minimum interval between rendered title changes. pub const TITLE_MIN_HOLD: Duration = Duration::from_millis(500); -/// Continuous absence of a rendered-rank-or-better candidate before a -/// demotion commits: more than one 200 ms quiet-tick gap with margin, so a -/// single-tick transient (a partial repaint, a held sync frame) never demotes -/// the rendered preview. +/// Time a lower-ranked candidate must persist before it replaces the rendered +/// preview. pub const DEMOTION_HOLD: Duration = Duration::from_millis(600); /// Preview text for an alternate-screen child with no usable title. @@ -52,12 +47,9 @@ impl PreviewSource { } } -/// One resolved preview. `frozen` is mutability, orthogonal to `source`: a -/// finished task reports its last source with `frozen: true`, which a -/// `Frozen` variant would erase. `rule` is the summary-adapter matcher id -/// behind an Anchor preview (`None` for every other tier), and it stays -/// daemon-side — the peek footer sees it only in-process, never over the -/// wire. +/// One resolved preview. `frozen` marks an immutable final value. `rule` is +/// the summary-adapter matcher ID for an Anchor preview and is `None` for +/// other sources. #[derive(Debug, Clone, PartialEq)] pub struct Preview { pub text: String, @@ -88,7 +80,7 @@ pub trait ScreenFacts { /// Every live-viewport row, trailing padding trimmed: the summary /// adapters' structural scan input. fn live_rows(&self) -> Vec; - /// The floor snapshotted at the most recent alt-screen exit — what the + /// The floor snapshotted at the most recent alt-screen exit: what the /// 1049l restore left visible; `None` before the first exit. fn alt_leave_floor(&self) -> Option<&str>; } @@ -123,8 +115,7 @@ impl ScreenFacts for Emulator { } } -/// The instantaneous candidate. Every branch is a fact the emulator tracks -/// or an extraction from it; nothing is fabricated: +/// Resolve the instantaneous candidate in descending priority: /// 1. summary adapter: the normalized live status when the CLI's working /// structure is present, `{model label} · `-prefixed when the adapter /// reads one from stable chrome @@ -164,7 +155,7 @@ fn cascade(screen: &impl ScreenFacts, adapter: Option<&dyn SummaryAdapter>) -> P // Leading indentation is layout, not meaning: codex's status bar (an // inline UI's bottom-most row, the floor of an idle codex task) indents // itself, and the spaces waste preview width. Trimmed here, not in - // `live_floor` — the emulator's row stays a faithful fact because it + // `live_floor`: the emulator's row stays a faithful fact because it // doubles as the teardown-snapshot comparator. let floor = screen.live_floor(); let trimmed = floor.trim_start(); @@ -175,14 +166,11 @@ fn cascade(screen: &impl ScreenFacts, adapter: Option<&dyn SummaryAdapter>) -> P }) } -/// Candidate-recompute key: `(revision, alt epoch, alt bit, title, finished)`. -/// Titles and the alt bit cannot change without a revision bump (the -/// emulator's advance bookkeeping guarantees it), but the composite is cheap -/// and self-documenting. +/// State that invalidates the cached preview candidate. type ResolveKey = (u64, u64, bool, Option, bool); -/// Per-task resolution state. Lives on `Task` and resets with it: a rerun -/// replaces the whole `Task`, so run-scoped state needs no external map. +/// Per-task preview resolution state. A rerun replaces the `Task` and resets +/// this state. #[derive(Debug)] pub struct PreviewState { rendered: Preview, @@ -192,20 +180,16 @@ pub struct PreviewState { pending_candidate: Option, /// Start of the demotion hold: the first resolution whose candidate /// ranked below the rendered source. Pending-candidate changes never - /// reset it — the timer measures continuous absence of + /// reset it: the timer measures continuous absence of /// rendered-or-higher, so a flapping demoted candidate cannot postpone /// the commit forever. downgrade_pending_since: Option, /// Instant of the last rendered title: the min-hold deadline base. last_title_render: Option, last_key: Option, - /// Screen mode that produced the rendered preview, stamped at every - /// render commit; `finalize`'s teardown predicate compares it against - /// the final screen. Per-render rather than per-resolve because the - /// exit's own 1049l output wakes the core, so a resolve routinely runs - /// between teardown and reader EOF — a live-resolve bit would flip - /// primary on that tick while the demotion hold still keeps the - /// alt-committed preview rendered. + /// Whether the rendered preview was committed on the alternate screen. + /// Finalization combines this with the exit snapshot to detect a restored + /// primary screen. rendered_under_alt: bool, finalized: bool, } @@ -268,8 +252,8 @@ impl PreviewState { } /// One pass of the candidate-vs-rendered transition table. `alt` is the - /// alt bit of the screen this resolution ran against; every commit — - /// demotion-hold expiries included — stamps it onto the render. + /// alt bit of the screen this resolution ran against; every commit + /// (demotion-hold expiries included) stamps it onto the render. fn step(&mut self, now: Instant, alt: bool) { use std::cmp::Ordering; match self.candidate.source.cmp(&self.rendered.source) { @@ -279,8 +263,8 @@ impl PreviewState { self.render(cand, now, alt); } Ordering::Equal => { - // Rank ≥ rendered: a pending demotion was a one-tick repaint - // miss; recover with no visible change. + // A recovered rank cancels a pending demotion without a + // visible change. self.cancel_demotion(); if self.candidate == self.rendered { return; @@ -337,38 +321,12 @@ impl PreviewState { self.downgrade_pending_since = None; } - /// Freeze the preview once the task's output is complete. Re-resolves - /// the cascade — anchor tier included, which is how a primary-screen - /// agent's final completion row (codex's `• Ran …`) freezes — against - /// the final screen instead of freezing the last rendered value: output - /// can land between the last resolution tick and output-complete (a - /// stream's final `test result: ok` flush), and the final resolve must - /// see it. `exit_line` outranks everything when present: it is the - /// adapter's synthetic exit summary from retained text (a v1 dead slot; - /// see [`SummaryAdapter::exit_preview`]). One carve-out, - /// `alt_torn_down_at_exit`: the rendered preview was committed under - /// the alternate screen and the final screen is primary, so the exit's - /// 1049l restored pre-launch junk (alt-screen agent CLIs print nothing - /// afterward) and the held preview freezes instead — a stale but - /// meaningful line under a truthful outcome glyph beats shell junk. The - /// predicate reads the per-render stamp, not a latched ever-entered-alt - /// bit: a child that leaves the alt screen and then lives on the - /// primary screen has its demotion hold expire and commit the floor, - /// stamped primary, and there the floor IS the honest final value. A - /// sub-hold exit teardown keeps the alt-committed preview rendered - /// precisely because the demotion hold absorbs the flip. The carve-out - /// additionally demands that the final floor still equal the floor - /// snapshotted at the alt exit: a changed floor means the child wrote - /// real primary output after teardown (`tui; echo done`), and the - /// fresh cascade must pick it up even inside the hold. The - /// discriminator is visible content, never byte arrival — claude's - /// exit emits title-reset controls that can land in a chunk after the - /// 1049l, so bytes arrive while nothing visible changes, and a - /// byte/revision test would freeze restored junk for exactly the CLI - /// the carve-out serves. Control-only chunks do not move the floor; - /// written output does. Holds do not apply: finalization overrides the whole transition - /// table. Idempotent; later resolutions short-circuit to the frozen - /// value. + /// Freeze the preview once output is complete. An `exit_line` takes + /// precedence; otherwise the final screen is resolved without hold timers. + /// If an alternate-screen render is followed only by restoration of the + /// snapshotted primary floor, the rendered preview is retained. A + /// different final floor is resolved normally. + /// Repeated calls are no-ops. pub fn finalize( &mut self, screen: &impl ScreenFacts, @@ -503,7 +461,7 @@ mod tests { } /// Fixed-output adapter: the cascade tests here cover the slot's - /// plumbing (rank, label prefix, freeze), not extraction — that lives + /// plumbing (rank, label prefix, freeze), not extraction; that lives /// with the adapters in `harness::summary`. struct StubAdapter { live: Option<(&'static str, &'static str)>, @@ -607,7 +565,6 @@ mod tests { } /// An adapter exit line outranks the final screen and freezes verbatim. - /// No v1 adapter produces one; the slot's plumbing is pinned here. #[test] fn finalize_prefers_an_adapter_exit_line() { let t0 = Instant::now(); @@ -719,7 +676,7 @@ mod tests { let p = st.resolve(t0 + ms(300), false, &s, None).clone(); assert_eq!((p.text.as_str(), p.source), ("app", PreviewSource::Title)); - // The next demotion measures from its own start, not the old stamp. + // The next demotion starts a new hold interval. s.clear_title(); st.resolve(t0 + ms(400), false, &s, None); assert_eq!( @@ -816,10 +773,8 @@ mod tests { assert_eq!(s.floor_calls.get(), 2, "a revision bump must recompute"); } - /// A resize-shaped change — revision bumped, floor reflowed, alt bit, - /// epoch, and title untouched — invalidates the key and recomputes the - /// candidate (the emulator bumps its revision on resize for exactly - /// this). + /// A resize invalidates the key and recomputes the reflowed floor even + /// when the alternate-screen state and title are unchanged. #[test] fn a_resize_shaped_revision_bump_recomputes_the_candidate() { let t0 = Instant::now(); @@ -853,8 +808,8 @@ mod tests { ); } - /// Alt torn down at exit: the restored primary junk must not replace the - /// last rendered preview, and the frozen value never moves again. + /// Alternate-screen teardown with an unchanged restored primary floor + /// retains and freezes the last rendered preview. #[test] fn finalize_keeps_the_rendered_preview_across_alt_teardown() { let t0 = Instant::now(); @@ -881,12 +836,8 @@ mod tests { ); } - /// The likely interleaving, not the lucky one: the 1049l output itself - /// wakes the core, so a resolve routinely runs between alt teardown and - /// reader EOF. The teardown stamp travels with the rendered preview — - /// the demotion hold keeps the alt-committed title rendered through - /// that tick — so finalization must still keep it over the restored - /// primary junk. + /// A resolution between alternate-screen teardown and reader EOF retains + /// the alternate-screen render through the demotion hold and finalization. #[test] fn finalize_keeps_the_render_when_a_resolve_saw_the_teardown() { let t0 = Instant::now(); @@ -913,11 +864,8 @@ mod tests { ); } - /// The stamp is per-render, not a latched ever-entered-alt bit: a child - /// that leaves the alt screen and lives on the primary screen long - /// enough for the demotion hold to commit gets a primary-stamped floor, - /// and finalization trusts the final screen — the floor is the honest - /// final value there. + /// When the demotion hold commits a primary-screen floor after teardown, + /// finalization resolves the final primary screen. #[test] fn finalize_trusts_the_screen_after_a_primary_commit() { let t0 = Instant::now(); @@ -949,10 +897,8 @@ mod tests { ); } - /// `tui; echo done`: the child leaves the alt screen, prints a real - /// final line, and exits inside the demotion hold. The changed floor - /// defeats the teardown carve-out — the fresh cascade freezes the line - /// instead of a stale title discarding it. + /// Primary output written after alternate-screen teardown changes the + /// snapshotted floor and is frozen even inside the demotion hold. #[test] fn finalize_freezes_primary_output_written_after_teardown() { let t0 = Instant::now(); @@ -977,10 +923,9 @@ mod tests { ); } - /// Control-only chunks after teardown — claude's exit emits title - /// resets that can land after the 1049l — advance the revision without - /// moving the floor. The carve-out compares visible content, not byte - /// arrival, so the held preview still freezes. + /// Control-only chunks after teardown can advance the revision without + /// moving the floor. Finalization compares visible content and retains + /// the held preview. #[test] fn finalize_holds_through_control_only_output_after_teardown() { let t0 = Instant::now(); @@ -1007,12 +952,8 @@ mod tests { ); } - /// One read coalescing the 1049l with the successor's line — routine - /// on a loaded machine, since PTY reads do not preserve write - /// boundaries. The alt-exit observer snapshots at the mode event, - /// before the same read's successor bytes parse, so the finalize - /// comparison sees the line as new output and freezes it: - /// deterministic in read boundaries. + /// When one read contains 1049l and subsequent primary-screen output, the + /// exit snapshot excludes that output and finalization freezes it. #[test] fn finalize_freezes_coalesced_output_after_teardown() { let t0 = Instant::now(); @@ -1026,7 +967,7 @@ mod tests { "premise: the title rendered under the alt screen" ); - // Teardown and the real final line arrive in ONE read. + // Teardown and the final line arrive in one read. emu.process(b"\x1b[?1049ldone\r\n"); assert_eq!( emu.alt_leave_floor(), @@ -1041,10 +982,8 @@ mod tests { ); } - /// A resize between teardown and finalize reflows the restored junk. - /// The emulator re-snapshots the reflowed floor (both comparison sides - /// move together), so the reflow does not read as post-teardown output - /// and the held title still freezes. + /// A resize between teardown and finalization re-snapshots an unchanged + /// restored floor, so the held title remains eligible to freeze. #[test] fn finalize_holds_across_a_resize_after_teardown() { let t0 = Instant::now(); @@ -1074,9 +1013,8 @@ mod tests { ); } - /// The dirty variant: real output after teardown, then a resize. The - /// pre-resize floor already differs from the snapshot, the mismatch is - /// evidence and stands, and finalize freezes the output floor. + /// A resize after primary output preserves the mismatch with the + /// alternate-screen exit snapshot, so finalization freezes the output. #[test] fn finalize_freezes_output_across_a_resize_after_teardown() { let t0 = Instant::now(); @@ -1121,9 +1059,7 @@ mod tests { ); } - /// The floor drops leading indentation: an idle codex task's floor is - /// its self-indented status bar, and the spaces waste preview width. - /// Layout, not meaning. + /// Floor previews remove leading layout indentation. #[test] fn floor_preview_trims_leading_indentation() { let mut st = PreviewState::new(); diff --git a/src/protocol.rs b/src/protocol.rs index 680eaae..22c1533 100644 --- a/src/protocol.rs +++ b/src/protocol.rs @@ -224,13 +224,12 @@ pub struct TaskView { /// idle threshold; `false` once finished. pub parked: bool, pub preview: String, - /// Provenance of `preview` (see [`PreviewSource`]). + /// Provenance of `preview`. pub source: PreviewSource, /// Whether the preview froze at output-complete and can no longer change. pub frozen: bool, - /// Fine-grained matcher id behind an adapter-produced preview. Never - /// encoded: an in-process core hands it to the peek footer directly, and - /// a socket frame decodes it as `None`. + /// Matcher ID for an adapter-produced preview. It is available only + /// in-process and is not encoded on the wire. pub rule: Option<&'static str>, pub started_ago: Duration, /// Time since the last PTY output; `Some` only while the task is live. @@ -753,7 +752,7 @@ pub fn encode_event(ev: &Event) -> (u8, Vec) { insert_opt_str(&mut o, "name", &tv.name); let _ = o.insert("life", lifecycle_str(tv.lifecycle)); let _ = o.insert("preview", tv.preview.as_str()); - // `rule` deliberately stays off the wire (see `TaskView`). + // Matcher rules are process-local and omitted from the wire. let _ = o.insert("src", tv.source.label()); let _ = o.insert("frozen", tv.frozen); let _ = o.insert("started_ms", tv.started_ago.as_millis() as u64); @@ -833,9 +832,8 @@ pub fn decode_event(kind: u8, payload: &[u8]) -> Option { } else { tv["parked"].as_bool()? }; - // Pre-preview daemons omit both keys: fall back to - // the floor default, never to a dropped frame, - // exactly like `parked` above. + // Missing preview metadata uses conservative defaults + // so the task frame remains usable. let source = if tv["src"].is_null() { PreviewSource::Floor } else { @@ -1481,7 +1479,7 @@ mod tests { let (k, p) = encode_event(&tasks); assert_eq!(decode_event(k, &p), Some(tasks)); - // A daemon-side rule is dropped by encoding, not carried. + // Encoding omits the process-local matcher rule. let ruled = Event::Tasks(vec![TaskView { source: PreviewSource::Anchor, rule: Some("claude-status"), @@ -1502,9 +1500,7 @@ mod tests { } } - /// A frame from a daemon predating the preview fields decodes with the - /// floor default, exactly like the `parked` fallback: skew degrades the - /// provenance, never the frame. + /// Missing preview metadata decodes to an unfrozen Floor source. #[test] fn tasks_frame_without_preview_keys_decodes_with_floor_defaults() { let old = r#"{"t":"tasks","tasks":[{"id":1,"command":"x","cwd":"Lw==","tagged":false,"life":"active","preview":"p","started_ms":0,"parked":false}]}"#; diff --git a/src/supervisor.rs b/src/supervisor.rs index dc61293..29332b9 100644 --- a/src/supervisor.rs +++ b/src/supervisor.rs @@ -354,9 +354,7 @@ impl Supervisor { // Scrape after process exit and reader EOF, when every child byte // is present in the grid (see `Task::scrape_exit_hint`). t.scrape_exit_hint(); - // Same gate: freeze the preview once output is complete, off the - // final screen rather than the last-rendered value (see - // `Task::finalize_preview`). + // Freeze the preview from the complete output and final screen. t.finalize_preview(); if t.overdue(now, self.kill_grace) { t.force_kill(); @@ -418,9 +416,8 @@ impl Supervisor { // child that opens BSU and stalls would freeze its view. This tick is // the loop's only periodic path (the idle backstop guarantees one at // least every 200 ms), so an expired sync flushes here, before the - // preview resolve reads the grid, letting the same tick ship it. - // Iteration is `&mut` because preview resolution mutates per-task - // hold state; every task resolves against the one `now` above. + // preview resolution reads the grid, letting the same tick ship it. + // Resolution mutates per-task hold state; all tasks use one timestamp. let views = self .tasks .iter_mut() diff --git a/src/supervisor_tests.rs b/src/supervisor_tests.rs index c1e3fef..4d52ded 100644 --- a/src/supervisor_tests.rs +++ b/src/supervisor_tests.rs @@ -2718,9 +2718,8 @@ fn config_toml_notify_chains_through_the_injected_script() { argv.iter().any(|a| a.starts_with("notify=[")), "a chained spawn must still inject the override; argv: {argv:?}" ); - // Existence is not completion: the stub's `>` creates the record when - // the shell opens it, before printf writes a byte, so a reader in that - // gap sees an empty file. Gate on the full expected content instead. + // Redirection creates the record before `printf` writes, so wait for the + // complete expected contents. let expected = format!("turn-ended\n{payload}\n"); assert!( reap_until(&mut s, Duration::from_secs(5), |_| { diff --git a/src/task.rs b/src/task.rs index f70d444..7421eb4 100644 --- a/src/task.rs +++ b/src/task.rs @@ -339,11 +339,8 @@ pub struct Task { pub harness: Option<&'static dyn crate::harness::Harness>, /// Harness home resolved from this run's launch environment. pub harness_home: Option, - /// Summary adapter selected at spawn from the requested command, - /// deliberately not keyed off `harness`: that field is set only when - /// detection *and* capture-asset install succeed, and an - /// instrumentation failure must not silently kill dashboard summaries. - /// Adapter output is display-only (`harness::summary` module docs). + /// Display-only summary adapter selected from the requested command, + /// independently of session-capture instrumentation. pub summary_adapter: Option<&'static dyn crate::harness::summary::SummaryAdapter>, /// Run number used to give each rerun a distinct capture path. pub run: u32, @@ -625,11 +622,9 @@ impl Task { Ok(()) } - /// Whether the child exited and the PTY reader reached EOF, so no more - /// bytes can ever reach the grid. A missing reader handle counts as - /// complete. The reader ends on `Ok(0) | Err(_)` without distinguishing - /// clean EOF from a read error, so the name claims completeness, not - /// cleanliness. + /// Whether the child exited and the PTY reader stopped, so no more bytes + /// can reach the grid. A missing reader handle counts as complete; the + /// reader treats EOF and read errors identically. pub(crate) fn output_complete(&self) -> bool { self.finished.is_some() && self.handle.as_ref().is_none_or(JoinHandle::is_finished) } @@ -644,10 +639,9 @@ impl Task { self.scraped = true; let text = { let mut emu = grid(&self.parser); - // The gate above holds: reader EOF means no ESU can ever close an - // open `?2026` frame, so land it before reading, or a hint printed - // inside the frame is invisible to the scrape. Probe replies are - // dropped because every slave fd is closed — nothing reads them. + // Land any open synchronized frame before scraping. The reader is + // stopped, so no closing ESU can arrive; all slave fds are closed, + // so generated probe replies have no recipient. let _ = emu.finish_output(); emu.text_with_history() }; @@ -759,11 +753,9 @@ impl Task { .clone() } - /// Freeze the preview once per task life at `output_complete`. Lands any - /// open `?2026` frame first so the final resolve sees every byte; - /// `scrape_exit_hint` does the same for its own read, and whichever runs - /// second no-ops. The adapter's `exit_preview` slot reads retained text, - /// paid for only when an adapter exists (no v1 adapter returns one). + /// Freeze the preview once output is complete. Any open `?2026` frame is + /// landed first, and retained text is read only when an adapter is + /// selected. pub(crate) fn finalize_preview(&mut self) { if self.preview.finalized() || !self.output_complete() { return; @@ -1941,12 +1933,8 @@ mod tests { let _ = std::fs::remove_dir_all(&dir); } - /// Alt teardown at exit: the child enters the alt screen, titles it, and - /// exits through 1049l. The restored primary junk must not replace the - /// last rendered preview; it freezes with its source preserved. The - /// teardown is fenced behind a second flag so the test can force a - /// resolve on the torn-down screen before EOF — the interleaving the - /// core actually produces, since the 1049l output is what wakes it. + /// A resolution after 1049l but before reader EOF retains and freezes the + /// alternate-screen title when the restored primary floor is unchanged. #[test] fn finalize_preview_keeps_the_last_render_across_alt_teardown() { use crate::preview::PreviewSource; @@ -1975,9 +1963,8 @@ mod tests { }), "teardown never reached the grid" ); - // The killer tick: resolve against the torn-down screen while the - // child still lives. The demotion hold keeps the title rendered, - // and the render's alt stamp must survive this resolve. + // Resolve against the restored primary screen before reader EOF. The + // demotion hold retains the alternate-screen title and mode stamp. assert_eq!( t.resolve_preview(Instant::now()).source, PreviewSource::Title, @@ -2005,13 +1992,9 @@ mod tests { let _ = std::fs::remove_dir_all(&dir); } - /// `tui; echo done` at the PTY level: teardown, then a real primary - /// line, then exit inside the demotion hold. The frozen preview is the - /// line, not the stale title. The 1049l and the line are written - /// together, without a separating sleep: the alt-exit observer - /// snapshots at the mode event, so the read boundary is immaterial — - /// coalesced or split, the result is the same. That is the fix's - /// point. + /// Alternate-screen teardown followed by primary output freezes the + /// primary line even when both are written together inside the demotion + /// hold. #[test] fn finalize_preview_freezes_primary_output_after_alt_teardown() { use crate::preview::PreviewSource; @@ -2053,7 +2036,7 @@ mod tests { /// working screen into a real PTY, repaints it with the completion row, /// and exits; the summary adapter anchors the live preview and /// finalization freezes the completion. The adapter is installed - /// manually because the child is `printf` under `$SHELL`, not `codex` — + /// manually because the child is `printf` under `$SHELL`, not `codex`: /// no real agent CLI runs here. #[test] fn summary_adapter_anchors_live_and_freezes_completion_at_exit() { diff --git a/src/terminal/emulator.rs b/src/terminal/emulator.rs index 3ad3f8a..f0bfa9e 100644 --- a/src/terminal/emulator.rs +++ b/src/terminal/emulator.rs @@ -35,14 +35,9 @@ pub enum MouseProtocolEncoding { Sgr, } -/// Routes the backend's `Event::PtyWrite` probe responses into a buffer. -/// The listener fires inside `Processor::advance`, while the caller holds -/// the emulator lock, so it only stores, never blocks; the caller drains, -/// filters, and sanitizes after `advance` returns. Every other backend -/// event (titles, clipboard, color requests, bell) is discarded here: the -/// default-deny probe policy starts with what never gets buffered, and -/// titles are observed at their handler event by [`ObservedTerm`], not -/// through this listener. +/// Buffers backend-generated PTY responses while the parser advances. Other +/// backend events are discarded; [`ObservedTerm`] captures titles directly +/// from parser events. pub struct ProbeSink { responses: Arc>>, } @@ -128,7 +123,7 @@ fn is_bidi_control(c: char) -> bool { /// Sanitize child-controlled title text: strip C0/C1 controls and bidi /// formatting controls, collapse each whitespace run to one space, trim the /// ends, cap at [`TITLE_MAX_BYTES`] on a char boundary. Empty output means -/// the caller must unset its capture — a blank label is never displayed. +/// the caller must unset its capture: a blank label is never displayed. fn sanitize_title(raw: &str) -> String { let mut out = String::new(); for c in raw.chars() { @@ -161,11 +156,8 @@ fn sanitize_title(raw: &str) -> String { out } -/// A sanitized title plus the alt-screen epoch it was captured in — at the -/// title event itself, or by promotion from staging at the entry event (see -/// [`ObservedTerm::observe_title`]). The title is honored only while its -/// epoch is current: each alt-screen entry starts a new epoch, so a title -/// from a previous full-screen app never labels the app that replaced it. +/// A sanitized title and the alternate-screen epoch that owns it. Each +/// alternate-screen entry starts a new epoch. struct CapturedTitle { text: String, alt_epoch: u64, @@ -276,11 +268,7 @@ impl Emulator { } } - /// The shared bookkeeping step behind every grid advance — `process` and - /// both sync-frame landings. Nothing but the revision bump remains: - /// epochs, teardown snapshots, and title ownership are all observed at - /// their parser events by [`ObservedTerm`], so no preview semantics - /// depend on where PTY reads split. + /// Record one parser or synchronized-frame advance. fn observe_advance(&mut self) { self.revision += 1; } @@ -332,7 +320,7 @@ impl Emulator { /// timeout, landing the buffered frame in the grid; returns any /// allowlisted probe replies the landed bytes generated. Exists for /// reader EOF: every child fd is closed, so the closing ESU can never - /// arrive and `flush_expired_sync`'s deadline wait protects nothing — + /// arrive and `flush_expired_sync`'s deadline wait protects nothing: /// the frame is landed, not torn. No-op when no sync is open. pub fn finish_output(&mut self) -> Vec { if self.parser.sync_timeout().sync_timeout().is_none() { @@ -469,14 +457,8 @@ impl Emulator { /// Resize the grid to `rows`×`cols`. pub fn resize(&mut self, rows: u16, cols: u16) { - // The teardown snapshot must survive the reflow: when the floor - // still equals it (nothing written since the alt exit), the resize - // rewraps one side of the finalize comparison, so re-snapshot the - // reflowed floor afterwards to keep both sides equal. When they - // already differ, real output arrived and the mismatch is evidence - // — leave it standing. Never simply clear: a cleared snapshot - // fails the teardown carve-out and freezes restored junk, the - // wrong direction. Exact equality in both branches; no heuristic. + // Re-snapshot an unchanged restored floor after reflow. A floor that + // already differs records primary output after the alt-screen exit. let untouched = self .alt .leave_floor @@ -489,11 +471,8 @@ impl Emulator { if untouched { self.alt.leave_floor = Some(live_floor_of(&self.term)); } - // A resize reflows the grid — wrapping, row positions — without any - // bytes arriving, so revision-keyed pollers must re-read. A bare - // bump, not `observe_advance`: that step consumes byte-driven state - // (a pending title event) that a resize never produces, and - // consuming it here would misattribute it. + // Reflow changes grid contents without parser input, so cached screen + // facts must be invalidated. self.revision += 1; } @@ -522,21 +501,20 @@ impl Emulator { self.alt.epoch } - /// The floor snapshotted at the most recent alt-screen exit (see the - /// field docs); `None` until the child first leaves the alt screen. + /// The live floor captured at the most recent alt-screen exit, or `None` + /// before the first exit. pub fn alt_leave_floor(&self) -> Option<&str> { self.alt.leave_floor.as_deref() } /// The window title. On the alternate screen: the captured title, - /// honored only while its alt-screen epoch is current — a title from a + /// honored only while its alt-screen epoch is current; a title from a /// previous alt session reads as `None`. On the primary screen: a live /// staged announce (a title not yet disclaimed by printed output) /// surfaces first, then a still-current captured title. pub fn title(&self) -> Option<&str> { - // On the primary screen a live staged announce surfaces, so shell - // titles read as before; the preview cascade never consults titles - // there. The captured title keeps its epoch gate unchanged. + // Surface a staged primary-screen title until printable output + // disclaims it or an alternate-screen entry claims it. if !self.alternate_screen() && let Some(staged) = self.alt.staged_title.as_deref() { @@ -550,7 +528,7 @@ impl Emulator { } /// The last non-blank row of the live screen, trailing padding trimmed; - /// empty when the screen is blank. Ignores the scrollback view offset — + /// empty when the screen is blank. Ignores the scrollback view offset: /// `contents` follows `display_offset`, which would make a scrolled-back /// task preview historical rows instead of live output. pub fn live_floor(&self) -> String { @@ -567,10 +545,8 @@ impl Emulator { } } -/// The last non-blank row of `term`'s live screen, trailing padding -/// trimmed; empty when the screen is blank (see [`Emulator::live_floor`]). -/// Free over the term so the alt-exit observer can snapshot mid-advance, -/// while the `&mut Term` is borrowed as a handler. +/// The last non-blank live row, with trailing padding removed. This free +/// function can run while `Term` is borrowed through a parser handler. fn live_floor_of(term: &Term) -> String { for row in (0..term.grid().screen_lines() as i32).rev() { let text = live_row_text_of(term, row); @@ -608,16 +584,10 @@ fn live_row_text_of(term: &Term, row: i32) -> String { text } -/// Alt-screen and title facts observed at parser-event granularity: every -/// field moves at its exact event, never at read boundaries, so no preview -/// semantics depend on where PTY reads split — a read that coalesces a -/// teardown with successor output, a leave and re-enter, or a title with a -/// following mode flip all observe identically however the reads land. -/// -/// The one accepted residual: a title emitted between two apps (after A's -/// 1049l, before B's 1049h) with no intervening glyphs stages into B — -/// indistinguishable from grok's legitimate pre-entry announce by any fact -/// held here. +/// Alternate-screen and title state updated at parser-event boundaries. A +/// primary-screen title with no following printable output is assigned to the +/// next alternate-screen entry; the state cannot distinguish that announce +/// from a title emitted between two full-screen applications. #[derive(Default)] struct AltScreen { /// Count of alt-screen entries. Compared against @@ -630,52 +600,39 @@ struct AltScreen { /// The live floor captured at each alt-screen exit, at the mode event /// itself: successor bytes in the same read have not parsed yet, so /// this is exactly what the restore left visible. Preview finalization - /// compares the final floor against it to tell restored pre-launch - /// junk from real primary output written after teardown. + /// compares the final floor against it to distinguish restored primary + /// content from content written after teardown. leave_floor: Option, /// Sanitized title owned by an alt session, epoch-stamped at its event. title: Option, - /// Sanitized title announced on the primary screen, awaiting the next - /// alt entry: grok titles the window just before its 1049h, and the - /// entry event promotes this into the new epoch. Printable output - /// disclaims it (see the `input` forward); a reset clears it. + /// Sanitized title announced on the primary screen and awaiting the next + /// alternate-screen entry. Printable output disclaims it; a reset clears + /// it. staged_title: Option, /// Mirror of the backend's raw (unsanitized) current title, kept only /// so the title-stack shadow pushes what the backend pushes. raw_title: Option, - /// Mirror of the backend's title stack. A pop restores through the - /// backend's own internal `set_title`, which never re-enters the - /// wrapper, so the pop is replayed against this shadow instead. Same - /// bound and eviction as the backend (`TITLE_STACK_MAX_DEPTH`, pinned - /// `=0.26.0`). + /// Shadow of the backend title stack, needed because a backend pop does + /// not emit a handler title event. title_stack: Vec>, } -/// The backend's `TITLE_STACK_MAX_DEPTH` (term/mod.rs, pinned `=0.26.0`): -/// the shadow stack must evict exactly when the backend does or a deep -/// stack would desynchronize pops. +/// Capacity of the backend title stack mirrored by [`AltScreen::title_stack`]. const TITLE_STACK_SHADOW_MAX: usize = 4096; /// Delegating [`Handler`] that forwards every parser event to the wrapped /// [`Term`] and observes alt-screen transitions the moment they happen. /// -/// # Missed-forward hazard +/// # Forwarding invariant /// -/// Every `Handler` method has an empty `{}` default, so a missing forward -/// compiles silently and swallows that escape. Two fences hold: the -/// backend is pinned `=0.26.0` in Cargo.toml, and -/// `golden::emulator_wrapper_matches_the_raw_backend_on_every_fixture` -/// replays every corpus fixture through this wrapper and diffs the full -/// screen, cursor, and mode against a raw backend replay — a swallowed -/// method breaks it loudly (the classic goldens alone cannot serve: they -/// drive the raw backend and never touch this path). The forwards below -/// are mechanically generated from the vte 0.15 trait: 71 methods, -/// count-verified against the trait definition. +/// `Handler` methods default to no-ops, so every method must delegate to +/// `Term`. `golden::emulator_wrapper_matches_the_raw_backend_on_every_fixture` +/// compares wrapper and raw-backend replays to detect missing delegation. /// -/// # Why this is sound under `?2026` +/// # Synchronized updates /// /// The parser buffers a synchronized-update frame and drives the handler -/// only when the frame lands (in `advance` or `stop_sync` — both routed +/// only when the frame lands (in `advance` or `stop_sync`, both routed /// through this wrapper), so these events fire exactly when the grid /// moves: the observer can never see a transition the grid has not /// performed, which no byte-scanner could guarantee. @@ -685,11 +642,8 @@ struct ObservedTerm<'a> { } impl ObservedTerm<'_> { - /// Compare the wrapped term's alt bit against the last observed value - /// after a delegated mode-touching event. Mode-number-agnostic by - /// design — no 1049/1047/47 literals: the bit compare tracks whatever - /// modes the backend maps to the alt screen, surviving backend - /// changes. + /// Update alternate-screen state after a delegated mode change by + /// comparing the backend's current and previously observed mode bits. fn observe_alt(&mut self) { let alt = self.term.mode().contains(TermMode::ALT_SCREEN); if alt && !self.alt.last_alt { @@ -710,18 +664,10 @@ impl ObservedTerm<'_> { self.alt.last_alt = alt; } - /// Title ownership at the event. A title set ON the alt screen labels - /// the current epoch, where it was spoken. A title set on the primary - /// screen is STAGED for the next alt entry — grok announces its title - /// just before its 1049h — and promoted at the entry event. A reset, - /// or a title that sanitizes to nothing, clears both: `printf - /// '\x1b]0;\x07'` un-titles the window, it does not freeze a stale - /// one. Staging is disclaimed by printable output (`input`), never by - /// control traffic: grok's gap between its title and 1049h is clears - /// and cursor moves, which must not disclaim, while a shell prompt - /// always prints glyphs, so a prompt-titling shell cannot leak its - /// title into the next app. Input-only is the deliberate, minimal - /// rule. + /// Assign a sanitized title to the current alternate-screen epoch or + /// stage it for the next entry when on the primary screen. An empty title + /// clears captured and staged titles. Printable output, but not control + /// traffic, disclaims a staged title. fn observe_title(&mut self, title: Option) { self.alt.raw_title.clone_from(&title); let text = title @@ -743,7 +689,7 @@ impl ObservedTerm<'_> { } } -/// Mechanical forwards. Five carry observations after delegating: +/// Handler delegation. Five methods also update observed state: /// `set_private_mode`, `unset_private_mode`, and `reset_state` observe the /// alt bit (RIS exits the alt screen too); `set_title` observes title /// ownership; `input` disclaims a staged title. `push_title`/`pop_title` @@ -762,10 +708,7 @@ impl Handler for ObservedTerm<'_> { } fn input(&mut self, a0: char) { self.term.input(a0); - // Printable output disclaims a staged title (rationale on - // `observe_title`). One branch, predictably not-taken: staging is - // only ever live between a primary-screen title and the next alt - // entry. + // Printable output disclaims a staged primary-screen title. if self.alt.staged_title.is_some() { self.alt.staged_title = None; } @@ -875,11 +818,8 @@ impl Handler for ObservedTerm<'_> { fn reset_state(&mut self) { self.term.reset_state(); self.observe_alt(); - // RIS clears the backend's title and title stack directly, without - // a handler event (term/mod.rs `reset_state`): mirror both, and - // drop any staged announce with the rest of the pre-reset world. - // The captured title stays — it is epoch-gated and expires at the - // next entry, matching the pre-observer behavior. + // RIS clears the backend title and title stack without separate + // handler events. The captured title remains epoch-gated. self.alt.raw_title = None; self.alt.title_stack.clear(); self.alt.staged_title = None; @@ -1487,7 +1427,7 @@ mod tests { assert_eq!(sanitize_title("\x01\x02\x03"), ""); } - /// Each bidi formatting control is stripped individually — the fixed set + /// Each bidi formatting control is stripped individually: the fixed set /// the sanitizer names, not a general `Cf` sweep. #[test] fn sanitize_strips_each_bidi_control() { @@ -1554,7 +1494,7 @@ mod tests { } /// A title just before the alt entry stages and is promoted into the - /// new epoch at the entry event — children emit the title bytes just + /// new epoch at the entry event: children emit the title bytes just /// before DECSET 1049, and no glyphs intervene to disclaim it. #[test] fn title_entering_alt_in_one_chunk_is_honored() { @@ -1564,10 +1504,8 @@ mod tests { assert_eq!(emu.title(), Some("app")); } - /// A primary-screen title followed by printed output is the shell - /// titling itself: the glyphs are what disclaim it now — a bare title - /// with only control traffic until the entry stays valid by the - /// staging rule, deliberately (that is grok's announce shape). + /// Printable primary-screen output disclaims a staged title; control-only + /// traffic leaves it available for the next alternate-screen entry. #[test] fn title_before_alt_entry_in_a_prior_chunk_expires() { let mut emu = Emulator::new(4, 20, 0); @@ -1582,11 +1520,8 @@ mod tests { ); } - /// The maintainer's counterexample: app A titles itself inside alt - /// epoch 1, then bounces to app B. Whether the title, the 1049l, and - /// the 1049h share one read or split before the 1049l, the outcome is - /// identical — the title event stamped epoch 1 at the event, and the - /// bounce advanced to 2. + /// A leave and re-entry expires the first alternate-screen epoch's title, + /// independent of how the bytes are split across reads. #[test] fn in_alt_title_expires_across_a_bounce_on_any_read_boundary() { for split in [false, true] { @@ -1609,7 +1544,7 @@ mod tests { } /// grok's announce shape: a primary-screen title, a control-only gap - /// (clears and cursor moves), then the alt entry — honored on either + /// (clears and cursor moves), then the alt entry. Honored on either /// read boundary, because control traffic never disclaims staging. #[test] fn staged_title_survives_a_control_only_gap_into_the_entry() { @@ -1637,10 +1572,8 @@ mod tests { assert_eq!(emu.title(), None); } - /// The accepted residual, pinned as documented behavior: a title - /// emitted between two apps — after A's 1049l, before B's 1049h — with - /// no intervening glyphs stages into B. No fact held here can tell it - /// from grok's legitimate pre-entry announce (`AltScreen` docs). + /// A primary-screen title between two alternate-screen sessions stages + /// into the second session when no printable output intervenes. #[test] fn inter_app_title_with_no_glyphs_stages_into_the_next_app() { let mut emu = Emulator::new(4, 20, 0); @@ -1667,7 +1600,7 @@ mod tests { /// A title followed by leaving the alt screen: the title event fires /// while the alt screen is still active, capturing into the current - /// epoch, and the exit keeps the epoch — so it stays honored. + /// epoch, and the exit keeps the epoch, so it stays honored. #[test] fn title_just_before_alt_exit_stays_honored() { let mut emu = Emulator::new(4, 20, 0); @@ -1694,9 +1627,8 @@ mod tests { assert_eq!(emu.title(), Some("app")); } - /// The expired-timeout landing, same premise: sleeping past vte's 150 ms - /// deadline is a minimum-duration wait, so expiry is guaranteed, not - /// raced. + /// An expired synchronized frame updates the revision, alternate-screen + /// epoch, and title when it lands. #[test] fn flush_expired_sync_runs_the_advance_bookkeeping() { let mut emu = Emulator::new(4, 20, 0); @@ -1737,11 +1669,8 @@ mod tests { assert_eq!(emu.revision(), before + 1); } - /// The alt-exit snapshot captures what the 1049l restore left visible, - /// at the mode event itself: text after the 1049l — in a later read OR - /// coalesced into the same one — moves the floor without touching the - /// snapshot. PTY reads do not preserve write boundaries, so the - /// coalesced case is routine on a loaded machine, not rare. + /// The alternate-screen exit snapshot captures the restored floor before + /// any following bytes, including bytes coalesced into the same read. #[test] fn alt_leave_floor_snapshots_the_restore() { let mut emu = Emulator::new(4, 20, 0); @@ -1769,9 +1698,8 @@ mod tests { assert_eq!(emu.live_floor(), "coalesced"); } - /// The maintainer's epoch regression: a leave and re-enter inside one - /// read must advance the epoch and expire the previous app's title — - /// read boundaries are not allowed to decide title expiry. + /// A leave and re-entry within one read advances the epoch and expires the + /// preceding alternate-screen title. #[test] fn same_read_alt_bounce_advances_the_epoch_and_expires_the_title() { let mut emu = Emulator::new(4, 20, 0); diff --git a/src/ui.rs b/src/ui.rs index 2144875..7242cb0 100644 --- a/src/ui.rs +++ b/src/ui.rs @@ -308,9 +308,8 @@ fn row_age(v: &TaskView) -> Duration { edge.unwrap_or(v.started_ago) } -/// The row's three cells — everything left of the preview, the padded preview -/// cell, and the time column — split so `dim_preview_row` can restyle the -/// preview cell alone. +/// Split a task row into its leading, preview, and time cells so the preview +/// can be styled independently. fn task_row_parts(v: &TaskView, cols: usize) -> (String, String, String) { let glyph = match v.lifecycle { Lifecycle::Active => "✻", @@ -339,8 +338,7 @@ fn task_row(v: &TaskView, cols: usize) -> String { format!("{lead}{preview}{time}") } -/// Paint one row with a dimmed preview cell, restyling the padded plain text -/// like the header does so truncation cannot desync the styled runs. +/// Paint a task row with only its padded preview cell dimmed. fn dim_preview_row(out: &mut impl Write, y: u16, v: &TaskView, cols: usize) -> io::Result<()> { let (lead, preview, time) = task_row_parts(v, cols); let display = pad(&format!("{lead}{preview}{time}"), cols); @@ -411,8 +409,7 @@ fn render_peek(out: &mut impl Write, app: &App) -> io::Result<()> { MoveTo(x0 as u16, by), Print(format!("└{}┘", "─".repeat(inner_w))) )?; - // Debug affordance: where the row's preview came from. `rule` is only - // ever present in-process (it does not cross the wire). + // The peek footer identifies the preview source and in-process matcher. let footer = format!( " space/esc close · enter attach · preview: {} ", preview_provenance(v) @@ -428,7 +425,7 @@ fn render_peek(out: &mut impl Write, app: &App) -> io::Result<()> { } /// The peek footer's provenance label: source, then the matcher rule when -/// one produced it, then the frozen flag — e.g. `title` or `floor (frozen)`. +/// one produced it, then the frozen flag. Examples: `title`, `floor (frozen)`. fn preview_provenance(v: &TaskView) -> String { let mut s = v.source.label().to_string(); if let Some(rule) = v.rule { diff --git a/tests/corpus/README.md b/tests/corpus/README.md index 31cd764..73e8dde 100644 --- a/tests/corpus/README.md +++ b/tests/corpus/README.md @@ -30,16 +30,13 @@ feed the bytes to the emulator verbatim. ## Preview fixtures -The `preview_*.bin` fixtures pin the summary adapters (`src/harness/summary.rs`): -per-state agent-CLI screens whose extraction, normalization, and refusal -behavior the adapter tests assert exactly. Unlike the raw recordings above, -each is a constructed repaint stream — an optional alt-screen entry (claude -and grok run on the alternate screen; codex is inline), clear, home, then the -captured screen's rows joined with CRLF — trimmed from per-state snapshots of -claude 2.1.215, codex-cli 0.144.6, and grok 0.2.102. All identifying content -(names, account identifiers, filesystem paths, MCP server names, and every -user-configured statusline row) is replaced with same-length synthetic values, -so box borders stay column-aligned. Geometry is 40×120 unless noted. +The `preview_*.bin` fixtures pin summary-adapter extraction, normalization, +and fallback behavior. Each fixture is a constructed repaint stream: optional +alternate-screen entry, clear, home, then sanitized screen rows joined with +CRLF. Claude and Grok use the alternate screen; Codex is inline. The source +screens came from claude 2.1.215, codex-cli 0.144.6, and grok 0.2.102. +Identifying and user-configured text is replaced with alignment-preserving +synthetic values. Geometry is 40×120 unless noted. | Fixture | Scenario | Coverage | | --- | --- | --- | @@ -49,20 +46,20 @@ so box borders stay column-aligned. Geometry is 40×120 unless noted. | `preview_claude_approval.bin` | claude file-write approval dialog, input box replaced | `claude:approval-menu` synthesizes `awaiting approval` | | `preview_claude_idle.bin` | claude idle with the `Try "…"` placeholder | fall-through to the marker; the placeholder never anchors | | `preview_claude_done.bin` | claude after a finished turn (`✻ Crunched for 4s` in the body) | fall-through; body completion rows are out of the pinned window | -| `preview_claude_body_menu.bin` | approval-menu text quoted in the body while the spinner runs | negative: the pinned spinner wins over body menu shapes | -| `preview_claude_body_menu_idle.bin` | approval-menu text touching the chrome window, input box intact | negative: a foreign column-0 row aborts to fall-through | +| `preview_claude_body_menu.bin` | approval-menu text quoted in the body while the spinner runs | the pinned spinner wins; body menu text is ignored | +| `preview_claude_body_menu_idle.bin` | approval-menu text touching the chrome window, input box intact | a foreign column-0 row aborts extraction and resolves to the marker | | `preview_codex_working.bin` | codex `• Working (7s • esc to interrupt) · 1 background terminal running · /ps to view · /stop to close` | `codex:working` normalization: affordances stripped, slow suffix kept | | `preview_codex_working_over_ran.bin` | codex working with a `• Ran` row higher in the same turn | `codex:working` wins at the pin; the stale row never surfaces | -| `preview_codex_scrollback.bin` | codex finished turn, `• Ran` from the prior turn in scrollback | negative: the scan stops at the reply bullet; floor tier reports | +| `preview_codex_scrollback.bin` | codex finished turn, `• Ran` from the prior turn in scrollback | the scan stops at the reply bullet and resolves to the floor tier | | `preview_codex_ran.bin` | codex transient completion (synthetic: no raw capture holds it) | `codex:ran` extraction through the `└` attachment row | | `preview_grok_working.bin` | grok braille spinner with elapsed/throughput ticker | `grok:spinner` cut at the label's `…`; border label read | | `preview_grok_worked.bin` | grok `Worked for 8.7s` completion row above the box | `grok:worked` kept verbatim | | `preview_grok_idle.bin` | grok idle session | fall-through to the marker | | `preview_grok_splash.bin` | grok launch splash with resume hint above the box | fall-through; distinct views never anchor | | `preview_trunc_claude.bin` | synthetic 40×80: spinner row truncated inside its parenthetical | head match still extracts `Hashing…` | -| `preview_trunc_codex.bin` | synthetic 40×80: working row truncated inside the `/ps` hint | head match still extracts; dropped suffix was strippable anyway | +| `preview_trunc_codex.bin` | synthetic 40×80: working row truncated inside the `/ps` hint | the head still matches and the key-hint suffix is omitted | | `preview_trunc_grok.bin` | synthetic 40×80: spinner label truncated with the CLI's ellipsis | extraction keeps the CLI's own `…` verbatim | -| `preview_wrap_grok.bin` | synthetic 40×30: the status row wraps its ellipsis onto the next row | pathological width fails the structure check and falls through | +| `preview_wrap_grok.bin` | synthetic 40×30: the status row wraps its ellipsis onto the next row | the structure check fails and resolves to the marker | ## What the fixtures prove From 4b0290d20560eafe0c05879109d4f5672e2b55e3 Mon Sep 17 00:00:00 2001 From: Christopher Sardegna Date: Mon, 20 Jul 2026 12:19:04 -0700 Subject: [PATCH 08/11] fix: re-pin the codex adapter on its composer, not the token bar --- src/harness/summary.rs | 208 ++++++++++++++++++++--- tests/corpus/README.md | 9 + tests/corpus/preview_codex_approval.bin | 15 ++ tests/corpus/preview_codex_body_menu.bin | 8 + tests/corpus/preview_codex_hint_row.bin | 14 ++ 5 files changed, 227 insertions(+), 27 deletions(-) create mode 100644 tests/corpus/preview_codex_approval.bin create mode 100644 tests/corpus/preview_codex_body_menu.bin create mode 100644 tests/corpus/preview_codex_hint_row.bin diff --git a/src/harness/summary.rs b/src/harness/summary.rs index cf957bb..1cbfcd2 100644 --- a/src/harness/summary.rs +++ b/src/harness/summary.rs @@ -21,8 +21,9 @@ //! //! Normalization removes spinner glyphs, elapsed counters, throughput data, //! and key hints while preserving the CLI's status text. The only synthesized -//! status is `awaiting approval` for claude's approval menu. Corpus fixtures -//! in `tests/corpus` pin the supported screen structures. +//! status is `awaiting approval`, for approval menus: claude's dialog and +//! codex's modal. Corpus fixtures in `tests/corpus` pin the supported screen +//! structures. use std::path::Path; @@ -211,15 +212,21 @@ fn claude_welcome_label(rows: &[String]) -> Option { // ----------------------------------------------------------------- codex -- -/// codex (inline UI, primary screen). The pin is its status bar (the -/// bottom-most non-blank row) with the composer above it; status rows sit -/// above the composer, and scrollback beyond the first foreign row is out of -/// bounds. +/// codex (inline UI, primary screen). The pin is its composer: the +/// bottom-most column-0 `›` row that is not a modal selector; status rows +/// sit above it, and scrollback beyond the first foreign row is out of +/// bounds. The approval modal removes the composer and is checked first. +/// Both observed layout generations anchor: token bar as the bottom row, +/// or hint rows below the composer with no token bar painted (codex-cli +/// 0.144.6, sighted 2026-07-20). pub struct CodexSummary; impl SummaryAdapter for CodexSummary { fn live_preview(&self, screen: &dyn ScreenFacts) -> Option<(String, &'static str)> { let rows = screen.live_rows(); + if let Some(hit) = codex_approval(&rows) { + return Some(hit); + } let composer = codex_composer(&rows)?; codex_status(&rows, composer) } @@ -232,28 +239,66 @@ impl SummaryAdapter for CodexSummary { } } -/// codex's status bar is the bottom-most non-blank row of its inline UI: -/// `{model} · {…} in · {…} out`. Its absence (codex exited and left its -/// resume hint as the last row, or something else owns the screen) fails -/// the whole pin. +/// `› 1. Yes, proceed (y)`: the modal's selected option row — column-0 `›`, +/// one digit, `. `. +fn codex_menu_head(row: &str) -> bool { + row.strip_prefix("› ") + .and_then(|r| r.strip_prefix(|c: char| c.is_ascii_digit())) + .is_some_and(|r| r.starts_with(". ")) +} + +/// An unselected modal option: indented, `{digit}. `-headed. +fn codex_numbered_option(row: &str) -> bool { + let t = row.trim_start(); + let digits = t.chars().take_while(char::is_ascii_digit).count(); + t.len() > digits && digits >= 1 && t[digits..].starts_with(". ") +} + +/// codex's approval modal: a selector row with an indented numbered sibling +/// below it, pinned to the last nine painted rows (the modal has no +/// composer to pin on — it removes the composer and token bar outright, +/// and that removal is the disambiguator: a menu quoted in the +/// conversation always has the live composer somewhere below it, so any +/// non-selector `›` row below the selector suppresses the match). Second +/// member of the approval-menu synthesis class (module docs). +fn codex_approval(rows: &[String]) -> Option<(String, &'static str)> { + let last = rows.iter().rposition(|r| !r.is_empty())?; + let i = (last.saturating_sub(8)..=last).find(|&i| codex_menu_head(&rows[i]))?; + let sibling = rows[i + 1..].iter().find(|r| !r.is_empty())?; + if !(sibling.starts_with(' ') && codex_numbered_option(sibling)) { + return None; + } + rows[i + 1..] + .iter() + .all(|r| !r.starts_with('›') || codex_menu_head(r)) + .then(|| ("awaiting approval".to_string(), "codex:approval-menu")) +} + +/// The token/status bar, when painted: the bottom-most +/// `{model} · {…} in · {…} out` row among the last six painted rows. +/// Deliberately decoupled from the composer pin — the live-sighted working +/// layout omits the bar entirely, and the anchor then fires without a +/// model prefix. fn codex_token_line(rows: &[String]) -> Option { - let i = rows.iter().rposition(|r| !r.is_empty())?; - let segs: Vec<&str> = rows[i].trim().split(" · ").collect(); - (segs.len() >= 3 - && !segs[0].is_empty() - && segs[segs.len() - 2].ends_with(" in") - && segs[segs.len() - 1].ends_with(" out")) - .then_some(i) + let last = rows.iter().rposition(|r| !r.is_empty())?; + (last.saturating_sub(5)..=last).rev().find(|&i| { + let segs: Vec<&str> = rows[i].trim().split(" · ").collect(); + segs.len() >= 3 + && !segs[0].is_empty() + && segs[segs.len() - 2].ends_with(" in") + && segs[segs.len() - 1].ends_with(" out") + }) } -/// The composer row (`› …`, column 0) within three rows above the status -/// bar. Prompt echoes in scrollback share the `›` head but sit above the -/// composer, which is why the search runs bottom-up from the bar. +/// The composer: the bottom-most column-0 `›` row that is not a modal +/// selector. Rows below it are tolerated, never required — blank rows, +/// indented affordance hints (`tab to queue message`), or the token bar — +/// because the working layout can paint hints below the composer with no +/// bar at all. Prompt echoes in scrollback share the `›` head but sit +/// above the composer, which is why the bottom-most wins. fn codex_composer(rows: &[String]) -> Option { - let token = codex_token_line(rows)?; - (token.saturating_sub(3)..token) - .rev() - .find(|&i| rows[i].starts_with('›')) + rows.iter() + .rposition(|r| (r.as_str() == "›" || r.starts_with("› ")) && !codex_menu_head(r)) } /// Walk up from the composer through the status region: blanks and indented @@ -707,10 +752,86 @@ mod tests { assert_eq!(CodexSummary.live_preview(&behind_reply), None); } - /// Without the status bar as the bottom row (codex exited; its resume - /// hint owns the floor) the whole pin fails. + /// The live-sighted working layout (codex-cli 0.144.6, 2026-07-20): + /// a hint row below the composer, no token bar painted. The composer + /// pin tolerates the rows below it, the anchor fires, and the absent + /// bar means no model label — `Working` with no prefix is correct. #[test] - fn codex_requires_the_status_bar_pin() { + fn codex_hint_row_layout_anchors_without_a_token_bar() { + let hinted = rs(&[ + "• Running cargo test --test daemon_env", + "", + "", + "• Working (10m 26s • esc to interrupt)", + "", + "›", + "", + " tab to queue message", + ]); + assert_eq!( + CodexSummary.live_preview(&hinted), + Some(("Working".to_string(), "codex:working")) + ); + assert_eq!(CodexSummary.model_label(&hinted), None); + } + + /// The approval modal replaces composer and token bar with a numbered + /// menu; the selector row plus a numbered sibling synthesizes the + /// label, wherever the selection sits. + #[test] + fn codex_approval_modal_synthesizes_on_any_selection() { + let on_first = rs(&[ + " Would you like to run the following command?", + "", + " $ cargo test --test daemon_env", + "", + "› 1. Yes, proceed (y)", + " 2. Yes, and don't ask again for commands that start with `cargo test` (p)", + " 3. No, and tell Codex what to do differently (esc)", + "", + " Press enter to confirm or esc to cancel", + ]); + assert_eq!( + CodexSummary.live_preview(&on_first), + Some(("awaiting approval".to_string(), "codex:approval-menu")) + ); + + let on_second = rs(&[ + " 1. Yes, proceed (y)", + "› 2. Yes, and don't ask again (p)", + " 3. No (esc)", + "", + " Press enter to confirm or esc to cancel", + ]); + assert_eq!( + CodexSummary.live_preview(&on_second), + Some(("awaiting approval".to_string(), "codex:approval-menu")) + ); + } + + /// A menu quoted in the conversation always has the live composer + /// somewhere below it; the composer's presence suppresses the modal + /// match, and the quote is a foreign row to the status scan — no + /// anchor, floor tier. + #[test] + fn codex_quoted_menu_with_a_live_composer_is_not_a_modal() { + let quoted = rs(&[ + "• I found these options in the doc:", + "", + "› 1. Yes, proceed (y)", + " 2. No, cancel (esc)", + "", + "›", + "", + " gpt-5.6-sol high · 0 in · 0 out", + ]); + assert_eq!(CodexSummary.live_preview("ed), None); + } + + /// Without any composer row (codex exited; its resume hint owns the + /// floor) the whole pin fails. + #[test] + fn codex_requires_the_composer_pin() { let exited = rs(&[ "• Ran sleep 5 && echo ok", "", @@ -824,6 +945,21 @@ mod tests { "gpt-5.6-sol high · Ran sleep 5 && echo ok", "codex:ran", ), + Case( + "preview_codex_hint_row", + include_bytes!("../../tests/corpus/preview_codex_hint_row.bin"), + &CodexSummary, + // No token bar in this layout: no model prefix, correctly. + "Working", + "codex:working", + ), + Case( + "preview_codex_approval", + include_bytes!("../../tests/corpus/preview_codex_approval.bin"), + &CodexSummary, + "awaiting approval", + "codex:approval-menu", + ), Case( "preview_grok_working", include_bytes!("../../tests/corpus/preview_grok_working.bin"), @@ -926,6 +1062,24 @@ mod tests { ) ); + // A modal-shaped menu quoted in the body with the live composer + // below it: the composer suppresses the approval match, the quote + // is foreign to the status scan, and the floor tier reports. + let p = resolve_corpus( + include_bytes!("../../tests/corpus/preview_codex_body_menu.bin"), + &CodexSummary, + 40, + 120, + ); + assert_eq!( + parts(&p), + ( + "gpt-5.6-sol high · 0 in · 0 out".to_string(), + PreviewSource::Floor, + None + ) + ); + // `• Ran` visible mid-turn with `• Working` at the pin: live wins. let p = resolve_corpus( include_bytes!("../../tests/corpus/preview_codex_working_over_ran.bin"), diff --git a/tests/corpus/README.md b/tests/corpus/README.md index 73e8dde..58e6fef 100644 --- a/tests/corpus/README.md +++ b/tests/corpus/README.md @@ -38,6 +38,12 @@ screens came from claude 2.1.215, codex-cli 0.144.6, and grok 0.2.102. Identifying and user-configured text is replaced with alignment-preserving synthetic values. Geometry is 40×120 unless noted. +The `preview_codex_hint_row` and `preview_codex_approval` fixtures are +paste-derived from a maintainer live sighting (2026-07-20, codex-cli 0.144.6 +— the same version as the snapshot captures; these are uncaptured states, +not drift): indentation is approximate, so their tests key on trimmed heads +and column-0 discipline only. `preview_codex_body_menu` is synthetic. + | Fixture | Scenario | Coverage | | --- | --- | --- | | `preview_claude_working.bin` | claude spinner with the tmux focus-events hint row | `claude:spinner` extraction through an indented hint row | @@ -52,6 +58,9 @@ synthetic values. Geometry is 40×120 unless noted. | `preview_codex_working_over_ran.bin` | codex working with a `• Ran` row higher in the same turn | `codex:working` wins at the pin; the stale row never surfaces | | `preview_codex_scrollback.bin` | codex finished turn, `• Ran` from the prior turn in scrollback | the scan stops at the reply bullet and resolves to the floor tier | | `preview_codex_ran.bin` | codex transient completion (synthetic: no raw capture holds it) | `codex:ran` extraction through the `└` attachment row | +| `preview_codex_hint_row.bin` | codex working with `tab to queue message` below the composer, no token bar | `codex:working` through the composer pin; no model prefix without the bar | +| `preview_codex_approval.bin` | codex approval modal: composer and token bar replaced by a numbered menu | `codex:approval-menu` synthesizes `awaiting approval` | +| `preview_codex_body_menu.bin` | modal-shaped menu quoted in the body, live composer below | negative: the composer's presence suppresses the modal match; floor tier reports | | `preview_grok_working.bin` | grok braille spinner with elapsed/throughput ticker | `grok:spinner` cut at the label's `…`; border label read | | `preview_grok_worked.bin` | grok `Worked for 8.7s` completion row above the box | `grok:worked` kept verbatim | | `preview_grok_idle.bin` | grok idle session | fall-through to the marker | diff --git a/tests/corpus/preview_codex_approval.bin b/tests/corpus/preview_codex_approval.bin new file mode 100644 index 0000000..e621af2 --- /dev/null +++ b/tests/corpus/preview_codex_approval.bin @@ -0,0 +1,15 @@ +• Running cargo test --test daemon_env + + Would you like to run the following command? + + Environment: local + + Reason: Allow this integration test to run outside the sandbox so its daemon can bind a Unix socket? + + $ cargo test --test daemon_env + +› 1. Yes, proceed (y) + 2. Yes, and don't ask again for commands that start with `cargo test` (p) + 3. No, and tell Codex what to do differently (esc) + + Press enter to confirm or esc to cancel \ No newline at end of file diff --git a/tests/corpus/preview_codex_body_menu.bin b/tests/corpus/preview_codex_body_menu.bin new file mode 100644 index 0000000..d3b243d --- /dev/null +++ b/tests/corpus/preview_codex_body_menu.bin @@ -0,0 +1,8 @@ +• I found these options in the doc: + +› 1. Yes, proceed (y) + 2. No, cancel (esc) + +› + + gpt-5.6-sol high · 0 in · 0 out \ No newline at end of file diff --git a/tests/corpus/preview_codex_hint_row.bin b/tests/corpus/preview_codex_hint_row.bin new file mode 100644 index 0000000..e3d7f95 --- /dev/null +++ b/tests/corpus/preview_codex_hint_row.bin @@ -0,0 +1,14 @@ +✔ You approved codex to run cargo test --test daemon_env + +• Ran cargo test --test daemon_env + └ test spawn_runs_the_command ... ok + test result: ok. 1 passed; 0 failed + +• Running cargo test --test daemon_env + + +• Working (10m 26s • esc to interrupt) + +› + + tab to queue message \ No newline at end of file From 0f0664cdd0cdc29a1dbbb61e204b744c30995649 Mon Sep 17 00:00:00 2001 From: Christopher Sardegna Date: Mon, 20 Jul 2026 12:38:02 -0700 Subject: [PATCH 09/11] feat: keep the claude parenthetical's slow segments in the preview --- src/harness/summary.rs | 136 +++++++++++++++++++++++++++++++++++++++-- 1 file changed, 131 insertions(+), 5 deletions(-) diff --git a/src/harness/summary.rs b/src/harness/summary.rs index 1cbfcd2..df7bce3 100644 --- a/src/harness/summary.rs +++ b/src/harness/summary.rs @@ -129,19 +129,24 @@ fn claude_spinner_status(rows: &[String], top: usize) -> Option<(String, &'stati continue; } let verb = claude_spinner_text(row)?; + // The spinner row's parenthetical contributes its slow semantic + // tail to whichever text wins the head. + let tail = claude_semantic_tail(row); // The spinner confirms the working state; only then is the // concrete-action row worth preferring over the rotating verb. if let Some(action) = claude_action_row(rows, i) { - return Some((action, "claude:action-row")); + return Some((format!("{action}{tail}"), "claude:action-row")); } - return Some((verb, "claude:spinner")); + return Some((format!("{verb}{tail}"), "claude:spinner")); } None } /// Extract the text through the first `…` after a claude spinner frame. -/// Task-derived phrases may contain spaces, parentheses, and digits. Text -/// after the ellipsis, including counters and key hints, is discarded. +/// Task-derived phrases may contain spaces, parentheses, and digits. The +/// parenthetical after the ellipsis is not discarded wholesale: its +/// recognized tickers drop and its slow segments survive through +/// [`claude_semantic_tail`]. fn claude_spinner_text(row: &str) -> Option { let mut chars = row.chars(); if !CLAUDE_SPINNER.contains(&chars.next()?) || chars.next()? != ' ' { @@ -155,6 +160,57 @@ fn claude_spinner_text(row: &str) -> Option { .then(|| text.to_string()) } +/// The spinner parenthetical's slow semantic tail: +/// `(1m 8s · ↓ 2.1k tokens · thinking with high effort)` keeps +/// ` · thinking with high effort`. Segments the classifier positively +/// recognizes as tickers drop; everything else is semantic until proven +/// otherwise and survives verbatim, in order, as ` · {seg}` each. The +/// survivors change only at state transitions, so the no-hold anchor +/// policy is unaffected — which is exactly why tickers must drop rather +/// than ride along. No parenthetical yields an empty tail; an unclosed +/// one is CLI-side truncation and parses to the cut. +fn claude_semantic_tail(row: &str) -> String { + let Some(open) = row.find("… (") else { + return String::new(); + }; + let inner = &row[open + "… (".len()..]; + let inner = inner.strip_suffix(')').unwrap_or(inner); + let mut out = String::new(); + for seg in inner.split(" · ") { + let seg = seg.trim(); + if seg.is_empty() || claude_ticker_segment(seg) { + continue; + } + out.push_str(" · "); + out.push_str(seg); + } + out +} + +/// Whether one parenthetical segment is recognized ticker churn: elapsed +/// time (every whitespace token digits — dot tolerated — plus an `s`/`m`/`h` +/// unit: `6s`, `1m 8s`, `2h 3m`), token/throughput counters (`↓`/`↑`-headed +/// or `tokens`-suffixed), or the `esc to interrupt` affordance. +fn claude_ticker_segment(seg: &str) -> bool { + if seg == "esc to interrupt" + || seg == "tokens" + || seg.starts_with('↓') + || seg.starts_with('↑') + || seg.ends_with(" tokens") + { + return true; + } + !seg.is_empty() + && seg.split_whitespace().all(|tok| { + let Some((num, unit)) = tok.split_at_checked(tok.len() - 1) else { + return false; + }; + matches!(unit, "s" | "m" | "h") + && num.starts_with(|c: char| c.is_ascii_digit()) + && num.chars().all(|c| c.is_ascii_digit() || c == '.') + }) +} + /// The concrete-action row above a confirmed spinner: skip the blank gap, /// probe exactly one row. `⏺ Running 1 shell command…` names real work while /// the spinner phrase rotates per request, so it wins when both are @@ -625,10 +681,80 @@ mod tests { assert_eq!( ClaudeSummary.live_preview(&s), Some(( - "Overseeing phase 4 (adapters)…".to_string(), + "Overseeing phase 4 (adapters)… · almost done thinking with high effort" + .to_string(), + "claude:spinner" + )) + ); + } + + /// Parenthetical segments: every recognized ticker shape drops — alone + /// and beside a kept segment — and an unknown segment survives + /// verbatim. The sighted row keeps its effort note; a bare row is + /// unchanged. + #[test] + fn claude_parenthetical_keeps_slow_segments_and_drops_tickers() { + let sep = "─".repeat(120); + let spin = |row: &str| { + let rows = [row, &sep, "❯", &sep]; + ClaudeSummary.live_preview(&rs(&rows)) + }; + assert_eq!( + spin("✻ Envisioning… (1m 8s · ↓ 2.1k tokens · thinking with high effort)"), + Some(( + "Envisioning… · thinking with high effort".to_string(), "claude:spinner" )) ); + for ticker in [ + "6s", + "1m 8s", + "2h 3m", + "8.7s", + "↓ 87 tokens", + "↑ 1.2k tokens", + "↓ 2.1k", + "2.1k tokens", + "esc to interrupt", + ] { + assert_eq!( + spin(&format!("✻ Hashing… ({ticker})")), + Some(("Hashing…".to_string(), "claude:spinner")), + "{ticker:?} must drop" + ); + assert_eq!( + spin(&format!("✻ Hashing… ({ticker} · thinking)")), + Some(("Hashing… · thinking".to_string(), "claude:spinner")), + "{ticker:?} must drop beside a kept segment" + ); + } + assert_eq!( + spin("✽ Concocting…"), + Some(("Concocting…".to_string(), "claude:spinner")), + "a row with no parenthetical is unchanged" + ); + } + + /// The action row wins the head while the spinner row's parenthetical + /// still contributes the semantic tail. + #[test] + fn claude_action_row_carries_the_spinner_rows_semantic_tail() { + let sep = "─".repeat(120); + let rows = [ + "⏺ Running 1 shell command…", + "", + "✻ Envisioning… (1m 8s · ↓ 2.1k tokens · thinking with high effort)", + &sep, + "❯", + &sep, + ]; + assert_eq!( + ClaudeSummary.live_preview(&rs(&rows)), + Some(( + "Running 1 shell command… · thinking with high effort".to_string(), + "claude:action-row" + )) + ); } /// A column-0 row in the chrome window that is not spinner-shaped aborts: From bee57f4f03fc0908d1ba129bdcf68207b6d72665 Mon Sep 17 00:00:00 2001 From: Christopher Sardegna Date: Mon, 20 Jul 2026 12:56:22 -0700 Subject: [PATCH 10/11] feat: extract claude's waiting-for-agents state and steady its titles --- src/harness/summary.rs | 197 ++++++++++++++++++++++-- src/preview.rs | 9 +- tests/corpus/README.md | 6 +- tests/corpus/preview_claude_waiting.bin | 17 ++ 4 files changed, 218 insertions(+), 11 deletions(-) create mode 100644 tests/corpus/preview_claude_waiting.bin diff --git a/src/harness/summary.rs b/src/harness/summary.rs index df7bce3..bf39090 100644 --- a/src/harness/summary.rs +++ b/src/harness/summary.rs @@ -44,6 +44,14 @@ pub trait SummaryAdapter: Sync { fn exit_preview(&self, _retained_text: &str) -> Option { None } + + /// Display-time rewrite for the Title tier's text. Per-CLI title + /// knowledge lives here, in tier 1: the capture layer (the emulator's + /// title events) stays program-agnostic and closed to per-program + /// mappings. `None` renders the captured title verbatim. + fn normalize_title(&self, _title: &str) -> Option { + None + } } /// Select an adapter by the basename of the command's first @@ -98,6 +106,25 @@ impl SummaryAdapter for ClaudeSummary { fn model_label(&self, screen: &dyn ScreenFacts) -> Option { claude_welcome_label(&screen.live_rows()) } + + /// claude's titles lead with an animated frame. Two observed shapes: + /// the launch title `✳ Claude Code` (asterisk-bloom frame, captured + /// 2026-07-19) and the in-session `{braille} {session summary}` + /// (braille frame, animated per frame — `⠐ Review fleetcom preview + /// design document`, sighted 2026-07-20). A frozen interim frame reads + /// as stuck, and canonicalizing to `✻` dedupes the animation BEFORE + /// the min-hold: the rendered text is constant and never re-renders. + /// The braille test is a range check over U+2800..=U+28FF, not a frame + /// list — codex's captured title churn already showed the braille + /// vocabulary is large. Non-frame-led titles pass through verbatim. + /// grok's title is static (`grok`) and codex never reaches the Title + /// tier, so neither maps. + fn normalize_title(&self, title: &str) -> Option { + let mut chars = title.chars(); + let frame = chars.next()?; + let framed = CLAUDE_SPINNER.contains(&frame) || ('\u{2800}'..='\u{28FF}').contains(&frame); + (framed && chars.next()? == ' ').then(|| format!("✻ {}", chars.as_str())) + } } /// Index of the input box's top separator. The bottom-most full-width rule @@ -128,20 +155,51 @@ fn claude_spinner_status(rows: &[String], top: usize) -> Option<(String, &'stati if row.is_empty() || row.starts_with(' ') { continue; } - let verb = claude_spinner_text(row)?; - // The spinner row's parenthetical contributes its slow semantic - // tail to whichever text wins the head. - let tail = claude_semantic_tail(row); - // The spinner confirms the working state; only then is the - // concrete-action row worth preferring over the rotating verb. - if let Some(action) = claude_action_row(rows, i) { - return Some((format!("{action}{tail}"), "claude:action-row")); + if let Some(verb) = claude_spinner_text(row) { + // The spinner row's parenthetical contributes its slow + // semantic tail to whichever text wins the head. + let tail = claude_semantic_tail(row); + // The spinner confirms the working state; only then is the + // concrete-action row worth preferring over the rotating verb. + if let Some(action) = claude_action_row(rows, i) { + return Some((format!("{action}{tail}"), "claude:action-row")); + } + return Some((format!("{verb}{tail}"), "claude:spinner")); } - return Some((format!("{verb}{tail}"), "claude:spinner")); + // The waiting row is self-describing: no action-row probe — the + // col-0 `⏺` rows above it are body prose, and probing them would + // widen the false-positive surface for nothing. + if let Some(waiting) = claude_waiting_text(row) { + return Some((waiting, "claude:waiting-agents")); + } + // Foreign column-0 row: abort (see above). + return None; } None } +/// The ellipsis-less waiting row: `✻ Waiting for {n} background agent(s) +/// to finish`, returned verbatim after the glyph. An explicit pattern, not +/// a loosened spinner rule: the `…` guard on the spinner extraction cannot +/// relax without re-opening the `·`-as-body-bullet false positive, so +/// ellipsis-less states are admitted one sighted shape at a time — the +/// designed maintenance model. No semantic-tail extraction: the sighted +/// row carries no parenthetical (extend only on a future sighting). Live +/// sighting 2026-07-20, claude 2.1.215. +fn claude_waiting_text(row: &str) -> Option { + let mut chars = row.chars(); + if !CLAUDE_SPINNER.contains(&chars.next()?) || chars.next()? != ' ' { + return None; + } + let text = chars.as_str(); + let n = text.strip_prefix("Waiting for ")?; + let digits = n.chars().take_while(char::is_ascii_digit).count(); + let tail = &n[digits..]; + (digits >= 1 + && (tail == " background agent to finish" || tail == " background agents to finish")) + .then(|| text.to_string()) +} + /// Extract the text through the first `…` after a claude spinner frame. /// Task-derived phrases may contain spaces, parentheses, and digits. The /// parenthetical after the ellipsis is not discarded wholesale: its @@ -757,6 +815,118 @@ mod tests { ); } + /// The ellipsis-less waiting row (live sighting 2026-07-20, claude + /// 2.1.215): exact shape extracts verbatim on any spinner frame, + /// singular or plural; near-miss shapes stay foreign and abort. + #[test] + fn claude_waiting_row_matches_exactly_and_never_probes() { + let sep = "─".repeat(120); + let spin = |row: &str| { + let rows = [row, &sep, "❯", &sep]; + ClaudeSummary.live_preview(&rs(&rows)) + }; + for row in [ + "✻ Waiting for 1 background agent to finish", + "· Waiting for 1 background agent to finish", + ] { + assert_eq!( + spin(row), + Some(( + "Waiting for 1 background agent to finish".to_string(), + "claude:waiting-agents" + )), + "{row:?}" + ); + } + assert_eq!( + spin("✻ Waiting for 3 background agents to finish"), + Some(( + "Waiting for 3 background agents to finish".to_string(), + "claude:waiting-agents" + )) + ); + + // Wrong shapes abort to fall-through, glyph or not: the pattern + // carries the specificity, not the frame. + assert_eq!(spin("✻ Waiting patiently"), None); + assert_eq!(spin("· Waiting for review comments to land"), None); + + // Self-describing: an action row above the waiting row is body + // prose to this state and must not win the head. + let rows = [ + "⏺ Running 1 shell command…", + "", + "✻ Waiting for 1 background agent to finish", + &sep, + "❯", + &sep, + ]; + assert_eq!( + ClaudeSummary.live_preview(&rs(&rows)), + Some(( + "Waiting for 1 background agent to finish".to_string(), + "claude:waiting-agents" + )) + ); + } + + /// Title display: every spinner frame canonicalizes to `✻`, giving + /// constant text across frame rotation (the churn fix); non-frame + /// titles pass through untouched. + #[test] + fn claude_title_frames_canonicalize_to_constant_text() { + for frame in CLAUDE_SPINNER { + assert_eq!( + ClaudeSummary.normalize_title(&format!("{frame} Claude Code")), + Some("✻ Claude Code".to_string()), + "{frame:?}" + ); + } + let a = ClaudeSummary.normalize_title("✢ Claude Code"); + let b = ClaudeSummary.normalize_title("✽ Claude Code"); + assert_eq!(a, b, "two frames must normalize identically"); + + // The in-session shape: a braille frame plus the session summary, + // animated per frame (sighted 2026-07-20). + assert_eq!( + ClaudeSummary.normalize_title("⠐ Review fleetcom preview design document"), + Some("✻ Review fleetcom preview design document".to_string()) + ); + assert_eq!( + ClaudeSummary.normalize_title("⠴ Review fleetcom preview design document"), + Some("✻ Review fleetcom preview design document".to_string()), + "mid-block braille frame" + ); + + assert_eq!(ClaudeSummary.normalize_title("zellij: main"), None); + assert_eq!(ClaudeSummary.normalize_title("✻"), None, "frame alone"); + } + + /// Cascade-level: with the claude adapter installed and no anchor on + /// the screen, a frame-led title renders canonicalized under the Title + /// tier; without an adapter it renders verbatim. + #[test] + fn title_tier_renders_the_normalized_title() { + let mut emu = Emulator::new(24, 80, 100); + emu.process(b"\x1b[?1049h\x1b]0;\xe2\x9c\xa2 Claude Code\x07conversation body"); + let mut st = PreviewState::new(); + let p = st + .resolve(Instant::now(), false, &emu, Some(&ClaudeSummary)) + .clone(); + assert_eq!( + (p.text.as_str(), p.source, p.rule), + ("✻ Claude Code", PreviewSource::Title, None) + ); + + let mut st = PreviewState::new(); + let p = st.resolve(Instant::now(), false, &emu, None).clone(); + assert_eq!( + (p.text.as_str(), p.source), + ("✢ Claude Code", PreviewSource::Title), + "no adapter: verbatim" + ); + } + /// A column-0 row in the chrome window that is not spinner-shaped aborts: /// a wrapped status tail and body text touching the chrome both refuse. #[test] @@ -1071,6 +1241,15 @@ mod tests { "gpt-5.6-sol high · Ran sleep 5 && echo ok", "codex:ran", ), + Case( + "preview_claude_waiting", + include_bytes!("../../tests/corpus/preview_claude_waiting.bin"), + &ClaudeSummary, + // The sighted screen is mid-session: the welcome box has + // scrolled off, so no model label prefixes the text. + "Waiting for 1 background agent to finish", + "claude:waiting-agents", + ), Case( "preview_codex_hint_row", include_bytes!("../../tests/corpus/preview_codex_hint_row.bin"), diff --git a/src/preview.rs b/src/preview.rs index ed47aab..f4b2d3c 100644 --- a/src/preview.rs +++ b/src/preview.rs @@ -138,8 +138,15 @@ fn cascade(screen: &impl ScreenFacts, adapter: Option<&dyn SummaryAdapter>) -> P } if screen.alternate_screen() { return match screen.title() { + // The adapter may rewrite the title for display (claude's + // rotating spinner-frame prefix canonicalizes so the text + // stays constant); capture itself remains program-agnostic. + // Source stays Title and rule stays None: rules are anchor + // matcher ids, and a rewritten title is still a title. Some(text) => Preview { - text: text.to_string(), + text: adapter + .and_then(|a| a.normalize_title(text)) + .unwrap_or_else(|| text.to_string()), source: PreviewSource::Title, rule: None, frozen: false, diff --git a/tests/corpus/README.md b/tests/corpus/README.md index 58e6fef..f36f7cf 100644 --- a/tests/corpus/README.md +++ b/tests/corpus/README.md @@ -42,7 +42,10 @@ The `preview_codex_hint_row` and `preview_codex_approval` fixtures are paste-derived from a maintainer live sighting (2026-07-20, codex-cli 0.144.6 — the same version as the snapshot captures; these are uncaptured states, not drift): indentation is approximate, so their tests key on trimmed heads -and column-0 discipline only. `preview_codex_body_menu` is synthetic. +and column-0 discipline only. `preview_codex_body_menu` is synthetic. `preview_claude_waiting` is +paste-derived from a maintainer live sighting (2026-07-20, claude 2.1.215): +the ellipsis-less waiting-for-agents spinner state, mid-session (welcome box +scrolled off), with agent-roster rows below the input box. | Fixture | Scenario | Coverage | | --- | --- | --- | @@ -61,6 +64,7 @@ and column-0 discipline only. `preview_codex_body_menu` is synthetic. | `preview_codex_hint_row.bin` | codex working with `tab to queue message` below the composer, no token bar | `codex:working` through the composer pin; no model prefix without the bar | | `preview_codex_approval.bin` | codex approval modal: composer and token bar replaced by a numbered menu | `codex:approval-menu` synthesizes `awaiting approval` | | `preview_codex_body_menu.bin` | modal-shaped menu quoted in the body, live composer below | negative: the composer's presence suppresses the modal match; floor tier reports | +| `preview_claude_waiting.bin` | claude waiting on a backgrounded subagent, `⏺` prose and agent roster around the box | `claude:waiting-agents` extracts the ellipsis-less row verbatim; no model label mid-session | | `preview_grok_working.bin` | grok braille spinner with elapsed/throughput ticker | `grok:spinner` cut at the label's `…`; border label read | | `preview_grok_worked.bin` | grok `Worked for 8.7s` completion row above the box | `grok:worked` kept verbatim | | `preview_grok_idle.bin` | grok idle session | fall-through to the marker | diff --git a/tests/corpus/preview_claude_waiting.bin b/tests/corpus/preview_claude_waiting.bin new file mode 100644 index 0000000..c3995d8 --- /dev/null +++ b/tests/corpus/preview_claude_waiting.bin @@ -0,0 +1,17 @@ +[?1049h⏺ I'll spawn an Explore agent to map the text-input prompt editing surface before drafting the phase specs. + +⏺ Explore(Map text-input prompt editing) + ⎿ Running in background (↓ 1.2k tokens) + ⎿ Tell the agent what to explore + +⏺ The specs need the editing map before the loop kicks off; waiting on the background agent's report. + +✻ Waiting for 1 background agent to finish +──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── +❯ draft the phase specs and kick off the loop +──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── + (statusline scrubbed: user-configured row) + ⏵⏵ auto mode on (shift+tab to cycle) + +⏺ main +◯ Explore Map text-input prompt editing \ No newline at end of file From 2cb105fcc884f860c48ae8d3ea1a2b1549a83663 Mon Sep 17 00:00:00 2001 From: Christopher Sardegna Date: Mon, 20 Jul 2026 13:23:27 -0700 Subject: [PATCH 11/11] refactor: streamline comments and improve clarity across multiple modules --- src/golden.rs | 6 +- src/harness/summary.rs | 140 ++++++++++++++------------------------- src/main.rs | 1 - src/task.rs | 9 +-- src/terminal/emulator.rs | 5 +- tests/corpus/README.md | 20 ++---- 6 files changed, 65 insertions(+), 116 deletions(-) diff --git a/src/golden.rs b/src/golden.rs index 72ee8ea..7ad79ca 100644 --- a/src/golden.rs +++ b/src/golden.rs @@ -577,10 +577,8 @@ fn semantic_dec_scrollregion_charset_translation() { assert_eq!(al.grid().cursor.point, Point::new(Line(39), Column(0))); } -/// Verify that `ObservedTerm` forwards parser events by comparing each corpus -/// replay through `Emulator` with a replay through the raw backend. The -/// comparison covers the screen, cursor, and alternate-screen mode; the other -/// golden tests exercise only the raw backend. +/// Compare `ObservedTerm` and the raw backend across the corpus: screen, +/// cursor, and alternate-screen mode must match. #[test] fn emulator_wrapper_matches_the_raw_backend_on_every_fixture() { let fixtures: [(&str, &[u8]); 12] = [ diff --git a/src/harness/summary.rs b/src/harness/summary.rs index bf39090..f42703f 100644 --- a/src/harness/summary.rs +++ b/src/harness/summary.rs @@ -45,10 +45,8 @@ pub trait SummaryAdapter: Sync { None } - /// Display-time rewrite for the Title tier's text. Per-CLI title - /// knowledge lives here, in tier 1: the capture layer (the emulator's - /// title events) stays program-agnostic and closed to per-program - /// mappings. `None` renders the captured title verbatim. + /// Optionally normalize a captured title for display. Emulator title + /// capture remains program-agnostic; `None` renders the title verbatim. fn normalize_title(&self, _title: &str) -> Option { None } @@ -90,7 +88,7 @@ const CLAUDE_SPINNER: &[char] = &['·', '✢', '✳', '✶', '✻', '✽']; /// claude (alt screen). Working state: a column-0 spinner row directly above /// the input box's top separator. Approval state: the dialog replaces the -/// input box entirely, which is what licenses the menu match. +/// input box entirely; the menu match fires only when that box is gone. pub struct ClaudeSummary; impl SummaryAdapter for ClaudeSummary { @@ -107,18 +105,9 @@ impl SummaryAdapter for ClaudeSummary { claude_welcome_label(&screen.live_rows()) } - /// claude's titles lead with an animated frame. Two observed shapes: - /// the launch title `✳ Claude Code` (asterisk-bloom frame, captured - /// 2026-07-19) and the in-session `{braille} {session summary}` - /// (braille frame, animated per frame — `⠐ Review fleetcom preview - /// design document`, sighted 2026-07-20). A frozen interim frame reads - /// as stuck, and canonicalizing to `✻` dedupes the animation BEFORE - /// the min-hold: the rendered text is constant and never re-renders. - /// The braille test is a range check over U+2800..=U+28FF, not a frame - /// list — codex's captured title churn already showed the braille - /// vocabulary is large. Non-frame-led titles pass through verbatim. - /// grok's title is static (`grok`) and codex never reaches the Title - /// tier, so neither maps. + /// Canonicalize a leading claude spinner or braille frame to `✻` so title + /// animation does not change the rendered text. Other titles pass through + /// unchanged. fn normalize_title(&self, title: &str) -> Option { let mut chars = title.chars(); let frame = chars.next()?; @@ -147,8 +136,8 @@ fn claude_box_top(rows: &[String]) -> Option { /// (the tmux focus-events notice, the right-aligned `● high · /effort`) are /// indented while the spinner paints at column 0; a column-0 row that is not /// spinner-shaped aborts the scan: body text reaching the chrome, or the -/// wrapped tail of a status row too wide for the window. Both must fail -/// structurally rather than risk matching something status-shaped. +/// wrapped tail of a status row too wide for the window. Both fail the +/// structural check instead of matching status-shaped body text. fn claude_spinner_status(rows: &[String], top: usize) -> Option<(String, &'static str)> { for i in (top.saturating_sub(3)..top).rev() { let row = &rows[i]; @@ -159,16 +148,15 @@ fn claude_spinner_status(rows: &[String], top: usize) -> Option<(String, &'stati // The spinner row's parenthetical contributes its slow // semantic tail to whichever text wins the head. let tail = claude_semantic_tail(row); - // The spinner confirms the working state; only then is the - // concrete-action row worth preferring over the rotating verb. + // The spinner confirms the working state; only then prefer the + // concrete-action row over the rotating verb. if let Some(action) = claude_action_row(rows, i) { return Some((format!("{action}{tail}"), "claude:action-row")); } return Some((format!("{verb}{tail}"), "claude:spinner")); } - // The waiting row is self-describing: no action-row probe — the - // col-0 `⏺` rows above it are body prose, and probing them would - // widen the false-positive surface for nothing. + // Waiting rows need no action-row probe: col-0 `⏺` rows above are + // body prose, and probing them only widens false positives. if let Some(waiting) = claude_waiting_text(row) { return Some((waiting, "claude:waiting-agents")); } @@ -178,14 +166,9 @@ fn claude_spinner_status(rows: &[String], top: usize) -> Option<(String, &'stati None } -/// The ellipsis-less waiting row: `✻ Waiting for {n} background agent(s) -/// to finish`, returned verbatim after the glyph. An explicit pattern, not -/// a loosened spinner rule: the `…` guard on the spinner extraction cannot -/// relax without re-opening the `·`-as-body-bullet false positive, so -/// ellipsis-less states are admitted one sighted shape at a time — the -/// designed maintenance model. No semantic-tail extraction: the sighted -/// row carries no parenthetical (extend only on a future sighting). Live -/// sighting 2026-07-20, claude 2.1.215. +/// Match `Waiting for {n} background agent(s) to finish` after a recognized +/// spinner frame. Keeping this separate from ellipsis-terminated spinner rows +/// prevents `·` body bullets from matching. fn claude_waiting_text(row: &str) -> Option { let mut chars = row.chars(); if !CLAUDE_SPINNER.contains(&chars.next()?) || chars.next()? != ' ' { @@ -220,13 +203,9 @@ fn claude_spinner_text(row: &str) -> Option { /// The spinner parenthetical's slow semantic tail: /// `(1m 8s · ↓ 2.1k tokens · thinking with high effort)` keeps -/// ` · thinking with high effort`. Segments the classifier positively -/// recognizes as tickers drop; everything else is semantic until proven -/// otherwise and survives verbatim, in order, as ` · {seg}` each. The -/// survivors change only at state transitions, so the no-hold anchor -/// policy is unaffected — which is exactly why tickers must drop rather -/// than ride along. No parenthetical yields an empty tail; an unclosed -/// one is CLI-side truncation and parses to the cut. +/// ` · thinking with high effort`. Recognized ticker segments drop; +/// everything else is kept in order as ` · {seg}`. No parenthetical yields +/// an empty tail; an unclosed one is parsed to the cut. fn claude_semantic_tail(row: &str) -> String { let Some(open) = row.find("… (") else { return String::new(); @@ -246,9 +225,9 @@ fn claude_semantic_tail(row: &str) -> String { } /// Whether one parenthetical segment is recognized ticker churn: elapsed -/// time (every whitespace token digits — dot tolerated — plus an `s`/`m`/`h` -/// unit: `6s`, `1m 8s`, `2h 3m`), token/throughput counters (`↓`/`↑`-headed -/// or `tokens`-suffixed), or the `esc to interrupt` affordance. +/// time (each whitespace token is digits, optional dot, then `s`/`m`/`h`: +/// `6s`, `1m 8s`, `2h 3m`), token/throughput counters (`↓`/`↑`-headed or +/// `tokens`-suffixed), or the `esc to interrupt` affordance. fn claude_ticker_segment(seg: &str) -> bool { if seg == "esc to interrupt" || seg == "tokens" @@ -295,10 +274,8 @@ fn claude_approval(rows: &[String]) -> Option<(String, &'static str)> { } /// `Fable 5 with high effort` from the welcome box → `Fable 5 (high)`. The -/// box is the stable source: the statusline rows below the input box render -/// user-configured text (different on every machine) and must never be -/// read. The box scrolls away as the conversation grows and the label -/// simply drops off. +/// welcome box is the stable source; user-configurable statusline rows are not +/// parsed. When the box scrolls away, the label is unavailable. fn claude_welcome_label(rows: &[String]) -> Option { let start = rows .iter() @@ -330,9 +307,7 @@ fn claude_welcome_label(rows: &[String]) -> Option { /// bottom-most column-0 `›` row that is not a modal selector; status rows /// sit above it, and scrollback beyond the first foreign row is out of /// bounds. The approval modal removes the composer and is checked first. -/// Both observed layout generations anchor: token bar as the bottom row, -/// or hint rows below the composer with no token bar painted (codex-cli -/// 0.144.6, sighted 2026-07-20). +/// A token bar or indented hint rows may appear below the composer. pub struct CodexSummary; impl SummaryAdapter for CodexSummary { @@ -353,8 +328,8 @@ impl SummaryAdapter for CodexSummary { } } -/// `› 1. Yes, proceed (y)`: the modal's selected option row — column-0 `›`, -/// one digit, `. `. +/// `› 1. Yes, proceed (y)`: the modal's selected option row (column-0 `›`, +/// one digit, `. `). fn codex_menu_head(row: &str) -> bool { row.strip_prefix("› ") .and_then(|r| r.strip_prefix(|c: char| c.is_ascii_digit())) @@ -369,12 +344,10 @@ fn codex_numbered_option(row: &str) -> bool { } /// codex's approval modal: a selector row with an indented numbered sibling -/// below it, pinned to the last nine painted rows (the modal has no -/// composer to pin on — it removes the composer and token bar outright, -/// and that removal is the disambiguator: a menu quoted in the -/// conversation always has the live composer somewhere below it, so any -/// non-selector `›` row below the selector suppresses the match). Second -/// member of the approval-menu synthesis class (module docs). +/// below it, pinned to the last nine painted rows. The modal removes the +/// composer and token bar; that absence is the disambiguator (a menu quoted +/// in the conversation always has the live composer below it, so any +/// non-selector `›` row under the selector suppresses the match). fn codex_approval(rows: &[String]) -> Option<(String, &'static str)> { let last = rows.iter().rposition(|r| !r.is_empty())?; let i = (last.saturating_sub(8)..=last).find(|&i| codex_menu_head(&rows[i]))?; @@ -390,9 +363,8 @@ fn codex_approval(rows: &[String]) -> Option<(String, &'static str)> { /// The token/status bar, when painted: the bottom-most /// `{model} · {…} in · {…} out` row among the last six painted rows. -/// Deliberately decoupled from the composer pin — the live-sighted working -/// layout omits the bar entirely, and the anchor then fires without a -/// model prefix. +/// Independent of the composer pin because the bar may be absent; without it, +/// the anchor has no model prefix. fn codex_token_line(rows: &[String]) -> Option { let last = rows.iter().rposition(|r| !r.is_empty())?; (last.saturating_sub(5)..=last).rev().find(|&i| { @@ -405,11 +377,11 @@ fn codex_token_line(rows: &[String]) -> Option { } /// The composer: the bottom-most column-0 `›` row that is not a modal -/// selector. Rows below it are tolerated, never required — blank rows, -/// indented affordance hints (`tab to queue message`), or the token bar — -/// because the working layout can paint hints below the composer with no -/// bar at all. Prompt echoes in scrollback share the `›` head but sit -/// above the composer, which is why the bottom-most wins. +/// selector. Rows below it are tolerated, never required: blank rows, +/// indented affordance hints (`tab to queue message`), or the token bar. +/// The working layout can paint hints below the composer with no bar at +/// all. Prompt echoes in scrollback share the `›` head but sit above the +/// composer, so the bottom-most wins. fn codex_composer(rows: &[String]) -> Option { rows.iter() .rposition(|r| (r.as_str() == "›" || r.starts_with("› ")) && !codex_menu_head(r)) @@ -445,7 +417,7 @@ fn codex_status(rows: &[String], composer: usize) -> Option<(String, &'static st /// parenthetical is the elapsed counter plus interrupt affordance, dropped /// whole: an unclosed paren is CLI-side truncation mid-affordance and drops /// to the end. Of the ` · ` suffixes, `/`-headed segments are key hints; -/// everything else is slow-moving state worth keeping, with its own ellipsis +/// everything else is slow-moving state and is kept, with its own ellipsis /// when the CLI truncated it. fn codex_working(after_paren: &str) -> String { let mut out = String::from("Working"); @@ -746,10 +718,8 @@ mod tests { ); } - /// Parenthetical segments: every recognized ticker shape drops — alone - /// and beside a kept segment — and an unknown segment survives - /// verbatim. The sighted row keeps its effort note; a bare row is - /// unchanged. + /// Parenthetical segments: recognized ticker shapes are dropped and + /// unknown segments are preserved. A bare row is unchanged. #[test] fn claude_parenthetical_keeps_slow_segments_and_drops_tickers() { let sep = "─".repeat(120); @@ -815,9 +785,8 @@ mod tests { ); } - /// The ellipsis-less waiting row (live sighting 2026-07-20, claude - /// 2.1.215): exact shape extracts verbatim on any spinner frame, - /// singular or plural; near-miss shapes stay foreign and abort. + /// The ellipsis-less waiting row extracts verbatim for singular and plural + /// counts; near-miss shapes remain foreign and abort. #[test] fn claude_waiting_row_matches_exactly_and_never_probes() { let sep = "─".repeat(120); @@ -851,8 +820,8 @@ mod tests { assert_eq!(spin("✻ Waiting patiently"), None); assert_eq!(spin("· Waiting for review comments to land"), None); - // Self-describing: an action row above the waiting row is body - // prose to this state and must not win the head. + // An action row above the waiting row is body prose in this state + // and must not win the head. let rows = [ "⏺ Running 1 shell command…", "", @@ -870,9 +839,7 @@ mod tests { ); } - /// Title display: every spinner frame canonicalizes to `✻`, giving - /// constant text across frame rotation (the churn fix); non-frame - /// titles pass through untouched. + /// Every spinner frame canonicalizes to `✻`; non-frame titles pass through. #[test] fn claude_title_frames_canonicalize_to_constant_text() { for frame in CLAUDE_SPINNER { @@ -886,8 +853,7 @@ mod tests { let b = ClaudeSummary.normalize_title("✽ Claude Code"); assert_eq!(a, b, "two frames must normalize identically"); - // The in-session shape: a braille frame plus the session summary, - // animated per frame (sighted 2026-07-20). + // A braille frame plus the session summary. assert_eq!( ClaudeSummary.normalize_title("⠐ Review fleetcom preview design document"), Some("✻ Review fleetcom preview design document".to_string()) @@ -1048,10 +1014,8 @@ mod tests { assert_eq!(CodexSummary.live_preview(&behind_reply), None); } - /// The live-sighted working layout (codex-cli 0.144.6, 2026-07-20): - /// a hint row below the composer, no token bar painted. The composer - /// pin tolerates the rows below it, the anchor fires, and the absent - /// bar means no model label — `Working` with no prefix is correct. + /// A hint row may follow the composer without a token bar. The anchor + /// still fires, without a model prefix. #[test] fn codex_hint_row_layout_anchors_without_a_token_bar() { let hinted = rs(&[ @@ -1107,7 +1071,7 @@ mod tests { /// A menu quoted in the conversation always has the live composer /// somewhere below it; the composer's presence suppresses the modal - /// match, and the quote is a foreign row to the status scan — no + /// match, and the quote is a foreign row to the status scan: no /// anchor, floor tier. #[test] fn codex_quoted_menu_with_a_live_composer_is_not_a_modal() { @@ -1245,8 +1209,7 @@ mod tests { "preview_claude_waiting", include_bytes!("../../tests/corpus/preview_claude_waiting.bin"), &ClaudeSummary, - // The sighted screen is mid-session: the welcome box has - // scrolled off, so no model label prefixes the text. + // The welcome box is absent, so there is no model prefix. "Waiting for 1 background agent to finish", "claude:waiting-agents", ), @@ -1359,8 +1322,7 @@ mod tests { assert_eq!( parts(&p), ( - // The floor trims the status bar's self-indentation - // (layout, not meaning; see the cascade's floor arm). + // Floor previews omit the status bar's indentation. "gpt-5.6-sol high · 5.26K used · 28.2K in · 78 out".to_string(), PreviewSource::Floor, None diff --git a/src/main.rs b/src/main.rs index 31c8fe7..935d788 100644 --- a/src/main.rs +++ b/src/main.rs @@ -30,7 +30,6 @@ mod testutil; mod transport; mod ui; -// Expose terminal modules through the crate root. pub(crate) use terminal::{ansi, emulator, format, frame}; use std::{ diff --git a/src/task.rs b/src/task.rs index 7421eb4..abb8dbf 100644 --- a/src/task.rs +++ b/src/task.rs @@ -2032,12 +2032,9 @@ mod tests { let _ = std::fs::remove_dir_all(&dir); } - /// Full cascade end to end: an agent-shaped child paints a codex-shaped - /// working screen into a real PTY, repaints it with the completion row, - /// and exits; the summary adapter anchors the live preview and - /// finalization freezes the completion. The adapter is installed - /// manually because the child is `printf` under `$SHELL`, not `codex`: - /// no real agent CLI runs here. + /// End-to-end adapter path: a PTY screen resolves as a Codex anchor while + /// live and after exit. The test installs the adapter directly because the + /// child command is `printf`. #[test] fn summary_adapter_anchors_live_and_freezes_completion_at_exit() { use crate::preview::PreviewSource; diff --git a/src/terminal/emulator.rs b/src/terminal/emulator.rs index f0bfa9e..c280627 100644 --- a/src/terminal/emulator.rs +++ b/src/terminal/emulator.rs @@ -1493,9 +1493,8 @@ mod tests { assert_eq!(emu.title(), None, "ResetTitle must unset the capture"); } - /// A title just before the alt entry stages and is promoted into the - /// new epoch at the entry event: children emit the title bytes just - /// before DECSET 1049, and no glyphs intervene to disclaim it. + /// A title immediately before alt-screen entry is promoted from staging + /// into the new epoch when no glyph intervenes. #[test] fn title_entering_alt_in_one_chunk_is_honored() { let mut emu = Emulator::new(4, 20, 0); diff --git a/tests/corpus/README.md b/tests/corpus/README.md index f36f7cf..29ecd01 100644 --- a/tests/corpus/README.md +++ b/tests/corpus/README.md @@ -33,19 +33,13 @@ feed the bytes to the emulator verbatim. The `preview_*.bin` fixtures pin summary-adapter extraction, normalization, and fallback behavior. Each fixture is a constructed repaint stream: optional alternate-screen entry, clear, home, then sanitized screen rows joined with -CRLF. Claude and Grok use the alternate screen; Codex is inline. The source -screens came from claude 2.1.215, codex-cli 0.144.6, and grok 0.2.102. -Identifying and user-configured text is replaced with alignment-preserving -synthetic values. Geometry is 40×120 unless noted. +CRLF. Claude and Grok use the alternate screen; Codex is inline. Identifying +and user-configured text is replaced with alignment-preserving synthetic +values. Geometry is 40×120 unless noted. -The `preview_codex_hint_row` and `preview_codex_approval` fixtures are -paste-derived from a maintainer live sighting (2026-07-20, codex-cli 0.144.6 -— the same version as the snapshot captures; these are uncaptured states, -not drift): indentation is approximate, so their tests key on trimmed heads -and column-0 discipline only. `preview_codex_body_menu` is synthetic. `preview_claude_waiting` is -paste-derived from a maintainer live sighting (2026-07-20, claude 2.1.215): -the ellipsis-less waiting-for-agents spinner state, mid-session (welcome box -scrolled off), with agent-roster rows below the input box. +The Codex hint-row and approval fixtures use approximate indentation, so their +tests match trimmed heads and column-0 structure. The Claude waiting fixture +omits the welcome box and includes agent-roster rows below the input box. | Fixture | Scenario | Coverage | | --- | --- | --- | @@ -60,7 +54,7 @@ scrolled off), with agent-roster rows below the input box. | `preview_codex_working.bin` | codex `• Working (7s • esc to interrupt) · 1 background terminal running · /ps to view · /stop to close` | `codex:working` normalization: affordances stripped, slow suffix kept | | `preview_codex_working_over_ran.bin` | codex working with a `• Ran` row higher in the same turn | `codex:working` wins at the pin; the stale row never surfaces | | `preview_codex_scrollback.bin` | codex finished turn, `• Ran` from the prior turn in scrollback | the scan stops at the reply bullet and resolves to the floor tier | -| `preview_codex_ran.bin` | codex transient completion (synthetic: no raw capture holds it) | `codex:ran` extraction through the `└` attachment row | +| `preview_codex_ran.bin` | codex transient completion row | `codex:ran` extraction through the `└` attachment row | | `preview_codex_hint_row.bin` | codex working with `tab to queue message` below the composer, no token bar | `codex:working` through the composer pin; no model prefix without the bar | | `preview_codex_approval.bin` | codex approval modal: composer and token bar replaced by a numbered menu | `codex:approval-menu` synthesizes `awaiting approval` | | `preview_codex_body_menu.bin` | modal-shaped menu quoted in the body, live composer below | negative: the composer's presence suppresses the modal match; floor tier reports |