From 99981d486915dff29581e7bd5b740c229c9e0665 Mon Sep 17 00:00:00 2001 From: Christopher Sardegna Date: Wed, 22 Jul 2026 17:04:07 -0700 Subject: [PATCH 1/2] feat(selection): enhance drag selection and clipboard handling in scrollback mode --- docs/commands.md | 21 +++++--- src/app.rs | 129 ++++++++++++++++++++++++++++------------------- src/app_tests.rs | 76 ++++++++++++++++++++++++++++ src/ui.rs | 15 ++++-- 4 files changed, 177 insertions(+), 64 deletions(-) diff --git a/docs/commands.md b/docs/commands.md index 7a5d2a4..bdeeb5a 100644 --- a/docs/commands.md +++ b/docs/commands.md @@ -59,18 +59,25 @@ The `∙` glyph and the Idle section both apply after 10 seconds without output. While attached, `Ctrl-\` returns to the dashboard. Other supported keys, including `Ctrl-C`, `Ctrl-Z`, and `Ctrl-D`, are forwarded to the child. The daemon encodes cursor keys against the child's live cursor-key mode: unmodified cursor keys use `SS3` in application-cursor mode (`ESC O A` for Up), while modified cursor keys use `CSI`. Function keys, modified navigation (e.g. `Alt+Left`), and standard `Ctrl` combinations are included. `Ctrl-\` refers to the physical chord; the input handler accepts both `Ctrl-\` and crossterm's `Ctrl-4` representation of that chord. -#### Shift+Enter, paste, and the wheel +#### Modified keys, paste, and mouse input -Modified Enter, paste, and mouse input require state-dependent encoding: +These inputs cannot all be forwarded byte-for-byte. Their encoding and destination depend on the terminal and the attached child's modes. -- Shift+Enter and Alt+Enter are sent as `ESC CR`, which is distinct from plain Enter. Shift requires a terminal that reports modified keys; terminals that do not report it send plain `CR`. -- Paste travels as one message. Bracketed-paste-aware children receive paste markers with embedded terminators removed; other children receive line endings as `CR`. `fleetcom` text fields strip control characters. -- Mouse-protocol children receive clicks, drags, releases, and wheel events in the negotiated encoding. Full-screen children without a mouse protocol use alternate scroll when enabled. For a live child without mouse reporting, a left drag selects child-screen text whenever `fleetcom` owns mouse capture: on inline screens and in full-screen programs that disable alternate scroll. Release copies the highlighted span to the system clipboard via OSC 52 and shows a `copied N chars` status-bar notice. Trailing padding is trimmed from each selected row, and concealed (SGR 8) cells copy as the blanks shown on screen. Whenever `fleetcom` captures the mouse, terminal-native selection requires the terminal's selection-override modifier (typically Shift) and covers the terminal's view, including `fleetcom`'s chrome. On the dashboard, in peek, and in full-screen programs using alternate scroll, drag remains terminal-native. -- Attached children do not receive kitty keyboard-protocol or application-keypad sequences. Keypad digits send their normal characters. +Shift+Enter and Alt+Enter send `ESC CR` rather than plain `CR`. If the terminal does not report modified keys, `fleetcom` cannot distinguish Shift+Enter from Enter and forwards plain `CR`. + +Paste travels as one message. For bracketed-paste-aware children, `fleetcom` adds paste markers and removes embedded terminators. Otherwise, it converts line endings to `CR`. Its own text fields strip control characters. + +Mouse routing follows the child's reported modes. A mouse-aware child receives clicks, drags, releases, and wheel events in the negotiated encoding. For a full-screen child without mouse reporting, the terminal's alternate-scroll mode handles the wheel when enabled. + +Selection depends on who owns mouse input. When `fleetcom` has capture, a left drag selects child-screen text on inline screens, in full-screen programs that disable alternate scroll, and in scrollback. While scrollback is visible, `fleetcom` keeps capture and never forwards mouse events to the child. Release copies the highlighted span to the system clipboard via OSC 52 and shows a `copied N chars` status-bar notice. Trailing padding is trimmed from each selected row, and concealed (SGR 8) cells copy as the blanks shown on screen. + +Terminal-native selection remains available under capture through the terminal's override modifier, typically Shift. This selects across the terminal's entire view, including `fleetcom`'s chrome. On the dashboard, in peek, and in full-screen programs using alternate scroll, the terminal owns drag selection directly. + +Attached children do not receive kitty keyboard-protocol or application-keypad sequences. Keypad digits send their normal characters. #### Scrollback -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. +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 unparsable 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. A left drag selects displayed history; release copies it with the same trimming and concealment rules as live-screen selection. Scrolling or leaving scrollback cancels an in-progress drag. `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 diff --git a/src/app.rs b/src/app.rs index 1159a76..a6336d4 100644 --- a/src/app.rs +++ b/src/app.rs @@ -234,8 +234,8 @@ pub struct App { mouse_captured: bool, /// Whether the attached task is displaying scrollback. view_scroll: bool, - /// Active live-screen drag selection in attached-pane cell coordinates. - /// Cleared when its attachment context or viewport changes, mouse reporting + /// Active drag selection over the attached pane's displayed rows. Cleared + /// when its attachment context or viewport changes, child mouse reporting /// takes over, or host mouse capture ends. selection: Option, } @@ -446,7 +446,7 @@ impl App { self.focused_screen.as_ref().filter(|s| s.id == id) } - /// The active live-screen selection displayed by the attached overlay. + /// The active selection displayed by the attached overlay. pub fn selection(&self) -> Option<&Selection> { self.selection.as_ref() } @@ -631,15 +631,7 @@ impl App { // The handshake is handled before the transport is created. Event::HelloOk => {} Event::Tasks(v) => self.views = v, - Event::Screen(s) => { - // Exit only after a nonzero offset returns to live, so a - // pre-entry screen update cannot immediately exit the view. - let prev = self.focused_screen.as_ref().map_or(0, |p| p.scrollback); - if self.view_scroll && prev > 0 && s.scrollback == 0 { - self.view_scroll = false; - } - self.focused_screen = Some(s); - } + Event::Screen(s) => self.on_screen(s), Event::Status(s) => { // Mirror attached-mode status messages into the visible notice bar. if self.mode == Mode::Attached { @@ -662,6 +654,19 @@ impl App { } } + /// Apply a screen frame and leave scrollback when its viewport returns live. + fn on_screen(&mut self, s: ScreenView) { + // A live frame ends an established scrollback view. Requiring a prior + // nonzero offset prevents an already-queued live frame from canceling entry. + let prev = self.focused_screen.as_ref().map_or(0, |p| p.scrollback); + if self.view_scroll && prev > 0 && s.scrollback == 0 { + self.view_scroll = false; + // The live rows invalidate a drag anchored to history rows. + self.selection = None; + } + self.focused_screen = Some(s); + } + /// Queue a clipboard store only when it comes from the attached task. fn on_clipboard_copy(&mut self, id: u64, kind: ClipboardKind, text: String) { if self.mode == Mode::Attached && self.focused_id == Some(id) { @@ -1284,6 +1289,8 @@ impl App { // Keep one row of overlap between pages. let page = self.pane_rows().saturating_sub(1).max(1); if self.view_scroll { + // Scrollback keys can replace the displayed rows, invalidating the drag. + self.selection = None; // Scrollback navigation is not forwarded to the child. match k.code { KeyCode::PageUp => self.send_scrollback(ScrollAction::Up(page)), @@ -1365,8 +1372,8 @@ impl App { } } - /// Route mouse input to dashboard or peek navigation, attached scrollback, - /// live-screen selection, or the attached child's PTY. + /// Route mouse input to dashboard or peek navigation, attached-pane + /// selection and scrollback, or the attached child's PTY. fn on_mouse(&mut self, m: MouseEvent) { let btn = |b: MouseButton| match b { MouseButton::Left => MouseBtn::Left, @@ -1389,12 +1396,25 @@ impl App { _ => {} }, Mode::Attached => { - // The wheel navigates scrollback instead of the child. + // In scrollback, the wheel moves the viewport and left-button + // gestures select displayed history. The child receives no mouse + // events while history is visible. if self.view_scroll { match kind { - MouseKind::WheelUp => self.send_scrollback(ScrollAction::Up(3)), - MouseKind::WheelDown => self.send_scrollback(ScrollAction::Down(3)), - _ => {} + // Scrolling can replace the rows beneath the drag. + MouseKind::WheelUp => { + self.selection = None; + self.send_scrollback(ScrollAction::Up(3)); + } + MouseKind::WheelDown => { + self.selection = None; + self.send_scrollback(ScrollAction::Down(3)); + } + _ => { + if let Some(id) = self.focused_id { + self.on_selection_gesture(id, kind, m.row, m.column); + } + } } return; } @@ -1405,44 +1425,11 @@ impl App { match kind { // Wheel navigation cancels the active drag. MouseKind::WheelUp | MouseKind::WheelDown => self.selection = None, - MouseKind::Press(MouseBtn::Left) => { - // A selection needs the rest of its gesture - let fresh = self - .screen_for(id) - .is_some_and(|s| s.lines.len() == self.pane_rows() as usize); - self.selection = (self.mouse_captured - && fresh - && m.row < self.rows.saturating_sub(1)) - .then(|| { - Selection::begin( - m.row, - m.column.min(self.cols.saturating_sub(1)), - ) - }); - if self.selection.is_some() { + _ => { + if self.on_selection_gesture(id, kind, m.row, m.column) { return; } } - MouseKind::Drag(MouseBtn::Left) if self.selection.is_some() => { - let row = m.row.min(self.pane_rows().saturating_sub(1)); - let col = m.column.min(self.cols.saturating_sub(1)); - if let Some(sel) = self.selection.as_mut() { - sel.extend(row, col); - } - return; - } - MouseKind::Release(MouseBtn::Left) if self.selection.is_some() => { - // The release cell is the final head, including - // for flicks with no intermediate drag event. - let row = m.row.min(self.pane_rows().saturating_sub(1)); - let col = m.column.min(self.cols.saturating_sub(1)); - if let Some(sel) = self.selection.as_mut() { - sel.extend(row, col); - } - self.finish_selection(id); - return; - } - _ => {} } } else if self.selection.is_some() { // Mouse reporting can turn on mid-gesture. Discard the @@ -1471,6 +1458,42 @@ impl App { } } + /// Handle a non-wheel drag-selection event over displayed live or scrollback + /// rows. The live view forwards unconsumed events to the child. + fn on_selection_gesture(&mut self, id: u64, kind: MouseKind, row: u16, col: u16) -> bool { + match kind { + MouseKind::Press(MouseBtn::Left) => { + let fresh = self + .screen_for(id) + .is_some_and(|s| s.lines.len() == self.pane_rows() as usize); + self.selection = + (self.mouse_captured && fresh && row < self.rows.saturating_sub(1)) + .then(|| Selection::begin(row, col.min(self.cols.saturating_sub(1)))); + self.selection.is_some() + } + MouseKind::Drag(MouseBtn::Left) if self.selection.is_some() => { + let row = row.min(self.pane_rows().saturating_sub(1)); + let col = col.min(self.cols.saturating_sub(1)); + if let Some(sel) = self.selection.as_mut() { + sel.extend(row, col); + } + true + } + MouseKind::Release(MouseBtn::Left) if self.selection.is_some() => { + // The release cell is the final head, including for flicks + // with no intermediate drag event. + let row = row.min(self.pane_rows().saturating_sub(1)); + let col = col.min(self.cols.saturating_sub(1)); + if let Some(sel) = self.selection.as_mut() { + sel.extend(row, col); + } + self.finish_selection(id); + true + } + _ => false, + } + } + /// Queue the selected text unless the gesture is a click or selects only /// whitespace. fn finish_selection(&mut self, id: u64) { diff --git a/src/app_tests.rs b/src/app_tests.rs index d5c70c3..424a254 100644 --- a/src/app_tests.rs +++ b/src/app_tests.rs @@ -2579,6 +2579,82 @@ fn one_row_terminal_has_no_selectable_pane() { assert!(app.pending_clipboard.is_empty(), "nothing may copy"); } +impl App { + /// Put an attached fixture into scrollback at the given offset. + fn enter_scrollback(&mut self, offset: usize) { + self.view_scroll = true; + if let Some(s) = self.focused_screen.as_mut() { + s.scrollback = offset; + } + } +} + +/// A drag in scrollback copies the displayed history rows. +#[test] +fn scrollback_drag_copies_the_displayed_history_rows() { + let mut app = App::attached_with_lines(&["old line one", "old line two"]); + app.enter_scrollback(5); + app.on_mouse(press(0, 4)); + app.on_mouse(drag_to(1, 7)); + app.on_mouse(release(1, 7)); + assert_eq!( + app.pending_clipboard, + vec![(ClipboardKind::Clipboard, "line one\nold line".to_string())] + ); + assert!(app.selection.is_none(), "release must clear the selection"); +} + +/// A scrollback wheel event cancels the drag without leaving scrollback. +#[test] +fn scrollback_wheel_cancels_the_drag() { + let mut app = App::attached_with_lines(&["old line one"]); + app.enter_scrollback(5); + app.on_mouse(press(0, 0)); + app.on_mouse(drag_to(0, 4)); + assert!(app.selection.is_some(), "premise: a drag is live"); + app.on_mouse(left(MouseEventKind::ScrollUp, 0, 0)); + assert!(app.selection.is_none(), "wheel must drop the drag"); + assert!(app.view_scroll, "the view stays in scrollback"); + app.on_mouse(release(0, 4)); + assert!(app.pending_clipboard.is_empty(), "a cancel is not a copy"); +} + +/// Scrollback navigation and exit keys cancel an active drag. +#[test] +fn scrollback_keys_clear_the_drag() { + let mut out = io::stdout(); + let mut app = App::attached_with_lines(&["old line one"]); + app.enter_scrollback(5); + app.on_mouse(press(0, 0)); + app.on_mouse(drag_to(0, 4)); + assert!(app.selection.is_some(), "premise: a drag is live"); + app.on_key_attached(&mut out, KeyEvent::new(KeyCode::PageUp, KeyModifiers::NONE)) + .unwrap(); + assert!(app.selection.is_none(), "navigation must drop the drag"); + + app.on_mouse(press(0, 0)); + app.on_mouse(drag_to(0, 4)); + app.on_key_attached(&mut out, KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)) + .unwrap(); + assert!(!app.view_scroll, "Esc exits to live"); + assert!(app.selection.is_none(), "the exit must drop the drag"); +} + +/// A live frame exits scrollback and cancels a drag over replaced history rows. +#[test] +fn live_return_frame_clears_a_scrollback_drag() { + let mut app = App::attached_with_lines(&["old line one"]); + app.enter_scrollback(5); + app.on_mouse(press(0, 0)); + app.on_mouse(drag_to(0, 4)); + assert!(app.selection.is_some(), "premise: a drag is live"); + let mut live = app.focused_screen.clone().expect("screen"); + live.scrollback = 0; + app.on_screen(live); + assert!(!app.view_scroll, "a live frame exits the view"); + assert!(app.selection.is_none(), "the exit must drop the drag"); +} + /// With a mouse-aware child the left button forwards: the SGR /// press/drag/release bytes reach the child's PTY, and no selection /// state forms. diff --git a/src/ui.rs b/src/ui.rs index 0012c28..f0fbf59 100644 --- a/src/ui.rs +++ b/src/ui.rs @@ -749,12 +749,15 @@ fn selection_overlay<'a>(sel: Option<&Selection>, lines: &'a [String]) -> Vec<(u .collect() } -/// Build the attached or scrollback bar, showing notices only in live view. +/// Build the attached or scrollback bar. An active notice replaces the key +/// hints in either view. fn attached_bar(title: &str, scrollback: usize, notice: Option<&str>) -> String { match (scrollback, notice) { (0, Some(n)) => format!(" [attached] {title} {n}"), (0, None) => format!(" [attached] {title} Ctrl-\\ background"), - (n, _) => { + // Active notices replace the scrollback key hints until they expire. + (n, Some(msg)) => format!(" [scroll ↑{n}] {title} {msg}"), + (n, None) => { format!(" [scroll ↑{n}] {title} Esc live · PgUp/PgDn move · Ctrl-\\ background") } } @@ -1028,7 +1031,7 @@ mod tests { ); } - /// The live bar shows notices while the scrollback bar keeps its key hints. + /// Both bars swap their key hints for an active notice. #[test] fn attached_bar_swaps_the_hint_for_an_active_notice() { assert_eq!( @@ -1040,9 +1043,13 @@ mod tests { " [attached] cargo test copied 5 chars" ); assert_eq!( - attached_bar("cargo test", 3, Some("copied 5 chars")), + attached_bar("cargo test", 3, None), " [scroll ↑3] cargo test Esc live · PgUp/PgDn move · Ctrl-\\ background" ); + assert_eq!( + attached_bar("cargo test", 3, Some("copied 5 chars")), + " [scroll ↑3] cargo test copied 5 chars" + ); } /// The dashboard command row prefers an active notice over status text. From ad7b44f2363ccc8d5461394e082a9e916effdb50 Mon Sep 17 00:00:00 2001 From: Christopher Sardegna Date: Wed, 22 Jul 2026 17:10:30 -0700 Subject: [PATCH 2/2] docs(commands): update section titles and clarify input handling for tasks --- docs/commands.md | 40 +++++++++++++++++++++++++++++++--------- 1 file changed, 31 insertions(+), 9 deletions(-) diff --git a/docs/commands.md b/docs/commands.md index bdeeb5a..58e1ac2 100644 --- a/docs/commands.md +++ b/docs/commands.md @@ -49,7 +49,7 @@ The `∙` glyph and the Idle section both apply after 10 seconds without output. -### Input and lifecycle mechanics +### Input and task lifecycle #### Peek vs. attach @@ -57,7 +57,11 @@ The `∙` glyph and the Idle section both apply after 10 seconds without output. #### Attach and background -While attached, `Ctrl-\` returns to the dashboard. Other supported keys, including `Ctrl-C`, `Ctrl-Z`, and `Ctrl-D`, are forwarded to the child. The daemon encodes cursor keys against the child's live cursor-key mode: unmodified cursor keys use `SS3` in application-cursor mode (`ESC O A` for Up), while modified cursor keys use `CSI`. Function keys, modified navigation (e.g. `Alt+Left`), and standard `Ctrl` combinations are included. `Ctrl-\` refers to the physical chord; the input handler accepts both `Ctrl-\` and crossterm's `Ctrl-4` representation of that chord. +Attached mode gives the child control of terminal input. `Ctrl-\` returns to the dashboard; other supported keys, including `Ctrl-C`, `Ctrl-Z`, and `Ctrl-D`, are forwarded to the child. + +Cursor-key encoding follows the child's live cursor-key mode. In application-cursor mode, unmodified cursor keys use `SS3` (`ESC O A` for Up); modified cursor keys use `CSI`. Function keys, modified navigation such as `Alt+Left`, and standard `Ctrl` combinations are also supported. + +`Ctrl-\` names the physical chord. Crossterm may report the same chord as `Ctrl-4`, so `fleetcom` accepts both representations. #### Modified keys, paste, and mouse input @@ -77,19 +81,37 @@ Attached children do not receive kitty keyboard-protocol or application-keypad s #### Scrollback -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 unparsable 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. A left drag selects displayed history; release copies it with the same trimming and concealment rules as live-screen selection. Scrolling or leaving scrollback cancels an in-progress drag. `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. +Each task retains 2,000 lines of scrollback by default. `--scrollback ` or `FLEETCOM_SCROLLBACK` overrides the depth, with the flag taking precedence. Values are capped at 100,000 lines; `0` disables scrollback, and an unparsable environment value falls back to the default instead of failing startup. + +The supervisor reads this setting when it starts. It therefore applies to a `--foreground` run or a daemon started by the current invocation. An existing daemon keeps its configured 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` provide alternatives when the terminal intercepts Shift. The status bar shows the current offset as `[scroll ↑N]`. + +Once open, the wheel scrolls by three lines. `PageUp` and `PageDown` move by pages, `↑` and `↓` move by one line, and `Home` jumps to the oldest retained row. A left drag selects the displayed history; release copies it using the same trimming and concealment rules as live-screen selection. Scrolling cancels an active drag. + +`Esc`, `Enter`, `q`, `End`, or reaching the bottom returns to live output. Typing returns to live output and forwards the key to the child; `Ctrl-\` backgrounds the task as usual. Leaving scrollback cancels an active drag, while detaching or switching tasks resets the view. + +#### Destroying tasks + +Destroy is Shift-gated: only uppercase `X` acts. It kills a running task or removes a finished one. Removal also terminates remaining processes in the task's process group, escalating from `TERM` to `KILL` after two seconds. Lowercase `x` and `Ctrl-X` do nothing. + +#### Rerunning tasks + +`r` acts only on a finished task. A running task remains untouched because rerunning it would first require a destructive kill. + +The replacement starts in the same directory using the requesting client's environment. Most tasks reuse their stored command. A supported `claude`, `codex`, or `grok` task instead uses its captured resume command when a valid conversation ID is available. -#### Destroy is Shift-gated +Rerunning preserves the task's ID, `◆` tag, group, name, and spawn order; its clock and screen reset. Since lifecycle affects sorting, the task may move to another section when it starts. The same key works inside peek, which remains open while the replacement starts. -`X` kills a running task or removes a finished one. Removal also terminates remaining processes in the task's process group, escalating from `TERM` to `KILL` after two seconds. Lowercase `x` and Ctrl-X do nothing. +#### Disconnecting and quitting -#### Rerun +Input meaning depends on the active surface. From the dashboard, `q` or `Ctrl-C` disconnects the client. A daemon and its tasks continue running, so the next `fleetcom` invocation reconnects. Under `--foreground`, the in-process core exits with the client and its tasks die. While attached, `Ctrl-C` belongs to the child. In prompts and pickers, `Esc` cancels without disconnecting. -`r` re-executes a *finished* task in the same directory, using the environment of the client that requested the rerun. Most tasks reuse their stored command. A supported `claude`, `codex`, or `grok` task instead uses its captured resume command when a valid conversation ID is available. The task retains its ID, `◆` tag, group, name, and spawn order; its clock and screen reset. Because lifecycle participates in sorting, the task can move to another section when it starts running again. A running task is left untouched because rerunning it would require a destructive kill first. The same key works inside peek, keeping the task visible while the replacement starts. +Uppercase `Q` stops the daemon and terminates each task's process group. Shutdown sends `TERM` first, then `KILL` after a two-second grace period. Processes that have moved into another group are outside this sweep. -#### Detach vs. quit +A `TERM`-ignoring member can also survive when its leader exits during shutdown. The group-emptiness check then releases the process-group ID reservation before escalation; [Shutdown is graceful-first](README.md#shutdown-is-graceful-first) explains the tradeoff. -From the dashboard, `q` or `Ctrl-C` disconnects the client and leaves the daemon and its tasks running; the next `fleetcom` reattaches. `Q` stops the daemon after sending `TERM` to each task's process group, then `KILL` after a 2 s grace. Processes that have moved to another group are outside this sweep. A `TERM`-ignoring member can also survive if its leader exits during shutdown, because checking group emptiness releases the process-group ID reservation before escalation (see [Shutdown is graceful-first](README.md#shutdown-is-graceful-first)). Outside attached mode, `Ctrl-C` disconnects the client; while attached, it belongs to the child. In prompts and pickers, `Esc` cancels without disconnecting. +### Task organization #### Grouping and tagging