From 6c3df3a2cdc64e848fe8f008dc6c758ac424ad42 Mon Sep 17 00:00:00 2001 From: Christopher Sardegna Date: Thu, 30 Jul 2026 19:37:28 -0700 Subject: [PATCH 01/19] feat(dashboard): cycle the selection through tagged tasks with M --- docs/README.md | 4 +- docs/commands.md | 3 + src/app.rs | 23 ++++++ src/app_tests.rs | 187 +++++++++++++++++++++++++++++++++++++++++++++++ src/ui.rs | 2 +- 5 files changed, 216 insertions(+), 3 deletions(-) diff --git a/docs/README.md b/docs/README.md index eace472..e7861a0 100644 --- a/docs/README.md +++ b/docs/README.md @@ -63,7 +63,7 @@ Run `fleetcom`. The first invocation starts the daemon and opens an empty dashbo fleetcom 0 running · 0 idle · 0 done by state · dir · custom ❯ n run · @ dir · / find · s sort · w save · o load - ↑↓ select · enter attach · space peek · m tag · g group · R rename · r rerun · X kill · q detach · Q quit + ↑↓ select · enter attach · space peek · m tag · M next tag · g group · R rename · r rerun · X kill · q detach · Q quit ``` Press `n`, enter a command, and press `Enter`. The command runs in its own PTY and appears under Running. Repeat the process for a second command: @@ -76,7 +76,7 @@ Press `n`, enter a command, and press `Enter`. The command runs in its own PTY a ✻ npm run dev VITE v5.0 ready in 312 ms 4s ❯ n run · @ dir · / find · s sort · w save · o load - ↑↓ select · enter attach · space peek · m tag · g group · R rename · r rerun · X kill · q detach · Q quit + ↑↓ select · enter attach · space peek · m tag · M next tag · g group · R rename · r rerun · X kill · q detach · Q quit ``` Each row is `glyph · tag · command · latest output · age`. The age counts from the task's last meaningful edge: launch while running, last output once idle, exit once completed. `Space` peeks: a read-only box of the selected task's live screen, without leaving the dashboard: diff --git a/docs/commands.md b/docs/commands.md index 29df21e..98bfdc8 100644 --- a/docs/commands.md +++ b/docs/commands.md @@ -29,6 +29,7 @@ | `/` | Jump the selection to a task by name, command, or group (opens the [find palette](#the--find-palette)) | | `s` | Cycle grouping: by state / by directory / by custom group | | `m` | Tag the selected task "in use" (toggles) | +| `M` | Move the selection to the next tagged task, wrapping at the end | | `g` | Assign the selected task to a group (opens the group picker) | | `R` | Rename the selected task: a display name shown in place of the command | | `r` | Rerun a finished task; supported agent tasks use the captured resume command | @@ -128,6 +129,8 @@ Groups belong to task state: an assignment survives client detach and rerun (`r` `m` toggles the "in use" tag and marks the task with `◆`. In state mode, tagged tasks form the In use section at the top. In custom mode, a tag moves the task to the top of its existing group rather than creating a global section. Within a dir or custom section, tasks sort as tagged, live, then completed; each class then sorts by directory and spawn order. Idle state does not affect row order in these modes, so a quiet task keeps its position and shows `∙`. State mode instead moves quiet tasks from Running to Idle. +`M` moves the selection to the next tagged task in dashboard order, wrapping at the end; tagging marks a context, `M` switches between them. Untagged tasks are skipped, so the cycle visits only tagged rows regardless of how many lie between them. With nothing tagged the key does nothing: the selection stays put and no mode changes. One tagged task wraps onto itself, leaving the selection unchanged. + In custom mode only, a new command inherits the selected task's group, through both `n` and the `@` picker. The spawn prompt shows the destination as `❯ dir ▸ group ▸ command`, each segment present only when it applies: the dir segment for a non-default directory, the group segment when a group will be inherited. State- and dir-mode spawns start unassigned. #### Renaming diff --git a/src/app.rs b/src/app.rs index 2599b6e..efe189d 100644 --- a/src/app.rs +++ b/src/app.rs @@ -664,6 +664,27 @@ impl App { self.select_section_wrap(false); } + /// Select the next tagged task in display order, wrapping at the end. + /// Untagged tasks are skipped, so the cycle visits only the contexts `m` + /// marked. Nothing tagged means nothing to move to: the selection stands. + /// With one tagged task the scan wraps back onto it, leaving the selection + /// where it is rather than clearing it. + fn select_next_tagged(&mut self) { + let order = self.display_order(); + if order.is_empty() { + return; + } + // Start one past the selection so a tagged selection advances; without + // a selection, start at the top of the list. + let start = self.selected_pos(&order).map_or(0, |pos| pos + 1); + let next = (0..order.len()) + .map(|off| order[(start + off) % order.len()]) + .find(|&i| self.views[i].tagged); + if let Some(i) = next { + self.selected_id = Some(self.views[i].id); + } + } + /// Send the desired watch state when its target or attachment mode changes. fn set_watch(&mut self, want: Option<(u64, bool)>) { if want != self.watched { @@ -1159,12 +1180,14 @@ impl App { } } KeyCode::Enter => self.attach(), + // Lowercase `m` marks; uppercase `M` moves between marks. KeyCode::Char('m') => { if let Some(i) = self.selected_task() { let (id, tagged) = (self.views[i].id, self.views[i].tagged); self.transport.send(Command::Tag { id, on: !tagged }); } } + KeyCode::Char('M') => self.select_next_tagged(), KeyCode::Char('g') => self.open_group_picker(), KeyCode::Char('/') => self.open_find_palette(), // Uppercase R renames; lowercase r reruns. diff --git a/src/app_tests.rs b/src/app_tests.rs index 1fa8058..5f72288 100644 --- a/src/app_tests.rs +++ b/src/app_tests.rs @@ -1262,6 +1262,193 @@ fn section_nav_defaults_without_selection() { assert_eq!(empty.selected_id, None); } +// --- `M` cycle tagged tasks ------------------------------------------- + +/// Four running tasks with ids 2 and 4 tagged. State mode floats the tagged +/// pair into In use, so the cycle order is [2, 4] ahead of the untagged rest. +fn app_with_tagged_pair() -> 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: 2, on: true }); + app.transport.send(Command::Tag { id: 4, on: true }); + app.pump(); + assert_eq!( + app.section_ids(), + vec![ + ("In use".to_string(), vec![2, 4]), + ("Running".to_string(), vec![1, 3]), + ], + "tagging reorders: the cycle runs 2 -> 4, then wraps" + ); + app +} + +/// Two groups of two, tagged at the head of each. Custom mode keeps a tag +/// inside its group, so an untagged row sits between the two tagged ones and +/// the display order is [1, 2, 3, 4]. +fn app_with_tags_split_across_groups() -> App { + 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.clone(), "alpha"); // id 2 + app.spawn_grouped("sleep 5", inv.clone(), "beta"); // id 3 + app.spawn_grouped("sleep 5", inv, "beta"); // id 4 + app.pump(); + app.group_mode = GroupMode::Custom; + app.transport.send(Command::Tag { id: 1, on: true }); + app.transport.send(Command::Tag { id: 3, on: true }); + app.pump(); + assert_eq!( + app.section_ids(), + vec![ + ("alpha".to_string(), vec![1, 2]), + ("beta".to_string(), vec![3, 4]), + ], + "tags head their own groups: display order is 1, 2, 3, 4" + ); + app +} + +/// `M` advances through the tagged tasks in display order and wraps. +#[test] +fn cycle_tagged_advances_and_wraps() { + let mut app = app_with_tagged_pair(); + app.resolve_selection(); + assert_eq!(app.selected_id, Some(2), "first row is the first tag"); + + app.on_key_dashboard(key(KeyCode::Char('M'))); + assert_eq!(app.selected_id, Some(4), "forward to the second tag"); + app.on_key_dashboard(key(KeyCode::Char('M'))); + assert_eq!( + app.selected_id, + Some(2), + "past the last tag wraps to the first" + ); +} + +/// Untagged rows between two tags are skipped, however many there are. +#[test] +fn cycle_tagged_skips_untagged_tasks() { + let mut app = app_with_tags_split_across_groups(); + app.selected_id = Some(1); + + app.on_key_dashboard(key(KeyCode::Char('M'))); + assert_eq!(app.selected_id, Some(3), "untagged id 2 is skipped"); + app.on_key_dashboard(key(KeyCode::Char('M'))); + assert_eq!( + app.selected_id, + Some(1), + "untagged id 4 is skipped on the wrap" + ); +} + +/// Nothing tagged means nothing to move to: the key is inert. +#[test] +fn cycle_tagged_is_noop_without_tags() { + let mut app = App::new_local(30, 100); + let inv = app.invocation_dir.clone(); + app.spawn_in("sleep 5", inv.clone()); // id 1 + app.spawn_in("sleep 5", inv); // id 2 + app.pump(); + app.resolve_selection(); + assert_eq!(app.selected_id, Some(1)); + + app.on_key_dashboard(key(KeyCode::Char('M'))); + assert_eq!(app.selected_id, Some(1), "no tags: the selection stands"); + assert!(app.mode == Mode::Dashboard, "no tags: the mode stands"); + assert!(app.notice().is_none() && app.status.is_none()); +} + +/// From an untagged row, `M` lands on the first tag after it, wrapping. +#[test] +fn cycle_tagged_from_untagged_selection_jumps_forward() { + let mut app = app_with_tags_split_across_groups(); + + app.selected_id = Some(2); + app.on_key_dashboard(key(KeyCode::Char('M'))); + assert_eq!(app.selected_id, Some(3), "next tag after the untagged row"); + + // Past the last tag, the scan wraps to the first. + app.selected_id = Some(4); + app.on_key_dashboard(key(KeyCode::Char('M'))); + assert_eq!(app.selected_id, Some(1), "no tag below: wrap to the first"); +} + +/// One tag, already selected: the scan wraps onto itself and holds. +#[test] +fn cycle_tagged_with_one_tag_holds_the_selection() { + let mut app = App::new_local(30, 100); + let inv = app.invocation_dir.clone(); + app.spawn_in("sleep 5", inv.clone()); // id 1 + app.spawn_in("sleep 5", inv.clone()); // id 2 + app.spawn_in("sleep 5", inv); // id 3 + app.pump(); + app.transport.send(Command::Tag { id: 2, on: true }); + app.pump(); + app.resolve_selection(); + assert_eq!(app.selected_id, Some(2), "the only tag heads the list"); + + app.on_key_dashboard(key(KeyCode::Char('M'))); + app.on_key_dashboard(key(KeyCode::Char('M'))); + assert_eq!(app.selected_id, Some(2), "selection is held, not cleared"); +} + +/// Without a selection, `M` takes the first tag; with no tasks at all it does +/// nothing. +#[test] +fn cycle_tagged_without_selection_takes_the_first_tag() { + let mut app = app_with_tagged_pair(); + app.selected_id = None; + app.on_key_dashboard(key(KeyCode::Char('M'))); + assert_eq!(app.selected_id, Some(2), "no selection: first tag in order"); + + let mut empty = App::new_local(30, 100); + empty.on_key_dashboard(key(KeyCode::Char('M'))); + assert_eq!(empty.selected_id, None, "empty fleet: nothing to select"); +} + +/// `M` is pure navigation: it moves the cursor and touches no task state. +#[test] +fn cycle_tagged_mutates_no_task_state() { + let mut app = app_with_tags_split_across_groups(); + app.resolve_selection(); + let before: Vec<_> = app + .views + .iter() + .map(|v| (v.id, v.tagged, v.group.clone(), v.lifecycle)) + .collect(); + + for _ in 0..5 { + app.on_key_dashboard(key(KeyCode::Char('M'))); + } + // A command would have landed on the core by now: the local transport + // ticks the supervisor inline on every poll. + app.pump(); + + let after: Vec<_> = app + .views + .iter() + .map(|v| (v.id, v.tagged, v.group.clone(), v.lifecycle)) + .collect(); + assert_eq!(before, after, "tags, groups, and lifecycles are untouched"); + assert_eq!( + app.section_ids(), + vec![ + ("alpha".to_string(), vec![1, 2]), + ("beta".to_string(), vec![3, 4]), + ], + "order is unchanged, so nothing reordered the list" + ); + assert!(app.notice().is_none() && app.status.is_none()); + assert!(app.mode == Mode::Dashboard); + // Five presses over two tags: an odd count lands on the second. + assert_eq!(app.selected_id, Some(3)); +} + /// Supported crossterm keys and modifiers map to their wire representation. #[test] fn key_event_maps_to_semantic_key() { diff --git a/src/ui.rs b/src/ui.rs index 9424fb0..f5b92f1 100644 --- a/src/ui.rs +++ b/src/ui.rs @@ -224,7 +224,7 @@ fn render_dashboard(out: &mut impl Write, app: &App) -> io::Result<()> { out, rows.saturating_sub(1), &format!( - " ↑↓ select · enter attach · space peek · m tag · g group · R rename · r rerun · X kill · {exit_hint}" + " ↑↓ select · enter attach · space peek · m tag · M next tag · g group · R rename · r rerun · X kill · {exit_hint}" ), cols, )?; From 003f2b1fd2142ccf6a6677ffde8ae74ec1f2f1e4 Mon Sep 17 00:00:00 2001 From: Christopher Sardegna Date: Thu, 30 Jul 2026 19:51:08 -0700 Subject: [PATCH 02/19] fix(pickdir): filter the recent directories instead of hiding them --- docs/commands.md | 10 ++- src/app.rs | 48 +++++++++-- src/app_tests.rs | 208 +++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 254 insertions(+), 12 deletions(-) diff --git a/docs/commands.md b/docs/commands.md index 98bfdc8..1cdcd78 100644 --- a/docs/commands.md +++ b/docs/commands.md @@ -145,11 +145,15 @@ The daemon removes control characters, trims surrounding whitespace, and limits `@` opens a bottom panel containing a path field and its matching directories. `Enter` depends on the selected row type: -- Current directory: run the command in that directory (`Enter`). -- Recent directories: ones you've launched in before; `Enter` runs there, `Tab`/`→` browses into them. +- Current directory: run the command in that directory (`Enter`). Row 0 is always this row, so the list is never empty. +- Recent directories: ones you've launched in before; `Enter` runs there, `Tab`/`→` browses into them. They sort above the subdirectories: with several projects running, a directory you already work in is the likelier target. - Subdirectories of the current path: `Enter` or `Tab`/`→` descends into one. -Typing filters the rows; `Backspace` deletes one character and the matches re-filter; `↑`/`↓` 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. +Typing filters both lists, under different rules. A subdirectory matches the fragment as a case-insensitive prefix. A recent matches it as a case-insensitive substring of its final path component alone, the way the [find palette](#the--find-palette) matches a task: `log` finds `~/Documents/Code/Rust/Logria`, and `crab` finds both `crabapple` and `crabstep`. Matching the whole label instead would let a shared parent answer for everything under it — `doc` would return every `~/Documents/…` recent and take the row the `docs/` subdirectory should hold. + +A `/` in the field drops the recents. The slash commits the panel to path navigation, and the subdirectory rows of the resolved base already list what those recents would repeat. For the same reason, a recent that is also a subdirectory of the current path takes one row, the recent's. + +`Backspace` deletes one character and the matches re-filter; `↑`/`↓` 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 `/` find palette diff --git a/src/app.rs b/src/app.rs index efe189d..dc52ab9 100644 --- a/src/app.rs +++ b/src/app.rs @@ -908,8 +908,8 @@ impl App { // --- `@` directory picker ------------------------------------------------- /// Recompute picker rows: the current directory first (row 0, "run here"), - /// then, before you've typed anything, the in-use dirs for one-press - /// reuse, then the subdirectories of the current dir matching the fragment. + /// then the in-use dirs whose name matches the fragment, then the + /// subdirectories of the current dir matching it. fn refresh_dir_candidates(&mut self) { let (base_str, partial) = split_input(&self.dir_input); let base = self.resolve(base_str); @@ -920,20 +920,38 @@ impl App { kind: DirKind::Use, }]; - if self.dir_input.is_empty() { + // A `/` in the field means the user has committed to path navigation: + // the `Into` rows below already list the resolved base, so recents + // would double-list it. `split_input` leaves `base_str` empty exactly + // when the input holds no `/`, so that emptiness is the test. + if base_str.is_empty() { + let needle = partial.to_lowercase(); for p in self.in_use_dirs() { - if p != base { - cands.push(DirCand { - label: path::abbreviate(&p), - path: p, - kind: DirKind::Jump, - }); + let label = path::abbreviate(&p); + // Match the final component, not the whole label: recents + // under one parent all carry it, so `doc` would answer for + // every `~/Documents/…` row and steal row 1 from `docs/`. + if p == base || !label_leaf(&label).to_lowercase().contains(&needle) { + continue; } + cands.push(DirCand { + label, + path: p, + kind: DirKind::Jump, + }); } } for name in list_dirs(&base, partial) { let path = base.join(&name); + // A recent that is also a subdirectory of `base` already has a row, + // and that row does strictly more: Enter runs there, Tab descends. + if cands + .iter() + .any(|c| c.kind == DirKind::Jump && c.path == path) + { + continue; + } cands.push(DirCand { label: name, path, @@ -1868,6 +1886,18 @@ fn split_input(input: &str) -> (&str, &str) { } } +/// The final path component of a display label: what reads as the directory's +/// own name. `~/Documents/Code/Rust/Logria` yields `Logria`, and a trailing +/// slash is ignored, so `/tmp/` and `/tmp` both yield `tmp`. The home row `~` +/// is its own final component. The root `/` has none and yields itself, which +/// costs nothing: a `/` in the field suppresses recents before this is called. +fn label_leaf(label: &str) -> &str { + Path::new(label) + .file_name() + .and_then(|n| n.to_str()) + .unwrap_or(label) +} + /// Subdirectories of `base` whose names start with `partial`, ignoring case. /// Results use the same case-insensitive collation. Hidden entries appear only /// when `partial` starts with `.`. diff --git a/src/app_tests.rs b/src/app_tests.rs index 5f72288..33e6de6 100644 --- a/src/app_tests.rs +++ b/src/app_tests.rs @@ -1019,6 +1019,214 @@ fn list_dirs_collates_case_insensitively() { let _ = std::fs::remove_dir_all(&base); } +/// Build the `@`-picker fixture: `/Documents/Code/Rust/{fleetcom,Logria, +/// crabapple,crabstep}` plus `fleetcom/docs`, a task running in each project +/// but `fleetcom`, and the app invoked from `fleetcom`. Every recent is a +/// sibling of the invocation dir — never a subdirectory — and every one of them +/// carries `Documents` as a middle component. Returns the app and the root to +/// remove. +fn recents_fixture(tag: &str) -> (App, PathBuf) { + let root = temp(tag); + let rust = root.join("Documents/Code/Rust"); + for name in ["fleetcom", "Logria", "crabapple", "crabstep"] { + std::fs::create_dir_all(rust.join(name)).unwrap(); + } + std::fs::create_dir_all(rust.join("fleetcom/docs")).unwrap(); + + let mut app = App::new_local(30, 100); + app.invocation_dir = rust.join("fleetcom"); + // Oldest first, so `in_use_dirs` reports Logria, crabapple, crabstep. + for name in ["crabstep", "crabapple", "Logria"] { + app.spawn_in("sleep 5", rust.join(name)); + } + app.pump(); + (app, root) +} + +/// Open the `@` picker and type `fragment` one key at a time. +fn type_pickdir(app: &mut App, fragment: &str) { + app.on_key_dashboard(key(KeyCode::Char('@'))); + for c in fragment.chars() { + app.on_key_pickdir(key(KeyCode::Char(c))); + } +} + +/// The paths of the picker's recent rows, in display order. +fn jump_paths(app: &App) -> Vec { + app.dir_candidates + .iter() + .filter(|c| c.kind == DirKind::Jump) + .map(|c| c.path.clone()) + .collect() +} + +/// A typed fragment matches a recent by its final path component, so a sibling +/// of the invocation dir — which `list_dirs` can never reach — is still one +/// keypress away. +#[test] +fn pickdir_fragment_surfaces_a_sibling_recent() { + let (mut app, root) = recents_fixture("pickdir_recent_sibling"); + let rust = root.join("Documents/Code/Rust"); + + type_pickdir(&mut app, "log"); + + assert_eq!(app.dir_candidates[0].kind, DirKind::Use); + assert_eq!(app.dir_candidates[0].path, app.invocation_dir); + assert_eq!( + jump_paths(&app), + vec![rust.join("Logria")], + "`log` matches the Logria leaf" + ); + assert!( + !rust.join("Logria").starts_with(&app.invocation_dir), + "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); +} + +/// Recent matching folds case and matches anywhere in the component, like the +/// `/` palette rather than the prefix-matched subdirectory rows. +#[test] +fn pickdir_recent_match_folds_case() { + let (mut app, root) = recents_fixture("pickdir_recent_case"); + let rust = root.join("Documents/Code/Rust"); + + type_pickdir(&mut app, "LOG"); + assert_eq!(app.dir_candidates[0].kind, DirKind::Use); + assert_eq!( + jump_paths(&app), + vec![rust.join("Logria")], + "an uppercase fragment matches a capitalized name" + ); + + // Mid-component: `ria` sits at the end of `Logria`, past any prefix. + 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); +} + +/// One fragment can match several recents; all of them appear. +#[test] +fn pickdir_fragment_surfaces_every_matching_recent() { + let (mut app, root) = recents_fixture("pickdir_recent_many"); + let rust = root.join("Documents/Code/Rust"); + + type_pickdir(&mut app, "crab"); + + assert_eq!(app.dir_candidates[0].kind, DirKind::Use); + assert_eq!( + jump_paths(&app), + vec![rust.join("crabapple"), rust.join("crabstep")], + "recents keep their newest-first order" + ); + let _ = std::fs::remove_dir_all(&root); +} + +/// Matching is on the final component alone. `doc` is a middle component of +/// every recent here, so it matches none of them and leaves the `docs/` +/// subdirectory the user is completing selected. +#[test] +fn pickdir_middle_component_matches_no_recent() { + let (mut app, root) = recents_fixture("pickdir_recent_middle"); + + type_pickdir(&mut app, "doc"); + + assert_eq!(app.dir_candidates[0].kind, DirKind::Use); + assert!( + jump_paths(&app).is_empty(), + "a shared parent must not flood the panel" + ); + assert_eq!(app.dir_candidates.len(), 2); + 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 `/` hands the panel to path navigation: the subdirectory rows already +/// list the resolved base, so recents drop out rather than double-listing it. +#[test] +fn pickdir_slash_suppresses_recents() { + let (mut app, root) = recents_fixture("pickdir_recent_slash"); + let rust = root.join("Documents/Code/Rust"); + + // `..` resolves to the parent every recent lives in: without the rule, + // each one would appear as both a recent and a subdirectory. + type_pickdir(&mut app, "../"); + assert_eq!(app.dir_candidates[0].kind, DirKind::Use); + assert_eq!(app.dir_candidates[0].path, rust); + assert!(jump_paths(&app).is_empty(), "a `/` drops the recents"); + assert!( + app.dir_candidates[1..] + .iter() + .all(|c| c.kind == DirKind::Into) + ); + assert_eq!( + app.dir_candidates + .iter() + .filter(|c| c.path == rust.join("Logria")) + .count(), + 1, + "Logria is listed once, as a subdirectory" + ); + + // Filtering under a base keeps the same rule. + for c in "log".chars() { + app.on_key_pickdir(key(KeyCode::Char(c))); + } + 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 still lists every recent. +#[test] +fn pickdir_empty_input_lists_every_recent() { + let (mut app, root) = recents_fixture("pickdir_recent_empty"); + let rust = root.join("Documents/Code/Rust"); + + type_pickdir(&mut app, ""); + + assert_eq!(app.dir_candidates[0].kind, DirKind::Use); + assert_eq!(app.dir_candidates[0].path, app.invocation_dir); + assert_eq!( + jump_paths(&app), + vec![ + rust.join("Logria"), + rust.join("crabapple"), + rust.join("crabstep"), + ] + ); + assert_eq!(app.dir_sel, 0, "an empty field keeps the current dir"); + let _ = std::fs::remove_dir_all(&root); +} + +/// A recent that is also a subdirectory of the base gets one row, not two. +/// The recent row wins: Enter runs there and Tab still descends. +#[test] +fn pickdir_dedupes_a_recent_that_is_also_a_subdirectory() { + let (mut app, root) = recents_fixture("pickdir_recent_dedupe"); + let docs = root.join("Documents/Code/Rust/fleetcom/docs"); + app.spawn_in("sleep 5", docs.clone()); + app.pump(); + + type_pickdir(&mut app, "doc"); + + assert_eq!(app.dir_candidates[0].kind, DirKind::Use); + assert_eq!( + app.dir_candidates.iter().filter(|c| c.path == docs).count(), + 1, + "one row per directory" + ); + 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 /// (a lower-id task is removed) and reports gone once it's removed. #[test] From 303ebb477267f269c7a3f60a5577026728f69368 Mon Sep 17 00:00:00 2001 From: Christopher Sardegna Date: Thu, 30 Jul 2026 20:08:37 -0700 Subject: [PATCH 03/19] refactor(docs): clarify command descriptions and improve consistency in command behavior --- docs/commands.md | 14 +++++----- src/app.rs | 55 ++++++++++++++++++---------------------- src/app_tests.rs | 66 +++++++++++++++++++----------------------------- 3 files changed, 57 insertions(+), 78 deletions(-) diff --git a/docs/commands.md b/docs/commands.md index 1cdcd78..1414641 100644 --- a/docs/commands.md +++ b/docs/commands.md @@ -29,7 +29,7 @@ | `/` | Jump the selection to a task by name, command, or group (opens the [find palette](#the--find-palette)) | | `s` | Cycle grouping: by state / by directory / by custom group | | `m` | Tag the selected task "in use" (toggles) | -| `M` | Move the selection to the next tagged task, wrapping at the end | +| `M` | Select the next tagged task in dashboard order, wrapping at the end | | `g` | Assign the selected task to a group (opens the group picker) | | `R` | Rename the selected task: a display name shown in place of the command | | `r` | Rerun a finished task; supported agent tasks use the captured resume command | @@ -129,7 +129,7 @@ Groups belong to task state: an assignment survives client detach and rerun (`r` `m` toggles the "in use" tag and marks the task with `◆`. In state mode, tagged tasks form the In use section at the top. In custom mode, a tag moves the task to the top of its existing group rather than creating a global section. Within a dir or custom section, tasks sort as tagged, live, then completed; each class then sorts by directory and spawn order. Idle state does not affect row order in these modes, so a quiet task keeps its position and shows `∙`. State mode instead moves quiet tasks from Running to Idle. -`M` moves the selection to the next tagged task in dashboard order, wrapping at the end; tagging marks a context, `M` switches between them. Untagged tasks are skipped, so the cycle visits only tagged rows regardless of how many lie between them. With nothing tagged the key does nothing: the selection stays put and no mode changes. One tagged task wraps onto itself, leaving the selection unchanged. +`M` cycles the selection through tagged tasks in dashboard order. It wraps after the last tagged task. With no tagged tasks, the selection does not move; with one, the selection moves to that task and stays there. In custom mode only, a new command inherits the selected task's group, through both `n` and the `@` picker. The spawn prompt shows the destination as `❯ dir ▸ group ▸ command`, each segment present only when it applies: the dir segment for a non-default directory, the group segment when a group will be inherited. State- and dir-mode spawns start unassigned. @@ -145,13 +145,13 @@ The daemon removes control characters, trims surrounding whitespace, and limits `@` opens a bottom panel containing a path field and its matching directories. `Enter` depends on the selected row type: -- Current directory: run the command in that directory (`Enter`). Row 0 is always this row, so the list is never empty. -- Recent directories: ones you've launched in before; `Enter` runs there, `Tab`/`→` browses into them. They sort above the subdirectories: with several projects running, a directory you already work in is the likelier target. -- Subdirectories of the current path: `Enter` or `Tab`/`→` descends into one. +- Resolved path: run the command in that directory (`Enter`). Row 0 is always this row, so the list is never empty. +- Current task directories: `Enter` runs there; `Tab`/`→` browses into them. These rows precede subdirectories. +- Subdirectories of the resolved path: `Enter` or `Tab`/`→` descends into one. -Typing filters both lists, under different rules. A subdirectory matches the fragment as a case-insensitive prefix. A recent matches it as a case-insensitive substring of its final path component alone, the way the [find palette](#the--find-palette) matches a task: `log` finds `~/Documents/Code/Rust/Logria`, and `crab` finds both `crabapple` and `crabstep`. Matching the whole label instead would let a shared parent answer for everything under it — `doc` would return every `~/Documents/…` recent and take the row the `docs/` subdirectory should hold. +Typing filters both lists under different rules. A subdirectory matches the fragment as a case-insensitive prefix. A current task directory matches a case-insensitive substring of its final path component: `log` finds `~/Documents/Code/Rust/Logria`, while `crab` finds both `crabapple` and `crabstep`. Parent components do not participate, so `doc` does not match every directory under `~/Documents/`. -A `/` in the field drops the recents. The slash commits the panel to path navigation, and the subdirectory rows of the resolved base already list what those recents would repeat. For the same reason, a recent that is also a subdirectory of the current path takes one row, the recent's. +Once the field contains `/`, current task directory rows are omitted; the picker shows the resolved path and its matching subdirectories. Without `/`, a current task directory that is also a matching subdirectory appears once, with the current task row behavior. `Backspace` deletes one character and the matches re-filter; `↑`/`↓` 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. diff --git a/src/app.rs b/src/app.rs index dc52ab9..4bf1d03 100644 --- a/src/app.rs +++ b/src/app.rs @@ -665,10 +665,8 @@ impl App { } /// Select the next tagged task in display order, wrapping at the end. - /// Untagged tasks are skipped, so the cycle visits only the contexts `m` - /// marked. Nothing tagged means nothing to move to: the selection stands. - /// With one tagged task the scan wraps back onto it, leaving the selection - /// where it is rather than clearing it. + /// Without a selection, select the first tagged task. Leave the selection + /// unchanged when no task is tagged. fn select_next_tagged(&mut self) { let order = self.display_order(); if order.is_empty() { @@ -907,9 +905,9 @@ impl App { // --- `@` directory picker ------------------------------------------------- - /// Recompute picker rows: the current directory first (row 0, "run here"), - /// then the in-use dirs whose name matches the fragment, then the - /// subdirectories of the current dir matching it. + /// Rebuild directory-picker rows with the resolved path first. When the + /// input has no slash, matching current-task directories follow. Matching + /// subdirectories of the resolved path come last. fn refresh_dir_candidates(&mut self) { let (base_str, partial) = split_input(&self.dir_input); let base = self.resolve(base_str); @@ -920,17 +918,14 @@ impl App { kind: DirKind::Use, }]; - // A `/` in the field means the user has committed to path navigation: - // the `Into` rows below already list the resolved base, so recents - // would double-list it. `split_input` leaves `base_str` empty exactly - // when the input holds no `/`, so that emptiness is the test. + // Include current-task directories only when the input contains no `/`. + // `split_input` leaves `base_str` empty exactly in that case. if base_str.is_empty() { let needle = partial.to_lowercase(); for p in self.in_use_dirs() { let label = path::abbreviate(&p); - // Match the final component, not the whole label: recents - // under one parent all carry it, so `doc` would answer for - // every `~/Documents/…` row and steal row 1 from `docs/`. + // Match the final component so shared parent components do not + // match every sibling directory. if p == base || !label_leaf(&label).to_lowercase().contains(&needle) { continue; } @@ -944,8 +939,8 @@ impl App { for name in list_dirs(&base, partial) { let path = base.join(&name); - // A recent that is also a subdirectory of `base` already has a row, - // and that row does strictly more: Enter runs there, Tab descends. + // A current-task directory that is also a subdirectory already has + // a row: Enter runs there, and Tab descends. if cands .iter() .any(|c| c.kind == DirKind::Jump && c.path == path) @@ -959,8 +954,8 @@ impl App { }); } - // Nothing typed → keep the current dir selected (row 0). Filtering → - // jump to the first match so Tab/Enter drills straight in. + // 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 { @@ -969,8 +964,7 @@ impl App { self.dir_candidates = cands; } - /// Distinct working directories of current tasks, most-recently-spawned - /// first: the "recent" quick-pick list. + /// Distinct task working directories, ordered by the newest task in each. fn in_use_dirs(&self) -> Vec { let mut order: Vec = (0..self.views.len()).collect(); order.sort_by_key(|&i| std::cmp::Reverse(self.views[i].id)); @@ -1198,7 +1192,7 @@ impl App { } } KeyCode::Enter => self.attach(), - // Lowercase `m` marks; uppercase `M` moves between marks. + // `m` toggles a tag; `M` cycles through tagged tasks. KeyCode::Char('m') => { if let Some(i) = self.selected_task() { let (id, tagged) = (self.views[i].id, self.views[i].tagged); @@ -1331,7 +1325,8 @@ 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. + // Descend into the highlighted directory; the resolved-path row is + // a no-op. if let Some(c) = self.dir_candidates.get(self.dir_sel) && c.kind != DirKind::Use { @@ -1352,9 +1347,9 @@ impl App { if let Some(c) = self.dir_candidates.get(self.dir_sel) { let path = c.path.clone(); match c.kind { - // Current dir or a recent dir: run the command there. + // Resolved path or current-task directory: run there. DirKind::Use | DirKind::Jump => self.confirm_dir(path), - // Subdirectory: descend and select it (one keypress). + // Subdirectory: descend and select its resolved-path row. DirKind::Into => self.enter_dir(path), } } @@ -1877,8 +1872,8 @@ fn paste_into(buf: &mut EditBuffer, s: &str) { } } -/// Split a typed path into (directory-so-far, trailing fragment). The fragment -/// is prefix-matched against candidates; the directory is what we list. +/// Split a typed path into its directory prefix and trailing search fragment. +/// Candidate types apply their own matching rules to the fragment. fn split_input(input: &str) -> (&str, &str) { match input.rfind('/') { Some(pos) => (&input[..=pos], &input[pos + 1..]), @@ -1886,11 +1881,9 @@ fn split_input(input: &str) -> (&str, &str) { } } -/// The final path component of a display label: what reads as the directory's -/// own name. `~/Documents/Code/Rust/Logria` yields `Logria`, and a trailing -/// slash is ignored, so `/tmp/` and `/tmp` both yield `tmp`. The home row `~` -/// is its own final component. The root `/` has none and yields itself, which -/// costs nothing: a `/` in the field suppresses recents before this is called. +/// Return the final path component of an abbreviated display label. +/// Trailing slashes are ignored. Labels without a final component, such as +/// `/`, are returned unchanged. fn label_leaf(label: &str) -> &str { Path::new(label) .file_name() diff --git a/src/app_tests.rs b/src/app_tests.rs index 33e6de6..1e87740 100644 --- a/src/app_tests.rs +++ b/src/app_tests.rs @@ -1019,12 +1019,8 @@ fn list_dirs_collates_case_insensitively() { let _ = std::fs::remove_dir_all(&base); } -/// Build the `@`-picker fixture: `/Documents/Code/Rust/{fleetcom,Logria, -/// crabapple,crabstep}` plus `fleetcom/docs`, a task running in each project -/// but `fleetcom`, and the app invoked from `fleetcom`. Every recent is a -/// sibling of the invocation dir — never a subdirectory — and every one of them -/// carries `Documents` as a middle component. Returns the app and the root to -/// remove. +/// 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) { let root = temp(tag); let rust = root.join("Documents/Code/Rust"); @@ -1035,7 +1031,7 @@ fn recents_fixture(tag: &str) -> (App, PathBuf) { let mut app = App::new_local(30, 100); app.invocation_dir = rust.join("fleetcom"); - // Oldest first, so `in_use_dirs` reports Logria, crabapple, crabstep. + // Spawn oldest first so `in_use_dirs` returns Logria, crabapple, crabstep. for name in ["crabstep", "crabapple", "Logria"] { app.spawn_in("sleep 5", rust.join(name)); } @@ -1051,7 +1047,7 @@ fn type_pickdir(app: &mut App, fragment: &str) { } } -/// The paths of the picker's recent rows, in display order. +/// Current-task directory paths in picker order. fn jump_paths(app: &App) -> Vec { app.dir_candidates .iter() @@ -1060,9 +1056,8 @@ fn jump_paths(app: &App) -> Vec { .collect() } -/// A typed fragment matches a recent by its final path component, so a sibling -/// of the invocation dir — which `list_dirs` can never reach — is still one -/// keypress away. +/// Final-component matching includes task directories outside the invocation +/// directory. #[test] fn pickdir_fragment_surfaces_a_sibling_recent() { let (mut app, root) = recents_fixture("pickdir_recent_sibling"); @@ -1085,8 +1080,7 @@ fn pickdir_fragment_surfaces_a_sibling_recent() { let _ = std::fs::remove_dir_all(&root); } -/// Recent matching folds case and matches anywhere in the component, like the -/// `/` palette rather than the prefix-matched subdirectory rows. +/// Current-task directories use case-insensitive substring matching. #[test] fn pickdir_recent_match_folds_case() { let (mut app, root) = recents_fixture("pickdir_recent_case"); @@ -1107,7 +1101,7 @@ fn pickdir_recent_match_folds_case() { let _ = std::fs::remove_dir_all(&root); } -/// One fragment can match several recents; all of them appear. +/// A fragment includes every matching current-task directory. #[test] fn pickdir_fragment_surfaces_every_matching_recent() { let (mut app, root) = recents_fixture("pickdir_recent_many"); @@ -1124,9 +1118,7 @@ fn pickdir_fragment_surfaces_every_matching_recent() { let _ = std::fs::remove_dir_all(&root); } -/// Matching is on the final component alone. `doc` is a middle component of -/// every recent here, so it matches none of them and leaves the `docs/` -/// subdirectory the user is completing selected. +/// 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"); @@ -1145,15 +1137,13 @@ fn pickdir_middle_component_matches_no_recent() { let _ = std::fs::remove_dir_all(&root); } -/// A `/` hands the panel to path navigation: the subdirectory rows already -/// list the resolved base, so recents drop out rather than double-listing it. +/// A `/` omits current-task directories from the picker. #[test] fn pickdir_slash_suppresses_recents() { let (mut app, root) = recents_fixture("pickdir_recent_slash"); let rust = root.join("Documents/Code/Rust"); - // `..` resolves to the parent every recent lives in: without the rule, - // each one would appear as both a recent and a subdirectory. + // `..` resolves to the parent containing every fixture task directory. type_pickdir(&mut app, "../"); assert_eq!(app.dir_candidates[0].kind, DirKind::Use); assert_eq!(app.dir_candidates[0].path, rust); @@ -1172,7 +1162,7 @@ fn pickdir_slash_suppresses_recents() { "Logria is listed once, as a subdirectory" ); - // Filtering under a base keeps the same rule. + // Filtering the resolved path still omits current-task directory rows. for c in "log".chars() { app.on_key_pickdir(key(KeyCode::Char(c))); } @@ -1182,7 +1172,7 @@ fn pickdir_slash_suppresses_recents() { let _ = std::fs::remove_dir_all(&root); } -/// An empty field still lists every recent. +/// An empty field lists every current-task directory. #[test] fn pickdir_empty_input_lists_every_recent() { let (mut app, root) = recents_fixture("pickdir_recent_empty"); @@ -1204,8 +1194,8 @@ fn pickdir_empty_input_lists_every_recent() { let _ = std::fs::remove_dir_all(&root); } -/// A recent that is also a subdirectory of the base gets one row, not two. -/// The recent row wins: Enter runs there and Tab still descends. +/// A current-task directory that is also a subdirectory appears once, using +/// current-task row behavior. #[test] fn pickdir_dedupes_a_recent_that_is_also_a_subdirectory() { let (mut app, root) = recents_fixture("pickdir_recent_dedupe"); @@ -1472,8 +1462,7 @@ fn section_nav_defaults_without_selection() { // --- `M` cycle tagged tasks ------------------------------------------- -/// Four running tasks with ids 2 and 4 tagged. State mode floats the tagged -/// pair into In use, so the cycle order is [2, 4] ahead of the untagged rest. +/// Build four state-grouped tasks with ids 2 and 4 tagged. fn app_with_tagged_pair() -> App { let mut app = App::new_local(30, 100); let inv = app.invocation_dir.clone(); @@ -1495,9 +1484,7 @@ fn app_with_tagged_pair() -> App { app } -/// Two groups of two, tagged at the head of each. Custom mode keeps a tag -/// inside its group, so an untagged row sits between the two tagged ones and -/// the display order is [1, 2, 3, 4]. +/// Build two custom groups with the first task in each group tagged. fn app_with_tags_split_across_groups() -> App { let mut app = App::new_local(30, 100); let inv = app.invocation_dir.clone(); @@ -1538,7 +1525,7 @@ fn cycle_tagged_advances_and_wraps() { ); } -/// Untagged rows between two tags are skipped, however many there are. +/// `M` skips untagged rows between tagged tasks. #[test] fn cycle_tagged_skips_untagged_tasks() { let mut app = app_with_tags_split_across_groups(); @@ -1554,7 +1541,7 @@ fn cycle_tagged_skips_untagged_tasks() { ); } -/// Nothing tagged means nothing to move to: the key is inert. +/// `M` leaves the dashboard unchanged when no task is tagged. #[test] fn cycle_tagged_is_noop_without_tags() { let mut app = App::new_local(30, 100); @@ -1571,7 +1558,7 @@ fn cycle_tagged_is_noop_without_tags() { assert!(app.notice().is_none() && app.status.is_none()); } -/// From an untagged row, `M` lands on the first tag after it, wrapping. +/// From an untagged row, `M` selects the next tagged task. #[test] fn cycle_tagged_from_untagged_selection_jumps_forward() { let mut app = app_with_tags_split_across_groups(); @@ -1580,13 +1567,13 @@ fn cycle_tagged_from_untagged_selection_jumps_forward() { app.on_key_dashboard(key(KeyCode::Char('M'))); assert_eq!(app.selected_id, Some(3), "next tag after the untagged row"); - // Past the last tag, the scan wraps to the first. + // A selection after the last tagged task wraps to the first. app.selected_id = Some(4); app.on_key_dashboard(key(KeyCode::Char('M'))); assert_eq!(app.selected_id, Some(1), "no tag below: wrap to the first"); } -/// One tag, already selected: the scan wraps onto itself and holds. +/// With one tagged task selected, `M` leaves it selected. #[test] fn cycle_tagged_with_one_tag_holds_the_selection() { let mut app = App::new_local(30, 100); @@ -1605,8 +1592,7 @@ fn cycle_tagged_with_one_tag_holds_the_selection() { assert_eq!(app.selected_id, Some(2), "selection is held, not cleared"); } -/// Without a selection, `M` takes the first tag; with no tasks at all it does -/// nothing. +/// Without a selection, `M` selects the first tagged task. #[test] fn cycle_tagged_without_selection_takes_the_first_tag() { let mut app = app_with_tagged_pair(); @@ -1619,7 +1605,7 @@ fn cycle_tagged_without_selection_takes_the_first_tag() { assert_eq!(empty.selected_id, None, "empty fleet: nothing to select"); } -/// `M` is pure navigation: it moves the cursor and touches no task state. +/// `M` changes only the dashboard selection. #[test] fn cycle_tagged_mutates_no_task_state() { let mut app = app_with_tags_split_across_groups(); @@ -1633,8 +1619,8 @@ fn cycle_tagged_mutates_no_task_state() { for _ in 0..5 { app.on_key_dashboard(key(KeyCode::Char('M'))); } - // A command would have landed on the core by now: the local transport - // ticks the supervisor inline on every poll. + // Pump once so any command emitted by `M` updates task state before the + // comparison. app.pump(); let after: Vec<_> = app From 0952f18e52c49e1449440198146037a0f30582ec Mon Sep 17 00:00:00 2001 From: Christopher Sardegna Date: Thu, 30 Jul 2026 22:32:28 -0700 Subject: [PATCH 04/19] feat(controls): add controls overlay for dashboard key references and update related documentation --- README.md | 15 ++-- docs/README.md | 10 +-- docs/commands.md | 11 +++ src/app.rs | 24 +++++ src/app_tests.rs | 144 ++++++++++++++++++++++++++++++ src/ui.rs | 221 +++++++++++++++++++++++++++++++++++++++++++---- 6 files changed, 393 insertions(+), 32 deletions(-) diff --git a/README.md b/README.md index 322645a..0ce2065 100644 --- a/README.md +++ b/README.md @@ -77,15 +77,12 @@ The first ordinary invocation starts the daemon when necessary. `--daemon` is an ### Dashboard -| Key | Command | -| -- | -- | -| ↑ ↓ / `k` `j` | move the selection | -| `Enter` | attach to the selected task | -| `Space` | peek at the selected task | -| `n` | new command in the invocation directory | -| `@` | new command in a directory you pick (with completion) | -| `q` | disconnect; leave the daemon and tasks running | -| `Q` | quit; kill the tasks and stop the daemon | +The dashboard shows two short key hints; `?` opens an expanded key reference: + +```text + ❯ n run · @ dir · / find · s sort + ↑↓ select · enter attach · space peek · ? controls +``` ### Attached diff --git a/docs/README.md b/docs/README.md index e7861a0..8f3abe1 100644 --- a/docs/README.md +++ b/docs/README.md @@ -62,11 +62,11 @@ Run `fleetcom`. The first invocation starts the daemon and opens an empty dashbo ```text fleetcom 0 running · 0 idle · 0 done by state · dir · custom - ❯ n run · @ dir · / find · s sort · w save · o load - ↑↓ select · enter attach · space peek · m tag · M next tag · g group · R rename · r rerun · X kill · q detach · Q quit + ❯ n run · @ dir · / find · s sort + ↑↓ select · enter attach · space peek · ? controls ``` -Press `n`, enter a command, and press `Enter`. The command runs in its own PTY and appears under Running. Repeat the process for a second command: +The hint rows cover common dashboard actions; `?` opens an expanded key reference. Press `n`, enter a command, and press `Enter`. The command runs in its own PTY and appears under Running. Repeat the process for a second command: ```text fleetcom 2 running · 0 idle · 0 done by state · dir · custom @@ -75,8 +75,8 @@ Press `n`, enter a command, and press `Enter`. The command runs in its own PTY a ✻ cargo watch -x test test result: ok. 42 passed 9s ✻ npm run dev VITE v5.0 ready in 312 ms 4s - ❯ n run · @ dir · / find · s sort · w save · o load - ↑↓ select · enter attach · space peek · m tag · M next tag · g group · R rename · r rerun · X kill · q detach · Q quit + ❯ n run · @ dir · / find · s sort + ↑↓ select · enter attach · space peek · ? controls ``` Each row is `glyph · tag · command · latest output · age`. The age counts from the task's last meaningful edge: launch while running, last output once idle, exit once completed. `Space` peeks: a read-only box of the selected task's live screen, without leaving the dashboard: diff --git a/docs/commands.md b/docs/commands.md index 1414641..85d058b 100644 --- a/docs/commands.md +++ b/docs/commands.md @@ -36,6 +36,7 @@ | `X` | Kill a running task (`TERM`, then `KILL` after 2 s), or remove a finished one | | `w` | Save the current tasks as a session | | `o` | Load a saved session or a recovery snapshot (opens the [session picker](#the-o-session-picker)) | +| `?` | Open the [controls overlay](#the--controls-overlay) | | `q` (or `Ctrl-C`) | Disconnect; leave the daemon and tasks running | | `Q` | Quit; kill the tasks and stop the daemon | @@ -185,6 +186,16 @@ The daemon normalizes every group name received from the picker or a [session](s A recovery row reads ` ago · task(s) ·