diff --git a/src/emulator.rs b/src/emulator.rs deleted file mode 100644 index d820686..0000000 --- a/src/emulator.rs +++ /dev/null @@ -1,833 +0,0 @@ -//! `fleetcom` reconstructs each task's terminal state from raw PTY output. -//! `alacritty_terminal` provides the parser, visible grid, and scrollback. - -use std::{ - sync::{Arc, Mutex}, - time::Instant, -}; - -use alacritty_terminal::{ - Term, - event::{Event, EventListener}, - grid::{Dimensions, Scroll}, - index::{Column, Line}, - term::{ - Config, TermMode, - cell::{Cell, Flags}, - }, - vte::ansi::Processor, -}; - -/// Mouse event classes requested by the child through DECSET 1000/1002/1003. -#[derive(Clone, Copy, Debug, Eq, PartialEq)] -pub enum MouseProtocolMode { - None, - PressRelease, - ButtonMotion, - AnyMotion, -} - -/// Coordinate encoding for mouse reports (DECSET 1005/1006). -#[derive(Clone, Copy, Debug, Eq, PartialEq)] -pub enum MouseProtocolEncoding { - Default, - Utf8, - 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>>); - -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); - } - } -} - -/// Terminal dimensions supplied to `Term::new` and `Term::resize`. -struct GridSize { - lines: usize, - columns: usize, -} - -impl Dimensions for GridSize { - fn total_lines(&self) -> usize { - self.lines - } - - fn screen_lines(&self) -> usize { - self.lines - } - - fn columns(&self) -> usize { - self.columns - } -} - -/// Default-deny allowlist for backend-generated probe responses. `fleetcom` -/// forwards only CPR (`ESC[;R`), DSR-5 (`ESC[0n`), and primary DA -/// (`ESC[?c`) responses. -fn allowed_probe_response(resp: &str) -> bool { - let Some(body) = resp.strip_prefix("\x1b[") else { - return false; - }; - // DSR 5: the fixed "terminal ok" status reply. - if body == "0n" { - return true; - } - // CPR: exactly two numeric fields. - if let Some(params) = body.strip_suffix('R') { - let mut fields = params.split(';'); - return matches!( - (fields.next(), fields.next(), fields.next()), - (Some(row), Some(col), None) if is_digits(row) && is_digits(col) - ); - } - // Primary DA: `?`-prefixed attributes. Secondary DA uses `>` and fails - // the prefix check. - if let Some(params) = body.strip_prefix('?').and_then(|b| b.strip_suffix('c')) { - return !params.is_empty() && params.bytes().all(|b| b.is_ascii_digit() || b == b';'); - } - false -} - -fn is_digits(s: &str) -> bool { - !s.is_empty() && s.bytes().all(|b| b.is_ascii_digit()) -} - -/// 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; - -/// Child-output byte threshold for scanning oversized zero-width vectors. -/// Counting bytes lets repeated marks trigger a scan without a separate timer. -const SWEEP_INTERVAL_BYTES: usize = 256 * 1024; - -/// One task's parser, terminal grid, and buffered probe responses. The parser -/// and grid advance together under the same caller-held lock. -pub struct Emulator { - term: Term, - parser: Processor, - responses: Arc>>, - /// Bytes ingested since the last zero-width scan. - bytes_since_sweep: usize, -} - -impl Emulator { - /// Drain buffered `PtyWrite` responses through the allowlist, preserving - /// generation order. Runs after `advance`/`stop_sync` returns, outside - /// the listener callback. - fn drain_allowed(&mut self) -> Vec { - let mut buf = self - .responses - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner); - buf.drain(..) - .filter(|r| allowed_probe_response(r)) - .collect() - } - - /// Truncate zero-width characters in each active-grid cell to - /// `MAX_ZEROWIDTH`. The scan covers the viewport and all scrollback rows. - /// - /// The inactive screen does not receive parsed output and is scanned only - /// after it becomes active. Synchronized-update bytes are counted before - /// they reach the grid, so cells applied after an early scan remain until - /// a later scan. - fn sweep_zerowidth(&mut self) { - let grid = self.term.grid_mut(); - // History rows are negative Line indices, oldest first. - let top = -(grid.history_size() as i32); - let bottom = grid.screen_lines() as i32 - 1; - for line in top..=bottom { - for cell in &mut grid[Line(line)][..] { - if !cell.zerowidth().is_some_and(|z| z.len() > MAX_ZEROWIDTH) { - continue; - } - // Rebuild the cell because `Cell` has no setter for - // truncating its zero-width vector. - let mut rebuilt = Cell { - c: cell.c, - fg: cell.fg, - bg: cell.bg, - flags: cell.flags, - extra: None, - }; - if let Some(z) = cell.zerowidth() { - for &mark in &z[..MAX_ZEROWIDTH] { - rebuilt.push_zerowidth(mark); - } - } - rebuilt.set_underline_color(cell.underline_color()); - rebuilt.set_hyperlink(cell.hyperlink()); - *cell = rebuilt; - } - } - } - - /// 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 config = Config { - // Use fleetcom's per-task history limit instead of the backend - // default. - scrolling_history: scrollback, - ..Config::default() - }; - let term = Term::new( - config, - &GridSize { - lines: rows as usize, - columns: cols as usize, - }, - ProbeSink(Arc::clone(&responses)), - ); - Self { - term, - parser: Processor::new(), - responses, - bytes_since_sweep: 0, - } - } - - /// 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.bytes_since_sweep = self.bytes_since_sweep.saturating_add(bytes.len()); - if self.bytes_since_sweep >= SWEEP_INTERVAL_BYTES { - self.bytes_since_sweep = 0; - self.sweep_zerowidth(); - } - self.drain_allowed() - } - - /// Terminate a `?2026` synchronized update whose timeout has expired, - /// flushing the buffered frame into the grid; returns any allowlisted - /// probe replies the flushed bytes generated. vte re-checks its timeout - /// only when more bytes arrive, so a child that opens BSU and stalls - /// would freeze its view until then. The core's periodic tick calls this - /// to bound the stall. No-op while the timeout is still pending (an - /// in-flight frame is not torn) and when no sync is open. - pub fn flush_expired_sync(&mut self) -> Vec { - let expired = self - .parser - .sync_timeout() - .sync_timeout() - .is_some_and(|deadline| deadline <= Instant::now()); - if !expired { - 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) { - crate::ansi::formatted(&self.term) - } - - /// Plain-text contents of the visible screen, one line per row. - pub fn contents(&self) -> String { - crate::ansi::contents(&self.term) - } - - /// Reconstruct retained terminal text from the oldest history row through - /// the live viewport. Soft wraps join into logical lines, hard lines lose - /// trailing padding, and the current scroll offset does not affect output. - pub fn text_with_history(&self) -> String { - let grid = self.term.grid(); - let top = -(grid.history_size() as i32); - let bottom = grid.screen_lines() as i32 - 1; - let last_col = grid.columns() - 1; - let mut out = String::new(); - for row in top..=bottom { - let row_start = out.len(); - let line = &grid[Line(row)]; - 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; - } - out.push(if cell.c == '\t' { ' ' } else { cell.c }); - if let Some(zerowidth) = cell.zerowidth() { - out.extend(zerowidth.iter()); - } - } - // A soft wrap continues on the next grid row. - if line[Column(last_col)].flags.contains(Flags::WRAPLINE) { - continue; - } - while out.len() > row_start && out.ends_with(' ') { - out.pop(); - } - if row < bottom { - out.push('\n'); - } - } - out - } - - /// Which mouse events the child asked for; the most recent DECSET wins - /// (the backend keeps the modes mutually exclusive). DECSET 9 (X10) is - /// not modeled, so an X10-only child gets no mouse reports. - pub fn mouse_protocol_mode(&self) -> MouseProtocolMode { - let mode = self.term.mode(); - if mode.contains(TermMode::MOUSE_MOTION) { - MouseProtocolMode::AnyMotion - } else if mode.contains(TermMode::MOUSE_DRAG) { - MouseProtocolMode::ButtonMotion - } else if mode.contains(TermMode::MOUSE_REPORT_CLICK) { - MouseProtocolMode::PressRelease - } else { - MouseProtocolMode::None - } - } - - /// How mouse coordinates are encoded on the wire. SGR wins over UTF-8 - /// if both bits are set. Each DECSET normally clears the other bit. - pub fn mouse_protocol_encoding(&self) -> MouseProtocolEncoding { - let mode = self.term.mode(); - if mode.contains(TermMode::SGR_MOUSE) { - MouseProtocolEncoding::Sgr - } else if mode.contains(TermMode::UTF8_MOUSE) { - MouseProtocolEncoding::Utf8 - } else { - MouseProtocolEncoding::Default - } - } - - /// Whether the child is on the alternate screen (DECSET 1049). - pub fn alternate_screen(&self) -> bool { - self.term.mode().contains(TermMode::ALT_SCREEN) - } - - /// Whether wheel events should reach the child as arrow keys: requires - /// the alternate screen and DECSET 1007, which defaults enabled. - pub fn alternate_scroll(&self) -> bool { - self.term - .mode() - .contains(TermMode::ALT_SCREEN | TermMode::ALTERNATE_SCROLL) - } - - /// Whether application cursor keys are on (DECSET 1). - pub fn application_cursor(&self) -> bool { - self.term.mode().contains(TermMode::APP_CURSOR) - } - - /// Whether the child opted into bracketed paste (DECSET 2004). - pub fn bracketed_paste(&self) -> bool { - self.term.mode().contains(TermMode::BRACKETED_PASTE) - } - - /// Rows the viewport is scrolled back from live output. - pub fn scrollback(&self) -> usize { - self.term.grid().display_offset() - } - - /// Move the viewport `rows` back from live output; the backend clamps to - /// retained history, so `usize::MAX` means the oldest stored row. - pub fn set_scrollback(&mut self, rows: usize) { - // Clamp contract: absolute target, capped at retained history. The - // grid API is relative; both offsets are bounded by the history cap, - // so the delta fits i32. - let grid = self.term.grid(); - let target = rows.min(grid.history_size()); - let delta = target as i32 - grid.display_offset() as i32; - self.term.scroll_display(Scroll::Delta(delta)); - } - - /// Resize the grid to `rows`×`cols`. - pub fn resize(&mut self, rows: u16, cols: u16) { - self.term.resize(GridSize { - lines: rows as usize, - columns: cols as usize, - }); - } - - /// Grid size as `(rows, cols)`. - #[cfg(test)] - pub fn size(&self) -> (u16, u16) { - ( - self.term.grid().screen_lines() as u16, - self.term.grid().columns() as u16, - ) - } -} - -#[cfg(test)] -mod tests { - use std::time::Duration; - - use alacritty_terminal::{ - index::Column, - term::cell::Flags, - vte::ansi::{Color, NamedColor}, - }; - - use super::*; - - /// The allowlist accepts only the advertised response shapes and rejects - /// other backend responses, malformed variants, and unknown strings. - #[test] - fn probe_allowlist_forwards_only_the_advertised_shapes() { - // Allowed: CPR, DSR-5 ok, primary DA. - assert!(allowed_probe_response("\x1b[1;1R")); - assert!(allowed_probe_response("\x1b[24;80R")); - assert!(allowed_probe_response("\x1b[0n")); - assert!(allowed_probe_response("\x1b[?6c")); - assert!(allowed_probe_response("\x1b[?62;22c")); - - // Denied: other backend response classes. - assert!(!allowed_probe_response("\x1b[>0;2606;1c")); // secondary DA - assert!(!allowed_probe_response("\x1b[?1u")); // kitty keyboard report - assert!(!allowed_probe_response("\x1b[?2026;2$y")); // DECRPM, private - assert!(!allowed_probe_response("\x1b[4;2$y")); // DECRPM, ANSI - assert!(!allowed_probe_response("\x1b[8;40;120t")); // window size - - // Denied: OSC-shaped replies (color/clipboard) and DCS. - assert!(!allowed_probe_response("\x1b]4;1;rgb:aa/bb/cc\x1b\\")); - assert!(!allowed_probe_response("\x1b]52;c;aGk=\x07")); - assert!(!allowed_probe_response("\x1bP>|term 1.0\x1b\\")); - - // Denied: malformed near-misses of allowed shapes. - assert!(!allowed_probe_response("\x1b[1R")); // CPR needs two fields - assert!(!allowed_probe_response("\x1b[1;2;3R")); - assert!(!allowed_probe_response("\x1b[;1R")); - assert!(!allowed_probe_response("\x1b[?c")); // DA needs params - assert!(!allowed_probe_response("\x1b[?6xc")); - - // Denied: arbitrary unknown responses. - assert!(!allowed_probe_response("\x1b[?9999;42z")); - assert!(!allowed_probe_response("unrecognized")); - assert!(!allowed_probe_response("")); - } - - /// End to end through `process`: the queries fleetcom answers produce - /// exactly their replies, nothing more. - #[test] - fn allowed_probe_queries_are_answered() { - let mut emu = Emulator::new(24, 80, 0); - // CPR reports the parse-time cursor position, 1-based. - assert!(emu.process(b"ab").is_empty()); - assert_eq!(emu.process(b"\x1b[6n"), vec!["\x1b[1;3R".to_string()]); - // DSR 5: status ok. - assert_eq!(emu.process(b"\x1b[5n"), vec!["\x1b[0n".to_string()]); - // Primary DA, both spellings. - assert_eq!(emu.process(b"\x1b[c"), vec!["\x1b[?6c".to_string()]); - assert_eq!(emu.process(b"\x1b[0c"), vec!["\x1b[?6c".to_string()]); - } - - /// End to end through `process`: queries outside the contract produce - /// nothing, even though the backend generates replies for them. - #[test] - fn denied_probe_queries_are_silenced() { - let mut emu = Emulator::new(24, 80, 0); - // Secondary DA: backend replies, allowlist drops it. - assert!(emu.process(b"\x1b[>c").is_empty()); - // DECRQM in both forms: DECRPM replies dropped. - assert!(emu.process(b"\x1b[?2026$p").is_empty()); - assert!(emu.process(b"\x1b[4$p").is_empty()); - // Window-size report dropped. - assert!(emu.process(b"\x1b[18t").is_empty()); - // Kitty keyboard query: disabled in config, no reply generated; the - // allowlist would drop the `ESC[?...u` shape regardless. - assert!(emu.process(b"\x1b[?u").is_empty()); - } - - /// The stall the flush hook exists for: BSU plus a partial frame, then - /// silence. The buffered frame must stay invisible while the timeout is - /// pending (the hook must not tear an in-flight frame) and flush once the - /// hook runs after expiry. - #[test] - fn stalled_sync_update_flushes_after_timeout() { - let mut emu = Emulator::new(4, 20, 0); - emu.process(b"before\x1b[?2026hafter"); - assert!(emu.contents().contains("before")); - assert!( - !emu.contents().contains("after"), - "sync update must buffer the frame" - ); - - // Not expired yet: the hook must not flush. - assert!(emu.flush_expired_sync().is_empty()); - assert!(!emu.contents().contains("after")); - - // vte's sync timeout is 150 ms; wait it out, then flush. - std::thread::sleep(Duration::from_millis(200)); - emu.flush_expired_sync(); - assert!( - emu.contents().contains("after"), - "expired sync must flush the buffered frame" - ); - - // The emulator parses normally after the forced flush. - 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] - fn mouse_modes_map_to_termmode_bits() { - let mut emu = Emulator::new(24, 80, 0); - assert_eq!(emu.mouse_protocol_mode(), MouseProtocolMode::None); - emu.process(b"\x1b[?1000h"); - assert_eq!(emu.mouse_protocol_mode(), MouseProtocolMode::PressRelease); - emu.process(b"\x1b[?1002h"); - assert_eq!(emu.mouse_protocol_mode(), MouseProtocolMode::ButtonMotion); - emu.process(b"\x1b[?1003h"); - assert_eq!(emu.mouse_protocol_mode(), MouseProtocolMode::AnyMotion); - emu.process(b"\x1b[?1003l"); - assert_eq!(emu.mouse_protocol_mode(), MouseProtocolMode::None); - - assert_eq!( - emu.mouse_protocol_encoding(), - MouseProtocolEncoding::Default - ); - emu.process(b"\x1b[?1005h"); - assert_eq!(emu.mouse_protocol_encoding(), MouseProtocolEncoding::Utf8); - emu.process(b"\x1b[?1006h"); - assert_eq!(emu.mouse_protocol_encoding(), MouseProtocolEncoding::Sgr); - emu.process(b"\x1b[?1006l"); - assert_eq!( - emu.mouse_protocol_encoding(), - MouseProtocolEncoding::Default - ); - } - - /// DECSET 9 (X10 press-only) is not modeled, so an X10-only child gets no - /// mouse reports. - #[test] - fn x10_decset9_is_not_modeled() { - let mut emu = Emulator::new(24, 80, 0); - emu.process(b"\x1b[?9h"); - assert_eq!(emu.mouse_protocol_mode(), MouseProtocolMode::None); - } - - /// The wheel-as-arrows gate requires the alternate screen and DECSET - /// 1007, which defaults on. - #[test] - fn alternate_scroll_requires_alt_screen_and_1007() { - let mut emu = Emulator::new(24, 80, 0); - assert!(!emu.alternate_scroll(), "primary screen never gates open"); - emu.process(b"\x1b[?1049h"); - assert!(emu.alternate_scroll(), "1007 defaults on"); - emu.process(b"\x1b[?1007l"); - assert!(!emu.alternate_scroll(), "the child's veto must stick"); - emu.process(b"\x1b[?1007h"); - assert!(emu.alternate_scroll()); - emu.process(b"\x1b[?1049l"); - assert!(!emu.alternate_scroll(), "leaving the alt screen closes it"); - } - - /// The clamp contract `scroll_view` relies on: absolute target capped at - /// retained history, `usize::MAX` → oldest stored row, 0 → live. - #[test] - fn scrollback_clamps_to_retained_history() { - let mut emu = Emulator::new(4, 10, 100); - for i in 0..12 { - emu.process(format!("l{i}\r\n").as_bytes()); - } - // 12 newlines on a 4-row screen, cursor starting at the top: the - // first 3 move the cursor, the remaining 9 scroll rows into history. - emu.set_scrollback(usize::MAX); - assert_eq!(emu.scrollback(), 9); - assert!( - emu.contents().starts_with("l0"), - "the oldest stored row must be displayed" - ); - emu.set_scrollback(3); - assert_eq!(emu.scrollback(), 3); - emu.set_scrollback(10_000); - assert_eq!(emu.scrollback(), 9, "over-scroll clamps at history"); - emu.set_scrollback(0); - assert_eq!(emu.scrollback(), 0); - } - - /// Retained text includes scrollback in chronological order and does not - /// change when the viewport scroll offset changes. - #[test] - fn text_with_history_includes_scrolled_off_rows() { - let mut emu = Emulator::new(4, 10, 100); - for i in 0..12 { - emu.process(format!("l{i}\r\n").as_bytes()); - } - // 12 newlines on a 4-row screen: the first rows are history now. - assert!(!emu.contents().contains("l0")); - let full = emu.text_with_history(); - assert!(full.starts_with("l0"), "oldest history row leads"); - assert!(full.contains("l11"), "the live screen is included"); - // 9 history rows plus the 4-row viewport, one line per row. - assert_eq!(full.split('\n').count(), 13); - // The view offset must not change what is reported. - emu.set_scrollback(usize::MAX); - assert_eq!(emu.text_with_history(), full); - } - - /// Soft wraps reconstruct one logical line without erasing explicit line - /// breaks. - #[test] - fn text_with_history_joins_soft_wrapped_rows() { - let mut emu = Emulator::new(6, 20, 100); - let hint = "claude --resume 123e4567-e89b-42d3-a456-426614174000"; - emu.process(format!("before\r\n{hint}\r\nafter").as_bytes()); - let full = emu.text_with_history(); - assert!( - full.contains(hint), - "52 chars over 3 rows at 20 columns must come back unbroken: {full:?}" - ); - // Explicit newlines still bound logical lines on both sides. - assert!(full.contains(&format!("before\n{hint}\nafter"))); - } - - /// A wrapped codex named-thread hint remains one logical line. - #[test] - fn text_with_history_joins_codex_hint_across_rows() { - let mut emu = Emulator::new(8, 40, 100); - let hint = "To continue this session, run codex resume, then select \ - mythic-otter (123e4567-e89b-42d3-a456-426614174000)"; - emu.process(hint.as_bytes()); - assert!( - emu.text_with_history().contains(hint), - "the hint spans 3 rows at 40 columns and must join unbroken" - ); - } - - /// Wrap markers travel with rows into scrollback: a wrapped line pushed - /// off the live screen still joins, including across the history to - /// viewport boundary. - #[test] - fn text_with_history_joins_wrapped_rows_in_scrollback() { - let mut emu = Emulator::new(4, 20, 100); - let hint = "claude --resume 123e4567-e89b-42d3-a456-426614174000"; - emu.process(format!("{hint}\r\n").as_bytes()); - for i in 0..6 { - emu.process(format!("pad {i}\r\n").as_bytes()); - } - let full = emu.text_with_history(); - assert!( - !emu.contents().contains("claude"), - "premise: the hint scrolled fully into history" - ); - assert!( - full.contains(hint), - "history rows keep their wrap markers: {full:?}" - ); - } - - /// Top-anchored region scrollback remains reachable after shrinking and - /// regrowing the grid, while new output continues to accumulate. - #[test] - fn region_scrolled_history_survives_resize() { - let mut emu = Emulator::new(40, 120, 2000); - for i in 1..=20 { - emu.process(format!("\x1b[{i};1Hseed {i:02}").as_bytes()); - } - // Insert history through newlines at a top-anchored region's bottom. - emu.process(b"\x1b[1;20r\x1b[20;1H"); - for i in 1..=30 { - emu.process(format!("\r\nhist {i:02}").as_bytes()); - } - emu.process(b"\x1b[r"); - emu.set_scrollback(usize::MAX); - assert_eq!(emu.scrollback(), 30); - assert!( - emu.contents().starts_with("seed 01"), - "oldest region-scrolled row heads the history" - ); - emu.set_scrollback(0); - - // Shrink, then keep inserting at the new geometry. Row counts shift - // with reflow (shrinking parks the excess viewport rows in history), - // so assert reachability and monotonic growth, not exact totals. - emu.resize(30, 100); - emu.process(b"\x1b[1;15r\x1b[15;1H"); - for i in 1..=20 { - emu.process(format!("\r\nmore {i:02}").as_bytes()); - } - emu.process(b"\x1b[r"); - emu.set_scrollback(usize::MAX); - let after_shrink = emu.scrollback(); - assert!( - after_shrink >= 50, - "history keeps accumulating at the new size: {after_shrink}" - ); - assert!( - emu.contents().starts_with("seed 01"), - "pre-resize history remains reachable" - ); - - // Growing back must not orphan anything either. - emu.set_scrollback(0); - emu.resize(40, 120); - emu.set_scrollback(usize::MAX); - assert!( - emu.contents().contains("seed 01"), - "history survives the round trip" - ); - let live = { - emu.set_scrollback(0); - emu.contents() - }; - assert!( - live.contains("more 20"), - "the newest insertion is on the live screen" - ); - } - - /// Total zero-width characters retained across the viewport and history. - fn total_zerowidth(emu: &Emulator) -> usize { - let grid = emu.term.grid(); - let top = -(grid.history_size() as i32); - let bottom = grid.screen_lines() as i32 - 1; - (top..=bottom) - .flat_map(|line| grid[Line(line)][..].iter()) - .map(|cell| cell.zerowidth().map_or(0, <[char]>::len)) - .sum() - } - - /// A full interval of combining marks on one cell is capped before - /// `process` returns. - #[test] - fn zerowidth_spam_on_one_cell_is_capped() { - let mut emu = Emulator::new(4, 10, 0); - emu.process(b"a"); - // U+0301 is two UTF-8 bytes, making this chunk one full interval. - let chunk = "\u{0301}".repeat(SWEEP_INTERVAL_BYTES / 2); - - emu.process(chunk.as_bytes()); - let len = emu.term.grid()[Line(0)][Column(0)] - .zerowidth() - .map_or(0, <[char]>::len); - assert!( - len <= MAX_ZEROWIDTH, - "hot cell retains {len} marks after process returned" - ); - - // A second interval on the same cell is capped independently. - emu.process(chunk.as_bytes()); - assert!( - total_zerowidth(&emu) <= MAX_ZEROWIDTH, - "marks retained beyond the single spammed cell" - ); - } - - /// A scan caps combining marks in every cell across a populated row. - #[test] - fn zerowidth_spray_across_cells_is_capped() { - let mut emu = Emulator::new(4, 80, 0); - let marks = "\u{0301}".repeat(2048); - let mut payload = String::new(); - for col in 1..=80 { - payload.push_str(&format!("\x1b[2;{col}Hx")); - payload.push_str(&marks); - } - assert!( - payload.len() >= SWEEP_INTERVAL_BYTES, - "payload must cross the sweep interval in one call" - ); - emu.process(payload.as_bytes()); - - let grid = emu.term.grid(); - for col in 0..80 { - let z = grid[Line(1)][Column(col)] - .zerowidth() - .expect("sprayed cell lost its marks entirely"); - // Exactly the cap: truncation keeps the first marks, it does not - // clear the cell. - assert_eq!(z.len(), MAX_ZEROWIDTH, "column {col}"); - assert!(z.iter().all(|&m| m == '\u{0301}')); - } - assert!(total_zerowidth(&emu) <= 80 * MAX_ZEROWIDTH); - } - - /// A scan triggered by unrelated output preserves an under-limit styled - /// cluster and all of its cell attributes. - #[test] - fn legitimate_cluster_survives_sweep_untouched() { - let mut emu = Emulator::new(4, 80, 0); - let cluster = "\x1b]8;;https://example.com\x1b\\\ - \x1b[1;4;31;44m\x1b[58;5;42m\ - e\u{0301}\u{0302}\u{0304}\ - \x1b[0m\x1b]8;;\x1b\\"; - emu.process(cluster.as_bytes()); - - // CUP keeps the filler on row 2 while enough bytes trigger a scan. - let filler = format!("\x1b[2;1H{}", "x".repeat(64)).repeat(1024); - let mut fed = cluster.len(); - while fed < SWEEP_INTERVAL_BYTES { - emu.process(filler.as_bytes()); - fed += filler.len(); - } - - let cell = &emu.term.grid()[Line(0)][Column(0)]; - assert_eq!(cell.c, 'e'); - assert_eq!( - cell.zerowidth(), - Some(&['\u{0301}', '\u{0302}', '\u{0304}'][..]) - ); - assert_eq!(cell.fg, Color::Named(NamedColor::Red)); - assert_eq!(cell.bg, Color::Named(NamedColor::Blue)); - assert!(cell.flags.contains(Flags::BOLD | Flags::UNDERLINE)); - assert_eq!(cell.underline_color(), Some(Color::Indexed(42))); - assert_eq!( - cell.hyperlink().map(|h| h.uri().to_owned()), - Some("https://example.com".to_owned()) - ); - } - - /// A scan also caps a cell that entered scrollback before the threshold. - #[test] - fn history_cells_are_swept() { - let mut emu = Emulator::new(4, 10, 100); - // This oversized cluster remains below the scan threshold. - let spam = format!("h{}", "\u{0301}".repeat(4096)); - emu.process(spam.as_bytes()); - emu.process(b"\r\n\r\n\r\n\r\n\r\n\r\n"); - - // Confirm that the oversized cell reached history before the scan. - let find_h = |emu: &Emulator| -> (i32, usize) { - let grid = emu.term.grid(); - let top = -(grid.history_size() as i32); - (top..0) - .find_map(|line| { - let cell = &grid[Line(line)][Column(0)]; - (cell.c == 'h').then(|| (line, cell.zerowidth().map_or(0, <[char]>::len))) - }) - .expect("spammed row must be in history") - }; - let (line, len) = find_h(&emu); - assert!(line < 0); - assert_eq!(len, 4096, "excess must predate the sweep"); - - // CUP keeps filler on the last row so the history position is stable. - let filler = "\x1b[4;1Hxxxxxxxx".repeat(1024); - let mut fed = spam.len() + 12; - while fed < SWEEP_INTERVAL_BYTES { - emu.process(filler.as_bytes()); - fed += filler.len(); - } - - let (line_after, len_after) = find_h(&emu); - assert_eq!(line_after, line, "row must not have moved"); - assert_eq!(len_after, MAX_ZEROWIDTH); - } -} diff --git a/src/golden.rs b/src/golden.rs index d28402f..7ad79ca 100644 --- a/src/golden.rs +++ b/src/golden.rs @@ -576,3 +576,71 @@ fn semantic_dec_scrollregion_charset_translation() { } assert_eq!(al.grid().cursor.point, Point::new(Line(39), Column(0))); } + +/// 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] = [ + ( + "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..dc841f6 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`. Summary adapters are display-only and do not return session +//! IDs. 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..f42703f --- /dev/null +++ b/src/harness/summary.rs @@ -0,0 +1,1453 @@ +//! 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 and never enters a shell +//! command. It is therefore outside the session-ID validation boundary in +//! [`is_uuid`](super::is_uuid). +//! +//! # Anchor discipline +//! +//! 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 +//! scans only rows pinned to it, never the whole grid; +//! 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 spinner glyphs, elapsed counters, throughput data, +//! and key hints while preserving the CLI's status text. The only synthesized +//! 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; + +use crate::preview::ScreenFacts; + +/// Display-only status and model-label extraction for one agent CLI. +pub trait SummaryAdapter: Sync { + /// 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)>; + + /// 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; + + /// 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 + } + + /// 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 + } +} + +/// 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()? { + "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 -- + +/// 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 +/// the input box's top separator. Approval state: the dialog replaces the +/// input box entirely; the menu match fires only when that box is gone. +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), + // Consider approval menus only when the normal input box is absent. + None => claude_approval(&rows), + } + } + + fn model_label(&self, screen: &dyn ScreenFacts) -> Option { + claude_welcome_label(&screen.live_rows()) + } + + /// 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()?; + 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 +/// 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: body text reaching the chrome, or the +/// 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]; + if row.is_empty() || row.starts_with(' ') { + continue; + } + 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 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")); + } + // 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")); + } + // Foreign column-0 row: abort (see above). + return None; + } + None +} + +/// 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()? != ' ' { + 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 +/// 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()? != ' ' { + return None; + } + let rest = chars.as_str(); + let text = &rest[..rest.find('…')? + '…'.len_utf8()]; + text.chars() + .next()? + .is_alphanumeric() + .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`. 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(); + }; + 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 (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" + || 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 +/// 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())?; + let text = row.strip_prefix("⏺ ")?.trim(); + let tail = text.len().checked_sub('…'.len_utf8())?; + (text.find('…') == Some(tail)).then(|| text.to_string()) +} + +/// 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. "))?; + 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 +/// 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() + .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 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. +/// A token bar or indented hint rows may appear below the composer. +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) + } + + 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()) + } +} + +/// `› 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 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]))?; + 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. +/// 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| { + 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: 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. +/// 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)) +} + +/// 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 `); 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 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"); + 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")) + ); + } + + /// 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); + 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)… · almost done thinking with high effort" + .to_string(), + "claude:spinner" + )) + ); + } + + /// 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); + 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" + )) + ); + } + + /// 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); + 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); + + // 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…", + "", + "✻ 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" + )) + ); + } + + /// Every spinner frame canonicalizes to `✻`; non-frame titles pass through. + #[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"); + + // 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()) + ); + 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] + 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); + } + + /// 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(&[ + "• 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", + "", + "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_claude_waiting", + include_bytes!("../../tests/corpus/preview_claude_waiting.bin"), + &ClaudeSummary, + // The welcome box is absent, so there is no model prefix. + "Waiting for 1 background agent to finish", + "claude:waiting-agents", + ), + 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"), + &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}"); + } + } + + /// 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] = [ + ( + "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}" + ); + } + } + + /// 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] + 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), + ( + // 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 + ) + ); + + // 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"), + &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" + ) + ); + } + + /// 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( + 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/main.rs b/src/main.rs index 68584db..935d788 100644 --- a/src/main.rs +++ b/src/main.rs @@ -8,29 +8,30 @@ #[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; // 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; mod task; +mod terminal; // Shared test scaffolds: scratch dirs, deadline polling, corpus fixtures. #[cfg(test)] mod testutil; mod transport; mod ui; +pub(crate) use terminal::{ansi, emulator, format, frame}; + use std::{ io, sync::{ diff --git a/src/preview.rs b/src/preview.rs new file mode 100644 index 0000000..f4b2d3c --- /dev/null +++ b/src/preview.rs @@ -0,0 +1,1084 @@ +//! 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}; + +// Holds use elapsed time because tick intervals range from the 8 ms frame +// minimum to the 200 ms idle backstop. + +/// Minimum interval between rendered title changes. +pub const TITLE_MIN_HOLD: Duration = Duration::from_millis(500); + +/// 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. +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. + 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` 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, + 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; + /// 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 { + 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) + } + + fn live_rows(&self) -> Vec { + Emulator::live_rows(self) + } + + fn alt_leave_floor(&self) -> Option<&str> { + Emulator::alt_leave_floor(self) + } +} + +/// 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 +/// 2. alternate screen: the title while its epoch is current, else the marker +/// 3. primary screen: the live floor +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() { + // 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: adapter + .and_then(|a| a.normalize_title(text)) + .unwrap_or_else(|| text.to_string()), + source: PreviewSource::Title, + rule: None, + frozen: false, + }, + None => Preview { + text: MARKER.to_string(), + source: PreviewSource::Marker, + rule: None, + frozen: false, + }, + }; + } + // 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() + }) +} + +/// State that invalidates the cached preview candidate. +type ResolveKey = (u64, u64, bool, Option, bool); + +/// Per-task preview resolution state. A rerun replaces the `Task` and resets +/// this state. +#[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, + /// 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, +} + +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, + rendered_under_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. `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; + } + 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, adapter); + self.last_key = Some(key); + } + self.step(now, screen.alternate_screen()); + &self.rendered + } + + /// 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, alt); + } + Ordering::Equal => { + // A recovered rank cancels a pending demotion without a + // 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, alt); + } + } + // The floor is live output; anchor text changes are + // semantic (a new verb, a new completion row). Both + // render immediately. + PreviewSource::Floor | PreviewSource::Anchor => { + let cand = self.candidate.clone(); + self.render(cand, now, alt); + } + } + } + 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, alt); + } + } + } + } + + /// 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) { + self.pending_candidate = None; + self.downgrade_pending_since = None; + } + + /// 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, + adapter: Option<&dyn SummaryAdapter>, + exit_line: Option, + ) { + if self.finalized { + return; + } + self.finalized = true; + self.cancel_demotion(); + 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, adapter); + 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 at the last `leave_alt`, mirroring the emulator's + /// alt-exit snapshot. + alt_leave_floor: Option, + floor_calls: Cell, + } + + impl FakeScreen { + fn primary(floor: &str) -> FakeScreen { + FakeScreen { + revision: 1, + alt_epoch: 0, + alt: false, + title: None, + floor: floor.into(), + alt_leave_floor: None, + 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; + // The emulator snapshots the restored floor at the alt exit. + self.alt_leave_floor = Some(self.floor.clone()); + 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() + } + + 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. + #[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. + #[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, None).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, 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, 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, None).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, None).source, + PreviewSource::Floor + ); + + s.enter_alt(); + 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, None).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, None); + + s.clear_title(); + assert_eq!( + 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, 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)); + } + + /// 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, None); + + s.clear_title(); + 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, None).clone(); + assert_eq!((p.text.as_str(), p.source), ("app", PreviewSource::Title)); + + // The next demotion starts a new hold interval. + s.clear_title(); + st.resolve(t0 + ms(400), false, &s, None); + assert_eq!( + 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, None).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, None); + + // First demoted candidate: the marker. + s.clear_title(); + 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, None) + .source, + PreviewSource::Title + ); + let p = st.resolve(t0 + DEMOTION_HOLD, false, &s, None).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, None).text, "one"); + + s.set_title("two"); + 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, None).text, "one"); + assert_eq!( + st.resolve(t0 + TITLE_MIN_HOLD, false, &s, None).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, None).text, "compiling foo"); + s.set_floor("compiling bar"); + assert_eq!(st.resolve(t0, false, &s, None).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, None); + assert_eq!(s.floor_calls.get(), 1); + + 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, None); + assert_eq!(s.floor_calls.get(), 2, "a revision bump must recompute"); + } + + /// 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(); + 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] + 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, None); + + s.set_floor("test result: ok"); + 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) + ); + } + + /// 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(); + 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 exit's 1049l lands with no live resolution in between. + s.leave_alt(); + 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) + ); + + s.set_floor("stray"); + assert_eq!( + st.resolve(t0 + Duration::from_secs(5), true, &s, None).text, + "agent: working", + "resolution must short-circuit to the frozen value" + ); + } + + /// 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(); + 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) + ); + } + + /// 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(); + 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" + ); + } + + /// 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(); + 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 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(); + 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" + ); + } + + /// 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(); + 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 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 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(); + 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" + ); + } + + /// 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(); + 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] + 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, None); + + s.set_title("step 2: 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), + ("step 2: done", PreviewSource::Title, true) + ); + } + + /// Floor previews remove leading layout indentation. + #[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 + ) + ); + } +} diff --git a/src/protocol.rs b/src/protocol.rs index 738ef86..22c1533 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,13 @@ pub struct TaskView { /// idle threshold; `false` once finished. pub parked: bool, pub preview: String, + /// Provenance of `preview`. + pub source: PreviewSource, + /// Whether the preview froze at output-complete and can no longer change. + pub frozen: bool, + /// 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. pub quiet_ago: Option, @@ -356,6 +366,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 +752,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()); + // 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); let _ = o.insert("parked", tv.parked); // Each age exists in exactly one phase: `quiet_ms` while @@ -808,6 +832,18 @@ pub fn decode_event(kind: u8, payload: &[u8]) -> Option { } else { tv["parked"].as_bool()? }; + // Missing preview metadata uses conservative defaults + // so the task frame remains usable. + 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 +855,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 +1207,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 +1225,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 +1301,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 +1317,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 +1338,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 +1354,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 +1386,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 +1403,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 +1436,87 @@ 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)); + + // Encoding omits the process-local matcher rule. + 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:?}"), + } + } + + /// 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}]}"#; + 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..29332b9 100644 --- a/src/supervisor.rs +++ b/src/supervisor.rs @@ -354,6 +354,8 @@ 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(); + // Freeze the preview from the complete output and final screen. + t.finalize_preview(); if t.overdue(now, self.kill_grace) { t.force_kill(); } @@ -409,32 +411,36 @@ 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 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() - .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/supervisor_tests.rs b/src/supervisor_tests.rs index b7c6c8e..4d52ded 100644 --- a/src/supervisor_tests.rs +++ b/src/supervisor_tests.rs @@ -2718,9 +2718,14 @@ 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:?}" ); + // 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), |_| 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 6ebc0b8..abb8dbf 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}, }; @@ -338,6 +339,9 @@ pub struct Task { pub harness: Option<&'static dyn crate::harness::Harness>, /// Harness home resolved from this run's launch environment. pub harness_home: Option, + /// 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, /// Session ID injected or recognized at spawn. Later capture data or an @@ -350,6 +354,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, @@ -574,11 +581,13 @@ impl Task { name: None, harness: None, harness_home: None, + summary_adapter: crate::harness::summary::select(command), run: 0, resume_id: None, capture_file: None, scraped_id: None, scraped: false, + preview: PreviewState::new(), spawned_at: SystemTime::now(), exit_code: None, started: Instant::now(), @@ -613,18 +622,29 @@ impl Task { Ok(()) } + /// 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) + } + /// 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; - let text = grid(&self.parser).text_with_history(); + let text = { + let mut emu = grid(&self.parser); + // 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() + }; if let Some(id) = h.scrape_exit(&text) { self.scraped_id = Some(id); } @@ -722,15 +742,31 @@ 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, self.summary_adapter) + .clone() + } + + /// 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; + } + let mut emu = grid(&self.parser); + let _ = emu.finish_output(); + 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 @@ -1020,7 +1056,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)); @@ -1839,6 +1875,217 @@ 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)); + } + + /// 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); + } + + /// 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; + let dir = temp("task_final_alt"); + 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 '{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(); + assert!( + wait_until(Duration::from_secs(5), || { + t.resolve_preview(Instant::now()).source == PreviewSource::Title + }), + "title never rendered" + ); + std::fs::write(&teardown, b"").unwrap(); + assert!( + wait_until(Duration::from_secs(60), || { + !grid(&t.parser).alternate_screen() + }), + "teardown never reached the grid" + ); + // 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, + "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(); + 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); + } + + /// 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; + 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); + } + + /// 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; + 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/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/terminal/emulator.rs b/src/terminal/emulator.rs new file mode 100644 index 0000000..c280627 --- /dev/null +++ b/src/terminal/emulator.rs @@ -0,0 +1,1747 @@ +//! `fleetcom` reconstructs each task's terminal state from raw PTY output. +//! `alacritty_terminal` provides the parser, visible grid, and scrollback. + +use std::{ + sync::{Arc, Mutex}, + time::Instant, +}; + +use alacritty_terminal::{ + Term, + event::{Event, EventListener}, + grid::{Dimensions, Scroll}, + index::{Column, Line}, + term::{ + Config, TermMode, + cell::{Cell, Flags}, + }, + vte::ansi::{self as vt, Handler, Processor}, +}; + +/// Mouse event classes requested by the child through DECSET 1000/1002/1003. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum MouseProtocolMode { + None, + PressRelease, + ButtonMotion, + AnyMotion, +} + +/// Coordinate encoding for mouse reports (DECSET 1005/1006). +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum MouseProtocolEncoding { + Default, + Utf8, + Sgr, +} + +/// 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>>, +} + +impl EventListener for ProbeSink { + fn send_event(&self, event: Event) { + if let Event::PtyWrite(text) = event { + let mut buf = self + .responses + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + buf.push(text); + } + } +} + +/// Terminal dimensions supplied to `Term::new` and `Term::resize`. +struct GridSize { + lines: usize, + columns: usize, +} + +impl Dimensions for GridSize { + fn total_lines(&self) -> usize { + self.lines + } + + fn screen_lines(&self) -> usize { + self.lines + } + + fn columns(&self) -> usize { + self.columns + } +} + +/// Default-deny allowlist for backend-generated probe responses. `fleetcom` +/// forwards only CPR (`ESC[;R`), DSR-5 (`ESC[0n`), and primary DA +/// (`ESC[?c`) responses. +fn allowed_probe_response(resp: &str) -> bool { + let Some(body) = resp.strip_prefix("\x1b[") else { + return false; + }; + // DSR 5: the fixed "terminal ok" status reply. + if body == "0n" { + return true; + } + // CPR: exactly two numeric fields. + if let Some(params) = body.strip_suffix('R') { + let mut fields = params.split(';'); + return matches!( + (fields.next(), fields.next(), fields.next()), + (Some(row), Some(col), None) if is_digits(row) && is_digits(col) + ); + } + // Primary DA: `?`-prefixed attributes. Secondary DA uses `>` and fails + // the prefix check. + if let Some(params) = body.strip_prefix('?').and_then(|b| b.strip_suffix('c')) { + return !params.is_empty() && params.bytes().all(|b| b.is_ascii_digit() || b == b';'); + } + false +} + +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 and the alternate-screen epoch that owns it. Each +/// alternate-screen entry starts a new epoch. +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; + +/// Child-output byte threshold for scanning oversized zero-width vectors. +/// Counting bytes lets repeated marks trigger a scan without a separate timer. +const SWEEP_INTERVAL_BYTES: usize = 256 * 1024; + +/// One task's parser, terminal grid, and buffered probe responses. The parser +/// and grid advance together under the same caller-held lock. +pub struct Emulator { + term: Term, + parser: Processor, + responses: Arc>>, + /// 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, + /// Bytes ingested since the last zero-width scan. + bytes_since_sweep: usize, +} + +impl Emulator { + /// Drain buffered `PtyWrite` responses through the allowlist, preserving + /// generation order. Runs after `advance`/`stop_sync` returns, outside + /// the listener callback. + fn drain_allowed(&mut self) -> Vec { + let mut buf = self + .responses + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + buf.drain(..) + .filter(|r| allowed_probe_response(r)) + .collect() + } + + /// Truncate zero-width characters in each active-grid cell to + /// `MAX_ZEROWIDTH`. The scan covers the viewport and all scrollback rows. + /// + /// The inactive screen does not receive parsed output and is scanned only + /// after it becomes active. Synchronized-update bytes are counted before + /// they reach the grid, so cells applied after an early scan remain until + /// a later scan. + fn sweep_zerowidth(&mut self) { + let grid = self.term.grid_mut(); + // History rows are negative Line indices, oldest first. + let top = -(grid.history_size() as i32); + let bottom = grid.screen_lines() as i32 - 1; + for line in top..=bottom { + for cell in &mut grid[Line(line)][..] { + if !cell.zerowidth().is_some_and(|z| z.len() > MAX_ZEROWIDTH) { + continue; + } + // Rebuild the cell because `Cell` has no setter for + // truncating its zero-width vector. + let mut rebuilt = Cell { + c: cell.c, + fg: cell.fg, + bg: cell.bg, + flags: cell.flags, + extra: None, + }; + if let Some(z) = cell.zerowidth() { + for &mark in &z[..MAX_ZEROWIDTH] { + rebuilt.push_zerowidth(mark); + } + } + rebuilt.set_underline_color(cell.underline_color()); + rebuilt.set_hyperlink(cell.hyperlink()); + *cell = rebuilt; + } + } + } + + /// 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 config = Config { + // Use fleetcom's per-task history limit instead of the backend + // default. + scrolling_history: scrollback, + ..Config::default() + }; + let term = Term::new( + config, + &GridSize { + lines: rows as usize, + columns: cols as usize, + }, + ProbeSink { + responses: Arc::clone(&responses), + }, + ); + Self { + term, + parser: Processor::new(), + responses, + alt: AltScreen::default(), + revision: 0, + bytes_since_sweep: 0, + } + } + + /// Record one parser or synchronized-frame advance. + fn observe_advance(&mut self) { + self.revision += 1; + } + + /// 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 { + 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 { + self.bytes_since_sweep = 0; + self.sweep_zerowidth(); + } + self.drain_allowed() + } + + /// Terminate a `?2026` synchronized update whose timeout has expired, + /// flushing the buffered frame into the grid; returns any allowlisted + /// probe replies the flushed bytes generated. vte re-checks its timeout + /// only when more bytes arrive, so a child that opens BSU and stalls + /// would freeze its view until then. The core's periodic tick calls this + /// to bound the stall. No-op while the timeout is still pending (an + /// in-flight frame is not torn) and when no sync is open. + pub fn flush_expired_sync(&mut self) -> Vec { + let expired = self + .parser + .sync_timeout() + .sync_timeout() + .is_some_and(|deadline| deadline <= Instant::now()); + if !expired { + return Vec::new(); + } + let mut observed = ObservedTerm { + term: &mut self.term, + alt: &mut self.alt, + }; + self.parser.stop_sync(&mut observed); + self.observe_advance(); + 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(); + } + let mut observed = ObservedTerm { + term: &mut self.term, + alt: &mut self.alt, + }; + self.parser.stop_sync(&mut observed); + self.observe_advance(); + 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) { + crate::ansi::formatted(&self.term) + } + + /// Plain-text contents of the visible screen, one line per row. + pub fn contents(&self) -> String { + crate::ansi::contents(&self.term) + } + + /// Reconstruct retained terminal text from the oldest history row through + /// the live viewport. Soft wraps join into logical lines, hard lines lose + /// trailing padding, and the current scroll offset does not affect output. + pub fn text_with_history(&self) -> String { + let grid = self.term.grid(); + let top = -(grid.history_size() as i32); + let bottom = grid.screen_lines() as i32 - 1; + let last_col = grid.columns() - 1; + let mut out = String::new(); + for row in top..=bottom { + let row_start = out.len(); + let line = &grid[Line(row)]; + 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; + } + out.push(if cell.c == '\t' { ' ' } else { cell.c }); + if let Some(zerowidth) = cell.zerowidth() { + out.extend(zerowidth.iter()); + } + } + // A soft wrap continues on the next grid row. + if line[Column(last_col)].flags.contains(Flags::WRAPLINE) { + continue; + } + while out.len() > row_start && out.ends_with(' ') { + out.pop(); + } + if row < bottom { + out.push('\n'); + } + } + out + } + + /// Which mouse events the child asked for; the most recent DECSET wins + /// (the backend keeps the modes mutually exclusive). DECSET 9 (X10) is + /// not modeled, so an X10-only child gets no mouse reports. + pub fn mouse_protocol_mode(&self) -> MouseProtocolMode { + let mode = self.term.mode(); + if mode.contains(TermMode::MOUSE_MOTION) { + MouseProtocolMode::AnyMotion + } else if mode.contains(TermMode::MOUSE_DRAG) { + MouseProtocolMode::ButtonMotion + } else if mode.contains(TermMode::MOUSE_REPORT_CLICK) { + MouseProtocolMode::PressRelease + } else { + MouseProtocolMode::None + } + } + + /// How mouse coordinates are encoded on the wire. SGR wins over UTF-8 + /// if both bits are set. Each DECSET normally clears the other bit. + pub fn mouse_protocol_encoding(&self) -> MouseProtocolEncoding { + let mode = self.term.mode(); + if mode.contains(TermMode::SGR_MOUSE) { + MouseProtocolEncoding::Sgr + } else if mode.contains(TermMode::UTF8_MOUSE) { + MouseProtocolEncoding::Utf8 + } else { + MouseProtocolEncoding::Default + } + } + + /// Whether the child is on the alternate screen (DECSET 1049). + pub fn alternate_screen(&self) -> bool { + self.term.mode().contains(TermMode::ALT_SCREEN) + } + + /// Whether wheel events should reach the child as arrow keys: requires + /// the alternate screen and DECSET 1007, which defaults enabled. + pub fn alternate_scroll(&self) -> bool { + self.term + .mode() + .contains(TermMode::ALT_SCREEN | TermMode::ALTERNATE_SCROLL) + } + + /// Whether application cursor keys are on (DECSET 1). + pub fn application_cursor(&self) -> bool { + self.term.mode().contains(TermMode::APP_CURSOR) + } + + /// Whether the child opted into bracketed paste (DECSET 2004). + pub fn bracketed_paste(&self) -> bool { + self.term.mode().contains(TermMode::BRACKETED_PASTE) + } + + /// Rows the viewport is scrolled back from live output. + pub fn scrollback(&self) -> usize { + self.term.grid().display_offset() + } + + /// Move the viewport `rows` back from live output; the backend clamps to + /// retained history, so `usize::MAX` means the oldest stored row. + pub fn set_scrollback(&mut self, rows: usize) { + // Clamp contract: absolute target, capped at retained history. The + // grid API is relative; both offsets are bounded by the history cap, + // so the delta fits i32. + let grid = self.term.grid(); + let target = rows.min(grid.history_size()); + let delta = target as i32 - grid.display_offset() as i32; + self.term.scroll_display(Scroll::Delta(delta)); + } + + /// Resize the grid to `rows`×`cols`. + pub fn resize(&mut self, rows: u16, cols: u16) { + // 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 + .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)); + } + // Reflow changes grid contents without parser input, so cached screen + // facts must be invalidated. + self.revision += 1; + } + + /// Grid size as `(rows, cols)`. + #[cfg(test)] + pub fn size(&self) -> (u16, u16) { + ( + self.term.grid().screen_lines() as u16, + self.term.grid().columns() as u16, + ) + } +} + +/// 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 + /// 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 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 + /// 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> { + // 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() + { + return Some(staged); + } + self.alt + .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 { + 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 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); + 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 +} + +/// 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 + /// `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 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 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, + /// Shadow of the backend title stack, needed because a backend pop does + /// not emit a handler title event. + title_stack: Vec>, +} + +/// 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. +/// +/// # Forwarding invariant +/// +/// `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. +/// +/// # 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 +/// 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<'_> { + /// 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 { + 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, + }); + } + } + if !alt && self.alt.last_alt { + self.alt.leave_floor = Some(live_floor_of(self.term)); + } + self.alt.last_alt = alt; + } + + /// 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 + .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); + } + } +} + +/// 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` +/// 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 primary-screen title. + 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 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; + } + 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); + } +} + +#[cfg(test)] +mod tests { + use std::time::Duration; + + use alacritty_terminal::{ + index::Column, + term::cell::Flags, + vte::ansi::{Color, NamedColor}, + }; + + use super::*; + + /// The allowlist accepts only the advertised response shapes and rejects + /// other backend responses, malformed variants, and unknown strings. + #[test] + fn probe_allowlist_forwards_only_the_advertised_shapes() { + // Allowed: CPR, DSR-5 ok, primary DA. + assert!(allowed_probe_response("\x1b[1;1R")); + assert!(allowed_probe_response("\x1b[24;80R")); + assert!(allowed_probe_response("\x1b[0n")); + assert!(allowed_probe_response("\x1b[?6c")); + assert!(allowed_probe_response("\x1b[?62;22c")); + + // Denied: other backend response classes. + assert!(!allowed_probe_response("\x1b[>0;2606;1c")); // secondary DA + assert!(!allowed_probe_response("\x1b[?1u")); // kitty keyboard report + assert!(!allowed_probe_response("\x1b[?2026;2$y")); // DECRPM, private + assert!(!allowed_probe_response("\x1b[4;2$y")); // DECRPM, ANSI + assert!(!allowed_probe_response("\x1b[8;40;120t")); // window size + + // Denied: OSC-shaped replies (color/clipboard) and DCS. + assert!(!allowed_probe_response("\x1b]4;1;rgb:aa/bb/cc\x1b\\")); + assert!(!allowed_probe_response("\x1b]52;c;aGk=\x07")); + assert!(!allowed_probe_response("\x1bP>|term 1.0\x1b\\")); + + // Denied: malformed near-misses of allowed shapes. + assert!(!allowed_probe_response("\x1b[1R")); // CPR needs two fields + assert!(!allowed_probe_response("\x1b[1;2;3R")); + assert!(!allowed_probe_response("\x1b[;1R")); + assert!(!allowed_probe_response("\x1b[?c")); // DA needs params + assert!(!allowed_probe_response("\x1b[?6xc")); + + // Denied: arbitrary unknown responses. + assert!(!allowed_probe_response("\x1b[?9999;42z")); + assert!(!allowed_probe_response("unrecognized")); + assert!(!allowed_probe_response("")); + } + + /// End to end through `process`: the queries fleetcom answers produce + /// exactly their replies, nothing more. + #[test] + fn allowed_probe_queries_are_answered() { + let mut emu = Emulator::new(24, 80, 0); + // CPR reports the parse-time cursor position, 1-based. + assert!(emu.process(b"ab").is_empty()); + assert_eq!(emu.process(b"\x1b[6n"), vec!["\x1b[1;3R".to_string()]); + // DSR 5: status ok. + assert_eq!(emu.process(b"\x1b[5n"), vec!["\x1b[0n".to_string()]); + // Primary DA, both spellings. + assert_eq!(emu.process(b"\x1b[c"), vec!["\x1b[?6c".to_string()]); + assert_eq!(emu.process(b"\x1b[0c"), vec!["\x1b[?6c".to_string()]); + } + + /// End to end through `process`: queries outside the contract produce + /// nothing, even though the backend generates replies for them. + #[test] + fn denied_probe_queries_are_silenced() { + let mut emu = Emulator::new(24, 80, 0); + // Secondary DA: backend replies, allowlist drops it. + assert!(emu.process(b"\x1b[>c").is_empty()); + // DECRQM in both forms: DECRPM replies dropped. + assert!(emu.process(b"\x1b[?2026$p").is_empty()); + assert!(emu.process(b"\x1b[4$p").is_empty()); + // Window-size report dropped. + assert!(emu.process(b"\x1b[18t").is_empty()); + // Kitty keyboard query: disabled in config, no reply generated; the + // allowlist would drop the `ESC[?...u` shape regardless. + assert!(emu.process(b"\x1b[?u").is_empty()); + } + + /// The stall the flush hook exists for: BSU plus a partial frame, then + /// silence. The buffered frame must stay invisible while the timeout is + /// pending (the hook must not tear an in-flight frame) and flush once the + /// hook runs after expiry. + #[test] + fn stalled_sync_update_flushes_after_timeout() { + let mut emu = Emulator::new(4, 20, 0); + emu.process(b"before\x1b[?2026hafter"); + assert!(emu.contents().contains("before")); + assert!( + !emu.contents().contains("after"), + "sync update must buffer the frame" + ); + + // Not expired yet: the hook must not flush. + assert!(emu.flush_expired_sync().is_empty()); + assert!(!emu.contents().contains("after")); + + // vte's sync timeout is 150 ms; wait it out, then flush. + std::thread::sleep(Duration::from_millis(200)); + emu.flush_expired_sync(); + assert!( + emu.contents().contains("after"), + "expired sync must flush the buffered frame" + ); + + // The emulator parses normally after the forced flush. + emu.process(b" and on"); + 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] + fn mouse_modes_map_to_termmode_bits() { + let mut emu = Emulator::new(24, 80, 0); + assert_eq!(emu.mouse_protocol_mode(), MouseProtocolMode::None); + emu.process(b"\x1b[?1000h"); + assert_eq!(emu.mouse_protocol_mode(), MouseProtocolMode::PressRelease); + emu.process(b"\x1b[?1002h"); + assert_eq!(emu.mouse_protocol_mode(), MouseProtocolMode::ButtonMotion); + emu.process(b"\x1b[?1003h"); + assert_eq!(emu.mouse_protocol_mode(), MouseProtocolMode::AnyMotion); + emu.process(b"\x1b[?1003l"); + assert_eq!(emu.mouse_protocol_mode(), MouseProtocolMode::None); + + assert_eq!( + emu.mouse_protocol_encoding(), + MouseProtocolEncoding::Default + ); + emu.process(b"\x1b[?1005h"); + assert_eq!(emu.mouse_protocol_encoding(), MouseProtocolEncoding::Utf8); + emu.process(b"\x1b[?1006h"); + assert_eq!(emu.mouse_protocol_encoding(), MouseProtocolEncoding::Sgr); + emu.process(b"\x1b[?1006l"); + assert_eq!( + emu.mouse_protocol_encoding(), + MouseProtocolEncoding::Default + ); + } + + /// DECSET 9 (X10 press-only) is not modeled, so an X10-only child gets no + /// mouse reports. + #[test] + fn x10_decset9_is_not_modeled() { + let mut emu = Emulator::new(24, 80, 0); + emu.process(b"\x1b[?9h"); + assert_eq!(emu.mouse_protocol_mode(), MouseProtocolMode::None); + } + + /// The wheel-as-arrows gate requires the alternate screen and DECSET + /// 1007, which defaults on. + #[test] + fn alternate_scroll_requires_alt_screen_and_1007() { + let mut emu = Emulator::new(24, 80, 0); + assert!(!emu.alternate_scroll(), "primary screen never gates open"); + emu.process(b"\x1b[?1049h"); + assert!(emu.alternate_scroll(), "1007 defaults on"); + emu.process(b"\x1b[?1007l"); + assert!(!emu.alternate_scroll(), "the child's veto must stick"); + emu.process(b"\x1b[?1007h"); + assert!(emu.alternate_scroll()); + emu.process(b"\x1b[?1049l"); + assert!(!emu.alternate_scroll(), "leaving the alt screen closes it"); + } + + /// The clamp contract `scroll_view` relies on: absolute target capped at + /// retained history, `usize::MAX` → oldest stored row, 0 → live. + #[test] + fn scrollback_clamps_to_retained_history() { + let mut emu = Emulator::new(4, 10, 100); + for i in 0..12 { + emu.process(format!("l{i}\r\n").as_bytes()); + } + // 12 newlines on a 4-row screen, cursor starting at the top: the + // first 3 move the cursor, the remaining 9 scroll rows into history. + emu.set_scrollback(usize::MAX); + assert_eq!(emu.scrollback(), 9); + assert!( + emu.contents().starts_with("l0"), + "the oldest stored row must be displayed" + ); + emu.set_scrollback(3); + assert_eq!(emu.scrollback(), 3); + emu.set_scrollback(10_000); + assert_eq!(emu.scrollback(), 9, "over-scroll clamps at history"); + emu.set_scrollback(0); + assert_eq!(emu.scrollback(), 0); + } + + /// Retained text includes scrollback in chronological order and does not + /// change when the viewport scroll offset changes. + #[test] + fn text_with_history_includes_scrolled_off_rows() { + let mut emu = Emulator::new(4, 10, 100); + for i in 0..12 { + emu.process(format!("l{i}\r\n").as_bytes()); + } + // 12 newlines on a 4-row screen: the first rows are history now. + assert!(!emu.contents().contains("l0")); + let full = emu.text_with_history(); + assert!(full.starts_with("l0"), "oldest history row leads"); + assert!(full.contains("l11"), "the live screen is included"); + // 9 history rows plus the 4-row viewport, one line per row. + assert_eq!(full.split('\n').count(), 13); + // The view offset must not change what is reported. + emu.set_scrollback(usize::MAX); + assert_eq!(emu.text_with_history(), full); + } + + /// Soft wraps reconstruct one logical line without erasing explicit line + /// breaks. + #[test] + fn text_with_history_joins_soft_wrapped_rows() { + let mut emu = Emulator::new(6, 20, 100); + let hint = "claude --resume 123e4567-e89b-42d3-a456-426614174000"; + emu.process(format!("before\r\n{hint}\r\nafter").as_bytes()); + let full = emu.text_with_history(); + assert!( + full.contains(hint), + "52 chars over 3 rows at 20 columns must come back unbroken: {full:?}" + ); + // Explicit newlines still bound logical lines on both sides. + assert!(full.contains(&format!("before\n{hint}\nafter"))); + } + + /// A wrapped codex named-thread hint remains one logical line. + #[test] + fn text_with_history_joins_codex_hint_across_rows() { + let mut emu = Emulator::new(8, 40, 100); + let hint = "To continue this session, run codex resume, then select \ + mythic-otter (123e4567-e89b-42d3-a456-426614174000)"; + emu.process(hint.as_bytes()); + assert!( + emu.text_with_history().contains(hint), + "the hint spans 3 rows at 40 columns and must join unbroken" + ); + } + + /// Wrap markers travel with rows into scrollback: a wrapped line pushed + /// off the live screen still joins, including across the history to + /// viewport boundary. + #[test] + fn text_with_history_joins_wrapped_rows_in_scrollback() { + let mut emu = Emulator::new(4, 20, 100); + let hint = "claude --resume 123e4567-e89b-42d3-a456-426614174000"; + emu.process(format!("{hint}\r\n").as_bytes()); + for i in 0..6 { + emu.process(format!("pad {i}\r\n").as_bytes()); + } + let full = emu.text_with_history(); + assert!( + !emu.contents().contains("claude"), + "premise: the hint scrolled fully into history" + ); + assert!( + full.contains(hint), + "history rows keep their wrap markers: {full:?}" + ); + } + + /// Top-anchored region scrollback remains reachable after shrinking and + /// regrowing the grid, while new output continues to accumulate. + #[test] + fn region_scrolled_history_survives_resize() { + let mut emu = Emulator::new(40, 120, 2000); + for i in 1..=20 { + emu.process(format!("\x1b[{i};1Hseed {i:02}").as_bytes()); + } + // Insert history through newlines at a top-anchored region's bottom. + emu.process(b"\x1b[1;20r\x1b[20;1H"); + for i in 1..=30 { + emu.process(format!("\r\nhist {i:02}").as_bytes()); + } + emu.process(b"\x1b[r"); + emu.set_scrollback(usize::MAX); + assert_eq!(emu.scrollback(), 30); + assert!( + emu.contents().starts_with("seed 01"), + "oldest region-scrolled row heads the history" + ); + emu.set_scrollback(0); + + // Shrink, then keep inserting at the new geometry. Row counts shift + // with reflow (shrinking parks the excess viewport rows in history), + // so assert reachability and monotonic growth, not exact totals. + emu.resize(30, 100); + emu.process(b"\x1b[1;15r\x1b[15;1H"); + for i in 1..=20 { + emu.process(format!("\r\nmore {i:02}").as_bytes()); + } + emu.process(b"\x1b[r"); + emu.set_scrollback(usize::MAX); + let after_shrink = emu.scrollback(); + assert!( + after_shrink >= 50, + "history keeps accumulating at the new size: {after_shrink}" + ); + assert!( + emu.contents().starts_with("seed 01"), + "pre-resize history remains reachable" + ); + + // Growing back must not orphan anything either. + emu.set_scrollback(0); + emu.resize(40, 120); + emu.set_scrollback(usize::MAX); + assert!( + emu.contents().contains("seed 01"), + "history survives the round trip" + ); + let live = { + emu.set_scrollback(0); + emu.contents() + }; + assert!( + live.contains("more 20"), + "the newest insertion is on the live screen" + ); + } + + /// Total zero-width characters retained across the viewport and history. + fn total_zerowidth(emu: &Emulator) -> usize { + let grid = emu.term.grid(); + let top = -(grid.history_size() as i32); + let bottom = grid.screen_lines() as i32 - 1; + (top..=bottom) + .flat_map(|line| grid[Line(line)][..].iter()) + .map(|cell| cell.zerowidth().map_or(0, <[char]>::len)) + .sum() + } + + /// A full interval of combining marks on one cell is capped before + /// `process` returns. + #[test] + fn zerowidth_spam_on_one_cell_is_capped() { + let mut emu = Emulator::new(4, 10, 0); + emu.process(b"a"); + // U+0301 is two UTF-8 bytes, making this chunk one full interval. + let chunk = "\u{0301}".repeat(SWEEP_INTERVAL_BYTES / 2); + + emu.process(chunk.as_bytes()); + let len = emu.term.grid()[Line(0)][Column(0)] + .zerowidth() + .map_or(0, <[char]>::len); + assert!( + len <= MAX_ZEROWIDTH, + "hot cell retains {len} marks after process returned" + ); + + // A second interval on the same cell is capped independently. + emu.process(chunk.as_bytes()); + assert!( + total_zerowidth(&emu) <= MAX_ZEROWIDTH, + "marks retained beyond the single spammed cell" + ); + } + + /// A scan caps combining marks in every cell across a populated row. + #[test] + fn zerowidth_spray_across_cells_is_capped() { + let mut emu = Emulator::new(4, 80, 0); + let marks = "\u{0301}".repeat(2048); + let mut payload = String::new(); + for col in 1..=80 { + payload.push_str(&format!("\x1b[2;{col}Hx")); + payload.push_str(&marks); + } + assert!( + payload.len() >= SWEEP_INTERVAL_BYTES, + "payload must cross the sweep interval in one call" + ); + emu.process(payload.as_bytes()); + + let grid = emu.term.grid(); + for col in 0..80 { + let z = grid[Line(1)][Column(col)] + .zerowidth() + .expect("sprayed cell lost its marks entirely"); + // Exactly the cap: truncation keeps the first marks, it does not + // clear the cell. + assert_eq!(z.len(), MAX_ZEROWIDTH, "column {col}"); + assert!(z.iter().all(|&m| m == '\u{0301}')); + } + assert!(total_zerowidth(&emu) <= 80 * MAX_ZEROWIDTH); + } + + /// A scan triggered by unrelated output preserves an under-limit styled + /// cluster and all of its cell attributes. + #[test] + fn legitimate_cluster_survives_sweep_untouched() { + let mut emu = Emulator::new(4, 80, 0); + let cluster = "\x1b]8;;https://example.com\x1b\\\ + \x1b[1;4;31;44m\x1b[58;5;42m\ + e\u{0301}\u{0302}\u{0304}\ + \x1b[0m\x1b]8;;\x1b\\"; + emu.process(cluster.as_bytes()); + + // CUP keeps the filler on row 2 while enough bytes trigger a scan. + let filler = format!("\x1b[2;1H{}", "x".repeat(64)).repeat(1024); + let mut fed = cluster.len(); + while fed < SWEEP_INTERVAL_BYTES { + emu.process(filler.as_bytes()); + fed += filler.len(); + } + + let cell = &emu.term.grid()[Line(0)][Column(0)]; + assert_eq!(cell.c, 'e'); + assert_eq!( + cell.zerowidth(), + Some(&['\u{0301}', '\u{0302}', '\u{0304}'][..]) + ); + assert_eq!(cell.fg, Color::Named(NamedColor::Red)); + assert_eq!(cell.bg, Color::Named(NamedColor::Blue)); + assert!(cell.flags.contains(Flags::BOLD | Flags::UNDERLINE)); + assert_eq!(cell.underline_color(), Some(Color::Indexed(42))); + assert_eq!( + cell.hyperlink().map(|h| h.uri().to_owned()), + Some("https://example.com".to_owned()) + ); + } + + /// A scan also caps a cell that entered scrollback before the threshold. + #[test] + fn history_cells_are_swept() { + let mut emu = Emulator::new(4, 10, 100); + // This oversized cluster remains below the scan threshold. + let spam = format!("h{}", "\u{0301}".repeat(4096)); + emu.process(spam.as_bytes()); + emu.process(b"\r\n\r\n\r\n\r\n\r\n\r\n"); + + // Confirm that the oversized cell reached history before the scan. + let find_h = |emu: &Emulator| -> (i32, usize) { + let grid = emu.term.grid(); + let top = -(grid.history_size() as i32); + (top..0) + .find_map(|line| { + let cell = &grid[Line(line)][Column(0)]; + (cell.c == 'h').then(|| (line, cell.zerowidth().map_or(0, <[char]>::len))) + }) + .expect("spammed row must be in history") + }; + let (line, len) = find_h(&emu); + assert!(line < 0); + assert_eq!(len, 4096, "excess must predate the sweep"); + + // CUP keeps filler on the last row so the history position is stable. + let filler = "\x1b[4;1Hxxxxxxxx".repeat(1024); + let mut fed = spam.len() + 12; + while fed < SWEEP_INTERVAL_BYTES { + emu.process(filler.as_bytes()); + fed += filler.len(); + } + + let (line_after, len_after) = find_h(&emu); + 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 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); + emu.process(b"\x1b]0;app\x07\x1b[?1049h"); + assert_eq!(emu.alt_epoch(), 1); + assert_eq!(emu.title(), Some("app")); + } + + /// 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); + 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, + "printed output disclaimed the staged title" + ); + } + + /// 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] { + 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); + } + + /// 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); + 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 + /// 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: 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_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); + 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")); + } + + /// 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); + 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"); + } + + /// 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 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); + 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"); + } + + /// 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); + 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] + 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(), ""); + } +} 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; diff --git a/src/ui.rs b/src/ui.rs index 124a5d4..7242cb0 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,9 @@ fn row_age(v: &TaskView) -> Duration { edge.unwrap_or(v.started_ago) } -fn task_row(v: &TaskView, cols: usize) -> String { +/// 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 => "✻", Lifecycle::Idle => "∙", @@ -320,15 +326,34 @@ 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 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); + 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 +409,35 @@ fn render_peek(out: &mut impl Write, app: &App) -> io::Result<()> { MoveTo(x0 as u16, by), Print(format!("└{}┘", "─".repeat(inner_w))) )?; + // The peek footer identifies the preview source and in-process matcher. + 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. Examples: `title`, `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 +728,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 +795,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() { diff --git a/tests/corpus/README.md b/tests/corpus/README.md index 78d15cc..29ecd01 100644 --- a/tests/corpus/README.md +++ b/tests/corpus/README.md @@ -28,6 +28,46 @@ 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 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. Identifying +and user-configured text is replaced with alignment-preserving synthetic +values. Geometry is 40×120 unless noted. + +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 | +| --- | --- | --- | +| `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 | 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 | the scan stops at the reply bullet and resolves to the floor tier | +| `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 | +| `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 | +| `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 | 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 | the structure check fails and resolves to the marker | + ## What the fixtures prove The fixtures provide evidence for three distinct boundaries: @@ -43,4 +83,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_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 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_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 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