From 890e2e0270422ef90eb4aff2e0279bbc11ddbc6a Mon Sep 17 00:00:00 2001 From: Christopher Sardegna Date: Fri, 25 Sep 2026 15:58:03 -0700 Subject: [PATCH 01/10] feat(protocol): add the daemon-side flagship mark (protocol v13) --- src/app_readme_tests.rs | 21 ++++++ src/app_tests.rs | 1 + src/protocol.rs | 19 +++++- src/protocol_tests.rs | 47 ++++++++++--- src/supervisor.rs | 28 +++++++- src/supervisor_tests.rs | 144 ++++++++++++++++++++++++++++++++++++++++ src/ui.rs | 1 + tests/common/mod.rs | 2 +- 8 files changed, 250 insertions(+), 13 deletions(-) diff --git a/src/app_readme_tests.rs b/src/app_readme_tests.rs index be4fd22..62bcf79 100644 --- a/src/app_readme_tests.rs +++ b/src/app_readme_tests.rs @@ -124,6 +124,7 @@ fn live_fleet(dirs: &Dirs) -> Vec { command: "claude".to_string(), cwd: dirs.fleetcom.clone(), tagged: true, + flagship: false, group: Some("dashboard".to_string()), name: Some("Dashboard Refine".to_string()), lifecycle: Lifecycle::Active, @@ -140,6 +141,7 @@ fn live_fleet(dirs: &Dirs) -> Vec { command: "claude".to_string(), cwd: dirs.fleetcom.clone(), tagged: true, + flagship: false, group: Some("dashboard".to_string()), name: Some("Summary Refine".to_string()), lifecycle: Lifecycle::Active, @@ -153,6 +155,7 @@ fn live_fleet(dirs: &Dirs) -> Vec { command: "grok".to_string(), cwd: dirs.fleetcom.clone(), tagged: false, + flagship: false, group: Some("dashboard".to_string()), name: Some("Grok Language".to_string()), lifecycle: Lifecycle::Active, @@ -166,6 +169,7 @@ fn live_fleet(dirs: &Dirs) -> Vec { command: "codex".to_string(), cwd: dirs.fleetcom.clone(), tagged: false, + flagship: false, group: Some("dashboard".to_string()), name: Some("Codex Language".to_string()), lifecycle: Lifecycle::Active, @@ -179,6 +183,7 @@ fn live_fleet(dirs: &Dirs) -> Vec { command: "codex".to_string(), cwd: dirs.fleetcom.clone(), tagged: false, + flagship: false, group: Some("dashboard".to_string()), name: Some("Codex Review".to_string()), lifecycle: Lifecycle::Active, @@ -192,6 +197,7 @@ fn live_fleet(dirs: &Dirs) -> Vec { command: "cargo test".to_string(), cwd: dirs.fleetcom.clone(), tagged: false, + flagship: false, group: Some("tests".to_string()), name: None, lifecycle: Lifecycle::Ok, @@ -205,6 +211,7 @@ fn live_fleet(dirs: &Dirs) -> Vec { command: "cargo clippy".to_string(), cwd: dirs.fleetcom.clone(), tagged: false, + flagship: false, group: Some("tests".to_string()), name: None, lifecycle: Lifecycle::Failed, @@ -218,6 +225,7 @@ fn live_fleet(dirs: &Dirs) -> Vec { command: "claude".to_string(), cwd: dirs.home.clone(), tagged: false, + flagship: false, group: Some("desktop".to_string()), name: Some("claude agents".to_string()), lifecycle: Lifecycle::Active, @@ -231,6 +239,7 @@ fn live_fleet(dirs: &Dirs) -> Vec { command: "zellij".to_string(), cwd: dirs.home.clone(), tagged: false, + flagship: false, group: Some("desktop".to_string()), name: Some("Zellij".to_string()), lifecycle: Lifecycle::Active, @@ -244,6 +253,7 @@ fn live_fleet(dirs: &Dirs) -> Vec { command: "python".to_string(), cwd: dirs.home.clone(), tagged: false, + flagship: false, group: None, name: None, lifecycle: Lifecycle::Idle, @@ -257,6 +267,7 @@ fn live_fleet(dirs: &Dirs) -> Vec { command: "brew update && brew upgrade".to_string(), cwd: dirs.home.clone(), tagged: false, + flagship: false, group: Some("desktop".to_string()), name: None, lifecycle: Lifecycle::Ok, @@ -270,6 +281,7 @@ fn live_fleet(dirs: &Dirs) -> Vec { command: "grok".to_string(), cwd: dirs.turret.clone(), tagged: false, + flagship: false, group: Some("turret".to_string()), name: Some("Game Infra Review".to_string()), lifecycle: Lifecycle::Active, @@ -283,6 +295,7 @@ fn live_fleet(dirs: &Dirs) -> Vec { command: "omp".to_string(), cwd: dirs.turret.clone(), tagged: false, + flagship: false, group: Some("turret".to_string()), name: Some("Missile Nerf".to_string()), lifecycle: Lifecycle::Active, @@ -298,6 +311,7 @@ fn live_fleet(dirs: &Dirs) -> Vec { command: "omp".to_string(), cwd: dirs.turret.clone(), tagged: false, + flagship: false, group: Some("turret".to_string()), name: Some("EMP Nerf".to_string()), lifecycle: Lifecycle::Active, @@ -313,6 +327,7 @@ fn live_fleet(dirs: &Dirs) -> Vec { command: "cargo test".to_string(), cwd: dirs.crabapple.clone(), tagged: false, + flagship: false, group: Some("tests".to_string()), name: None, lifecycle: Lifecycle::Ok, @@ -326,6 +341,7 @@ fn live_fleet(dirs: &Dirs) -> Vec { command: "cargo test".to_string(), cwd: dirs.crabstep.clone(), tagged: false, + flagship: false, group: Some("tests".to_string()), name: None, lifecycle: Lifecycle::Ok, @@ -339,6 +355,7 @@ fn live_fleet(dirs: &Dirs) -> Vec { command: "claude".to_string(), cwd: dirs.imessage.clone(), tagged: false, + flagship: false, group: None, name: None, lifecycle: Lifecycle::Active, @@ -352,6 +369,7 @@ fn live_fleet(dirs: &Dirs) -> Vec { command: "cargo test".to_string(), cwd: dirs.imessage.clone(), tagged: false, + flagship: false, group: Some("tests".to_string()), name: None, lifecycle: Lifecycle::Ok, @@ -365,6 +383,7 @@ fn live_fleet(dirs: &Dirs) -> Vec { command: "cargo test".to_string(), cwd: dirs.logria.clone(), tagged: false, + flagship: false, group: Some("tests".to_string()), name: None, lifecycle: Lifecycle::Ok, @@ -378,6 +397,7 @@ fn live_fleet(dirs: &Dirs) -> Vec { command: "cargo watch -x test".to_string(), cwd: dirs.logria.clone(), tagged: false, + flagship: false, group: Some("tests".to_string()), name: None, lifecycle: Lifecycle::Active, @@ -391,6 +411,7 @@ fn live_fleet(dirs: &Dirs) -> Vec { command: "cargo doc --open".to_string(), cwd: dirs.logria.clone(), tagged: false, + flagship: false, group: Some("tests".to_string()), name: None, lifecycle: Lifecycle::Idle, diff --git a/src/app_tests.rs b/src/app_tests.rs index f33b352..aa08860 100644 --- a/src/app_tests.rs +++ b/src/app_tests.rs @@ -113,6 +113,7 @@ fn view(id: u64, cwd: PathBuf, tagged: bool, group: Option<&str>) -> TaskView { command: "true".to_string(), cwd, tagged, + flagship: false, group: group.map(str::to_string), name: None, lifecycle: Lifecycle::Active, diff --git a/src/protocol.rs b/src/protocol.rs index 6a87f6f..23a51f3 100644 --- a/src/protocol.rs +++ b/src/protocol.rs @@ -12,7 +12,7 @@ use base64::{Engine as _, engine::general_purpose::STANDARD as B64}; use crate::frame::{KIND_CONTROL, KIND_HELLO, KIND_SCREEN}; /// Wire-protocol version; mismatched peers are rejected during the handshake. -pub const PROTOCOL_VERSION: u32 = 12; +pub const PROTOCOL_VERSION: u32 = 13; /// Reserved dashboard label for tasks without a custom group. pub const UNASSIGNED: &str = "Unassigned"; @@ -66,6 +66,9 @@ pub enum Command { Restart { id: u64 }, /// Set the manual "in use" tag. Tag { id: u64, on: bool }, + /// Mark the flagship, displacing any prior mark, or clear it for `None`. + /// The core drops the mark once the task finishes or leaves the set. + Flagship { id: Option }, /// Set a task's group; clear to unassigned for `None`. SetGroup { id: u64, group: Option }, /// Set a task's display name; clear it for `None`. @@ -328,6 +331,9 @@ pub struct TaskView { pub command: String, pub cwd: PathBuf, pub tagged: bool, + /// Whether this task is the flagship. At most one view in a snapshot + /// carries it, and never a finished one. + pub flagship: bool, /// Dashboard group; `None` means unassigned. pub group: Option, /// Custom display name; `None` means unnamed. @@ -566,6 +572,8 @@ pub fn encode_command(cmd: &Command) -> (u8, Vec) { Command::Remove { id } => jzon::object! { "t": "remove", "id": *id }, Command::Restart { id } => jzon::object! { "t": "restart", "id": *id }, Command::Tag { id, on } => jzon::object! { "t": "tag", "id": *id, "on": *on }, + // Preserve a cleared mark as JSON null. + Command::Flagship { id } => jzon::object! { "t": "flagship", "id": *id }, Command::SetGroup { id, group } => { let mut o = jzon::object! { "t": "group", "id": *id }; // Absence of `g` encodes an unassigned task. @@ -703,6 +711,13 @@ pub fn decode_command(kind: u8, payload: &[u8]) -> Option { id: v["id"].as_u64()?, on: v["on"].as_bool()?, }, + "flagship" => Command::Flagship { + id: if v["id"].is_null() { + None + } else { + Some(v["id"].as_u64()?) + }, + }, "group" => Command::SetGroup { id: v["id"].as_u64()?, group: opt_str(&v["g"])?, @@ -833,6 +848,7 @@ pub fn encode_event(ev: &Event) -> (u8, Vec) { let _ = o.insert("command", tv.command.as_str()); let _ = o.insert("cwd", path_b64(&tv.cwd)); let _ = o.insert("tagged", tv.tagged); + let _ = o.insert("flagship", tv.flagship); // Group and name fields are present only when set. insert_opt_str(&mut o, "group", &tv.group); insert_opt_str(&mut o, "name", &tv.name); @@ -925,6 +941,7 @@ pub fn decode_event(kind: u8, payload: &[u8]) -> Option { command: tv["command"].as_str()?.to_string(), cwd: path_from_b64(&tv["cwd"])?, tagged: tv["tagged"].as_bool()?, + flagship: tv["flagship"].as_bool()?, // Missing and null both mean unassigned/unnamed. group: opt_str(&tv["group"])?, name: opt_str(&tv["name"])?, diff --git a/src/protocol_tests.rs b/src/protocol_tests.rs index 7e7f50b..fa933f8 100644 --- a/src/protocol_tests.rs +++ b/src/protocol_tests.rs @@ -29,6 +29,8 @@ fn command_round_trips() { Command::Remove { id: 3 }, Command::Restart { id: 4 }, Command::Tag { id: 2, on: true }, + Command::Flagship { id: Some(2) }, + Command::Flagship { id: None }, Command::SetGroup { id: 2, group: Some("infra".into()), @@ -163,9 +165,10 @@ fn command_round_trips() { Command::LoadRecovery { .. } => 15, Command::ListSessions => 16, Command::Shutdown => 17, + Command::Flagship { .. } => 18, } } - let mut seen = [false; 18]; + let mut seen = [false; 19]; for c in cases { seen[variant_index(&c)] = true; let (k, p) = encode_command(&c); @@ -305,6 +308,7 @@ fn tv(id: u64) -> TaskView { command: "x".into(), cwd: PathBuf::from("/"), tagged: false, + flagship: false, group: None, name: None, lifecycle: Lifecycle::Ok, @@ -334,11 +338,11 @@ fn mistyped_event_members_are_rejected() { for json in [ r#"{"t":"tasks","tasks":[{"id":"nope"}]}"#, // The cwd must be a base64 string. - r#"{"t":"tasks","tasks":[{"id":1,"command":"x","cwd":"/x","tagged":true,"life":"ok","preview":"","src":"floor","frozen":false,"started_ms":0}]}"#, + r#"{"t":"tasks","tasks":[{"id":1,"command":"x","cwd":"/x","tagged":true,"flagship":false,"life":"ok","preview":"","src":"floor","frozen":false,"started_ms":0}]}"#, // A present group must be a string; only missing/null means unassigned. - r#"{"t":"tasks","tasks":[{"id":1,"command":"x","cwd":"Lw==","tagged":true,"life":"ok","preview":"","src":"floor","frozen":false,"started_ms":0,"group":5}]}"#, + r#"{"t":"tasks","tasks":[{"id":1,"command":"x","cwd":"Lw==","tagged":true,"flagship":false,"life":"ok","preview":"","src":"floor","frozen":false,"started_ms":0,"group":5}]}"#, // A present name must be a string. - r#"{"t":"tasks","tasks":[{"id":1,"command":"x","cwd":"Lw==","tagged":true,"life":"ok","preview":"","src":"floor","frozen":false,"started_ms":0,"name":5}]}"#, + r#"{"t":"tasks","tasks":[{"id":1,"command":"x","cwd":"Lw==","tagged":true,"flagship":false,"life":"ok","preview":"","src":"floor","frozen":false,"started_ms":0,"name":5}]}"#, r#"{"t":"tasks","tasks":["flat"]}"#, // Numeric member in `names`. r#"{"t":"sessions","names":["ok",5]}"#, @@ -367,6 +371,7 @@ fn tasks_and_status_round_trip() { command: "vim".into(), cwd: PathBuf::from("/home/x"), tagged: true, + flagship: true, group: Some("x".into()), name: Some("editor".into()), lifecycle: Lifecycle::Idle, @@ -468,7 +473,7 @@ fn set_name_wire_form() { #[test] fn tasks_frame_group_key_is_optional() { // "Lw==" is the base64 encoding of "/". - let ungrouped = r#"{"t":"tasks","tasks":[{"id":1,"command":"x","cwd":"Lw==","tagged":false,"life":"ok","preview":"","src":"floor","frozen":false,"started_ms":0}]}"#; + let ungrouped = r#"{"t":"tasks","tasks":[{"id":1,"command":"x","cwd":"Lw==","tagged":false,"flagship":false,"life":"ok","preview":"","src":"floor","frozen":false,"started_ms":0}]}"#; match decode_event(KIND_CONTROL, ungrouped.as_bytes()) { Some(Event::Tasks(v)) => assert_eq!(v[0].group, None), other => panic!("expected tasks event, got {other:?}"), @@ -477,7 +482,7 @@ fn tasks_frame_group_key_is_optional() { let (_, p) = encode_event(&Event::Tasks(vec![tv(1)])); assert_eq!(std::str::from_utf8(&p).unwrap(), ungrouped); - let grouped = r#"{"t":"tasks","tasks":[{"id":1,"command":"x","cwd":"Lw==","tagged":false,"group":"infra","life":"ok","preview":"","src":"floor","frozen":false,"started_ms":0}]}"#; + let grouped = r#"{"t":"tasks","tasks":[{"id":1,"command":"x","cwd":"Lw==","tagged":false,"flagship":false,"group":"infra","life":"ok","preview":"","src":"floor","frozen":false,"started_ms":0}]}"#; match decode_event(KIND_CONTROL, grouped.as_bytes()) { Some(Event::Tasks(v)) => assert_eq!(v[0].group.as_deref(), Some("infra")), other => panic!("expected tasks event, got {other:?}"), @@ -489,7 +494,7 @@ fn tasks_frame_group_key_is_optional() { #[test] fn tasks_frame_name_key_is_optional() { // "Lw==" is the base64 encoding of "/". - let unnamed = r#"{"t":"tasks","tasks":[{"id":1,"command":"x","cwd":"Lw==","tagged":false,"life":"ok","preview":"","src":"floor","frozen":false,"started_ms":0}]}"#; + let unnamed = r#"{"t":"tasks","tasks":[{"id":1,"command":"x","cwd":"Lw==","tagged":false,"flagship":false,"life":"ok","preview":"","src":"floor","frozen":false,"started_ms":0}]}"#; match decode_event(KIND_CONTROL, unnamed.as_bytes()) { Some(Event::Tasks(v)) => assert_eq!(v[0].name, None), other => panic!("expected tasks event, got {other:?}"), @@ -498,7 +503,7 @@ fn tasks_frame_name_key_is_optional() { let (_, p) = encode_event(&Event::Tasks(vec![tv(1)])); assert_eq!(std::str::from_utf8(&p).unwrap(), unnamed); - let named = r#"{"t":"tasks","tasks":[{"id":1,"command":"x","cwd":"Lw==","tagged":false,"name":"build","life":"ok","preview":"","src":"floor","frozen":false,"started_ms":0}]}"#; + let named = r#"{"t":"tasks","tasks":[{"id":1,"command":"x","cwd":"Lw==","tagged":false,"flagship":false,"name":"build","life":"ok","preview":"","src":"floor","frozen":false,"started_ms":0}]}"#; match decode_event(KIND_CONTROL, named.as_bytes()) { Some(Event::Tasks(v)) => assert_eq!(v[0].name.as_deref(), Some("build")), other => panic!("expected tasks event, got {other:?}"), @@ -778,6 +783,32 @@ fn watch_wire_form_requires_the_attached_flag() { } } +/// A flagship frame carries its id as a number, or null to clear the mark. +#[test] +fn flagship_wire_form() { + let (k, p) = encode_command(&Command::Flagship { id: Some(5) }); + assert_eq!(k, KIND_CONTROL); + assert_eq!( + std::str::from_utf8(&p).unwrap(), + r#"{"t":"flagship","id":5}"# + ); + let (_, p) = encode_command(&Command::Flagship { id: None }); + assert_eq!( + std::str::from_utf8(&p).unwrap(), + r#"{"t":"flagship","id":null}"# + ); + for json in [ + r#"{"t":"flagship","id":"5"}"#, // id must be a number + r#"{"t":"flagship","id":-1}"#, // id must be unsigned + ] { + assert_eq!( + decode_command(KIND_CONTROL, json.as_bytes()), + None, + "should reject {json}" + ); + } +} + /// `LoadRecovery` requires a string stem in its wire representation. #[test] fn load_recovery_wire_form() { diff --git a/src/supervisor.rs b/src/supervisor.rs index 3051970..dede6e4 100644 --- a/src/supervisor.rs +++ b/src/supervisor.rs @@ -171,11 +171,12 @@ fn affects_recipe(cmd: &Command) -> bool { | Command::SetName { .. } | Command::LoadSession { .. } | Command::LoadRecovery { .. } => true, - // `Kill` changes lifecycle and `Tag` changes dashboard state; neither - // changes the task set or serialized fields. The remaining variants - // also leave the recipe unchanged. + // `Kill` changes lifecycle; `Tag` and `Flagship` change dashboard + // state. None changes the task set or serialized fields. The remaining + // variants also leave the recipe unchanged. Command::Kill { .. } | Command::Tag { .. } + | Command::Flagship { .. } | Command::Resize { .. } | Command::Watch { .. } | Command::Paste { .. } @@ -248,6 +249,10 @@ pub struct Supervisor { watched: Option, /// Whether the current watch permits clipboard forwarding. watch_attached: bool, + /// The one flagship task, or `None`. A single id makes at-most-one a + /// property of the type. `tick` drops an id that names a finished or + /// missing task, so no snapshot carries a dead mark. + flagship: Option, /// The last emitted screen fingerprint. `lines` stays empty because only /// emitted copies carry them. Cleared when `watched` changes to force a /// fresh screen after attachment. @@ -282,6 +287,7 @@ impl Supervisor { scrollback, watched: None, watch_attached: false, + flagship: None, last_screen: None, launch: None, events: Vec::new(), @@ -365,6 +371,7 @@ impl Supervisor { } Command::Restart { id } => self.rerun(id), Command::Tag { id, on } => self.with_task(id, |t| t.tagged = on), + Command::Flagship { id } => self.flagship = id, Command::SetGroup { id, group } => { self.with_task(id, |t| t.group = normalize_group(group)) } @@ -471,6 +478,15 @@ impl Supervisor { /// `drain`ed events, never a `Task`. pub fn tick(&mut self) { self.reap(); + // Level-triggered: one check covers exit, removal, session load, and a + // mark placed on an already-finished task. + if let Some(id) = self.flagship + && self + .index_of(id) + .is_none_or(|i| self.tasks[i].finished.is_some()) + { + self.flagship = None; + } let now = Instant::now(); // vte re-checks its ?2026 sync timeout only when bytes arrive, so a // child that opens BSU and stalls would freeze its view. This tick is @@ -500,6 +516,7 @@ impl Supervisor { command: t.command.clone(), cwd: t.cwd.clone(), tagged: t.tagged, + flagship: self.flagship == Some(t.id), group: t.group.clone(), name: t.name.clone(), lifecycle: t.lifecycle(now, IDLE_AFTER), @@ -834,6 +851,11 @@ impl Supervisor { self.status("rerun failed: task is still running"); return; } + // The exit may have latched just above, between ticks, and the live + // replacement keeps this id, so the tick check would read it as alive. + if self.flagship == Some(id) { + self.flagship = None; + } let Some(launch) = self.launch_or_refuse() else { return; }; diff --git a/src/supervisor_tests.rs b/src/supervisor_tests.rs index 6a5c6e3..6cb4219 100644 --- a/src/supervisor_tests.rs +++ b/src/supervisor_tests.rs @@ -2093,6 +2093,150 @@ fn recovery_arms_on_structural_mutations_not_tag() { assert!(take_dirty(&mut s), "Remove must arm"); } +/// Tick once and return the ids the snapshot marks as flagship. +fn flagship_ids(s: &mut Supervisor) -> Vec { + s.tick(); + s.drain() + .iter() + .find_map(|e| match e { + Event::Tasks(v) => Some(v.iter().filter(|t| t.flagship).map(|t| t.id).collect()), + _ => None, + }) + .expect("expected a Tasks snapshot") +} + +/// Take the id from the `Spawned` ack without ticking, so no `reap` runs. +fn spawned_id(s: &mut Supervisor) -> u64 { + s.drain() + .iter() + .find_map(|e| match e { + Event::Spawned { id } => Some(*id), + _ => None, + }) + .expect("spawn must ack with Spawned") +} + +/// Marking a second task displaces the first; `None` clears the mark. +#[test] +fn flagship_marks_at_most_one() { + let mut s = sup(24, 80); + spawn(&mut s, "sleep 30", here()); + let a = spawned_id(&mut s); + spawn(&mut s, "sleep 30", here()); + let b = spawned_id(&mut s); + + s.apply(Command::Flagship { id: Some(a) }); + assert_eq!(flagship_ids(&mut s), vec![a]); + s.apply(Command::Flagship { id: Some(b) }); + assert_eq!(flagship_ids(&mut s), vec![b], "marking B must displace A"); + s.apply(Command::Flagship { id: None }); + assert_eq!(flagship_ids(&mut s), Vec::::new()); +} + +/// The first snapshot that shows the flagship finished shows it unmarked. +#[test] +fn tick_clears_exited_flagship() { + let mut s = sup(24, 80); + spawn(&mut s, "sleep 30", here()); + let id = spawned_id(&mut s); + s.apply(Command::Flagship { id: Some(id) }); + assert_eq!(flagship_ids(&mut s), vec![id]); + + s.apply(Command::Kill { id }); + let finished = wait_until(Duration::from_secs(5), || { + let t = view_of(&mut s, id); + assert!( + !(t.flagship && t.finished_ago.is_some()), + "a snapshot showed a finished flagship" + ); + t.finished_ago.is_some() + }); + assert!(finished, "the killed task never finished"); + assert_eq!(s.flagship, None); +} + +/// Removing the flagship leaves no stale id behind. +#[test] +fn tick_clears_removed_flagship() { + let mut s = sup(24, 80); + spawn(&mut s, "sleep 30", here()); + let id = spawned_id(&mut s); + s.apply(Command::Flagship { id: Some(id) }); + assert_eq!(flagship_ids(&mut s), vec![id]); + + s.apply(Command::Remove { id }); + assert_eq!(flagship_ids(&mut s), Vec::::new()); + assert_eq!(s.flagship, None, "the removed id must not linger"); +} + +/// A mark placed on an already-finished task never reaches a snapshot. +#[test] +fn flagship_on_finished_task_never_surfaces() { + let mut s = sup(24, 80); + spawn(&mut s, "true", here()); + let id = spawned_id(&mut s); + wait_for_lifecycle(&mut s, id, |l| l == crate::protocol::Lifecycle::Ok); + + s.apply(Command::Flagship { id: Some(id) }); + assert!(!view_of(&mut s, id).flagship); + assert_eq!(s.flagship, None); +} + +/// `Restart` unmarks the flagship even when its exit latches inside `rerun` +/// and the replacement is alive by the next tick, so the tick check alone +/// would keep the mark. The first run exits; the rerun finds the marker and +/// stays up. +#[test] +fn rerun_clears_flagship() { + use crate::protocol::Lifecycle; + let dir = scratch("rerun_flagship"); + let marker = dir.join("ran"); + let mut s = sup(24, 80); + spawn( + &mut s, + format!( + "test -f {m} || {{ touch {m}; exit 0; }}; sleep 30", + m = marker.display() + ), + dir.to_path_buf(), + ); + let id = spawned_id(&mut s); + s.apply(Command::Flagship { id: Some(id) }); + + // No tick runs until the rerun proceeds: every refusal means the exit + // had not happened yet, and the accepted attempt latched it itself. + let rerun = wait_until(Duration::from_secs(5), || { + s.apply(Command::Restart { id }); + !s.drain() + .iter() + .any(|e| matches!(e, Event::Status(m) if m.contains("still running"))) + }); + assert!(rerun, "the first run never exited"); + + let t = view_of(&mut s, id); + assert!( + matches!(t.lifecycle, Lifecycle::Active | Lifecycle::Idle), + "the replacement must be alive, got {:?}", + t.lifecycle + ); + assert!(!t.flagship, "rerun must clear the flagship"); + assert_eq!(s.flagship, None); +} + +/// Neither marking nor clearing the flagship arms recovery. +#[test] +fn flagship_does_not_affect_recipe() { + let mut s = sup(24, 80); + spawn(&mut s, "sleep 30", here()); + let id = spawned_id(&mut s); + s.recovery.dirty = false; // Spawn armed; reassert a clean baseline + + s.apply(Command::Flagship { id: Some(id) }); + assert!(!take_dirty(&mut s), "marking must not arm"); + s.apply(Command::Flagship { id: None }); + assert!(!take_dirty(&mut s), "clearing must not arm"); +} + /// Session listings include recovery metadata in descending stem order. #[test] fn list_sessions_includes_recovery_snapshots_newest_first() { diff --git a/src/ui.rs b/src/ui.rs index 5020881..70964d3 100644 --- a/src/ui.rs +++ b/src/ui.rs @@ -1175,6 +1175,7 @@ mod tests { command: "cargo test".into(), cwd: std::path::PathBuf::from("/tmp"), tagged: false, + flagship: false, group: None, name: name.map(str::to_string), lifecycle: Lifecycle::Active, diff --git a/tests/common/mod.rs b/tests/common/mod.rs index bb1f1a0..83cdff7 100644 --- a/tests/common/mod.rs +++ b/tests/common/mod.rs @@ -17,7 +17,7 @@ use std::{ /// Protocol version used by this test suite; must match /// `protocol::PROTOCOL_VERSION`. -pub const PROTOCOL_VERSION: u32 = 12; +pub const PROTOCOL_VERSION: u32 = 13; /// One frame of the given kind: `[u32 len][kind][payload]`. pub fn frame(kind: u8, payload: &[u8]) -> Vec { From 2f02b14f044080880274e89afd27459e58136d84 Mon Sep 17 00:00:00 2001 From: Christopher Sardegna Date: Fri, 25 Sep 2026 16:06:47 -0700 Subject: [PATCH 02/10] feat(app): add the flagship keys and the Ctrl-] return path --- src/app.rs | 176 +++++++++++++++--- src/app_tests.rs | 474 +++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 623 insertions(+), 27 deletions(-) diff --git a/src/app.rs b/src/app.rs index fce25b5..5f0dba1 100644 --- a/src/app.rs +++ b/src/app.rs @@ -173,6 +173,26 @@ pub enum Row { Task(usize), } +/// Where a flagship jump started, so the chord can return there. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum Origin { + Dashboard, + Task(u64), +} + +/// Where `Ctrl-]` goes next. The key handler and the attached bar both read +/// it from `App::chord_target`, so the hint never disagrees with the key. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Target { + /// Return to the task the jump started from; the id is in `views`. + Task(u64), + /// Return to the dashboard: the jump started there, or its origin task + /// has left `views`. + Dashboard, + /// Jump to the flagship with this id. + Flagship(u64), +} + pub struct App { /// Connection to the task-owning core. transport: Box, @@ -206,6 +226,12 @@ pub struct App { /// Id of the attached task, if any: by id (not index) so it survives the /// task list changing underneath it. pub focused_id: Option, + /// Where `Ctrl-]` returns to. Set only by attaching to the flagship, so + /// attached-to-flagship implies `Some`; it outlives the flagship's mark + /// (the daemon unmarks on exit) to keep the way back open. Cleared on + /// every exit from attached mode and on reconnect, since task ids are + /// daemon-local. + return_to: Option, /// Whether the host terminal window has focus. While unfocused, highlighted /// rows use a bright-black background. pub terminal_focused: bool, @@ -394,6 +420,7 @@ impl App { self.watched = None; self.selected_id = None; self.pending_select = None; + self.return_to = None; self.mode = Mode::Dashboard; } @@ -439,6 +466,7 @@ impl App { spawn_cwd: invocation_dir.clone(), spawn_group: None, focused_id: None, + return_to: None, terminal_focused: true, rows, cols, @@ -849,13 +877,7 @@ impl App { self.set_watch(watch); self.sync(); - // Replace an unreachable daemon's snapshot with the reconnect banner. - if self.mode != Mode::Disconnected && !self.transport.connected() { - self.mode = Mode::Disconnected; - self.focused_id = None; - self.status = None; - self.selection = None; - } + self.check_connection(); if self.term_signal.load(Ordering::Relaxed) { // Detach on a terminating signal; leave tasks running under the daemon. @@ -866,13 +888,7 @@ impl App { break; } self.resolve_selection(); - // If the attached task is gone, fall back to the dashboard rather - // than pointing `focused_id` at nothing. - if self.mode == Mode::Attached && self.focused_task().is_none() { - self.mode = Mode::Dashboard; - self.focused_id = None; - self.selection = None; - } + self.check_attached_task(); // Flush clipboard output and synchronize terminal input modes. self.flush_clipboard(out)?; @@ -919,6 +935,28 @@ impl App { Ok(()) } + /// Replace an unreachable daemon's snapshot with the reconnect banner. + fn check_connection(&mut self) { + if self.mode != Mode::Disconnected && !self.transport.connected() { + self.mode = Mode::Disconnected; + self.focused_id = None; + self.return_to = None; + self.status = None; + self.selection = None; + } + } + + /// If the attached task is gone, fall back to the dashboard rather than + /// pointing `focused_id` at nothing. + fn check_attached_task(&mut self) { + if self.mode == Mode::Attached && self.focused_task().is_none() { + self.mode = Mode::Dashboard; + self.focused_id = None; + self.return_to = None; + self.selection = None; + } + } + fn on_resize(&mut self, rows: u16, cols: u16) { // A terminal resize invalidates the selected pane coordinates. self.selection = None; @@ -1170,6 +1208,16 @@ impl App { self.should_quit = true; return; } + // `Ctrl-]` is reserved from children, like `Ctrl-\`. Dispatch it ahead of + // the mode handlers: in scrollback, `on_key_attached`'s catch-all would + // leave scrollback and forward it. Other modes drop it, since jumping + // out of a prompt would discard its buffer. + if is_chord_key(k) { + if matches!(self.mode, Mode::Dashboard | Mode::Peek | Mode::Attached) { + self.on_chord(out); + } + return; + } match self.mode { Mode::Dashboard => self.on_key_dashboard(k), Mode::Spawn => self.on_key_spawn(k), @@ -1229,6 +1277,17 @@ impl App { } } KeyCode::Char('M') => self.select_next_tagged(), + // `]` toggles the flagship mark. The daemon unmarks a finished task + // on its next tick, so marking one would be a silent no-op anyway. + KeyCode::Char(']') => { + if let Some(i) = self.selected_task() + && !matches!(self.views[i].lifecycle, Lifecycle::Ok | Lifecycle::Failed) + { + let v = &self.views[i]; + let id = (!v.flagship).then_some(v.id); + self.transport.send(Command::Flagship { id }); + } + } KeyCode::Char('g') => self.open_group_picker(), KeyCode::Char('/') => self.open_find_palette(), // Uppercase R renames; lowercase r reruns. @@ -1478,13 +1537,7 @@ impl App { let detach = k.modifiers.contains(KeyModifiers::CONTROL) && matches!(k.code, KeyCode::Char('\\') | KeyCode::Char('4')); if detach { - self.mode = Mode::Dashboard; - self.focused_id = None; - // Reset the task viewport on watch change. - self.view_scroll = false; - self.selection = None; - // Repaint from scratch next tick; wipe the child's screen now. - let _ = execute!(out, Clear(ClearType::All), MoveTo(0, 0)); + self.detach(out); return; } // Keep one row of overlap between pages. @@ -1743,14 +1796,76 @@ impl App { Ok(()) } + /// Attach to the selected task from the dashboard or Peek. fn attach(&mut self) { if let Some(i) = self.selected_task() { - // All tasks already run at the client's content size, so there's no - // resize to do: just take focus. The screen arrives via `Watch`, - // sent from the run loop next tick. - self.focused_id = Some(self.views[i].id); - self.mode = Mode::Attached; - self.view_scroll = false; + self.attach_to(self.views[i].id, Origin::Dashboard); + } + } + + /// Attach to task `id`. Every attach funnels through here, so attaching to + /// the flagship always records `origin` and attaching elsewhere drops it: + /// attached-to-flagship implies `return_to` is `Some`. + fn attach_to(&mut self, id: u64, origin: Origin) { + // All tasks already run at the client's content size, so there's no + // resize to do: just take focus. The screen arrives via `Watch`, sent + // from the run loop next tick. + let flagship = self.task_index(id).is_some_and(|i| self.views[i].flagship); + self.focused_id = Some(id); + self.mode = Mode::Attached; + self.view_scroll = false; + self.return_to = flagship.then_some(origin); + } + + /// Background the attached task and return to the dashboard. The + /// selection is untouched, so the list reopens on the same row. + fn detach(&mut self, out: &mut Stdout) { + self.mode = Mode::Dashboard; + self.focused_id = None; + self.return_to = None; + // Reset the task viewport on watch change. + self.view_scroll = false; + self.selection = None; + // Repaint from scratch next tick; wipe the child's screen now. + let _ = execute!(out, Clear(ClearType::All), MoveTo(0, 0)); + } + + /// Where `Ctrl-]` goes next, first match wins: + /// 1. A recorded origin: back to that task while it is still in `views` + /// (finished or not), else back to the dashboard. + /// 2. A marked flagship: jump to it. + /// 3. Nothing. + /// + /// Arm 1 needs no "already on the flagship" guard: `attach_to` records an + /// origin whenever the target is the flagship. + pub fn chord_target(&self) -> Option { + if let Some(origin) = self.return_to { + return Some(match origin { + Origin::Task(id) if self.task_index(id).is_some() => Target::Task(id), + _ => Target::Dashboard, + }); + } + self.views + .iter() + .find(|v| v.flagship) + .map(|v| Target::Flagship(v.id)) + } + + /// Follow `Ctrl-]` to `chord_target`. A jump from the dashboard or Peek + /// records the dashboard as its origin (the jump closes Peek); a jump from + /// an attached task records that task. Returning to a task is an attach + /// to a non-flagship, which clears `return_to`. + fn on_chord(&mut self, out: &mut Stdout) { + let Some(target) = self.chord_target() else { + return; + }; + let here = match (self.mode, self.focused_id) { + (Mode::Attached, Some(id)) => Origin::Task(id), + _ => Origin::Dashboard, + }; + match target { + Target::Dashboard => self.detach(out), + Target::Task(id) | Target::Flagship(id) => self.attach_to(id, here), } } @@ -1835,6 +1950,13 @@ fn key_event_to_key(ev: KeyEvent) -> Option<(Key, Mods)> { Some((code, mods)) } +/// Recognize `Ctrl-]`. Kitty reports the real key; crossterm reports legacy +/// 0x1D as Ctrl-5, since it maps 0x1C..=0x1F to `'4'..='7'`. +fn is_chord_key(k: KeyEvent) -> bool { + k.modifiers.contains(KeyModifiers::CONTROL) + && matches!(k.code, KeyCode::Char(']') | KeyCode::Char('5')) +} + /// Recognize either Shift-`/` event: `?`, or `/` with the Shift modifier. /// An unmodified `/` remains available to the find palette. fn is_controls_key(k: KeyEvent) -> bool { diff --git a/src/app_tests.rs b/src/app_tests.rs index aa08860..8a4eaba 100644 --- a/src/app_tests.rs +++ b/src/app_tests.rs @@ -3968,5 +3968,479 @@ fn input_and_attached_echo_bypass_the_repaint_floor() { assert_eq!(wait_for_paint(false, Duration::ZERO), PAINT_MIN); } +// --- flagship ---------------------------------------------------------------- + +/// A transport whose core is gone: sends vanish and `connected` reports false. +struct Unplugged; + +impl Transport for Unplugged { + fn send(&mut self, _cmd: Command) {} + + fn poll(&mut self) -> Vec { + Vec::new() + } + + fn connected(&self) -> bool { + false + } + + fn shutdown(&mut self, _intent: ExitIntent) {} +} + +/// Pass commands through to a real core and log each one. +struct Recording { + inner: Box, + sent: Arc>>, +} + +impl Transport for Recording { + fn send(&mut self, cmd: Command) { + self.sent.lock().unwrap().push(cmd.clone()); + self.inner.send(cmd); + } + + fn poll(&mut self) -> Vec { + self.inner.poll() + } + + fn connected(&self) -> bool { + self.inner.connected() + } + + fn shutdown(&mut self, intent: ExitIntent) { + self.inner.shutdown(intent); + } +} + +impl App { + /// Wrap the transport in `Recording` and return its log. + fn record_sends(&mut self) -> Arc>> { + let sent = Arc::default(); + let inner = std::mem::replace(&mut self.transport, Box::new(Unplugged)); + self.transport = Box::new(Recording { + inner, + sent: Arc::clone(&sent), + }); + sent + } + + /// Dispatch `k` through `on_key`, the run loop's entry point. + fn hit(&mut self, k: KeyEvent) { + self.on_key(&mut io::stdout(), k); + } + + /// The id the latest snapshot marks as the flagship. + fn flagship(&self) -> Option { + self.views.iter().find(|v| v.flagship).map(|v| v.id) + } + + /// Kill `id` and pump until its row is finished and unmarked. + fn finish(&mut self, id: u64) { + self.transport.send(Command::Kill { id }); + assert!( + wait_until(Duration::from_secs(5), || { + self.pump(); + self.views.iter().any(|v| { + v.id == id + && !v.flagship + && matches!(v.lifecycle, Lifecycle::Ok | Lifecycle::Failed) + }) + }), + "task {id} never finished" + ); + } +} + +/// The chord in its kitty encoding. +fn chord() -> KeyEvent { + ctrl(KeyCode::Char(']')) +} + +/// Two live tasks, A and Q, with Q marked through the daemon by `]` and the +/// selection back on A. +fn flagship_pair() -> (App, u64, u64) { + let mut app = App::new_local(30, 100); + let dir = app.invocation_dir.clone(); + app.spawn_in("sleep 30", dir.clone()); + app.spawn_in("sleep 30", dir); + let (a, q) = (app.views[0].id, app.views[1].id); + app.selected_id = Some(q); + app.hit(key(KeyCode::Char(']'))); + app.pump(); + assert_eq!(app.flagship(), Some(q), "premise: Q is marked"); + app.selected_id = Some(a); + (app, a, q) +} + +/// `]` marks the selected task, displacing a prior mark. +#[test] +fn bracket_marks_selected() { + let (mut app, a, _) = flagship_pair(); + app.hit(key(KeyCode::Char(']'))); + app.pump(); + assert_eq!(app.flagship(), Some(a), "the mark moves to A"); + assert!(app.mode == Mode::Dashboard); +} + +/// `]` on the flagship clears the mark. +#[test] +fn bracket_on_flagship_clears() { + let (mut app, _, q) = flagship_pair(); + app.selected_id = Some(q); + let sent = app.record_sends(); + app.hit(key(KeyCode::Char(']'))); + app.pump(); + assert_eq!(app.flagship(), None); + assert_eq!( + *sent.lock().unwrap(), + vec![Command::Flagship { id: None }], + "a clear names no id" + ); +} + +/// `]` on a finished row sends nothing: the mark stays where it was. +#[test] +fn bracket_on_finished_row_noops() { + let (mut app, a, q) = flagship_pair(); + app.finish(a); + let sent = app.record_sends(); + app.hit(key(KeyCode::Char(']'))); + app.pump(); + assert!(sent.lock().unwrap().is_empty(), "no command for a dead row"); + assert_eq!(app.flagship(), Some(q)); +} + +/// `]` with nothing selected sends nothing. +#[test] +fn bracket_without_selection_noops() { + let mut app = App::new_local(30, 100); + let sent = app.record_sends(); + app.hit(key(KeyCode::Char(']'))); + assert!(sent.lock().unwrap().is_empty()); + assert!(app.mode == Mode::Dashboard); +} + +/// `]` is a dashboard key only: Peek ignores it. +#[test] +fn bracket_in_peek_noops() { + let (mut app, _, q) = flagship_pair(); + app.hit(key(KeyCode::Char(' '))); + assert!(app.mode == Mode::Peek, "premise: peeking at A"); + let sent = app.record_sends(); + app.hit(key(KeyCode::Char(']'))); + app.pump(); + assert!(sent.lock().unwrap().is_empty()); + assert_eq!(app.flagship(), Some(q)); + assert!(app.mode == Mode::Peek); +} + +/// Without a mark the chord goes nowhere, from the dashboard or attached, +/// and is not forwarded to the child. +#[test] +fn ctrl_bracket_noops_without_flagship() { + let (mut app, a) = App::attached(30, 100, "sleep 30"); + assert_eq!(app.chord_target(), None); + let sent = app.record_sends(); + + app.hit(chord()); + assert!(app.mode == Mode::Attached); + assert_eq!(app.focused_id, Some(a)); + + app.hit(ctrl(KeyCode::Char('\\'))); + app.hit(chord()); + assert!(app.mode == Mode::Dashboard); + assert_eq!(app.focused_id, None); + assert!(sent.lock().unwrap().is_empty(), "nothing reaches the core"); +} + +/// Dashboard → flagship → dashboard lands on the same row, through the +/// detach body. +#[test] +fn jump_from_dashboard_and_back() { + let (mut app, a, q) = flagship_pair(); + let row = app.selected_row(&app.list_rows()); + assert_eq!(app.chord_target(), Some(Target::Flagship(q))); + + app.hit(chord()); + assert!(app.mode == Mode::Attached); + assert_eq!(app.focused_id, Some(q)); + assert_eq!(app.return_to, Some(Origin::Dashboard)); + assert_eq!(app.selected_id, Some(a), "the jump leaves selection alone"); + assert_eq!(app.chord_target(), Some(Target::Dashboard)); + + app.hit(chord()); + assert!(app.mode == Mode::Dashboard); + assert_eq!(app.focused_id, None); + assert!(!app.view_scroll); + assert_eq!(app.return_to, None); + assert_eq!(app.selected_id, Some(a)); + assert_eq!( + app.selected_row(&app.list_rows()), + row, + "same row, same window" + ); +} + +/// Task → flagship → task, starting in scrollback: the chord is handled, not +/// forwarded, and both ends come up live. +#[test] +fn jump_from_task_and_back() { + let (mut app, a, q) = flagship_pair(); + app.hit(key(KeyCode::Enter)); + assert_eq!(app.focused_id, Some(a)); + assert_eq!(app.return_to, None, "A is not the flagship"); + app.hit(shift(KeyCode::PageUp)); + assert!(app.view_scroll, "premise: scrollback on A"); + let sent = app.record_sends(); + + app.hit(chord()); + assert_eq!( + app.focused_id, + Some(q), + "the chord jumped, not just left scrollback" + ); + assert!(!app.view_scroll); + assert_eq!(app.return_to, Some(Origin::Task(a))); + assert_eq!(app.chord_target(), Some(Target::Task(a))); + + app.hit(chord()); + assert!(app.mode == Mode::Attached); + assert_eq!(app.focused_id, Some(a)); + assert!(!app.view_scroll); + assert_eq!(app.return_to, None); + assert!( + !sent + .lock() + .unwrap() + .iter() + .any(|c| matches!(c, Command::Key { .. })), + "no keystroke reaches a child" + ); +} + +/// A jump from Peek closes it; the return lands on the dashboard. +#[test] +fn jump_from_peek_lands_on_dashboard() { + let (mut app, a, q) = flagship_pair(); + app.hit(key(KeyCode::Char(' '))); + assert!(app.mode == Mode::Peek); + + app.hit(chord()); + assert!(app.mode == Mode::Attached); + assert_eq!(app.focused_id, Some(q)); + assert_eq!(app.return_to, Some(Origin::Dashboard)); + + app.hit(chord()); + assert!( + app.mode == Mode::Dashboard, + "back to the fleet view, not Peek" + ); + assert_eq!(app.selected_id, Some(a)); +} + +/// `Enter` onto the flagship records the dashboard as the way back, from the +/// dashboard and from Peek. +#[test] +fn enter_onto_flagship_sets_dashboard_return() { + let (mut app, _, q) = flagship_pair(); + app.selected_id = Some(q); + app.hit(key(KeyCode::Enter)); + assert_eq!(app.focused_id, Some(q)); + assert_eq!(app.return_to, Some(Origin::Dashboard)); + + app.hit(ctrl(KeyCode::Char('\\'))); + app.hit(key(KeyCode::Char(' '))); + app.hit(key(KeyCode::Enter)); + assert_eq!(app.focused_id, Some(q)); + assert_eq!(app.return_to, Some(Origin::Dashboard)); +} + +/// A finished origin is still in `views`, so the chord returns to it. +#[test] +fn return_to_finished_origin() { + let (mut app, a, q) = flagship_pair(); + app.hit(key(KeyCode::Enter)); + app.hit(chord()); + assert_eq!(app.focused_id, Some(q)); + app.finish(a); + + assert_eq!(app.chord_target(), Some(Target::Task(a))); + app.hit(chord()); + assert!(app.mode == Mode::Attached); + assert_eq!(app.focused_id, Some(a)); + assert_eq!(app.return_to, None); +} + +/// An origin that left `views` degrades the return to the dashboard. +#[test] +fn return_to_removed_origin_degrades_to_dashboard() { + let (mut app, a, q) = flagship_pair(); + app.hit(key(KeyCode::Enter)); + app.hit(chord()); + app.finish(a); + app.transport.send(Command::Remove { id: a }); + app.pump(); + assert!(app.task_index(a).is_none(), "premise: A is gone"); + + assert_eq!(app.chord_target(), Some(Target::Dashboard)); + app.hit(chord()); + assert!(app.mode == Mode::Dashboard); + assert_eq!(app.focused_id, None); + assert_eq!(app.return_to, None); + assert!(app.task_index(q).is_some()); +} + +/// The flagship dies while attached: the chord still returns, then no-ops +/// because the daemon unmarked it. +#[test] +fn dead_flagship_returns_then_noops() { + // Jumped from A. + let (mut app, a, q) = flagship_pair(); + app.hit(key(KeyCode::Enter)); + app.hit(chord()); + assert_eq!(app.focused_id, Some(q)); + app.finish(q); + assert_eq!(app.flagship(), None, "the daemon unmarks a dead flagship"); + app.hit(chord()); + assert_eq!(app.focused_id, Some(a)); + assert_eq!(app.chord_target(), None); + app.hit(chord()); + assert!(app.mode == Mode::Attached); + assert_eq!(app.focused_id, Some(a), "second press no-ops"); + + // Entered by `Enter`. + let (mut app, _, q) = flagship_pair(); + app.selected_id = Some(q); + app.hit(key(KeyCode::Enter)); + app.finish(q); + app.hit(chord()); + assert!(app.mode == Mode::Dashboard); + assert_eq!(app.chord_target(), None); + app.hit(chord()); + assert!(app.mode == Mode::Dashboard, "second press no-ops"); + assert_eq!(app.focused_id, None); +} + +/// `Ctrl-\` clears the way back. +#[test] +fn return_to_clears_on_detach() { + let (mut app, _, _) = flagship_pair(); + app.hit(chord()); + assert!(app.return_to.is_some()); + app.hit(ctrl(KeyCode::Char('\\'))); + assert!(app.mode == Mode::Dashboard); + assert_eq!(app.return_to, None); +} + +/// The run loop's fallback for a vanished attached task clears the way back. +#[test] +fn return_to_clears_on_vanish() { + let (mut app, a, q) = flagship_pair(); + app.hit(key(KeyCode::Enter)); + app.hit(chord()); + assert_eq!(app.return_to, Some(Origin::Task(a))); + app.views.retain(|v| v.id != q); + app.check_attached_task(); + assert!(app.mode == Mode::Dashboard); + assert_eq!(app.focused_id, None); + assert_eq!(app.return_to, None); +} + +/// Losing the daemon clears the way back, and so does the reconnect reset: +/// a replacement daemon may reuse the origin's id. +#[test] +fn return_to_clears_on_disconnect() { + let (mut app, _, _) = flagship_pair(); + app.hit(chord()); + assert!(app.return_to.is_some()); + app.transport = Box::new(Unplugged); + app.check_connection(); + assert!(app.mode == Mode::Disconnected); + assert_eq!(app.return_to, None); + + app.return_to = Some(Origin::Task(1)); + app.reset_for_reconnect(); + assert_eq!(app.return_to, None); +} + +/// Legacy terminals send 0x1D, which crossterm reports as Ctrl-5. +#[test] +fn ctrl_bracket_matches_legacy_ctrl_5() { + for k in [chord(), ctrl(KeyCode::Char('5'))] { + let (mut app, _, q) = flagship_pair(); + app.hit(k); + assert_eq!(app.focused_id, Some(q), "{k:?} must jump"); + app.hit(k); + assert!(app.mode == Mode::Dashboard, "{k:?} must return"); + } +} + +/// In scrollback the chord navigates; scrollback's catch-all never sees it. +#[test] +fn ctrl_bracket_in_scrollback_is_not_forwarded() { + let (mut app, a) = App::attached(30, 100, "sleep 30"); + app.hit(shift(KeyCode::PageUp)); + assert!(app.view_scroll, "premise: scrollback"); + let sent = app.record_sends(); + app.hit(chord()); + app.hit(ctrl(KeyCode::Char('5'))); + assert!(app.view_scroll, "a no-op chord leaves scrollback alone"); + assert_eq!(app.focused_id, Some(a)); + assert!( + !sent + .lock() + .unwrap() + .iter() + .any(|c| matches!(c, Command::Key { .. })), + "the chord is reserved from children" + ); +} + +/// Prompts, Controls, and Disconnected ignore both keys: a jump would +/// discard a half-typed buffer, and `]` there is text or nothing. +#[test] +fn flagship_keys_ignored_outside_navigation_modes() { + let (mut app, a, q) = flagship_pair(); + let modes = [ + Mode::Spawn, + Mode::PickDir, + Mode::PickGroup { target: a }, + Mode::Find, + Mode::SaveSession, + Mode::Rename(a), + Mode::LoadSession, + Mode::Controls, + Mode::Disconnected, + ]; + for mode in modes { + app.mode = mode; + for k in [key(KeyCode::Char(']')), chord(), ctrl(KeyCode::Char('5'))] { + app.hit(k); + app.pump(); + assert!(app.mode == mode, "{k:?} changed the mode"); + assert_eq!(app.focused_id, None, "{k:?} attached"); + assert_eq!(app.return_to, None); + assert_eq!(app.flagship(), Some(q), "{k:?} moved the mark"); + } + } +} + +/// `chord_target` arms in order: a recorded origin beats a marked flagship, +/// and a missing origin task degrades to the dashboard. +#[test] +fn chord_target_arms() { + let (mut app, a, q) = flagship_pair(); + assert_eq!(app.chord_target(), Some(Target::Flagship(q))); + app.return_to = Some(Origin::Dashboard); + assert_eq!(app.chord_target(), Some(Target::Dashboard)); + app.return_to = Some(Origin::Task(a)); + assert_eq!(app.chord_target(), Some(Target::Task(a))); + app.return_to = Some(Origin::Task(9999)); + assert_eq!(app.chord_target(), Some(Target::Dashboard)); + app.return_to = None; + app.views.iter_mut().for_each(|v| v.flagship = false); + assert_eq!(app.chord_target(), None); +} + #[path = "app_readme_tests.rs"] mod readme; From 6f61d99befd4956f0a760be4611afc504a52d471 Mon Sep 17 00:00:00 2001 From: Christopher Sardegna Date: Fri, 25 Sep 2026 16:14:37 -0700 Subject: [PATCH 03/10] feat(ui): render the flagship mark and the Ctrl-] hints, and document them --- README.md | 1 + docs/commands.md | 22 +++- docs/img/controls.ansi | 2 +- src/app_tests.rs | 4 +- src/ui.rs | 260 +++++++++++++++++++++++++++++++++++++---- 5 files changed, 261 insertions(+), 28 deletions(-) diff --git a/README.md b/README.md index ced75a5..5e908d0 100644 --- a/README.md +++ b/README.md @@ -85,6 +85,7 @@ Use the two dashboard hints for common actions; press `?` for the expanded key r ### Attached - Press `Ctrl-\` to background the task and return to the dashboard. +- Press `Ctrl-]` to jump to the flagship task (marked with `]` on the dashboard), and again to go back. - Other supported input is forwarded to the task's PTY. See [`docs/commands.md`](docs/commands.md#dashboard) for every key and launch flag, including the routing mechanics. diff --git a/docs/commands.md b/docs/commands.md index e58bbd1..5fbf4c4 100644 --- a/docs/commands.md +++ b/docs/commands.md @@ -30,6 +30,8 @@ Choose the operating mode with launch arguments. Use keys to control the dashboa | `s` | Cycle grouping: by state / by directory / by custom group | | `m` | Tag the selected task "in use" (toggles) | | `M` | Select the next tagged task in dashboard order, wrapping at the end | +| `]` | Mark the selected task as the [flagship](#the-flagship) (toggles; one at a time) | +| `Ctrl-]` | Attach to the flagship | | `g` | Assign the selected task to a group (through the group picker) | | `R` | Rename the selected task: a display name shown in place of the command | | `r` | Rerun a finished task; use the captured resume command for supported agent tasks | @@ -51,6 +53,7 @@ Choose the operating mode with launch arguments. Use keys to control the dashboa | `✓` | Completed, exit 0 | | `✗` | Completed, non-zero exit | | `◆` | Tagged "in use" | +| `⚑` | The [flagship](#the-flagship), in the row's first column | After 10 seconds without output, a task is marked `∙` and grouped under Idle. @@ -68,6 +71,8 @@ Cursor keys are encoded according to the child's live cursor-key mode. In applic `Ctrl-\` is the physical chord. The same chord may be reported by Crossterm as `Ctrl-4`; both representations are accepted. +Press `Ctrl-]` to jump to the [flagship](#the-flagship), then again to go back to where the jump started. Like `Ctrl-\`, it is reserved from children, so an attached `vim` loses tag-jump and readline loses character search. Crossterm may report it as `Ctrl-5`; both are accepted. + #### Modified keys, paste, and mouse input These inputs cannot all be forwarded byte-for-byte. Their encoding and destination depend on the terminal and the attached child's modes. @@ -134,6 +139,19 @@ Press `M` to select the next tagged task in dashboard order, wrapping after the In custom mode only, a new command is assigned the selected task's group, through both `n` and the `@` picker. The destination is shown in the spawn prompt 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. New tasks are unassigned in state and dir modes. +#### The flagship + +Press `]` to mark the selected task as the flagship, marked `⚑`; press it again to clear the mark. At most one task is the flagship, so marking another moves the mark. `]` on a finished task does nothing. The mark does not affect sorting. + +Press `Ctrl-]` from the dashboard, peek, or an attached task to attach to the flagship, and again to go back: + +- From an attached task: back to that task, even if it has finished since. If it was removed, back to the dashboard. +- From the dashboard or peek: back to the dashboard, with the selection on the same row. Peek is not reopened. + +Attaching to the flagship with `Enter` also sets the way back to the dashboard. + +The mark clears when the flagship exits or is removed, and sessions do not save it. `m` tags, by contrast, survive exit. + #### Renaming Press `R` to rename the selected task, starting with its current name. Press `Enter` to save, or `Esc` to cancel. Leave the field empty to clear the name and display the command again. Use lowercase `r` to rerun. @@ -188,7 +206,7 @@ A recovery row is formatted as ` ago · task(s) ·