diff --git a/docs/agent-resume.md b/docs/agent-resume.md index 56a408c..f4e53e1 100644 --- a/docs/agent-resume.md +++ b/docs/agent-resume.md @@ -101,12 +101,11 @@ Every captured value eventually enters a shell command, which makes validation t Each tool implements the `Harness` trait in [`src/harness/mod.rs`](../src/harness/mod.rs). The methods keep detection, evidence collection, and command construction separate: -- `detect` classifies the accepted command shapes. +- `shape` supplies the program word and resume selector. The default `detect` and `resume_command` methods derive the accepted and canonical forms from that pair. - `instrument` returns spawn-time arguments, environment entries, and an optional pinned ID. - `parse_capture` reads an ID from hook or notify JSON. - `scrape_exit` reads an ID from retained terminal text. - `correlate_fs` finds one matching on-disk session. -- `resume_command` builds the canonical resume form. The supervisor resolves each harness home from the task's launch environment: the tool-specific variable first, then `$HOME` plus the tool's dot directory. That resolved path remains attached to the task for later filesystem correlation. diff --git a/src/app.rs b/src/app.rs index 5ccba03..db2559d 100644 --- a/src/app.rs +++ b/src/app.rs @@ -30,7 +30,7 @@ use crate::{ path, protocol::{ ClipboardKind, Command, Event, Key, Lifecycle, Mods, MouseBtn, MouseKind, RecoveryEntry, - ScreenView, ScrollAction, TaskView, + ScreenView, ScrollAction, TaskView, UNASSIGNED, }, selection::Selection, transport::{ExitIntent, SocketTransport, ThreadTransport, Transport}, @@ -84,14 +84,17 @@ pub enum Mode { Spawn, /// Live directory picker (the `@` flow) that sets `spawn_cwd`. PickDir, - /// Live group picker (the `g` flow) that reassigns the selected task's group. - PickGroup, + /// Live group picker (the `g` flow). Its target remains fixed if a snapshot + /// reorders the dashboard selection. + PickGroup { + target: u64, + }, /// Find palette for selecting a task from filtered results. Find, /// Typing a name to save the current tasks as a session. SaveSession, /// Editing the display name of the task selected when the prompt opened. - Rename, + Rename(u64), /// Picking a saved session to load. LoadSession, /// Overlay preview of the selected task. @@ -115,18 +118,18 @@ pub enum GroupMode { impl GroupMode { pub fn label(self) -> &'static str { match self { - GroupMode::State => "state", - GroupMode::Dir => "dir", - GroupMode::Custom => "custom", + Self::State => "state", + Self::Dir => "dir", + Self::Custom => "custom", } } /// Advance through State → Dir → Custom → State. - pub fn next(self) -> GroupMode { + pub fn next(self) -> Self { match self { - GroupMode::State => GroupMode::Dir, - GroupMode::Dir => GroupMode::Custom, - GroupMode::Custom => GroupMode::State, + Self::State => Self::Dir, + Self::Dir => Self::Custom, + Self::Custom => Self::State, } } } @@ -220,16 +223,12 @@ pub struct App { pub group_input: EditBuffer, pub group_candidates: Vec, pub group_sel: usize, - /// Id of the task being reassigned by the open group picker. - group_target: Option, // `/` find-palette state (only meaningful in `Mode::Find`). pub find_input: EditBuffer, /// Matching task IDs in display order. IDs remain stable if a daemon /// snapshot reorders `views` while the palette is open. pub find_candidates: Vec, pub find_sel: usize, - /// Task ID captured when the rename prompt opens. - rename_target: Option, // Load-session picker state. pub session_names: Vec, pub session_sel: usize, @@ -295,6 +294,12 @@ fn desired_mouse_capture(attached: Option<&ScreenView>, view_scroll: bool) -> bo } } +/// Select row 0 for an empty filter or no match; otherwise select the first +/// match on row 1. +fn preselected_row(filter_empty: bool, cands: usize) -> usize { + usize::from(!filter_empty && cands >= 2) +} + /// One Down keypress over a picker list: advance, clamped to the last row. /// Safe on an empty list because every picker pins its selection to 0 there. fn step_down(sel: usize, len: usize) -> usize { @@ -334,12 +339,12 @@ impl App { /// complete the hello handshake, so tasks outlive the UI and run under /// *this* client's env. The core lives in `fleetcom --daemon`, reached over /// the socket. - pub fn connect(rows: u16, cols: u16) -> io::Result { + pub fn connect(rows: u16, cols: u16) -> io::Result { let (stream, origin) = crate::daemon::connect_ready()?; // Split the stream here (the fallible part) so the transport factory in // `assemble` (which owns the wake sender) stays infallible. let read = stream.try_clone()?; - let mut app = App::assemble(rows, cols, move |_, _, wait_tx| { + let mut app = Self::assemble(rows, cols, move |_, _, wait_tx| { Box::new(SocketTransport::from_halves(stream, read, wait_tx)) }); app.daemon_backed = true; @@ -378,8 +383,8 @@ impl App { /// `--foreground`: run the core in-process on a thread (no daemon). A /// non-daemon escape hatch, and the deterministic target the UI harnesses use. - pub fn new_foreground(rows: u16, cols: u16) -> App { - App::assemble(rows, cols, |pr, c, wait_tx| { + pub fn new_foreground(rows: u16, cols: u16) -> Self { + Self::assemble(rows, cols, |pr, c, wait_tx| { Box::new(ThreadTransport::foreground(pr, c, wait_tx)) }) } @@ -389,7 +394,7 @@ impl App { rows: u16, cols: u16, make: impl FnOnce(u16, u16, Sender<()>) -> Box, - ) -> App { + ) -> Self { let invocation_dir = std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")); let invocation_label = path::abbreviate(&invocation_dir); // The core runs every PTY at the *content* size: full height minus the @@ -404,7 +409,7 @@ impl App { rows: pane_rows, cols, }); - App { + Self { transport, views: Vec::new(), focused_screen: None, @@ -430,11 +435,9 @@ impl App { group_input: EditBuffer::default(), group_candidates: Vec::new(), group_sel: 0, - group_target: None, find_input: EditBuffer::default(), find_candidates: Vec::new(), find_sel: 0, - rename_target: None, session_names: Vec::new(), session_sel: 0, session_recovery: Vec::new(), @@ -501,16 +504,20 @@ impl App { self.selection.as_ref() } - pub fn dir_label(&self, path: &Path) -> String { - path::abbreviate(path) - } - /// Height of a task's PTY grid: full screen minus the one-row status bar /// that attached mode paints. Uniform across tasks so attach never reflows. fn pane_rows(&self) -> u16 { self.rows.saturating_sub(1).max(1) } + /// Clamp a pointer to the child pane, excluding fleetcom's status row. + fn clamp_to_pane(&self, row: u16, col: u16) -> (u16, u16) { + ( + row.min(self.pane_rows().saturating_sub(1)), + col.min(self.cols.saturating_sub(1)), + ) + } + /// Task sections in render order. Navigation uses their flattened order. pub fn sections(&self) -> Vec<(String, Vec)> { let mut labeled: Vec<(u8, String, u8, String, u64, usize)> = self @@ -530,7 +537,7 @@ impl App { (b, l.to_string()) } GroupMode::Dir => { - let label = self.dir_label(&v.cwd); + let label = path::abbreviate(&v.cwd); // Keep the invocation directory first. let rank = if label == self.invocation_label { 0 } else { 1 }; (rank, label) @@ -538,11 +545,11 @@ impl App { GroupMode::Custom => match &v.group { Some(g) => (0, g.clone()), // Named groups sort before Unassigned. - None => (1, "Unassigned".to_string()), + None => (1, UNASSIGNED.to_string()), }, }; // Within each section, sort by row rank, directory, then task ID. - (rank, label, row_rank(v), self.dir_label(&v.cwd), v.id, i) + (rank, label, row_rank(v), path::abbreviate(&v.cwd), v.id, i) }) .collect(); // Apply the same case-insensitive collation to section and directory labels. @@ -777,12 +784,7 @@ impl App { } let mut last = 0; for (kind, text) in self.pending_clipboard.drain(..) { - let k = match kind { - ClipboardKind::Clipboard => 'c', - ClipboardKind::Primary => 'p', - ClipboardKind::Selection => 's', - }; - write!(out, "\x1b]52;{k};{}\x07", B64.encode(&text))?; + write!(out, "\x1b]52;{};{}\x07", kind.selector(), B64.encode(&text))?; last = text.chars().count(); } out.flush()?; @@ -872,7 +874,7 @@ impl App { CtEvent::Key(k) if matches!(k.kind, KeyEventKind::Press | KeyEventKind::Repeat) => { - self.on_key(out, k)?; + self.on_key(out, k); } CtEvent::Resize(cols, rows) => self.on_resize(rows, cols), CtEvent::Paste(s) => self.on_paste(&s), @@ -956,13 +958,8 @@ impl App { }); } - // An empty trailing fragment selects the resolved path. Otherwise, - // select the first matching row when one exists. - self.dir_sel = if partial.is_empty() || cands.len() < 2 { - 0 - } else { - 1 - }; + // Empty trailing input keeps the resolved path selected. + self.dir_sel = preselected_row(partial.is_empty(), cands.len()); self.dir_candidates = cands; } @@ -1014,10 +1011,12 @@ impl App { /// Open the `g` picker on the selected task; a no-op with no selection. fn open_group_picker(&mut self) { if let Some(i) = self.selected_task() { - self.group_target = Some(self.views[i].id); + // Store the target before building its candidate list. + self.mode = Mode::PickGroup { + target: self.views[i].id, + }; self.group_input.clear(); self.refresh_group_candidates(); - self.mode = Mode::PickGroup; } } @@ -1025,10 +1024,12 @@ impl App { /// byte order. fn refresh_group_candidates(&mut self) { // Mark the pinned target's group even if dashboard selection changes. - let current = self - .group_target - .and_then(|id| self.task_index(id)) - .and_then(|i| self.views[i].group.clone()); + let current = match self.mode { + Mode::PickGroup { target } => self + .task_index(target) + .and_then(|i| self.views[i].group.clone()), + _ => None, + }; let mark = |name: &str, is_current: bool| { if is_current { format!("{name} (current)") @@ -1038,7 +1039,7 @@ impl App { }; let mut cands = vec![GroupCand { - label: mark("Unassigned", current.is_none()), + label: mark(UNASSIGNED, current.is_none()), group: None, }]; @@ -1060,12 +1061,7 @@ impl App { }); } - // Empty input selects Unassigned; matched input selects the first group. - self.group_sel = if self.group_input.is_empty() || cands.len() < 2 { - 0 - } else { - 1 - }; + self.group_sel = preselected_row(self.group_input.is_empty(), cands.len()); self.group_candidates = cands; } @@ -1078,7 +1074,6 @@ impl App { fn close_group_picker(&mut self) { self.group_input.clear(); self.group_candidates.clear(); - self.group_target = None; self.mode = Mode::Dashboard; } @@ -1119,22 +1114,18 @@ impl App { /// Open the rename prompt for the selected task, prefilled with its name. fn open_rename_prompt(&mut self) { if let Some(i) = self.selected_task() { - self.rename_target = Some(self.views[i].id); self.input = EditBuffer::seeded(self.views[i].name.clone().unwrap_or_default()); - self.mode = Mode::Rename; + self.mode = Mode::Rename(self.views[i].id); } } - /// Clear the text-prompt state and return to the dashboard. Dropping - /// `rename_target` is a no-op for the other prompts: only the rename flow - /// sets it, and it re-arms on every open. + /// Clear the text-prompt state and return to the dashboard. fn close_prompt(&mut self) { self.input.clear(); - self.rename_target = None; self.mode = Mode::Dashboard; } - fn on_key(&mut self, out: &mut Stdout, k: KeyEvent) -> io::Result<()> { + fn on_key(&mut self, out: &mut Stdout, k: KeyEvent) { // Any key dismisses a lingering save/load notice. self.status = None; // Global escape hatch, except while attached (Ctrl-C belongs to the child). @@ -1145,23 +1136,22 @@ impl App { { self.exit_intent = ExitIntent::Disconnect; self.should_quit = true; - return Ok(()); + return; } match self.mode { Mode::Dashboard => self.on_key_dashboard(k), Mode::Spawn => self.on_key_spawn(k), Mode::PickDir => self.on_key_pickdir(k), - Mode::PickGroup => self.on_key_pickgroup(k), + Mode::PickGroup { .. } => self.on_key_pickgroup(k), Mode::Find => self.on_key_find(k), Mode::SaveSession => self.on_key_savesession(k), - Mode::Rename => self.on_key_rename(k), + Mode::Rename(_) => self.on_key_rename(k), Mode::LoadSession => self.on_key_loadsession(k), Mode::Peek => self.on_key_peek(k), Mode::Controls => self.on_key_controls(k), - Mode::Attached => self.on_key_attached(out, k)?, + Mode::Attached => self.on_key_attached(out, k), Mode::Disconnected => self.on_key_disconnected(k), } - Ok(()) } fn on_key_disconnected(&mut self, k: KeyEvent) { @@ -1252,7 +1242,7 @@ impl App { /// Shared editing for the single-line text prompts: Enter runs `submit` /// with the trimmed input and closes; Esc closes without submitting. - fn on_key_textinput(&mut self, k: KeyEvent, submit: fn(&mut App, &str)) { + fn on_key_textinput(&mut self, k: KeyEvent, submit: fn(&mut Self, &str)) { match k.code { KeyCode::Enter => { // Submit the text on both sides of the caret. @@ -1280,7 +1270,8 @@ impl App { // Whitespace-only input clears the name; the supervisor applies // the remaining label normalization. let name = Some(name.to_string()).filter(|s| !s.is_empty()); - if let Some(id) = app.rename_target { + // The mode retains the target until submission closes the prompt. + if let Mode::Rename(id) = app.mode { app.transport.send(Command::SetName { id, name }); } }); @@ -1387,8 +1378,8 @@ impl App { .get(self.group_sel) .and_then(|c| c.group.clone()) }; - if let Some(id) = self.group_target { - self.transport.send(Command::SetGroup { id, group }); + if let Mode::PickGroup { target } = self.mode { + self.transport.send(Command::SetGroup { id: target, group }); } self.close_group_picker(); } @@ -1450,7 +1441,7 @@ impl App { } } - fn on_key_attached(&mut self, out: &mut Stdout, k: KeyEvent) -> io::Result<()> { + fn on_key_attached(&mut self, out: &mut Stdout, k: KeyEvent) { // Ctrl-\ backgrounds the task; crossterm may report it as Ctrl-4. let detach = k.modifiers.contains(KeyModifiers::CONTROL) && matches!(k.code, KeyCode::Char('\\') | KeyCode::Char('4')); @@ -1462,7 +1453,7 @@ impl App { self.selection = None; // Repaint from scratch next tick; wipe the child's screen now. let _ = execute!(out, Clear(ClearType::All), MoveTo(0, 0)); - return Ok(()); + return; } // Keep one row of overlap between pages. let page = self.pane_rows().saturating_sub(1).max(1); @@ -1486,7 +1477,7 @@ impl App { self.forward_key(k); } } - return Ok(()); + return; } // Ctrl/Alt provide alternatives when the terminal intercepts Shift. if k.code == KeyCode::PageUp @@ -1497,10 +1488,9 @@ impl App { // Entering scrollback replaces the selected live rows. self.selection = None; self.send_scrollback(ScrollAction::Up(page)); - return Ok(()); + return; } self.forward_key(k); - Ok(()) } /// Forward an encodable keystroke to the focused task's PTY. @@ -1535,14 +1525,14 @@ impl App { }); } } - Mode::Spawn | Mode::SaveSession | Mode::Rename => { + Mode::Spawn | Mode::SaveSession | Mode::Rename(_) => { paste_into(&mut self.input, s); } Mode::PickDir => { paste_into(&mut self.dir_input, s); self.refresh_dir_candidates(); } - Mode::PickGroup => { + Mode::PickGroup { .. } => { paste_into(&mut self.group_input, s); self.refresh_group_candidates(); } @@ -1629,10 +1619,7 @@ impl App { self.send_scrollback(ScrollAction::Up(3)); return; } - // Keep the pointer coordinate within the child pane: the - // bottom row is fleetcom's status bar, not the child's. - let row = m.row.min(self.pane_rows().saturating_sub(1)); - let col = m.column.min(self.cols.saturating_sub(1)); + let (row, col) = self.clamp_to_pane(m.row, m.column); self.transport.send(Command::Mouse { id, kind, col, row }); } } @@ -1653,23 +1640,18 @@ impl App { .then(|| Selection::begin(row, col.min(self.cols.saturating_sub(1)))); self.selection.is_some() } - MouseKind::Drag(MouseBtn::Left) if self.selection.is_some() => { - let row = row.min(self.pane_rows().saturating_sub(1)); - let col = col.min(self.cols.saturating_sub(1)); - if let Some(sel) = self.selection.as_mut() { - sel.extend(row, col); - } - true - } - MouseKind::Release(MouseBtn::Left) if self.selection.is_some() => { + MouseKind::Drag(MouseBtn::Left) | MouseKind::Release(MouseBtn::Left) + if self.selection.is_some() => + { // The release cell is the final head, including for flicks // with no intermediate drag event. - let row = row.min(self.pane_rows().saturating_sub(1)); - let col = col.min(self.cols.saturating_sub(1)); + let (row, col) = self.clamp_to_pane(row, col); if let Some(sel) = self.selection.as_mut() { sel.extend(row, col); } - self.finish_selection(id); + if matches!(kind, MouseKind::Release(MouseBtn::Left)) { + self.finish_selection(id); + } true } _ => false, diff --git a/src/app_readme_tests.rs b/src/app_readme_tests.rs new file mode 100644 index 0000000..01617dc --- /dev/null +++ b/src/app_readme_tests.rs @@ -0,0 +1,629 @@ +//! Generates the four README dashboard fixtures under `docs/img`. +//! +//! The ignored test writes repository files on demand. Fabricated tasks and +//! fixed durations keep its output deterministic for a given `$HOME`. + +use super::*; + +/// Fixture dimensions. Thirty rows fit every section; 107 columns produce a +/// 71-column preview cell and an 80-column peek box. +const FIXTURE_ROWS: u16 = 30; +const FIXTURE_COLS: u16 = 107; + +/// Transport stub for fixtures that assign app state directly. +struct NoTransport; + +impl Transport for NoTransport { + fn send(&mut self, _cmd: Command) {} + + fn poll(&mut self) -> Vec { + Vec::new() + } + + fn connected(&self) -> bool { + true + } + + fn shutdown(&mut self, _intent: ExitIntent) {} +} + +/// Constant fixture duration in seconds. +const fn secs(n: u64) -> Duration { + Duration::from_secs(n) +} + +/// Constant fixture duration in minutes. +const fn mins(n: u64) -> Duration { + Duration::from_secs(n * 60) +} + +/// Live anchor preview with its matcher ID. +fn anchor(text: &str, rule: &'static str) -> Preview { + Preview { + text: text.to_string(), + source: PreviewSource::Anchor, + rule: Some(rule), + frozen: false, + } +} + +/// Live window-title preview. +fn title(text: &str) -> Preview { + Preview { + text: text.to_string(), + source: PreviewSource::Title, + rule: None, + frozen: false, + } +} + +/// Live last-row preview. +fn floor(text: &str) -> Preview { + Preview { + text: text.to_string(), + source: PreviewSource::Floor, + rule: None, + frozen: false, + } +} + +/// Frozen last-row preview for a finished task. +fn frozen(text: &str) -> Preview { + Preview { + text: text.to_string(), + source: PreviewSource::Floor, + rule: None, + frozen: true, + } +} + +/// Working directories rooted at `$HOME` for `~`-abbreviated section labels. +struct Dirs { + home: PathBuf, + fleetcom: PathBuf, + turret: PathBuf, + crabapple: PathBuf, + crabstep: PathBuf, + imessage: PathBuf, + logria: PathBuf, +} + +impl Dirs { + fn new(home: &Path) -> Self { + let code = home.join("Documents/Code"); + Self { + home: home.to_path_buf(), + fleetcom: code.join("Rust/fleetcom"), + turret: code.join("Apple/turret"), + crabapple: code.join("Rust/crabapple"), + crabstep: code.join("Rust/crabstep"), + imessage: code.join("Rust/imessage-exporter"), + logria: code.join("Rust/Logria"), + } + } +} + +/// Build a daemon-backed fixture with fleetcom as the invocation directory. +fn fixture_app(dirs: &Dirs, group_mode: GroupMode, views: Vec) -> App { + let mut app = App::assemble(FIXTURE_ROWS, FIXTURE_COLS, |_, _, _| Box::new(NoTransport)); + app.daemon_backed = true; + app.invocation_label = path::abbreviate(&dirs.fleetcom); + app.invocation_dir = dirs.fleetcom.clone(); + app.spawn_cwd = dirs.fleetcom.clone(); + app.group_mode = group_mode; + app.views = views; + app +} + +/// Active fixture: 12 active, two idle, and seven finished tasks. IDs encode +/// launch order; tags and completion determine row rank within each section. +fn live_fleet(dirs: &Dirs) -> Vec { + vec![ + TaskView { + id: 1, + command: "claude".to_string(), + cwd: dirs.fleetcom.clone(), + tagged: true, + group: Some("dashboard".to_string()), + name: Some("Dashboard Refine".to_string()), + lifecycle: Lifecycle::Active, + parked: false, + preview: anchor( + "✻ Scope small fixes for dashboard and CLI", + "claude:action-row", + ), + started_ago: mins(2), + quiet_ago: Some(secs(3)), + finished_ago: None, + }, + TaskView { + id: 2, + command: "claude".to_string(), + cwd: dirs.fleetcom.clone(), + tagged: true, + group: Some("dashboard".to_string()), + name: Some("Summary Refine".to_string()), + lifecycle: Lifecycle::Active, + parked: false, + preview: anchor("Inferring… · thinking with high effort", "claude:spinner"), + started_ago: mins(5), + quiet_ago: Some(secs(8)), + finished_ago: None, + }, + TaskView { + id: 3, + command: "grok".to_string(), + cwd: dirs.fleetcom.clone(), + tagged: false, + group: Some("dashboard".to_string()), + name: Some("Grok Language".to_string()), + lifecycle: Lifecycle::Active, + parked: false, + preview: anchor("Grok 4.5 (xhigh) · Responding…", "grok:spinner"), + started_ago: mins(12), + quiet_ago: Some(secs(4)), + finished_ago: None, + }, + TaskView { + id: 4, + command: "codex".to_string(), + cwd: dirs.fleetcom.clone(), + tagged: false, + group: Some("dashboard".to_string()), + name: Some("Codex Language".to_string()), + lifecycle: Lifecycle::Active, + parked: false, + preview: anchor(CODEX_LANGUAGE, "codex:working"), + started_ago: mins(18), + quiet_ago: Some(secs(2)), + finished_ago: None, + }, + TaskView { + id: 5, + command: "codex".to_string(), + cwd: dirs.fleetcom.clone(), + tagged: false, + group: Some("dashboard".to_string()), + name: Some("Codex Review".to_string()), + lifecycle: Lifecycle::Active, + parked: false, + preview: anchor(CODEX_REVIEW, "codex:working"), + started_ago: mins(24), + quiet_ago: Some(secs(6)), + finished_ago: None, + }, + TaskView { + id: 6, + command: "cargo test".to_string(), + cwd: dirs.fleetcom.clone(), + tagged: false, + group: Some("tests".to_string()), + name: None, + lifecycle: Lifecycle::Ok, + parked: false, + preview: frozen(FLEETCOM_TESTS), + started_ago: mins(2), + quiet_ago: None, + finished_ago: Some(secs(12)), + }, + TaskView { + id: 19, + command: "cargo clippy".to_string(), + cwd: dirs.fleetcom.clone(), + tagged: false, + group: Some("tests".to_string()), + name: None, + lifecycle: Lifecycle::Failed, + parked: false, + preview: frozen(FLEETCOM_CLIPPY), + started_ago: mins(5), + quiet_ago: None, + finished_ago: Some(mins(3)), + }, + TaskView { + id: 7, + command: "claude".to_string(), + cwd: dirs.home.clone(), + tagged: false, + group: Some("desktop".to_string()), + name: Some("claude agents".to_string()), + lifecycle: Lifecycle::Active, + parked: false, + preview: title("2 awaiting input · claude agents"), + started_ago: mins(63), + quiet_ago: Some(secs(9)), + finished_ago: None, + }, + TaskView { + id: 8, + command: "zellij".to_string(), + cwd: dirs.home.clone(), + tagged: false, + group: Some("desktop".to_string()), + name: Some("Zellij".to_string()), + lifecycle: Lifecycle::Active, + parked: false, + preview: title("Desktop ¦ Utility"), + started_ago: mins(126), + quiet_ago: Some(secs(4)), + finished_ago: None, + }, + TaskView { + id: 9, + command: "python".to_string(), + cwd: dirs.home.clone(), + tagged: false, + group: None, + name: None, + lifecycle: Lifecycle::Idle, + parked: true, + preview: floor(">>>"), + started_ago: mins(48), + quiet_ago: Some(mins(41)), + finished_ago: None, + }, + TaskView { + id: 10, + command: "brew update && brew upgrade".to_string(), + cwd: dirs.home.clone(), + tagged: false, + group: Some("desktop".to_string()), + name: None, + lifecycle: Lifecycle::Ok, + parked: false, + preview: frozen("Already up-to-date."), + started_ago: mins(14), + quiet_ago: None, + finished_ago: Some(mins(13)), + }, + TaskView { + id: 11, + command: "grok".to_string(), + cwd: dirs.turret.clone(), + tagged: false, + group: Some("turret".to_string()), + name: Some("Game Infra Review".to_string()), + lifecycle: Lifecycle::Active, + parked: false, + preview: title("Turret Game Codebase Organization and Ex… - grok"), + started_ago: mins(8), + quiet_ago: Some(secs(5)), + finished_ago: None, + }, + TaskView { + id: 12, + command: "codex".to_string(), + cwd: dirs.turret.clone(), + tagged: false, + group: Some("turret".to_string()), + name: Some("Missile Nerf".to_string()), + lifecycle: Lifecycle::Active, + parked: false, + preview: anchor(MISSILE_NERF, "codex:working"), + started_ago: mins(33), + quiet_ago: Some(secs(7)), + finished_ago: None, + }, + TaskView { + id: 13, + command: "codex".to_string(), + cwd: dirs.turret.clone(), + tagged: false, + group: Some("turret".to_string()), + name: Some("EMP Nerf".to_string()), + lifecycle: Lifecycle::Active, + parked: false, + preview: anchor(EMP_NERF, "codex:working"), + started_ago: mins(35), + quiet_ago: Some(secs(3)), + finished_ago: None, + }, + TaskView { + id: 14, + command: "cargo test".to_string(), + cwd: dirs.crabapple.clone(), + tagged: false, + group: Some("tests".to_string()), + name: None, + lifecycle: Lifecycle::Ok, + parked: false, + preview: frozen(CRABAPPLE_TESTS), + started_ago: mins(18), + quiet_ago: None, + finished_ago: Some(mins(17)), + }, + TaskView { + id: 15, + command: "cargo test".to_string(), + cwd: dirs.crabstep.clone(), + tagged: false, + group: Some("tests".to_string()), + name: None, + lifecycle: Lifecycle::Ok, + parked: false, + preview: frozen(CRABSTEP_TESTS), + started_ago: mins(22), + quiet_ago: None, + finished_ago: Some(mins(21)), + }, + TaskView { + id: 16, + command: "claude".to_string(), + cwd: dirs.imessage.clone(), + tagged: false, + group: None, + name: None, + lifecycle: Lifecycle::Active, + parked: false, + preview: anchor("✻ Review GitHub issue 780", "claude:action-row"), + started_ago: mins(6), + quiet_ago: Some(secs(2)), + finished_ago: None, + }, + TaskView { + id: 17, + command: "cargo test".to_string(), + cwd: dirs.imessage.clone(), + tagged: false, + group: Some("tests".to_string()), + name: None, + lifecycle: Lifecycle::Ok, + parked: false, + preview: frozen(IMESSAGE_TESTS), + started_ago: mins(20), + quiet_ago: None, + finished_ago: Some(mins(19)), + }, + TaskView { + id: 18, + command: "cargo test".to_string(), + cwd: dirs.logria.clone(), + tagged: false, + group: Some("tests".to_string()), + name: None, + lifecycle: Lifecycle::Ok, + parked: false, + preview: frozen(LOGRIA_TESTS), + started_ago: mins(32), + quiet_ago: None, + finished_ago: Some(mins(31)), + }, + TaskView { + id: 20, + command: "cargo watch -x test".to_string(), + cwd: dirs.logria.clone(), + tagged: false, + group: Some("tests".to_string()), + name: None, + lifecycle: Lifecycle::Active, + parked: false, + preview: floor(LOGRIA_WATCH), + started_ago: secs(45), + quiet_ago: Some(secs(2)), + finished_ago: None, + }, + TaskView { + id: 21, + command: "cargo doc --open".to_string(), + cwd: dirs.logria.clone(), + tagged: false, + group: Some("tests".to_string()), + name: None, + lifecycle: Lifecycle::Idle, + parked: true, + preview: floor(LOGRIA_DOC), + started_ago: mins(28), + quiet_ago: Some(mins(26)), + finished_ago: None, + }, + ] +} + +/// Per-task state overrides for the quiet fixture. +struct Quiet { + id: u64, + lifecycle: Lifecycle, + parked: bool, + started_ago: Duration, + quiet_ago: Option, + finished_ago: Option, + /// Optional replacement anchor preview as `(text, matcher ID)`. + preview: Option<(&'static str, &'static str)>, +} + +impl Quiet { + /// Idle task timed from its last output. + const fn idle(id: u64, started: Duration, quiet: Duration) -> Self { + Self { + id, + lifecycle: Lifecycle::Idle, + parked: true, + started_ago: started, + quiet_ago: Some(quiet), + finished_ago: None, + preview: None, + } + } + + /// Active task timed from launch. + const fn active(id: u64, started: Duration, quiet: Duration) -> Self { + Self { + lifecycle: Lifecycle::Active, + parked: false, + ..Self::idle(id, started, quiet) + } + } + + /// Successful task timed from exit. + const fn done(id: u64, started: Duration, finished: Duration) -> Self { + Self { + lifecycle: Lifecycle::Ok, + parked: false, + quiet_ago: None, + finished_ago: Some(finished), + ..Self::idle(id, started, finished) + } + } + + /// Failed task timed from exit. + const fn failed(id: u64, started: Duration, finished: Duration) -> Self { + Self { + lifecycle: Lifecycle::Failed, + ..Self::done(id, started, finished) + } + } + + /// Replace the anchor preview. + const fn saying(mut self, text: &'static str, rule: &'static str) -> Self { + self.preview = Some((text, rule)); + self + } +} + +/// Overrides for every task in the quiet fixture. +const QUIET: [Quiet; 21] = [ + Quiet::idle(1, mins(22), secs(32)), + Quiet::idle(2, mins(21), secs(13)).saying(SUMMARY_QUIET, "claude:action-row"), + Quiet::idle(3, mins(21), mins(1)), + Quiet::idle(4, mins(21), mins(1)), + Quiet::idle(5, mins(21), mins(1)), + Quiet::done(6, mins(21), mins(20)), + Quiet::idle(7, mins(21), mins(1)), + // Keep Zellij active so the Running section remains non-empty. + Quiet::active(8, mins(20), secs(4)), + Quiet::idle(9, mins(22), mins(20)), + Quiet::done(10, mins(16), mins(15)), + Quiet::idle(11, mins(21), mins(1)), + Quiet::idle(12, mins(21), mins(1)), + Quiet::idle(13, mins(21), mins(1)), + Quiet::done(14, mins(20), mins(19)), + Quiet::done(15, mins(21), mins(20)), + Quiet::idle(16, mins(21), mins(1)), + Quiet::done(17, mins(21), mins(20)), + Quiet::done(18, mins(21), mins(20)), + Quiet::failed(19, mins(24), mins(21)), + Quiet::idle(20, mins(4), mins(2)), + Quiet::idle(21, mins(30), mins(28)), +]; + +/// Quiet fixture: one active, 13 idle, and seven finished tasks. +fn quiet_fleet(dirs: &Dirs) -> Vec { + let mut views = live_fleet(dirs); + assert_eq!( + views.len(), + QUIET.len(), + "every task needs a peek-frame override" + ); + for v in &mut views { + let Some(q) = QUIET.iter().find(|q| q.id == v.id) else { + panic!("no peek-frame override for task {}", v.id); + }; + v.lifecycle = q.lifecycle; + v.parked = q.parked; + v.started_ago = q.started_ago; + v.quiet_ago = q.quiet_ago; + v.finished_ago = q.finished_ago; + if let Some((text, rule)) = q.preview { + v.preview = anchor(text, rule); + } + } + views +} + +// Store full preview text so truncation comes from the renderer. +const CODEX_LANGUAGE: &str = + "gpt-5.6-sol high · fleetcom · feat/cs/interface-fixes · 387K used · 9.53M in · 61.2K out"; +const CODEX_REVIEW: &str = + "gpt-5.6-sol high · fleetcom · feat/cs/interface-fixes · 221K used · 4.41M in · 38.7K out"; +const MISSILE_NERF: &str = "gpt-5.6-sol high · turret · main · 129K used · 1.31M in · 10.1K out"; +const EMP_NERF: &str = "gpt-5.6-sol high · turret · main · 161K used · 1.64M in · 10.4K out"; +const FLEETCOM_TESTS: &str = + "test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.03s"; +const LOGRIA_TESTS: &str = "test result: ok. 223 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.38s"; +const CRABAPPLE_TESTS: &str = "all doctests ran in 0.39s; merged doctests compilation took 0.38s"; +const CRABSTEP_TESTS: &str = "all doctests ran in 0.83s; merged doctests compilation took 0.81s"; +const IMESSAGE_TESTS: &str = "all doctests ran in 1.99s; merged doctests compilation took 1.95s"; +const FLEETCOM_CLIPPY: &str = + "error: could not compile `fleetcom` (lib test) due to 1 previous error"; +const LOGRIA_WATCH: &str = "[Running 'cargo test'] test result: ok. 223 passed; 0 failed"; +const LOGRIA_DOC: &str = "Finished `dev` profile [unoptimized + debuginfo] target(s) in 3.41s"; +/// Quiet-fixture summary preview. +const SUMMARY_QUIET: &str = "✻ Review fleetcom preview design document"; + +/// Visible `cargo test` tail used by the peek fixture. +fn cargo_test_screen(id: u64) -> ScreenView { + let lines = [ + "test util::sanitizers::tests::test_length_clean ... ok", + "test util::sanitizers::tests::test_row_length_clean ... ok", + "test util::sanitizers::tests::test_length_dirty ... ok", + "test util::sanitizers::tests::test_length_wide_chars ... ok", + "test util::sanitizers::tests::test_sanitize_filename_clean ... ok", + "test util::sanitizers::tests::test_row_length_dirty ... ok", + "test util::sanitizers::tests::test_row_length_wide_chars ... ok", + "test util::sanitizers::tests::test_sanitize_filename_control_chars ... ok", + "test util::sanitizers::tests::test_sanitize_filename_trim ... ok", + "test util::sanitizers::tests::test_sanitize_filename_invalid_chars ... ok", + "test util::sanitizers::tests::test_sanitize_filename_long ... ok", + "", + LOGRIA_TESTS, + "", + ]; + ScreenView { + id, + lines: lines.iter().map(|s| s.to_string()).collect(), + // Peek reads plain lines; formatted bytes are unused. + formatted: Vec::new(), + cursor: (0, 0), + hide_cursor: true, + wants_mouse: false, + alt_screen: false, + alt_scroll: false, + scrollback: 0, + } +} + +/// Render one fixture frame. +fn frame(app: &mut App) -> Vec { + // Set a stable captured window title. + let mut out = b"\x1b]0;fleetcom\x07".to_vec(); + let painted = out.len(); + crate::ui::render(&mut out, app).expect("a fixture frame always paints"); + assert!(out.len() > painted, "a fresh App must emit its first frame"); + // Park the cursor outside centered overlays. + out.extend_from_slice(format!("\x1b[{};1H", app.rows).as_bytes()); + out +} + +/// Rewrite the four deterministic README dashboard fixtures under `docs/img`. +#[test] +#[ignore = "writes docs/img/*.ansi; run by hand to refresh the README screenshots"] +fn write_readme_screenshot_fixtures() { + let home = std::env::var("HOME").expect("HOME must be set to abbreviate the section labels"); + assert!(!home.is_empty(), "HOME must not be empty"); + let dirs = Dirs::new(Path::new(&home)); + let out_dir = Path::new(env!("CARGO_MANIFEST_DIR")).join("docs/img"); + + // Directory grouping with a live Codex task selected. + let mut app = fixture_app(&dirs, GroupMode::Dir, live_fleet(&dirs)); + app.selected_id = Some(4); + std::fs::write(out_dir.join("home.ansi"), frame(&mut app)).unwrap(); + + // State grouping with a completed test selected in peek. + let mut app = fixture_app(&dirs, GroupMode::State, quiet_fleet(&dirs)); + app.mode = Mode::Peek; + app.selected_id = Some(14); + // Seed the watched screen because NoTransport emits no frames. + app.focused_screen = Some(cargo_test_screen(14)); + std::fs::write(out_dir.join("quickpeek.ansi"), frame(&mut app)).unwrap(); + + // Custom grouping splits fleetcom tasks between dashboard and tests. + let mut app = fixture_app(&dirs, GroupMode::Custom, live_fleet(&dirs)); + app.selected_id = Some(4); + std::fs::write(out_dir.join("groups.ansi"), frame(&mut app)).unwrap(); + + // Controls overlay on the directory-grouped fixture. + let mut app = fixture_app(&dirs, GroupMode::Dir, live_fleet(&dirs)); + app.selected_id = Some(4); + app.mode = Mode::Controls; + std::fs::write(out_dir.join("controls.ansi"), frame(&mut app)).unwrap(); +} diff --git a/src/app_tests.rs b/src/app_tests.rs index bc084ce..9fb66cc 100644 --- a/src/app_tests.rs +++ b/src/app_tests.rs @@ -2,7 +2,7 @@ use super::*; use crate::{ protocol::{Preview, PreviewSource}, supervisor::Supervisor, - testutil::{temp, wait_until}, + testutil::{Scratch, temp, wait_until}, transport::LocalTransport, ui::scroll_window, }; @@ -11,8 +11,8 @@ impl App { /// A synchronous App: the supervisor ticks inline on `poll`, so `send` /// then `pump` is deterministic with no core-thread timing to race. /// Uses this process's launch context. - fn new_local(rows: u16, cols: u16) -> App { - App::assemble(rows, cols, |pr, c, _wait_tx| { + fn new_local(rows: u16, cols: u16) -> Self { + Self::assemble(rows, cols, |pr, c, _wait_tx| { let mut sup = Supervisor::new(pr, c, 2000); sup.set_launch_context(crate::protocol::LaunchContext::here()); Box::new(LocalTransport::new(sup)) @@ -21,8 +21,8 @@ impl App { /// `new_local` with an explicit launch context, for tests that must pin /// the core's session root instead of inheriting this process's env. - fn new_local_with_ctx(rows: u16, cols: u16, ctx: crate::protocol::LaunchContext) -> App { - App::assemble(rows, cols, move |pr, c, _wait_tx| { + fn new_local_with_ctx(rows: u16, cols: u16, ctx: crate::protocol::LaunchContext) -> Self { + Self::assemble(rows, cols, move |pr, c, _wait_tx| { let mut sup = Supervisor::new(pr, c, 2000); sup.set_launch_context(ctx); Box::new(LocalTransport::new(sup)) @@ -77,6 +77,31 @@ impl App { .map(|(l, idxs)| (l, idxs.into_iter().map(|i| self.views[i].id).collect())) .collect() } + + /// Spawn `cmd`, resolve its dashboard selection, and attach to it. + fn attached(rows: u16, cols: u16, cmd: &str) -> (Self, u64) { + let mut app = Self::new_local(rows, cols); + let dir = app.invocation_dir.clone(); + app.spawn_in(cmd, dir); + app.pump(); + app.resolve_selection(); + app.attach(); + let id = app.focused_id.expect("attached"); + (app, id) + } +} + +// Key-event constructors, used file-wide by every `on_key_*` call. +fn key(code: KeyCode) -> KeyEvent { + KeyEvent::new(code, KeyModifiers::NONE) +} + +fn ctrl(code: KeyCode) -> KeyEvent { + KeyEvent::new(code, KeyModifiers::CONTROL) +} + +fn shift(code: KeyCode) -> KeyEvent { + KeyEvent::new(code, KeyModifiers::SHIFT) } /// Selection is bound to a task id, so a reorder (here: tagging a task into @@ -164,14 +189,13 @@ fn dir_sections_collate_case_insensitively() { app.pump(); app.group_mode = GroupMode::Dir; - let (z, a) = (app.dir_label(&upper), app.dir_label(&lower)); + let (z, a) = (path::abbreviate(&upper), path::abbreviate(&lower)); assert!(z < a, "byte order must put Zed first for this test to bite"); assert_eq!( app.section_ids(), vec![(a, vec![2]), (z, vec![1])], "apple before Zed once the label folds" ); - let _ = std::fs::remove_dir_all(&base); } /// `s` cycles through all grouping modes. @@ -184,7 +208,7 @@ fn group_mode_cycles_state_dir_custom() { let mut app = App::new_local(30, 100); assert_eq!(app.group_mode, GroupMode::State); for expect in [GroupMode::Dir, GroupMode::Custom, GroupMode::State] { - app.on_key_dashboard(KeyEvent::new(KeyCode::Char('s'), KeyModifiers::NONE)); + app.on_key_dashboard(key(KeyCode::Char('s'))); assert_eq!(app.group_mode, expect); } } @@ -296,7 +320,6 @@ fn custom_mode_clusters_by_dir_within_group() { vec![("alpha".to_string(), vec![2, 3, 1])], "dir a's tasks cluster (in id order) ahead of dir b's" ); - let _ = std::fs::remove_dir_all(&base); } /// A manual tag must pull a task out of Completed into In use, even after it @@ -596,7 +619,7 @@ fn state_mode_ordering_survives_the_row_key_split() { app.views[i].parked = parked; } - let (a, b) = (app.dir_label(&dir_a), app.dir_label(&dir_b)); + let (a, b) = (path::abbreviate(&dir_a), path::abbreviate(&dir_b)); assert!( b < a, "byte order must put dir b first, or this test cannot see the collation" @@ -611,7 +634,6 @@ fn state_mode_ordering_survives_the_row_key_split() { ], "four sections in state order; within each, dir a before dir b, then id" ); - let _ = std::fs::remove_dir_all(&base); } /// `r` sends `Restart` only for a finished selection. On a running task @@ -623,8 +645,11 @@ fn rerun_key_is_gated_to_finished_tasks() { let mut app = App::new_local(30, 100); let dir = temp("app_rerun"); let marker = dir.join("marker"); - app.spawn_in("sleep 30", dir.clone()); // id 1: stays running - app.spawn_in(&format!("echo run >> {}", marker.display()), dir.clone()); // id 2 + app.spawn_in("sleep 30", dir.to_path_buf()); // id 1: stays running + app.spawn_in( + &format!("echo run >> {}", marker.display()), + dir.to_path_buf(), + ); // id 2 wait_until(Duration::from_secs(5), || { app.pump(); app.views @@ -634,7 +659,7 @@ fn rerun_key_is_gated_to_finished_tasks() { // Running selection: `r` must send nothing (and thus kill nothing). app.selected_id = Some(1); - app.on_key_dashboard(KeyEvent::new(KeyCode::Char('r'), KeyModifiers::NONE)); + app.on_key_dashboard(key(KeyCode::Char('r'))); app.pump(); assert!(app.status.is_none(), "no Restart should have been sent"); assert!( @@ -646,7 +671,7 @@ fn rerun_key_is_gated_to_finished_tasks() { // Finished selection: `r` reruns it under the same id. app.selected_id = Some(2); - app.on_key_dashboard(KeyEvent::new(KeyCode::Char('r'), KeyModifiers::NONE)); + app.on_key_dashboard(key(KeyCode::Char('r'))); wait_until(Duration::from_secs(5), || { app.pump(); std::fs::read_to_string(&marker) @@ -662,11 +687,10 @@ fn rerun_key_is_gated_to_finished_tasks() { app.views.iter().any(|v| v.id == 2), "rerun must keep the id" ); - let _ = std::fs::remove_dir_all(&dir); } /// Scratch config dir with the given pre-written (empty) session recipes. -fn session_scratch(tag: &str, names: &[&str]) -> PathBuf { +fn session_scratch(tag: &str, names: &[&str]) -> Scratch { let dir = temp(&format!("app_{tag}")); std::fs::create_dir_all(dir.join("sessions")).unwrap(); for n in names { @@ -699,7 +723,7 @@ fn o_key_round_trips_the_session_list_through_the_core() { let mut app = app_with_config_dir(&dir); app.session_sel = 3; // stale from a previous picker visit - app.on_key_dashboard(KeyEvent::new(KeyCode::Char('o'), KeyModifiers::NONE)); + app.on_key_dashboard(key(KeyCode::Char('o'))); assert!(matches!(app.mode, Mode::LoadSession)); assert!( app.session_names.is_empty(), @@ -716,7 +740,6 @@ fn o_key_round_trips_the_session_list_through_the_core() { vec!["a".to_string(), "b".to_string()], "the Sessions reply populates the picker, sorted" ); - let _ = std::fs::remove_dir_all(&dir); } /// A shorter list arriving while the picker is open clamps the selection so @@ -725,7 +748,7 @@ fn o_key_round_trips_the_session_list_through_the_core() { fn session_selection_clamps_when_a_shorter_list_arrives() { let dir = session_scratch("sess_clamp", &["a", "b", "c"]); let mut app = app_with_config_dir(&dir); - app.on_key_dashboard(KeyEvent::new(KeyCode::Char('o'), KeyModifiers::NONE)); + app.on_key_dashboard(key(KeyCode::Char('o'))); app.pump(); assert_eq!(app.session_names.len(), 3); app.session_sel = 2; @@ -744,7 +767,6 @@ fn session_selection_clamps_when_a_shorter_list_arrives() { app.pump(); assert!(app.session_names.is_empty()); assert_eq!(app.session_sel, 0); - let _ = std::fs::remove_dir_all(&dir); } /// Write a recovery fixture whose commands run in `dir`. @@ -805,7 +827,6 @@ fn sessions_reply_populates_and_clamps_both_lists() { assert_eq!(app.session_recovery.len(), 1); assert_eq!(app.recovery_sel, 0, "recovery selection must clamp"); assert_eq!(app.session_sel, 0); - let _ = std::fs::remove_dir_all(&dir); } /// Reopening the picker resets its page and recovery state. @@ -833,7 +854,6 @@ fn o_key_resets_the_picker_to_the_saved_page() { app.session_recovery.is_empty(), "the picker opens empty until the reply lands" ); - let _ = std::fs::remove_dir_all(&dir); } /// Tab does not leave the saved page when no recovery entries exist. @@ -849,7 +869,6 @@ fn tab_is_a_no_op_without_recovery_entries() { assert_eq!(app.session_page, SessionPage::Saved); app.on_key_loadsession(key(KeyCode::BackTab)); assert_eq!(app.session_page, SessionPage::Saved); - let _ = std::fs::remove_dir_all(&dir); } /// Tab switches available pages without resetting either selection. @@ -884,7 +903,6 @@ fn tab_toggles_pages_and_selections_stay_independent() { app.on_key_loadsession(key(KeyCode::BackTab)); assert_eq!(app.session_page, SessionPage::Recovery); assert_eq!(app.recovery_sel, 1, "the recovery selection survives too"); - let _ = std::fs::remove_dir_all(&dir); } /// An empty recovery refresh returns the picker to the saved page. @@ -913,7 +931,6 @@ fn emptied_recovery_list_returns_to_the_saved_page() { app.pump(); assert!(app.session_recovery.is_empty()); assert_eq!(app.session_page, SessionPage::Saved); - let _ = std::fs::remove_dir_all(&dir); } /// Enter loads the selected recovery stem and displays the resulting status. @@ -952,7 +969,6 @@ fn enter_on_the_recovery_page_loads_the_selected_stem() { ); assert_eq!(app.views.len(), 1); assert_eq!(app.views[0].command, "sleep 7"); - let _ = std::fs::remove_dir_all(&dir); } /// Esc closes the recovery page. @@ -972,7 +988,6 @@ fn esc_closes_the_picker_from_the_recovery_page() { assert_eq!(app.session_page, SessionPage::Recovery); app.on_key_loadsession(key(KeyCode::Esc)); assert!(matches!(app.mode, Mode::Dashboard)); - let _ = std::fs::remove_dir_all(&dir); } /// The `@` recent list is the distinct task cwds, newest first. @@ -1017,12 +1032,11 @@ fn list_dirs_collates_case_insensitively() { vec!["apple", "Beta", "cider", "Zed"], "byte order would read Beta, Zed, apple, cider" ); - let _ = std::fs::remove_dir_all(&base); } /// Build an `@`-picker fixture with `fleetcom` as the invocation directory, /// three sibling task directories, and a `fleetcom/docs` subdirectory. -fn recents_fixture(tag: &str) -> (App, PathBuf) { +fn recents_fixture(tag: &str) -> (App, Scratch) { let root = temp(tag); let rust = root.join("Documents/Code/Rust"); for name in ["fleetcom", "Logria", "crabapple", "crabstep"] { @@ -1078,7 +1092,6 @@ fn pickdir_fragment_surfaces_a_sibling_recent() { "the match must be a sibling, not a subdirectory" ); assert_eq!(app.dir_sel, 1, "the recent is preselected for Enter"); - let _ = std::fs::remove_dir_all(&root); } /// Current-task directories use case-insensitive substring matching. @@ -1099,7 +1112,6 @@ fn pickdir_recent_match_folds_case() { app.on_key_pickdir(key(KeyCode::Esc)); type_pickdir(&mut app, "ria"); assert_eq!(jump_paths(&app), vec![rust.join("Logria")]); - let _ = std::fs::remove_dir_all(&root); } /// A fragment includes every matching current-task directory. @@ -1116,13 +1128,13 @@ fn pickdir_fragment_surfaces_every_matching_recent() { vec![rust.join("crabapple"), rust.join("crabstep")], "recents keep their newest-first order" ); - let _ = std::fs::remove_dir_all(&root); } /// Parent components do not match current-task directories. #[test] fn pickdir_middle_component_matches_no_recent() { - let (mut app, root) = recents_fixture("pickdir_recent_middle"); + // Keep the scratch guard alive while `type_pickdir` scans the fixture tree. + let (mut app, _root) = recents_fixture("pickdir_recent_middle"); type_pickdir(&mut app, "doc"); @@ -1135,7 +1147,6 @@ fn pickdir_middle_component_matches_no_recent() { assert_eq!(app.dir_candidates[1].kind, DirKind::Into); assert_eq!(app.dir_candidates[1].label, "docs"); assert_eq!(app.dir_sel, 1, "the subdirectory keeps row 1"); - let _ = std::fs::remove_dir_all(&root); } /// A `/` omits current-task directories from the picker. @@ -1170,7 +1181,6 @@ fn pickdir_slash_suppresses_recents() { assert!(jump_paths(&app).is_empty()); assert_eq!(app.dir_candidates[1].path, rust.join("Logria")); assert_eq!(app.dir_candidates[1].kind, DirKind::Into); - let _ = std::fs::remove_dir_all(&root); } /// An empty field lists every current-task directory. @@ -1192,7 +1202,6 @@ fn pickdir_empty_input_lists_every_recent() { ] ); assert_eq!(app.dir_sel, 0, "an empty field keeps the current dir"); - let _ = std::fs::remove_dir_all(&root); } /// A current-task directory that is also a subdirectory appears once, using @@ -1215,7 +1224,6 @@ fn pickdir_dedupes_a_recent_that_is_also_a_subdirectory() { assert_eq!(app.dir_candidates.len(), 2); assert_eq!(app.dir_candidates[1].kind, DirKind::Jump); assert_eq!(app.dir_candidates[1].path, docs); - let _ = std::fs::remove_dir_all(&root); } /// Focus is by id, so it points at the same task even after the list shifts @@ -1792,13 +1800,7 @@ fn input_modes_match_screen_type() { /// mouse-aware children. #[test] fn wheel_up_enters_scroll_view_for_inline_children() { - let mut app = App::new_local(30, 100); - let dir = app.invocation_dir.clone(); - app.spawn_in("sleep 5", dir); - app.pump(); - app.resolve_selection(); - app.attach(); - let id = app.focused_id.expect("attached"); + let (mut app, id) = App::attached(30, 100, "sleep 5"); let screen = |wants_mouse| ScreenView { id, lines: Vec::new(), @@ -1842,8 +1844,6 @@ fn attached_wheel_honors_the_childs_1007_veto() { }; // Send one wheel notch and return the first `take` bytes read by the child. let run = |veto: bool, take: usize, out: PathBuf| -> Vec { - let mut app = App::new_local(30, 100); - let cwd = app.invocation_dir.clone(); let modes = if veto { "\\033[?1049h\\033[?1007l" } else { @@ -1854,11 +1854,7 @@ fn attached_wheel_honors_the_childs_1007_veto() { "stty -icanon -echo min 1 time 0; printf '{modes}'; head -c {take} > {}", out.display() ); - app.spawn_in(&cmd, cwd); - app.pump(); - app.resolve_selection(); - app.attach(); - let id = app.focused_id.expect("attached"); + let (mut app, id) = App::attached(30, 100, &cmd); app.set_watch(Some((id, true))); // Wait for the child's terminal modes to reach the client. assert!( @@ -1896,43 +1892,27 @@ fn attached_wheel_honors_the_childs_1007_veto() { run(false, 9, dir.join("dflt")), b"\x1b[A\x1b[A\x1b[A".to_vec() ); - let _ = std::fs::remove_dir_all(&dir); } /// Scrollback opens with modified PageUp and closes on Esc or typing. #[test] fn scroll_view_entry_and_exit() { - let mut app = App::new_local(30, 100); - let dir = app.invocation_dir.clone(); - app.spawn_in("sleep 5", dir); - app.pump(); - app.resolve_selection(); - app.attach(); + let (mut app, _) = App::attached(30, 100, "sleep 5"); assert!(app.mode == Mode::Attached); let mut out = io::stdout(); - let key = |code| KeyEvent::new(code, KeyModifiers::NONE); - app.on_key_attached( - &mut out, - KeyEvent::new(KeyCode::PageUp, KeyModifiers::SHIFT), - ) - .unwrap(); + app.on_key_attached(&mut out, shift(KeyCode::PageUp)); assert!(app.view_scroll, "Shift+PageUp must enter the scroll view"); - app.on_key_attached(&mut out, key(KeyCode::Esc)).unwrap(); + app.on_key_attached(&mut out, key(KeyCode::Esc)); assert!(!app.view_scroll, "Esc must return to live"); - app.on_key_attached( - &mut out, - KeyEvent::new(KeyCode::PageUp, KeyModifiers::CONTROL), - ) - .unwrap(); + app.on_key_attached(&mut out, ctrl(KeyCode::PageUp)); assert!(app.view_scroll, "Ctrl+PageUp is an entry fallback"); - app.on_key_attached(&mut out, key(KeyCode::Char('x'))) - .unwrap(); + app.on_key_attached(&mut out, key(KeyCode::Char('x'))); assert!(!app.view_scroll, "typing must snap back to live"); // Plain PageUp is forwarded to the child. - app.on_key_attached(&mut out, key(KeyCode::PageUp)).unwrap(); + app.on_key_attached(&mut out, key(KeyCode::PageUp)); assert!(!app.view_scroll); } @@ -1961,18 +1941,6 @@ fn wheel_moves_dashboard_selection() { // --- `g` group picker ------------------------------------------------- -fn key(code: KeyCode) -> KeyEvent { - KeyEvent::new(code, KeyModifiers::NONE) -} - -fn ctrl(code: KeyCode) -> KeyEvent { - KeyEvent::new(code, KeyModifiers::CONTROL) -} - -fn shift(code: KeyCode) -> KeyEvent { - KeyEvent::new(code, KeyModifiers::SHIFT) -} - /// `g` opens the picker only when a task is selected, pinning the target /// to that task's id. #[test] @@ -1986,8 +1954,7 @@ fn group_picker_opens_on_g_only_with_a_selection() { app.pump(); app.resolve_selection(); app.on_key_dashboard(key(KeyCode::Char('g'))); - assert!(app.mode == Mode::PickGroup); - assert_eq!(app.group_target, Some(1)); + assert!(app.mode == Mode::PickGroup { target: 1 }); } /// Group candidates are distinct, case-insensitively sorted, and follow @@ -2161,7 +2128,6 @@ fn group_esc_cancels_without_sending() { app.on_key_pickgroup(key(KeyCode::Esc)); assert!(app.mode == Mode::Dashboard); assert!(app.group_input.is_empty() && app.group_candidates.is_empty()); - assert_eq!(app.group_target, None); app.pump(); let v = app.views.iter().find(|v| v.id == 1).unwrap(); assert_eq!(v.group.as_deref(), Some("alpha"), "Esc must send nothing"); @@ -2308,7 +2274,7 @@ fn find_does_not_match_the_directory() { let mut app = App::new_local(30, 100); let dir = temp("findpalettedir"); let name = dir.file_name().unwrap().to_string_lossy().into_owned(); - app.spawn_in("sleep 5", dir); // id 1 + app.spawn_in("sleep 5", dir.to_path_buf()); // id 1 app.pump(); assert!(name.contains("findpalettedir"), "scratch dir name: {name}"); @@ -2587,15 +2553,13 @@ fn rename_prompt_opens_on_shift_r_only_with_a_selection() { let mut app = App::new_local(30, 100); app.on_key_dashboard(key(KeyCode::Char('R'))); assert!(app.mode == Mode::Dashboard, "no selection: R must no-op"); - assert_eq!(app.rename_target, None); let inv = app.invocation_dir.clone(); app.spawn_in("sleep 5", inv); app.pump(); app.resolve_selection(); app.on_key_dashboard(key(KeyCode::Char('R'))); - assert!(app.mode == Mode::Rename); - assert_eq!(app.rename_target, Some(1)); + assert!(app.mode == Mode::Rename(1)); assert_eq!(app.input.as_str(), "", "an unnamed task prefills empty"); // A named task prefills its name. @@ -2623,7 +2587,7 @@ fn rename_enter_sends_the_typed_name() { } app.on_key_rename(key(KeyCode::Enter)); assert!(app.mode == Mode::Dashboard); - assert!(app.input.is_empty() && app.rename_target.is_none()); + assert!(app.input.is_empty()); app.pump(); let v = app.views.iter().find(|v| v.id == 1).unwrap(); assert_eq!(v.name.as_deref(), Some("api server")); @@ -2671,7 +2635,6 @@ fn rename_esc_cancels_without_sending() { app.on_key_rename(key(KeyCode::Esc)); assert!(app.mode == Mode::Dashboard); assert!(app.input.is_empty()); - assert_eq!(app.rename_target, None); app.pump(); let v = app.views.iter().find(|v| v.id == 1).unwrap(); assert_eq!(v.name.as_deref(), Some("api"), "Esc must send nothing"); @@ -2760,7 +2723,7 @@ fn pickdir_right_descends_only_from_the_end() { let dir = temp("caret_pickdir"); std::fs::create_dir_all(dir.join("alpha")).unwrap(); let mut app = App::new_local(30, 100); - app.invocation_dir = dir.clone(); + app.invocation_dir = dir.to_path_buf(); app.on_key_dashboard(key(KeyCode::Char('@'))); for c in "al".chars() { @@ -2784,7 +2747,6 @@ fn pickdir_right_descends_only_from_the_end() { "Right at end descends: {:?}", app.dir_input.as_str() ); - let _ = std::fs::remove_dir_all(&dir); } /// Typing after caret motion still refreshes the `@` candidates; the @@ -2794,7 +2756,7 @@ fn pickdir_refreshes_on_edits_not_caret_motion() { let dir = temp("caret_pickdir_refresh"); std::fs::create_dir_all(dir.join("alpha")).unwrap(); let mut app = App::new_local(30, 100); - app.invocation_dir = dir.clone(); + app.invocation_dir = dir.to_path_buf(); app.on_key_dashboard(key(KeyCode::Char('@'))); app.on_key_pickdir(key(KeyCode::Char('l'))); @@ -2810,7 +2772,6 @@ fn pickdir_refreshes_on_edits_not_caret_motion() { app.dir_candidates.iter().any(|c| c.label == "alpha"), "an edit at the caret refreshes the candidates" ); - let _ = std::fs::remove_dir_all(&dir); } /// Caret-positioned edits refresh the group filter like end-of-line ones. @@ -3086,7 +3047,6 @@ fn set_watch_resends_on_kind_change_with_the_same_id() { app.pending_clipboard, vec![(ClipboardKind::Clipboard, "post".to_string())] ); - let _ = std::fs::remove_dir_all(&dir); } /// Pending stores emit in order, and the notice counts the last store's characters. @@ -3202,7 +3162,6 @@ fn attached_status_event_mirrors_into_the_notice() { app.pump(); assert_eq!(app.status.as_deref(), Some("saved 'mirror': 0 command(s)")); assert_eq!(app.notice(), Some("saved 'mirror': 0 command(s)")); - let _ = std::fs::remove_dir_all(&dir); } /// Dashboard status events do not create an ephemeral notice. @@ -3214,7 +3173,6 @@ fn dashboard_status_event_sets_only_the_status() { app.pump(); assert_eq!(app.status.as_deref(), Some("saved 'dash': 0 command(s)")); assert!(app.notice().is_none(), "no mirror outside attached mode"); - let _ = std::fs::remove_dir_all(&dir); } // --- drag-copy selection ------------------------------------------------ @@ -3244,14 +3202,8 @@ fn release(row: u16, col: u16) -> MouseEvent { impl App { /// Attach to a freshly spawned inline child and install a screen whose /// `lines` the test controls. - fn attached_with_lines(lines: &[&str]) -> App { - let mut app = App::new_local(30, 100); - let dir = app.invocation_dir.clone(); - app.spawn_in("sleep 5", dir); - app.pump(); - app.resolve_selection(); - app.attach(); - let id = app.focused_id.expect("attached"); + fn attached_with_lines(lines: &[&str]) -> Self { + let (mut app, id) = Self::attached(30, 100, "sleep 5"); let mut lines: Vec = lines.iter().map(|l| l.to_string()).collect(); lines.resize(app.pane_rows() as usize, String::new()); app.focused_screen = Some(ScreenView { @@ -3270,14 +3222,8 @@ impl App { } /// Spawn `cmd`, attach, and wait for a `ScreenView` satisfying `ready`. - fn attached_watching(cmd: &str, ready: impl Fn(&ScreenView) -> bool) -> (App, u64) { - let mut app = App::new_local(30, 100); - let cwd = app.invocation_dir.clone(); - app.spawn_in(cmd, cwd); - app.pump(); - app.resolve_selection(); - app.attach(); - let id = app.focused_id.expect("attached"); + fn attached_watching(cmd: &str, ready: impl Fn(&ScreenView) -> bool) -> (Self, u64) { + let (mut app, id) = Self::attached(30, 100, cmd); app.set_watch(Some((id, true))); assert!( wait_until(Duration::from_secs(5), || { @@ -3390,18 +3336,13 @@ fn coordinate_invalidation_clears_the_selection() { let mut app = App::attached_with_lines(&["hello world"]); start(&mut app); - app.on_key_attached(&mut out, ctrl(KeyCode::Char('\\'))) - .unwrap(); + app.on_key_attached(&mut out, ctrl(KeyCode::Char('\\'))); assert!(app.mode == Mode::Dashboard, "ctrl-\\ detaches"); assert!(app.selection.is_none(), "detach must clear"); let mut app = App::attached_with_lines(&["hello world"]); start(&mut app); - app.on_key_attached( - &mut out, - KeyEvent::new(KeyCode::PageUp, KeyModifiers::SHIFT), - ) - .unwrap(); + app.on_key_attached(&mut out, shift(KeyCode::PageUp)); assert!(app.view_scroll); assert!(app.selection.is_none(), "scrollback entry must clear"); @@ -3512,7 +3453,6 @@ fn mid_drag_mouse_enable_reroutes_the_gesture_to_the_child() { got.len() >= 19 }); assert_eq!(got, b"\x1b[<32;6;2M\x1b[<0;6;2m".to_vec()); - let _ = std::fs::remove_dir_all(&dir); } /// Concealed (SGR 8) text reaches the client as the blanks the screen shows, @@ -3619,14 +3559,8 @@ fn press_on_a_stale_geometry_screen_starts_no_selection() { /// selectable rows. #[test] fn one_row_terminal_has_no_selectable_pane() { - let mut app = App::new_local(1, 80); - let dir = app.invocation_dir.clone(); - app.spawn_in("sleep 5", dir); - app.pump(); - app.resolve_selection(); - app.attach(); + let (mut app, id) = App::attached(1, 80, "sleep 5"); app.mouse_captured = true; - let id = app.focused_id.expect("attached"); app.focused_screen = Some(ScreenView { id, lines: vec!["hidden".to_string()], @@ -3694,14 +3628,12 @@ fn scrollback_keys_clear_the_drag() { app.on_mouse(press(0, 0)); app.on_mouse(drag_to(0, 4)); assert!(app.selection.is_some(), "premise: a drag is live"); - app.on_key_attached(&mut out, KeyEvent::new(KeyCode::PageUp, KeyModifiers::NONE)) - .unwrap(); + app.on_key_attached(&mut out, key(KeyCode::PageUp)); assert!(app.selection.is_none(), "navigation must drop the drag"); app.on_mouse(press(0, 0)); app.on_mouse(drag_to(0, 4)); - app.on_key_attached(&mut out, KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)) - .unwrap(); + app.on_key_attached(&mut out, key(KeyCode::Esc)); assert!(!app.view_scroll, "Esc exits to live"); assert!(app.selection.is_none(), "the exit must drop the drag"); } @@ -3728,18 +3660,12 @@ fn live_return_frame_clears_a_scrollback_drag() { fn wants_mouse_child_keeps_the_left_button() { let dir = temp("app_drag_fwd"); let out_file = dir.join("bytes"); - let mut app = App::new_local(30, 100); - let cwd = app.invocation_dir.clone(); // 1002 (button motion) reports drags; 1006 selects the SGR encoding. let cmd = format!( "stty -icanon -echo min 1 time 0; printf '\\033[?1002h\\033[?1006h'; head -c 28 > {}", out_file.display() ); - app.spawn_in(&cmd, cwd); - app.pump(); - app.resolve_selection(); - app.attach(); - let id = app.focused_id.expect("attached"); + let (mut app, id) = App::attached(30, 100, &cmd); app.set_watch(Some((id, true))); // Wait for the child's mouse mode to reach the client. assert!( @@ -3765,7 +3691,6 @@ fn wants_mouse_child_keeps_the_left_button() { }); // SGR: press `\x1b[<0;col+1;row+1M`, drag adds 32, release ends in `m`. assert_eq!(got, b"\x1b[<0;3;2M\x1b[<32;6;2M\x1b[<0;6;2m".to_vec()); - let _ = std::fs::remove_dir_all(&dir); } // Frame emission: overlay modes composite by overdraw, so the emulator must @@ -3855,651 +3780,5 @@ fn input_and_attached_echo_bypass_the_repaint_floor() { assert_eq!(wait_for_paint(false, Duration::ZERO), PAINT_MIN); } -// --- README frame fixtures -------------------------------------------------- -// -// The real renderer writes each fabricated fleet to `docs/img/*.ansi`. Printing -// one of these files reproduces the corresponding dashboard frame. - -/// Fixture terminal size. The 30 rows fit every section plus one spare list -/// row; 107 columns produce a 71-column preview cell and an 80-column peek box. -const FIXTURE_ROWS: u16 = 30; -const FIXTURE_COLS: u16 = 107; - -/// A client with no core behind it. The fixture assigns `views` and -/// `focused_screen` directly, so no command is sent and no event arrives. -struct NoTransport; - -impl Transport for NoTransport { - fn send(&mut self, _cmd: Command) {} - - fn poll(&mut self) -> Vec { - Vec::new() - } - - fn connected(&self) -> bool { - true - } - - fn shutdown(&mut self, _intent: ExitIntent) {} -} - -/// Fabricated seconds. Every fixture duration is a constant: a clock reading -/// would change the bytes between runs. `const` so the `QUIET` table can hold -/// them directly. -const fn secs(n: u64) -> Duration { - Duration::from_secs(n) -} - -/// Fabricated minutes. -const fn mins(n: u64) -> Duration { - Duration::from_secs(n * 60) -} - -/// Live summary-adapter preview, carrying the matcher id the peek footer names. -fn anchor(text: &str, rule: &'static str) -> Preview { - Preview { - text: text.to_string(), - source: PreviewSource::Anchor, - rule: Some(rule), - frozen: false, - } -} - -/// Live window-title preview. -fn title(text: &str) -> Preview { - Preview { - text: text.to_string(), - source: PreviewSource::Title, - rule: None, - frozen: false, - } -} - -/// Live last-row preview. -fn floor(text: &str) -> Preview { - Preview { - text: text.to_string(), - source: PreviewSource::Floor, - rule: None, - frozen: false, - } -} - -/// The last-row preview a finished task froze on. -fn frozen(text: &str) -> Preview { - Preview { - text: text.to_string(), - source: PreviewSource::Floor, - rule: None, - frozen: true, - } -} - -/// The fleet's working directories, keyed as they appear in the section labels. -/// `dir_label` abbreviates `$HOME` to `~`, so these must be built from it. -struct Dirs { - home: PathBuf, - fleetcom: PathBuf, - turret: PathBuf, - crabapple: PathBuf, - crabstep: PathBuf, - imessage: PathBuf, - logria: PathBuf, -} - -impl Dirs { - fn new(home: &Path) -> Dirs { - let code = home.join("Documents/Code"); - Dirs { - home: home.to_path_buf(), - fleetcom: code.join("Rust/fleetcom"), - turret: code.join("Apple/turret"), - crabapple: code.join("Rust/crabapple"), - crabstep: code.join("Rust/crabstep"), - imessage: code.join("Rust/imessage-exporter"), - logria: code.join("Rust/Logria"), - } - } -} - -/// A dashboard client over `views`, with `~/Documents/Code/Rust/fleetcom` as -/// the invocation directory so directory mode ranks that section first. -/// Daemon-backed mode omits the foreground marker from generated frames. -fn fixture_app(dirs: &Dirs, group_mode: GroupMode, views: Vec) -> App { - let mut app = App::assemble(FIXTURE_ROWS, FIXTURE_COLS, |_, _, _| Box::new(NoTransport)); - app.daemon_backed = true; - app.invocation_label = app.dir_label(&dirs.fleetcom); - app.invocation_dir = dirs.fleetcom.clone(); - app.spawn_cwd = dirs.fleetcom.clone(); - app.group_mode = group_mode; - app.views = views; - app -} - -/// The active frame's 21 tasks: 12 active, two idle, and seven finished. -/// Task IDs encode launch order; `row_rank` moves tagged tasks ahead of their -/// peers and finished tasks behind them within a section. -fn live_fleet(dirs: &Dirs) -> Vec { - vec![ - TaskView { - id: 1, - command: "claude".to_string(), - cwd: dirs.fleetcom.clone(), - tagged: true, - group: Some("dashboard".to_string()), - name: Some("Dashboard Refine".to_string()), - lifecycle: Lifecycle::Active, - parked: false, - preview: anchor( - "✻ Scope small fixes for dashboard and CLI", - "claude:action-row", - ), - started_ago: mins(2), - quiet_ago: Some(secs(3)), - finished_ago: None, - }, - TaskView { - id: 2, - command: "claude".to_string(), - cwd: dirs.fleetcom.clone(), - tagged: true, - group: Some("dashboard".to_string()), - name: Some("Summary Refine".to_string()), - lifecycle: Lifecycle::Active, - parked: false, - preview: anchor("Inferring… · thinking with high effort", "claude:spinner"), - started_ago: mins(5), - quiet_ago: Some(secs(8)), - finished_ago: None, - }, - TaskView { - id: 3, - command: "grok".to_string(), - cwd: dirs.fleetcom.clone(), - tagged: false, - group: Some("dashboard".to_string()), - name: Some("Grok Language".to_string()), - lifecycle: Lifecycle::Active, - parked: false, - preview: anchor("Grok 4.5 (xhigh) · Responding…", "grok:spinner"), - started_ago: mins(12), - quiet_ago: Some(secs(4)), - finished_ago: None, - }, - TaskView { - id: 4, - command: "codex".to_string(), - cwd: dirs.fleetcom.clone(), - tagged: false, - group: Some("dashboard".to_string()), - name: Some("Codex Language".to_string()), - lifecycle: Lifecycle::Active, - parked: false, - preview: anchor(CODEX_LANGUAGE, "codex:working"), - started_ago: mins(18), - quiet_ago: Some(secs(2)), - finished_ago: None, - }, - TaskView { - id: 5, - command: "codex".to_string(), - cwd: dirs.fleetcom.clone(), - tagged: false, - group: Some("dashboard".to_string()), - name: Some("Codex Review".to_string()), - lifecycle: Lifecycle::Active, - parked: false, - preview: anchor(CODEX_REVIEW, "codex:working"), - started_ago: mins(24), - quiet_ago: Some(secs(6)), - finished_ago: None, - }, - TaskView { - id: 6, - command: "cargo test".to_string(), - cwd: dirs.fleetcom.clone(), - tagged: false, - group: Some("tests".to_string()), - name: None, - lifecycle: Lifecycle::Ok, - parked: false, - preview: frozen(FLEETCOM_TESTS), - started_ago: mins(2), - quiet_ago: None, - finished_ago: Some(secs(12)), - }, - TaskView { - id: 19, - command: "cargo clippy".to_string(), - cwd: dirs.fleetcom.clone(), - tagged: false, - group: Some("tests".to_string()), - name: None, - lifecycle: Lifecycle::Failed, - parked: false, - preview: frozen(FLEETCOM_CLIPPY), - started_ago: mins(5), - quiet_ago: None, - finished_ago: Some(mins(3)), - }, - TaskView { - id: 7, - command: "claude".to_string(), - cwd: dirs.home.clone(), - tagged: false, - group: Some("desktop".to_string()), - name: Some("claude agents".to_string()), - lifecycle: Lifecycle::Active, - parked: false, - preview: title("2 awaiting input · claude agents"), - started_ago: mins(63), - quiet_ago: Some(secs(9)), - finished_ago: None, - }, - TaskView { - id: 8, - command: "zellij".to_string(), - cwd: dirs.home.clone(), - tagged: false, - group: Some("desktop".to_string()), - name: Some("Zellij".to_string()), - lifecycle: Lifecycle::Active, - parked: false, - preview: title("Desktop ¦ Utility"), - started_ago: mins(126), - quiet_ago: Some(secs(4)), - finished_ago: None, - }, - TaskView { - id: 9, - command: "python".to_string(), - cwd: dirs.home.clone(), - tagged: false, - group: None, - name: None, - lifecycle: Lifecycle::Idle, - parked: true, - preview: floor(">>>"), - started_ago: mins(48), - quiet_ago: Some(mins(41)), - finished_ago: None, - }, - TaskView { - id: 10, - command: "brew update && brew upgrade".to_string(), - cwd: dirs.home.clone(), - tagged: false, - group: Some("desktop".to_string()), - name: None, - lifecycle: Lifecycle::Ok, - parked: false, - preview: frozen("Already up-to-date."), - started_ago: mins(14), - quiet_ago: None, - finished_ago: Some(mins(13)), - }, - TaskView { - id: 11, - command: "grok".to_string(), - cwd: dirs.turret.clone(), - tagged: false, - group: Some("turret".to_string()), - name: Some("Game Infra Review".to_string()), - lifecycle: Lifecycle::Active, - parked: false, - preview: title("Turret Game Codebase Organization and Ex… - grok"), - started_ago: mins(8), - quiet_ago: Some(secs(5)), - finished_ago: None, - }, - TaskView { - id: 12, - command: "codex".to_string(), - cwd: dirs.turret.clone(), - tagged: false, - group: Some("turret".to_string()), - name: Some("Missile Nerf".to_string()), - lifecycle: Lifecycle::Active, - parked: false, - preview: anchor(MISSILE_NERF, "codex:working"), - started_ago: mins(33), - quiet_ago: Some(secs(7)), - finished_ago: None, - }, - TaskView { - id: 13, - command: "codex".to_string(), - cwd: dirs.turret.clone(), - tagged: false, - group: Some("turret".to_string()), - name: Some("EMP Nerf".to_string()), - lifecycle: Lifecycle::Active, - parked: false, - preview: anchor(EMP_NERF, "codex:working"), - started_ago: mins(35), - quiet_ago: Some(secs(3)), - finished_ago: None, - }, - TaskView { - id: 14, - command: "cargo test".to_string(), - cwd: dirs.crabapple.clone(), - tagged: false, - group: Some("tests".to_string()), - name: None, - lifecycle: Lifecycle::Ok, - parked: false, - preview: frozen(CRABAPPLE_TESTS), - started_ago: mins(18), - quiet_ago: None, - finished_ago: Some(mins(17)), - }, - TaskView { - id: 15, - command: "cargo test".to_string(), - cwd: dirs.crabstep.clone(), - tagged: false, - group: Some("tests".to_string()), - name: None, - lifecycle: Lifecycle::Ok, - parked: false, - preview: frozen(CRABSTEP_TESTS), - started_ago: mins(22), - quiet_ago: None, - finished_ago: Some(mins(21)), - }, - TaskView { - id: 16, - command: "claude".to_string(), - cwd: dirs.imessage.clone(), - tagged: false, - group: None, - name: None, - lifecycle: Lifecycle::Active, - parked: false, - preview: anchor("✻ Review GitHub issue 780", "claude:action-row"), - started_ago: mins(6), - quiet_ago: Some(secs(2)), - finished_ago: None, - }, - TaskView { - id: 17, - command: "cargo test".to_string(), - cwd: dirs.imessage.clone(), - tagged: false, - group: Some("tests".to_string()), - name: None, - lifecycle: Lifecycle::Ok, - parked: false, - preview: frozen(IMESSAGE_TESTS), - started_ago: mins(20), - quiet_ago: None, - finished_ago: Some(mins(19)), - }, - TaskView { - id: 18, - command: "cargo test".to_string(), - cwd: dirs.logria.clone(), - tagged: false, - group: Some("tests".to_string()), - name: None, - lifecycle: Lifecycle::Ok, - parked: false, - preview: frozen(LOGRIA_TESTS), - started_ago: mins(32), - quiet_ago: None, - finished_ago: Some(mins(31)), - }, - TaskView { - id: 20, - command: "cargo watch -x test".to_string(), - cwd: dirs.logria.clone(), - tagged: false, - group: Some("tests".to_string()), - name: None, - lifecycle: Lifecycle::Active, - parked: false, - preview: floor(LOGRIA_WATCH), - started_ago: secs(45), - quiet_ago: Some(secs(2)), - finished_ago: None, - }, - TaskView { - id: 21, - command: "cargo doc --open".to_string(), - cwd: dirs.logria.clone(), - tagged: false, - group: Some("tests".to_string()), - name: None, - lifecycle: Lifecycle::Idle, - parked: true, - preview: floor(LOGRIA_DOC), - started_ago: mins(28), - quiet_ago: Some(mins(26)), - finished_ago: None, - }, - ] -} - -/// Per-task state for the quiet frame. Task identity remains in `live_fleet`; -/// this table replaces lifecycle, age, and one preview. -struct Quiet { - id: u64, - lifecycle: Lifecycle, - parked: bool, - started_ago: Duration, - quiet_ago: Option, - finished_ago: Option, - /// Replacement anchor preview as `(text, matcher id)`; `None` keeps the - /// live fleet's. - preview: Option<(&'static str, &'static str)>, -} - -impl Quiet { - /// A live task quiet past `IDLE_AFTER`, timed from its last output. - const fn idle(id: u64, started: Duration, quiet: Duration) -> Quiet { - Quiet { - id, - lifecycle: Lifecycle::Idle, - parked: true, - started_ago: started, - quiet_ago: Some(quiet), - finished_ago: None, - preview: None, - } - } - - /// A live task still inside `IDLE_AFTER`, timed from launch. - const fn active(id: u64, started: Duration, quiet: Duration) -> Quiet { - Quiet { - lifecycle: Lifecycle::Active, - parked: false, - ..Quiet::idle(id, started, quiet) - } - } - - /// A task that exited cleanly, timed from the exit. - const fn done(id: u64, started: Duration, finished: Duration) -> Quiet { - Quiet { - lifecycle: Lifecycle::Ok, - parked: false, - quiet_ago: None, - finished_ago: Some(finished), - ..Quiet::idle(id, started, finished) - } - } - - /// A task that exited non-zero, timed from the exit. - const fn failed(id: u64, started: Duration, finished: Duration) -> Quiet { - Quiet { - lifecycle: Lifecycle::Failed, - ..Quiet::done(id, started, finished) - } - } - - /// Swap in a different status line. - const fn saying(mut self, text: &'static str, rule: &'static str) -> Quiet { - self.preview = Some((text, rule)); - self - } -} - -/// Quiet-frame overrides, one per task. The rendered ages include `32s` and -/// `13s` for the tagged pair, `1m` for most idle agents, and `15m`–`21m` for -/// finished tasks. -const QUIET: [Quiet; 21] = [ - Quiet::idle(1, mins(22), secs(32)), - Quiet::idle(2, mins(21), secs(13)).saying(SUMMARY_QUIET, "claude:action-row"), - Quiet::idle(3, mins(21), mins(1)), - Quiet::idle(4, mins(21), mins(1)), - Quiet::idle(5, mins(21), mins(1)), - Quiet::done(6, mins(21), mins(20)), - Quiet::idle(7, mins(21), mins(1)), - // Keep Zellij active so the Running section remains non-empty. - Quiet::active(8, mins(20), secs(4)), - Quiet::idle(9, mins(22), mins(20)), - Quiet::done(10, mins(16), mins(15)), - Quiet::idle(11, mins(21), mins(1)), - Quiet::idle(12, mins(21), mins(1)), - Quiet::idle(13, mins(21), mins(1)), - Quiet::done(14, mins(20), mins(19)), - Quiet::done(15, mins(21), mins(20)), - Quiet::idle(16, mins(21), mins(1)), - Quiet::done(17, mins(21), mins(20)), - Quiet::done(18, mins(21), mins(20)), - Quiet::failed(19, mins(24), mins(21)), - Quiet::idle(20, mins(4), mins(2)), - Quiet::idle(21, mins(30), mins(28)), -]; - -/// The quiet frame's 21 tasks: one active, 13 idle, and seven finished. -/// `parked` follows `lifecycle` because the core derives both from the same -/// `IDLE_AFTER` window. -fn quiet_fleet(dirs: &Dirs) -> Vec { - let mut views = live_fleet(dirs); - assert_eq!( - views.len(), - QUIET.len(), - "every task needs a peek-frame override" - ); - for v in &mut views { - let Some(q) = QUIET.iter().find(|q| q.id == v.id) else { - panic!("no peek-frame override for task {}", v.id); - }; - v.lifecycle = q.lifecycle; - v.parked = q.parked; - v.started_ago = q.started_ago; - v.quiet_ago = q.quiet_ago; - v.finished_ago = q.finished_ago; - if let Some((text, rule)) = q.preview { - v.preview = anchor(text, rule); - } - } - views -} - -// Preview texts long enough that the row cell truncates them. They are stored -// whole: the `…` in the painted frame is the renderer's, not the fixture's. -const CODEX_LANGUAGE: &str = - "gpt-5.6-sol high · fleetcom · feat/cs/interface-fixes · 387K used · 9.53M in · 61.2K out"; -const CODEX_REVIEW: &str = - "gpt-5.6-sol high · fleetcom · feat/cs/interface-fixes · 221K used · 4.41M in · 38.7K out"; -const MISSILE_NERF: &str = "gpt-5.6-sol high · turret · main · 129K used · 1.31M in · 10.1K out"; -const EMP_NERF: &str = "gpt-5.6-sol high · turret · main · 161K used · 1.64M in · 10.4K out"; -const FLEETCOM_TESTS: &str = - "test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.03s"; -const LOGRIA_TESTS: &str = "test result: ok. 223 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.38s"; -const CRABAPPLE_TESTS: &str = "all doctests ran in 0.39s; merged doctests compilation took 0.38s"; -const CRABSTEP_TESTS: &str = "all doctests ran in 0.83s; merged doctests compilation took 0.81s"; -const IMESSAGE_TESTS: &str = "all doctests ran in 1.99s; merged doctests compilation took 1.95s"; -const FLEETCOM_CLIPPY: &str = - "error: could not compile `fleetcom` (lib test) due to 1 previous error"; -const LOGRIA_WATCH: &str = "[Running 'cargo test'] test result: ok. 223 passed; 0 failed"; -const LOGRIA_DOC: &str = "Finished `dev` profile [unoptimized + debuginfo] target(s) in 3.41s"; -/// Preview used only by the quiet frame. -const SUMMARY_QUIET: &str = "✻ Review fleetcom preview design document"; - -/// The peeked task's screen: the tail of a `cargo test` run. `render_peek` -/// shows the last `inner_h` lines, so these are already the visible ones. -fn cargo_test_screen(id: u64) -> ScreenView { - let lines = [ - "test util::sanitizers::tests::test_length_clean ... ok", - "test util::sanitizers::tests::test_row_length_clean ... ok", - "test util::sanitizers::tests::test_length_dirty ... ok", - "test util::sanitizers::tests::test_length_wide_chars ... ok", - "test util::sanitizers::tests::test_sanitize_filename_clean ... ok", - "test util::sanitizers::tests::test_row_length_dirty ... ok", - "test util::sanitizers::tests::test_row_length_wide_chars ... ok", - "test util::sanitizers::tests::test_sanitize_filename_control_chars ... ok", - "test util::sanitizers::tests::test_sanitize_filename_trim ... ok", - "test util::sanitizers::tests::test_sanitize_filename_invalid_chars ... ok", - "test util::sanitizers::tests::test_sanitize_filename_long ... ok", - "", - LOGRIA_TESTS, - "", - ]; - ScreenView { - id, - lines: lines.iter().map(|s| s.to_string()).collect(), - // Peek reads `lines` only; the attached path never runs here. - formatted: Vec::new(), - cursor: (0, 0), - hide_cursor: true, - wants_mouse: false, - alt_screen: false, - alt_scroll: false, - scrollback: 0, - } -} - -/// Paint `app` once and return the frame bytes. -fn frame(app: &mut App) -> Vec { - // OSC 0 keeps the captured window title independent of the printing shell. - let mut out = b"\x1b]0;fleetcom\x07".to_vec(); - let painted = out.len(); - crate::ui::render(&mut out, app).expect("a fixture frame always paints"); - assert!(out.len() > painted, "a fresh App must emit its first frame"); - // Park the cursor on the terminal's final row, outside centered overlays. - out.extend_from_slice(format!("\x1b[{};1H", app.rows).as_bytes()); - out -} - -/// Rewrite the four `docs/img/*.ansi` dashboard frames. This test is ignored -/// because it writes repository fixtures. -/// -/// Fixed durations and ordered inputs make the output deterministic for a -/// given `$HOME`; `dir_label` abbreviates that path to `~` in section labels. -#[test] -#[ignore = "writes docs/img/*.ansi; run by hand to refresh the README screenshots"] -fn write_readme_screenshot_fixtures() { - let home = std::env::var("HOME").expect("HOME must be set to abbreviate the section labels"); - assert!(!home.is_empty(), "HOME must not be empty"); - let dirs = Dirs::new(Path::new(&home)); - let out_dir = Path::new(env!("CARGO_MANIFEST_DIR")).join("docs/img"); - - // Grouped by directory, selection on a live codex task. - let mut app = fixture_app(&dirs, GroupMode::Dir, live_fleet(&dirs)); - app.selected_id = Some(4); - std::fs::write(out_dir.join("home.ansi"), frame(&mut app)).unwrap(); - - // State grouping with peek open over the first finished test. Directory - // ordering places id 14 first in Completed and beside the peek box. - let mut app = fixture_app(&dirs, GroupMode::State, quiet_fleet(&dirs)); - app.mode = Mode::Peek; - app.selected_id = Some(14); - // Seed the watched screen directly because NoTransport emits no frames. - app.focused_screen = Some(cargo_test_screen(14)); - std::fs::write(out_dir.join("quickpeek.ansi"), frame(&mut app)).unwrap(); - - // Custom grouping puts five directories in `tests` and splits fleetcom's - // directory between two sections. - let mut app = fixture_app(&dirs, GroupMode::Custom, live_fleet(&dirs)); - app.selected_id = Some(4); - std::fs::write(out_dir.join("groups.ansi"), frame(&mut app)).unwrap(); - - // The `?` overlay over the same dir-grouped dashboard. - let mut app = fixture_app(&dirs, GroupMode::Dir, live_fleet(&dirs)); - app.selected_id = Some(4); - app.mode = Mode::Controls; - std::fs::write(out_dir.join("controls.ansi"), frame(&mut app)).unwrap(); -} +#[path = "app_readme_tests.rs"] +mod readme; diff --git a/src/daemon.rs b/src/daemon.rs index d507d71..1ddef12 100644 --- a/src/daemon.rs +++ b/src/daemon.rs @@ -357,6 +357,12 @@ fn spawn_daemon() -> io::Result<()> { Ok(()) } +/// Report a successful no-op when `--kill` finds no daemon. +fn no_daemon() -> io::Result<()> { + eprintln!("fleetcom: no daemon running"); + Ok(()) +} + /// `fleetcom --kill`: stop the daemon and every task it owns. Signal path, not /// socket: the daemon serves one client at a time, so a `Shutdown` *frame* /// would sit in the accept backlog until an attached client detached. @@ -375,25 +381,21 @@ pub fn run_kill() -> io::Result<()> { .write(true) .open(&lock_path) else { - eprintln!("fleetcom: no daemon running"); - return Ok(()); + return no_daemon(); }; // Probe the single-instance lock: acquirable means no daemon holds it. let mut file = match Flock::lock(file, FlockArg::LockExclusiveNonblock) { - Ok(_held) => { - eprintln!("fleetcom: no daemon running"); - return Ok(()); - } + Ok(_held) => return no_daemon(), Err((file, _)) => file, }; let mut pid_str = String::new(); file.read_to_string(&mut pid_str)?; - let Some(pid) = pid_str.trim().parse::().ok().filter(|p| *p > 0) else { + let Some(pid) = crate::task::positive_pid(pid_str.trim()) else { // Without a usable pid, fall back to a Shutdown frame over the socket. // Bound the fallback because an attached client can keep the daemon // from accepting this connection. - return kill_via_socket(); + return kill_via_socket_at(&socket_path(), KILL_SOCKET_TIMEOUT); }; // ESRCH means the daemon exited between the lock probe and here; the flock @@ -451,29 +453,18 @@ fn deadline_mapped(e: io::Error) -> io::Error { } } -/// Send `Shutdown` when the lock file has no usable pid. Complete the handshake -/// first, then wait for the daemon to close the socket after stopping its tasks. -fn kill_via_socket() -> io::Result<()> { - kill_via_socket_at(&socket_path(), KILL_SOCKET_TIMEOUT) -} - -/// Run the socket-fallback kill exchange at `path` using `budget` for I/O. +/// When the lock lacks a valid PID, send `Shutdown` over the socket and bound +/// handshake and completion I/O by `budget`. fn kill_via_socket_at(path: &Path, budget: Duration) -> io::Result<()> { match UnixStream::connect(path) { - Ok(mut s) => kill_over_stream(&mut s, budget), - Err(_) => { - eprintln!("fleetcom: no daemon running"); - Ok(()) + Ok(mut s) => { + let (kind, payload) = encode_hello(&LaunchContext::here()); + kill_exchange(&mut s, budget, kind, &payload) } + Err(_) => no_daemon(), } } -/// Drive the Shutdown exchange with bounded writes and a shared read deadline. -fn kill_over_stream(s: &mut UnixStream, budget: Duration) -> io::Result<()> { - let (kind, payload) = encode_hello(&LaunchContext::here()); - kill_exchange(s, budget, kind, &payload) -} - /// Drive the bounded Shutdown exchange with a pre-encoded hello frame. fn kill_exchange( s: &mut UnixStream, @@ -537,9 +528,8 @@ pub fn run_daemon() -> io::Result<()> { .open(dir.join("daemon.lock"))?; // `lock` is held for the whole function, so the flock lives until this // daemon exits, then releases on drop. - let mut lock = match Flock::lock(lock_file, FlockArg::LockExclusiveNonblock) { - Ok(l) => l, - Err(_) => return Ok(()), // another daemon already owns the socket + let Ok(mut lock) = Flock::lock(lock_file, FlockArg::LockExclusiveNonblock) else { + return Ok(()); // another daemon already holds the lock }; // Sole owner: advertise our pid inside the lock file, the signal target for // `--kill`. Trustworthy only while the flock is held. A stale pid from a @@ -780,7 +770,6 @@ mod tests { let link = base.join("runtime"); std::os::unix::fs::symlink(&target, &link).unwrap(); assert!(ensure_runtime_dir(&link).is_err()); - let _ = fs::remove_dir_all(&base); } #[test] @@ -789,7 +778,6 @@ mod tests { let path = base.join("runtime"); fs::write(&path, b"x").unwrap(); assert!(ensure_runtime_dir(&path).is_err()); - let _ = fs::remove_dir_all(&base); } /// Connection setup rejects symlinked and non-directory runtime paths. @@ -805,7 +793,6 @@ mod tests { let file = base.join("file"); fs::write(&file, b"x").unwrap(); assert!(connect_or_autostart_in(&file).is_err()); - let _ = fs::remove_dir_all(&base); } /// Oversized events are skipped without preventing subsequent writes. @@ -905,7 +892,6 @@ mod tests { let mode = fs::symlink_metadata(&path).unwrap().permissions().mode(); assert_eq!(mode & 0o777, 0o700, "dir must be private"); ensure_runtime_dir(&path).unwrap(); - let _ = fs::remove_dir_all(&base); } /// Reject group- or other-writable directories because they may already @@ -923,7 +909,6 @@ mod tests { 0o700 | bits ); } - let _ = fs::remove_dir_all(&base); } /// The notice requires both a flag and an already-running daemon. @@ -944,7 +929,7 @@ mod tests { #[test] fn kill_via_socket_bounds_the_handshake_wait() { let base = temp("kill_socket_mute"); - fs::create_dir_all(&base).unwrap(); + fs::create_dir_all(&*base).unwrap(); let sock = base.join("mute.sock"); // Leave the connection queued in the listener backlog. let _listener = UnixListener::bind(&sock).unwrap(); @@ -956,14 +941,13 @@ mod tests { start.elapsed() < Duration::from_secs(5), "the deadline must fire, not the test's timeout" ); - let _ = fs::remove_dir_all(&base); } /// A blocked hello write reports the kill-handshake timeout. #[test] fn kill_exchange_maps_a_write_timeout() { let base = temp("kill_socket_bigenv"); - fs::create_dir_all(&base).unwrap(); + fs::create_dir_all(&*base).unwrap(); let sock = base.join("mute.sock"); let _listener = UnixListener::bind(&sock).unwrap(); let mut s = UnixStream::connect(&sock).unwrap(); @@ -972,14 +956,13 @@ mod tests { let err = kill_exchange(&mut s, Duration::from_millis(200), 0, &oversized).unwrap_err(); assert_eq!(err.kind(), ErrorKind::TimedOut); assert!(err.to_string().contains("kill handshake"), "{err}"); - let _ = fs::remove_dir_all(&base); } /// A daemon that keeps the socket open after Shutdown times out the drain. #[test] fn kill_via_socket_bounds_the_drain_wait() { let base = temp("kill_socket_drain"); - fs::create_dir_all(&base).unwrap(); + fs::create_dir_all(&*base).unwrap(); let sock = base.join("stuck.sock"); let listener = UnixListener::bind(&sock).unwrap(); let server = thread::spawn(move || { @@ -994,16 +977,14 @@ mod tests { let err = kill_via_socket_at(&sock, Duration::from_millis(300)).unwrap_err(); assert_eq!(err.kind(), ErrorKind::TimedOut); server.join().unwrap(); - let _ = fs::remove_dir_all(&base); } /// A missing socket makes the fallback a no-op. #[test] fn kill_via_socket_without_a_socket_is_a_noop() { let base = temp("kill_socket_absent"); - fs::create_dir_all(&base).unwrap(); + fs::create_dir_all(&*base).unwrap(); assert!(kill_via_socket_at(&base.join("absent.sock"), Duration::from_millis(100)).is_ok()); - let _ = fs::remove_dir_all(&base); } /// Remove group and other read/execute permissions from a valid directory. @@ -1020,6 +1001,5 @@ mod tests { 0o700, "harmless bits must be tightened to 0700" ); - let _ = fs::remove_dir_all(&base); } } diff --git a/src/format.rs b/src/format.rs index 519b66b..af43aa8 100644 --- a/src/format.rs +++ b/src/format.rs @@ -75,6 +75,19 @@ pub fn pad(s: &str, width: usize) -> String { t } +/// Return the longest UTF-8 prefix no longer than `max` bytes. Unlike +/// [`truncate`], this limits bytes rather than display columns. +pub(crate) fn prefix_bytes(s: &str, max: usize) -> &str { + if s.len() <= max { + return s; + } + let mut end = max; + while !s.is_char_boundary(end) { + end -= 1; + } + &s[..end] +} + /// Sort key for human-readable names. /// The lowercase value provides case-insensitive collation; the exact value /// makes ordering deterministic and keeps case-distinct names separate. diff --git a/src/terminal/frame.rs b/src/frame.rs similarity index 100% rename from src/terminal/frame.rs rename to src/frame.rs diff --git a/src/harness/assets.rs b/src/harness/assets.rs index 53f3ab8..5a68eb0 100644 --- a/src/harness/assets.rs +++ b/src/harness/assets.rs @@ -29,6 +29,7 @@ use std::{ }; use super::CapturePaths; +use crate::task::{pid_is_dead, positive_pid}; /// Notify program injected into `codex`. It writes the capture payload when a /// path exists, then replaces itself with the configured notifier when present. @@ -98,7 +99,7 @@ fn reap_dead_namespaces(root: &Path) { let Some(pid) = namespace_owner(name.to_str().unwrap_or("")) else { continue; }; - if owner_is_dead(pid) { + if pid_is_dead(pid) { let _ = fs::remove_dir_all(entry.path()); } } @@ -117,13 +118,7 @@ fn namespace_owner(name: &str) -> Option { { return None; } - pid.parse::().ok().filter(|p| *p > 0) -} - -/// Return true only when signal 0 reports that `pid` does not exist. -fn owner_is_dead(pid: i32) -> bool { - use nix::{errno::Errno, sys::signal::kill, unistd::Pid}; - matches!(kill(Pid::from_raw(pid), None), Err(Errno::ESRCH)) + positive_pid(pid) } /// Capture assets owned by one supervisor process. @@ -140,7 +135,7 @@ impl CaptureAssets { /// namespace uses mode `0700`; its Claude settings use `0600`, and its /// executable Codex notifier uses `0700`. Dead-owner namespaces are reaped /// before the new namespace is created; other root entries remain. - pub fn install(root: &Path, pid: u32) -> io::Result { + pub fn install(root: &Path, pid: u32) -> io::Result { fs::DirBuilder::new() .recursive(true) .mode(0o700) @@ -171,7 +166,7 @@ impl CaptureAssets { fs::write(&codex_notify, CODEX_NOTIFY_SCRIPT)?; fs::set_permissions(&codex_notify, fs::Permissions::from_mode(0o700))?; - Ok(CaptureAssets { + Ok(Self { dir, claude_settings, codex_notify, @@ -257,7 +252,6 @@ mod tests { assert_eq!(assets.codex_notify, ns.join("codex-notify.sh")); assert_eq!(mode(&assets.claude_settings), 0o600); assert_eq!(mode(&assets.codex_notify), 0o700); - let _ = fs::remove_dir_all(&base); } /// Installation creates a distinct namespace, reapplies the root mode, and @@ -267,7 +261,7 @@ mod tests { let root = temp("assets_fresh"); let first = CaptureAssets::install(&root, std::process::id()).unwrap(); fs::write(&first.claude_settings, "garbage").unwrap(); - fs::set_permissions(&root, fs::Permissions::from_mode(0o755)).unwrap(); + fs::set_permissions(&*root, fs::Permissions::from_mode(0o755)).unwrap(); let second = CaptureAssets::install(&root, std::process::id()).unwrap(); assert_ne!( @@ -291,7 +285,6 @@ mod tests { assert_eq!(mode(&root), 0o700); assert_eq!(mode(&second.claude_settings), 0o600); assert_eq!(mode(&second.codex_notify), 0o700); - let _ = fs::remove_dir_all(&root); } /// Installation retains live-owner namespaces and non-namespace entries. @@ -325,7 +318,6 @@ mod tests { ); assert!(assets.claude_settings.exists()); assert!(assets.codex_notify.exists()); - let _ = fs::remove_dir_all(&root); } /// A live matching PID retains its namespace and receives a distinct nonce. @@ -364,7 +356,6 @@ mod tests { CODEX_NOTIFY_SCRIPT ); assert_eq!(mode(&ns), 0o700); - let _ = fs::remove_dir_all(&root); } /// Installation removes a dead owner's namespace and its contents. @@ -378,20 +369,18 @@ mod tests { let assets = CaptureAssets::install(&root, std::process::id()).unwrap(); assert!(!dead.exists(), "a dead owner's namespace must be reaped"); assert!(assets.claude_settings.exists()); - let _ = fs::remove_dir_all(&root); } /// A namespace-shaped file is not reaped. #[test] fn install_keeps_a_file_named_like_a_dead_namespace() { let root = temp("assets_reap_file"); - fs::create_dir_all(&root).unwrap(); + fs::create_dir_all(&*root).unwrap(); let decoy = root.join(format!("{}-0123456789ab", dead_pid())); fs::write(&decoy, "not a namespace").unwrap(); let _assets = CaptureAssets::install(&root, std::process::id()).unwrap(); assert!(decoy.exists(), "a file is never a reap candidate"); - let _ = fs::remove_dir_all(&root); } /// Malformed namespace names are not reaped. @@ -416,7 +405,6 @@ mod tests { for name in names { assert!(root.join(name).exists(), "{name:?} must be kept"); } - let _ = fs::remove_dir_all(&root); } /// The notifier overwrites the capture file with its first argument. With @@ -464,7 +452,6 @@ mod tests { .unwrap(); assert!(out.status.success()); assert_eq!(fs::read(&cap).unwrap(), second.as_bytes()); - let _ = fs::remove_dir_all(&root); } /// A configured chain runs after capture and receives its original argv @@ -495,7 +482,6 @@ mod tests { format!("turn-ended\n{payload}\n"), "the notifier must receive its original args, payload last" ); - let _ = fs::remove_dir_all(&root); } /// The capture write precedes the chained notifier, whose exit status @@ -517,7 +503,6 @@ mod tests { .unwrap(); assert!(!out.status.success(), "exec forwards the notifier's status"); assert_eq!(fs::read(&cap).unwrap(), payload.as_bytes()); - let _ = fs::remove_dir_all(&root); } /// Without a capture path, the script still execs the configured chain. @@ -537,7 +522,6 @@ mod tests { .unwrap(); assert!(out.status.success()); assert_eq!(fs::read_to_string(&record).unwrap(), "payload\n"); - let _ = fs::remove_dir_all(&root); } /// The hook command serialized into the settings file copies stdin into @@ -573,7 +557,6 @@ mod tests { .unwrap(); assert!(child.wait().unwrap().success()); assert_eq!(fs::read_to_string(&cap).unwrap(), payload); - let _ = fs::remove_dir_all(&root); } /// Drop removes only the owned namespace and its contents. @@ -596,7 +579,6 @@ mod tests { sibling.join("task-1-0.json").exists(), "drop must never touch another process's namespace" ); - let _ = fs::remove_dir_all(&root); } #[test] @@ -613,6 +595,5 @@ mod tests { ); assert_eq!(paths.claude_settings, ns.join("claude-settings.json")); assert_eq!(paths.codex_notify, ns.join("codex-notify.sh")); - let _ = fs::remove_dir_all(&root); } } diff --git a/src/harness/claude.rs b/src/harness/claude.rs index de43a09..f04950d 100644 --- a/src/harness/claude.rs +++ b/src/harness/claude.rs @@ -7,8 +7,8 @@ use std::{path::Path, time::SystemTime}; use super::{ - CAPTURE_ENV, CapturePaths, Harness, Invocation, SpawnPlan, detect_shape, is_uuid, last_hint, - pin_plan, resume_shape, shell_quote, unique_in_window, + CAPTURE_ENV, CapturePaths, Harness, Invocation, SpawnPlan, is_uuid, last_hint, pin_plan, + shell_quote, unique_in_window, }; pub struct Claude; @@ -22,8 +22,8 @@ impl Harness for Claude { ".claude" } - fn detect(&self, cmd: &str) -> Option { - detect_shape(cmd, "claude", "--resume") + fn shape(&self) -> (&'static str, &'static str) { + ("claude", "--resume") } fn instrument( @@ -66,10 +66,6 @@ impl Harness for Claude { Some(path.file_stem()?.to_str()?.to_string()) }) } - - fn resume_command(&self, cmd: &str, id: &str) -> String { - resume_shape(cmd, "claude", "--resume", id) - } } /// Convert an absolute working directory to Claude's project slug by replacing @@ -207,7 +203,6 @@ mod tests { // A second in-window transcript makes the match ambiguous. fs::write(dir.join(format!("{OTHER}.jsonl")), "{}").unwrap(); assert_eq!(Claude.correlate_fs(cwd, now, Some(&home)), None); - let _ = fs::remove_dir_all(&home); } #[test] @@ -221,7 +216,6 @@ mod tests { Claude.correlate_fs(cwd, SystemTime::now(), Some(&home)), None ); - let _ = fs::remove_dir_all(&home); } /// The scraper recovers the exit-hint ID from the corpus terminal bytes. diff --git a/src/harness/codex.rs b/src/harness/codex.rs index 8a431c3..eae89c2 100644 --- a/src/harness/codex.rs +++ b/src/harness/codex.rs @@ -13,8 +13,8 @@ use std::{ }; use super::{ - CAPTURE_ENV, CapturePaths, Harness, Invocation, NOTIFY_CHAIN_ENV, SpawnPlan, detect_shape, - is_uuid, last_hint, leading_uuid, resume_shape, shell_quote, within_window_ms, + CAPTURE_ENV, CapturePaths, Harness, Invocation, NOTIFY_CHAIN_ENV, SpawnPlan, is_uuid, + last_hint, leading_uuid, shell_quote, within_window_ms, }; pub struct Codex; @@ -28,8 +28,8 @@ impl Harness for Codex { ".codex" } - fn detect(&self, cmd: &str) -> Option { - detect_shape(cmd, "codex", "resume") + fn shape(&self) -> (&'static str, &'static str) { + ("codex", "resume") } fn instrument( @@ -152,10 +152,6 @@ impl Harness for Codex { _ => None, } } - - fn resume_command(&self, cmd: &str, id: &str) -> String { - resume_shape(cmd, "codex", "resume", id) - } } /// Whether Codex notification capture can preserve the configured route. @@ -356,7 +352,7 @@ mod tests { use super::*; use crate::{ harness::fixtures::{OTHER, assert_all_opaque, assert_corpus_scrape, paths}, - testutil::{temp, v7_at, write_rollout}, + testutil::{Scratch, temp, v7_at, write_rollout}, }; /// Codex's own launch and resume commands carry v7 IDs; the shared v4 @@ -395,7 +391,7 @@ mod tests { } /// Scratch home without a `config.toml`. - fn no_config_home() -> PathBuf { + fn no_config_home() -> Scratch { temp("codex_no_config_home") } @@ -471,7 +467,6 @@ mod tests { assert!(!plan.args_suffix.is_empty(), "{inert:?}"); assert_eq!(chained(&plan), Some("".into()), "{inert:?}"); } - let _ = fs::remove_dir_all(&home); } /// The newline-joined chain preserves spaces within argv elements. @@ -494,7 +489,6 @@ mod tests { "/Applications/Codex Computer Use.app/Contents/MacOS/SkyComputerUseClient\nturn-ended" .into() ))); - let _ = fs::remove_dir_all(&home); } /// An unrepresentable route disables capture injection. @@ -526,7 +520,6 @@ mod tests { "{opaque:?}" ); } - let _ = fs::remove_dir_all(&home); } #[test] @@ -704,8 +697,6 @@ mod tests { // A missing profile file leaves only the base config. fs::write(&cfg, "profile = \"ghost\"\n").unwrap(); assert_eq!(config_notify_route(Some(&home)), NotifyRoute::Vacant); - - let _ = fs::remove_dir_all(&home); } #[test] @@ -740,7 +731,6 @@ mod tests { Codex.correlate_fs(Path::new("/work/proj"), spawned, Some(&home)), None ); - let _ = fs::remove_dir_all(&home); } /// The ±2-day probe includes a rollout in the adjacent day directory. @@ -772,7 +762,6 @@ mod tests { .as_deref(), Some(id.as_str()) ); - let _ = fs::remove_dir_all(&home); } /// The scraper recovers an SGR-split exit hint from the corpus bytes after diff --git a/src/harness/grok.rs b/src/harness/grok.rs index 96ba3e7..35e6d5a 100644 --- a/src/harness/grok.rs +++ b/src/harness/grok.rs @@ -5,10 +5,7 @@ use std::{path::Path, time::SystemTime}; -use super::{ - CapturePaths, Harness, Invocation, SpawnPlan, detect_shape, last_hint, pin_plan, resume_shape, - unique_in_window, -}; +use super::{CapturePaths, Harness, Invocation, SpawnPlan, last_hint, pin_plan, unique_in_window}; pub struct Grok; @@ -21,8 +18,8 @@ impl Harness for Grok { ".grok" } - fn detect(&self, cmd: &str) -> Option { - detect_shape(cmd, "grok", "--resume") + fn shape(&self) -> (&'static str, &'static str) { + ("grok", "--resume") } fn instrument( @@ -59,10 +56,6 @@ impl Harness for Grok { Some(entry.file_name().to_str()?.to_string()) }) } - - fn resume_command(&self, cmd: &str, id: &str) -> String { - resume_shape(cmd, "grok", "--resume", id) - } } /// Encode an absolute working directory as a Grok session-store key. `/` @@ -207,7 +200,6 @@ mod tests { // A second in-window session makes the match ambiguous. fs::create_dir_all(dir.join(OTHER)).unwrap(); assert_eq!(Grok.correlate_fs(cwd, now, Some(&home)), None); - let _ = fs::remove_dir_all(&home); } #[test] @@ -217,7 +209,6 @@ mod tests { let dir = home.join("sessions").join("%2Fw"); fs::create_dir_all(dir.join("not-a-session")).unwrap(); assert_eq!(Grok.correlate_fs(cwd, SystemTime::now(), Some(&home)), None); - let _ = fs::remove_dir_all(&home); } /// The scraper recovers the exit-hint ID from the corpus terminal bytes. diff --git a/src/harness/mod.rs b/src/harness/mod.rs index 9cd7f93..625c9bb 100644 --- a/src/harness/mod.rs +++ b/src/harness/mod.rs @@ -63,9 +63,16 @@ pub trait Harness: Sync { } } + /// Program word and canonical resume selector. The default detection and + /// resume rewriting derive from this pair. + fn shape(&self) -> (&'static str, &'static str); + /// Classify a command. Return `None` for another tool or an unsupported /// command shape. - fn detect(&self, cmd: &str) -> Option; + fn detect(&self, cmd: &str) -> Option { + let (program, selector) = self.shape(); + detect_shape(cmd, program, selector) + } /// Build spawn-time command and environment additions. `home` is resolved /// from the launch environment; `None` uses the harness's platform-home @@ -89,7 +96,10 @@ pub trait Harness: Sync { /// Rewrite an accepted `cmd` into the canonical command that resumes /// `id`. - fn resume_command(&self, cmd: &str, id: &str) -> String; + fn resume_command(&self, cmd: &str, id: &str) -> String { + let (program, selector) = self.shape(); + resume_shape(cmd, program, selector, id) + } } /// Harness registry in detection order. @@ -116,8 +126,8 @@ impl Invocation { /// The session ID the command already targets. pub fn known_id(self) -> Option { match self { - Invocation::Bare => None, - Invocation::Resume(id) => Some(id), + Self::Bare => None, + Self::Resume(id) => Some(id), } } } @@ -363,30 +373,25 @@ mod tests { *, }; - /// Harness, program word, selector, and path prefix for the shape tests - /// shared by every harness. Codex's resume selector is a subcommand, not - /// a flag. - static SHAPES: [(&dyn Harness, &str, &str, &str); 3] = [ - (&Claude, "claude", "--resume", "/usr/local/bin"), - (&Codex, "codex", "resume", "/opt/bin"), - (&Grok, "grok", "--resume", "/usr/local/bin"), - ]; - - /// Each harness accepts exactly its bare program word (plain or path - /// form) and its canonical resume form (bare or quoted ID). + /// Path prefix used to verify basename matching. + const BIN: &str = "/usr/local/bin"; + + /// Each registered harness accepts bare and canonical resume forms, + /// including path-qualified programs and quoted IDs. #[test] fn every_harness_detects_the_two_authored_shapes() { - for &(h, prog, sel, path) in &SHAPES { + for &h in HARNESSES { + let (prog, sel) = h.shape(); assert_eq!(h.detect(prog), Some(Invocation::Bare), "{prog}"); assert_eq!( - h.detect(&format!("{path}/{prog}")), + h.detect(&format!("{BIN}/{prog}")), Some(Invocation::Bare), "{prog}" ); for cmd in [ format!("{prog} {sel} {ID}"), format!("{prog} {sel} '{ID}'"), - format!("{path}/{prog} {sel} '{ID}'"), + format!("{BIN}/{prog} {sel} '{ID}'"), ] { assert_eq!(h.detect(&cmd), Some(Invocation::Resume(ID.into())), "{cmd}"); } @@ -398,12 +403,13 @@ mod tests { /// unchanged. #[test] fn every_harness_regenerates_the_canonical_resume_form() { - for &(h, prog, sel, path) in &SHAPES { + for &h in HARNESSES { + let (prog, sel) = h.shape(); let canonical = format!("{prog} {sel} '{ID}'"); assert_eq!(h.resume_command(prog, ID), canonical, "{prog}"); assert_eq!( - h.resume_command(&format!("{path}/{prog}"), ID), - format!("{path}/{prog} {sel} '{ID}'") + h.resume_command(&format!("{BIN}/{prog}"), ID), + format!("{BIN}/{prog} {sel} '{ID}'") ); assert_eq!( h.resume_command(&format!("{prog} {sel} '{OTHER}'"), ID), @@ -426,7 +432,8 @@ mod tests { /// opacity cases stay in each harness's own test module. #[test] fn every_harness_keeps_shared_shell_syntax_opaque() { - for &(h, prog, sel, _) in &SHAPES { + for &h in HARNESSES { + let (prog, sel) = h.shape(); let opaque = [ format!("{prog} 'fix the tests'"), format!("{prog} {sel}"), @@ -449,7 +456,7 @@ mod tests { ); } // Another tool's program word never matches. - for other in ["claude", "codex", "grok"] { + for other in HARNESSES.iter().map(|o| o.shape().0) { if other != prog { assert_eq!(h.detect(other), None, "{other:?} is not {prog}"); } diff --git a/src/main.rs b/src/main.rs index 76dcb53..fa3572d 100644 --- a/src/main.rs +++ b/src/main.rs @@ -22,6 +22,7 @@ mod task; // Daemon transport, sessions, and wire protocol. mod daemon; +mod frame; mod protocol; mod session; mod transport; @@ -35,7 +36,7 @@ mod terminal; #[cfg(test)] mod testutil; -pub(crate) use terminal::{ansi, emulator, frame, input}; +pub(crate) use terminal::{ansi, emulator, input}; use std::{ io::{self, IsTerminal}, diff --git a/src/preview.rs b/src/preview.rs index 1276197..900ad96 100644 --- a/src/preview.rs +++ b/src/preview.rs @@ -39,31 +39,31 @@ pub trait ScreenFacts { impl ScreenFacts for Emulator { fn revision(&self) -> u64 { - Emulator::revision(self) + Self::revision(self) } fn alt_epoch(&self) -> u64 { - Emulator::alt_epoch(self) + Self::alt_epoch(self) } fn alternate_screen(&self) -> bool { - Emulator::alternate_screen(self) + Self::alternate_screen(self) } fn title(&self) -> Option<&str> { - Emulator::title(self) + Self::title(self) } fn live_floor(&self) -> String { - Emulator::live_floor(self) + Self::live_floor(self) } fn live_rows(&self) -> Vec { - Emulator::live_rows(self) + Self::live_rows(self) } fn alt_leave_floor(&self) -> Option<&str> { - Emulator::alt_leave_floor(self) + Self::alt_leave_floor(self) } } @@ -174,15 +174,15 @@ pub struct PreviewState { } impl Default for PreviewState { - fn default() -> PreviewState { - PreviewState::new() + fn default() -> Self { + Self::new() } } impl PreviewState { - pub fn new() -> PreviewState { + pub fn new() -> Self { let empty = Preview::floor(String::new()); - PreviewState { + Self { rendered: empty.clone(), candidate: empty, pending_candidate: None, @@ -343,8 +343,8 @@ mod tests { } impl FakeScreen { - fn primary(floor: &str) -> FakeScreen { - FakeScreen { + fn primary(floor: &str) -> Self { + Self { revision: 1, alt_epoch: 0, alt: false, diff --git a/src/protocol.rs b/src/protocol.rs index ae79158..f948850 100644 --- a/src/protocol.rs +++ b/src/protocol.rs @@ -14,6 +14,9 @@ use crate::frame::{KIND_CONTROL, KIND_HELLO, KIND_SCREEN}; /// Wire-protocol version; the handshake rejects mismatched peers. pub const PROTOCOL_VERSION: u32 = 10; +/// Reserved dashboard label for tasks without a custom group. +pub const UNASSIGNED: &str = "Unassigned"; + /// Environment and working directory supplied by the launching client. #[derive(Debug, Clone, PartialEq)] pub struct LaunchContext { @@ -24,8 +27,8 @@ pub struct LaunchContext { impl LaunchContext { /// Capture this process's environment and current directory. - pub fn here() -> LaunchContext { - LaunchContext { + pub fn here() -> Self { + Self { env: std::env::vars_os().collect(), cwd: std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")), } @@ -216,6 +219,27 @@ pub enum ClipboardKind { Selection, } +impl ClipboardKind { + /// OSC 52 selector used for this target and the protocol `clip` tag. + pub fn selector(self) -> &'static str { + match self { + Self::Clipboard => "c", + Self::Primary => "p", + Self::Selection => "s", + } + } + + /// Parse an exact `c`, `p`, or `s` selector from bytes. + pub fn from_selector(sel: &[u8]) -> Option { + match sel { + b"c" => Some(Self::Clipboard), + b"p" => Some(Self::Primary), + b"s" => Some(Self::Selection), + _ => None, + } + } +} + /// Recovery-snapshot metadata sent to the session picker. #[derive(Debug, Clone, PartialEq, Eq)] pub struct RecoveryEntry { @@ -260,10 +284,10 @@ impl PreviewSource { /// Lowercase provenance identifier. pub fn label(self) -> &'static str { match self { - PreviewSource::Floor => "floor", - PreviewSource::Marker => "marker", - PreviewSource::Title => "title", - PreviewSource::Anchor => "anchor", + Self::Floor => "floor", + Self::Marker => "marker", + Self::Title => "title", + Self::Anchor => "anchor", } } } @@ -282,8 +306,8 @@ pub struct Preview { impl Preview { /// An unfrozen `Floor` preview of `text`. - pub(crate) fn floor(text: String) -> Preview { - Preview { + pub(crate) fn floor(text: String) -> Self { + Self { text, source: PreviewSource::Floor, rule: None, @@ -385,7 +409,7 @@ fn bool_flag(v: &jzon::JsonValue) -> Option { /// Decode an optional-string field: missing and null both mean the cleared /// state (`Some(None)`), a string is the set state, and any other type /// rejects the message (`None`). -fn opt_str(v: &jzon::JsonValue) -> Option> { +pub(crate) fn opt_str(v: &jzon::JsonValue) -> Option> { if v.is_null() { return Some(None); } @@ -394,7 +418,7 @@ fn opt_str(v: &jzon::JsonValue) -> Option> { /// Insert `key` only when the optional field is set; absence encodes `None` /// on the wire (see [`opt_str`]). -fn insert_opt_str(o: &mut jzon::JsonValue, key: &str, val: &Option) { +pub(crate) fn insert_opt_str(o: &mut jzon::JsonValue, key: &str, val: &Option) { if let Some(s) = val { let _ = o.insert(key, s.as_str()); } @@ -480,17 +504,15 @@ fn source_from(s: &str) -> Option { /// Serialize a launch context as a `KIND_HELLO` frame. pub fn encode_hello(ctx: &LaunchContext) -> (u8, Vec) { - let mut o = jzon::JsonValue::new_object(); - let _ = o.insert("v", PROTOCOL_VERSION); - let _ = o.insert("cwd", path_b64(&ctx.cwd)); let mut pairs = jzon::JsonValue::new_array(); for (k, v) in &ctx.env { - let mut pair = jzon::JsonValue::new_array(); - let _ = pair.push(os_b64(k)); - let _ = pair.push(os_b64(v)); - let _ = pairs.push(pair); + let _ = pairs.push(jzon::array![os_b64(k), os_b64(v)]); } - let _ = o.insert("env", pairs); + let o = jzon::object! { + "v": PROTOCOL_VERSION, + "cwd": path_b64(&ctx.cwd), + "env": pairs, + }; (KIND_HELLO, o.dump().into_bytes()) } @@ -529,73 +551,58 @@ pub fn hello_version(kind: u8, payload: &[u8]) -> Option { /// Serialize a command to `(kind, payload)` for [`crate::frame::write_frame`]. /// Every command is a jzon control frame tagged by a `"t"` discriminant. pub fn encode_command(cmd: &Command) -> (u8, Vec) { - let mut o = jzon::JsonValue::new_object(); - match cmd { + let o = match cmd { Command::Spawn { command, cwd, group, } => { - let _ = o.insert("t", "spawn"); - let _ = o.insert("command", command.as_str()); - let _ = o.insert("cwd", path_b64(cwd)); + let mut o = jzon::object! { + "t": "spawn", + "command": command.as_str(), + "cwd": path_b64(cwd), + }; insert_opt_str(&mut o, "group", group); + o } - Command::Kill { id } => { - let _ = o.insert("t", "kill"); - let _ = o.insert("id", *id); - } - Command::Remove { id } => { - let _ = o.insert("t", "remove"); - let _ = o.insert("id", *id); - } - Command::Restart { id } => { - let _ = o.insert("t", "restart"); - let _ = o.insert("id", *id); - } - Command::Tag { id, on } => { - let _ = o.insert("t", "tag"); - let _ = o.insert("id", *id); - let _ = o.insert("on", *on); - } + Command::Kill { id } => jzon::object! { "t": "kill", "id": *id }, + Command::Remove { id } => jzon::object! { "t": "remove", "id": *id }, + Command::Restart { id } => jzon::object! { "t": "restart", "id": *id }, + Command::Tag { id, on } => jzon::object! { "t": "tag", "id": *id, "on": *on }, Command::SetGroup { id, group } => { - let _ = o.insert("t", "group"); - let _ = o.insert("id", *id); + let mut o = jzon::object! { "t": "group", "id": *id }; // Absence of `g` encodes an unassigned task. insert_opt_str(&mut o, "g", group); + o } Command::SetName { id, name } => { - let _ = o.insert("t", "name"); - let _ = o.insert("id", *id); + let mut o = jzon::object! { "t": "name", "id": *id }; // Absence of `n` encodes an unnamed task. insert_opt_str(&mut o, "n", name); + o } - Command::Resize { rows, cols } => { - let _ = o.insert("t", "resize"); - let _ = o.insert("rows", *rows as u64); - let _ = o.insert("cols", *cols as u64); - } + Command::Resize { rows, cols } => jzon::object! { + "t": "resize", + "rows": *rows as u64, + "cols": *cols as u64, + }, Command::Watch { id, attached } => { - let _ = o.insert("t", "watch"); - // Encode an absent watch ID as explicit JSON null. - let _ = o.insert("id", *id); - let _ = o.insert("attached", *attached); + // Preserve an absent watch ID as JSON null. + jzon::object! { "t": "watch", "id": *id, "attached": *attached } } // Encode both byte-carrying commands as base64. The paste-size bound in // `app` accounts for base64 expansion and the frame limit. - Command::Input { id, bytes } => { - let _ = o.insert("t", "input"); - let _ = o.insert("id", *id); - let _ = o.insert("bytes", B64.encode(bytes)); - } - Command::Paste { id, bytes } => { - let _ = o.insert("t", "paste"); - let _ = o.insert("id", *id); - let _ = o.insert("bytes", B64.encode(bytes)); - } + Command::Input { id, bytes } => jzon::object! { + "t": "input", + "id": *id, + "bytes": B64.encode(bytes), + }, + Command::Paste { id, bytes } => jzon::object! { + "t": "paste", + "id": *id, + "bytes": B64.encode(bytes), + }, Command::Mouse { id, kind, col, row } => { - let _ = o.insert("t", "mouse"); - let _ = o.insert("id", *id); let (k, btn) = match kind { MouseKind::WheelUp => ("wu", None), MouseKind::WheelDown => ("wd", None), @@ -603,16 +610,16 @@ pub fn encode_command(cmd: &Command) -> (u8, Vec) { MouseKind::Drag(b) => ("d", Some(*b)), MouseKind::Release(b) => ("r", Some(*b)), }; - let _ = o.insert("k", k); + let mut o = jzon::object! { "t": "mouse", "id": *id, "k": k }; if let Some(b) = btn { let _ = o.insert("b", b as u64); } let _ = o.insert("col", *col as u64); let _ = o.insert("row", *row as u64); + o } Command::Key { id, code, mods } => { - let _ = o.insert("t", "key"); - let _ = o.insert("id", *id); + let mut o = jzon::object! { "t": "key", "id": *id }; // A short tag names the variant; `Char`/`F` carry an extra field. let tag = match code { Key::Char(c) => { @@ -651,40 +658,27 @@ pub fn encode_command(cmd: &Command) -> (u8, Vec) { if mods.ctrl { let _ = o.insert("ct", true); } + o } Command::Scrollback { id, action } => { - let _ = o.insert("t", "sb"); - let _ = o.insert("id", *id); let (a, n) = match action { ScrollAction::Up(n) => ("u", Some(*n)), ScrollAction::Down(n) => ("d", Some(*n)), ScrollAction::Top => ("t", None), ScrollAction::Live => ("l", None), }; - let _ = o.insert("a", a); + let mut o = jzon::object! { "t": "sb", "id": *id, "a": a }; if let Some(n) = n { let _ = o.insert("n", n as u64); } + o } - Command::SaveSession { name } => { - let _ = o.insert("t", "save"); - let _ = o.insert("name", name.as_str()); - } - Command::LoadSession { name } => { - let _ = o.insert("t", "load"); - let _ = o.insert("name", name.as_str()); - } - Command::LoadRecovery { stem } => { - let _ = o.insert("t", "recover"); - let _ = o.insert("stem", stem.as_str()); - } - Command::ListSessions => { - let _ = o.insert("t", "list"); - } - Command::Shutdown => { - let _ = o.insert("t", "shutdown"); - } - } + Command::SaveSession { name } => jzon::object! { "t": "save", "name": name.as_str() }, + Command::LoadSession { name } => jzon::object! { "t": "load", "name": name.as_str() }, + Command::LoadRecovery { stem } => jzon::object! { "t": "recover", "stem": stem.as_str() }, + Command::ListSessions => jzon::object! { "t": "list" }, + Command::Shutdown => jzon::object! { "t": "shutdown" }, + }; (KIND_CONTROL, o.dump().into_bytes()) } @@ -840,8 +834,7 @@ pub fn decode_command(kind: u8, payload: &[u8]) -> Option { pub fn encode_event(ev: &Event) -> (u8, Vec) { match ev { Event::HelloOk => { - let mut o = jzon::JsonValue::new_object(); - let _ = o.insert("t", "hello_ok"); + let o = jzon::object! { "t": "hello_ok" }; (KIND_CONTROL, o.dump().into_bytes()) } Event::Tasks(views) => { @@ -868,70 +861,51 @@ pub fn encode_event(ev: &Event) -> (u8, Vec) { insert_opt_ms(&mut o, "finished_ms", tv.finished_ago); let _ = arr.push(o); } - let mut root = jzon::JsonValue::new_object(); - let _ = root.insert("t", "tasks"); - let _ = root.insert("tasks", arr); + let root = jzon::object! { "t": "tasks", "tasks": arr }; (KIND_CONTROL, root.dump().into_bytes()) } Event::Status(msg) => { - let mut o = jzon::JsonValue::new_object(); - let _ = o.insert("t", "status"); - let _ = o.insert("msg", msg.as_str()); + let o = jzon::object! { "t": "status", "msg": msg.as_str() }; (KIND_CONTROL, o.dump().into_bytes()) } Event::Sessions { names, recovery } => { - let mut arr = jzon::JsonValue::new_array(); - for n in names { - let _ = arr.push(n.as_str()); - } let mut rec = jzon::JsonValue::new_array(); for r in recovery { - let mut m = jzon::JsonValue::new_object(); - let _ = m.insert("stem", r.stem.as_str()); - let _ = m.insert("label", r.label.as_str()); - let _ = m.insert("tasks", u64::from(r.tasks)); - let _ = m.insert("age", r.age_secs); - let _ = rec.push(m); + let _ = rec.push(jzon::object! { + "stem": r.stem.as_str(), + "label": r.label.as_str(), + "tasks": u64::from(r.tasks), + "age": r.age_secs, + }); } - let mut o = jzon::JsonValue::new_object(); - let _ = o.insert("t", "sessions"); - let _ = o.insert("names", arr); - let _ = o.insert("recovery", rec); + let o = jzon::object! { + "t": "sessions", + "names": names.iter().map(String::as_str).collect::>(), + "recovery": rec, + }; (KIND_CONTROL, o.dump().into_bytes()) } // Base64 preserves arbitrary clipboard text in the JSON frame. Event::ClipboardCopy { id, kind, text } => { - let mut o = jzon::JsonValue::new_object(); - let _ = o.insert("t", "clip"); - let _ = o.insert("id", *id); - let _ = o.insert( - "k", - match kind { - ClipboardKind::Clipboard => "c", - ClipboardKind::Primary => "p", - ClipboardKind::Selection => "s", - }, - ); - let _ = o.insert("text", B64.encode(text.as_bytes())); + let o = jzon::object! { + "t": "clip", + "id": *id, + "k": kind.selector(), + "text": B64.encode(text.as_bytes()), + }; (KIND_CONTROL, o.dump().into_bytes()) } Event::Screen(sv) => { - let mut header = jzon::JsonValue::new_object(); - let _ = header.insert("id", sv.id); - let mut cur = jzon::JsonValue::new_array(); - let _ = cur.push(sv.cursor.0 as u64); - let _ = cur.push(sv.cursor.1 as u64); - let _ = header.insert("cursor", cur); - let _ = header.insert("hide", sv.hide_cursor); - let _ = header.insert("mouse", sv.wants_mouse); - let _ = header.insert("alt", sv.alt_screen); - let _ = header.insert("ascr", sv.alt_scroll); - let _ = header.insert("sb", sv.scrollback as u64); - let mut lines = jzon::JsonValue::new_array(); - for l in &sv.lines { - let _ = lines.push(l.as_str()); - } - let _ = header.insert("lines", lines); + let header = jzon::object! { + "id": sv.id, + "cursor": [sv.cursor.0 as u64, sv.cursor.1 as u64], + "hide": sv.hide_cursor, + "mouse": sv.wants_mouse, + "alt": sv.alt_screen, + "ascr": sv.alt_scroll, + "sb": sv.scrollback as u64, + "lines": sv.lines.iter().map(String::as_str).collect::>(), + }; let hbytes = header.dump().into_bytes(); let mut payload = Vec::with_capacity(4 + hbytes.len() + sv.formatted.len()); @@ -1001,12 +975,7 @@ pub fn decode_event(kind: u8, payload: &[u8]) -> Option { }), "clip" => { let id = v["id"].as_u64()?; - let kind = match v["k"].as_str()? { - "c" => ClipboardKind::Clipboard, - "p" => ClipboardKind::Primary, - "s" => ClipboardKind::Selection, - _ => return None, - }; + let kind = ClipboardKind::from_selector(v["k"].as_str()?.as_bytes())?; // Reject clipboard payloads that are not valid UTF-8. let text = String::from_utf8(B64.decode(v["text"].as_str()?).ok()?).ok()?; Some(Event::ClipboardCopy { id, kind, text }) diff --git a/src/session.rs b/src/session.rs index 37bcd31..cfb3f00 100644 --- a/src/session.rs +++ b/src/session.rs @@ -11,7 +11,10 @@ use std::{ time::SystemTime, }; -use crate::protocol::RecoveryEntry; +use crate::{ + protocol::{RecoveryEntry, insert_opt_str, opt_str}, + task::{pid_is_dead, positive_pid}, +}; /// One recipe entry. Entries without a group or name serialize as strings; /// other entries use objects whose optional fields are written only when set. @@ -52,18 +55,6 @@ fn sanitize(name: &str) -> String { out } -/// Longest prefix of `s` at most `max` bytes long, on a char boundary. -fn prefix_bytes(s: &str, max: usize) -> &str { - if s.len() <= max { - return s; - } - let mut end = max; - while !s.is_char_boundary(end) { - end -= 1; - } - &s[..end] -} - /// Session-recipe directory: `/sessions`. A caller-supplied /// `root` wins (the supervisor passes the connecting client's /// [`FLEETCOM_CONFIG_DIR`]); otherwise the same var from this process's env, @@ -88,14 +79,9 @@ fn dirs_json(cfg: &SessionConfig) -> jzon::JsonValue { // Entries without optional labels use the string form. jzon::JsonValue::from(e.cmd.as_str()) } else { - let mut m = jzon::JsonValue::new_object(); - let _ = m.insert("cmd", e.cmd.as_str()); - if let Some(g) = &e.group { - let _ = m.insert("group", g.as_str()); - } - if let Some(n) = &e.name { - let _ = m.insert("name", n.as_str()); - } + let mut m = jzon::object! { "cmd": e.cmd.as_str() }; + insert_opt_str(&mut m, "group", &e.group); + insert_opt_str(&mut m, "name", &e.name); m }; let _ = arr.push(member); @@ -108,11 +94,12 @@ fn dirs_json(cfg: &SessionConfig) -> jzon::JsonValue { /// Serialize the versioned wrapped schema. The stored name distinguishes /// names that sanitize to the same filename. fn to_json(name: &str, cfg: &SessionConfig) -> String { - let mut obj = jzon::JsonValue::new_object(); - let _ = obj.insert("version", FORMAT_VERSION); - let _ = obj.insert("name", name); - let _ = obj.insert("dirs", dirs_json(cfg)); - obj.pretty(2) + jzon::object! { + "version": FORMAT_VERSION, + "name": name, + "dirs": dirs_json(cfg), + } + .pretty(2) } /// Serialize the recipe body for content-based change detection. @@ -173,14 +160,8 @@ fn from_json(text: &str) -> io::Result<(Option, SessionConfig)> { } // Indexing a non-object yields Null, so malformed members drop here. let cmd = m["cmd"].as_str()?.to_string(); - let group = match &m["group"] { - g if g.is_null() => None, - g => Some(g.as_str()?.to_string()), - }; - let name = match &m["name"] { - n if n.is_null() => None, - n => Some(n.as_str()?.to_string()), - }; + let group = opt_str(&m["group"])?; + let name = opt_str(&m["name"])?; Some(SessionEntry { cmd, group, name }) }) .collect(); @@ -220,7 +201,7 @@ fn write_atomic(dir: &Path, file_name: &str, contents: &str) -> io::Result Option { if pid.is_empty() || !pid.bytes().all(|b| b.is_ascii_digit()) { return None; } - pid.parse::().ok().filter(|p| *p > 0) + positive_pid(pid) } /// Return whether a valid PID suffix is not known to be dead. Only `ESRCH` /// proves death; invalid suffixes receive no liveness protection. fn stem_names_live_writer(stem: &str) -> bool { - use nix::{errno::Errno, sys::signal::kill, unistd::Pid}; - stem_pid(stem).is_some_and(|pid| !matches!(kill(Pid::from_raw(pid), None), Err(Errno::ESRCH))) + stem_pid(stem).is_some_and(|pid| !pid_is_dead(pid)) } /// Best-effort pruning that protects `keep_stem` and snapshots whose PID is @@ -496,7 +476,6 @@ mod tests { save_in(&dir, "work", &cfg).unwrap(); assert_eq!(load_in(&dir, "work").unwrap(), cfg); assert_eq!(list_in(&dir), vec!["work".to_string()]); - let _ = fs::remove_dir_all(&dir); } /// Saved session names use case-insensitive collation. @@ -512,7 +491,6 @@ mod tests { list_in(&dir), vec!["apple".to_string(), "Beta".to_string(), "Zed".to_string()] ); - let _ = fs::remove_dir_all(&dir); } /// Mixed string and object entries survive one serialization round trip. @@ -527,7 +505,6 @@ mod tests { save_in(&dir, "mixed", &cfg).unwrap(); assert_eq!(load_in(&dir, "mixed").unwrap(), cfg); - let _ = fs::remove_dir_all(&dir); } /// Every group/name combination survives serialization. @@ -547,7 +524,6 @@ mod tests { save_in(&dir, "named", &cfg).unwrap(); assert_eq!(load_in(&dir, "named").unwrap(), cfg); - let _ = fs::remove_dir_all(&dir); } /// String members parse as unadorned entries; flat files have no stored name. @@ -592,7 +568,6 @@ mod tests { .contains("\"version\": 1") ); assert_eq!(load_in(&dir, "versioned").unwrap(), cfg); - let _ = fs::remove_dir_all(&dir); } /// A missing version is interpreted as version 1. @@ -657,7 +632,6 @@ mod tests { let err = load_in(&dir, "future").unwrap_err(); assert!(err.to_string().contains("version 3"), "{err}"); assert!(err.to_string().contains("supports 1"), "{err}"); - let _ = fs::remove_dir_all(&dir); } /// A flat schema treats `version` as metadata, not a directory. @@ -735,7 +709,6 @@ mod tests { let file = save_in(&dir, &name, &cfg).unwrap(); assert_eq!(file.file_name().unwrap().len(), 255); assert_eq!(load_in(&dir, &name).unwrap(), cfg); - let _ = fs::remove_dir_all(&dir); } /// Recipe files are owner-only, including after replacing a 0644 file. @@ -752,7 +725,6 @@ mod tests { fs::set_permissions(&file, fs::Permissions::from_mode(0o644)).unwrap(); save_in(&dir, "keys", &cfg).unwrap(); assert_eq!(mode(&file), 0o600); - let _ = fs::remove_dir_all(&dir); } /// Session directories are created private and existing permissive session @@ -774,7 +746,6 @@ mod tests { fs::set_permissions(&loose, fs::Permissions::from_mode(0o755)).unwrap(); save_in(&loose, "old", &cfg).unwrap(); assert_eq!(mode(&loose), 0o700); - let _ = fs::remove_dir_all(&base); } /// The temp is renamed away on success; only the recipe remains. @@ -785,12 +756,11 @@ mod tests { cfg.insert("~/p".into(), vec![e("vim")]); save_in(&dir, "clean", &cfg).unwrap(); - let names: Vec = fs::read_dir(&dir) + let names: Vec = fs::read_dir(&*dir) .unwrap() .map(|e| e.unwrap().file_name().into_string().unwrap()) .collect(); assert_eq!(names, vec!["clean.json".to_string()]); - let _ = fs::remove_dir_all(&dir); } /// Saves reject a different name that sanitizes to an occupied filename. @@ -808,7 +778,6 @@ mod tests { assert!(err.to_string().contains("\"a.b\""), "{err}"); assert!(err.to_string().contains("\"a/b\""), "{err}"); assert_eq!(load_in(&dir, "a/b").unwrap(), first); - let _ = fs::remove_dir_all(&dir); } /// Flat-schema files load and list by filename stem. @@ -822,7 +791,6 @@ mod tests { vec![e("cargo test")] ); assert_eq!(list_in(&dir), vec!["old".to_string()]); - let _ = fs::remove_dir_all(&dir); } /// A flat-schema file can be replaced under its filename stem. @@ -835,7 +803,6 @@ mod tests { cfg.insert("~/new".into(), vec![e("top")]); save_in(&dir, "mine", &cfg).unwrap(); assert_eq!(load_in(&dir, "mine").unwrap(), cfg); - let _ = fs::remove_dir_all(&dir); } /// Wrapped files list by stored name; flat files list by filename stem. @@ -851,7 +818,6 @@ mod tests { for n in list_in(&dir) { load_in(&dir, &n).unwrap(); } - let _ = fs::remove_dir_all(&dir); } /// Fixed instant at 2026-07-14 09:30:15 UTC. @@ -897,7 +863,6 @@ mod tests { let mode = |p: &Path| fs::metadata(p).unwrap().permissions().mode() & 0o777; assert_eq!(mode(&rec), 0o700); assert_eq!(mode(&file), 0o600); - let _ = fs::remove_dir_all(&base); } /// Pruning keeps the lexically greatest [`RECOVERY_KEEP`] filenames. @@ -922,7 +887,6 @@ mod tests { .map(|i| format!("20260714-0930{i:02}-{DEAD_FIXTURE_PID}.json")) .collect(); assert_eq!(names, expected, "prune must drop exactly the oldest two"); - let _ = fs::remove_dir_all(&base); } /// Pruning retains the just-written stem even when it is the oldest. @@ -956,7 +920,6 @@ mod tests { names, expected, "the active file plus the nine newest others must remain" ); - let _ = fs::remove_dir_all(&base); } /// Below [`RECOVERY_KEEP`] files, pruning removes nothing. @@ -976,7 +939,6 @@ mod tests { 5, "no file may be pruned below the retention limit" ); - let _ = fs::remove_dir_all(&base); } /// Pruning retains an older snapshot whose PID is still live. @@ -1007,7 +969,6 @@ mod tests { names, expected, "the live writer's file must survive; the oldest dead file must not" ); - let _ = fs::remove_dir_all(&base); } /// A snapshot from an exited process is eligible for pruning. @@ -1033,7 +994,6 @@ mod tests { RECOVERY_KEEP, "dead-stem retention must converge to the bound" ); - let _ = fs::remove_dir_all(&base); } /// Invalid PID suffixes receive no liveness protection. @@ -1068,7 +1028,6 @@ mod tests { RECOVERY_KEEP, "only the well-formed newest files may remain" ); - let _ = fs::remove_dir_all(&base); } /// The recovery directory is excluded from named-session listings. @@ -1087,7 +1046,6 @@ mod tests { .unwrap(); assert_eq!(list_in(&dir), vec!["real".to_string()]); - let _ = fs::remove_dir_all(&dir); } /// Listing sorts by descending stem and skips corrupt files. @@ -1137,7 +1095,6 @@ mod tests { entries.iter().all(|en| en.age_secs < 3600), "just-written files must read near-zero ages: {entries:?}" ); - let _ = fs::remove_dir_all(&base); } /// Recovery loads reject empty, dotted, or path-shaped stems. @@ -1170,6 +1127,5 @@ mod tests { .kind(), io::ErrorKind::NotFound ); - let _ = fs::remove_dir_all(&base); } } diff --git a/src/supervisor.rs b/src/supervisor.rs index 0d151da..ca5a639 100644 --- a/src/supervisor.rs +++ b/src/supervisor.rs @@ -17,7 +17,9 @@ use crate::{ core::{Wake, Waker}, harness::{self, assets}, path, - protocol::{Command, Event, LaunchContext, ScreenView, ScrollAction, TaskView, env_get}, + protocol::{ + Command, Event, LaunchContext, ScreenView, ScrollAction, TaskView, UNASSIGNED, env_get, + }, session::{self, SessionConfig, SessionEntry}, task::{Task, WriteRefused}, }; @@ -116,10 +118,9 @@ fn normalize_label(label: Option) -> Option { Some(capped) } -/// Normalize a group assignment and map the case-sensitive reserved label -/// `Unassigned` to `None`. Display names do not reserve this label. +/// Normalize a group and map the reserved [`UNASSIGNED`] label to `None`. fn normalize_group(name: Option) -> Option { - normalize_label(name).filter(|g| g != "Unassigned") + normalize_label(name).filter(|g| g != UNASSIGNED) } /// Return the 64-bit FNV-1a hash used to separate fallback capture roots. The @@ -176,9 +177,9 @@ struct Recovery { last_mutation: Option, /// The start of the most recent cadence interval. last_cadence: Instant, - /// Sessions root and recipe fingerprint of the last successful write. - /// A match is skipped only while the corresponding snapshot still exists. - last_written: Option<(PathBuf, String)>, + /// Sessions root, snapshot path, and recipe fingerprint from the last + /// successful write. Deduplication requires all three and an existing file. + last_written: Option<(PathBuf, PathBuf, String)>, /// Filename stem reused for this supervisor's recovery writes. stem: String, /// Whether a write failure has been reported since the last successful write. @@ -189,8 +190,8 @@ struct Recovery { } impl Recovery { - fn new() -> Recovery { - Recovery { + fn new() -> Self { + Self { enabled: !cfg!(test), dirty: false, last_mutation: None, @@ -252,8 +253,8 @@ pub struct Supervisor { } impl Supervisor { - pub fn new(rows: u16, cols: u16, scrollback: usize) -> Supervisor { - Supervisor { + pub fn new(rows: u16, cols: u16, scrollback: usize) -> Self { + Self { tasks: Vec::new(), graveyard: Vec::new(), next_id: 1, @@ -347,16 +348,9 @@ impl Supervisor { } => self.spawn(&command, cwd, group), 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) { - let mut t = self.tasks.remove(i); - t.terminate(); - // Remove the task's own capture file; the current client - // may use a different capture root. - if let Some(cap) = &t.capture_file { - let _ = std::fs::remove_file(cap); - } - self.graveyard.push(t); + let t = self.tasks.remove(i); + self.retire(t); } } Command::Restart { id } => self.rerun(id), @@ -609,13 +603,12 @@ impl Supervisor { let hash = fnv1a_hex(session::fingerprint_json(&cfg).as_bytes()); // Deduplication is scoped to the current root and requires the snapshot // to remain on disk, so a removed snapshot is recreated on a due pass. - let dest = session::recovery_dir(&root).join(format!("{}.json", self.recovery.stem)); + // A reconnect can change the sessions root, so include it in the match. if self .recovery .last_written .as_ref() - .is_some_and(|(r, h)| *r == root && *h == hash) - && std::fs::metadata(&dest).is_ok() + .is_some_and(|(r, dest, h)| *r == root && *h == hash && std::fs::metadata(dest).is_ok()) { self.recovery.dirty = false; return; @@ -627,8 +620,8 @@ impl Supervisor { &label, &cfg, ) { - Ok(_) => { - self.recovery.last_written = Some((root, hash)); + Ok(dest) => { + self.recovery.last_written = Some((root, dest, hash)); self.recovery.failing = false; } Err(e) => { @@ -668,6 +661,16 @@ impl Supervisor { } } + /// Terminate a removed task, unlink its capture, and retain it for + /// escalation and reaping. + fn retire(&mut self, mut t: Task) { + t.terminate(); + if let Some(cap) = &t.capture_file { + let _ = std::fs::remove_file(cap); + } + self.graveyard.push(t); + } + /// Route one input send to task `id`, reporting a bounded-queue refusal. fn deliver( &mut self, @@ -863,17 +866,10 @@ impl Supervisor { fresh.tagged = self.tasks[i].tagged; fresh.group = self.tasks[i].group.clone(); fresh.name = self.tasks[i].name.clone(); - // The displaced task exits like a Remove: TERM now, the - // graveyard's grace-then-KILL behind it. Dropping it here - // would straight-SIGKILL stragglers of the old run. - let mut old = std::mem::replace(&mut self.tasks[i], fresh); - old.terminate(); - // Delete the displaced run's capture after deriving its resume - // command. Use the task's path because capture roots can vary. - if let Some(cap) = &old.capture_file { - let _ = std::fs::remove_file(cap); - } - self.graveyard.push(old); + // Derive the resume command before retirement removes the + // displaced run's capture file. + let old = std::mem::replace(&mut self.tasks[i], fresh); + self.retire(old); // Reset the fingerprint for the replacement task's screen. if self.watched == Some(id) { self.last_screen = None; @@ -1038,7 +1034,7 @@ impl Supervisor { let Some((spawned, skipped, failed)) = self.materialize(&cfg) else { return; }; - // Omit zero buckets, except report zero tasks for an empty recipe. + // Report zero tasks only for an empty recipe. let mut parts = Vec::new(); if spawned > 0 || (skipped == 0 && failed == 0) { parts.push(format!("{spawned} task(s)")); @@ -1062,6 +1058,7 @@ impl Supervisor { let Some((_, skipped, failed)) = self.materialize(&cfg) else { return; }; + // Append optional clauses to the fixed message prefix. let mut msg = String::from("loaded recovery snapshot; save to name it"); if skipped > 0 { msg.push_str(&format!(", {skipped} skipped ({SKIP_REASONS})")); diff --git a/src/supervisor_capture_tests.rs b/src/supervisor_capture_tests.rs index eb774f2..40869d0 100644 --- a/src/supervisor_capture_tests.rs +++ b/src/supervisor_capture_tests.rs @@ -94,8 +94,8 @@ 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 = sup_ctx(agent_ctx(&bin, &runtime, dir.clone())); - spawn(&mut s, "claude", dir.clone()); + let mut s = sup_ctx(agent_ctx(&bin, &runtime, dir.to_path_buf())); + spawn(&mut s, "claude", dir.to_path_buf()); let argv = wait_argv(&mut s, &dir.join("argv")); let si = argv @@ -159,7 +159,6 @@ fn spawn_claude_pins_an_id_and_layers_settings() { ); assert_eq!(t.resume_id.as_deref(), Some(id.as_str())); assert!(t.harness.is_some()); - let _ = std::fs::remove_dir_all(&dir); } /// An unrecognized command spawns without capture state or assets. @@ -167,8 +166,8 @@ 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 = sup_ctx(agent_ctx(&dir.join("bin"), &runtime, dir.clone())); - spawn(&mut s, "printf ok", dir.clone()); + let mut s = sup_ctx(agent_ctx(&dir.join("bin"), &runtime, dir.to_path_buf())); + spawn(&mut s, "printf ok", dir.to_path_buf()); let t = &s.tasks[0]; assert!(t.harness.is_none()); assert!(t.capture_file.is_none()); @@ -178,7 +177,6 @@ fn spawn_non_agent_command_is_not_instrumented() { "a non-agent spawn must not install capture assets" ); assert!(!runtime.exists()); - let _ = std::fs::remove_dir_all(&dir); } /// A resuming `claude` launch retains its target ID and adds only the @@ -188,8 +186,12 @@ 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 = sup_ctx(agent_ctx(&bin, &runtime, dir.clone())); - spawn(&mut s, format!("claude --resume {CAP_ID}"), dir.clone()); + let mut s = sup_ctx(agent_ctx(&bin, &runtime, dir.to_path_buf())); + spawn( + &mut s, + format!("claude --resume {CAP_ID}"), + dir.to_path_buf(), + ); let argv = wait_argv(&mut s, &dir.join("argv")); assert!( @@ -203,7 +205,6 @@ fn spawn_resuming_claude_injects_only_the_capture_channel() { let t = &s.tasks[0]; assert_eq!(t.command, format!("claude --resume {CAP_ID}")); assert_eq!(t.resume_id.as_deref(), Some(CAP_ID)); - let _ = std::fs::remove_dir_all(&dir); } /// Rerun prefers the capture-file ID, stores the resulting resume command, @@ -214,8 +215,8 @@ 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 = sup_ctx(agent_ctx(&bin, &runtime, dir.clone())); - spawn(&mut s, "claude", dir.clone()); + let mut s = sup_ctx(agent_ctx(&bin, &runtime, dir.to_path_buf())); + spawn(&mut s, "claude", dir.to_path_buf()); let _ = wait_argv(&mut s, &dir.join("argv")); let id = s.tasks[0].id; wait_for_lifecycle(&mut s, id, |l| l == Lifecycle::Ok); @@ -256,7 +257,6 @@ fn rerun_resumes_the_captured_conversation() { !cap.exists(), "rerun must delete the displaced run's capture file" ); - let _ = std::fs::remove_dir_all(&dir); } /// A rerun uses a new capture path, so the displaced run's payload and @@ -274,10 +274,10 @@ fn rerun_cannot_read_the_old_runs_stale_capture() { let mut s = sup_ctx(agent_ctx_plus( &bin, &runtime, - dir.clone(), + dir.to_path_buf(), &[("FLEETCOM_CONFIG_DIR", &config)], )); - spawn(&mut s, "claude", dir.clone()); + spawn(&mut s, "claude", dir.to_path_buf()); let id = s.tasks[0].id; // The capture file still holds the pre-drift session. let stale = format!( @@ -310,7 +310,6 @@ fn rerun_cannot_read_the_old_runs_stale_capture() { !text.contains(CAP_OTHER), "the old run's stale capture must be unreachable; got {text}" ); - let _ = std::fs::remove_dir_all(&dir); } /// Removing a task also removes its capture file. @@ -320,8 +319,8 @@ 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 = sup_ctx(agent_ctx(&bin, &runtime, dir.clone())); - spawn(&mut s, "claude", dir.clone()); + let mut s = sup_ctx(agent_ctx(&bin, &runtime, dir.to_path_buf())); + spawn(&mut s, "claude", dir.to_path_buf()); let _ = wait_argv(&mut s, &dir.join("argv")); let id = s.tasks[0].id; wait_for_lifecycle(&mut s, id, |l| l == Lifecycle::Ok); @@ -330,7 +329,6 @@ fn remove_deletes_the_capture_file() { s.apply(Command::Remove { id }); assert!(!cap.exists(), "Remove must delete the task's capture file"); - let _ = std::fs::remove_dir_all(&dir); } /// Reconnecting with the active root reuses installed assets and preserves @@ -340,20 +338,19 @@ 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 = sup_ctx(agent_ctx(&bin, &runtime, dir.clone())); - spawn(&mut s, "claude", dir.clone()); + let mut s = sup_ctx(agent_ctx(&bin, &runtime, dir.to_path_buf())); + spawn(&mut s, "claude", dir.to_path_buf()); let cap = s.tasks[0].capture_file.clone().expect("capture file set"); std::fs::write(&cap, "{}").unwrap(); // The client reconnects with an identical env and spawns again. - s.set_launch_context(agent_ctx(&bin, &runtime, dir.clone())); - spawn(&mut s, "claude", dir.clone()); + s.set_launch_context(agent_ctx(&bin, &runtime, dir.to_path_buf())); + spawn(&mut s, "claude", dir.to_path_buf()); assert_eq!(s.tasks.len(), 2); assert!( cap.exists(), "an unchanged root must not disturb live capture files" ); - let _ = std::fs::remove_dir_all(&dir); } /// Returning to an installed root preserves its live capture files. @@ -363,17 +360,17 @@ fn returning_to_a_prior_root_preserves_its_live_captures() { let (bin, root_a, root_b) = (dir.join("bin"), dir.join("run-a"), dir.join("run-b")); install_stub(&bin, "claude", &dir); let mut s = Supervisor::new(24, 80, 2000); - s.set_launch_context(agent_ctx(&bin, &root_a, dir.clone())); - spawn(&mut s, "claude", dir.clone()); + s.set_launch_context(agent_ctx(&bin, &root_a, dir.to_path_buf())); + spawn(&mut s, "claude", dir.to_path_buf()); let cap_a = s.tasks[0].capture_file.clone().expect("capture file set"); std::fs::write(&cap_a, "{}").unwrap(); // The client reconnects under root B, spawns, then returns to A and // spawns again. - s.set_launch_context(agent_ctx(&bin, &root_b, dir.clone())); - spawn(&mut s, "claude", dir.clone()); - s.set_launch_context(agent_ctx(&bin, &root_a, dir.clone())); - spawn(&mut s, "claude", dir.clone()); + s.set_launch_context(agent_ctx(&bin, &root_b, dir.to_path_buf())); + spawn(&mut s, "claude", dir.to_path_buf()); + s.set_launch_context(agent_ctx(&bin, &root_a, dir.to_path_buf())); + spawn(&mut s, "claude", dir.to_path_buf()); assert_eq!(s.tasks.len(), 3); assert!( @@ -390,7 +387,6 @@ fn returning_to_a_prior_root_preserves_its_live_captures() { ns_b.join("claude-settings.json").is_file(), "the interleaved root must keep its own namespaced assets" ); - let _ = std::fs::remove_dir_all(&dir); } /// Remove deletes the capture file under the root the task spawned in, @@ -402,8 +398,8 @@ fn remove_deletes_the_capture_file_under_the_spawn_root() { let (bin, root_a, root_b) = (dir.join("bin"), dir.join("run-a"), dir.join("run-b")); install_stub(&bin, "claude", &dir); let mut s = Supervisor::new(24, 80, 2000); - s.set_launch_context(agent_ctx(&bin, &root_a, dir.clone())); - spawn(&mut s, "claude", dir.clone()); + s.set_launch_context(agent_ctx(&bin, &root_a, dir.to_path_buf())); + spawn(&mut s, "claude", dir.to_path_buf()); let _ = wait_argv(&mut s, &dir.join("argv")); let id = s.tasks[0].id; wait_for_lifecycle(&mut s, id, |l| l == Lifecycle::Ok); @@ -412,8 +408,8 @@ fn remove_deletes_the_capture_file_under_the_spawn_root() { // Root B is installed by a newer spawn; a same-id file under it must // survive the A task's removal. - s.set_launch_context(agent_ctx(&bin, &root_b, dir.clone())); - spawn(&mut s, "claude", dir.clone()); + s.set_launch_context(agent_ctx(&bin, &root_b, dir.to_path_buf())); + spawn(&mut s, "claude", dir.to_path_buf()); let decoy = s.tasks[1] .capture_file .as_deref() @@ -431,7 +427,6 @@ fn remove_deletes_the_capture_file_under_the_spawn_root() { decoy.exists(), "Remove must not touch the same id under another root" ); - let _ = std::fs::remove_dir_all(&dir); } /// A `codex` spawn receives a `notify=[...]` override naming an executable @@ -446,10 +441,10 @@ fn spawn_codex_installs_the_notify_override() { let mut s = sup_ctx(agent_ctx_plus( &bin, &runtime, - dir.clone(), + dir.to_path_buf(), &[("CODEX_HOME", &dir.join("codex_home"))], )); - spawn(&mut s, "codex", dir.clone()); + spawn(&mut s, "codex", dir.to_path_buf()); let argv = wait_argv(&mut s, &dir.join("argv")); let ci = argv @@ -469,7 +464,6 @@ fn spawn_codex_installs_the_notify_override() { assert_eq!(t.command, "codex"); assert!(t.resume_id.is_none(), "codex cannot pin an id at launch"); assert!(t.capture_file.is_some()); - let _ = std::fs::remove_dir_all(&dir); } /// A `grok` spawn receives exactly the pinned ID: no settings overlay, @@ -484,13 +478,13 @@ fn spawn_grok_pins_an_id_and_injects_nothing_else() { let mut s = sup_ctx(agent_ctx_plus( &bin, &runtime, - dir.clone(), + dir.to_path_buf(), &[ ("FLEETCOM_CONFIG_DIR", &config), ("GROK_HOME", &dir.join("grok_home")), ], )); - spawn(&mut s, "grok", dir.clone()); + spawn(&mut s, "grok", dir.to_path_buf()); let argv = wait_argv(&mut s, &dir.join("argv")); assert_eq!( @@ -522,7 +516,6 @@ fn spawn_grok_pins_an_id_and_injects_nothing_else() { text.contains(&format!("grok --resume '{id}'")), "the recipe must resume the pinned session; got {text}" ); - let _ = std::fs::remove_dir_all(&dir); } /// A `claude` exit hint becomes the session ID used by the saved recipe. @@ -538,10 +531,10 @@ fn exit_hint_is_scraped_and_saved_as_a_resume() { let mut s = sup_ctx(agent_ctx_plus( &bin, &runtime, - dir.clone(), + dir.to_path_buf(), &[("FLEETCOM_CONFIG_DIR", &config)], )); - spawn(&mut s, "claude", dir.clone()); + spawn(&mut s, "claude", dir.to_path_buf()); // No pre-exit synchronization: the scrape's reader-EOF gate means // reap can run against the exiting stub at any point and the hint // still lands. @@ -555,7 +548,6 @@ fn exit_hint_is_scraped_and_saved_as_a_resume() { text.contains(&format!("claude --resume '{CAP_ID}'")), "the recipe must resume the scraped session; got {text}" ); - let _ = std::fs::remove_dir_all(&dir); } /// Saving between process exit and the next reap tick still captures the @@ -572,10 +564,10 @@ fn save_scrapes_a_finished_task_without_reap() { let mut s = sup_ctx(agent_ctx_plus( &bin, &runtime, - dir.clone(), + dir.to_path_buf(), &[("FLEETCOM_CONFIG_DIR", &config)], )); - spawn(&mut s, "claude", dir.clone()); + spawn(&mut s, "claude", dir.to_path_buf()); // Wait out only the residual reader-drain race: after EOF the sole // remaining gate is the exit latch, which save's own pass must flip. @@ -591,7 +583,6 @@ fn save_scrapes_a_finished_task_without_reap() { "save must scrape the finished task itself; got {text}" ); assert_eq!(s.tasks[0].scraped_id.as_deref(), Some(CAP_ID)); - let _ = std::fs::remove_dir_all(&dir); } /// Rerunning between process exit and the next reap tick latches the exit, @@ -605,8 +596,8 @@ fn rerun_scrapes_a_finished_task_without_reap() { "claude", &format!("printf 'Resume this session with:\\nclaude --resume {CAP_ID}\\n'"), ); - let mut s = sup_ctx(agent_ctx(&bin, &runtime, dir.clone())); - spawn(&mut s, "claude", dir.clone()); + let mut s = sup_ctx(agent_ctx(&bin, &runtime, dir.to_path_buf())); + spawn(&mut s, "claude", dir.to_path_buf()); let id = s.tasks[0].id; assert!( @@ -621,7 +612,6 @@ fn rerun_scrapes_a_finished_task_without_reap() { format!("claude --resume '{CAP_ID}'"), "rerun must compute its resume command from the exit scrape" ); - let _ = std::fs::remove_dir_all(&dir); } /// Session-ID precedence is exit scrape, capture file, then spawn-time ID. @@ -644,10 +634,10 @@ fn resume_id_precedence_scrape_over_capture_over_spawn() { let mut s = sup_ctx(agent_ctx_plus( &bin, &runtime, - dir.clone(), + dir.to_path_buf(), &[("FLEETCOM_CONFIG_DIR", &config)], )); - spawn(&mut s, "claude", dir.clone()); + spawn(&mut s, "claude", dir.to_path_buf()); let injected = s.tasks[0] .resume_id .clone() @@ -683,7 +673,6 @@ fn resume_id_precedence_scrape_over_capture_over_spawn() { text.contains(&format!("claude --resume '{CAP_ID}'")), "post-exit the scraped hint must beat the capture file; got {text}" ); - let _ = std::fs::remove_dir_all(&dir); } /// A silent Codex task falls back to one matching rollout under @@ -701,13 +690,13 @@ fn save_falls_back_to_fs_correlation_for_a_silent_codex() { let mut s = sup_ctx(agent_ctx_plus( &bin, &runtime, - dir.clone(), + dir.to_path_buf(), &[ ("FLEETCOM_CONFIG_DIR", &config), ("CODEX_HOME", &codex_home), ], )); - spawn(&mut s, "codex", dir.clone()); + spawn(&mut s, "codex", dir.to_path_buf()); assert!(reap_until(&mut s, Duration::from_secs(5), |s| s.tasks[0] .finished .is_some())); @@ -722,7 +711,6 @@ fn save_falls_back_to_fs_correlation_for_a_silent_codex() { text.contains(&format!("codex resume '{id}'")), "save must fall back to filesystem correlation; got {text}" ); - let _ = std::fs::remove_dir_all(&dir); } /// Save-time correlation uses the task's spawn-time `CODEX_HOME`, even @@ -741,10 +729,10 @@ fn save_correlates_against_the_spawn_time_home() { let mut s = sup_ctx(agent_ctx_plus( &bin, &runtime, - dir.clone(), + dir.to_path_buf(), &[("FLEETCOM_CONFIG_DIR", &config), ("CODEX_HOME", &home_a)], )); - spawn(&mut s, "codex", dir.clone()); + spawn(&mut s, "codex", dir.to_path_buf()); assert!(reap_until(&mut s, Duration::from_secs(5), |s| s.tasks[0] .finished .is_some())); @@ -753,7 +741,7 @@ fn save_correlates_against_the_spawn_time_home() { s.set_launch_context(agent_ctx_plus( &bin, &runtime, - dir.clone(), + dir.to_path_buf(), &[("FLEETCOM_CONFIG_DIR", &config), ("CODEX_HOME", &home_b)], )); let text = save_and_read(&mut s, &config, "homepin"); @@ -765,7 +753,6 @@ fn save_correlates_against_the_spawn_time_home() { !text.contains(&id_b), "the reconnect store's decoy must not correlate; got {text}" ); - let _ = std::fs::remove_dir_all(&dir); } /// Home resolution order: the tool's own var, then the launch env's HOME @@ -823,10 +810,10 @@ fn home_only_launch_env_targets_the_clients_dot_codex() { let mut s = sup_ctx(agent_ctx_plus( &bin, &runtime, - dir.clone(), + dir.to_path_buf(), &[("FLEETCOM_CONFIG_DIR", &config), ("HOME", &home)], )); - spawn(&mut s, "codex", dir.clone()); + spawn(&mut s, "codex", dir.to_path_buf()); assert_eq!( s.tasks[0].harness_home.as_deref(), Some(codex_home.as_path()), @@ -846,7 +833,6 @@ fn home_only_launch_env_targets_the_clients_dot_codex() { text.contains(&format!("codex resume '{id}'")), "correlation must read /.codex; got {text}" ); - let _ = std::fs::remove_dir_all(&dir); } /// With no configured notifier, instrumentation clears an inherited @@ -871,13 +857,18 @@ fn stale_inherited_notify_chain_is_never_executed() { &format!("\"${{FLEETCOM_CAPTURE_FILE%/*}}/codex-notify.sh\" '{payload}'"), ); let mut s = Supervisor::new(24, 80, 2000); - let mut ctx = agent_ctx_plus(&bin, &runtime, dir.clone(), &[("CODEX_HOME", &codex_home)]); + let mut ctx = agent_ctx_plus( + &bin, + &runtime, + dir.to_path_buf(), + &[("CODEX_HOME", &codex_home)], + ); ctx.env.push(( crate::harness::NOTIFY_CHAIN_ENV.into(), stale.as_os_str().to_os_string(), )); s.set_launch_context(ctx); - spawn(&mut s, "codex", dir.clone()); + spawn(&mut s, "codex", dir.to_path_buf()); assert!(reap_until(&mut s, Duration::from_secs(5), |s| s.tasks[0] .finished .is_some())); @@ -891,7 +882,6 @@ fn stale_inherited_notify_chain_is_never_executed() { !record.exists(), "the stale inherited chain must not execute" ); - let _ = std::fs::remove_dir_all(&dir); } /// Without a live or filesystem ID, an agent recipe retains the original @@ -907,13 +897,13 @@ fn agent_save_without_any_id_keeps_the_plain_command() { let mut s = sup_ctx(agent_ctx_plus( &bin, &runtime, - dir.clone(), + dir.to_path_buf(), &[ ("FLEETCOM_CONFIG_DIR", &config), ("CODEX_HOME", &codex_home), ], )); - spawn(&mut s, "codex", dir.clone()); + spawn(&mut s, "codex", dir.to_path_buf()); assert!(reap_until(&mut s, Duration::from_secs(5), |s| s.tasks[0] .finished .is_some())); @@ -927,7 +917,6 @@ fn agent_save_without_any_id_keeps_the_plain_command() { !text.contains("resume"), "no id exists, so nothing may be rewritten; got {text}" ); - let _ = std::fs::remove_dir_all(&dir); } /// A representable `notify` assignment runs through the injected notifier @@ -966,10 +955,10 @@ fn config_toml_notify_chains_through_the_injected_script() { let mut s = sup_ctx(agent_ctx_plus( &bin, &runtime, - dir.clone(), + dir.to_path_buf(), &[("CODEX_HOME", &codex_home)], )); - spawn(&mut s, "codex", dir.clone()); + spawn(&mut s, "codex", dir.to_path_buf()); let argv = wait_argv(&mut s, &dir.join("argv")); assert!( argv.iter().any(|a| a.starts_with("notify=[")), @@ -996,7 +985,6 @@ fn config_toml_notify_chains_through_the_injected_script() { payload, "the capture write must precede the chain handoff" ); - let _ = std::fs::remove_dir_all(&dir); } /// An unrepresentable `notify` value disables injection, while a commented @@ -1017,10 +1005,10 @@ fn unrepresentable_config_notify_suppresses_injection() { let mut s = sup_ctx(agent_ctx_plus( &bin, &runtime, - dir.clone(), + dir.to_path_buf(), &[("CODEX_HOME", &codex_home)], )); - spawn(&mut s, "codex", dir.clone()); + spawn(&mut s, "codex", dir.to_path_buf()); let argv = wait_argv(&mut s, &dir.join("argv")); assert!( !argv.iter().any(|a| a.contains("notify=")), @@ -1034,13 +1022,12 @@ fn unrepresentable_config_notify_suppresses_injection() { ) .unwrap(); std::fs::remove_file(dir.join("argv")).unwrap(); - spawn(&mut s, "codex", dir.clone()); + spawn(&mut s, "codex", dir.to_path_buf()); let argv = wait_argv(&mut s, &dir.join("argv")); assert!( argv.iter().any(|a| a.starts_with("notify=[")), "a commented notify must not suppress the injection; argv: {argv:?}" ); - let _ = std::fs::remove_dir_all(&dir); } /// Non-agent commands remain plain string entries in persisted JSON. @@ -1048,8 +1035,12 @@ 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 = sup_ctx(config_ctx(&config, dir.clone(), &[("SHELL", "/bin/sh")])); - spawn(&mut s, "sleep 30", dir.clone()); + let mut s = sup_ctx(config_ctx( + &config, + dir.to_path_buf(), + &[("SHELL", "/bin/sh")], + )); + spawn(&mut s, "sleep 30", dir.to_path_buf()); let text = save_and_read(&mut s, &config, "plain"); assert!( text.contains("\"sleep 30\""), @@ -1068,7 +1059,6 @@ fn non_agent_entries_survive_save_as_plain_strings() { name: None, }] ); - let _ = std::fs::remove_dir_all(&dir); } /// Cadence passes persist changed capture IDs without rewriting stable recipes. @@ -1080,11 +1070,11 @@ fn recovery_cadence_rewrites_on_capture_drift_and_skips_when_static() { let mut s = sup_ctx(agent_ctx_plus( &bin, &runtime, - dir.clone(), + dir.to_path_buf(), &[("FLEETCOM_CONFIG_DIR", &config)], )); s.set_recovery_timing(Duration::from_millis(20), Duration::from_millis(100)); - spawn(&mut s, "claude", dir.clone()); + spawn(&mut s, "claude", dir.to_path_buf()); let _ = wait_argv(&mut s, &dir.join("argv")); let rec = config.join("sessions").join("recovery"); @@ -1133,5 +1123,4 @@ fn recovery_cadence_rewrites_on_capture_drift_and_skips_when_static() { ); let names: Vec<_> = std::fs::read_dir(&rec).unwrap().flatten().collect(); assert_eq!(names.len(), 1, "one incarnation owns one snapshot file"); - let _ = std::fs::remove_dir_all(&dir); } diff --git a/src/supervisor_tests.rs b/src/supervisor_tests.rs index c48ae4c..1887304 100644 --- a/src/supervisor_tests.rs +++ b/src/supervisor_tests.rs @@ -4,8 +4,8 @@ use super::*; use crate::{ protocol::{ClipboardKind, Key, Mods}, testutil::{ - here, install_fake_notifier, now_ms, read_pid, sh_env, wait_until, write_executable, - write_rollout, + Scratch, here, install_fake_notifier, now_ms, read_pid, sh_env, wait_until, + write_executable, write_rollout, }, }; @@ -200,7 +200,6 @@ fn decset_1007_flip_resends_watched_screen() { .any(|e| matches!(e, Event::Screen(sv) if sv.alt_screen && !sv.alt_scroll)) }); assert!(closed, "the ?1007l flip never re-sent the screen"); - let _ = std::fs::remove_dir_all(&dir); } /// A periodic tick flushes an expired synchronized update from a child @@ -270,7 +269,6 @@ fn watched_task_clipboard_stores_are_forwarded() { (id, ClipboardKind::Selection, "world".to_string()), ] ); - let _ = std::fs::remove_dir_all(&dir); } /// Starting a watch discards stores captured before the watch. @@ -460,7 +458,6 @@ fn peeked_stores_never_forward_and_die_at_the_attach_transition() { copies, vec![(id, ClipboardKind::Clipboard, "post".to_string())] ); - let _ = std::fs::remove_dir_all(&dir); } /// An oversized store produces a status notice instead of a clipboard event. @@ -505,11 +502,10 @@ fn oversized_watched_store_yields_notice_and_no_copy() { notice.as_deref(), Some("clipboard copy dropped: 3 MiB exceeds the 1 MiB limit") ); - let _ = std::fs::remove_dir_all(&dir); } /// Scratch dir for tests that sync through marker files. -fn scratch(tag: &str) -> PathBuf { +fn scratch(tag: &str) -> Scratch { crate::testutil::temp(&format!("sup_{tag}")) } @@ -591,13 +587,12 @@ fn kill_delivers_term_before_kill() { t = trapped.display(), r = ready.display() ), - dir.clone(), + dir.to_path_buf(), &ready, ); s.apply(Command::Kill { id }); wait_for_lifecycle(&mut s, id, |l| l == Lifecycle::Ok); assert!(trapped.exists(), "the TERM trap never ran"); - let _ = std::fs::remove_dir_all(&dir); } /// A task that ignores SIGTERM is SIGKILLed once the grace elapses, via the @@ -615,12 +610,11 @@ fn term_ignoring_task_escalates_to_kill() { "trap '' TERM; echo r > {r}; while :; do sleep 0.1; done", r = ready.display() ), - dir.clone(), + dir.to_path_buf(), &ready, ); s.apply(Command::Kill { id }); wait_for_lifecycle(&mut s, id, |l| l == Lifecycle::Failed); - let _ = std::fs::remove_dir_all(&dir); } /// `Shutdown` exits as soon as TERM-respecting tasks die: well inside the @@ -720,7 +714,7 @@ fn shutdown_is_bounded_by_grace() { "trap '' TERM; echo r > {r}; while :; do sleep 0.1; done", r = ready.display() ), - dir.clone(), + dir.to_path_buf(), &ready, ); let t0 = Instant::now(); @@ -736,7 +730,6 @@ fn shutdown_is_bounded_by_grace() { .iter() .any(|e| matches!(e, Event::Tasks(v) if v.is_empty())) ); - let _ = std::fs::remove_dir_all(&dir); } /// `clear_watch` (the client-disconnect path) must stop the `Screen` stream @@ -823,7 +816,7 @@ fn rerun_replaces_finished_task_in_place() { spawn( &mut s, format!("echo run >> {}", marker.display()), - dir.clone(), + dir.to_path_buf(), ); let id = first_id(&mut s); s.apply(Command::Tag { id, on: true }); @@ -840,7 +833,6 @@ fn rerun_replaces_finished_task_in_place() { .iter() .any(|e| matches!(e, Event::Tasks(v) if v.iter().any(|t| t.id == id && t.tagged))); assert!(tagged, "rerun must carry the tag over"); - let _ = std::fs::remove_dir_all(&dir); } /// Group normalization strips controls, trims whitespace, caps by character, @@ -1297,7 +1289,7 @@ fn remove_sweeps_stragglers_of_an_exited_leader() { let dir = scratch("remove_sweep"); let (spid, ready) = (dir.join("spid"), dir.join("ready")); let mut s = sup(24, 80); - hello_with_sh(&mut s, dir.clone()); + hello_with_sh(&mut s, dir.to_path_buf()); let id = spawn_ready( &mut s, format!( @@ -1305,7 +1297,7 @@ fn remove_sweeps_stragglers_of_an_exited_leader() { sp = spid.display(), r = ready.display() ), - dir.clone(), + dir.to_path_buf(), &ready, ); let straggler = read_pid(&spid); @@ -1325,7 +1317,6 @@ fn remove_sweeps_stragglers_of_an_exited_leader() { reap_until(&mut s, Duration::from_secs(5), |s| s.graveyard.is_empty()), "graveyard entry was never collected" ); - let _ = std::fs::remove_dir_all(&dir); } /// Rerun must give the displaced task the same graceful exit as Remove: @@ -1338,7 +1329,7 @@ fn rerun_sweeps_stragglers_of_the_old_run() { let dir = scratch("rerun_sweep"); let (spid, ready) = (dir.join("spid"), dir.join("ready")); let mut s = sup(24, 80); - hello_with_sh(&mut s, dir.clone()); + hello_with_sh(&mut s, dir.to_path_buf()); let id = spawn_ready( &mut s, format!( @@ -1346,7 +1337,7 @@ fn rerun_sweeps_stragglers_of_the_old_run() { sp = spid.display(), r = ready.display() ), - dir.clone(), + dir.to_path_buf(), &ready, ); let old_straggler = read_pid(&spid); @@ -1368,7 +1359,6 @@ fn rerun_sweeps_stragglers_of_the_old_run() { // The fresh run exists under the same id; its own straggler dies with // the supervisor (Task::drop backstop). assert!(s.tasks.iter().any(|t| t.id == id)); - let _ = std::fs::remove_dir_all(&dir); } /// The escalation must reach a TERM-ignoring straggler *after the leader @@ -1381,7 +1371,7 @@ fn kill_escalation_reaches_term_ignoring_straggler_after_leader_exit() { let (spid, ready) = (dir.join("spid"), dir.join("ready")); let mut s = sup(24, 80); s.set_kill_grace(Duration::from_millis(150)); - hello_with_sh(&mut s, dir.clone()); + hello_with_sh(&mut s, dir.to_path_buf()); // The leader ignores HUP (inherited by the `&` child, so it survives // the leader's exit); the subshell ignores TERM, then execs sleep, // which inherits both. Only the KILL can end it. @@ -1392,7 +1382,7 @@ fn kill_escalation_reaches_term_ignoring_straggler_after_leader_exit() { sp = spid.display(), r = ready.display() ), - dir.clone(), + dir.to_path_buf(), &ready, ); let straggler = read_pid(&spid); @@ -1406,7 +1396,6 @@ fn kill_escalation_reaches_term_ignoring_straggler_after_leader_exit() { .is_err()), "reap-driven escalation never KILLed the straggler" ); - let _ = std::fs::remove_dir_all(&dir); } /// Shutdown after removal preserves the removed task's TERM grace. @@ -1417,7 +1406,7 @@ fn shutdown_waits_for_graveyard_grace() { let (spid, ready) = (dir.join("spid"), dir.join("ready")); let mut s = sup(24, 80); s.set_kill_grace(Duration::from_millis(400)); - hello_with_sh(&mut s, dir.clone()); + hello_with_sh(&mut s, dir.to_path_buf()); // The background process ignores HUP and TERM. let id = spawn_ready( &mut s, @@ -1426,7 +1415,7 @@ fn shutdown_waits_for_graveyard_grace() { sp = spid.display(), r = ready.display() ), - dir.clone(), + dir.to_path_buf(), &ready, ); let straggler = read_pid(&spid); @@ -1450,7 +1439,6 @@ fn shutdown_waits_for_graveyard_grace() { .is_err()), "straggler survived shutdown" ); - let _ = std::fs::remove_dir_all(&dir); } /// The defect the group probe fixes: every leader exits at birth after @@ -1466,7 +1454,7 @@ fn shutdown_holds_the_grace_for_members_of_an_exited_leader() { let (spid, ready) = (dir.join("spid"), dir.join("ready")); let mut s = sup(24, 80); s.set_kill_grace(Duration::from_millis(400)); - hello_with_sh(&mut s, dir.clone()); + hello_with_sh(&mut s, dir.to_path_buf()); let id = spawn_ready( &mut s, format!( @@ -1474,7 +1462,7 @@ fn shutdown_holds_the_grace_for_members_of_an_exited_leader() { sp = spid.display(), r = ready.display() ), - dir.clone(), + dir.to_path_buf(), &ready, ); let straggler = read_pid(&spid); @@ -1501,7 +1489,6 @@ fn shutdown_holds_the_grace_for_members_of_an_exited_leader() { survived, "the straggler was KILLed instead of receiving the TERM grace" ); - let _ = std::fs::remove_dir_all(&dir); } /// Prompt exit, pinned: leaders exited long ago and left empty groups, @@ -1532,7 +1519,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 = sup_ctx(config_ctx(&config, dir.clone(), &[])); + let mut s = sup_ctx(config_ctx(&config, dir.to_path_buf(), &[])); s.apply(Command::SaveSession { name: "ctx".into() }); assert!( @@ -1560,7 +1547,6 @@ fn session_commands_use_the_launch_context_config_dir() { .any(|e| matches!(e, Event::Status(m) if m.starts_with("loaded 'ctx'"))), "load must find the recipe under the same root; got {evs:?}" ); - let _ = std::fs::remove_dir_all(&dir); } /// Saving and loading preserve independent group and display-name fields. @@ -1568,15 +1554,15 @@ fn session_commands_use_the_launch_context_config_dir() { fn load_session_restores_saved_groups_and_names() { let dir = scratch("sess_labels"); let config = dir.join("config"); - let ctx = config_ctx(&config, dir.clone(), &[]); + let ctx = config_ctx(&config, dir.to_path_buf(), &[]); let mut s = sup_ctx(ctx.clone()); - spawn(&mut s, "sleep 31", dir.clone()); + spawn(&mut s, "sleep 31", dir.to_path_buf()); let id = first_id(&mut s); s.apply(Command::SetName { id, name: Some("web".into()), }); - spawn_grouped(&mut s, "sleep 30", dir.clone(), "api"); + spawn_grouped(&mut s, "sleep 30", dir.to_path_buf(), "api"); s.apply(Command::SaveSession { name: "fleet".into(), }); @@ -1617,7 +1603,6 @@ fn load_session_restores_saved_groups_and_names() { (None, Some("web".into())), "the {{cmd,name}} member must restore its name and stay ungrouped" ); - let _ = std::fs::remove_dir_all(&dir); } /// Loaded recipe groups and names are normalized before assignment. @@ -1634,7 +1619,7 @@ fn load_session_renormalizes_hand_edited_groups() { ), ) .unwrap(); - let mut s = sup_ctx(config_ctx(&config, dir.clone(), &[])); + let mut s = sup_ctx(config_ctx(&config, dir.to_path_buf(), &[])); s.apply(Command::LoadSession { name: "edited".into(), }); @@ -1649,7 +1634,6 @@ fn load_session_renormalizes_hand_edited_groups() { restored, "loaded group and name must come back normalized; got {evs:?}" ); - let _ = std::fs::remove_dir_all(&dir); } /// Broken JSON reports a load error rather than a missing session. @@ -1659,7 +1643,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 = sup_ctx(config_ctx(&config, dir.clone(), &[])); + let mut s = sup_ctx(config_ctx(&config, dir.to_path_buf(), &[])); s.apply(Command::LoadSession { name: "broken".into(), }); @@ -1675,7 +1659,6 @@ fn load_surfaces_parse_errors_instead_of_absence() { .any(|e| matches!(e, Event::Status(m) if m.contains("not found"))), "a parse failure must not read as absence; got {evs:?}" ); - let _ = std::fs::remove_dir_all(&dir); } /// Missing recipes report "not found". @@ -1683,7 +1666,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 = sup_ctx(config_ctx(&config, dir.clone(), &[])); + let mut s = sup_ctx(config_ctx(&config, dir.to_path_buf(), &[])); s.apply(Command::LoadSession { name: "ghost".into(), }); @@ -1693,7 +1676,6 @@ fn load_missing_session_reads_as_not_found() { .any(|e| matches!(e, Event::Status(m) if m == "session 'ghost' not found")), "a missing recipe must still read as not found" ); - let _ = std::fs::remove_dir_all(&dir); } /// Spawn failures have their own status bucket. An invalid `SHELL` makes both @@ -1710,7 +1692,7 @@ fn load_reports_admit_failures_not_clean_success() { .unwrap(); let mut s = sup_ctx(config_ctx( &config, - dir.clone(), + dir.to_path_buf(), &[("SHELL", "/nonexistent/no-such-shell")], )); s.apply(Command::LoadSession { @@ -1729,7 +1711,6 @@ fn load_reports_admit_failures_not_clean_success() { .any(|e| matches!(e, Event::Tasks(v) if v.is_empty())), "no task may exist when every spawn failed" ); - let _ = std::fs::remove_dir_all(&dir); } /// Direct spawns reject commands above `MAX_COMMAND_LEN` without creating a task. @@ -1771,7 +1752,7 @@ fn load_skips_over_length_commands() { ), ) .unwrap(); - let mut s = sup_ctx(config_ctx(&config, dir.clone(), &[])); + let mut s = sup_ctx(config_ctx(&config, dir.to_path_buf(), &[])); s.apply(Command::LoadSession { name: "big".into() }); let evs = s.drain(); assert!( @@ -1779,7 +1760,6 @@ fn load_skips_over_length_commands() { if m.contains("1 task(s)") && m.contains("1 skipped"))), "the over-length entry must be counted as skipped; got {evs:?}" ); - let _ = std::fs::remove_dir_all(&dir); } /// Spawns inherit only the installed launch-context environment. @@ -1793,7 +1773,7 @@ fn spawn_uses_the_launch_context_env_not_the_process_env() { let out = dir.join("out"); let mut s = sup_ctx(LaunchContext { env: vec![("FLEETCOM_MARKER".into(), "xyzzy".into())], - cwd: dir.clone(), + cwd: dir.to_path_buf(), }); spawn( &mut s, @@ -1801,14 +1781,13 @@ fn spawn_uses_the_launch_context_env_not_the_process_env() { "printf '%s:%s' \"$FLEETCOM_MARKER\" \"${{USER:-unset}}\" > {}", out.display() ), - dir.clone(), + dir.to_path_buf(), ); let ok = reap_until(&mut s, Duration::from_secs(5), |_| { std::fs::read_to_string(&out).is_ok_and(|c| !c.is_empty()) }); assert!(ok, "the marker task never wrote its output"); assert_eq!(std::fs::read_to_string(&out).unwrap(), "xyzzy:unset"); - let _ = std::fs::remove_dir_all(&dir); } /// A supervisor with no launch context refuses every launch path (spawn, @@ -1846,7 +1825,7 @@ fn key_command_encodes_against_live_cursor_mode() { let dir = scratch("key_live_mode"); let (ready, out) = (dir.join("ready"), dir.join("out")); let mut s = sup(24, 80); - hello_with_sh(&mut s, dir.clone()); + hello_with_sh(&mut s, dir.to_path_buf()); // Raw mode lets `cat` receive ESC-prefixed keys without a newline. The // child enables DECCKM before alternate-screen mode, so observing the @@ -1858,7 +1837,7 @@ fn key_command_encodes_against_live_cursor_mode() { r = ready.display(), o = out.display() ), - dir.clone(), + dir.to_path_buf(), &ready, ); @@ -1907,7 +1886,6 @@ fn key_command_encodes_against_live_cursor_mode() { ); s.apply(Command::Kill { id }); - let _ = std::fs::remove_dir_all(&dir); } // --- recovery-snapshot writer ------------------------------------------- @@ -1988,7 +1966,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 = sup_ctx(config_ctx(&config, dir.clone(), &[])); + let mut s = sup_ctx(config_ctx(&config, dir.to_path_buf(), &[])); let rec = config.join("sessions").join("recovery"); let entry = |cmd: &str| SessionEntry { @@ -2037,7 +2015,6 @@ fn list_sessions_includes_recovery_snapshots_newest_first() { ], "snapshots must list newest first with labels and task counts" ); - let _ = std::fs::remove_dir_all(&dir); } /// Recovery loading restores commands, groups, and names and reports success. @@ -2045,7 +2022,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 = sup_ctx(config_ctx(&config, dir.clone(), &[])); + let mut s = sup_ctx(config_ctx(&config, dir.to_path_buf(), &[])); let mut cfg = SessionConfig::new(); cfg.insert( @@ -2093,7 +2070,6 @@ fn load_recovery_materializes_the_fleet_and_notices() { }; assert_eq!(by_cmd(&s, "sleep 30"), (Some("api".into()), None)); assert_eq!(by_cmd(&s, "sleep 31"), (None, Some("web".into()))); - let _ = std::fs::remove_dir_all(&dir); } /// Unknown and path-shaped recovery stems fail without spawning tasks. @@ -2101,7 +2077,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 = sup_ctx(config_ctx(&config, dir.clone(), &[])); + let mut s = sup_ctx(config_ctx(&config, dir.to_path_buf(), &[])); s.apply(Command::LoadRecovery { stem: "20990101-000000-1".into(), @@ -2125,7 +2101,6 @@ fn load_recovery_refuses_unknown_and_traversal_stems() { "a traversal stem must be refused, not probed" ); assert!(s.tasks.is_empty(), "refused loads must spawn nothing"); - let _ = std::fs::remove_dir_all(&dir); } /// Debouncing coalesces a mutation burst into one complete snapshot. @@ -2135,13 +2110,13 @@ fn recovery_debounce_coalesces_a_mutation_burst() { let config = dir.join("config"); let mut s = recovery_sup( &config, - dir.clone(), + dir.to_path_buf(), Duration::from_millis(500), Duration::from_secs(600), ); - spawn(&mut s, "sleep 30", dir.clone()); - spawn(&mut s, "sleep 31", dir.clone()); - spawn(&mut s, "sleep 32", dir.clone()); + spawn(&mut s, "sleep 30", dir.to_path_buf()); + spawn(&mut s, "sleep 31", dir.to_path_buf()); + spawn(&mut s, "sleep 32", dir.to_path_buf()); s.tick(); assert!( recovery_files(&config).is_empty(), @@ -2167,7 +2142,6 @@ fn recovery_debounce_coalesces_a_mutation_burst() { assert!(text.contains(cmd), "snapshot must carry {cmd:?}: {text}"); } assert!(!s.recovery.dirty, "a completed pass clears the flag"); - let _ = std::fs::remove_dir_all(&dir); } /// Empty fleets do not create or replace recovery snapshots. @@ -2177,7 +2151,7 @@ fn recovery_empty_fleet_never_writes() { let config = dir.join("config"); let mut s = recovery_sup( &config, - dir.clone(), + dir.to_path_buf(), Duration::from_millis(100), Duration::from_millis(200), ); @@ -2191,7 +2165,7 @@ fn recovery_empty_fleet_never_writes() { ); // Remove the only task before the debounced pass runs. - spawn(&mut s, "sleep 30", dir.clone()); + spawn(&mut s, "sleep 30", dir.to_path_buf()); let id = s.tasks[0].id; s.apply(Command::Remove { id }); assert!( @@ -2201,7 +2175,6 @@ fn recovery_empty_fleet_never_writes() { }), "a fleet emptied before the pass must never write" ); - let _ = std::fs::remove_dir_all(&dir); } /// Persistent write failures emit one notice and do not interrupt supervision. @@ -2214,11 +2187,11 @@ fn recovery_write_failure_notices_once_and_keeps_supervising() { std::fs::write(config.join("sessions").join("recovery"), "not a dir").unwrap(); let mut s = recovery_sup( &config, - dir.clone(), + dir.to_path_buf(), Duration::from_millis(10), Duration::from_millis(50), ); - spawn(&mut s, "sleep 30", dir.clone()); + spawn(&mut s, "sleep 30", dir.to_path_buf()); // Count notices across several debounce and cadence intervals. let mut notices = 0usize; @@ -2241,7 +2214,6 @@ fn recovery_write_failure_notices_once_and_keeps_supervising() { .any(|e| matches!(e, Event::Tasks(v) if v.len() == 1)), "a failing writer must never disturb supervision" ); - let _ = std::fs::remove_dir_all(&dir); } /// Detached recovery maintenance writes without queuing client events. @@ -2251,11 +2223,11 @@ fn recovery_maintenance_writes_detached_and_queues_nothing() { let config = dir.join("config"); let mut s = recovery_sup( &config, - dir.clone(), + dir.to_path_buf(), Duration::from_millis(50), Duration::from_secs(600), ); - spawn(&mut s, "sleep 30", dir.clone()); + spawn(&mut s, "sleep 30", dir.to_path_buf()); // Match the daemon's detached reap-and-maintain loop. assert!( wait_until(Duration::from_secs(5), || { @@ -2269,7 +2241,6 @@ fn recovery_maintenance_writes_detached_and_queues_nothing() { s.drain().is_empty(), "the idle path must not queue events; nothing drains them" ); - let _ = std::fs::remove_dir_all(&dir); } /// Deduplication treats the destination root as part of snapshot identity. @@ -2279,11 +2250,11 @@ fn recovery_dedup_is_per_destination_root() { let (config_a, config_b) = (dir.join("cfg_a"), dir.join("cfg_b")); let mut s = recovery_sup( &config_a, - dir.clone(), + dir.to_path_buf(), Duration::from_millis(50), Duration::from_secs(600), ); - spawn(&mut s, "sleep 30", dir.clone()); + spawn(&mut s, "sleep 30", dir.to_path_buf()); assert!( wait_until(Duration::from_secs(5), || { s.recovery_maintenance(); @@ -2293,7 +2264,7 @@ fn recovery_dedup_is_per_destination_root() { ); // Move the unchanged recipe to a new destination and arm recovery. - s.set_launch_context(config_ctx(&config_b, dir.clone(), &[])); + s.set_launch_context(config_ctx(&config_b, dir.to_path_buf(), &[])); s.recovery.dirty = true; s.recovery.last_mutation = Some(Instant::now()); assert!( @@ -2307,7 +2278,6 @@ fn recovery_dedup_is_per_destination_root() { !recovery_files(&config_a).is_empty(), "the old root keeps its snapshot" ); - let _ = std::fs::remove_dir_all(&dir); } /// A failed write remains eligible for a later cadence retry. @@ -2320,11 +2290,11 @@ fn recovery_failed_write_retries_until_success() { std::fs::write(config.join("sessions").join("recovery"), "not a dir").unwrap(); let mut s = recovery_sup( &config, - dir.clone(), + dir.to_path_buf(), Duration::from_millis(10), Duration::from_millis(50), ); - spawn(&mut s, "sleep 30", dir.clone()); + spawn(&mut s, "sleep 30", dir.to_path_buf()); assert!( wait_until(Duration::from_secs(5), || { s.recovery_maintenance(); @@ -2334,7 +2304,7 @@ fn recovery_failed_write_retries_until_success() { ); assert!( s.recovery.last_written.is_none(), - "a failed write must not advance the dedup pair" + "a failed write must not advance the dedup record" ); // Remove the blocker; a cadence pass retries the unchanged content. @@ -2347,7 +2317,6 @@ fn recovery_failed_write_retries_until_success() { "the cadence never retried after the root became writable" ); assert!(!s.recovery.failing, "a successful write clears the latch"); - let _ = std::fs::remove_dir_all(&dir); } /// A cadence pass recreates a missing snapshot even when its recipe is unchanged. @@ -2357,11 +2326,11 @@ fn recovery_rewrites_after_a_sibling_prune_deletes_the_snapshot() { let config = dir.join("config"); let mut s = recovery_sup( &config, - dir.clone(), + dir.to_path_buf(), Duration::from_millis(50), Duration::from_millis(100), ); - spawn(&mut s, "sleep 30", dir.clone()); + spawn(&mut s, "sleep 30", dir.to_path_buf()); assert!( wait_until(Duration::from_secs(5), || { s.recovery_maintenance(); @@ -2384,7 +2353,6 @@ fn recovery_rewrites_after_a_sibling_prune_deletes_the_snapshot() { }), "an unchanged recipe must rewrite an externally deleted snapshot" ); - let _ = std::fs::remove_dir_all(&dir); } #[path = "supervisor_capture_tests.rs"] diff --git a/src/task.rs b/src/task.rs index bf10c9d..6d47ca3 100644 --- a/src/task.rs +++ b/src/task.rs @@ -15,7 +15,8 @@ use std::{ use alacritty_terminal::sync::FairMutex; use nix::{ - sys::signal::{Signal, killpg}, + errno::Errno, + sys::signal::{Signal, kill, killpg}, unistd::Pid, }; use portable_pty::{CommandBuilder, MasterPty, PtySize, native_pty_system}; @@ -47,6 +48,18 @@ fn io_err(e: impl std::fmt::Display) -> io::Error { io::Error::other(e.to_string()) } +/// Return true only when signal 0 reports `ESRCH`. `EPERM` remains potentially +/// live so callers do not delete another owner's files. +pub(crate) fn pid_is_dead(pid: i32) -> bool { + matches!(kill(Pid::from_raw(pid), None), Err(Errno::ESRCH)) +} + +/// Parse an untrimmed, strictly positive decimal PID. Rejecting zero and +/// negatives avoids `kill` process-group semantics. +pub(crate) fn positive_pid(field: &str) -> Option { + field.parse::().ok().filter(|p| *p > 0) +} + pub struct Task { pub id: u64, pub command: String, @@ -208,7 +221,7 @@ impl Task { scrollback: usize, env: &[(OsString, OsString)], waker: Waker, - ) -> io::Result { + ) -> io::Result { let pair = native_pty_system() .openpty(PtySize { rows, @@ -310,7 +323,7 @@ impl Task { // Process-group signalling and `waitid` use the leader PID directly. let pid = child.process_id(); drop(child); - Ok(Task { + Ok(Self { id, command: command.to_string(), cwd: cwd.to_path_buf(), @@ -712,7 +725,7 @@ impl Task { // a member that exists but is beyond our signals. Both hold the wait. matches!( killpg(Pid::from_raw(pid as i32), None::), - Err(nix::errno::Errno::ESRCH) + Err(Errno::ESRCH) ) } } diff --git a/src/task_tests.rs b/src/task_tests.rs index 4a7610a..d021b4c 100644 --- a/src/task_tests.rs +++ b/src/task_tests.rs @@ -165,7 +165,6 @@ fn terminate_reaches_stragglers_after_leader_exit() { wait_until(Duration::from_secs(5), || kill(straggler, None).is_err()), "TERM after leader exit never reached the straggler" ); - let _ = std::fs::remove_dir_all(&dir); } /// `collect` reports SIGKILL as shell exit code 137. @@ -227,7 +226,6 @@ fn group_gone_holds_while_a_member_survives() { wait_until(Duration::from_secs(5), || t.group_gone()), "the group must probe gone once its last member dies" ); - let _ = std::fs::remove_dir_all(&dir); } /// `finished` gates the zombie-spending reap: a leader that has not @@ -462,7 +460,6 @@ fn finalize_preview_freezes_the_final_primary_line() { (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 @@ -521,7 +518,6 @@ fn finalize_preview_keeps_the_last_render_across_alt_teardown() { (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 @@ -561,7 +557,6 @@ fn finalize_preview_freezes_primary_output_after_alt_teardown() { ("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 @@ -615,7 +610,6 @@ fn summary_adapter_anchors_live_and_freezes_completion_at_exit() { true ) ); - let _ = std::fs::remove_dir_all(&dir); } /// A child's cursor-position probe is answered on the wire: the reply @@ -651,5 +645,4 @@ fn probe_replies_reach_the_child_through_the_allowlist() { "CPR reply must follow the DA reply; got {got:?}" ); t.terminate(); - let _ = std::fs::remove_dir_all(&dir); } diff --git a/src/terminal/emulator.rs b/src/terminal/emulator.rs index 37148b8..da1a273 100644 --- a/src/terminal/emulator.rs +++ b/src/terminal/emulator.rs @@ -19,7 +19,7 @@ use alacritty_terminal::{ }; use base64::{Engine as _, engine::general_purpose::STANDARD as B64}; -use crate::protocol::ClipboardKind; +use crate::{format::prefix_bytes, protocol::ClipboardKind}; /// Mouse event classes requested by the child through DECSET 1000/1002/1003. #[derive(Clone, Copy, Debug, Eq, PartialEq)] @@ -163,13 +163,8 @@ fn sanitize_title(raw: &str) -> String { } 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); - } + let cut = prefix_bytes(&out, TITLE_MAX_BYTES).len(); + out.truncate(cut); // Runs are already collapsed, so at most one trailing space survives // (possibly exposed by the truncation). if out.ends_with(' ') { @@ -819,12 +814,9 @@ impl Handler for ObservedTerm<'_> { } /// 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' => ClipboardKind::Clipboard, - b'p' => ClipboardKind::Primary, - b's' => ClipboardKind::Selection, - _ => return, + // Ignore unsupported selectors; the dispatcher maps an empty one to `c`. + let Some(selector) = ClipboardKind::from_selector(&[a0]) else { + return; }; // Accept padded standard base64 containing UTF-8 text. let Ok(bytes) = B64.decode(a1) else { return }; diff --git a/src/terminal/input.rs b/src/terminal/input.rs index 2b674b3..98b495f 100644 --- a/src/terminal/input.rs +++ b/src/terminal/input.rs @@ -190,6 +190,17 @@ fn f_bytes(n: u8, m: Option) -> Option> { }) } +/// Prefix `base` with ESC when `meta` is set. `base` may be a multibyte +/// sequence, such as BackTab's CSI Z. +fn meta_bytes(meta: bool, base: &[u8]) -> Vec { + let mut out = Vec::with_capacity(base.len() + 1); + if meta { + out.push(0x1b); + } + out.extend_from_slice(base); + out +} + /// Encode a key for the child. Application-cursor mode selects SS3 for /// unmodified cursor and Home/End keys; their modified forms use CSI. /// Unsupported key combinations return `None`. @@ -229,35 +240,15 @@ pub fn key_bytes(app_cursor: bool, code: Key, mods: Mods) -> Option> { }) } // Enter uses ESC CR for Shift or Alt; Control does not change plain CR. - Key::Enter => Some(if mods.shift || mods.alt { - vec![0x1b, 0x0d] - } else { - vec![0x0d] - }), + Key::Enter => Some(meta_bytes(mods.shift || mods.alt, b"\x0d")), // Alt prefixes Tab with ESC; Control and Shift do not change HT. - Key::Tab => Some(if mods.alt { - vec![0x1b, 0x09] - } else { - vec![0x09] - }), + Key::Tab => Some(meta_bytes(mods.alt, b"\x09")), // Alt prefixes BackTab's CSI Z sequence; Control and Shift are ignored. - Key::BackTab => Some(if mods.alt { - b"\x1b\x1b[Z".to_vec() - } else { - b"\x1b[Z".to_vec() - }), + Key::BackTab => Some(meta_bytes(mods.alt, b"\x1b[Z")), // Backspace is DEL; Alt prefixes ESC, and Control/Shift leave it unchanged. - Key::Backspace => Some(if mods.alt { - vec![0x1b, 0x7f] - } else { - vec![0x7f] - }), + Key::Backspace => Some(meta_bytes(mods.alt, b"\x7f")), // Alt+Esc is the ESC-ESC meta form; Ctrl/Shift fold into a plain ESC. - Key::Esc => Some(if mods.alt { - vec![0x1b, 0x1b] - } else { - vec![0x1b] - }), + Key::Esc => Some(meta_bytes(mods.alt, b"\x1b")), } } diff --git a/src/terminal/mod.rs b/src/terminal/mod.rs index 646d8d5..e373be0 100644 --- a/src/terminal/mod.rs +++ b/src/terminal/mod.rs @@ -1,10 +1,9 @@ -//! Terminal reconstruction: each task's screen is rebuilt from raw PTY bytes, -//! serialized back to ANSI, and framed over the wire; `input` runs the reverse -//! direction, encoding client events into PTY bytes. +//! Terminal reconstruction: each task's screen is rebuilt from raw PTY bytes and +//! serialized back to ANSI; `input` runs the reverse direction, encoding client +//! events into PTY bytes. pub(crate) mod ansi; pub(crate) mod emulator; -pub(crate) mod frame; // Differential emulator tests over recorded PTY output. #[cfg(test)] mod golden; diff --git a/src/testutil.rs b/src/testutil.rs index 7a47ac3..c1a9e0d 100644 --- a/src/testutil.rs +++ b/src/testutil.rs @@ -15,14 +15,38 @@ use std::{ time::{Duration, Instant, SystemTime}, }; -use crate::{emulator::Emulator, format::civil_from_days}; +use crate::{ + emulator::Emulator, + format::civil_from_days, + task::{pid_is_dead, positive_pid}, +}; /// Versioned prefix for scratch directories eligible for sweeping. const SCRATCH_PREFIX: &str = "fleetcom_test2_"; +/// Scratch directory removed on drop. A panic preserves it for inspection; +/// later runs reclaim it after the owner exits. +pub(crate) struct Scratch(PathBuf); + +impl std::ops::Deref for Scratch { + type Target = Path; + fn deref(&self) -> &Path { + &self.0 + } +} + +impl Drop for Scratch { + fn drop(&mut self) { + if std::thread::panicking() { + return; + } + let _ = fs::remove_dir_all(&self.0); + } +} + /// Create an empty `__` directory under the system temp /// directory. The PID separates processes; the sequence separates calls. -pub(crate) fn temp(tag: &str) -> PathBuf { +pub(crate) fn temp(tag: &str) -> Scratch { static SEQ: AtomicU32 = AtomicU32::new(0); sweep_dead_scratch(); let seq = SEQ.fetch_add(1, Ordering::Relaxed); @@ -32,7 +56,7 @@ pub(crate) fn temp(tag: &str) -> PathBuf { )); let _ = fs::remove_dir_all(&d); fs::create_dir_all(&d).unwrap(); - d + Scratch(d) } /// Once per process, remove scratch directories owned by dead processes. @@ -69,14 +93,7 @@ fn scratch_pid(suffix: &str) -> Option { if !digits(seq) || !digits(pid) { return None; } - pid.parse::().ok().filter(|p| *p > 0) -} - -/// Whether a PID is known to be dead. Only `ESRCH` proves death, so a live -/// process and one owned by another user both keep their directory. -fn pid_is_dead(pid: i32) -> bool { - use nix::{errno::Errno, sys::signal::kill, unistd::Pid}; - matches!(kill(Pid::from_raw(pid), None), Err(Errno::ESRCH)) + positive_pid(pid) } /// Parse valid suffixes and reject malformed PID or sequence fields. diff --git a/src/transport.rs b/src/transport.rs index 5833acd..205e745 100644 --- a/src/transport.rs +++ b/src/transport.rs @@ -78,7 +78,7 @@ impl ThreadTransport { /// Build the in-process core at `rows`×`cols` and run it on its own /// thread. `wait_tx` wakes the *client's* run loop when an event is /// produced, so the loop reacts without polling. - pub fn foreground(rows: u16, cols: u16, wait_tx: Sender<()>) -> ThreadTransport { + pub fn foreground(rows: u16, cols: u16, wait_tx: Sender<()>) -> Self { let mut sup = Supervisor::new(rows, cols, resolve_scrollback()); let (wake_tx, wake_rx) = channel::(); let (evt_tx, evt_rx) = channel::(); @@ -102,7 +102,7 @@ impl ThreadTransport { // Loop returned (Shutdown or client gone): `sup` drops here, and with // it every Task (Task::drop → killpg), so no task outlives the core. }); - ThreadTransport { + Self { wake_tx, evt_rx, handle: Some(handle), @@ -167,11 +167,7 @@ impl SocketTransport { /// can fail is the caller's job: done outside the transport so the App's /// transport factory stays infallible. `wait_tx` wakes the client's run loop /// on each inbound event. - pub fn from_halves( - write: UnixStream, - read: UnixStream, - wait_tx: Sender<()>, - ) -> SocketTransport { + pub fn from_halves(write: UnixStream, read: UnixStream, wait_tx: Sender<()>) -> Self { // Keep construction infallible; if this best-effort setup fails, the // stream retains its existing write-timeout setting. let _ = write.set_write_timeout(Some(SEND_TIMEOUT)); @@ -192,7 +188,7 @@ impl SocketTransport { // the drop (via `poll` → disconnected) now, not on the idle backstop. let _ = wait_tx.send(()); }); - SocketTransport { + Self { write, evt_rx, reader: Some(reader), @@ -254,8 +250,8 @@ pub struct LocalTransport { #[cfg(test)] impl LocalTransport { - pub fn new(sup: Supervisor) -> LocalTransport { - LocalTransport { sup } + pub fn new(sup: Supervisor) -> Self { + Self { sup } } } diff --git a/src/ui.rs b/src/ui.rs index 68c5fc7..52eaa67 100644 --- a/src/ui.rs +++ b/src/ui.rs @@ -20,6 +20,7 @@ use crate::{ app::{App, DirKind, GroupMode, Mode, Row, SessionPage}, editbuf::EditBuffer, format::{pad, rel_time, truncate}, + path, protocol::{Lifecycle, Preview, PreviewSource, RecoveryEntry, TaskView}, selection::Selection, }; @@ -39,7 +40,7 @@ pub fn render(out: &mut impl Write, app: &mut App) -> io::Result { render_dashboard(&mut buf, app)?; render_pickdir(&mut buf, app)?; } - Mode::PickGroup => { + Mode::PickGroup { .. } => { render_dashboard(&mut buf, app)?; render_pickgroup(&mut buf, app)?; } @@ -56,7 +57,7 @@ pub fn render(out: &mut impl Write, app: &mut App) -> io::Result { render_session_picker(&mut buf, app)?; } Mode::Disconnected => render_disconnected(&mut buf, app)?, - Mode::Dashboard | Mode::Spawn | Mode::SaveSession | Mode::Rename => { + Mode::Dashboard | Mode::Spawn | Mode::SaveSession | Mode::Rename(_) => { render_dashboard(&mut buf, app)? } } @@ -219,10 +220,7 @@ fn render_dashboard(out: &mut impl Write, app: &App) -> io::Result<()> { )?; match &cmd { - Some((line, cx)) => { - let cx = clamp_caret(*cx, line, cols); - queue!(out, MoveTo(cx, cmd_y), Show)?; - } + Some((_, cx)) => queue!(out, MoveTo(*cx, cmd_y), Show)?, None => queue!(out, Hide)?, } Ok(()) @@ -240,31 +238,24 @@ fn cmdline(app: &App) -> Option<(String, u16)> { let prefix = match app.mode { Mode::Spawn => spawn_prefix(app), Mode::SaveSession => " save session as: ".to_string(), - Mode::Rename => " rename task: ".to_string(), + Mode::Rename(_) => " rename task: ".to_string(), _ => return None, }; - Some(caret_line(&prefix, &app.input)) + Some(caret_line(&prefix, &app.input, app.cols as usize)) } -/// Compose `prefix` + the buffer text with the caret's display column: the -/// width of the prefix plus the width of the text before the caret. Widths are -/// terminal columns (wide glyphs count 2), not scalar counts. The column is -/// unclamped; `clamp_caret` bounds it to what actually gets painted. -fn caret_line(prefix: &str, buf: &EditBuffer) -> (String, u16) { +/// Compose the prompt and return its caret column, clamped to the truncated +/// rendered width. Widths are terminal columns, not scalar counts. +fn caret_line(prefix: &str, buf: &EditBuffer, cols: usize) -> (String, u16) { + let line = format!("{prefix}{}", buf.as_str()); let cx = (prefix.width() + buf.before_caret().width()) as u16; - (format!("{prefix}{}", buf.as_str()), cx) -} - -/// Bound a caret column to the painted, `cols`-truncated line. An overflowing -/// prompt keeps its plain truncation, so a caret past the cut pins at the right -/// edge rather than scrolling the line to stay visible. -fn clamp_caret(cx: u16, line: &str, cols: usize) -> u16 { - cx.min(truncate(line, cols).width() as u16) + let cx = cx.min(truncate(&line, cols).width() as u16); + (line, cx) } /// The `❯` prompt prefix with optional directory and group destinations. fn spawn_prefix(app: &App) -> String { - let dir = (app.spawn_cwd != app.invocation_dir).then(|| app.dir_label(&app.spawn_cwd)); + let dir = (app.spawn_cwd != app.invocation_dir).then(|| path::abbreviate(&app.spawn_cwd)); prompt_line(dir.as_deref(), app.spawn_group.as_deref(), "") } @@ -384,8 +375,8 @@ fn dim_preview_row(out: &mut impl Write, y: u16, v: &TaskView, cols: usize) -> i ) } -/// Build a labeled peek-box border exactly `inner_w` display columns wide. -fn peek_top_border(label: &str, inner_w: usize) -> String { +/// Build a labeled overlay top border exactly `inner_w` display columns wide. +fn top_border(label: &str, inner_w: usize) -> String { let mut border = format!("─ {} ", truncate(label, inner_w.saturating_sub(4))); let w = border.width(); if w < inner_w { @@ -394,61 +385,90 @@ fn peek_top_border(label: &str, inner_w: usize) -> String { border } -fn render_peek(out: &mut impl Write, app: &App) -> io::Result<()> { - let Some(i) = app.selected_task() else { - return Ok(()); - }; - let v = &app.views[i]; - let cols = app.cols as usize; - let rows = app.rows as usize; - - let bw = (cols * 3 / 4).clamp(24, cols.max(24)); - let bh = rows.saturating_sub(6).clamp(5, 16); - let x0 = cols.saturating_sub(bw) / 2; - let y0 = rows.saturating_sub(bh) / 2; - let inner_w = bw.saturating_sub(2); - let inner_h = bh.saturating_sub(2); - - // 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 lines: &[String] = app.screen_for(v.id).map_or(&[], |s| &s.lines); - let start = lines.len().saturating_sub(inner_h); - let tail = &lines[start..]; +/// Centered overlay with a labeled border, padded body, and dim footer. +struct Overlay<'a> { + /// Terminal dimensions: columns, then rows. + cols: usize, + rows: usize, + /// Box dimensions including borders: columns, then rows. + bw: usize, + bh: usize, + /// Top-border label, truncated to fit. + label: &'a str, + /// Body lines; missing rows render blank. + body: &'a [String], + /// Footer written over the bottom border. + footer: &'a str, +} +/// Paint a centered overlay, then overwrite the bottom border with its footer. +fn render_overlay(out: &mut impl Write, o: &Overlay) -> io::Result<()> { + // Saturating: a box larger than the terminal pins to the origin. + let x0 = o.cols.saturating_sub(o.bw) / 2; + let y0 = o.rows.saturating_sub(o.bh) / 2; + let (inner_w, inner_h) = (o.bw.saturating_sub(2), o.bh.saturating_sub(2)); queue!( out, MoveTo(x0 as u16, y0 as u16), - Print(format!("┌{}┐", peek_top_border(display_label(v), inner_w))) + Print(format!("┌{}┐", top_border(o.label, inner_w))) )?; - for k in 0..inner_h { - let line = tail.get(k).map(String::as_str).unwrap_or(""); + let line = o.body.get(k).map_or("", String::as_str); queue!( out, MoveTo(x0 as u16, (y0 + 1 + k) as u16), Print(format!("│{}│", pad(line, inner_w))) )?; } - let by = (y0 + 1 + inner_h) as u16; queue!( out, MoveTo(x0 as u16, by), Print(format!("└{}┘", "─".repeat(inner_w))) )?; + queue!( + out, + MoveTo((x0 + 2) as u16, by), + SetAttribute(Attribute::Dim), + Print(truncate(o.footer, inner_w)), + SetAttribute(Attribute::Reset) + ) +} + +fn render_peek(out: &mut impl Write, app: &App) -> io::Result<()> { + let Some(i) = app.selected_task() else { + return Ok(()); + }; + let v = &app.views[i]; + let cols = app.cols as usize; + let rows = app.rows as usize; + + let bw = (cols * 3 / 4).clamp(24, cols.max(24)); + let bh = rows.saturating_sub(6).clamp(5, 16); + + // Use an empty body until the selected task's screen arrives. + let lines: &[String] = app.screen_for(v.id).map_or(&[], |s| &s.lines); + // Show the newest lines that fit inside the overlay. + let start = lines.len().saturating_sub(bh.saturating_sub(2)); + let tail = &lines[start..]; + // The peek footer identifies the preview source and in-process matcher. let footer = format!( " space/esc close · enter attach · preview: {} ", preview_provenance(&v.preview) ); - queue!( + render_overlay( out, - MoveTo((x0 + 2) as u16, by), - SetAttribute(Attribute::Dim), - Print(truncate(&footer, inner_w)), - SetAttribute(Attribute::Reset) - )?; - Ok(()) + &Overlay { + cols, + rows, + bw, + bh, + label: display_label(v), + body: tail, + footer: &footer, + }, + ) } /// The peek footer's provenance label: source, then the matcher rule when @@ -592,43 +612,25 @@ fn render_controls(out: &mut impl Write, app: &App) -> io::Result<()> { let content_w = body.iter().map(|s| s.width()).max().unwrap_or(0); let bw = (content_w + 3).min(cols.max(4)); let bh = body.len() + 2; - let inner_w = bw - 2; - let x0 = cols.saturating_sub(bw) / 2; - let y0 = rows.saturating_sub(bh) / 2; - queue!( - out, - MoveTo(x0 as u16, y0 as u16), - Print(format!("┌{}┐", peek_top_border("controls", inner_w))) - )?; - for (k, line) in body.iter().enumerate() { - queue!( - out, - MoveTo(x0 as u16, (y0 + 1 + k) as u16), - Print(format!("│{}│", pad(line, inner_w))) - )?; - } - - let by = (y0 + 1 + body.len()) as u16; - queue!( - out, - MoveTo(x0 as u16, by), - Print(format!("└{}┘", "─".repeat(inner_w))) - )?; // Report omitted entries on the bottom border. let more = if hidden > 0 { format!(" · +{hidden} more") } else { String::new() }; - queue!( + render_overlay( out, - MoveTo((x0 + 2) as u16, by), - SetAttribute(Attribute::Dim), - Print(truncate(&format!(" ? esc close{more} "), inner_w)), - SetAttribute(Attribute::Reset) - )?; - Ok(()) + &Overlay { + cols, + rows, + bw, + bh, + label: "controls", + body: &body, + footer: &format!(" ? esc close{more} "), + }, + ) } /// The varying content of a bottom-panel picker; `render_panel` owns the @@ -721,13 +723,13 @@ fn render_pickdir(out: &mut impl Write, app: &App) -> io::Result<()> { Some(DirKind::Into) => "enter/tab open", None => "", }; - let (line, cx) = caret_line(" @ ", &app.dir_input); - let cx = clamp_caret(cx, &line, app.cols as usize); + // Add three styled columns after the text without moving the caret. + let (line, cx) = caret_line(" @ ", &app.dir_input, app.cols as usize); render_panel( out, app, &Panel { - header: format!(" @ {} ", app.dir_input.as_str()), + header: format!("{line} "), labels: &labels, sel: app.dir_sel, max_rows: 8, @@ -757,13 +759,12 @@ fn render_pickgroup(out: &mut impl Write, app: &App) -> io::Result<()> { None => "", } }; - let (line, cx) = caret_line(" g ", &app.group_input); - let cx = clamp_caret(cx, &line, app.cols as usize); + let (line, cx) = caret_line(" g ", &app.group_input, app.cols as usize); render_panel( out, app, &Panel { - header: format!(" g {} ", app.group_input.as_str()), + header: format!("{line} "), labels: &labels, sel: app.group_sel, max_rows: 8, @@ -797,13 +798,12 @@ fn render_find(out: &mut impl Write, app: &App) -> io::Result<()> { None => String::new(), }) .collect(); - let (line, cx) = caret_line(" / ", &app.find_input); - let cx = clamp_caret(cx, &line, app.cols as usize); + let (line, cx) = caret_line(" / ", &app.find_input, app.cols as usize); render_panel( out, app, &Panel { - header: format!(" / {} ", app.find_input.as_str()), + header: format!("{line} "), labels: &labels, sel: app.find_sel, max_rows: 8, @@ -836,40 +836,37 @@ fn saved_page_hint(recovery: usize) -> String { /// Render the saved-session or recovery page of the session picker. fn render_session_picker(out: &mut impl Write, app: &App) -> io::Result<()> { - match app.session_page { - SessionPage::Saved => { - let hint = saved_page_hint(app.session_recovery.len()); - render_panel( - out, - app, - &Panel { - header: " load session".to_string(), - labels: &app.session_names, - sel: app.session_sel, - max_rows: 10, - hint, - empty: Some(" (no saved sessions)"), - cursor: None, - }, - ) - } - SessionPage::Recovery => { - let labels: Vec = app.session_recovery.iter().map(recovery_row).collect(); - render_panel( - out, - app, - &Panel { - header: " recovery".to_string(), - labels: &labels, - sel: app.recovery_sel, - max_rows: 10, - hint: "↑↓ pick · enter load · tab saved · esc".to_string(), - empty: None, - cursor: None, - }, - ) - } - } + // Preformat recovery rows for the recovery page. + let recovery: Vec = app.session_recovery.iter().map(recovery_row).collect(); + let (header, labels, sel, hint, empty) = match app.session_page { + SessionPage::Saved => ( + " load session", + app.session_names.as_slice(), + app.session_sel, + saved_page_hint(recovery.len()), + Some(" (no saved sessions)"), + ), + SessionPage::Recovery => ( + " recovery", + recovery.as_slice(), + app.recovery_sel, + "↑↓ pick · enter load · tab saved · esc".to_string(), + None, + ), + }; + render_panel( + out, + app, + &Panel { + header: header.to_string(), + labels, + sel, + max_rows: 10, + hint, + empty, + cursor: None, + }, + ) } /// Center `s` in `width` columns (a full-width string, so it overwrites the row). @@ -880,9 +877,7 @@ fn center(s: &str, width: usize) -> String { } let mut out = " ".repeat((width - len) / 2); out.push_str(s); - let cur = out.width(); - out.push_str(&" ".repeat(width - cur)); - out + pad(&out, width) } /// Full-screen reconnect prompt shown after a daemon connection drops. @@ -1235,15 +1230,15 @@ mod tests { assert_eq!(grouped_rows(), flat_rows() + control_groups().len()); } - /// Peek borders remain column-exact for wide and overlong labels. + /// Overlay borders remain column-exact for wide and overlong labels. #[test] - fn peek_top_border_fills_to_inner_width() { + fn top_border_fills_to_inner_width() { for label in ["cargo test", "日本語のテスト", "🚀 build", "e\u{0301}", ""] { - let b = peek_top_border(label, 40); + let b = top_border(label, 40); assert_eq!(b.width(), 40, "label {label:?}: {b:?}"); } // Overlong labels truncate inside the border rather than widening it. - let b = peek_top_border(&"長".repeat(40), 40); + let b = top_border(&"長".repeat(40), 40); assert_eq!(b.width(), 40, "{b:?}"); } diff --git a/tests/daemon_resume.rs b/tests/daemon_resume.rs index 2af4027..6da98a1 100644 --- a/tests/daemon_resume.rs +++ b/tests/daemon_resume.rs @@ -30,7 +30,7 @@ struct Scratch { } impl Scratch { - fn new(tag: &str) -> Scratch { + fn new(tag: &str) -> Self { // Keep the scratch tree separate from start_daemon_raw's directory, // which is cleared during daemon setup. let root = std::env::temp_dir().join(format!( @@ -41,7 +41,7 @@ impl Scratch { for sub in ["bin", "run", "config", "claude-home", "codex-home", "work"] { std::fs::create_dir_all(root.join(sub)).unwrap(); } - Scratch { root } + Self { root } } fn bin(&self) -> PathBuf {