diff --git a/README.md b/README.md index 9a9e89c..c2cdb56 100644 --- a/README.md +++ b/README.md @@ -62,6 +62,7 @@ From the project root: | `fleetcom` | Connect to the daemon, autostarting it when necessary, and open the dashboard | | `fleetcom ` | Load a saved session, then open the dashboard | | `fleetcom --foreground` | Run in-process without a daemon; jobs stop when the client quits | +| `fleetcom --scrollback ` | Set per-task scrollback (default 2000); read at supervisor start, so a running daemon keeps its value until `--kill` | | `fleetcom --kill` | Stop the daemon and every job it owns | | `fleetcom --help` / `--version` | Print usage or version information and exit | diff --git a/docs/README.md b/docs/README.md index 2e53d23..b1cc6e8 100644 --- a/docs/README.md +++ b/docs/README.md @@ -148,7 +148,19 @@ Because the daemon holds each PTY master, daemon termination closes the terminal ### Environment and directory -Each launch uses the launching client's environment and working directory, sent once per connection during the hello handshake. Connect from a venv terminal and your spawns, reruns, and session loads all see that venv, whichever client originally autostarted the daemon. Environment is never written to disk; session files store only directories, commands, group assignments, and display names +Each launch uses the launching client's environment and working directory, sent once per connection during the hello handshake. Connect from a venv terminal and your spawns, reruns, and session loads all see that venv, whichever client originally autostarted the daemon. Environment is never written to disk; session files store only directories, commands, group assignments, and display names. + +### Scrollback depth is fixed per supervisor + +Each task's terminal keeps a scrollback history whose depth is resolved once, when the owning supervisor starts: + +| Order | Condition | Depth | +| -- | -- | -- | +| 1 | `--scrollback ` was passed | that value, clamped to 100,000 | +| 2 | `FLEETCOM_SCROLLBACK` parses as a whole number | that value, clamped to 100,000 | +| 3 | otherwise | 2,000 | + +`0` disables scrollback. An unparseable `FLEETCOM_SCROLLBACK` falls back to 2,000 rather than failing daemon startup. The daemon resolves the depth at startup from its inherited environment, so a changed value reaches only the tasks of a new daemon; stop the current one with `fleetcom --kill` first. `--foreground` resolves the depth in-process for each invocation. ### Client and daemon protocol versions must match diff --git a/docs/commands.md b/docs/commands.md index ee33c82..21e13a6 100644 --- a/docs/commands.md +++ b/docs/commands.md @@ -9,6 +9,7 @@ | `fleetcom` | Connect to the daemon (autostarting it if needed) and open the dashboard | | `fleetcom ` | Load a saved [session](sessions.md) at startup, then open the dashboard | | `fleetcom --foreground` | Run the core in-process, no daemon; jobs die when you quit | +| `fleetcom --scrollback ` | Set per-task scrollback depth (default 2,000, max 100,000); see [Scrollback](#scrollback) | | `fleetcom --kill` | Stop the daemon and kill every job it owns; works even while another client is attached (it signals the daemon rather than queueing behind the socket) | | `fleetcom --help` / `-h` | Print usage and exit | | `fleetcom --version` / `-V` | Print the version and exit | @@ -45,7 +46,7 @@ | `✗` | Completed, non-zero exit | | `◆` | Tagged "in use" | -The `∙` glyph flips after ≈600 ms of quiet; the Idle *section* in the by-state sort uses a 10 s window. A task can therefore show `∙` while still filed under Running. +The `∙` glyph and the Idle section both apply after 10 seconds without output. ### Input and lifecycle mechanics @@ -68,7 +69,7 @@ Modified Enter, paste, and mouse input require state-dependent encoding: #### Scrollback -Tasks retain 2,000 lines of scrollback. While attached to an inline child, wheel-up over its output enters scrollback; `Shift+PageUp` also enters it (`Ctrl+PageUp` and `Alt+PageUp` work when Shift is intercepted). The status bar shows `[scroll ↑N]`. The wheel scrolls, `PageUp`/`PageDown` move by pages, `↑`/`↓` by lines, and `Home` jumps to the oldest row. `Esc`, `Enter`, `q`, `End`, or reaching the bottom returns to live output. Typing also returns to live and forwards the key. Detaching or switching tasks resets the view. +Tasks retain 2,000 lines of scrollback by default. `--scrollback ` or the `FLEETCOM_SCROLLBACK` environment variable overrides the depth (the flag wins), clamped to 100,000; `0` disables scrollback, and an unparseable env value falls back to the default rather than failing startup. The value is read when a supervisor starts, so it applies to a `--foreground` run or to a daemon the invocation autostarts. An already-running daemon keeps its depth until `fleetcom --kill`. While attached to an inline child, wheel-up over its output enters scrollback; `Shift+PageUp` also enters it (`Ctrl+PageUp` and `Alt+PageUp` work when Shift is intercepted). The status bar shows `[scroll ↑N]`. The wheel scrolls, `PageUp`/`PageDown` move by pages, `↑`/`↓` by lines, and `Home` jumps to the oldest row. `Esc`, `Enter`, `q`, `End`, or reaching the bottom returns to live output. Typing also returns to live and forwards the key. Detaching or switching tasks resets the view. #### Destroy is Shift-gated @@ -112,7 +113,7 @@ The daemon removes control characters, trims surrounding whitespace, and limits - Recent directories: ones you've launched in before; `Enter` runs there, `Tab`/`→` browses into them. - Subdirectories of the current path: `Enter` or `Tab`/`→` descends into one. -Typing filters the rows; `Backspace` climbs the typed path; `↑`/`↓` move the highlight; `Esc` cancels. Completion updates on each input, permitting navigation and launch without leaving the dashboard. +Typing filters the rows; `Backspace` climbs the typed path; `↑`/`↓` move the highlight; `Esc` cancels. Completion updates on each input, permitting navigation and launch without leaving the dashboard. `←`/`→` move the caret within the typed path (`→` descends only when the caret is at the end), and `Ctrl-A`/`Ctrl-E` (or `Home`/`End`) jump to either end; the same caret keys work in every `fleetcom` text field. ## The `g` group picker diff --git a/docs/img/attach.png b/docs/img/attach.png index 2898f47..80c2d00 100644 Binary files a/docs/img/attach.png and b/docs/img/attach.png differ diff --git a/docs/img/home.png b/docs/img/home.png index d3cc4f4..d0963ea 100644 Binary files a/docs/img/home.png and b/docs/img/home.png differ diff --git a/docs/img/quickpeek.png b/docs/img/quickpeek.png index 3564053..35e2d83 100644 Binary files a/docs/img/quickpeek.png and b/docs/img/quickpeek.png differ diff --git a/src/app.rs b/src/app.rs index 592a7ee..540d2d1 100644 --- a/src/app.rs +++ b/src/app.rs @@ -24,6 +24,7 @@ use crossterm::{ }; use crate::{ + editbuf::EditBuffer, path, protocol::{ Command, Event, Key, Lifecycle, Mods, MouseBtn, MouseKind, ScreenView, ScrollAction, @@ -145,7 +146,7 @@ pub struct App { pub selected_id: Option, pub mode: Mode, pub group_mode: GroupMode, - pub input: String, + pub input: EditBuffer, /// Directory a spawned command runs in. Set to `invocation_dir` for the `n` /// flow, or to the picked directory for the `@` flow. pub spawn_cwd: PathBuf, @@ -165,11 +166,11 @@ pub struct App { pub invocation_dir: PathBuf, pub invocation_label: String, // `@` directory-picker state (only meaningful in `Mode::PickDir`). - pub dir_input: String, + pub dir_input: EditBuffer, pub dir_candidates: Vec, pub dir_sel: usize, // `g` group-picker state (only meaningful in `Mode::PickGroup`). - pub group_input: String, + pub group_input: EditBuffer, pub group_candidates: Vec, pub group_sel: usize, /// Id of the task being reassigned by the open group picker. @@ -237,9 +238,8 @@ fn step_down(sel: usize, len: usize) -> usize { /// Tagged wins over everything; completed is classified by `lifecycle`, never /// by trusting `parked == false`, so a core that ever shipped both signals /// still lands finished tasks in Completed. Placement follows `parked`, the -/// core's 10 s debounced quiet signal, not the instantaneous `Lifecycle::Idle`: -/// `top`-cadence output flaps the 600 ms glyph edge, and the glyph may flicker -/// but the row must not change sections. +/// core's debounced quiet signal: it shares the 10 s window with +/// `Lifecycle::Idle`, so the idle glyph and the row's section flip together. fn bucket(v: &TaskView) -> u8 { if v.tagged { 0 @@ -300,7 +300,10 @@ impl App { /// 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| { - Box::new(ThreadTransport::spawn(Supervisor::new(pr, c), wait_tx)) + Box::new(ThreadTransport::spawn( + Supervisor::new(pr, c, crate::supervisor::resolve_scrollback()), + wait_tx, + )) }) } @@ -333,7 +336,7 @@ impl App { selected_id: None, mode: Mode::Dashboard, group_mode: GroupMode::State, - input: String::new(), + input: EditBuffer::default(), spawn_cwd: invocation_dir.clone(), spawn_group: None, focused_id: None, @@ -342,10 +345,10 @@ impl App { last_frame: Vec::new(), invocation_dir, invocation_label, - dir_input: String::new(), + dir_input: EditBuffer::default(), dir_candidates: Vec::new(), dir_sel: 0, - group_input: String::new(), + group_input: EditBuffer::default(), group_candidates: Vec::new(), group_sel: 0, group_target: None, @@ -530,6 +533,44 @@ impl App { self.selected_id = Some(self.views[order[next]].id); } + /// Index within `sections` of the section holding the selected task. + fn selected_section(&self, sections: &[(String, Vec)]) -> Option { + let id = self.selected_id?; + sections + .iter() + .position(|(_, idxs)| idxs.iter().any(|&i| self.views[i].id == id)) + } + + /// Select the first task in the next section, wrapping to the first. + /// With no current selection, select the first section. + fn select_next_section(&mut self) { + let sections = self.sections(); + if sections.is_empty() { + self.selected_id = None; + return; + } + let next = match self.selected_section(§ions) { + Some(cur) => (cur + 1) % sections.len(), + None => 0, + }; + self.selected_id = Some(self.views[sections[next].1[0]].id); + } + + /// Select the first task in the previous section, wrapping to the last. + /// With no current selection, select the last section. + fn select_prev_section(&mut self) { + let sections = self.sections(); + if sections.is_empty() { + self.selected_id = None; + return; + } + let prev = match self.selected_section(§ions) { + Some(cur) => (cur + sections.len() - 1) % sections.len(), + None => sections.len() - 1, + }; + self.selected_id = Some(self.views[sections[prev].1[0]].id); + } + /// Tell the core which task's screen we need (attach/peek), sending `Watch` /// only when the target actually changes. fn set_watch(&mut self, want: Option) { @@ -743,7 +784,7 @@ impl App { /// Navigate into `dir`: retype the input as its path (trailing slash) so /// completion continues inside it, with the dir itself selected as row 0. fn enter_dir(&mut self, dir: PathBuf) { - self.dir_input = format!("{}/", path::abbreviate(&dir)); + self.dir_input = EditBuffer::seeded(format!("{}/", path::abbreviate(&dir))); self.refresh_dir_candidates(); } @@ -829,7 +870,7 @@ impl App { fn open_rename_prompt(&mut self) { if let Some(i) = self.selected_task() { self.rename_target = Some(self.views[i].id); - self.input = self.views[i].name.clone().unwrap_or_default(); + self.input = EditBuffer::seeded(self.views[i].name.clone().unwrap_or_default()); self.mode = Mode::Rename; } } @@ -894,6 +935,8 @@ impl App { } KeyCode::Up | KeyCode::Char('k') => self.select_up(), KeyCode::Down | KeyCode::Char('j') => self.select_down(), + KeyCode::Tab => self.select_next_section(), + KeyCode::BackTab => self.select_prev_section(), KeyCode::Char(' ') => { if self.selected_task().is_some() { self.mode = Mode::Peek; @@ -950,16 +993,15 @@ impl App { fn on_key_textinput(&mut self, k: KeyEvent, submit: fn(&mut App, &str)) { match k.code { KeyCode::Enter => { - let text = self.input.trim().to_string(); - submit(self, &text); + // Submit the text on both sides of the caret. + let text = self.input.take(); + submit(self, text.trim()); self.close_prompt(); } KeyCode::Esc => self.close_prompt(), - KeyCode::Backspace => { - self.input.pop(); + _ => { + on_key_edit(&mut self.input, k); } - KeyCode::Char(c) => self.input.push(c), - _ => {} } } @@ -1000,6 +1042,17 @@ impl App { } fn on_key_pickdir(&mut self, k: KeyEvent) { + // Tab descends; Right descends at the end and moves the caret elsewhere. + if k.code == KeyCode::Tab || (k.code == KeyCode::Right && self.dir_input.at_end()) { + // Descend into the highlighted dir; a no-op on the current-dir row. + if let Some(c) = self.dir_candidates.get(self.dir_sel) + && c.kind != DirKind::Use + { + let path = c.path.clone(); + self.enter_dir(path); + } + return; + } match k.code { KeyCode::Esc => { self.dir_input.clear(); @@ -1008,15 +1061,6 @@ impl App { } KeyCode::Up => self.dir_sel = self.dir_sel.saturating_sub(1), KeyCode::Down => self.dir_sel = step_down(self.dir_sel, self.dir_candidates.len()), - KeyCode::Tab | KeyCode::Right => { - // Descend into the highlighted dir; a no-op on the current-dir row. - if let Some(c) = self.dir_candidates.get(self.dir_sel) - && c.kind != DirKind::Use - { - let path = c.path.clone(); - self.enter_dir(path); - } - } KeyCode::Enter => { if let Some(c) = self.dir_candidates.get(self.dir_sel) { let path = c.path.clone(); @@ -1028,15 +1072,12 @@ impl App { } } } - KeyCode::Backspace => { - self.dir_input.pop(); - self.refresh_dir_candidates(); - } - KeyCode::Char(c) => { - self.dir_input.push(c); - self.refresh_dir_candidates(); + // Caret motion does not affect directory candidates. + _ => { + if on_key_edit(&mut self.dir_input, k) == Some(true) { + self.refresh_dir_candidates(); + } } - _ => {} } } @@ -1051,7 +1092,7 @@ impl App { // Enter assigns the highlighted group, or creates the typed // group when no existing name matches. let group = if !self.group_input.is_empty() && self.group_candidates.len() < 2 { - Some(self.group_input.clone()) + Some(self.group_input.as_str().to_string()) } else { self.group_candidates .get(self.group_sel) @@ -1062,15 +1103,12 @@ impl App { } self.close_group_picker(); } - KeyCode::Backspace => { - self.group_input.pop(); - self.refresh_group_candidates(); - } - KeyCode::Char(c) => { - self.group_input.push(c); - self.refresh_group_candidates(); + // Caret motion does not affect group candidates. + _ => { + if on_key_edit(&mut self.group_input, k) == Some(true) { + self.refresh_group_candidates(); + } } - _ => {} } } @@ -1174,15 +1212,14 @@ impl App { } } Mode::Spawn | Mode::SaveSession | Mode::Rename => { - self.input.extend(s.chars().filter(|c| !c.is_control())); + paste_into(&mut self.input, s); } Mode::PickDir => { - self.dir_input.extend(s.chars().filter(|c| !c.is_control())); + paste_into(&mut self.dir_input, s); self.refresh_dir_candidates(); } Mode::PickGroup => { - self.group_input - .extend(s.chars().filter(|c| !c.is_control())); + paste_into(&mut self.group_input, s); self.refresh_group_candidates(); } _ => {} @@ -1363,6 +1400,57 @@ fn key_event_to_key(ev: KeyEvent) -> Option<(Key, Mods)> { Some((code, mods)) } +/// Apply prompt editing keys. Returns `Some(true)` for text changes, +/// `Some(false)` for caret motion or ignored Ctrl chords, and `None` for +/// unsupported keys. Ctrl-A and Ctrl-E move to the start and end. +fn on_key_edit(buf: &mut EditBuffer, k: KeyEvent) -> Option { + let ctrl = k.modifiers.contains(KeyModifiers::CONTROL); + match k.code { + KeyCode::Backspace => { + buf.backspace(); + Some(true) + } + KeyCode::Left => { + buf.left(); + Some(false) + } + KeyCode::Right => { + buf.right(); + Some(false) + } + KeyCode::Home => { + buf.home(); + Some(false) + } + KeyCode::End => { + buf.end(); + Some(false) + } + KeyCode::Char('a') if ctrl => { + buf.home(); + Some(false) + } + KeyCode::Char('e') if ctrl => { + buf.end(); + Some(false) + } + KeyCode::Char(c) if !ctrl => { + buf.insert(c); + Some(true) + } + // Ignore unbound Ctrl chords without inserting their character. + KeyCode::Char(_) => Some(false), + _ => None, + } +} + +/// Insert pasted text at the caret after removing control characters. +fn paste_into(buf: &mut EditBuffer, s: &str) { + for c in s.chars().filter(|c| !c.is_control()) { + buf.insert(c); + } +} + /// Split a typed path into (directory-so-far, trailing fragment). The fragment /// is prefix-matched against candidates; the directory is what we list. fn split_input(input: &str) -> (&str, &str) { @@ -1411,7 +1499,7 @@ mod tests { /// Uses this process's launch context. fn new_local(rows: u16, cols: u16) -> App { App::assemble(rows, cols, |pr, c, _wait_tx| { - let mut sup = Supervisor::new(pr, c); + let mut sup = Supervisor::new(pr, c, 2000); sup.set_launch_context(crate::protocol::LaunchContext::here()); Box::new(LocalTransport::new(sup)) }) @@ -1421,7 +1509,7 @@ mod tests { /// 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| { - let mut sup = Supervisor::new(pr, c); + let mut sup = Supervisor::new(pr, c, 2000); sup.set_launch_context(ctx); Box::new(LocalTransport::new(sup)) }) @@ -1738,11 +1826,11 @@ mod tests { ); } - /// A glyph-idle but not-parked task stays in "Running": `lifecycle` flaps - /// at the 600 ms edge for tools like `top` whose output arrives in 1-2 s - /// bursts, so placement keys off the debounced `parked`, never the glyph. + /// One window drives both signals, so a live core ships `Lifecycle::Idle` + /// and `parked` together: an idle-glyph task lands in the "Idle" section + /// under state grouping. Glyph and placement agree. #[test] - fn glyph_idle_without_parked_stays_in_running() { + fn idle_glyph_task_lands_in_idle_section() { let mut app = App::new_local(30, 100); let inv = app.invocation_dir.clone(); app.spawn_in("sleep 5", inv); // id 1 @@ -1750,9 +1838,9 @@ mod tests { let i = app.views.iter().position(|v| v.id == 1).unwrap(); app.views[i].lifecycle = Lifecycle::Idle; - app.views[i].parked = false; + app.views[i].parked = true; - assert_eq!(app.section_ids(), vec![("Running".to_string(), vec![1])]); + assert_eq!(app.section_ids(), vec![("Idle".to_string(), vec![1])]); } /// Selection is bound to a task id, so a `parked` flip (re-bucketing the @@ -2093,6 +2181,116 @@ mod tests { assert_eq!(app.selected_id, Some(1)); } + /// Build two two-task sections: In use [3, 4] and Running [1, 2]. + fn app_with_two_sections() -> App { + let mut app = App::new_local(30, 100); + let inv = app.invocation_dir.clone(); + for _ in 0..4 { + app.spawn_in("sleep 5", inv.clone()); + } + app.pump(); + app.transport.send(Command::Tag { id: 3, on: true }); + app.transport.send(Command::Tag { id: 4, on: true }); + app.pump(); + assert_eq!( + app.section_ids(), + vec![ + ("In use".to_string(), vec![3, 4]), + ("Running".to_string(), vec![1, 2]), + ], + "tags split the list into two two-task sections" + ); + app + } + + /// Next-section navigation selects its first task and wraps forward. + #[test] + fn tab_jumps_to_next_section_first_task() { + let mut app = app_with_two_sections(); + + // From In use, select Running's first task. + app.selected_id = Some(4); + app.select_next_section(); + assert_eq!( + app.selected_id, + Some(1), + "next section's first, not same offset" + ); + + // From the last section, wrap to the first task in In use. + app.selected_id = Some(2); + app.select_next_section(); + assert_eq!(app.selected_id, Some(3), "forward from last section wraps"); + } + + /// Previous-section navigation selects its first task and wraps backward. + #[test] + fn backtab_jumps_to_previous_section_first_task() { + let mut app = app_with_two_sections(); + + // From Running, select In use's first task. + app.selected_id = Some(2); + app.select_prev_section(); + assert_eq!( + app.selected_id, + Some(3), + "previous section's first, not current's" + ); + + // From the first section, wrap to Running's first task. + app.selected_id = Some(3); + app.select_prev_section(); + assert_eq!( + app.selected_id, + Some(1), + "backward from first section wraps" + ); + } + + /// Section navigation keeps a single task selected. + #[test] + fn section_nav_is_noop_with_one_task() { + let mut app = App::new_local(30, 100); + let inv = app.invocation_dir.clone(); + app.spawn_in("sleep 5", inv); + app.pump(); + app.resolve_selection(); + assert_eq!(app.selected_id, Some(1)); + + app.select_next_section(); + assert_eq!(app.selected_id, Some(1)); + app.select_prev_section(); + assert_eq!(app.selected_id, Some(1)); + } + + /// Without a selection, navigation chooses the boundary section. + #[test] + fn section_nav_defaults_without_selection() { + let mut app = app_with_two_sections(); + + app.selected_id = None; + app.select_next_section(); + assert_eq!( + app.selected_id, + Some(3), + "no selection: first section's first" + ); + + app.selected_id = None; + app.select_prev_section(); + assert_eq!( + app.selected_id, + Some(1), + "no selection: last section's first" + ); + + let mut empty = App::new_local(30, 100); + empty.select_next_section(); + assert_eq!(empty.selected_id, None); + empty.select_prev_section(); + assert_eq!(empty.selected_id, None); + } + /// Supported crossterm keys and modifiers map to their wire representation. #[test] fn key_event_maps_to_semantic_key() { @@ -2185,7 +2383,7 @@ mod tests { let mut app = App::new_local(30, 100); app.mode = Mode::Spawn; app.on_paste("cargo\ttest\r\n --all"); - assert_eq!(app.input, "cargotest --all"); + assert_eq!(app.input.as_str(), "cargotest --all"); assert!(app.mode == Mode::Spawn, "paste must not submit"); } @@ -2414,6 +2612,10 @@ mod tests { KeyEvent::new(code, KeyModifiers::NONE) } + fn ctrl(code: KeyCode) -> KeyEvent { + KeyEvent::new(code, KeyModifiers::CONTROL) + } + /// `g` opens the picker only when a task is selected, pinning the target /// to that task's id. #[test] @@ -2592,7 +2794,7 @@ mod tests { app.on_key_dashboard(key(KeyCode::Char('R'))); assert!(app.mode == Mode::Rename); assert_eq!(app.rename_target, Some(1)); - assert_eq!(app.input, "", "an unnamed task prefills empty"); + assert_eq!(app.input.as_str(), "", "an unnamed task prefills empty"); // A named task prefills its name. app.on_key_rename(key(KeyCode::Esc)); @@ -2602,7 +2804,7 @@ mod tests { }); app.pump(); app.on_key_dashboard(key(KeyCode::Char('R'))); - assert_eq!(app.input, "api"); + assert_eq!(app.input.as_str(), "api"); } /// Enter sends the trimmed name and returns to the dashboard. @@ -2638,7 +2840,7 @@ mod tests { app.pump(); app.resolve_selection(); app.on_key_dashboard(key(KeyCode::Char('R'))); - assert_eq!(app.input, "api"); + assert_eq!(app.input.as_str(), "api"); for _ in 0.."api".len() { app.on_key_rename(key(KeyCode::Backspace)); } @@ -2673,6 +2875,204 @@ mod tests { assert_eq!(v.name.as_deref(), Some("api"), "Esc must send nothing"); } + // --- prompt caret editing ---------------------------------------------- + + /// Left/Right, Ctrl-A/Ctrl-E, and Home/End reposition the caret in the + /// rename prompt, and edits land at it. + #[test] + fn rename_caret_keys_edit_mid_name() { + let mut app = App::new_local(30, 100); + let inv = app.invocation_dir.clone(); + app.spawn_in("sleep 5", inv); + app.transport.send(Command::SetName { + id: 1, + name: Some("api".to_string()), + }); + app.pump(); + app.resolve_selection(); + app.on_key_dashboard(key(KeyCode::Char('R'))); + assert_eq!(app.input.as_str(), "api"); + + // Move after "a" and insert within the name. + app.on_key_rename(key(KeyCode::Left)); + app.on_key_rename(key(KeyCode::Left)); + app.on_key_rename(key(KeyCode::Char('x'))); + assert_eq!(app.input.as_str(), "axpi"); + + // Ctrl-A jumps to the start; typing prepends. + app.on_key_rename(ctrl(KeyCode::Char('a'))); + app.on_key_rename(key(KeyCode::Char('z'))); + assert_eq!(app.input.as_str(), "zaxpi"); + + // Ctrl-E returns to the end; typing appends. + app.on_key_rename(ctrl(KeyCode::Char('e'))); + app.on_key_rename(key(KeyCode::Char('!'))); + assert_eq!(app.input.as_str(), "zaxpi!"); + + // Backspace removes only the char before the caret. + app.on_key_rename(key(KeyCode::Left)); + app.on_key_rename(key(KeyCode::Backspace)); + assert_eq!(app.input.as_str(), "zaxp!"); + + // Home/End alias the chords. + app.on_key_rename(key(KeyCode::Home)); + app.on_key_rename(key(KeyCode::Char('0'))); + app.on_key_rename(key(KeyCode::End)); + app.on_key_rename(key(KeyCode::Char('9'))); + assert_eq!(app.input.as_str(), "0zaxp!9"); + } + + /// Unbound Ctrl chords do not insert their literal letters. + #[test] + fn ctrl_chords_never_insert_their_letter() { + let mut app = App::new_local(30, 100); + let inv = app.invocation_dir.clone(); + app.spawn_grouped("sleep 5", inv, "alpha"); // id 1 + app.pump(); + app.resolve_selection(); + + app.on_key_dashboard(key(KeyCode::Char('w'))); + app.on_key_savesession(ctrl(KeyCode::Char('k'))); + assert!(app.input.is_empty(), "Ctrl-K must not insert into a prompt"); + app.on_key_savesession(key(KeyCode::Esc)); + + app.on_key_dashboard(key(KeyCode::Char('@'))); + app.on_key_pickdir(ctrl(KeyCode::Char('k'))); + assert!(app.dir_input.is_empty(), "Ctrl-K must not filter the dirs"); + app.on_key_pickdir(key(KeyCode::Esc)); + + app.on_key_dashboard(key(KeyCode::Char('g'))); + let before = app.group_candidates.len(); + app.on_key_pickgroup(ctrl(KeyCode::Char('k'))); + assert!( + app.group_input.is_empty(), + "Ctrl-K must not filter the groups" + ); + assert_eq!(app.group_candidates.len(), before); + } + + /// In the `@` picker, Right descends only when the caret sits at the end + /// of the typed path; off the end it is caret motion. + #[test] + 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.on_key_dashboard(key(KeyCode::Char('@'))); + for c in "al".chars() { + app.on_key_pickdir(key(KeyCode::Char(c))); + } + assert_eq!(app.dir_sel, 1, "the fragment preselects alpha"); + + // Off the end (one left), Right moves the caret without descending. + app.on_key_pickdir(key(KeyCode::Left)); + app.on_key_pickdir(key(KeyCode::Right)); + assert_eq!( + app.dir_input.as_str(), + "al", + "Right off-end must not descend" + ); + + // That Right returned the caret to the end, so the next one descends. + app.on_key_pickdir(key(KeyCode::Right)); + assert!( + app.dir_input.ends_with("alpha/"), + "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 + /// motion itself does not. + #[test] + 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.on_key_dashboard(key(KeyCode::Char('@'))); + app.on_key_pickdir(key(KeyCode::Char('l'))); + assert_eq!(app.dir_candidates.len(), 1, "\"l\" matches nothing"); + + app.on_key_pickdir(key(KeyCode::Home)); + assert_eq!(app.dir_candidates.len(), 1, "caret motion must not refresh"); + + // "a" typed at the start makes the buffer "al": a match again. + app.on_key_pickdir(key(KeyCode::Char('a'))); + assert_eq!(app.dir_input.as_str(), "al"); + assert!( + 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. + #[test] + fn pickgroup_caret_edits_refresh_the_filter() { + let mut app = App::new_local(30, 100); + let inv = app.invocation_dir.clone(); + app.spawn_grouped("sleep 5", inv.clone(), "alpha"); // id 1 + app.spawn_grouped("sleep 5", inv, "beta"); // id 2 + app.pump(); + app.resolve_selection(); + app.on_key_dashboard(key(KeyCode::Char('g'))); + + app.on_key_pickgroup(key(KeyCode::Char('b'))); + assert_eq!(app.group_candidates.len(), 2, "\"b\" matches beta"); + + app.on_key_pickgroup(key(KeyCode::Left)); + assert_eq!( + app.group_candidates.len(), + 2, + "caret motion must not refresh" + ); + + // "a" before the 'b' makes the filter "ab": nothing matches now. + app.on_key_pickgroup(key(KeyCode::Char('a'))); + assert_eq!(app.group_input.as_str(), "ab"); + assert_eq!(app.group_candidates.len(), 1, "the caret edit re-filtered"); + } + + /// A paste inserts at the caret and leaves the caret after the pasted text. + #[test] + fn paste_lands_at_the_caret() { + let mut app = App::new_local(30, 100); + app.mode = Mode::Spawn; + for c in "cargo t".chars() { + app.on_key_spawn(key(KeyCode::Char(c))); + } + app.on_key_spawn(key(KeyCode::Left)); // caret before 't' + app.on_paste("x\ny"); // control chars still stripped + assert_eq!(app.input.as_str(), "cargo xyt"); + app.on_key_spawn(key(KeyCode::Char('z'))); + assert_eq!( + app.input.as_str(), + "cargo xyzt", + "caret sits after the paste" + ); + } + + /// Multibyte characters move, insert, and delete as whole units. + #[test] + fn multibyte_chars_edit_cleanly_in_a_prompt() { + let mut app = App::new_local(30, 100); + app.on_key_dashboard(key(KeyCode::Char('w'))); + app.on_key_savesession(key(KeyCode::Char('é'))); + app.on_key_savesession(key(KeyCode::Left)); + app.on_key_savesession(key(KeyCode::Char('日'))); + assert_eq!(app.input.as_str(), "日é"); + app.on_key_savesession(key(KeyCode::Backspace)); + assert_eq!(app.input.as_str(), "é"); + app.on_key_savesession(key(KeyCode::Right)); + app.on_key_savesession(key(KeyCode::Backspace)); + assert!(app.input.is_empty()); + } + // --- spawn group inheritance ------------------------------------------- /// In Custom mode, `n` assigns the selected task's group to the spawn. diff --git a/src/core.rs b/src/core.rs index 6757f3c..da8b865 100644 --- a/src/core.rs +++ b/src/core.rs @@ -66,9 +66,8 @@ pub enum LoopExit { const FRAME_MIN: Duration = Duration::from_millis(8); /// Idle backstop: with nothing queued, tick this often anyway so time-based -/// dashboard state advances (`started_ago`, and the 600 ms Active→Idle edge) even -/// though no wake marks the passage of time. Also the ceiling on how long a -/// missed wake could stall a repaint. +/// dashboard state advances (`started_ago` and the Active→Idle edge) without +/// an event. It also bounds repaint delay after a missed wake. const FALLBACK: Duration = Duration::from_millis(200); /// How long to block before the next tick is due: honor the frame floor while @@ -203,7 +202,7 @@ mod tests { use std::{sync::mpsc::channel, thread}; let cwd = std::env::current_dir().unwrap(); - let mut sup = Supervisor::new(24, 80); + let mut sup = Supervisor::new(24, 80, 2000); sup.set_launch_context(crate::protocol::LaunchContext::here()); let (wake_tx, wake_rx) = channel::(); let (evt_tx, evt_rx) = channel::(); @@ -265,7 +264,7 @@ mod tests { #[test] fn stop_flag_ends_loop_with_shutdown() { let cwd = std::env::current_dir().unwrap(); - let mut sup = Supervisor::new(24, 80); + let mut sup = Supervisor::new(24, 80, 2000); sup.set_launch_context(crate::protocol::LaunchContext::here()); let (wake_tx, wake_rx) = std::sync::mpsc::channel::(); sup.set_waker(wake_tx); diff --git a/src/daemon.rs b/src/daemon.rs index e74e623..90f6e25 100644 --- a/src/daemon.rs +++ b/src/daemon.rs @@ -49,7 +49,7 @@ use crate::{ Command, Event, LaunchContext, PROTOCOL_VERSION, decode_command, decode_event, decode_hello, encode_command, encode_event, encode_hello, hello_version, }, - supervisor::Supervisor, + supervisor::{self, Supervisor}, }; /// Maximum duration of the hello handshake, on the daemon side and the @@ -319,6 +319,10 @@ fn spawn_daemon() -> io::Result<()> { .stdout(Stdio::null()) .stderr(log.map(Stdio::from).unwrap_or_else(Stdio::null)) .process_group(0); + // Pass the client's scrollback flag to the daemon through its environment. + if let Some(lines) = supervisor::scrollback_flag() { + cmd.env(supervisor::FLEETCOM_SCROLLBACK, lines.to_string()); + } cmd.spawn()?; Ok(()) } @@ -445,7 +449,8 @@ pub fn run_daemon() -> io::Result<()> { // 24x80 until the first client's Resize, which arrives before any Spawn. // Each connection supplies its launch context in the hello frame. - let mut sup = Supervisor::new(24, 80); + // Use one scrollback depth for every task owned by this daemon. + let mut sup = Supervisor::new(24, 80, supervisor::resolve_scrollback()); // A signalled daemon shuts down *cleanly*: TERM each job's group with a // KILL after the grace, remove the socket. Dying without that cleanup diff --git a/src/editbuf.rs b/src/editbuf.rs new file mode 100644 index 0000000..1638a07 --- /dev/null +++ b/src/editbuf.rs @@ -0,0 +1,189 @@ +//! Single-line text-prompt buffer with a caret maintained on UTF-8 boundaries. + +use std::ops::Deref; + +/// A single-line text buffer with a caret between characters. +#[derive(Debug, Default, Clone, PartialEq, Eq)] +pub struct EditBuffer { + text: String, + /// Byte offset into `text`; maintained on a `char` boundary by every op. + caret: usize, +} + +impl EditBuffer { + /// Create a buffer containing `text` with the caret at the end. + pub fn seeded(text: String) -> Self { + let caret = text.len(); + Self { text, caret } + } + + pub fn as_str(&self) -> &str { + &self.text + } + + /// Text before the caret, used to calculate the cursor column. + pub fn before_caret(&self) -> &str { + &self.text[..self.caret] + } + + /// Whether the caret is at the end of the buffer. + pub fn at_end(&self) -> bool { + self.caret == self.text.len() + } + + /// Clear the text and reset the caret. + pub fn clear(&mut self) { + self.text.clear(); + self.caret = 0; + } + + /// Take the text and reset the caret. + pub fn take(&mut self) -> String { + self.caret = 0; + std::mem::take(&mut self.text) + } + + /// Insert `c` at the caret; the caret ends up after it. + pub fn insert(&mut self, c: char) { + self.text.insert(self.caret, c); + self.caret += c.len_utf8(); + } + + /// Remove the character before the caret; a no-op at the start. + pub fn backspace(&mut self) { + if let Some((i, _)) = self.text[..self.caret].char_indices().next_back() { + self.text.remove(i); + self.caret = i; + } + } + + /// Move the caret one character left; a no-op at the start. + pub fn left(&mut self) { + if let Some((i, _)) = self.text[..self.caret].char_indices().next_back() { + self.caret = i; + } + } + + /// Move the caret one character right; a no-op at the end. + pub fn right(&mut self) { + if let Some(c) = self.text[self.caret..].chars().next() { + self.caret += c.len_utf8(); + } + } + + /// Jump the caret to the start. + pub fn home(&mut self) { + self.caret = 0; + } + + /// Jump the caret to the end. + pub fn end(&mut self) { + self.caret = self.text.len(); + } +} + +impl Deref for EditBuffer { + type Target = str; + + fn deref(&self) -> &str { + &self.text + } +} + +#[cfg(test)] +mod tests { + use super::*; + + /// Type each char of `s` into `buf`. + fn type_str(buf: &mut EditBuffer, s: &str) { + for c in s.chars() { + buf.insert(c); + } + } + + #[test] + fn insert_and_backspace_track_the_caret() { + let mut b = EditBuffer::default(); + type_str(&mut b, "abc"); + assert_eq!(b.as_str(), "abc"); + assert!(b.at_end()); + b.backspace(); + assert_eq!(b.as_str(), "ab"); + b.backspace(); + b.backspace(); + assert!(b.is_empty()); + // Backspace at the start is a no-op, not a panic. + b.backspace(); + assert!(b.is_empty()); + } + + #[test] + fn mid_string_edits_land_at_the_caret() { + let mut b = EditBuffer::seeded("café".to_string()); + b.left(); // before 'é' (2 bytes) + b.left(); // before 'f' + b.insert('x'); + assert_eq!(b.as_str(), "caxfé"); + assert_eq!(b.before_caret(), "cax"); + // Backspace removes the char before the caret only. + b.backspace(); + assert_eq!(b.as_str(), "café"); + assert_eq!(b.before_caret(), "ca"); + } + + #[test] + fn left_right_step_whole_multibyte_chars() { + let mut b = EditBuffer::seeded("日本語".to_string()); + b.left(); + assert_eq!(b.before_caret(), "日本"); + b.left(); + b.left(); + assert_eq!(b.before_caret(), ""); + // Left at the start is a no-op. + b.left(); + assert_eq!(b.before_caret(), ""); + b.right(); + assert_eq!(b.before_caret(), "日"); + b.right(); + b.right(); + assert!(b.at_end()); + // Right at the end is a no-op. + b.right(); + assert!(b.at_end()); + } + + #[test] + fn home_and_end_jump_across_multibyte_text() { + let mut b = EditBuffer::seeded("caféは".to_string()); + b.home(); + assert_eq!(b.before_caret(), ""); + b.insert('>'); + assert_eq!(b.as_str(), ">caféは"); + b.end(); + assert!(b.at_end()); + b.backspace(); + assert_eq!(b.as_str(), ">café"); + } + + #[test] + fn seeded_opens_with_the_caret_at_the_end() { + let b = EditBuffer::seeded("name".to_string()); + assert!(b.at_end()); + assert_eq!(b.before_caret(), "name"); + } + + #[test] + fn clear_and_take_reset_the_caret() { + let mut b = EditBuffer::seeded("path/".to_string()); + b.left(); + b.clear(); + assert!(b.is_empty() && b.at_end()); + type_str(&mut b, "ab"); + b.left(); + assert_eq!(b.take(), "ab"); + assert!(b.is_empty() && b.at_end()); + // The reset caret is a valid insertion point. + b.insert('z'); + assert_eq!(b.as_str(), "z"); + } +} diff --git a/src/harness/summary.rs b/src/harness/summary.rs index f42703f..955cf84 100644 --- a/src/harness/summary.rs +++ b/src/harness/summary.rs @@ -86,9 +86,14 @@ fn is_rule_row(row: &str) -> bool { /// space and an `…`-terminated status phrase. const CLAUDE_SPINNER: &[char] = &['·', '✢', '✳', '✶', '✻', '✽']; -/// claude (alt screen). Working state: a column-0 spinner row directly above -/// the input box's top separator. Approval state: the dialog replaces the -/// input box entirely; the menu match fires only when that box is gone. +/// Maximum rows scanned above the input box for a status row. This covers an +/// indented task-list block while keeping the scan pinned to nearby chrome. +const CLAUDE_STATUS_WINDOW: usize = 16; + +/// claude (alt screen). Working state: a column-0 spinner row above the +/// input box's top separator, within [`CLAUDE_STATUS_WINDOW`] rows of it. +/// Approval state: the dialog replaces the input box entirely; the menu +/// match fires only when that box is gone. pub struct ClaudeSummary; impl SummaryAdapter for ClaudeSummary { @@ -132,14 +137,11 @@ fn claude_box_top(rows: &[String]) -> Option { .then_some(top) } -/// Scan the three rows above the input box for the spinner row. Hint rows -/// (the tmux focus-events notice, the right-aligned `● high · /effort`) are -/// indented while the spinner paints at column 0; a column-0 row that is not -/// spinner-shaped aborts the scan: body text reaching the chrome, or the -/// wrapped tail of a status row too wide for the window. Both fail the -/// structural check instead of matching status-shaped body text. +/// Scan above the input box for a spinner or waiting row. Empty and indented +/// hint/task-list rows may separate it from the box. Any other column-0 row, +/// including body prose or a wrapped status tail, invalidates the structure. fn claude_spinner_status(rows: &[String], top: usize) -> Option<(String, &'static str)> { - for i in (top.saturating_sub(3)..top).rev() { + for i in (top.saturating_sub(CLAUDE_STATUS_WINDOW)..top).rev() { let row = &rows[i]; if row.is_empty() || row.starts_with(' ') { continue; @@ -253,7 +255,7 @@ fn claude_ticker_segment(seg: &str) -> bool { /// the spinner phrase rotates per request, so it wins when both are /// present. The probe requires the `⏺` head and a single trailing `…` /// (`⏺ ok`-style reply rows fail it); anything else keeps the spinner -/// phrase: scanning further up would be a body hunt. +/// phrase; scanning further up could match conversation content. fn claude_action_row(rows: &[String], spinner: usize) -> Option { let row = rows[..spinner].iter().rev().find(|r| !r.is_empty())?; let text = row.strip_prefix("⏺ ")?.trim(); @@ -907,6 +909,64 @@ mod tests { assert_eq!(ClaudeSummary.live_preview(&menu), None); } + /// The status scan crosses bounded indented gaps but stops at body prose. + #[test] + fn claude_scan_crosses_task_list_gaps_within_the_window() { + let sep = "─".repeat(120); + let behind_gap = |status: &str, gap: usize| { + let mut rows = vec![status.to_string()]; + rows.push(" ⎿ ✔ Phase 0: verify facts".to_string()); + rows.extend((1..gap).map(|i| format!(" ◼ Phase {i}: generic step"))); + rows.extend([sep.clone(), "❯".to_string(), sep.clone()]); + let refs: Vec<&str> = rows.iter().map(String::as_str).collect(); + ClaudeSummary.live_preview(&rs(&refs)) + }; + for gap in [4, 15] { + assert_eq!( + behind_gap( + "✢ Running phase 1 (dashboard UI)… (4m 20s · ↓ 17.1k tokens)", + gap + ), + Some(( + "Running phase 1 (dashboard UI)…".to_string(), + "claude:spinner" + )), + "gap of {gap} indented rows" + ); + } + for gap in [16, 17, 24] { + assert_eq!( + behind_gap( + "✢ Running phase 1 (dashboard UI)… (4m 20s · ↓ 17.1k tokens)", + gap + ), + None, + "gap of {gap} indented rows must exhaust the window" + ); + } + + // Waiting rows use the same bounded scan. + assert_eq!( + behind_gap("✻ Waiting for 2 background agents to finish", 5), + Some(( + "Waiting for 2 background agents to finish".to_string(), + "claude:waiting-agents" + )) + ); + + // Column-0 body prose invalidates the status structure. + let prose = rs(&[ + "✢ Running phase 1 (dashboard UI)… (4m 20s · ↓ 17.1k tokens)", + "⏺ The phase list below is queued, not running.", + " ⎿ ✔ Phase 0: verify facts", + " ◼ Phase 1: dashboard polish", + &sep, + "❯", + &sep, + ]); + assert_eq!(ClaudeSummary.live_preview(&prose), None); + } + /// The approval menu synthesizes its label only with the input box gone, /// and requires the `2.` sibling below the selector. #[test] @@ -1191,6 +1251,14 @@ mod tests { "Fable 5 (high) · awaiting approval", "claude:approval-menu", ), + Case( + "preview_claude_tasklist", + include_bytes!("../../tests/corpus/preview_claude_tasklist.bin"), + &ClaudeSummary, + // Without the welcome box, the preview has no model prefix. + "Running phase 1 (dashboard UI)…", + "claude:spinner", + ), Case( "preview_codex_working", include_bytes!("../../tests/corpus/preview_codex_working.bin"), @@ -1311,6 +1379,15 @@ mod tests { ); assert_eq!(parts(&p), (MARKER.to_string(), PreviewSource::Marker, None)); + // Body prose between spinner-shaped text and the task list yields the marker. + let p = resolve_corpus( + include_bytes!("../../tests/corpus/preview_claude_body_above_tasklist.bin"), + &ClaudeSummary, + 40, + 120, + ); + assert_eq!(parts(&p), (MARKER.to_string(), PreviewSource::Marker, None)); + // Prior-turn `• Ran` in scrollback with the turn finished: the scan // stops at the reply bullet and the floor tier reports the screen. let p = resolve_corpus( diff --git a/src/main.rs b/src/main.rs index 935d788..76c0ea0 100644 --- a/src/main.rs +++ b/src/main.rs @@ -11,6 +11,8 @@ compile_error!("fleetcom supports Unix platforms only."); mod app; mod core; mod daemon; +// Caret-addressed single-line buffer backing the text prompts. +mod editbuf; // Differential emulator tests over recorded PTY output. #[cfg(test)] mod golden; @@ -75,8 +77,10 @@ Usage: fleetcom starts it automatically) Options: - -h, --help print this help - -V, --version print the version + -h, --help print this help + -V, --version print the version + --scrollback per-task terminal scrollback in lines + (default 2000, max 100000, 0 disables) "; /// What a command line asks for, one variant per mutually-exclusive mode. @@ -91,6 +95,8 @@ enum Invocation { Client { foreground: bool, session: Option, + /// Validated `--scrollback` value; resolution clamps it later. + scrollback: Option, }, } @@ -100,13 +106,24 @@ enum Invocation { fn parse_args(args: &[String]) -> Result { let (mut daemon, mut kill, mut foreground) = (false, false, false); let mut session: Option = None; - for a in args { + let mut scrollback: Option = None; + let mut it = args.iter(); + while let Some(a) = it.next() { match a.as_str() { "-h" | "--help" => return Ok(Invocation::Help), "-V" | "--version" => return Ok(Invocation::Version), "--daemon" => daemon = true, "--kill" => kill = true, "--foreground" => foreground = true, + "--scrollback" => { + let v = it + .next() + .ok_or_else(|| "--scrollback requires a value".to_string())?; + scrollback = Some( + v.parse() + .map_err(|_| format!("invalid --scrollback value '{v}'"))?, + ); + } f if f.starts_with('-') => return Err(format!("unrecognized flag '{f}'")), name => { if session.is_some() { @@ -118,10 +135,10 @@ fn parse_args(args: &[String]) -> Result { } } } - if daemon && (kill || foreground || session.is_some()) { + if daemon && (kill || foreground || session.is_some() || scrollback.is_some()) { return Err("--daemon takes no other arguments".to_string()); } - if kill && (foreground || session.is_some()) { + if kill && (foreground || session.is_some() || scrollback.is_some()) { return Err("--kill takes no other arguments".to_string()); } match (daemon, kill) { @@ -130,13 +147,14 @@ fn parse_args(args: &[String]) -> Result { _ => Ok(Invocation::Client { foreground, session, + scrollback, }), } } fn main() -> io::Result<()> { let args: Vec = std::env::args().skip(1).collect(); - let (foreground, session) = match parse_args(&args) { + let (foreground, session, scrollback) = match parse_args(&args) { Ok(Invocation::Help) => { print!("{USAGE}"); return Ok(()); @@ -152,7 +170,8 @@ fn main() -> io::Result<()> { Ok(Invocation::Client { foreground, session, - }) => (foreground, session), + scrollback, + }) => (foreground, session, scrollback), Err(e) => { eprintln!("fleetcom: {e}"); eprintln!("try 'fleetcom --help'"); @@ -160,6 +179,11 @@ fn main() -> io::Result<()> { } }; + // Install the value before constructing or autostarting a supervisor. + if let Some(lines) = scrollback { + supervisor::set_scrollback_flag(lines); + } + install_panic_hook(); let (cols, rows) = size()?; @@ -306,23 +330,52 @@ mod tests { parse(&[]), Ok(Invocation::Client { foreground: false, - session: None + session: None, + scrollback: None }) ); assert_eq!( parse(&["work"]), Ok(Invocation::Client { foreground: false, - session: Some("work".into()) + session: Some("work".into()), + scrollback: None }) ); assert_eq!( parse(&["--foreground", "work"]), Ok(Invocation::Client { foreground: true, - session: Some("work".into()) + session: Some("work".into()), + scrollback: None + }) + ); + } + + /// `--scrollback` accepts a nonnegative integer and rejects invalid input. + #[test] + fn scrollback_flag_parses_and_rejects() { + assert_eq!( + parse(&["--scrollback", "5000"]), + Ok(Invocation::Client { + foreground: false, + session: None, + scrollback: Some(5000) + }) + ); + assert_eq!( + parse(&["--scrollback", "0", "work"]), + Ok(Invocation::Client { + foreground: false, + session: Some("work".into()), + scrollback: Some(0) }) ); + assert!(parse(&["--scrollback", "garbage"]).is_err()); + assert!(parse(&["--scrollback"]).is_err()); + assert!(parse(&["--scrollback", "-5"]).is_err()); + assert!(parse(&["--daemon", "--scrollback", "5000"]).is_err()); + assert!(parse(&["--kill", "--scrollback", "5000"]).is_err()); } #[test] diff --git a/src/supervisor.rs b/src/supervisor.rs index 29332b9..69f7c48 100644 --- a/src/supervisor.rs +++ b/src/supervisor.rs @@ -9,7 +9,7 @@ use std::{ ffi::OsString, io, path::{Path, PathBuf}, - sync::{Arc, Mutex, mpsc::Sender}, + sync::{Arc, Mutex, OnceLock, mpsc::Sender}, time::{Duration, Instant}, }; @@ -22,16 +22,9 @@ use crate::{ task::Task, }; -/// No output for this long ⇒ `Lifecycle::Idle`. Owned here because the core, not -/// the client, computes lifecycle. It holds the clock and the live parser. -const IDLE_AFTER: Duration = Duration::from_millis(600); - -/// No output for this long ⇒ parked: idle for status-sort *placement*. A -/// second window over the same `last_activity` signal as `IDLE_AFTER`: 600 ms -/// flips the per-row glyph, 10 s moves the row. A cadence shorter than the -/// window (`top` bursts every 1–2 s) resets the signal before it can -/// expire and never produces a placement edge: the window is the debounce. -const SORT_IDLE_AFTER: Duration = Duration::from_secs(10); +/// Quiet period after which a live task becomes idle. Lifecycle and placement +/// use this same threshold. +const IDLE_AFTER: Duration = Duration::from_secs(10); /// Send-on-change fingerprint for the watched screen and scrollback offset. type LastScreen = (u64, Vec, (u16, u16), bool, (bool, bool, bool), usize); @@ -57,6 +50,44 @@ const MAX_TASKS: usize = 256; /// this limit to bound shell arguments and serialized task snapshots. const MAX_COMMAND_LEN: usize = 64 * 1024; +/// Environment variable overriding per-task terminal history depth. +pub const FLEETCOM_SCROLLBACK: &str = "FLEETCOM_SCROLLBACK"; + +/// Default history rows retained by each task's terminal grid. +pub const DEFAULT_SCROLLBACK: usize = 2000; + +/// Maximum configured history rows per task. +const MAX_SCROLLBACK: usize = 100_000; + +/// Process-local value supplied by `--scrollback`. +static SCROLLBACK_FLAG: OnceLock = OnceLock::new(); + +/// Install the `--scrollback` flag value. The first call wins. +pub fn set_scrollback_flag(lines: usize) { + let _ = SCROLLBACK_FLAG.set(lines); +} + +/// The installed `--scrollback` flag value, if any. +pub fn scrollback_flag() -> Option { + SCROLLBACK_FLAG.get().copied() +} + +/// Resolve per-task scrollback from the flag, environment, or default. +pub fn resolve_scrollback() -> usize { + effective_scrollback( + scrollback_flag(), + std::env::var(FLEETCOM_SCROLLBACK).ok().as_deref(), + ) +} + +/// Resolve explicit scrollback sources. The flag takes precedence; invalid +/// environment values use the default; overrides are clamped; zero disables +/// history. +fn effective_scrollback(flag: Option, env: Option<&str>) -> usize { + flag.or_else(|| env.and_then(|v| v.parse().ok())) + .map_or(DEFAULT_SCROLLBACK, |lines| lines.min(MAX_SCROLLBACK)) +} + /// How long a SIGTERMed job gets to exit before SIGKILL. TERM-respecting /// processes exit in milliseconds, so this is the *ceiling* on quit latency, /// not the norm; 2 s is enough for any real flush handler while keeping a @@ -147,6 +178,8 @@ pub struct Supervisor { /// runs at this size, so attach never reflows. rows: u16, cols: u16, + /// History rows used by every task this supervisor spawns. + scrollback: usize, /// The task whose screen the client is watching (attach/peek), or `None`. watched: Option, /// The last `Screen` we emitted (`(id, formatted, cursor, hide)`), so an @@ -172,13 +205,14 @@ pub struct Supervisor { } impl Supervisor { - pub fn new(rows: u16, cols: u16) -> Supervisor { + pub fn new(rows: u16, cols: u16, scrollback: usize) -> Supervisor { Supervisor { tasks: Vec::new(), graveyard: Vec::new(), next_id: 1, rows, cols, + scrollback, watched: None, last_screen: None, launch: None, @@ -432,7 +466,7 @@ impl Supervisor { group: t.group.clone(), name: t.name.clone(), lifecycle: t.lifecycle(now, IDLE_AFTER), - parked: t.parked(now, SORT_IDLE_AFTER), + parked: t.parked(now, IDLE_AFTER), preview: preview.text, source: preview.source, frozen: preview.frozen, @@ -580,6 +614,7 @@ impl Supervisor { cwd, self.rows, self.cols, + self.scrollback, &env, Arc::clone(&self.waker), )?; diff --git a/src/supervisor_tests.rs b/src/supervisor_tests.rs index 4d52ded..5b911e1 100644 --- a/src/supervisor_tests.rs +++ b/src/supervisor_tests.rs @@ -11,11 +11,25 @@ fn here() -> PathBuf { /// Build a supervisor with this process's launch context. fn sup(rows: u16, cols: u16) -> Supervisor { - let mut s = Supervisor::new(rows, cols); + let mut s = Supervisor::new(rows, cols, 2000); s.set_launch_context(LaunchContext::here()); s } +/// Scrollback resolution applies precedence, clamping, and environment fallback. +#[test] +fn scrollback_resolution_precedence_clamp_and_fallback() { + assert_eq!(effective_scrollback(None, None), 2000); + assert_eq!(effective_scrollback(None, Some("500")), 500); + assert_eq!(effective_scrollback(None, Some("0")), 0); + assert_eq!(effective_scrollback(None, Some("999999999")), 100_000); + assert_eq!(effective_scrollback(None, Some("garbage")), 2000); + assert_eq!(effective_scrollback(None, Some("-5")), 2000); + assert_eq!(effective_scrollback(Some(5000), Some("500")), 5000); + assert_eq!(effective_scrollback(Some(999_999_999), None), 100_000); + assert_eq!(effective_scrollback(Some(0), Some("500")), 0); +} + /// The recipe groups commands by dir and preserves spawn order within a dir. /// `a`/`c` share the invocation dir; `b` is off in `/tmp`. #[test] @@ -1203,7 +1217,7 @@ fn shutdown_is_prompt_when_every_group_is_already_empty() { fn session_commands_use_the_launch_context_config_dir() { let dir = scratch("sess_root"); let config = dir.join("config"); - let mut s = Supervisor::new(24, 80); + let mut s = Supervisor::new(24, 80, 2000); s.set_launch_context(LaunchContext { env: vec![( "FLEETCOM_CONFIG_DIR".into(), @@ -1253,7 +1267,7 @@ fn load_session_restores_saved_groups() { )], cwd: dir.clone(), }; - let mut s = Supervisor::new(24, 80); + let mut s = Supervisor::new(24, 80, 2000); s.set_launch_context(ctx.clone()); s.apply(Command::Spawn { command: "sleep 30".into(), @@ -1275,7 +1289,7 @@ fn load_session_restores_saved_groups() { "save must still count commands" ); - let mut fresh = Supervisor::new(24, 80); + let mut fresh = Supervisor::new(24, 80, 2000); fresh.set_launch_context(ctx); fresh.apply(Command::LoadSession { name: "fleet".into(), @@ -1314,7 +1328,7 @@ fn load_session_restores_saved_names() { )], cwd: dir.clone(), }; - let mut s = Supervisor::new(24, 80); + let mut s = Supervisor::new(24, 80, 2000); s.set_launch_context(ctx.clone()); s.apply(Command::Spawn { command: "sleep 30".into(), @@ -1339,7 +1353,7 @@ fn load_session_restores_saved_names() { name: "fleet".into(), }); - let mut fresh = Supervisor::new(24, 80); + let mut fresh = Supervisor::new(24, 80, 2000); fresh.set_launch_context(ctx); fresh.apply(Command::LoadSession { name: "fleet".into(), @@ -1380,7 +1394,7 @@ fn load_session_renormalizes_hand_edited_groups() { ), ) .unwrap(); - let mut s = Supervisor::new(24, 80); + let mut s = Supervisor::new(24, 80, 2000); s.set_launch_context(LaunchContext { env: vec![("FLEETCOM_CONFIG_DIR".into(), config.into_os_string())], cwd: dir.clone(), @@ -1421,7 +1435,7 @@ fn load_surfaces_parse_errors_instead_of_absence() { let config = dir.join("config"); std::fs::create_dir_all(config.join("sessions")).unwrap(); std::fs::write(config.join("sessions").join("broken.json"), "{not json").unwrap(); - let mut s = Supervisor::new(24, 80); + let mut s = Supervisor::new(24, 80, 2000); s.set_launch_context(config_ctx(&config, dir.clone(), &[])); s.apply(Command::LoadSession { name: "broken".into(), @@ -1446,7 +1460,7 @@ fn load_surfaces_parse_errors_instead_of_absence() { fn load_missing_session_reads_as_not_found() { let dir = scratch("sess_missing"); let config = dir.join("config"); - let mut s = Supervisor::new(24, 80); + let mut s = Supervisor::new(24, 80, 2000); s.set_launch_context(config_ctx(&config, dir.clone(), &[])); s.apply(Command::LoadSession { name: "ghost".into(), @@ -1472,7 +1486,7 @@ fn load_reports_admit_failures_not_clean_success() { format!(r#"{{"{}": ["true", "true"]}}"#, dir.display()), ) .unwrap(); - let mut s = Supervisor::new(24, 80); + let mut s = Supervisor::new(24, 80, 2000); s.set_launch_context(config_ctx( &config, dir.clone(), @@ -1536,7 +1550,7 @@ fn load_skips_over_length_commands() { ), ) .unwrap(); - let mut s = Supervisor::new(24, 80); + let mut s = Supervisor::new(24, 80, 2000); s.set_launch_context(config_ctx(&config, dir.clone(), &[])); s.apply(Command::LoadSession { name: "big".into() }); let evs = s.drain(); @@ -1557,7 +1571,7 @@ fn spawn_uses_the_launch_context_env_not_the_process_env() { ); let dir = scratch("hello_env"); let out = dir.join("out"); - let mut s = Supervisor::new(24, 80); + let mut s = Supervisor::new(24, 80, 2000); s.set_launch_context(LaunchContext { env: vec![("FLEETCOM_MARKER".into(), "xyzzy".into())], cwd: dir.clone(), @@ -1582,7 +1596,7 @@ fn spawn_uses_the_launch_context_env_not_the_process_env() { /// rerun, session load) with a status notice. #[test] fn launch_without_context_is_refused() { - let mut s = Supervisor::new(24, 80); + let mut s = Supervisor::new(24, 80, 2000); s.apply(Command::Spawn { command: "true".into(), cwd: here(), @@ -1707,7 +1721,7 @@ fn spawn_claude_pins_an_id_and_layers_settings() { let dir = scratch("cap_claude"); let (bin, runtime) = (dir.join("bin"), dir.join("run")); install_stub(&bin, "claude", &dir); - let mut s = Supervisor::new(24, 80); + let mut s = Supervisor::new(24, 80, 2000); s.set_launch_context(agent_ctx(&bin, &runtime, dir.clone())); s.apply(Command::Spawn { command: "claude".into(), @@ -1785,7 +1799,7 @@ fn spawn_claude_pins_an_id_and_layers_settings() { fn spawn_non_agent_command_is_not_instrumented() { let dir = scratch("cap_plain"); let runtime = dir.join("run"); - let mut s = Supervisor::new(24, 80); + let mut s = Supervisor::new(24, 80, 2000); s.set_launch_context(agent_ctx(&dir.join("bin"), &runtime, dir.clone())); s.apply(Command::Spawn { command: "printf ok".into(), @@ -1811,7 +1825,7 @@ fn spawn_resuming_claude_injects_only_the_capture_channel() { let dir = scratch("cap_resume"); let (bin, runtime) = (dir.join("bin"), dir.join("run")); install_stub(&bin, "claude", &dir); - let mut s = Supervisor::new(24, 80); + let mut s = Supervisor::new(24, 80, 2000); s.set_launch_context(agent_ctx(&bin, &runtime, dir.clone())); s.apply(Command::Spawn { command: format!("claude --resume {CAP_ID}"), @@ -1842,7 +1856,7 @@ fn rerun_resumes_the_captured_conversation() { let dir = scratch("cap_rerun"); let (bin, runtime) = (dir.join("bin"), dir.join("run")); install_stub(&bin, "claude", &dir); - let mut s = Supervisor::new(24, 80); + let mut s = Supervisor::new(24, 80, 2000); s.set_launch_context(agent_ctx(&bin, &runtime, dir.clone())); s.apply(Command::Spawn { command: "claude".into(), @@ -1904,7 +1918,7 @@ fn rerun_cannot_read_the_old_runs_stale_capture() { "claude", &format!("printf 'Resume this session with:\\nclaude --resume {CAP_ID}\\n'"), ); - let mut s = Supervisor::new(24, 80); + let mut s = Supervisor::new(24, 80, 2000); s.set_launch_context(agent_ctx_plus( &bin, &runtime, @@ -1958,7 +1972,7 @@ fn remove_deletes_the_capture_file() { let dir = scratch("cap_remove"); let (bin, runtime) = (dir.join("bin"), dir.join("run")); install_stub(&bin, "claude", &dir); - let mut s = Supervisor::new(24, 80); + let mut s = Supervisor::new(24, 80, 2000); s.set_launch_context(agent_ctx(&bin, &runtime, dir.clone())); s.apply(Command::Spawn { command: "claude".into(), @@ -1983,7 +1997,7 @@ fn reconnect_with_unchanged_root_preserves_capture_files() { let dir = scratch("cap_reconnect"); let (bin, runtime) = (dir.join("bin"), dir.join("run")); install_stub(&bin, "claude", &dir); - let mut s = Supervisor::new(24, 80); + let mut s = Supervisor::new(24, 80, 2000); s.set_launch_context(agent_ctx(&bin, &runtime, dir.clone())); s.apply(Command::Spawn { command: "claude".into(), @@ -2014,7 +2028,7 @@ fn returning_to_a_prior_root_preserves_its_live_captures() { let dir = scratch("cap_aba"); 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); + let mut s = Supervisor::new(24, 80, 2000); s.set_launch_context(agent_ctx(&bin, &root_a, dir.clone())); s.apply(Command::Spawn { command: "claude".into(), @@ -2065,7 +2079,7 @@ fn remove_deletes_the_capture_file_under_the_spawn_root() { let dir = scratch("cap_remove_cross"); 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); + let mut s = Supervisor::new(24, 80, 2000); s.set_launch_context(agent_ctx(&bin, &root_a, dir.clone())); s.apply(Command::Spawn { command: "claude".into(), @@ -2114,7 +2128,7 @@ fn spawn_codex_installs_the_notify_override() { let dir = scratch("cap_codex"); let (bin, runtime) = (dir.join("bin"), dir.join("run")); install_stub(&bin, "codex", &dir); - let mut s = Supervisor::new(24, 80); + let mut s = Supervisor::new(24, 80, 2000); // Keep config lookup within this test's scratch directory. s.set_launch_context(agent_ctx_plus( &bin, @@ -2157,7 +2171,7 @@ fn spawn_grok_pins_an_id_and_injects_nothing_else() { let dir = scratch("cap_grok"); let (bin, runtime, config) = (dir.join("bin"), dir.join("run"), dir.join("config")); install_stub(&bin, "grok", &dir); - let mut s = Supervisor::new(24, 80); + let mut s = Supervisor::new(24, 80, 2000); // Keep save-time correlation inside the scratch tree. s.set_launch_context(agent_ctx_plus( &bin, @@ -2217,7 +2231,7 @@ fn exit_hint_is_scraped_and_saved_as_a_resume() { "claude", &format!("printf 'Resume this session with:\\nclaude --resume {CAP_ID}\\n'"), ); - let mut s = Supervisor::new(24, 80); + let mut s = Supervisor::new(24, 80, 2000); s.set_launch_context(agent_ctx_plus( &bin, &runtime, @@ -2256,7 +2270,7 @@ fn save_scrapes_a_finished_task_without_reap() { "claude", &format!("printf 'Resume this session with:\\nclaude --resume {CAP_ID}\\n'"), ); - let mut s = Supervisor::new(24, 80); + let mut s = Supervisor::new(24, 80, 2000); s.set_launch_context(agent_ctx_plus( &bin, &runtime, @@ -2297,7 +2311,7 @@ fn rerun_scrapes_a_finished_task_without_reap() { "claude", &format!("printf 'Resume this session with:\\nclaude --resume {CAP_ID}\\n'"), ); - let mut s = Supervisor::new(24, 80); + let mut s = Supervisor::new(24, 80, 2000); s.set_launch_context(agent_ctx(&bin, &runtime, dir.clone())); s.apply(Command::Spawn { command: "claude".into(), @@ -2338,7 +2352,7 @@ fn resume_id_precedence_scrape_over_capture_over_spawn() { d = done.display() ), ); - let mut s = Supervisor::new(24, 80); + let mut s = Supervisor::new(24, 80, 2000); s.set_launch_context(agent_ctx_plus( &bin, &runtime, @@ -2400,7 +2414,7 @@ fn save_falls_back_to_fs_correlation_for_a_silent_codex() { let now_ms = now_ms(); let id = write_rollout(&codex_home, now_ms, 1, &dir); - let mut s = Supervisor::new(24, 80); + let mut s = Supervisor::new(24, 80, 2000); s.set_launch_context(agent_ctx_plus( &bin, &runtime, @@ -2445,7 +2459,7 @@ fn save_correlates_against_the_spawn_time_home() { let id_a = write_rollout(&home_a, now_ms, 1, &dir); let id_b = write_rollout(&home_b, now_ms, 2, &dir); - let mut s = Supervisor::new(24, 80); + let mut s = Supervisor::new(24, 80, 2000); s.set_launch_context(agent_ctx_plus( &bin, &runtime, @@ -2532,7 +2546,7 @@ fn home_only_launch_env_targets_the_clients_dot_codex() { let now_ms = now_ms(); let id = write_rollout(&codex_home, now_ms, 1, &dir); - let mut s = Supervisor::new(24, 80); + let mut s = Supervisor::new(24, 80, 2000); s.set_launch_context(agent_ctx_plus( &bin, &runtime, @@ -2589,7 +2603,7 @@ fn stale_inherited_notify_chain_is_never_executed() { "codex", &format!("\"${{FLEETCOM_CAPTURE_FILE%/*}}/codex-notify.sh\" '{payload}'"), ); - let mut s = Supervisor::new(24, 80); + let mut s = Supervisor::new(24, 80, 2000); let mut ctx = agent_ctx_plus(&bin, &runtime, dir.clone(), &[("CODEX_HOME", &codex_home)]); ctx.env.push(( crate::harness::NOTIFY_CHAIN_ENV.into(), @@ -2627,7 +2641,7 @@ fn agent_save_without_any_id_keeps_the_plain_command() { // nothing to find, and the notify routing nothing to read. let codex_home = dir.join("codex_home"); install_stub(&bin, "codex", &dir); - let mut s = Supervisor::new(24, 80); + let mut s = Supervisor::new(24, 80, 2000); s.set_launch_context(agent_ctx_plus( &bin, &runtime, @@ -2701,7 +2715,7 @@ fn config_toml_notify_chains_through_the_injected_script() { chain = NOTIFY_CHAIN_ENV, ), ); - let mut s = Supervisor::new(24, 80); + let mut s = Supervisor::new(24, 80, 2000); s.set_launch_context(agent_ctx_plus( &bin, &runtime, @@ -2761,7 +2775,7 @@ fn unrepresentable_config_notify_suppresses_injection() { ) .unwrap(); install_stub(&bin, "codex", &dir); - let mut s = Supervisor::new(24, 80); + let mut s = Supervisor::new(24, 80, 2000); s.set_launch_context(agent_ctx_plus( &bin, &runtime, @@ -2804,7 +2818,7 @@ fn unrepresentable_config_notify_suppresses_injection() { fn non_agent_entries_survive_save_as_plain_strings() { let dir = scratch("plain_save"); let config = dir.join("config"); - let mut s = Supervisor::new(24, 80); + let mut s = Supervisor::new(24, 80, 2000); s.set_launch_context(LaunchContext { env: vec![ ("SHELL".into(), "/bin/sh".into()), diff --git a/src/task.rs b/src/task.rs index abb8dbf..a7d5509 100644 --- a/src/task.rs +++ b/src/task.rs @@ -28,9 +28,6 @@ use crate::{ protocol::{Key, Lifecycle, Mods, MouseKind, ScrollAction, env_get}, }; -/// Number of history rows retained by each task's terminal grid. -const SCROLLBACK: usize = 2000; - /// Maximum bytes admitted to one task's writer queue but not yet written to the /// PTY. This admits one maximum-size paste with headroom while bounding queued /// input when a child stops reading. @@ -449,10 +446,11 @@ fn wait_code(status: &rustix::process::WaitIdStatus) -> i32 { } impl Task { - /// Spawn `exec_command` under `$SHELL -c` in a fresh `rows`×`cols` PTY. - /// The task keeps `command` for the UI and recipes, while only - /// `exec_command` carries instrumentation. The child receives exactly - /// `env`; `waker` notifies the core when terminal output arrives. + /// Spawn `exec_command` under `$SHELL -c` in a fresh `rows`×`cols` PTY + /// whose grid retains `scrollback` history rows. The task keeps `command` + /// for the UI and recipes, while only `exec_command` carries + /// instrumentation. The child receives exactly `env`; `waker` notifies + /// the core when terminal output arrives. #[allow(clippy::too_many_arguments)] // All arguments define task launch state. pub fn spawn( id: u64, @@ -461,6 +459,7 @@ impl Task { cwd: &Path, rows: u16, cols: u16, + scrollback: usize, env: &[(OsString, OsString)], waker: Waker, ) -> io::Result { @@ -510,7 +509,7 @@ impl Task { let mut reader = pair.master.try_clone_reader().map_err(io_err)?; let writer = pair.master.take_writer().map_err(io_err)?; - let parser = Arc::new(FairMutex::new(Emulator::new(rows, cols, SCROLLBACK))); + let parser = Arc::new(FairMutex::new(Emulator::new(rows, cols, scrollback))); let last_activity = Arc::new(Mutex::new(Instant::now())); // The writer channel exists before the reader thread because the @@ -721,9 +720,7 @@ impl Task { .unwrap_or(Duration::ZERO) } - /// Idle for *placement*: live and quiet past `window`. The same signal - /// `lifecycle` reads, under the caller's (much longer) window; a finished - /// task is never parked because its exit state already places it. + /// Whether a live task has been quiet beyond the placement window. pub fn parked(&self, now: Instant, window: Duration) -> bool { self.finished.is_none() && self.quiet_for(now) > window } @@ -1032,6 +1029,7 @@ mod tests { &here(), 24, 80, + 2000, &env_here(), no_waker(), ) @@ -1070,31 +1068,44 @@ mod tests { wait_finished(&mut t); assert_eq!(t.exit_code, Some(3)); assert_eq!( - t.lifecycle(Instant::now(), Duration::from_millis(600)), + t.lifecycle(Instant::now(), Duration::from_secs(10)), Lifecycle::Failed ); t.terminate(); } - /// Parked reads the same quiet signal as `lifecycle` under its own - /// window: 1 s of quiet is past a 600 ms glyph edge but inside a 10 s - /// placement window; 11 s crosses both. + /// Lifecycle and placement cross the shared quiet threshold together. #[test] - fn parked_uses_its_own_window_over_the_idle_signal() { + fn lifecycle_and_parked_agree_across_the_window_edge() { let mut t = spawn(5, "sleep 5"); // `sleep` writes nothing, so `last_activity` keeps its spawn value // and the injected `now`s measure against a fixed instant. let quiet_since = *t.last_activity.lock().unwrap(); - let now = quiet_since + Duration::from_secs(1); - assert_eq!( - t.lifecycle(now, Duration::from_millis(600)), - Lifecycle::Idle - ); - assert!(!t.parked(now, Duration::from_secs(10))); - assert!(t.parked( - quiet_since + Duration::from_secs(11), - Duration::from_secs(10) - )); + let window = Duration::from_secs(10); + + let inside = quiet_since + Duration::from_secs(9); + assert_eq!(t.lifecycle(inside, window), Lifecycle::Active); + assert!(!t.parked(inside, window)); + + let past = quiet_since + Duration::from_secs(11); + assert_eq!(t.lifecycle(past, window), Lifecycle::Idle); + assert!(t.parked(past, window)); + t.terminate(); + } + + /// Repeated output before the quiet threshold keeps a task active. + #[test] + fn sub_window_quiet_gaps_never_read_as_idle() { + let mut t = spawn(7, "sleep 5"); + let window = Duration::from_secs(10); + let start = *t.last_activity.lock().unwrap(); + for gaps in 1..=4u32 { + let probe = start + Duration::from_secs(9) * gaps; + assert_eq!(t.lifecycle(probe, window), Lifecycle::Active); + assert!(!t.parked(probe, window)); + // Simulate output at the end of each quiet gap. + *t.last_activity.lock().unwrap() = probe; + } t.terminate(); } @@ -1117,6 +1128,7 @@ mod tests { &here(), 24, 80, + 2000, &env_here(), no_waker(), ) @@ -1158,7 +1170,8 @@ mod tests { // must survive its session leader's exit (leader death HUPs the // foreground group) to *be* a straggler. let cmd = format!("trap '' HUP; sleep 300 & echo $! > {}", spid.display()); - let mut t = Task::spawn(5, &cmd, &cmd, &here(), 24, 80, &sh_env(), no_waker()).unwrap(); + let mut t = + Task::spawn(5, &cmd, &cmd, &here(), 24, 80, 2000, &sh_env(), no_waker()).unwrap(); wait_finished(&mut t); // leader exits as soon as the background job is up let straggler = read_pid(&spid); assert!(kill(straggler, None).is_ok(), "straggler should be alive"); @@ -1218,7 +1231,8 @@ mod tests { // `trap '' HUP` first: the `&` child must survive its session // leader's exit to be a straggler (see the terminate test above). let cmd = format!("trap '' HUP; sleep 300 & echo $! > {}", spid.display()); - let mut t = Task::spawn(31, &cmd, &cmd, &here(), 24, 80, &sh_env(), no_waker()).unwrap(); + let mut t = + Task::spawn(31, &cmd, &cmd, &here(), 24, 80, 2000, &sh_env(), no_waker()).unwrap(); wait_finished(&mut t); let straggler = read_pid(&spid); @@ -1845,7 +1859,8 @@ mod tests { printf 'Resume this session with:\\nclaude --resume {ID}\\n'", f = flag.display() ); - let mut t = Task::spawn(20, &cmd, &cmd, &here(), 24, 80, &sh_env(), no_waker()).unwrap(); + let mut t = + Task::spawn(20, &cmd, &cmd, &here(), 24, 80, 2000, &sh_env(), no_waker()).unwrap(); t.harness = Some(&crate::harness::Claude); // Hold the grid before output so the reader cannot process bytes or @@ -1883,7 +1898,8 @@ mod tests { const ID: &str = "7f3b9c1e-5a2d-4e8f-9b6a-0c4d2e8f1a3b"; let cmd = format!("printf '\\033[?2026hResume this session with:\\nclaude --resume {ID}\\n'"); - let mut t = Task::spawn(21, &cmd, &cmd, &here(), 24, 80, &sh_env(), no_waker()).unwrap(); + let mut t = + Task::spawn(21, &cmd, &cmd, &here(), 24, 80, 2000, &sh_env(), no_waker()).unwrap(); t.harness = Some(&crate::harness::Claude); assert!( wait_until(Duration::from_secs(60), || { @@ -1912,7 +1928,8 @@ mod tests { "until [ -e '{}' ]; do sleep 0.05; done; printf 'test result: ok\\n'", flag.display() ); - let mut t = Task::spawn(40, &cmd, &cmd, &here(), 24, 80, &sh_env(), no_waker()).unwrap(); + let mut t = + Task::spawn(40, &cmd, &cmd, &here(), 24, 80, 2000, &sh_env(), no_waker()).unwrap(); // The last live resolution predates every byte of output. let early = t.resolve_preview(Instant::now()); assert!(!early.frozen); @@ -1949,7 +1966,8 @@ mod tests { td = teardown.display(), ex = exit.display() ); - let mut t = Task::spawn(41, &cmd, &cmd, &here(), 24, 80, &sh_env(), no_waker()).unwrap(); + let mut t = + Task::spawn(41, &cmd, &cmd, &here(), 24, 80, 2000, &sh_env(), no_waker()).unwrap(); assert!( wait_until(Duration::from_secs(5), || { t.resolve_preview(Instant::now()).source == PreviewSource::Title @@ -2007,7 +2025,8 @@ mod tests { printf '\\033[?1049ldone\\n'", flag.display() ); - let mut t = Task::spawn(43, &cmd, &cmd, &here(), 24, 80, &sh_env(), no_waker()).unwrap(); + let mut t = + Task::spawn(43, &cmd, &cmd, &here(), 24, 80, 2000, &sh_env(), no_waker()).unwrap(); assert!( wait_until(Duration::from_secs(5), || { t.resolve_preview(Instant::now()).source == PreviewSource::Title @@ -2046,7 +2065,8 @@ mod tests { printf '\\033[H\\033[2J• Ran echo ok\\n\\n› \\n synth-model high · 2 in · 3 out'", f = flag.display() ); - let mut t = Task::spawn(42, &cmd, &cmd, &here(), 24, 80, &sh_env(), no_waker()).unwrap(); + let mut t = + Task::spawn(42, &cmd, &cmd, &here(), 24, 80, 2000, &sh_env(), no_waker()).unwrap(); assert!(t.summary_adapter.is_none(), "printf selects nothing"); t.summary_adapter = crate::harness::summary::select("codex"); assert!(t.summary_adapter.is_some()); @@ -2103,7 +2123,8 @@ mod tests { head -c 11 > {}", out.display() ); - let mut t = Task::spawn(11, &cmd, &cmd, &here(), 24, 80, &sh_env(), no_waker()).unwrap(); + let mut t = + Task::spawn(11, &cmd, &cmd, &here(), 24, 80, 2000, &sh_env(), no_waker()).unwrap(); let mut got = Vec::new(); wait_until(Duration::from_secs(5), || { got = std::fs::read(&out).unwrap_or_default(); diff --git a/src/ui.rs b/src/ui.rs index 7242cb0..f25e90d 100644 --- a/src/ui.rs +++ b/src/ui.rs @@ -10,8 +10,11 @@ use crossterm::{ style::{Attribute, Print, SetAttribute}, }; +use unicode_width::UnicodeWidthStr; + use crate::{ app::{App, DirKind, GroupMode, Mode, Row}, + editbuf::EditBuffer, format::{pad, rel_time, truncate}, preview::PreviewSource, protocol::{Lifecycle, TaskView}, @@ -183,7 +186,7 @@ fn render_dashboard(out: &mut impl Write, app: &App) -> io::Result<()> { // transient save/load notice, else the key hint. let cmd_y = rows.saturating_sub(2); match cmdline(app) { - Some(line) => put(out, cmd_y, &line, cols)?, + Some((line, _)) => put(out, cmd_y, &line, cols)?, None => match &app.status { Some(s) => put(out, cmd_y, &format!(" {s}"), cols)?, None => dim( @@ -208,14 +211,14 @@ fn render_dashboard(out: &mut impl Write, app: &App) -> io::Result<()> { out, rows.saturating_sub(1), &format!( - " ↑↓ select · enter attach · space peek · n/@ new · s sort · m tag · g group · R rename · r rerun · X kill · {exit_hint}" + " ↑↓ select · enter attach · space peek · m tag · g group · R rename · r rerun · X kill · {exit_hint}" ), cols, )?; match cmdline(app) { - Some(line) => { - let cx = truncate(&line, cols).chars().count() as u16; + Some((line, cx)) => { + let cx = clamp_caret(cx, &line, cols); queue!(out, MoveTo(cx, cmd_y), Show)?; } None => queue!(out, Hide)?, @@ -223,21 +226,39 @@ fn render_dashboard(out: &mut impl Write, app: &App) -> io::Result<()> { Ok(()) } -/// The editable bottom line for the text-input modes, or `None` when the command -/// line should show a hint/status instead. -fn cmdline(app: &App) -> Option { - match app.mode { - Mode::Spawn => Some(spawn_prompt(app)), - Mode::SaveSession => Some(format!(" save session as: {}", app.input)), - Mode::Rename => Some(format!(" rename task: {}", app.input)), - _ => None, - } +/// The editable bottom line for the text-input modes (the rendered line and +/// the caret's display column), or `None` when the command line should show a +/// hint/status instead. +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(), + _ => return None, + }; + Some(caret_line(&prefix, &app.input)) +} + +/// 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) { + 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) } -/// Render the `❯` command line with optional directory and group destinations. -fn spawn_prompt(app: &App) -> String { +/// 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)); - prompt_line(dir.as_deref(), app.spawn_group.as_deref(), &app.input) + prompt_line(dir.as_deref(), app.spawn_group.as_deref(), "") } /// Assemble the spawn prompt from its optional `▸` destination segments. @@ -292,13 +313,8 @@ fn attached_title(v: &TaskView) -> String { } } -/// The time column's age: the task's last meaningful edge, not always launch. -/// Finished rows count from exit, parked rows from their last output, running -/// rows from launch. Quiet age keys off `parked` (the 10 s placement window), -/// not `Lifecycle::Idle` (the 600 ms glyph edge): a `top`-cadence task flaps -/// the glyph on every refresh and would flap the column with it. A `None` edge -/// means the frame came from a daemon that predates the field; it falls back -/// to launch age, exactly the old column. +/// Age shown for a task: time since exit when finished, last output when +/// parked, or launch otherwise. Missing edge timestamps fall back to launch. fn row_age(v: &TaskView) -> Duration { let edge = match (v.lifecycle, v.parked) { (Lifecycle::Ok | Lifecycle::Failed, _) => v.finished_ago, @@ -528,14 +544,13 @@ fn render_pickdir(out: &mut impl Write, app: &App) -> io::Result<()> { Some(DirKind::Into) => "enter/tab open", None => "", }; - let cx = truncate(&format!(" @ {}", app.dir_input), app.cols as usize) - .chars() - .count() as u16; + let (line, cx) = caret_line(" @ ", &app.dir_input); + let cx = clamp_caret(cx, &line, app.cols as usize); render_panel( out, app, &Panel { - header: format!(" @ {} ", app.dir_input), + header: format!(" @ {} ", app.dir_input.as_str()), labels: &labels, sel: app.dir_sel, max_rows: 8, @@ -566,14 +581,13 @@ fn render_pickgroup(out: &mut impl Write, app: &App) -> io::Result<()> { None => "", } }; - let cx = truncate(&format!(" g {}", app.group_input), app.cols as usize) - .chars() - .count() as u16; + let (line, cx) = caret_line(" g ", &app.group_input); + let cx = clamp_caret(cx, &line, app.cols as usize); render_panel( out, app, &Panel { - header: format!(" g {} ", app.group_input), + header: format!(" g {} ", app.group_input.as_str()), labels: &labels, sel: app.group_sel, max_rows: 8, @@ -755,10 +769,7 @@ mod tests { } } - /// The time column follows the debounced state: exit age once finished, - /// quiet age while parked, launch age otherwise, even when the glyph's - /// instantaneous `Idle` disagrees. A `None` edge (old-daemon frame) falls - /// back to launch age. + /// The time column uses exit, quiet, or launch age according to task state. #[test] fn task_row_time_column_follows_the_debounced_state() { let quiet = Some(Duration::from_secs(4 * 60)); // renders "4m" @@ -767,10 +778,9 @@ mod tests { (Lifecycle::Ok, false, None, exited, "3s"), (Lifecycle::Failed, false, None, exited, "3s"), (Lifecycle::Idle, true, quiet, None, "4m"), - (Lifecycle::Idle, true, None, None, "2h"), // old daemon: no quiet edge - (Lifecycle::Idle, false, quiet, None, "2h"), // glyph idles; placement has not + (Lifecycle::Idle, true, None, None, "2h"), // missing quiet timestamp (Lifecycle::Active, false, None, None, "2h"), - (Lifecycle::Ok, false, None, None, "2h"), // old daemon: no exit edge + (Lifecycle::Ok, false, None, None, "2h"), // missing exit timestamp ]; for (lifecycle, parked, quiet_ago, finished_ago, want) in cases { let row = task_row(&timed_view(lifecycle, parked, quiet_ago, finished_ago), 80); diff --git a/tests/corpus/README.md b/tests/corpus/README.md index 29ecd01..ccf769e 100644 --- a/tests/corpus/README.md +++ b/tests/corpus/README.md @@ -39,7 +39,9 @@ values. Geometry is 40×120 unless noted. The Codex hint-row and approval fixtures use approximate indentation, so their tests match trimmed heads and column-0 structure. The Claude waiting fixture -omits the welcome box and includes agent-roster rows below the input box. +omits the welcome box and includes agent-roster rows below the input box. The +Claude task-list fixtures use generic phase names in a task-list layout; both +omit the welcome box. | Fixture | Scenario | Coverage | | --- | --- | --- | @@ -51,6 +53,8 @@ omits the welcome box and includes agent-roster rows below the input box. | `preview_claude_done.bin` | claude after a finished turn (`✻ Crunched for 4s` in the body) | fall-through; body completion rows are out of the pinned window | | `preview_claude_body_menu.bin` | approval-menu text quoted in the body while the spinner runs | the pinned spinner wins; body menu text is ignored | | `preview_claude_body_menu_idle.bin` | approval-menu text touching the chrome window, input box intact | a foreign column-0 row aborts extraction and resolves to the marker | +| `preview_claude_tasklist.bin` | claude spinner above a six-row task-list block, agent roster below the box | `claude:spinner` through a bounded indented block | +| `preview_claude_body_above_tasklist.bin` | spinner-shaped body row above `⏺` prose and the task list | negative: column-0 prose invalidates the status structure | | `preview_codex_working.bin` | codex `• Working (7s • esc to interrupt) · 1 background terminal running · /ps to view · /stop to close` | `codex:working` normalization: affordances stripped, slow suffix kept | | `preview_codex_working_over_ran.bin` | codex working with a `• Ran` row higher in the same turn | `codex:working` wins at the pin; the stale row never surfaces | | `preview_codex_scrollback.bin` | codex finished turn, `• Ran` from the prior turn in scrollback | the scan stops at the reply bullet and resolves to the floor tier | diff --git a/tests/corpus/preview_claude_body_above_tasklist.bin b/tests/corpus/preview_claude_body_above_tasklist.bin new file mode 100644 index 0000000..87a6f76 --- /dev/null +++ b/tests/corpus/preview_claude_body_above_tasklist.bin @@ -0,0 +1,12 @@ +[?1049h✻ Hashing… (6s · ↓ 87 tokens) + +⏺ The phase list below is queued, not running. + ⎿ ✔ Phase 0: verify facts + ◼ Phase 1: dashboard polish + ◼ Phase 2: glyph timing + ◼ Phase 3: scrollback config + ◻ Phase 4: caret navigation +──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── +❯ +──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── + (statusline scrubbed: user-configured row) \ No newline at end of file diff --git a/tests/corpus/preview_claude_tasklist.bin b/tests/corpus/preview_claude_tasklist.bin new file mode 100644 index 0000000..68411c4 --- /dev/null +++ b/tests/corpus/preview_claude_tasklist.bin @@ -0,0 +1,14 @@ +[?1049h✢ Running phase 1 (dashboard UI)… (4m 20s · ↓ 17.1k tokens) + ⎿ ✔ Phase 0: verify facts + ◼ Phase 1: dashboard polish + ◼ Phase 2: glyph timing + ◼ Phase 3: scrollback config + ◻ Phase 4: caret navigation +──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── +❯ c +──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── + (statusline scrubbed: user-configured row) + ⏵⏵ auto mode on (shift+tab to cycle) + +⏺ main +◯ general-purpose Phase 1 worker 45s · ↓ 17.1k tokens \ No newline at end of file