diff --git a/src/app.rs b/src/app.rs index 0ed90a6..cae075b 100644 --- a/src/app.rs +++ b/src/app.rs @@ -1383,9 +1383,9 @@ impl App { // Avoid closing the client connection with an oversized frame. if s.len() > MAX_PASTE { self.status = Some(format!( - "paste dropped: {} MiB exceeds the {} MiB limit", - s.len() >> 20, - MAX_PASTE >> 20 + "paste dropped: {} exceeds the {} limit", + crate::format::bytes(s.len()), + crate::format::bytes(MAX_PASTE) )); return; } diff --git a/src/app_tests.rs b/src/app_tests.rs index 57f1c10..d3701a0 100644 --- a/src/app_tests.rs +++ b/src/app_tests.rs @@ -1105,7 +1105,8 @@ fn oversized_paste_is_refused_with_a_notice() { app.focused_id = Some(1); app.on_paste(&"x".repeat(MAX_PASTE + 1)); let status = app.status.clone().unwrap_or_default(); - assert!(status.contains("paste dropped"), "status was {status:?}"); + // MiB values are truncated, so both sizes display as 8 MiB. + assert_eq!(status, "paste dropped: 8 MiB exceeds the 8 MiB limit"); // The boundary value is accepted. app.on_paste(&"x".repeat(MAX_PASTE)); assert!(app.status.is_none(), "boundary paste must not be refused"); diff --git a/src/harness/assets.rs b/src/harness/assets.rs index d37b7de..53f3ab8 100644 --- a/src/harness/assets.rs +++ b/src/harness/assets.rs @@ -205,10 +205,11 @@ mod tests { process::{Command, Stdio}, }; - use super::super::testutil::ID; - use super::super::{CAPTURE_ENV, NOTIFY_CHAIN_ENV}; use super::*; - use crate::testutil::{install_fake_notifier, temp, write_executable}; + use crate::{ + harness::{CAPTURE_ENV, NOTIFY_CHAIN_ENV, fixtures::ID}, + testutil::{dead_pid, install_fake_notifier, temp, write_executable}, + }; fn mode(p: &Path) -> u32 { fs::metadata(p).unwrap().permissions().mode() & 0o777 @@ -366,14 +367,6 @@ mod tests { let _ = fs::remove_dir_all(&root); } - /// Spawn and reap a child, then return its inactive PID. - fn dead_pid() -> u32 { - let mut child = Command::new("sh").arg("-c").arg("exit 0").spawn().unwrap(); - let pid = child.id(); - child.wait().unwrap(); - pid - } - /// Installation removes a dead owner's namespace and its contents. #[test] fn install_reaps_a_dead_owner_namespace() { diff --git a/src/harness/claude.rs b/src/harness/claude.rs index b650fb9..de43a09 100644 --- a/src/harness/claude.rs +++ b/src/harness/claude.rs @@ -88,9 +88,11 @@ fn slug(cwd: &Path) -> Option { mod tests { use std::{fs, path::PathBuf}; - use super::super::testutil::{ID, OTHER, assert_all_opaque, assert_corpus_scrape, paths}; use super::*; - use crate::testutil::temp; + use crate::{ + harness::fixtures::{ID, OTHER, assert_all_opaque, assert_corpus_scrape, paths}, + testutil::temp, + }; /// Claude-specific opaque shapes: flags, `--continue`/`-c`, subcommands, /// the short/`=` resume spellings, and `--session-id`. The syntax shared diff --git a/src/harness/codex.rs b/src/harness/codex.rs index 84b501b..8a431c3 100644 --- a/src/harness/codex.rs +++ b/src/harness/codex.rs @@ -353,9 +353,11 @@ fn line1_cwd_matches(path: &Path, cwd: &Path) -> bool { mod tests { use std::path::PathBuf; - use super::super::testutil::{OTHER, assert_all_opaque, assert_corpus_scrape, paths}; use super::*; - use crate::testutil::{temp, v7_at, write_rollout}; + use crate::{ + harness::fixtures::{OTHER, assert_all_opaque, assert_corpus_scrape, paths}, + testutil::{temp, v7_at, write_rollout}, + }; /// Codex's own launch and resume commands carry v7 IDs; the shared v4 /// fixture stays valid for detection, which is version-agnostic. diff --git a/src/harness/grok.rs b/src/harness/grok.rs index 9b382b6..96ba3e7 100644 --- a/src/harness/grok.rs +++ b/src/harness/grok.rs @@ -85,10 +85,14 @@ fn encode_cwd(cwd: &Path) -> Option { mod tests { use std::fs; - use super::super::is_uuid; - use super::super::testutil::{ID, OTHER, assert_all_opaque, assert_corpus_scrape, paths}; use super::*; - use crate::testutil::temp; + use crate::{ + harness::{ + fixtures::{ID, OTHER, assert_all_opaque, assert_corpus_scrape, paths}, + is_uuid, + }, + testutil::temp, + }; /// Grok-specific opaque shapes: flags, the `-r`/`-s`/`=` spellings the /// tool prints but detection refuses, and subcommands. The syntax shared diff --git a/src/harness/mod.rs b/src/harness/mod.rs index c145e22..9cd7f93 100644 --- a/src/harness/mod.rs +++ b/src/harness/mod.rs @@ -316,7 +316,7 @@ fn shell_quote(s: &str) -> String { /// Fixtures and assertions for harness detection and exit scraping. #[cfg(test)] -pub(crate) mod testutil { +pub(crate) mod fixtures { use std::path::PathBuf; use super::{CapturePaths, Harness}; @@ -358,8 +358,10 @@ pub(crate) mod testutil { #[cfg(test)] mod tests { - use super::testutil::{ID, OTHER}; - use super::*; + use super::{ + fixtures::{ID, OTHER}, + *, + }; /// Harness, program word, selector, and path prefix for the shape tests /// shared by every harness. Codex's resume selector is a subcommand, not diff --git a/src/harness/summary.rs b/src/harness/summary.rs index 48e793a..9f34c53 100644 --- a/src/harness/summary.rs +++ b/src/harness/summary.rs @@ -27,7 +27,7 @@ use std::path::Path; -use crate::preview::{ScreenFacts, SummaryAdapter}; +use crate::preview::SummaryAdapter; /// Select an adapter by the basename of the command's first /// whitespace-separated word. Arguments are accepted; environment prefixes @@ -107,17 +107,16 @@ const CLAUDE_STATUS_WINDOW: usize = 16; 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), + fn live_preview(&self, rows: &[String]) -> Option<(String, &'static str)> { + 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), + None => claude_approval(rows), } } - fn model_label(&self, screen: &dyn ScreenFacts) -> Option { - claude_welcome_label(&screen.live_rows()) + fn model_label(&self, rows: &[String]) -> Option { + claude_welcome_label(rows) } /// Canonicalize a leading claude spinner or braille frame to `✻` so title @@ -307,18 +306,16 @@ fn claude_welcome_label(rows: &[String]) -> Option { 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) { + fn live_preview(&self, rows: &[String]) -> Option<(String, &'static str)> { + if let Some(hit) = codex_approval(rows) { return Some(hit); } - let composer = codex_composer(&rows)?; - codex_status(&rows, composer) + 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)?; + fn model_label(&self, rows: &[String]) -> Option { + let token = codex_token_line(rows)?; // `codex_token_line` guarantees a non-empty first segment. Some(rows[token].trim().split(" · ").next()?.to_string()) } @@ -428,9 +425,8 @@ fn codex_working(after_paren: &str) -> String { 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)?; + fn live_preview(&self, rows: &[String]) -> Option<(String, &'static str)> { + 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. @@ -444,9 +440,8 @@ impl SummaryAdapter for GrokSummary { 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)?; + fn model_label(&self, rows: &[String]) -> Option { + let (_, bottom) = grok_input_box(rows)?; grok_border_label(&rows[bottom]) } } diff --git a/src/harness/summary_tests.rs b/src/harness/summary_tests.rs index 61ebda9..8abae7a 100644 --- a/src/harness/summary_tests.rs +++ b/src/harness/summary_tests.rs @@ -4,71 +4,47 @@ use super::*; use crate::{ emulator::Emulator, preview::{MARKER, PreviewState, SummaryAdapter}, - protocol::{Preview, PreviewSource}, + protocol::PreviewSource, }; -/// Synthetic screen: adapters read only `live_rows`, so the other facts -/// are inert defaults. -struct RowsScreen { - rows: Vec, +/// Build a synthetic live viewport for an adapter. +fn rs(rows: &[&str]) -> Vec { + rows.iter().map(|s| s.to_string()).collect() } -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); +/// Place status rows above a 120-column Claude input box. +fn claude_screen>(above: &[S]) -> Vec { + let sep = "─".repeat(120); + let mut rows: Vec = above.iter().map(|s| s.as_ref().to_string()).collect(); + rows.extend([sep.clone(), "❯".to_string(), sep]); + rows +} + +/// Resolve a corpus fixture at 40 rows and return its text, source, and rule. +fn corpus( + bytes: &[u8], + adapter: &dyn SummaryAdapter, + cols: u16, +) -> (String, PreviewSource, Option<&'static str>) { + let mut emu = Emulator::new(40, cols, 2000); emu.process(bytes); let mut st = PreviewState::new(); - st.resolve(Instant::now(), &emu, Some(adapter)).clone() + let p = st.resolve(Instant::now(), &emu, Some(adapter)); + (p.text.clone(), p.source, p.rule) } 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) +/// Expected alternate-screen marker preview. +fn marker() -> (String, PreviewSource, Option<&'static str>) { + (MARKER.to_string(), PreviewSource::Marker, None) +} + +/// Expected floor-preview tuple. +fn floor(text: &str) -> (String, PreviewSource, Option<&'static str>) { + (text.to_string(), PreviewSource::Floor, None) } /// Selection is a basename match on the first word only: wider than @@ -128,20 +104,18 @@ fn select_routes_to_the_matching_adapter() { /// 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"]); + // Rows below the input box are outside the status scan. + let mut spin = claude_screen(&["✻ Hashing… (6s · ↓ 87 tokens)"]); + spin.push(" status".to_string()); assert_eq!( ClaudeSummary.live_preview(&spin), Some(("Hashing…".to_string(), "claude:spinner")) ); - let action = rs(&[ + let action = claude_screen(&[ "⏺ Running 1 shell command…", "", "· Hashing… (3s · ↓ 52 tokens)", - &sep, - "❯", - &sep, ]); assert_eq!( ClaudeSummary.live_preview(&action), @@ -149,13 +123,10 @@ fn claude_spinner_and_action_row() { ); // An indented attachment above the spinner is not the action row. - let attach = rs(&[ + let attach = claude_screen(&[ " Running 1 shell command…", " ⎿ $ sleep 5 && echo ok", "✻ Hashing… (6s)", - &sep, - "❯", - &sep, ]); assert_eq!( ClaudeSummary.live_preview(&attach), @@ -163,7 +134,7 @@ fn claude_spinner_and_action_row() { ); // A `⏺` reply row without a trailing ellipsis is not the action row. - let reply = rs(&["⏺ ok", "", "✻ Hashing… (2s)", &sep, "❯", &sep]); + let reply = claude_screen(&["⏺ ok", "", "✻ Hashing… (2s)"]); assert_eq!( ClaudeSummary.live_preview(&reply), Some(("Hashing…".to_string(), "claude:spinner")) @@ -174,12 +145,8 @@ fn claude_spinner_and_action_row() { /// digits; extraction keeps everything through the first ellipsis. #[test] fn claude_spinner_extracts_task_derived_phrases() { - let sep = "─".repeat(120); - let s = rs(&[ + let s = claude_screen(&[ "✳ Overseeing phase 4 (adapters)… (54s · almost done thinking with high effort)", - &sep, - "❯", - &sep, ]); assert_eq!( ClaudeSummary.live_preview(&s), @@ -194,11 +161,7 @@ fn claude_spinner_extracts_task_derived_phrases() { /// 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)) - }; + let spin = |row: &str| ClaudeSummary.live_preview(&claude_screen(&[row])); assert_eq!( spin("✻ Envisioning… (1m 8s · ↓ 2.1k tokens · thinking with high effort)"), Some(( @@ -239,17 +202,13 @@ fn claude_parenthetical_keeps_slow_segments_and_drops_tickers() { /// still contributes the semantic tail. #[test] fn claude_action_row_carries_the_spinner_rows_semantic_tail() { - let sep = "─".repeat(120); - let rows = [ + let screen = claude_screen(&[ "⏺ Running 1 shell command…", "", "✻ Envisioning… (1m 8s · ↓ 2.1k tokens · thinking with high effort)", - &sep, - "❯", - &sep, - ]; + ]); assert_eq!( - ClaudeSummary.live_preview(&rs(&rows)), + ClaudeSummary.live_preview(&screen), Some(( "Running 1 shell command… · thinking with high effort".to_string(), "claude:action-row" @@ -261,11 +220,7 @@ fn claude_action_row_carries_the_spinner_rows_semantic_tail() { /// trigger an action-row lookup. #[test] fn claude_waiting_family_matches_the_skeleton_and_never_probes() { - let sep = "─".repeat(120); - let spin = |row: &str| { - let rows = [row, &sep, "❯", &sep]; - ClaudeSummary.live_preview(&rs(&rows)) - }; + let spin = |row: &str| ClaudeSummary.live_preview(&claude_screen(&[row])); for row in [ "✻ Waiting for 1 background agent to finish", "· Waiting for 1 background agent to finish", @@ -292,16 +247,13 @@ fn claude_waiting_family_matches_the_skeleton_and_never_probes() { } // Waiting rows return without probing the action row above them. - let rows = [ + let screen = claude_screen(&[ "⏺ Running 1 shell command…", "", "✻ Waiting for 1 background agent to finish", - &sep, - "❯", - &sep, - ]; + ]); assert_eq!( - ClaudeSummary.live_preview(&rs(&rows)), + ClaudeSummary.live_preview(&screen), Some(( "Waiting for 1 background agent to finish".to_string(), "claude:waiting" @@ -380,14 +332,11 @@ fn claude_aborts_on_foreign_column_zero_rows() { /// The status scan crosses bounded indented gaps but stops at body prose. #[test] fn claude_scan_crosses_task_list_gaps_within_the_window() { - let sep = "─".repeat(120); let behind_gap = |status: &str, gap: usize| { let mut rows = vec![status.to_string()]; rows.push(" ⎿ ✔ Phase 0: verify facts".to_string()); rows.extend((1..gap).map(|i| format!(" ◼ Phase {i}: generic step"))); - rows.extend([sep.clone(), "❯".to_string(), sep.clone()]); - let refs: Vec<&str> = rows.iter().map(String::as_str).collect(); - ClaudeSummary.live_preview(&rs(&refs)) + ClaudeSummary.live_preview(&claude_screen(&rows)) }; for gap in [4, 15] { assert_eq!( @@ -423,14 +372,11 @@ fn claude_scan_crosses_task_list_gaps_within_the_window() { ); // Column-0 body prose invalidates the status structure. - let prose = rs(&[ + let prose = claude_screen(&[ "✢ Running phase 1 (dashboard UI)… (4m 20s · ↓ 17.1k tokens)", "⏺ The phase list below is queued, not running.", " ⎿ ✔ Phase 0: verify facts", " ◼ Phase 1: dashboard polish", - &sep, - "❯", - &sep, ]); assert_eq!(ClaudeSummary.live_preview(&prose), None); } @@ -438,19 +384,11 @@ fn claude_scan_crosses_task_list_gaps_within_the_window() { /// Blank rows do not consume the nonblank-row window. #[test] fn claude_blank_rows_do_not_consume_the_window() { - let sep = "─".repeat(120); - let resolve = |rows: Vec| { - let refs: Vec<&str> = rows.iter().map(String::as_str).collect(); - ClaudeSummary.live_preview(&rs(&refs)) - }; - let boxed = |sep: &str| [sep.to_string(), "❯ /workflows".to_string(), sep.to_string()]; - // Nineteen blank rows separate the waiting row from the input box. let mut rows = vec!["✻ Waiting for 1 dynamic workflow to finish".to_string()]; rows.extend(std::iter::repeat_n(String::new(), 19)); - rows.extend(boxed(&sep)); assert_eq!( - resolve(rows), + ClaudeSummary.live_preview(&claude_screen(&rows)), Some(( "Waiting for 1 dynamic workflow to finish".to_string(), "claude:waiting" @@ -464,9 +402,8 @@ fn claude_blank_rows_do_not_consume_the_window() { rows.push(String::new()); rows.push(format!(" ◼ Phase {i}: generic step")); } - rows.extend(boxed(&sep)); assert_eq!( - resolve(rows), + ClaudeSummary.live_preview(&claude_screen(&rows)), Some(( "Running phase 1 (dashboard UI)…".to_string(), "claude:spinner" @@ -479,20 +416,16 @@ fn claude_blank_rows_do_not_consume_the_window() { rows.push(String::new()); rows.push(format!(" ◼ Phase {i}: generic step")); } - rows.extend(boxed(&sep)); - assert_eq!(resolve(rows), None); + assert_eq!(ClaudeSummary.live_preview(&claude_screen(&rows)), None); // An intervening column-0 prose row still aborts the scan. - let prose = rs(&[ + let prose = claude_screen(&[ "✻ Hashing… (6s · ↓ 87 tokens)", "", "", "⏺ The workflow report lands below.", "", "", - &sep, - "❯ /workflows", - &sep, ]); assert_eq!(ClaudeSummary.live_preview(&prose), None); } @@ -850,8 +783,8 @@ fn corpus_positive_states_anchor_exactly() { ), ]; 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}"); + let got = corpus(bytes, adapter, 120); + assert_eq!(got, anchor(text, rule), "{name}"); } } @@ -882,12 +815,8 @@ fn corpus_idle_states_fall_through() { ), ]; 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}" - ); + let got = corpus(bytes, adapter, 120); + assert_eq!(got, marker(), "{name}"); } } @@ -897,82 +826,58 @@ fn corpus_idle_states_fall_through() { #[test] fn corpus_body_shaped_text_never_extracts() { // Menu in the body, spinner live: the pinned spinner wins. - let p = resolve_corpus( + let got = corpus( include_bytes!("../../tests/corpus/preview_claude_body_menu.bin"), &ClaudeSummary, - 40, 120, ); - assert_eq!( - parts(&p), - anchor("Fable 5 (high) · Hashing…", "claude:spinner") - ); + assert_eq!(got, anchor("Fable 5 (high) · Hashing…", "claude:spinner")); // Menu touching the chrome window on an idle screen: abort, marker. - let p = resolve_corpus( + let got = corpus( include_bytes!("../../tests/corpus/preview_claude_body_menu_idle.bin"), &ClaudeSummary, - 40, 120, ); - assert_eq!(parts(&p), (MARKER.to_string(), PreviewSource::Marker, None)); + assert_eq!(got, marker()); // Body prose between spinner-shaped text and the task list yields the marker. - let p = resolve_corpus( + let got = corpus( include_bytes!("../../tests/corpus/preview_claude_body_above_tasklist.bin"), &ClaudeSummary, - 40, 120, ); - assert_eq!(parts(&p), (MARKER.to_string(), PreviewSource::Marker, None)); + assert_eq!(got, marker()); // 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( + let got = 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 - ) + got, + floor("gpt-5.6-sol high · 5.26K used · 28.2K in · 78 out") ); // 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( + let got = 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 - ) - ); + assert_eq!(got, floor("gpt-5.6-sol high · 0 in · 0 out")); // `• Ran` visible mid-turn with `• Working` at the pin: live wins. - let p = resolve_corpus( + let got = 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") - ); + assert_eq!(got, anchor("gpt-5.6-sol high · Working", "codex:working")); } /// 80-column truncation: the CLIs cut their status rows at a word @@ -980,37 +885,34 @@ fn corpus_body_shaped_text_never_extracts() { /// the kept suffix keeps that ellipsis verbatim. #[test] fn corpus_truncated_rows_still_anchor() { - let p = resolve_corpus( + let got = 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")); + assert_eq!(got, anchor("Hashing…", "claude:spinner")); - let p = resolve_corpus( + let got = corpus( include_bytes!("../../tests/corpus/preview_trunc_codex.bin"), &CodexSummary, - 40, 80, ); assert_eq!( - parts(&p), + got, anchor( "gpt-5.6-sol high · Working · 1 background terminal running", "codex:working" ) ); - let p = resolve_corpus( + let got = corpus( include_bytes!("../../tests/corpus/preview_trunc_grok.bin"), &GrokSummary, - 40, 80, ); assert_eq!( - parts(&p), + got, anchor( "Grok 4.5 (xhigh) · Sleep 5 seconds then echo…", "grok:spinner" @@ -1022,13 +924,12 @@ fn corpus_truncated_rows_still_anchor() { /// resolves to the alternate-screen marker. #[test] fn corpus_wrapped_ellipsis_falls_through() { - let p = resolve_corpus( + let got = corpus( include_bytes!("../../tests/corpus/preview_wrap_grok.bin"), &GrokSummary, - 40, 30, ); - assert_eq!(parts(&p), (MARKER.to_string(), PreviewSource::Marker, None)); + assert_eq!(got, marker()); } /// Non-agent TUIs on the alternate screen resolve through the diff --git a/src/main.rs b/src/main.rs index 0f9ee4c..76dcb53 100644 --- a/src/main.rs +++ b/src/main.rs @@ -199,8 +199,7 @@ fn run() -> io::Result<()> { // The interactive client requires stdout for terminal frames. Headless // and informational modes return before this check. if !io::stdout().is_terminal() { - eprintln!("{}", error_line("stdout is not a terminal")); - std::process::exit(1); + return Err(io::Error::other("stdout is not a terminal")); } // Install the value before constructing or autostarting a supervisor. @@ -218,13 +217,8 @@ fn run() -> io::Result<()> { let mut app = if foreground { App::new_foreground(rows, cols) } else { - match App::connect(rows, cols) { - Ok(a) => a, - Err(e) => { - eprintln!("fleetcom: could not reach the daemon: {e}"); - std::process::exit(1); - } - } + App::connect(rows, cols) + .map_err(|e| io::Error::other(format!("could not reach the daemon: {e}")))? }; // Keep SIGINT's default behavior while a connection is waiting, then route diff --git a/src/preview.rs b/src/preview.rs index e81ee20..1276197 100644 --- a/src/preview.rs +++ b/src/preview.rs @@ -70,12 +70,13 @@ impl ScreenFacts for Emulator { /// 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)>; + /// chrome is present. `rows` contains live rows with trailing padding + /// removed. + fn live_preview(&self, rows: &[String]) -> 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; + fn model_label(&self, rows: &[String]) -> Option; /// Optionally normalize a captured title for display. Emulator title /// capture remains program-agnostic; `None` renders the title verbatim. @@ -91,19 +92,21 @@ pub trait SummaryAdapter: Sync { /// 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 let Some(a) = adapter { + // Both probes use the same viewport snapshot. + let rows = screen.live_rows(); + if let Some((text, rule)) = a.live_preview(&rows) { + let text = match a.model_label(&rows) { + 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() { @@ -233,8 +236,7 @@ impl PreviewState { match self.candidate.source.cmp(&self.rendered.source) { Ordering::Greater => { self.cancel_demotion(); - let cand = self.candidate.clone(); - self.render(cand, now, alt); + self.render(self.candidate.clone(), now, alt); } Ordering::Equal => { // A recovered rank cancels a pending demotion without a @@ -254,16 +256,14 @@ impl PreviewState { .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); + self.render(self.candidate.clone(), 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); + self.render(self.candidate.clone(), now, alt); } } } @@ -429,11 +429,11 @@ mod tests { } impl SummaryAdapter for StubAdapter { - fn live_preview(&self, _screen: &dyn ScreenFacts) -> Option<(String, &'static str)> { + fn live_preview(&self, _rows: &[String]) -> Option<(String, &'static str)> { self.live.map(|(text, rule)| (text.to_string(), rule)) } - fn model_label(&self, _screen: &dyn ScreenFacts) -> Option { + fn model_label(&self, _rows: &[String]) -> Option { self.label.map(str::to_string) } } diff --git a/src/protocol.rs b/src/protocol.rs index db00dfa..ae79158 100644 --- a/src/protocol.rs +++ b/src/protocol.rs @@ -205,14 +205,14 @@ pub enum Event { }, } -/// Clipboard target carried by an OSC 52 event. +/// Clipboard target identified by an OSC 52 selector byte. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum ClipboardKind { - /// The system clipboard (OSC 52 kind byte `c`). + /// The system clipboard (selector byte `c`). Clipboard, - /// The primary selection (OSC 52 kind byte `p`). + /// The primary selection (selector byte `p`). Primary, - /// The select buffer (OSC 52 kind byte `s`). + /// The select buffer (selector byte `s`). Selection, } diff --git a/src/selection.rs b/src/selection.rs index e26b950..dd7b497 100644 --- a/src/selection.rs +++ b/src/selection.rs @@ -42,35 +42,36 @@ impl Selection { /// /// Rows below the screen clamp to its last row. Columns beyond a row select /// no text, and an empty screen produces an empty string. + /// + /// Each selected screen row occupies one joined line, even when its + /// selected span is empty. pub fn extract(&self, rows: &[String]) -> String { let Some(last) = rows.len().checked_sub(1) else { return String::new(); }; let (start, end) = self.bounds(last); - let mut out = String::new(); - for (row, text) in rows.iter().enumerate().take(end.0 + 1).skip(start.0) { - if row > start.0 { - out.push('\n'); - } - let from = if row == start.0 { start.1 } else { 0 }; - let to = (row == end.0).then_some(end.1); - out.push_str(segment(text, from, to).trim_end()); - } - out + // `bounds` clamps both rows to `last`, so indexing `rows` is in range. + (start.0..=end.0) + .map(|row| { + self.row_segment(row, &rows[row], last) + .map_or("", |(_, seg)| seg) + }) + .collect::>() + .join("\n") } /// Return the selected text on `row` and its starting display column. - /// Uses the same endpoint ordering, row clamping, wide-glyph expansion, - /// and trailing-whitespace removal as [`Selection::extract`]. Returns - /// `None` outside the selection or when the row's selected span is empty. + /// + /// Endpoints are ordered and clamped before wide glyphs are expanded and + /// trailing whitespace is removed. Returns `None` outside the selection + /// or when the selected span is empty after trimming. pub fn row_segment<'a>( &self, - row: u16, + row: usize, text: &'a str, last_row: usize, ) -> Option<(u16, &'a str)> { let (start, end) = self.bounds(last_row); - let row = row as usize; if row < start.0 || row > end.0 { return None; } @@ -125,11 +126,6 @@ fn segment_span(row: &str, from: usize, to: Option) -> Option<(usize, &st start.map(|(s, c)| (c, &row[s..end])) } -/// [`segment_span`] without the column, for whole-selection extraction. -fn segment(row: &str, from: usize, to: Option) -> &str { - segment_span(row, from, to).map_or("", |(_, s)| s) -} - #[cfg(test)] mod tests { use super::*; @@ -281,9 +277,9 @@ mod tests { } #[test] - fn row_segment_clamps_like_extract() { - // Both endpoints below a two-row screen collapse onto the bottom row, - // matching `extract`'s clamping (including the ordering inversion). + fn clamping_below_the_screen_inverts_endpoint_order() { + // Both endpoints clamp to the bottom row before their columns are + // ordered; row 0 is outside the resulting selection. let s = drag((5, 1), (9, 0)); assert_eq!(s.extract(&screen(&["ab", "cd"])), "cd"); assert_eq!(s.row_segment(0, "ab", 1), None); diff --git a/src/session.rs b/src/session.rs index 6368def..57342d6 100644 --- a/src/session.rs +++ b/src/session.rs @@ -448,7 +448,7 @@ fn prune_recovery(dir: &Path, keep_stem: &str) { #[cfg(test)] mod tests { use super::*; - use crate::testutil::temp; + use crate::testutil::{dead_pid, temp}; /// Unadorned entry: the plain-string member form. fn e(cmd: &str) -> SessionEntry { @@ -846,18 +846,6 @@ mod tests { /// Out-of-range PID used for dead-writer fixtures. const DEAD_FIXTURE_PID: u32 = 9_999_999; - /// Spawn and reap a child, then return its inactive PID. - fn dead_child_pid() -> u32 { - let mut child = std::process::Command::new("sh") - .arg("-c") - .arg("exit 0") - .spawn() - .unwrap(); - let pid = child.id(); - child.wait().unwrap(); - pid - } - /// The stem is `-`; the label is the write minute. #[test] fn recovery_stem_and_label_render_utc() { @@ -1013,7 +1001,7 @@ mod tests { let rec = recovery_dir(&base); let mut cfg = SessionConfig::new(); cfg.insert("~/p".into(), vec![e("vim")]); - let oldest = format!("20260101-000000-{}", dead_child_pid()); + let oldest = format!("20260101-000000-{}", dead_pid()); save_recovery_in(&rec, &oldest, "autosaved 2026-01-01 00:00", &cfg).unwrap(); for i in 1..=10u32 { let stem = format!("20260714-0930{i:02}-{DEAD_FIXTURE_PID}"); diff --git a/src/supervisor.rs b/src/supervisor.rs index 00a4bc0..0d151da 100644 --- a/src/supervisor.rs +++ b/src/supervisor.rs @@ -15,12 +15,9 @@ use std::{ use crate::{ core::{Wake, Waker}, - emulator::ClipboardSelector, harness::{self, assets}, path, - protocol::{ - ClipboardKind, Command, Event, LaunchContext, ScreenView, ScrollAction, TaskView, env_get, - }, + protocol::{Command, Event, LaunchContext, ScreenView, ScrollAction, TaskView, env_get}, session::{self, SessionConfig, SessionEntry}, task::{Task, WriteRefused}, }; @@ -125,15 +122,6 @@ fn normalize_group(name: Option) -> Option { normalize_label(name).filter(|g| g != "Unassigned") } -/// Map an emulator clipboard selector to its protocol representation. -fn clipboard_kind(kind: ClipboardSelector) -> ClipboardKind { - match kind { - ClipboardSelector::Clipboard => ClipboardKind::Clipboard, - ClipboardSelector::Primary => ClipboardKind::Primary, - ClipboardSelector::Select => ClipboardKind::Selection, - } -} - /// Return the 64-bit FNV-1a hash used to separate fallback capture roots. The /// fixed-width hexadecimal result is a single path-safe component. fn fnv1a_hex(bytes: &[u8]) -> String { @@ -357,11 +345,7 @@ impl Supervisor { cwd, group, } => self.spawn(&command, cwd, group), - Command::Kill { id } => { - if let Some(t) = self.by_id_mut(id) { - t.terminate(); - } - } + Command::Kill { id } => self.with_task(id, Task::terminate), Command::Remove { id } => { // Keep removed tasks for TERM→KILL escalation and reaping. if let Some(i) = self.index_of(id) { @@ -376,22 +360,11 @@ impl Supervisor { } } Command::Restart { id } => self.rerun(id), - Command::Tag { id, on } => { - if let Some(t) = self.by_id_mut(id) { - t.tagged = on; - } - } - // Ignore assignments for tasks no longer present. + Command::Tag { id, on } => self.with_task(id, |t| t.tagged = on), Command::SetGroup { id, group } => { - if let Some(t) = self.by_id_mut(id) { - t.group = normalize_group(group); - } - } - Command::SetName { id, name } => { - if let Some(t) = self.by_id_mut(id) { - t.name = normalize_label(name); - } + self.with_task(id, |t| t.group = normalize_group(group)) } + Command::SetName { id, name } => self.with_task(id, |t| t.name = normalize_label(name)), Command::Resize { rows, cols } => { // Clamp each dimension first, then preserve rows and reduce // columns when the grid exceeds `MAX_CELLS`. The constant @@ -438,11 +411,7 @@ impl Supervisor { Command::Key { id, code, mods } => { self.deliver(id, "key input", |t| t.send_key(code, mods)) } - Command::Scrollback { id, action } => { - if let Some(t) = self.by_id_mut(id) { - t.scroll_view(action); - } - } + Command::Scrollback { id, action } => self.with_task(id, |t| t.scroll_view(action)), Command::SaveSession { name } => self.save_session(&name), Command::LoadSession { name } => self.load_session(&name), Command::LoadRecovery { stem } => self.load_recovery(&stem), @@ -563,11 +532,7 @@ impl Supervisor { if let Some((id, stores)) = clipboard { for (kind, text) in stores.stores { - self.events.push(Event::ClipboardCopy { - id, - kind: clipboard_kind(kind), - text, - }); + self.events.push(Event::ClipboardCopy { id, kind, text }); } if let Some(len) = stores.oversized_len { self.status(format!( @@ -696,6 +661,13 @@ impl Supervisor { self.events.push(Event::Status(msg.into())); } + /// Run `f` against task `id`; ignore an unknown id. + fn with_task(&mut self, id: u64, f: impl FnOnce(&mut Task)) { + if let Some(t) = self.by_id_mut(id) { + f(t); + } + } + /// Route one input send to task `id`, reporting a bounded-queue refusal. fn deliver( &mut self, diff --git a/src/supervisor_capture_tests.rs b/src/supervisor_capture_tests.rs index 7dfb9b6..eb774f2 100644 --- a/src/supervisor_capture_tests.rs +++ b/src/supervisor_capture_tests.rs @@ -1,5 +1,5 @@ use super::*; -use crate::harness::testutil::{ID as CAP_ID, OTHER as CAP_OTHER}; +use crate::harness::fixtures::{ID as CAP_ID, OTHER as CAP_OTHER}; // --- session-capture wiring ------------------------------------------- @@ -94,8 +94,7 @@ fn spawn_claude_pins_an_id_and_layers_settings() { let dir = scratch("cap_claude"); let (bin, runtime) = (dir.join("bin"), dir.join("run")); install_stub(&bin, "claude", &dir); - let mut s = Supervisor::new(24, 80, 2000); - s.set_launch_context(agent_ctx(&bin, &runtime, dir.clone())); + let mut s = sup_ctx(agent_ctx(&bin, &runtime, dir.clone())); spawn(&mut s, "claude", dir.clone()); let argv = wait_argv(&mut s, &dir.join("argv")); @@ -168,8 +167,7 @@ fn spawn_claude_pins_an_id_and_layers_settings() { fn spawn_non_agent_command_is_not_instrumented() { let dir = scratch("cap_plain"); let runtime = dir.join("run"); - let mut s = Supervisor::new(24, 80, 2000); - s.set_launch_context(agent_ctx(&dir.join("bin"), &runtime, dir.clone())); + let mut s = sup_ctx(agent_ctx(&dir.join("bin"), &runtime, dir.clone())); spawn(&mut s, "printf ok", dir.clone()); let t = &s.tasks[0]; assert!(t.harness.is_none()); @@ -190,8 +188,7 @@ fn spawn_resuming_claude_injects_only_the_capture_channel() { let dir = scratch("cap_resume"); let (bin, runtime) = (dir.join("bin"), dir.join("run")); install_stub(&bin, "claude", &dir); - let mut s = Supervisor::new(24, 80, 2000); - s.set_launch_context(agent_ctx(&bin, &runtime, dir.clone())); + let mut s = sup_ctx(agent_ctx(&bin, &runtime, dir.clone())); spawn(&mut s, format!("claude --resume {CAP_ID}"), dir.clone()); let argv = wait_argv(&mut s, &dir.join("argv")); @@ -217,8 +214,7 @@ fn rerun_resumes_the_captured_conversation() { let dir = scratch("cap_rerun"); let (bin, runtime) = (dir.join("bin"), dir.join("run")); install_stub(&bin, "claude", &dir); - let mut s = Supervisor::new(24, 80, 2000); - s.set_launch_context(agent_ctx(&bin, &runtime, dir.clone())); + let mut s = sup_ctx(agent_ctx(&bin, &runtime, dir.clone())); spawn(&mut s, "claude", dir.clone()); let _ = wait_argv(&mut s, &dir.join("argv")); let id = s.tasks[0].id; @@ -275,8 +271,7 @@ fn rerun_cannot_read_the_old_runs_stale_capture() { "claude", &format!("printf 'Resume this session with:\\nclaude --resume {CAP_ID}\\n'"), ); - let mut s = Supervisor::new(24, 80, 2000); - s.set_launch_context(agent_ctx_plus( + let mut s = sup_ctx(agent_ctx_plus( &bin, &runtime, dir.clone(), @@ -325,8 +320,7 @@ fn remove_deletes_the_capture_file() { let dir = scratch("cap_remove"); let (bin, runtime) = (dir.join("bin"), dir.join("run")); install_stub(&bin, "claude", &dir); - let mut s = Supervisor::new(24, 80, 2000); - s.set_launch_context(agent_ctx(&bin, &runtime, dir.clone())); + let mut s = sup_ctx(agent_ctx(&bin, &runtime, dir.clone())); spawn(&mut s, "claude", dir.clone()); let _ = wait_argv(&mut s, &dir.join("argv")); let id = s.tasks[0].id; @@ -346,8 +340,7 @@ fn reconnect_with_unchanged_root_preserves_capture_files() { let dir = scratch("cap_reconnect"); let (bin, runtime) = (dir.join("bin"), dir.join("run")); install_stub(&bin, "claude", &dir); - let mut s = Supervisor::new(24, 80, 2000); - s.set_launch_context(agent_ctx(&bin, &runtime, dir.clone())); + let mut s = sup_ctx(agent_ctx(&bin, &runtime, dir.clone())); spawn(&mut s, "claude", dir.clone()); let cap = s.tasks[0].capture_file.clone().expect("capture file set"); std::fs::write(&cap, "{}").unwrap(); @@ -449,9 +442,8 @@ fn spawn_codex_installs_the_notify_override() { let dir = scratch("cap_codex"); let (bin, runtime) = (dir.join("bin"), dir.join("run")); install_stub(&bin, "codex", &dir); - let mut s = Supervisor::new(24, 80, 2000); // Keep config lookup within this test's scratch directory. - s.set_launch_context(agent_ctx_plus( + let mut s = sup_ctx(agent_ctx_plus( &bin, &runtime, dir.clone(), @@ -488,9 +480,8 @@ fn spawn_grok_pins_an_id_and_injects_nothing_else() { let dir = scratch("cap_grok"); let (bin, runtime, config) = (dir.join("bin"), dir.join("run"), dir.join("config")); install_stub(&bin, "grok", &dir); - let mut s = Supervisor::new(24, 80, 2000); // Keep save-time correlation inside the scratch tree. - s.set_launch_context(agent_ctx_plus( + let mut s = sup_ctx(agent_ctx_plus( &bin, &runtime, dir.clone(), @@ -544,8 +535,7 @@ fn exit_hint_is_scraped_and_saved_as_a_resume() { "claude", &format!("printf 'Resume this session with:\\nclaude --resume {CAP_ID}\\n'"), ); - let mut s = Supervisor::new(24, 80, 2000); - s.set_launch_context(agent_ctx_plus( + let mut s = sup_ctx(agent_ctx_plus( &bin, &runtime, dir.clone(), @@ -579,8 +569,7 @@ fn save_scrapes_a_finished_task_without_reap() { "claude", &format!("printf 'Resume this session with:\\nclaude --resume {CAP_ID}\\n'"), ); - let mut s = Supervisor::new(24, 80, 2000); - s.set_launch_context(agent_ctx_plus( + let mut s = sup_ctx(agent_ctx_plus( &bin, &runtime, dir.clone(), @@ -616,8 +605,7 @@ fn rerun_scrapes_a_finished_task_without_reap() { "claude", &format!("printf 'Resume this session with:\\nclaude --resume {CAP_ID}\\n'"), ); - let mut s = Supervisor::new(24, 80, 2000); - s.set_launch_context(agent_ctx(&bin, &runtime, dir.clone())); + let mut s = sup_ctx(agent_ctx(&bin, &runtime, dir.clone())); spawn(&mut s, "claude", dir.clone()); let id = s.tasks[0].id; @@ -653,8 +641,7 @@ fn resume_id_precedence_scrape_over_capture_over_spawn() { d = done.display() ), ); - let mut s = Supervisor::new(24, 80, 2000); - s.set_launch_context(agent_ctx_plus( + let mut s = sup_ctx(agent_ctx_plus( &bin, &runtime, dir.clone(), @@ -711,8 +698,7 @@ fn save_falls_back_to_fs_correlation_for_a_silent_codex() { let now_ms = now_ms(); let id = write_rollout(&codex_home, now_ms, 1, &dir); - let mut s = Supervisor::new(24, 80, 2000); - s.set_launch_context(agent_ctx_plus( + let mut s = sup_ctx(agent_ctx_plus( &bin, &runtime, dir.clone(), @@ -752,8 +738,7 @@ fn save_correlates_against_the_spawn_time_home() { let id_a = write_rollout(&home_a, now_ms, 1, &dir); let id_b = write_rollout(&home_b, now_ms, 2, &dir); - let mut s = Supervisor::new(24, 80, 2000); - s.set_launch_context(agent_ctx_plus( + let mut s = sup_ctx(agent_ctx_plus( &bin, &runtime, dir.clone(), @@ -835,8 +820,7 @@ fn home_only_launch_env_targets_the_clients_dot_codex() { let now_ms = now_ms(); let id = write_rollout(&codex_home, now_ms, 1, &dir); - let mut s = Supervisor::new(24, 80, 2000); - s.set_launch_context(agent_ctx_plus( + let mut s = sup_ctx(agent_ctx_plus( &bin, &runtime, dir.clone(), @@ -920,8 +904,7 @@ fn agent_save_without_any_id_keeps_the_plain_command() { // nothing to find, and the notify routing nothing to read. let codex_home = dir.join("codex_home"); install_stub(&bin, "codex", &dir); - let mut s = Supervisor::new(24, 80, 2000); - s.set_launch_context(agent_ctx_plus( + let mut s = sup_ctx(agent_ctx_plus( &bin, &runtime, dir.clone(), @@ -980,8 +963,7 @@ fn config_toml_notify_chains_through_the_injected_script() { chain = NOTIFY_CHAIN_ENV, ), ); - let mut s = Supervisor::new(24, 80, 2000); - s.set_launch_context(agent_ctx_plus( + let mut s = sup_ctx(agent_ctx_plus( &bin, &runtime, dir.clone(), @@ -1000,7 +982,8 @@ fn config_toml_notify_chains_through_the_injected_script() { 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" + "the chained notifier must receive its original args plus the payload, \ + and never wrote that complete record" ); assert_eq!( std::fs::read_to_string(dir.join("chainenv")).unwrap(), @@ -1013,11 +996,6 @@ fn config_toml_notify_chains_through_the_injected_script() { payload, "the capture write must precede the chain handoff" ); - assert_eq!( - std::fs::read_to_string(&record).unwrap(), - format!("turn-ended\n{payload}\n"), - "the notifier must receive its original args plus the payload" - ); let _ = std::fs::remove_dir_all(&dir); } @@ -1036,8 +1014,7 @@ fn unrepresentable_config_notify_suppresses_injection() { ) .unwrap(); install_stub(&bin, "codex", &dir); - let mut s = Supervisor::new(24, 80, 2000); - s.set_launch_context(agent_ctx_plus( + let mut s = sup_ctx(agent_ctx_plus( &bin, &runtime, dir.clone(), @@ -1071,17 +1048,7 @@ fn unrepresentable_config_notify_suppresses_injection() { fn non_agent_entries_survive_save_as_plain_strings() { let dir = scratch("plain_save"); let config = dir.join("config"); - let mut s = Supervisor::new(24, 80, 2000); - s.set_launch_context(LaunchContext { - env: vec![ - ("SHELL".into(), "/bin/sh".into()), - ( - "FLEETCOM_CONFIG_DIR".into(), - config.clone().into_os_string(), - ), - ], - cwd: dir.clone(), - }); + let mut s = sup_ctx(config_ctx(&config, dir.clone(), &[("SHELL", "/bin/sh")])); spawn(&mut s, "sleep 30", dir.clone()); let text = save_and_read(&mut s, &config, "plain"); assert!( @@ -1110,8 +1077,7 @@ fn recovery_cadence_rewrites_on_capture_drift_and_skips_when_static() { let dir = scratch("cap_recovery_cadence"); let (bin, runtime, config) = (dir.join("bin"), dir.join("run"), dir.join("config")); install_stub(&bin, "claude", &dir); - let mut s = Supervisor::new(24, 80, 2000); - s.set_launch_context(agent_ctx_plus( + let mut s = sup_ctx(agent_ctx_plus( &bin, &runtime, dir.clone(), diff --git a/src/supervisor_tests.rs b/src/supervisor_tests.rs index 111d9eb..c48ae4c 100644 --- a/src/supervisor_tests.rs +++ b/src/supervisor_tests.rs @@ -1,10 +1,12 @@ use std::path::Path; use super::*; -use crate::protocol::{Key, Mods}; -use crate::testutil::{ - here, install_fake_notifier, now_ms, read_pid, sh_env, wait_until, write_executable, - write_rollout, +use crate::{ + protocol::{ClipboardKind, Key, Mods}, + testutil::{ + here, install_fake_notifier, now_ms, read_pid, sh_env, wait_until, write_executable, + write_rollout, + }, }; /// Build a supervisor with this process's launch context. @@ -14,6 +16,13 @@ fn sup(rows: u16, cols: u16) -> Supervisor { s } +/// Build a default-size supervisor with `ctx` installed. +fn sup_ctx(ctx: LaunchContext) -> Supervisor { + let mut s = Supervisor::new(24, 80, 2000); + s.set_launch_context(ctx); + s +} + /// Apply an ungrouped `Command::Spawn` of `cmd` in `cwd`. fn spawn(s: &mut Supervisor, cmd: impl Into, cwd: PathBuf) { s.apply(Command::Spawn { @@ -525,6 +534,31 @@ fn first_id(s: &mut Supervisor) -> u64 { } } +/// Tick once and return task `id` from the emitted snapshot. +fn view_of(s: &mut Supervisor, id: u64) -> TaskView { + s.tick(); + for e in s.drain() { + if let Event::Tasks(v) = e + && let Some(t) = v.iter().find(|t| t.id == id) + { + return t.clone(); + } + } + panic!("task {id} missing from the snapshot"); +} + +/// Launch context with `FLEETCOM_CONFIG_DIR` and optional environment entries. +fn config_ctx(config: &Path, cwd: PathBuf, extra: &[(&str, &str)]) -> LaunchContext { + let mut env: Vec<(std::ffi::OsString, std::ffi::OsString)> = vec![( + "FLEETCOM_CONFIG_DIR".into(), + config.as_os_str().to_os_string(), + )]; + for (k, v) in extra { + env.push(((*k).into(), (*v).into())); + } + LaunchContext { env, cwd } +} + /// Poll ticks until the task's lifecycle satisfies `pred`, or fail. fn wait_for_lifecycle( s: &mut Supervisor, @@ -861,26 +895,14 @@ fn set_group_round_trips_and_clears() { let mut s = sup(24, 80); spawn(&mut s, "sleep 30", here()); let id = first_id(&mut s); - let group_of = |s: &mut Supervisor| -> Option { - s.tick(); - for e in s.drain() { - if let Event::Tasks(v) = e - && let Some(t) = v.iter().find(|t| t.id == id) - { - return t.group.clone(); - } - } - panic!("task {id} missing from the snapshot"); - }; - s.apply(Command::SetGroup { id, group: Some(" api ".into()), }); - assert_eq!(group_of(&mut s), Some("api".into())); + assert_eq!(view_of(&mut s, id).group, Some("api".into())); s.apply(Command::SetGroup { id, group: None }); - assert_eq!(group_of(&mut s), None); + assert_eq!(view_of(&mut s, id).group, None); // Unknown id: no panic, no event, no state change. s.apply(Command::SetGroup { @@ -888,7 +910,7 @@ fn set_group_round_trips_and_clears() { group: Some("ghost".into()), }); assert!(s.drain().is_empty(), "unknown-id SetGroup must stay silent"); - assert_eq!(group_of(&mut s), None); + assert_eq!(view_of(&mut s, id).group, None); } /// `SetName` normalizes assignments, keeps the literal `Unassigned` @@ -898,33 +920,21 @@ fn set_name_round_trips_and_clears() { let mut s = sup(24, 80); spawn(&mut s, "sleep 30", here()); let id = first_id(&mut s); - let name_of = |s: &mut Supervisor| -> Option { - s.tick(); - for e in s.drain() { - if let Event::Tasks(v) = e - && let Some(t) = v.iter().find(|t| t.id == id) - { - return t.name.clone(); - } - } - panic!("task {id} missing from the snapshot"); - }; - s.apply(Command::SetName { id, name: Some(" api \x1b[2J ".into()), }); - assert_eq!(name_of(&mut s), Some("api [2J".into())); + assert_eq!(view_of(&mut s, id).name, Some("api [2J".into())); // The group picker's reserved label has no meaning for names. s.apply(Command::SetName { id, name: Some("Unassigned".into()), }); - assert_eq!(name_of(&mut s), Some("Unassigned".into())); + assert_eq!(view_of(&mut s, id).name, Some("Unassigned".into())); s.apply(Command::SetName { id, name: None }); - assert_eq!(name_of(&mut s), None); + assert_eq!(view_of(&mut s, id).name, None); // Unknown id: no panic, no event, no state change. s.apply(Command::SetName { @@ -932,7 +942,81 @@ fn set_name_round_trips_and_clears() { name: Some("ghost".into()), }); assert!(s.drain().is_empty(), "unknown-id SetName must stay silent"); - assert_eq!(name_of(&mut s), None); + assert_eq!(view_of(&mut s, id).name, None); +} + +/// Killing an unknown id does not signal a live task. +#[test] +fn kill_with_an_unknown_id_leaves_the_live_task_alone() { + let mut s = sup(24, 80); + spawn(&mut s, "sleep 30", here()); + let id = first_id(&mut s); + let before = view_of(&mut s, id).lifecycle; + + s.apply(Command::Kill { id: 999 }); + assert!(s.drain().is_empty(), "unknown-id Kill must stay silent"); + assert!( + !s.tasks[0].overdue(Instant::now(), Duration::ZERO), + "unknown-id Kill must not signal the live task" + ); + assert_eq!(view_of(&mut s, id).lifecycle, before); +} + +/// Tagging an unknown id does not change a live task's tag. +#[test] +fn tag_with_an_unknown_id_leaves_the_live_task_alone() { + let mut s = sup(24, 80); + spawn(&mut s, "sleep 30", here()); + let id = first_id(&mut s); + s.apply(Command::Tag { id, on: true }); + assert!(view_of(&mut s, id).tagged); + + s.apply(Command::Tag { id: 999, on: false }); + assert!(s.drain().is_empty(), "unknown-id Tag must stay silent"); + assert!( + view_of(&mut s, id).tagged, + "unknown-id Tag must not clear the live task's flag" + ); +} + +/// Scrolling an unknown id does not change a live task's viewport. +#[test] +fn scrollback_with_an_unknown_id_leaves_the_live_task_alone() { + // Short grid: history accrues within a few rows of output. + let mut s = sup(6, 80); + spawn(&mut s, "seq 1 200; sleep 30", here()); + let id = first_id(&mut s); + s.apply(Command::Watch { + id: Some(id), + attached: true, + }); + + // Retry until output has produced retained history. + let scrolled = wait_until(Duration::from_secs(5), || { + s.tick(); + let _ = s.drain(); + s.apply(Command::Scrollback { + id, + action: ScrollAction::Up(3), + }); + s.tasks[0].scroll_offset() > 0 + }); + assert!(scrolled, "the task never accrued scrollback"); + let offset = s.tasks[0].scroll_offset(); + + s.apply(Command::Scrollback { + id: 999, + action: ScrollAction::Live, + }); + assert!( + s.drain().is_empty(), + "unknown-id Scrollback must stay silent" + ); + assert_eq!( + s.tasks[0].scroll_offset(), + offset, + "unknown-id Scrollback must not snap the live task's viewport" + ); } /// Spawned tasks expose their normalized initial group in the first snapshot. @@ -1448,14 +1532,7 @@ fn shutdown_is_prompt_when_every_group_is_already_empty() { fn session_commands_use_the_launch_context_config_dir() { let dir = scratch("sess_root"); let config = dir.join("config"); - let mut s = Supervisor::new(24, 80, 2000); - s.set_launch_context(LaunchContext { - env: vec![( - "FLEETCOM_CONFIG_DIR".into(), - config.clone().into_os_string(), - )], - cwd: dir.clone(), - }); + let mut s = sup_ctx(config_ctx(&config, dir.clone(), &[])); s.apply(Command::SaveSession { name: "ctx".into() }); assert!( @@ -1486,22 +1563,20 @@ fn session_commands_use_the_launch_context_config_dir() { let _ = std::fs::remove_dir_all(&dir); } -/// Saving and loading preserve group assignments. +/// Saving and loading preserve independent group and display-name fields. #[test] -fn load_session_restores_saved_groups() { - let dir = scratch("sess_groups"); +fn load_session_restores_saved_groups_and_names() { + let dir = scratch("sess_labels"); let config = dir.join("config"); - let ctx = LaunchContext { - env: vec![( - "FLEETCOM_CONFIG_DIR".into(), - config.clone().into_os_string(), - )], - cwd: dir.clone(), - }; - let mut s = Supervisor::new(24, 80, 2000); - s.set_launch_context(ctx.clone()); - spawn_grouped(&mut s, "sleep 30", dir.clone(), "api"); + let ctx = config_ctx(&config, dir.clone(), &[]); + let mut s = sup_ctx(ctx.clone()); spawn(&mut s, "sleep 31", dir.clone()); + let id = first_id(&mut s); + s.apply(Command::SetName { + id, + name: Some("web".into()), + }); + spawn_grouped(&mut s, "sleep 30", dir.clone(), "api"); s.apply(Command::SaveSession { name: "fleet".into(), }); @@ -1512,8 +1587,7 @@ fn load_session_restores_saved_groups() { "save must still count commands" ); - let mut fresh = Supervisor::new(24, 80, 2000); - fresh.set_launch_context(ctx); + let mut fresh = sup_ctx(ctx); fresh.apply(Command::LoadSession { name: "fleet".into(), }); @@ -1526,72 +1600,23 @@ fn load_session_restores_saved_groups() { _ => None, }) .expect("a Tasks snapshot after load"); - let group_of = |cmd: &str| { - tasks + let by_cmd = |cmd: &str| { + let t = tasks .iter() .find(|t| t.command == cmd) - .unwrap_or_else(|| panic!("task '{cmd}' missing after load")) - .group - .clone() - }; - assert_eq!(group_of("sleep 30"), Some("api".into())); - assert_eq!(group_of("sleep 31"), None); - let _ = std::fs::remove_dir_all(&dir); -} - -/// Saving and loading preserve display names. -#[test] -fn load_session_restores_saved_names() { - let dir = scratch("sess_names"); - let config = dir.join("config"); - let ctx = LaunchContext { - env: vec![( - "FLEETCOM_CONFIG_DIR".into(), - config.clone().into_os_string(), - )], - cwd: dir.clone(), - }; - let mut s = Supervisor::new(24, 80, 2000); - s.set_launch_context(ctx.clone()); - spawn(&mut s, "sleep 30", dir.clone()); - spawn(&mut s, "sleep 31", dir.clone()); - s.tick(); - let id = match s.drain().first() { - Some(Event::Tasks(v)) => v.iter().find(|t| t.command == "sleep 30").unwrap().id, - _ => panic!("expected a Tasks snapshot"), - }; - s.apply(Command::SetName { - id, - name: Some("api server".into()), - }); - s.apply(Command::SaveSession { - name: "fleet".into(), - }); - - let mut fresh = Supervisor::new(24, 80, 2000); - fresh.set_launch_context(ctx); - fresh.apply(Command::LoadSession { - name: "fleet".into(), - }); - fresh.tick(); - let evs = fresh.drain(); - let tasks = evs - .iter() - .find_map(|e| match e { - Event::Tasks(v) => Some(v), - _ => None, - }) - .expect("a Tasks snapshot after load"); - let name_of = |cmd: &str| { - tasks - .iter() - .find(|t| t.command == cmd) - .unwrap_or_else(|| panic!("task '{cmd}' missing after load")) - .name - .clone() + .unwrap_or_else(|| panic!("task '{cmd}' missing after load")); + (t.group.clone(), t.name.clone()) }; - assert_eq!(name_of("sleep 30"), Some("api server".into())); - assert_eq!(name_of("sleep 31"), None); + assert_eq!( + by_cmd("sleep 30"), + (Some("api".into()), None), + "the {{cmd,group}} member must restore its group and stay unnamed" + ); + assert_eq!( + by_cmd("sleep 31"), + (None, Some("web".into())), + "the {{cmd,name}} member must restore its name and stay ungrouped" + ); let _ = std::fs::remove_dir_all(&dir); } @@ -1609,11 +1634,7 @@ fn load_session_renormalizes_hand_edited_groups() { ), ) .unwrap(); - let mut s = Supervisor::new(24, 80, 2000); - s.set_launch_context(LaunchContext { - env: vec![("FLEETCOM_CONFIG_DIR".into(), config.into_os_string())], - cwd: dir.clone(), - }); + let mut s = sup_ctx(config_ctx(&config, dir.clone(), &[])); s.apply(Command::LoadSession { name: "edited".into(), }); @@ -1631,18 +1652,6 @@ fn load_session_renormalizes_hand_edited_groups() { let _ = std::fs::remove_dir_all(&dir); } -/// Launch context with a session directory and optional environment entries. -fn config_ctx(config: &Path, cwd: PathBuf, extra: &[(&str, &str)]) -> LaunchContext { - let mut env: Vec<(std::ffi::OsString, std::ffi::OsString)> = vec![( - "FLEETCOM_CONFIG_DIR".into(), - config.as_os_str().to_os_string(), - )]; - for (k, v) in extra { - env.push(((*k).into(), (*v).into())); - } - LaunchContext { env, cwd } -} - /// Broken JSON reports a load error rather than a missing session. #[test] fn load_surfaces_parse_errors_instead_of_absence() { @@ -1650,8 +1659,7 @@ fn load_surfaces_parse_errors_instead_of_absence() { let config = dir.join("config"); std::fs::create_dir_all(config.join("sessions")).unwrap(); std::fs::write(config.join("sessions").join("broken.json"), "{not json").unwrap(); - let mut s = Supervisor::new(24, 80, 2000); - s.set_launch_context(config_ctx(&config, dir.clone(), &[])); + let mut s = sup_ctx(config_ctx(&config, dir.clone(), &[])); s.apply(Command::LoadSession { name: "broken".into(), }); @@ -1675,8 +1683,7 @@ fn load_surfaces_parse_errors_instead_of_absence() { fn load_missing_session_reads_as_not_found() { let dir = scratch("sess_missing"); let config = dir.join("config"); - let mut s = Supervisor::new(24, 80, 2000); - s.set_launch_context(config_ctx(&config, dir.clone(), &[])); + let mut s = sup_ctx(config_ctx(&config, dir.clone(), &[])); s.apply(Command::LoadSession { name: "ghost".into(), }); @@ -1701,8 +1708,7 @@ fn load_reports_admit_failures_not_clean_success() { format!(r#"{{"{}": ["true", "true"]}}"#, dir.display()), ) .unwrap(); - let mut s = Supervisor::new(24, 80, 2000); - s.set_launch_context(config_ctx( + let mut s = sup_ctx(config_ctx( &config, dir.clone(), &[("SHELL", "/nonexistent/no-such-shell")], @@ -1765,8 +1771,7 @@ fn load_skips_over_length_commands() { ), ) .unwrap(); - let mut s = Supervisor::new(24, 80, 2000); - s.set_launch_context(config_ctx(&config, dir.clone(), &[])); + let mut s = sup_ctx(config_ctx(&config, dir.clone(), &[])); s.apply(Command::LoadSession { name: "big".into() }); let evs = s.drain(); assert!( @@ -1786,8 +1791,7 @@ fn spawn_uses_the_launch_context_env_not_the_process_env() { ); let dir = scratch("hello_env"); let out = dir.join("out"); - let mut s = Supervisor::new(24, 80, 2000); - s.set_launch_context(LaunchContext { + let mut s = sup_ctx(LaunchContext { env: vec![("FLEETCOM_MARKER".into(), "xyzzy".into())], cwd: dir.clone(), }); @@ -1910,8 +1914,7 @@ fn key_command_encodes_against_live_cursor_mode() { /// Build a supervisor with recovery enabled at test-specific intervals. fn recovery_sup(config: &Path, cwd: PathBuf, debounce: Duration, cadence: Duration) -> Supervisor { - let mut s = Supervisor::new(24, 80, 2000); - s.set_launch_context(config_ctx(config, cwd, &[])); + let mut s = sup_ctx(config_ctx(config, cwd, &[])); s.set_recovery_timing(debounce, cadence); s } @@ -1985,8 +1988,7 @@ fn recovery_arms_on_structural_mutations_not_tag() { fn list_sessions_includes_recovery_snapshots_newest_first() { let dir = scratch("recovery_list_wire"); let config = dir.join("config"); - let mut s = Supervisor::new(24, 80, 2000); - s.set_launch_context(config_ctx(&config, dir.clone(), &[])); + let mut s = sup_ctx(config_ctx(&config, dir.clone(), &[])); let rec = config.join("sessions").join("recovery"); let entry = |cmd: &str| SessionEntry { @@ -2043,8 +2045,7 @@ fn list_sessions_includes_recovery_snapshots_newest_first() { fn load_recovery_materializes_the_fleet_and_notices() { let dir = scratch("recovery_load_wire"); let config = dir.join("config"); - let mut s = Supervisor::new(24, 80, 2000); - s.set_launch_context(config_ctx(&config, dir.clone(), &[])); + let mut s = sup_ctx(config_ctx(&config, dir.clone(), &[])); let mut cfg = SessionConfig::new(); cfg.insert( @@ -2100,8 +2101,7 @@ fn load_recovery_materializes_the_fleet_and_notices() { fn load_recovery_refuses_unknown_and_traversal_stems() { let dir = scratch("recovery_load_refuse"); let config = dir.join("config"); - let mut s = Supervisor::new(24, 80, 2000); - s.set_launch_context(config_ctx(&config, dir.clone(), &[])); + let mut s = sup_ctx(config_ctx(&config, dir.clone(), &[])); s.apply(Command::LoadRecovery { stem: "20990101-000000-1".into(), diff --git a/src/task.rs b/src/task.rs index 297f902..bf10c9d 100644 --- a/src/task.rs +++ b/src/task.rs @@ -600,10 +600,7 @@ impl Task { let p = grid(&self.parser); input::mouse_bytes(&p, kind, col, row) }; - match bytes { - Some(b) => self.send_input(&b), - None => Ok(()), - } + bytes.map_or(Ok(()), |b| self.send_input(&b)) } /// Encode and queue one key using the child's cursor-key mode, read under @@ -613,10 +610,7 @@ impl Task { let p = grid(&self.parser); input::key_bytes(p.application_cursor(), code, mods) }; - match bytes { - Some(b) => self.send_input(&b), - None => Ok(()), - } + bytes.map_or(Ok(()), |b| self.send_input(&b)) } /// Return the child's mouse, alternate-screen, and alternate-scroll modes diff --git a/src/terminal/emulator.rs b/src/terminal/emulator.rs index 151a681..37148b8 100644 --- a/src/terminal/emulator.rs +++ b/src/terminal/emulator.rs @@ -19,6 +19,8 @@ use alacritty_terminal::{ }; use base64::{Engine as _, engine::general_purpose::STANDARD as B64}; +use crate::protocol::ClipboardKind; + /// Mouse event classes requested by the child through DECSET 1000/1002/1003. #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub enum MouseProtocolMode { @@ -47,22 +49,11 @@ const _: () = assert!( "CLIPBOARD_STORE_MAX_BYTES must base64-encode to under frame::MAX_FRAME" ); -/// Supported OSC 52 clipboard targets. -#[derive(Clone, Copy, Debug, Eq, PartialEq)] -pub enum ClipboardSelector { - /// The system clipboard (selector byte `c`). - Clipboard, - /// The primary selection (selector byte `p`). - Primary, - /// The select buffer (selector byte `s`). - Select, -} - /// OSC 52 clipboard stores captured since the last drain. #[derive(Debug, Default)] pub struct ClipboardStores { - /// Latest store per selector, ordered by arrival. - pub stores: Vec<(ClipboardSelector, String)>, + /// Buffered stores, at most one per supported selector, ordered by arrival. + pub stores: Vec<(ClipboardKind, String)>, /// Byte length of the most recent oversized store. pub oversized_len: Option, } @@ -331,6 +322,18 @@ impl Emulator { self.drain_allowed() } + /// Stop synchronized buffering, record the advance, and drain allowed replies. + fn land_sync_frame(&mut self) -> Vec { + let mut observed = ObservedTerm { + term: &mut self.term, + alt: &mut self.alt, + clipboard: &mut self.clipboard, + }; + self.parser.stop_sync(&mut observed); + self.observe_advance(); + 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 @@ -347,14 +350,7 @@ impl Emulator { if !expired { return Vec::new(); } - let mut observed = ObservedTerm { - term: &mut self.term, - alt: &mut self.alt, - clipboard: &mut self.clipboard, - }; - self.parser.stop_sync(&mut observed); - self.observe_advance(); - self.drain_allowed() + self.land_sync_frame() } /// Terminate an open `?2026` synchronized update regardless of its @@ -367,14 +363,7 @@ impl Emulator { if self.parser.sync_timeout().sync_timeout().is_none() { return Vec::new(); } - let mut observed = ObservedTerm { - term: &mut self.term, - alt: &mut self.alt, - clipboard: &mut self.clipboard, - }; - self.parser.stop_sync(&mut observed); - self.observe_advance(); - self.drain_allowed() + self.land_sync_frame() } /// The visible screen as ANSI bytes, plus cursor position and whether the @@ -724,6 +713,17 @@ impl ObservedTerm<'_> { } } +/// Generate [`Handler`] methods that forward each call to the wrapped terminal. +macro_rules! delegate { + ($($name:ident($($arg:ident: $ty:ty),*);)+) => { + $( + fn $name(&mut self, $($arg: $ty),*) { + self.term.$name($($arg),*); + } + )+ + }; +} + /// 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 @@ -735,11 +735,9 @@ impl Handler for ObservedTerm<'_> { 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); + delegate! { + set_cursor_style(a0: Option); + set_cursor_shape(a0: vt::CursorShape); } fn input(&mut self, a0: char) { self.term.input(a0); @@ -748,107 +746,41 @@ impl Handler for ObservedTerm<'_> { 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); + delegate! { + goto(a0: i32, a1: usize); + goto_line(a0: i32); + goto_col(a0: usize); + insert_blank(a0: usize); + move_up(a0: usize); + move_down(a0: usize); + identify_terminal(a0: Option); + device_status(a0: usize); + move_forward(a0: usize); + move_backward(a0: usize); + move_down_and_cr(a0: usize); + move_up_and_cr(a0: usize); + put_tab(a0: u16); + backspace(); + carriage_return(); + linefeed(); + bell(); + substitute(); + newline(); + set_horizontal_tabstop(); + scroll_up(a0: usize); + scroll_down(a0: usize); + insert_blank_lines(a0: usize); + delete_lines(a0: usize); + erase_chars(a0: usize); + delete_chars(a0: usize); + move_backward_tabs(a0: u16); + move_forward_tabs(a0: u16); + save_cursor_position(); + restore_cursor_position(); + clear_line(a0: vt::LineClearMode); + clear_screen(a0: vt::ClearMode); + clear_tabs(a0: vt::TabulationClearMode); + set_tabs(a0: u16); } fn reset_state(&mut self) { self.term.reset_state(); @@ -859,20 +791,12 @@ impl Handler for ObservedTerm<'_> { 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); + delegate! { + reverse_index(); + terminal_attribute(a0: vt::Attr); + set_mode(a0: vt::Mode); + unset_mode(a0: vt::Mode); + report_mode(a0: vt::Mode); } fn set_private_mode(&mut self, a0: vt::PrivateMode) { self.term.set_private_mode(a0); @@ -882,40 +806,24 @@ impl Handler for ObservedTerm<'_> { 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); + delegate! { + report_private_mode(a0: vt::PrivateMode); + set_scrolling_region(a0: usize, a1: Option); + set_keypad_application_mode(); + unset_keypad_application_mode(); + set_active_charset(a0: vt::CharsetIndex); + configure_charset(a0: vt::CharsetIndex, a1: vt::StandardCharset); + set_color(a0: usize, a1: vt::Rgb); + dynamic_color_sequence(a0: String, a1: usize, a2: &str); + reset_color(a0: usize); } /// Capture supported OSC 52 stores while preserving their selector. fn clipboard_store(&mut self, a0: u8, a1: &[u8]) { // Ignore selectors without a forwarding target. let selector = match a0 { - b'c' => ClipboardSelector::Clipboard, - b'p' => ClipboardSelector::Primary, - b's' => ClipboardSelector::Select, + b'c' => ClipboardKind::Clipboard, + b'p' => ClipboardKind::Primary, + b's' => ClipboardKind::Selection, _ => return, }; // Accept padded standard base64 containing UTF-8 text. @@ -954,38 +862,18 @@ impl Handler for ObservedTerm<'_> { 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); + delegate! { + text_area_size_pixels(); + text_area_size_chars(); + set_hyperlink(a0: Option); + set_mouse_cursor_icon(a0: vt::cursor_icon::CursorIcon); + report_keyboard_mode(); + push_keyboard_mode(a0: vt::KeyboardModes); + pop_keyboard_modes(a0: u16); + set_keyboard_mode(a0: vt::KeyboardModes, a1: vt::KeyboardModesApplyBehavior); + set_modify_other_keys(a0: vt::ModifyOtherKeys); + report_modify_other_keys(); + set_scp(a0: vt::ScpCharPath, a1: vt::ScpUpdateMode); } } @@ -1080,7 +968,7 @@ mod tests { let drained = emu.drain_clipboard(); assert_eq!( drained.stores, - vec![(ClipboardSelector::Clipboard, "hello".to_string())] + vec![(ClipboardKind::Clipboard, "hello".to_string())] ); assert_eq!(drained.oversized_len, None); let again = emu.drain_clipboard(); @@ -1096,8 +984,8 @@ mod tests { assert_eq!( emu.drain_clipboard().stores, vec![ - (ClipboardSelector::Primary, "a".to_string()), - (ClipboardSelector::Select, "b".to_string()), + (ClipboardKind::Primary, "a".to_string()), + (ClipboardKind::Selection, "b".to_string()), ] ); } @@ -1109,7 +997,7 @@ mod tests { emu.process(b"\x1b]52;;aGk=\x07"); assert_eq!( emu.drain_clipboard().stores, - vec![(ClipboardSelector::Clipboard, "hi".to_string())] + vec![(ClipboardKind::Clipboard, "hi".to_string())] ); } @@ -1131,7 +1019,7 @@ mod tests { emu.process(b"\x1b]52;c;Zmlyc3Q=\x07\x1b]52;c;c2Vjb25k\x07"); assert_eq!( emu.drain_clipboard().stores, - vec![(ClipboardSelector::Clipboard, "second".to_string())] + vec![(ClipboardKind::Clipboard, "second".to_string())] ); } @@ -1143,9 +1031,9 @@ mod tests { assert_eq!( emu.drain_clipboard().stores, vec![ - (ClipboardSelector::Select, "sel".to_string()), - (ClipboardSelector::Primary, "pri".to_string()), - (ClipboardSelector::Clipboard, "clip".to_string()), + (ClipboardKind::Selection, "sel".to_string()), + (ClipboardKind::Primary, "pri".to_string()), + (ClipboardKind::Clipboard, "clip".to_string()), ] ); } @@ -1180,7 +1068,7 @@ mod tests { emu.process(b"\x1b]52;c;aGVsbG8=\x1b\\"); assert_eq!( emu.drain_clipboard().stores, - vec![(ClipboardSelector::Clipboard, "hello".to_string())] + vec![(ClipboardKind::Clipboard, "hello".to_string())] ); } @@ -1199,8 +1087,8 @@ mod tests { assert_eq!( drained.stores, vec![ - (ClipboardSelector::Select, "sel".to_string()), - (ClipboardSelector::Primary, "pri".to_string()), + (ClipboardKind::Selection, "sel".to_string()), + (ClipboardKind::Primary, "pri".to_string()), ], "the drop clears its own selector's slot and no other" ); diff --git a/src/testutil.rs b/src/testutil.rs index 32decfc..4d1f399 100644 --- a/src/testutil.rs +++ b/src/testutil.rs @@ -7,6 +7,7 @@ use std::{ fs, os::unix::fs::PermissionsExt, path::{Path, PathBuf}, + process::Command, time::{Duration, Instant, SystemTime}, }; @@ -56,6 +57,14 @@ pub(crate) fn read_pid(path: &Path) -> nix::unistd::Pid { nix::unistd::Pid::from_raw(pid.expect("pid file never appeared")) } +/// Return the PID of a child process after reaping it. +pub(crate) fn dead_pid() -> u32 { + let mut child = Command::new("sh").arg("-c").arg("exit 0").spawn().unwrap(); + let pid = child.id(); + child.wait().unwrap(); + pid +} + /// Return this process's working directory. pub(crate) fn here() -> PathBuf { std::env::current_dir().unwrap() diff --git a/src/ui.rs b/src/ui.rs index 37e38f1..5455fd7 100644 --- a/src/ui.rs +++ b/src/ui.rs @@ -2,8 +2,10 @@ //! buffered, wrapped in one synchronized update, and written only when they //! differ from the previous frame. -use std::io::{self, Write}; -use std::time::Duration; +use std::{ + io::{self, Write}, + time::Duration, +}; use crossterm::{ cursor::{Hide, MoveTo, Show}, @@ -411,8 +413,7 @@ fn render_peek(out: &mut impl Write, app: &App) -> io::Result<()> { // Screen lines for the selected task, once the core has streamed them. Empty // until then (or if the watch just switched); the box still frames cleanly. - let empty: Vec = Vec::new(); - let lines = app.screen_for(v.id).map(|s| &s.lines).unwrap_or(&empty); + let lines: &[String] = app.screen_for(v.id).map_or(&[], |s| &s.lines); let start = lines.len().saturating_sub(inner_h); let tail = &lines[start..]; @@ -748,10 +749,10 @@ fn selection_overlay<'a>(sel: Option<&Selection>, lines: &'a [String]) -> Vec<(u .iter() .enumerate() .filter_map(|(row, text)| { - // Screen row counts are bounded by the terminal's `u16` height. - let row = row as u16; - sel.row_segment(row, text, last) - .map(|(col, seg)| (row, col, seg)) + sel.row_segment(row, text, last).map(|(col, seg)| { + // Screen row counts are bounded by the terminal's `u16` height. + (row as u16, col, seg) + }) }) .collect() }