From b77b687ab9d2b6b560edc9a035d007c24092c6bf Mon Sep 17 00:00:00 2001 From: Christopher Sardegna Date: Tue, 21 Jul 2026 19:56:18 -0700 Subject: [PATCH 1/8] refactor(protocol): externalize tests, generalize bounded-int decode, collapse Watch id-encode --- src/protocol.rs | 890 +----------------------------------------- src/protocol_tests.rs | 849 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 867 insertions(+), 872 deletions(-) create mode 100644 src/protocol_tests.rs diff --git a/src/protocol.rs b/src/protocol.rs index 5ee19d0..4e0b7c5 100644 --- a/src/protocol.rs +++ b/src/protocol.rs @@ -368,9 +368,10 @@ fn path_from_b64(v: &jzon::JsonValue) -> Option { Some(PathBuf::from(os_from_b64(v)?)) } -/// Decode a JSON number as a `u16`, rejecting out-of-range values. -fn u16_from(v: &jzon::JsonValue) -> Option { - u16::try_from(v.as_u64()?).ok() +/// Decode a JSON number as a bounded integer `T`, rejecting negative and +/// out-of-range values: `as_u64` fails a negative, `T::try_from` an overflow. +fn num_from>(v: &jzon::JsonValue) -> Option { + T::try_from(v.as_u64()?).ok() } /// Decode an optional boolean flag. Missing and null values mean `false`; any @@ -438,7 +439,7 @@ fn recovery_vec(v: &jzon::JsonValue) -> Vec { Some(RecoveryEntry { stem: m["stem"].as_str()?.to_string(), label: m["label"].as_str()?.to_string(), - tasks: u32::try_from(m["tasks"].as_u64()?).ok()?, + tasks: num_from(&m["tasks"])?, age_secs: m["age"].as_u64()?, }) }; @@ -577,14 +578,8 @@ pub fn encode_command(cmd: &Command) -> (u8, Vec) { } Command::Watch { id, attached } => { let _ = o.insert("t", "watch"); - match id { - Some(n) => { - let _ = o.insert("id", *n); - } - None => { - let _ = o.insert("id", jzon::JsonValue::Null); - } - } + // `From>` maps `None` to `Null`, keeping `"id":null`. + let _ = o.insert("id", *id); let _ = o.insert("attached", *attached); } // Encode both byte-carrying commands as base64. The paste-size bound in @@ -732,8 +727,8 @@ pub fn decode_command(kind: u8, payload: &[u8]) -> Option { name: opt_str(&v["n"])?, }, "resize" => Command::Resize { - rows: u16_from(&v["rows"])?, - cols: u16_from(&v["cols"])?, + rows: num_from(&v["rows"])?, + cols: num_from(&v["cols"])?, }, "watch" => Command::Watch { id: if v["id"].is_null() { @@ -771,8 +766,8 @@ pub fn decode_command(kind: u8, payload: &[u8]) -> Option { "r" => MouseKind::Release(btn()?), _ => return None, }, - col: u16_from(&v["col"])?, - row: u16_from(&v["row"])?, + col: num_from(&v["col"])?, + row: num_from(&v["row"])?, } } "key" => { @@ -786,7 +781,7 @@ pub fn decode_command(kind: u8, payload: &[u8]) -> Option { } Key::Char(c) } - "f" => Key::F(u8::try_from(v["n"].as_u64()?).ok()?), + "f" => Key::F(num_from(&v["n"])?), "up" => Key::Up, "dn" => Key::Down, "lt" => Key::Left, @@ -817,8 +812,8 @@ pub fn decode_command(kind: u8, payload: &[u8]) -> Option { "sb" => Command::Scrollback { id: v["id"].as_u64()?, action: match v["a"].as_str()? { - "u" => ScrollAction::Up(u16_from(&v["n"])?), - "d" => ScrollAction::Down(u16_from(&v["n"])?), + "u" => ScrollAction::Up(num_from(&v["n"])?), + "d" => ScrollAction::Down(num_from(&v["n"])?), "t" => ScrollAction::Top, "l" => ScrollAction::Live, _ => return None, @@ -1025,7 +1020,7 @@ pub fn decode_event(kind: u8, payload: &[u8]) -> Option { let header_bytes = payload.get(4..4 + hlen)?; let formatted = payload.get(4 + hlen..)?.to_vec(); let h = jzon::parse(std::str::from_utf8(header_bytes).ok()?).ok()?; - let cursor = (u16_from(&h["cursor"][0])?, u16_from(&h["cursor"][1])?); + let cursor = (num_from(&h["cursor"][0])?, num_from(&h["cursor"][1])?); let lines = str_vec(&h["lines"])?; Some(Event::Screen(ScreenView { id: h["id"].as_u64()?, @@ -1036,7 +1031,7 @@ pub fn decode_event(kind: u8, payload: &[u8]) -> Option { wants_mouse: h["mouse"].as_bool()?, alt_screen: h["alt"].as_bool()?, alt_scroll: h["ascr"].as_bool()?, - scrollback: usize::try_from(h["sb"].as_u64()?).ok()?, + scrollback: num_from(&h["sb"])?, })) } _ => None, @@ -1044,854 +1039,5 @@ pub fn decode_event(kind: u8, payload: &[u8]) -> Option { } #[cfg(test)] -mod tests { - use super::*; - - /// Every command survives encode→frame-payload→decode unchanged, including - /// the `Watch{None}` null, raw `Input` bytes (0 and 255), and the no-field - /// `Shutdown`. - #[test] - fn command_round_trips() { - let cases = [ - Command::Spawn { - command: "echo hi".into(), - cwd: PathBuf::from("/tmp"), - group: None, - }, - Command::Spawn { - // Exercise byte-preserving serialization of a non-UTF-8 path. - command: "ls".into(), - cwd: PathBuf::from(OsString::from_vec(b"/tmp/\xff\xfe dir".to_vec())), - group: None, - }, - Command::Spawn { - command: "make".into(), - cwd: PathBuf::from("/tmp"), - group: Some("build".into()), - }, - Command::Kill { id: 7 }, - Command::Remove { id: 3 }, - Command::Restart { id: 4 }, - Command::Tag { id: 2, on: true }, - Command::SetGroup { - id: 2, - group: Some("infra".into()), - }, - Command::SetGroup { id: 2, group: None }, - Command::SetName { - id: 2, - name: Some("api server".into()), - }, - Command::SetName { id: 2, name: None }, - Command::Resize { - rows: 30, - cols: 100, - }, - Command::Watch { - id: Some(5), - attached: true, - }, - Command::Watch { - id: Some(5), - attached: false, - }, - Command::Watch { - id: None, - attached: false, - }, - Command::Input { - id: 1, - bytes: vec![0, 27, 91, 255], - }, - Command::Paste { - // Non-UTF-8 and marker-shaped bytes must survive: the core, not - // the client, decides what the child receives. - id: 6, - bytes: b"line1\nline2\x1b[201~\xff".to_vec(), - }, - Command::Mouse { - id: 8, - kind: MouseKind::WheelDown, - col: 79, - row: 23, - }, - Command::Mouse { - id: 8, - kind: MouseKind::Press(MouseBtn::Left), - col: 0, - row: 0, - }, - Command::Mouse { - id: 8, - kind: MouseKind::Drag(MouseBtn::Middle), - col: 10, - row: 5, - }, - Command::Mouse { - id: 8, - kind: MouseKind::Release(MouseBtn::Right), - col: 10, - row: 5, - }, - Command::Key { - id: 9, - code: Key::Char('λ'), - mods: Mods::default(), - }, - Command::Key { - id: 9, - code: Key::F(7), - mods: Mods { - ctrl: true, - ..Mods::default() - }, - }, - Command::Key { - id: 9, - // A modified arrow carries all three bits through the wire. - code: Key::Left, - mods: Mods { - shift: true, - alt: true, - ctrl: true, - }, - }, - Command::Key { - id: 9, - code: Key::Enter, - mods: Mods::default(), - }, - Command::Scrollback { - id: 3, - action: ScrollAction::Up(23), - }, - Command::Scrollback { - id: 3, - action: ScrollAction::Down(1), - }, - Command::Scrollback { - id: 3, - action: ScrollAction::Top, - }, - Command::Scrollback { - id: 3, - action: ScrollAction::Live, - }, - Command::SaveSession { - name: "work".into(), - }, - Command::LoadSession { - name: "home".into(), - }, - Command::LoadRecovery { - stem: "20260714-093015-4242".into(), - }, - Command::ListSessions, - Command::Shutdown, - ]; - for c in cases { - let (k, p) = encode_command(&c); - assert_eq!(decode_command(k, &p).as_ref(), Some(&c), "round-trip {c:?}"); - } - } - - #[test] - fn hello_ok_round_trips() { - let ack = Event::HelloOk; - let (k, p) = encode_event(&ack); - assert_eq!(k, KIND_CONTROL); - assert_eq!(decode_event(k, &p), Some(ack)); - } - - /// Handshake environment entries and the cwd round-trip byte-for-byte, - /// including non-UTF-8 bytes in both. - #[test] - fn hello_round_trips() { - let ctx = LaunchContext { - env: vec![ - ("PATH".into(), "/usr/bin:/bin".into()), - ( - OsString::from_vec(b"BAD\xff\xfe".to_vec()), - OsString::from_vec(b"v\xff".to_vec()), - ), - ], - cwd: PathBuf::from(OsString::from_vec(b"/home/x\xff\xfe".to_vec())), - }; - let (k, p) = encode_hello(&ctx); - assert_eq!(k, KIND_HELLO); - assert_eq!(decode_hello(k, &p), Some((PROTOCOL_VERSION, ctx))); - - let empty = LaunchContext { - env: Vec::new(), - cwd: PathBuf::from("/"), - }; - let (k, p) = encode_hello(&empty); - assert_eq!(decode_hello(k, &p), Some((PROTOCOL_VERSION, empty))); - } - - /// A hello payload is valid only in a hello frame. - #[test] - fn hello_requires_its_own_frame_kind() { - let (_, p) = encode_hello(&LaunchContext { - env: Vec::new(), - cwd: PathBuf::from("/"), - }); - assert_eq!(decode_hello(KIND_CONTROL, &p), None); - assert_eq!(decode_command(KIND_CONTROL, &p), None); - } - - /// Malformed environment entries reject the entire hello frame. - #[test] - fn hello_with_malformed_env_is_rejected() { - for env in [ - r#"[["P@TH","L2Jpbg=="]]"#, // invalid base64 character - r#"[["QUFBQUE","L2Jpbg=="]]"#, // truncated: missing padding - r#"[["UEFUSA==","AAAA="]]"#, // bad padding length - r#"[[[80],[65]]]"#, // env pairs must contain base64 strings - r#"["PATH=/bin"]"#, // flat string pair - ] { - // Keep the cwd valid so each case isolates env validation. - let json = format!(r#"{{"v":4,"cwd":"Lw==","env":{env}}}"#); - assert_eq!( - decode_hello(KIND_HELLO, json.as_bytes()), - None, - "should reject env {env}" - ); - } - } - - /// A hello with a non-base64 cwd is rejected. - #[test] - fn hello_with_malformed_cwd_is_rejected() { - let json = r#"{"v":3,"cwd":"/home/user","env":[]}"#; - assert_eq!(decode_hello(KIND_HELLO, json.as_bytes()), None); - } - - /// Out-of-range numeric fields reject the whole command. - #[test] - fn out_of_range_numerics_are_rejected() { - for json in [ - r#"{"t":"resize","rows":65536,"cols":100}"#, - r#"{"t":"resize","rows":30,"cols":65536}"#, - r#"{"t":"mouse","id":1,"k":"wu","col":65536,"row":0}"#, - r#"{"t":"mouse","id":1,"k":"wu","col":0,"row":65536}"#, - r#"{"t":"sb","id":1,"a":"u","n":65536}"#, - r#"{"t":"sb","id":1,"a":"d","n":-1}"#, - ] { - assert_eq!( - decode_command(KIND_CONTROL, json.as_bytes()), - None, - "should reject {json}" - ); - } - } - - /// Invalid base64 and non-string byte or path fields reject the command. - #[test] - fn invalid_base64_is_rejected() { - for json in [ - r#"{"t":"input","id":1,"bytes":"!!!"}"#, - r#"{"t":"input","id":1,"bytes":[0,27]}"#, // bytes must be a base64 string - r#"{"t":"paste","id":1,"bytes":"AAAA="}"#, // bad padding length - r#"{"t":"spawn","command":"ls","cwd":"/tmp/x"}"#, // plain path - ] { - assert_eq!( - decode_command(KIND_CONTROL, json.as_bytes()), - None, - "should reject {json}" - ); - } - } - - /// Build a `KIND_SCREEN` payload (`[u32 header_len][header]`, empty tail) - /// from a raw header string, for malformed-header tests. - fn screen_payload(header: &str) -> Vec { - let mut p = Vec::with_capacity(4 + header.len()); - p.extend_from_slice(&(header.len() as u32).to_be_bytes()); - p.extend_from_slice(header.as_bytes()); - p - } - - /// The neutral task view the exact-wire-string assertions pin: every - /// optional key absent, every flag false, an empty unfrozen floor preview. - fn tv(id: u64) -> TaskView { - TaskView { - id, - command: "x".into(), - cwd: PathBuf::from("/"), - tagged: false, - group: None, - name: None, - lifecycle: Lifecycle::Ok, - parked: false, - preview: Preview::floor(String::new()), - started_ago: Duration::from_millis(0), - quiet_ago: None, - finished_ago: None, - } - } - - /// A mistyped member in `lines`, `tasks`, or `names` rejects the whole - /// event, keeping decoded rows aligned with their encoded positions. - #[test] - fn mistyped_event_members_are_rejected() { - for header in [ - // Numeric member in `lines`. - r#"{"id":1,"cursor":[0,0],"hide":false,"mouse":false,"alt":false,"ascr":false,"sb":0,"lines":["ok",5]}"#, - // Out-of-range cursor cell. - r#"{"id":1,"cursor":[65536,0],"hide":false,"mouse":false,"alt":false,"ascr":false,"sb":0,"lines":[]}"#, - ] { - assert_eq!( - decode_event(KIND_SCREEN, &screen_payload(header)), - None, - "should reject header {header}" - ); - } - 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":"","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":"","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":"","started_ms":0,"name":5}]}"#, - r#"{"t":"tasks","tasks":["flat"]}"#, - // Numeric member in `names`. - r#"{"t":"sessions","names":["ok",5]}"#, - ] { - assert_eq!( - decode_event(KIND_CONTROL, json.as_bytes()), - None, - "should reject {json}" - ); - } - } - - /// Screen events without the required alternate-scroll field are rejected. - #[test] - fn screen_header_without_alt_scroll_is_rejected() { - let header = - r#"{"id":1,"cursor":[0,0],"hide":false,"mouse":false,"alt":true,"sb":0,"lines":[]}"#; - assert_eq!(decode_event(KIND_SCREEN, &screen_payload(header)), None); - } - - #[test] - fn tasks_and_status_round_trip() { - let tasks = Event::Tasks(vec![ - TaskView { - id: 1, - command: "vim".into(), - cwd: PathBuf::from("/home/x"), - tagged: true, - group: Some("x".into()), - name: Some("editor".into()), - lifecycle: Lifecycle::Idle, - parked: false, - preview: Preview { - text: "~ line".into(), - source: PreviewSource::Title, - rule: None, - frozen: false, - }, - started_ago: Duration::from_millis(4200), - quiet_ago: Some(Duration::from_millis(700)), - finished_ago: None, - }, - TaskView { - command: "make".into(), - // Exercise byte-preserving task-path serialization. - cwd: PathBuf::from(OsString::from_vec(b"/srv/\xff\xfe".to_vec())), - lifecycle: Lifecycle::Active, - started_ago: Duration::from_millis(10), - ..tv(2) - }, - ]); - let (k, p) = encode_event(&tasks); - assert_eq!(k, KIND_CONTROL); - assert_eq!(decode_event(k, &p), Some(tasks)); - - let status = Event::Status("saved 'x'".into()); - let (k, p) = encode_event(&status); - assert_eq!(decode_event(k, &p), Some(status)); - } - - /// `SetGroup` emits `"g"` only for an assignment. A missing or null `"g"` - /// decodes as a clear. - #[test] - fn set_group_wire_form() { - let (k, p) = encode_command(&Command::SetGroup { - id: 3, - group: Some("infra".into()), - }); - assert_eq!(k, KIND_CONTROL); - assert_eq!( - std::str::from_utf8(&p).unwrap(), - r#"{"t":"group","id":3,"g":"infra"}"# - ); - let (_, p) = encode_command(&Command::SetGroup { id: 3, group: None }); - assert!(!String::from_utf8(p).unwrap().contains("\"g\"")); - // An explicit null clears, same as an omitted key. - assert_eq!( - decode_command(KIND_CONTROL, br#"{"t":"group","id":3,"g":null}"#), - Some(Command::SetGroup { id: 3, group: None }) - ); - // A present group must be a string. - assert_eq!( - decode_command(KIND_CONTROL, br#"{"t":"group","id":3,"g":5}"#), - None - ); - } - - /// `SetName` omits `"n"` when clearing; a missing or null `"n"` decodes as - /// a clear. - #[test] - fn set_name_wire_form() { - let (k, p) = encode_command(&Command::SetName { - id: 3, - name: Some("api".into()), - }); - assert_eq!(k, KIND_CONTROL); - assert_eq!( - std::str::from_utf8(&p).unwrap(), - r#"{"t":"name","id":3,"n":"api"}"# - ); - let (_, p) = encode_command(&Command::SetName { id: 3, name: None }); - assert!(!String::from_utf8(p).unwrap().contains("\"n\"")); - // An explicit null clears, same as an omitted key. - assert_eq!( - decode_command(KIND_CONTROL, br#"{"t":"name","id":3,"n":null}"#), - Some(Command::SetName { id: 3, name: None }) - ); - // A present name must be a string. - assert_eq!( - decode_command(KIND_CONTROL, br#"{"t":"name","id":3,"n":5}"#), - None - ); - } - - /// Task frames omit `"group"` when unassigned; an absent key decodes as - /// `None`. - #[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,"parked":false}]}"#; - 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:?}"), - } - // Encoding an unassigned task omits the group key. - 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":"","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:?}"), - } - } - - /// Task frames omit `"name"` when unnamed; an absent key decodes as - /// `None`. - #[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,"parked":false}]}"#; - 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:?}"), - } - // Encoding an unnamed task omits the name key. - 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":"","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:?}"), - } - } - - /// The age keys ride the optional-key idiom: a live parked view carries - /// `quiet_ms` and no `finished_ms`, a finished view the reverse, and - /// both round-trip. - #[test] - fn parked_and_age_fields_round_trip() { - let tasks = Event::Tasks(vec![ - TaskView { - command: "top".into(), - lifecycle: Lifecycle::Idle, - parked: true, - started_ago: Duration::from_millis(60_000), - quiet_ago: Some(Duration::from_millis(12_000)), - ..tv(1) - }, - TaskView { - command: "make".into(), - started_ago: Duration::from_millis(60_000), - finished_ago: Some(Duration::from_millis(3_000)), - ..tv(2) - }, - ]); - let (k, p) = encode_event(&tasks); - let s = std::str::from_utf8(&p).unwrap(); - assert_eq!(s.matches("\"quiet_ms\"").count(), 1, "frame was {s}"); - assert_eq!(s.matches("\"finished_ms\"").count(), 1, "frame was {s}"); - assert_eq!(decode_event(k, &p), Some(tasks)); - } - - /// A frame from a daemon predating `parked` still decodes: `parked` - /// falls back to the idle lifecycle and both ages read as unknown, so - /// skew degrades to the pre-`parked` signal instead of dropping the - /// frame. - #[test] - fn tasks_frame_without_parked_keys_decodes_with_defaults() { - let old = r#"{"t":"tasks","tasks":[{"id":1,"command":"x","cwd":"Lw==","tagged":false,"life":"idle","preview":"","started_ms":0},{"id":2,"command":"y","cwd":"Lw==","tagged":false,"life":"active","preview":"","started_ms":0}]}"#; - match decode_event(KIND_CONTROL, old.as_bytes()) { - Some(Event::Tasks(v)) => { - assert!(v[0].parked, "idle derives parked"); - assert!(!v[1].parked, "active derives not-parked"); - assert_eq!((v[0].quiet_ago, v[0].finished_ago), (None, None)); - assert_eq!((v[1].quiet_ago, v[1].finished_ago), (None, None)); - } - other => panic!("expected tasks event, got {other:?}"), - } - } - - /// Every preview source round-trips with its frozen flag, and `rule` - /// never crosses the wire: an encoded `Some` decodes as `None`. - #[test] - fn preview_source_and_frozen_round_trip() { - let base = TaskView { - lifecycle: Lifecycle::Active, - preview: Preview::floor("p".into()), - quiet_ago: Some(Duration::from_millis(1)), - ..tv(1) - }; - let tasks = Event::Tasks(vec![ - base.clone(), - TaskView { - id: 2, - preview: Preview { - source: PreviewSource::Marker, - ..base.preview.clone() - }, - ..base.clone() - }, - TaskView { - id: 3, - preview: Preview { - source: PreviewSource::Title, - frozen: true, - ..base.preview.clone() - }, - ..base.clone() - }, - TaskView { - id: 4, - preview: Preview { - source: PreviewSource::Anchor, - ..base.preview.clone() - }, - ..base.clone() - }, - ]); - let (k, p) = encode_event(&tasks); - assert_eq!(decode_event(k, &p), Some(tasks)); - - // Encoding omits the process-local matcher rule. - let ruled = Event::Tasks(vec![TaskView { - preview: Preview { - source: PreviewSource::Anchor, - rule: Some("claude-status"), - ..base.preview.clone() - }, - ..base.clone() - }]); - let (k, p) = encode_event(&ruled); - assert!( - !String::from_utf8(p.clone()) - .unwrap() - .contains("claude-status") - ); - match decode_event(k, &p) { - Some(Event::Tasks(v)) => { - assert_eq!(v[0].preview.source, PreviewSource::Anchor); - assert_eq!(v[0].preview.rule, None, "rule must stay daemon-side"); - } - other => panic!("expected tasks event, got {other:?}"), - } - } - - /// Missing preview metadata decodes to an unfrozen Floor source. - #[test] - fn tasks_frame_without_preview_keys_decodes_with_floor_defaults() { - let old = r#"{"t":"tasks","tasks":[{"id":1,"command":"x","cwd":"Lw==","tagged":false,"life":"active","preview":"p","started_ms":0,"parked":false}]}"#; - match decode_event(KIND_CONTROL, old.as_bytes()) { - Some(Event::Tasks(v)) => { - assert_eq!(v[0].preview.source, PreviewSource::Floor); - assert!(!v[0].preview.frozen); - assert_eq!(v[0].preview.rule, None); - } - other => panic!("expected tasks event, got {other:?}"), - } - // A present source must be a known label. - let bad = r#"{"t":"tasks","tasks":[{"id":1,"command":"x","cwd":"Lw==","tagged":false,"life":"active","preview":"p","src":"vibes","frozen":false,"started_ms":0,"parked":false}]}"#; - assert_eq!(decode_event(KIND_CONTROL, bad.as_bytes()), None); - } - - /// `Sessions` carries the picker's names verbatim: several names, an empty - /// list, and names with spaces and non-ASCII all round-trip. - #[test] - fn sessions_event_round_trips() { - for names in [ - vec!["alpha".to_string(), "beta".to_string(), "gamma".to_string()], - Vec::new(), - vec!["my session".to_string(), "café ☕".to_string()], - ] { - let ev = Event::Sessions { - names, - recovery: Vec::new(), - }; - let (k, p) = encode_event(&ev); - assert_eq!(k, KIND_CONTROL); - assert_eq!(decode_event(k, &p).as_ref(), Some(&ev), "round-trip {ev:?}"); - } - } - - /// Recovery entries round-trip with their exact wire fields. - #[test] - fn sessions_recovery_entries_round_trip_and_pin_the_wire_shape() { - let ev = Event::Sessions { - names: vec!["work".to_string()], - recovery: vec![ - RecoveryEntry { - stem: "20260715-070000-22".into(), - label: "autosaved 2026-07-15 07:00".into(), - tasks: 3, - age_secs: 42, - }, - RecoveryEntry { - stem: "20260714-093015-11".into(), - label: "autosaved 2026-07-14 09:30".into(), - tasks: 1, - age_secs: 90_000, - }, - ], - }; - let (k, p) = encode_event(&ev); - assert_eq!(k, KIND_CONTROL); - assert_eq!( - std::str::from_utf8(&p).unwrap(), - r#"{"t":"sessions","names":["work"],"recovery":[{"stem":"20260715-070000-22","label":"autosaved 2026-07-15 07:00","tasks":3,"age":42},{"stem":"20260714-093015-11","label":"autosaved 2026-07-14 09:30","tasks":1,"age":90000}]}"# - ); - assert_eq!(decode_event(k, &p), Some(ev)); - } - - /// A missing or non-array `recovery` value decodes as an empty list. - #[test] - fn sessions_frame_without_recovery_key_decodes_empty() { - for json in [ - r#"{"t":"sessions","names":["a"]}"#, - r#"{"t":"sessions","names":["a"],"recovery":null}"#, - r#"{"t":"sessions","names":["a"],"recovery":"junk"}"#, - ] { - assert_eq!( - decode_event(KIND_CONTROL, json.as_bytes()), - Some(Event::Sessions { - names: vec!["a".to_string()], - recovery: Vec::new(), - }), - "should tolerate {json}" - ); - } - } - - /// Malformed recovery members are skipped without dropping valid entries. - #[test] - fn malformed_recovery_members_drop_without_rejecting_the_event() { - let json = r#"{"t":"sessions","names":[],"recovery":[ - {"stem":5,"label":"x","tasks":1,"age":0}, - {"label":"x","tasks":1,"age":0}, - {"stem":"s1","tasks":1,"age":0}, - {"stem":"s2","label":7,"tasks":1,"age":0}, - {"stem":"s3","label":"x","age":0}, - {"stem":"s4","label":"x","tasks":4294967296,"age":0}, - {"stem":"s5","label":"x","tasks":-1,"age":0}, - {"stem":"s6","label":"x","tasks":1,"age":-3}, - {"stem":"s7","label":"x","tasks":1}, - "flat", - {"stem":"good","label":"autosaved","tasks":2,"age":7} - ]}"#; - assert_eq!( - decode_event(KIND_CONTROL, json.as_bytes()), - Some(Event::Sessions { - names: Vec::new(), - recovery: vec![RecoveryEntry { - stem: "good".into(), - label: "autosaved".into(), - tasks: 2, - age_secs: 7, - }], - }) - ); - } - - /// Watch frames require a Boolean `attached` field. - #[test] - fn watch_wire_form_requires_the_attached_flag() { - let (k, p) = encode_command(&Command::Watch { - id: Some(5), - attached: true, - }); - assert_eq!(k, KIND_CONTROL); - assert_eq!( - std::str::from_utf8(&p).unwrap(), - r#"{"t":"watch","id":5,"attached":true}"# - ); - let (_, p) = encode_command(&Command::Watch { - id: None, - attached: false, - }); - assert_eq!( - std::str::from_utf8(&p).unwrap(), - r#"{"t":"watch","id":null,"attached":false}"# - ); - for json in [ - r#"{"t":"watch","id":5}"#, // missing flag - r#"{"t":"watch","id":null}"#, // missing flag on unwatch - r#"{"t":"watch","id":5,"attached":null}"#, // null is not a kind - r#"{"t":"watch","id":5,"attached":1}"#, // flag must be a boolean - ] { - 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() { - let (k, p) = encode_command(&Command::LoadRecovery { - stem: "20260714-093015-4242".into(), - }); - assert_eq!(k, KIND_CONTROL); - assert_eq!( - std::str::from_utf8(&p).unwrap(), - r#"{"t":"recover","stem":"20260714-093015-4242"}"# - ); - for json in [ - r#"{"t":"recover"}"#, - r#"{"t":"recover","stem":5}"#, - r#"{"t":"recover","stem":null}"#, - ] { - assert_eq!( - decode_command(KIND_CONTROL, json.as_bytes()), - None, - "should reject {json}" - ); - } - } - - /// The `Screen` event keeps its formatted bytes intact through the raw tail, - /// including non-UTF-8 bytes (0xFF) an ANSI stream really contains. - #[test] - fn screen_round_trips_raw_bytes() { - let screen = Event::Screen(ScreenView { - id: 9, - lines: vec!["row0".into(), "row1".into()], - formatted: vec![0x1b, b'[', b'm', 0, 255, b'x'], - cursor: (3, 12), - hide_cursor: false, - wants_mouse: true, - alt_screen: false, - // The wire encodes this field independently of `alt_screen`. - alt_scroll: true, - scrollback: 42, - }); - let (k, p) = encode_event(&screen); - assert_eq!(k, KIND_SCREEN); - assert_eq!(decode_event(k, &p), Some(screen)); - } - - /// Clipboard events preserve their source, target, and text. - #[test] - fn clipboard_copy_round_trips() { - for ev in [ - Event::ClipboardCopy { - id: 1, - kind: ClipboardKind::Clipboard, - text: "hello".into(), - }, - Event::ClipboardCopy { - id: 1 << 40, - kind: ClipboardKind::Selection, - text: "sélection λ 🦀".into(), - }, - Event::ClipboardCopy { - id: 2, - kind: ClipboardKind::Primary, - text: "primary".into(), - }, - Event::ClipboardCopy { - id: 7, - kind: ClipboardKind::Clipboard, - // Exercise control characters and paste-marker-shaped text. - text: "line1\nline2\tcol\u{0}\u{1b}[201~end".into(), - }, - ] { - let (k, p) = encode_event(&ev); - assert_eq!(k, KIND_CONTROL); - assert_eq!(decode_event(k, &p), Some(ev)); - } - } - - /// Clipboard events encode every target with its OSC 52 selector. - #[test] - fn clipboard_copy_wire_form() { - for (kind, wire) in [ - ( - ClipboardKind::Clipboard, - r#"{"t":"clip","id":5,"k":"c","text":"aGk="}"#, - ), - ( - ClipboardKind::Primary, - r#"{"t":"clip","id":5,"k":"p","text":"aGk="}"#, - ), - ( - ClipboardKind::Selection, - r#"{"t":"clip","id":5,"k":"s","text":"aGk="}"#, - ), - ] { - let (k, p) = encode_event(&Event::ClipboardCopy { - id: 5, - kind, - text: "hi".into(), - }); - assert_eq!(k, KIND_CONTROL); - assert_eq!(std::str::from_utf8(&p).unwrap(), wire); - } - } - - /// Malformed clipboard event frames are rejected. - #[test] - fn malformed_clipboard_copy_is_rejected() { - for json in [ - r#"{"t":"clip","id":1,"text":"aGk="}"#, // missing kind - r#"{"t":"clip","id":1,"k":"c"}"#, // missing text - r#"{"t":"clip","k":"c","text":"aGk="}"#, // missing id - r#"{"t":"clip","id":"1","k":"c","text":"aGk="}"#, // id must be a number - r#"{"t":"clip","id":1,"k":"x","text":"aGk="}"#, // unknown kind string - r#"{"t":"clip","id":1,"k":"c","text":"!!!"}"#, // invalid base64 - r#"{"t":"clip","id":1,"k":"c","text":"/w=="}"#, // 0xFF: not UTF-8 - r#"{"t":"clip","id":1,"k":"c","text":["aGk="]}"#, // text must be a string - ] { - assert_eq!( - decode_event(KIND_CONTROL, json.as_bytes()), - None, - "should reject {json}" - ); - } - } -} +#[path = "protocol_tests.rs"] +mod tests; diff --git a/src/protocol_tests.rs b/src/protocol_tests.rs new file mode 100644 index 0000000..3e44214 --- /dev/null +++ b/src/protocol_tests.rs @@ -0,0 +1,849 @@ +use super::*; + +/// Every command survives encode→frame-payload→decode unchanged, including +/// the `Watch{None}` null, raw `Input` bytes (0 and 255), and the no-field +/// `Shutdown`. +#[test] +fn command_round_trips() { + let cases = [ + Command::Spawn { + command: "echo hi".into(), + cwd: PathBuf::from("/tmp"), + group: None, + }, + Command::Spawn { + // Exercise byte-preserving serialization of a non-UTF-8 path. + command: "ls".into(), + cwd: PathBuf::from(OsString::from_vec(b"/tmp/\xff\xfe dir".to_vec())), + group: None, + }, + Command::Spawn { + command: "make".into(), + cwd: PathBuf::from("/tmp"), + group: Some("build".into()), + }, + Command::Kill { id: 7 }, + Command::Remove { id: 3 }, + Command::Restart { id: 4 }, + Command::Tag { id: 2, on: true }, + Command::SetGroup { + id: 2, + group: Some("infra".into()), + }, + Command::SetGroup { id: 2, group: None }, + Command::SetName { + id: 2, + name: Some("api server".into()), + }, + Command::SetName { id: 2, name: None }, + Command::Resize { + rows: 30, + cols: 100, + }, + Command::Watch { + id: Some(5), + attached: true, + }, + Command::Watch { + id: Some(5), + attached: false, + }, + Command::Watch { + id: None, + attached: false, + }, + Command::Input { + id: 1, + bytes: vec![0, 27, 91, 255], + }, + Command::Paste { + // Non-UTF-8 and marker-shaped bytes must survive: the core, not + // the client, decides what the child receives. + id: 6, + bytes: b"line1\nline2\x1b[201~\xff".to_vec(), + }, + Command::Mouse { + id: 8, + kind: MouseKind::WheelDown, + col: 79, + row: 23, + }, + Command::Mouse { + id: 8, + kind: MouseKind::Press(MouseBtn::Left), + col: 0, + row: 0, + }, + Command::Mouse { + id: 8, + kind: MouseKind::Drag(MouseBtn::Middle), + col: 10, + row: 5, + }, + Command::Mouse { + id: 8, + kind: MouseKind::Release(MouseBtn::Right), + col: 10, + row: 5, + }, + Command::Key { + id: 9, + code: Key::Char('λ'), + mods: Mods::default(), + }, + Command::Key { + id: 9, + code: Key::F(7), + mods: Mods { + ctrl: true, + ..Mods::default() + }, + }, + Command::Key { + id: 9, + // A modified arrow carries all three bits through the wire. + code: Key::Left, + mods: Mods { + shift: true, + alt: true, + ctrl: true, + }, + }, + Command::Key { + id: 9, + code: Key::Enter, + mods: Mods::default(), + }, + Command::Scrollback { + id: 3, + action: ScrollAction::Up(23), + }, + Command::Scrollback { + id: 3, + action: ScrollAction::Down(1), + }, + Command::Scrollback { + id: 3, + action: ScrollAction::Top, + }, + Command::Scrollback { + id: 3, + action: ScrollAction::Live, + }, + Command::SaveSession { + name: "work".into(), + }, + Command::LoadSession { + name: "home".into(), + }, + Command::LoadRecovery { + stem: "20260714-093015-4242".into(), + }, + Command::ListSessions, + Command::Shutdown, + ]; + for c in cases { + let (k, p) = encode_command(&c); + assert_eq!(decode_command(k, &p).as_ref(), Some(&c), "round-trip {c:?}"); + } +} + +#[test] +fn hello_ok_round_trips() { + let ack = Event::HelloOk; + let (k, p) = encode_event(&ack); + assert_eq!(k, KIND_CONTROL); + assert_eq!(decode_event(k, &p), Some(ack)); +} + +/// Handshake environment entries and the cwd round-trip byte-for-byte, +/// including non-UTF-8 bytes in both. +#[test] +fn hello_round_trips() { + let ctx = LaunchContext { + env: vec![ + ("PATH".into(), "/usr/bin:/bin".into()), + ( + OsString::from_vec(b"BAD\xff\xfe".to_vec()), + OsString::from_vec(b"v\xff".to_vec()), + ), + ], + cwd: PathBuf::from(OsString::from_vec(b"/home/x\xff\xfe".to_vec())), + }; + let (k, p) = encode_hello(&ctx); + assert_eq!(k, KIND_HELLO); + assert_eq!(decode_hello(k, &p), Some((PROTOCOL_VERSION, ctx))); + + let empty = LaunchContext { + env: Vec::new(), + cwd: PathBuf::from("/"), + }; + let (k, p) = encode_hello(&empty); + assert_eq!(decode_hello(k, &p), Some((PROTOCOL_VERSION, empty))); +} + +/// A hello payload is valid only in a hello frame. +#[test] +fn hello_requires_its_own_frame_kind() { + let (_, p) = encode_hello(&LaunchContext { + env: Vec::new(), + cwd: PathBuf::from("/"), + }); + assert_eq!(decode_hello(KIND_CONTROL, &p), None); + assert_eq!(decode_command(KIND_CONTROL, &p), None); +} + +/// Malformed environment entries reject the entire hello frame. +#[test] +fn hello_with_malformed_env_is_rejected() { + for env in [ + r#"[["P@TH","L2Jpbg=="]]"#, // invalid base64 character + r#"[["QUFBQUE","L2Jpbg=="]]"#, // truncated: missing padding + r#"[["UEFUSA==","AAAA="]]"#, // bad padding length + r#"[[[80],[65]]]"#, // env pairs must contain base64 strings + r#"["PATH=/bin"]"#, // flat string pair + ] { + // Keep the cwd valid so each case isolates env validation. + let json = format!(r#"{{"v":4,"cwd":"Lw==","env":{env}}}"#); + assert_eq!( + decode_hello(KIND_HELLO, json.as_bytes()), + None, + "should reject env {env}" + ); + } +} + +/// A hello with a non-base64 cwd is rejected. +#[test] +fn hello_with_malformed_cwd_is_rejected() { + let json = r#"{"v":3,"cwd":"/home/user","env":[]}"#; + assert_eq!(decode_hello(KIND_HELLO, json.as_bytes()), None); +} + +/// Out-of-range numeric fields reject the whole command. +#[test] +fn out_of_range_numerics_are_rejected() { + for json in [ + r#"{"t":"resize","rows":65536,"cols":100}"#, + r#"{"t":"resize","rows":30,"cols":65536}"#, + r#"{"t":"mouse","id":1,"k":"wu","col":65536,"row":0}"#, + r#"{"t":"mouse","id":1,"k":"wu","col":0,"row":65536}"#, + r#"{"t":"sb","id":1,"a":"u","n":65536}"#, + r#"{"t":"sb","id":1,"a":"d","n":-1}"#, + ] { + assert_eq!( + decode_command(KIND_CONTROL, json.as_bytes()), + None, + "should reject {json}" + ); + } +} + +/// Invalid base64 and non-string byte or path fields reject the command. +#[test] +fn invalid_base64_is_rejected() { + for json in [ + r#"{"t":"input","id":1,"bytes":"!!!"}"#, + r#"{"t":"input","id":1,"bytes":[0,27]}"#, // bytes must be a base64 string + r#"{"t":"paste","id":1,"bytes":"AAAA="}"#, // bad padding length + r#"{"t":"spawn","command":"ls","cwd":"/tmp/x"}"#, // plain path + ] { + assert_eq!( + decode_command(KIND_CONTROL, json.as_bytes()), + None, + "should reject {json}" + ); + } +} + +/// Build a `KIND_SCREEN` payload (`[u32 header_len][header]`, empty tail) +/// from a raw header string, for malformed-header tests. +fn screen_payload(header: &str) -> Vec { + let mut p = Vec::with_capacity(4 + header.len()); + p.extend_from_slice(&(header.len() as u32).to_be_bytes()); + p.extend_from_slice(header.as_bytes()); + p +} + +/// The neutral task view the exact-wire-string assertions pin: every +/// optional key absent, every flag false, an empty unfrozen floor preview. +fn tv(id: u64) -> TaskView { + TaskView { + id, + command: "x".into(), + cwd: PathBuf::from("/"), + tagged: false, + group: None, + name: None, + lifecycle: Lifecycle::Ok, + parked: false, + preview: Preview::floor(String::new()), + started_ago: Duration::from_millis(0), + quiet_ago: None, + finished_ago: None, + } +} + +/// A mistyped member in `lines`, `tasks`, or `names` rejects the whole +/// event, keeping decoded rows aligned with their encoded positions. +#[test] +fn mistyped_event_members_are_rejected() { + for header in [ + // Numeric member in `lines`. + r#"{"id":1,"cursor":[0,0],"hide":false,"mouse":false,"alt":false,"ascr":false,"sb":0,"lines":["ok",5]}"#, + // Out-of-range cursor cell. + r#"{"id":1,"cursor":[65536,0],"hide":false,"mouse":false,"alt":false,"ascr":false,"sb":0,"lines":[]}"#, + ] { + assert_eq!( + decode_event(KIND_SCREEN, &screen_payload(header)), + None, + "should reject header {header}" + ); + } + 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":"","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":"","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":"","started_ms":0,"name":5}]}"#, + r#"{"t":"tasks","tasks":["flat"]}"#, + // Numeric member in `names`. + r#"{"t":"sessions","names":["ok",5]}"#, + ] { + assert_eq!( + decode_event(KIND_CONTROL, json.as_bytes()), + None, + "should reject {json}" + ); + } +} + +/// Screen events without the required alternate-scroll field are rejected. +#[test] +fn screen_header_without_alt_scroll_is_rejected() { + let header = + r#"{"id":1,"cursor":[0,0],"hide":false,"mouse":false,"alt":true,"sb":0,"lines":[]}"#; + assert_eq!(decode_event(KIND_SCREEN, &screen_payload(header)), None); +} + +#[test] +fn tasks_and_status_round_trip() { + let tasks = Event::Tasks(vec![ + TaskView { + id: 1, + command: "vim".into(), + cwd: PathBuf::from("/home/x"), + tagged: true, + group: Some("x".into()), + name: Some("editor".into()), + lifecycle: Lifecycle::Idle, + parked: false, + preview: Preview { + text: "~ line".into(), + source: PreviewSource::Title, + rule: None, + frozen: false, + }, + started_ago: Duration::from_millis(4200), + quiet_ago: Some(Duration::from_millis(700)), + finished_ago: None, + }, + TaskView { + command: "make".into(), + // Exercise byte-preserving task-path serialization. + cwd: PathBuf::from(OsString::from_vec(b"/srv/\xff\xfe".to_vec())), + lifecycle: Lifecycle::Active, + started_ago: Duration::from_millis(10), + ..tv(2) + }, + ]); + let (k, p) = encode_event(&tasks); + assert_eq!(k, KIND_CONTROL); + assert_eq!(decode_event(k, &p), Some(tasks)); + + let status = Event::Status("saved 'x'".into()); + let (k, p) = encode_event(&status); + assert_eq!(decode_event(k, &p), Some(status)); +} + +/// `SetGroup` emits `"g"` only for an assignment. A missing or null `"g"` +/// decodes as a clear. +#[test] +fn set_group_wire_form() { + let (k, p) = encode_command(&Command::SetGroup { + id: 3, + group: Some("infra".into()), + }); + assert_eq!(k, KIND_CONTROL); + assert_eq!( + std::str::from_utf8(&p).unwrap(), + r#"{"t":"group","id":3,"g":"infra"}"# + ); + let (_, p) = encode_command(&Command::SetGroup { id: 3, group: None }); + assert!(!String::from_utf8(p).unwrap().contains("\"g\"")); + // An explicit null clears, same as an omitted key. + assert_eq!( + decode_command(KIND_CONTROL, br#"{"t":"group","id":3,"g":null}"#), + Some(Command::SetGroup { id: 3, group: None }) + ); + // A present group must be a string. + assert_eq!( + decode_command(KIND_CONTROL, br#"{"t":"group","id":3,"g":5}"#), + None + ); +} + +/// `SetName` omits `"n"` when clearing; a missing or null `"n"` decodes as +/// a clear. +#[test] +fn set_name_wire_form() { + let (k, p) = encode_command(&Command::SetName { + id: 3, + name: Some("api".into()), + }); + assert_eq!(k, KIND_CONTROL); + assert_eq!( + std::str::from_utf8(&p).unwrap(), + r#"{"t":"name","id":3,"n":"api"}"# + ); + let (_, p) = encode_command(&Command::SetName { id: 3, name: None }); + assert!(!String::from_utf8(p).unwrap().contains("\"n\"")); + // An explicit null clears, same as an omitted key. + assert_eq!( + decode_command(KIND_CONTROL, br#"{"t":"name","id":3,"n":null}"#), + Some(Command::SetName { id: 3, name: None }) + ); + // A present name must be a string. + assert_eq!( + decode_command(KIND_CONTROL, br#"{"t":"name","id":3,"n":5}"#), + None + ); +} + +/// Task frames omit `"group"` when unassigned; an absent key decodes as +/// `None`. +#[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,"parked":false}]}"#; + 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:?}"), + } + // Encoding an unassigned task omits the group key. + 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":"","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:?}"), + } +} + +/// Task frames omit `"name"` when unnamed; an absent key decodes as +/// `None`. +#[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,"parked":false}]}"#; + 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:?}"), + } + // Encoding an unnamed task omits the name key. + 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":"","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:?}"), + } +} + +/// The age keys ride the optional-key idiom: a live parked view carries +/// `quiet_ms` and no `finished_ms`, a finished view the reverse, and +/// both round-trip. +#[test] +fn parked_and_age_fields_round_trip() { + let tasks = Event::Tasks(vec![ + TaskView { + command: "top".into(), + lifecycle: Lifecycle::Idle, + parked: true, + started_ago: Duration::from_millis(60_000), + quiet_ago: Some(Duration::from_millis(12_000)), + ..tv(1) + }, + TaskView { + command: "make".into(), + started_ago: Duration::from_millis(60_000), + finished_ago: Some(Duration::from_millis(3_000)), + ..tv(2) + }, + ]); + let (k, p) = encode_event(&tasks); + let s = std::str::from_utf8(&p).unwrap(); + assert_eq!(s.matches("\"quiet_ms\"").count(), 1, "frame was {s}"); + assert_eq!(s.matches("\"finished_ms\"").count(), 1, "frame was {s}"); + assert_eq!(decode_event(k, &p), Some(tasks)); +} + +/// A frame from a daemon predating `parked` still decodes: `parked` +/// falls back to the idle lifecycle and both ages read as unknown, so +/// skew degrades to the pre-`parked` signal instead of dropping the +/// frame. +#[test] +fn tasks_frame_without_parked_keys_decodes_with_defaults() { + let old = r#"{"t":"tasks","tasks":[{"id":1,"command":"x","cwd":"Lw==","tagged":false,"life":"idle","preview":"","started_ms":0},{"id":2,"command":"y","cwd":"Lw==","tagged":false,"life":"active","preview":"","started_ms":0}]}"#; + match decode_event(KIND_CONTROL, old.as_bytes()) { + Some(Event::Tasks(v)) => { + assert!(v[0].parked, "idle derives parked"); + assert!(!v[1].parked, "active derives not-parked"); + assert_eq!((v[0].quiet_ago, v[0].finished_ago), (None, None)); + assert_eq!((v[1].quiet_ago, v[1].finished_ago), (None, None)); + } + other => panic!("expected tasks event, got {other:?}"), + } +} + +/// Every preview source round-trips with its frozen flag, and `rule` +/// never crosses the wire: an encoded `Some` decodes as `None`. +#[test] +fn preview_source_and_frozen_round_trip() { + let base = TaskView { + lifecycle: Lifecycle::Active, + preview: Preview::floor("p".into()), + quiet_ago: Some(Duration::from_millis(1)), + ..tv(1) + }; + let tasks = Event::Tasks(vec![ + base.clone(), + TaskView { + id: 2, + preview: Preview { + source: PreviewSource::Marker, + ..base.preview.clone() + }, + ..base.clone() + }, + TaskView { + id: 3, + preview: Preview { + source: PreviewSource::Title, + frozen: true, + ..base.preview.clone() + }, + ..base.clone() + }, + TaskView { + id: 4, + preview: Preview { + source: PreviewSource::Anchor, + ..base.preview.clone() + }, + ..base.clone() + }, + ]); + let (k, p) = encode_event(&tasks); + assert_eq!(decode_event(k, &p), Some(tasks)); + + // Encoding omits the process-local matcher rule. + let ruled = Event::Tasks(vec![TaskView { + preview: Preview { + source: PreviewSource::Anchor, + rule: Some("claude-status"), + ..base.preview.clone() + }, + ..base.clone() + }]); + let (k, p) = encode_event(&ruled); + assert!( + !String::from_utf8(p.clone()) + .unwrap() + .contains("claude-status") + ); + match decode_event(k, &p) { + Some(Event::Tasks(v)) => { + assert_eq!(v[0].preview.source, PreviewSource::Anchor); + assert_eq!(v[0].preview.rule, None, "rule must stay daemon-side"); + } + other => panic!("expected tasks event, got {other:?}"), + } +} + +/// Missing preview metadata decodes to an unfrozen Floor source. +#[test] +fn tasks_frame_without_preview_keys_decodes_with_floor_defaults() { + let old = r#"{"t":"tasks","tasks":[{"id":1,"command":"x","cwd":"Lw==","tagged":false,"life":"active","preview":"p","started_ms":0,"parked":false}]}"#; + match decode_event(KIND_CONTROL, old.as_bytes()) { + Some(Event::Tasks(v)) => { + assert_eq!(v[0].preview.source, PreviewSource::Floor); + assert!(!v[0].preview.frozen); + assert_eq!(v[0].preview.rule, None); + } + other => panic!("expected tasks event, got {other:?}"), + } + // A present source must be a known label. + let bad = r#"{"t":"tasks","tasks":[{"id":1,"command":"x","cwd":"Lw==","tagged":false,"life":"active","preview":"p","src":"vibes","frozen":false,"started_ms":0,"parked":false}]}"#; + assert_eq!(decode_event(KIND_CONTROL, bad.as_bytes()), None); +} + +/// `Sessions` carries the picker's names verbatim: several names, an empty +/// list, and names with spaces and non-ASCII all round-trip. +#[test] +fn sessions_event_round_trips() { + for names in [ + vec!["alpha".to_string(), "beta".to_string(), "gamma".to_string()], + Vec::new(), + vec!["my session".to_string(), "café ☕".to_string()], + ] { + let ev = Event::Sessions { + names, + recovery: Vec::new(), + }; + let (k, p) = encode_event(&ev); + assert_eq!(k, KIND_CONTROL); + assert_eq!(decode_event(k, &p).as_ref(), Some(&ev), "round-trip {ev:?}"); + } +} + +/// Recovery entries round-trip with their exact wire fields. +#[test] +fn sessions_recovery_entries_round_trip_and_pin_the_wire_shape() { + let ev = Event::Sessions { + names: vec!["work".to_string()], + recovery: vec![ + RecoveryEntry { + stem: "20260715-070000-22".into(), + label: "autosaved 2026-07-15 07:00".into(), + tasks: 3, + age_secs: 42, + }, + RecoveryEntry { + stem: "20260714-093015-11".into(), + label: "autosaved 2026-07-14 09:30".into(), + tasks: 1, + age_secs: 90_000, + }, + ], + }; + let (k, p) = encode_event(&ev); + assert_eq!(k, KIND_CONTROL); + assert_eq!( + std::str::from_utf8(&p).unwrap(), + r#"{"t":"sessions","names":["work"],"recovery":[{"stem":"20260715-070000-22","label":"autosaved 2026-07-15 07:00","tasks":3,"age":42},{"stem":"20260714-093015-11","label":"autosaved 2026-07-14 09:30","tasks":1,"age":90000}]}"# + ); + assert_eq!(decode_event(k, &p), Some(ev)); +} + +/// A missing or non-array `recovery` value decodes as an empty list. +#[test] +fn sessions_frame_without_recovery_key_decodes_empty() { + for json in [ + r#"{"t":"sessions","names":["a"]}"#, + r#"{"t":"sessions","names":["a"],"recovery":null}"#, + r#"{"t":"sessions","names":["a"],"recovery":"junk"}"#, + ] { + assert_eq!( + decode_event(KIND_CONTROL, json.as_bytes()), + Some(Event::Sessions { + names: vec!["a".to_string()], + recovery: Vec::new(), + }), + "should tolerate {json}" + ); + } +} + +/// Malformed recovery members are skipped without dropping valid entries. +#[test] +fn malformed_recovery_members_drop_without_rejecting_the_event() { + let json = r#"{"t":"sessions","names":[],"recovery":[ + {"stem":5,"label":"x","tasks":1,"age":0}, + {"label":"x","tasks":1,"age":0}, + {"stem":"s1","tasks":1,"age":0}, + {"stem":"s2","label":7,"tasks":1,"age":0}, + {"stem":"s3","label":"x","age":0}, + {"stem":"s4","label":"x","tasks":4294967296,"age":0}, + {"stem":"s5","label":"x","tasks":-1,"age":0}, + {"stem":"s6","label":"x","tasks":1,"age":-3}, + {"stem":"s7","label":"x","tasks":1}, + "flat", + {"stem":"good","label":"autosaved","tasks":2,"age":7} + ]}"#; + assert_eq!( + decode_event(KIND_CONTROL, json.as_bytes()), + Some(Event::Sessions { + names: Vec::new(), + recovery: vec![RecoveryEntry { + stem: "good".into(), + label: "autosaved".into(), + tasks: 2, + age_secs: 7, + }], + }) + ); +} + +/// Watch frames require a Boolean `attached` field. +#[test] +fn watch_wire_form_requires_the_attached_flag() { + let (k, p) = encode_command(&Command::Watch { + id: Some(5), + attached: true, + }); + assert_eq!(k, KIND_CONTROL); + assert_eq!( + std::str::from_utf8(&p).unwrap(), + r#"{"t":"watch","id":5,"attached":true}"# + ); + let (_, p) = encode_command(&Command::Watch { + id: None, + attached: false, + }); + assert_eq!( + std::str::from_utf8(&p).unwrap(), + r#"{"t":"watch","id":null,"attached":false}"# + ); + for json in [ + r#"{"t":"watch","id":5}"#, // missing flag + r#"{"t":"watch","id":null}"#, // missing flag on unwatch + r#"{"t":"watch","id":5,"attached":null}"#, // null is not a kind + r#"{"t":"watch","id":5,"attached":1}"#, // flag must be a boolean + ] { + 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() { + let (k, p) = encode_command(&Command::LoadRecovery { + stem: "20260714-093015-4242".into(), + }); + assert_eq!(k, KIND_CONTROL); + assert_eq!( + std::str::from_utf8(&p).unwrap(), + r#"{"t":"recover","stem":"20260714-093015-4242"}"# + ); + for json in [ + r#"{"t":"recover"}"#, + r#"{"t":"recover","stem":5}"#, + r#"{"t":"recover","stem":null}"#, + ] { + assert_eq!( + decode_command(KIND_CONTROL, json.as_bytes()), + None, + "should reject {json}" + ); + } +} + +/// The `Screen` event keeps its formatted bytes intact through the raw tail, +/// including non-UTF-8 bytes (0xFF) an ANSI stream really contains. +#[test] +fn screen_round_trips_raw_bytes() { + let screen = Event::Screen(ScreenView { + id: 9, + lines: vec!["row0".into(), "row1".into()], + formatted: vec![0x1b, b'[', b'm', 0, 255, b'x'], + cursor: (3, 12), + hide_cursor: false, + wants_mouse: true, + alt_screen: false, + // The wire encodes this field independently of `alt_screen`. + alt_scroll: true, + scrollback: 42, + }); + let (k, p) = encode_event(&screen); + assert_eq!(k, KIND_SCREEN); + assert_eq!(decode_event(k, &p), Some(screen)); +} + +/// Clipboard events preserve their source, target, and text. +#[test] +fn clipboard_copy_round_trips() { + for ev in [ + Event::ClipboardCopy { + id: 1, + kind: ClipboardKind::Clipboard, + text: "hello".into(), + }, + Event::ClipboardCopy { + id: 1 << 40, + kind: ClipboardKind::Selection, + text: "sélection λ 🦀".into(), + }, + Event::ClipboardCopy { + id: 2, + kind: ClipboardKind::Primary, + text: "primary".into(), + }, + Event::ClipboardCopy { + id: 7, + kind: ClipboardKind::Clipboard, + // Exercise control characters and paste-marker-shaped text. + text: "line1\nline2\tcol\u{0}\u{1b}[201~end".into(), + }, + ] { + let (k, p) = encode_event(&ev); + assert_eq!(k, KIND_CONTROL); + assert_eq!(decode_event(k, &p), Some(ev)); + } +} + +/// Clipboard events encode every target with its OSC 52 selector. +#[test] +fn clipboard_copy_wire_form() { + for (kind, wire) in [ + ( + ClipboardKind::Clipboard, + r#"{"t":"clip","id":5,"k":"c","text":"aGk="}"#, + ), + ( + ClipboardKind::Primary, + r#"{"t":"clip","id":5,"k":"p","text":"aGk="}"#, + ), + ( + ClipboardKind::Selection, + r#"{"t":"clip","id":5,"k":"s","text":"aGk="}"#, + ), + ] { + let (k, p) = encode_event(&Event::ClipboardCopy { + id: 5, + kind, + text: "hi".into(), + }); + assert_eq!(k, KIND_CONTROL); + assert_eq!(std::str::from_utf8(&p).unwrap(), wire); + } +} + +/// Malformed clipboard event frames are rejected. +#[test] +fn malformed_clipboard_copy_is_rejected() { + for json in [ + r#"{"t":"clip","id":1,"text":"aGk="}"#, // missing kind + r#"{"t":"clip","id":1,"k":"c"}"#, // missing text + r#"{"t":"clip","k":"c","text":"aGk="}"#, // missing id + r#"{"t":"clip","id":"1","k":"c","text":"aGk="}"#, // id must be a number + r#"{"t":"clip","id":1,"k":"x","text":"aGk="}"#, // unknown kind string + r#"{"t":"clip","id":1,"k":"c","text":"!!!"}"#, // invalid base64 + r#"{"t":"clip","id":1,"k":"c","text":"/w=="}"#, // 0xFF: not UTF-8 + r#"{"t":"clip","id":1,"k":"c","text":["aGk="]}"#, // text must be a string + ] { + assert_eq!( + decode_event(KIND_CONTROL, json.as_bytes()), + None, + "should reject {json}" + ); + } +} From 8ed20d80146bffb0abb0711da38f068fa8fe9d1c Mon Sep 17 00:00:00 2001 From: Christopher Sardegna Date: Tue, 21 Jul 2026 19:56:28 -0700 Subject: [PATCH 2/8] refactor(supervisor): collapse input-delivery and session-load scaffolding --- src/supervisor.rs | 125 ++++++++++++++++++++++------------------------ 1 file changed, 61 insertions(+), 64 deletions(-) diff --git a/src/supervisor.rs b/src/supervisor.rs index 7938f24..40459b4 100644 --- a/src/supervisor.rs +++ b/src/supervisor.rs @@ -22,7 +22,7 @@ use crate::{ ClipboardKind, Command, Event, LaunchContext, ScreenView, ScrollAction, TaskView, env_get, }, session::{self, SessionConfig, SessionEntry}, - task::Task, + task::{Task, WriteRefused}, }; /// Quiet period after which a live task becomes idle. Lifecycle and placement @@ -50,6 +50,10 @@ const MAX_TASKS: usize = 256; /// this limit to bound shell arguments and serialized task snapshots. const MAX_COMMAND_LEN: usize = 64 * 1024; +/// The reasons `materialize` counts an entry as skipped, quoted verbatim in +/// both load notices. +const SKIP_REASONS: &str = "missing dir, task limit, or command too long"; + /// Environment variable overriding per-task terminal history depth. pub const FLEETCOM_SCROLLBACK: &str = "FLEETCOM_SCROLLBACK"; @@ -423,37 +427,17 @@ impl Supervisor { self.watched = id; self.watch_attached = attached; } - Command::Input { id, bytes } => { - let refused = self.by_id_mut(id).and_then(|t| t.send_input(&bytes).err()); - if let Some(r) = refused { - self.notice_refused(id, "input", r.len); - } - } + Command::Input { id, bytes } => self.deliver(id, "input", |t| t.send_input(&bytes)), // Paste and scroll land here (not as pre-encoded `Input`) because // their encoding depends on the child's terminal state, which // only this side of the socket can see. - Command::Paste { id, bytes } => { - let refused = self.by_id_mut(id).and_then(|t| t.send_paste(&bytes).err()); - if let Some(r) = refused { - self.notice_refused(id, "paste", r.len); - } - } + Command::Paste { id, bytes } => self.deliver(id, "paste", |t| t.send_paste(&bytes)), Command::Mouse { id, kind, col, row } => { - let refused = self - .by_id_mut(id) - .and_then(|t| t.send_mouse(kind, col, row).err()); - if let Some(r) = refused { - self.notice_refused(id, "mouse input", r.len); - } + self.deliver(id, "mouse input", |t| t.send_mouse(kind, col, row)) } // Encode keys here because the child's cursor-key mode is core-side. Command::Key { id, code, mods } => { - let refused = self - .by_id_mut(id) - .and_then(|t| t.send_key(code, mods).err()); - if let Some(r) = refused { - self.notice_refused(id, "key input", r.len); - } + self.deliver(id, "key input", |t| t.send_key(code, mods)) } Command::Scrollback { id, action } => { if let Some(t) = self.by_id_mut(id) { @@ -656,10 +640,7 @@ impl Supervisor { return; }; // Refresh finished tasks' resume IDs before serialization. - for t in &mut self.tasks { - scrape_now(t); - } - let cfg = self.session_config(); + let cfg = self.refreshed_config(); // Exclude the timestamped label from content comparison. let hash = fnv1a_hex(session::fingerprint_json(&cfg).as_bytes()); // Deduplication is scoped to the current root and requires the snapshot @@ -716,6 +697,18 @@ impl Supervisor { self.events.push(Event::Status(msg.into())); } + /// Route one input send to task `id`, reporting a bounded-queue refusal. + fn deliver( + &mut self, + id: u64, + what: &str, + f: impl FnOnce(&mut Task) -> Result<(), WriteRefused>, + ) { + if let Some(r) = self.by_id_mut(id).and_then(|t| f(t).err()) { + self.notice_refused(id, what, r.len); + } + } + /// Report the task and message size for a bounded writer-queue refusal. fn notice_refused(&mut self, id: u64, what: &str, len: usize) { self.status(format!( @@ -919,6 +912,16 @@ impl Supervisor { } } + /// Give finished tasks their exit scrape, then snapshot the recipe. + /// `session_config` reads resume IDs through `&self`, so the scrape must + /// precede it (see `scrape_now`). + fn refreshed_config(&mut self) -> SessionConfig { + for t in &mut self.tasks { + scrape_now(t); + } + self.session_config() + } + /// Build `{dir: [entries]}` in spawn order. Groups and names remain intact; /// agent entries use the command returned by `recipe_command`. fn session_config(&self) -> SessionConfig { @@ -967,12 +970,7 @@ impl Supervisor { } fn save_session(&mut self, name: &str) { - // `session_config` reads ids through `&self`; give finished tasks - // their exit scrape first (see `scrape_now`). - for t in &mut self.tasks { - scrape_now(t); - } - let cfg = self.session_config(); + let cfg = self.refreshed_config(); let count: usize = cfg.values().map(Vec::len).sum(); let status = match self .sessions_root() @@ -1036,23 +1034,37 @@ impl Supervisor { Some((spawned, skipped, failed)) } - /// Spawn every command in the named session via `materialize`. - fn load_session(&mut self, name: &str) { + /// Resolve the sessions root, run `load` against it, and map failure to a + /// status. `subject` is the " ''" phrase both notices open with. + fn load_config( + &mut self, + subject: &str, + load: impl FnOnce(&Path) -> io::Result, + ) -> Option { let Some(root) = self.sessions_root() else { self.status("load failed: no config directory available"); - return; + return None; }; - let cfg = match session::load_in(&root, name) { - Ok(c) => c, + match load(&root) { + Ok(c) => Some(c), // Preserve load errors; only a missing file maps to "not found". Err(e) if e.kind() == io::ErrorKind::NotFound => { - self.status(format!("session '{name}' not found")); - return; + self.status(format!("{subject} not found")); + None } Err(e) => { - self.status(format!("session '{name}' failed to load: {e}")); - return; + self.status(format!("{subject} failed to load: {e}")); + None } + } + } + + /// Spawn every command in the named session via `materialize`. + fn load_session(&mut self, name: &str) { + let Some(cfg) = self.load_config(&format!("session '{name}'"), |root| { + session::load_in(root, name) + }) else { + return; }; let Some((spawned, skipped, failed)) = self.materialize(&cfg) else { return; @@ -1063,9 +1075,7 @@ impl Supervisor { parts.push(format!("{spawned} task(s)")); } if skipped > 0 { - parts.push(format!( - "{skipped} skipped (missing dir, task limit, or command too long)" - )); + parts.push(format!("{skipped} skipped ({SKIP_REASONS})")); } if failed > 0 { parts.push(format!("{failed} failed to spawn")); @@ -1075,30 +1085,17 @@ impl Supervisor { /// Load a recovery snapshot by stem and suggest saving it as a named session. fn load_recovery(&mut self, stem: &str) { - let Some(root) = self.sessions_root() else { - self.status("load failed: no config directory available"); + let Some(cfg) = self.load_config(&format!("recovery snapshot '{stem}'"), |root| { + session::load_recovery_in(&session::recovery_dir(root), stem) + }) else { return; }; - let cfg = match session::load_recovery_in(&session::recovery_dir(&root), stem) { - Ok(c) => c, - // Preserve load errors; only a missing file maps to "not found". - Err(e) if e.kind() == io::ErrorKind::NotFound => { - self.status(format!("recovery snapshot '{stem}' not found")); - return; - } - Err(e) => { - self.status(format!("recovery snapshot '{stem}' failed to load: {e}")); - return; - } - }; let Some((_, skipped, failed)) = self.materialize(&cfg) else { return; }; let mut msg = String::from("loaded recovery snapshot; save to name it"); if skipped > 0 { - msg.push_str(&format!( - ", {skipped} skipped (missing dir, task limit, or command too long)" - )); + msg.push_str(&format!(", {skipped} skipped ({SKIP_REASONS})")); } if failed > 0 { msg.push_str(&format!(", {failed} failed to spawn")); From d747e5770debd2feb2acc9777f58af9d4840580a Mon Sep 17 00:00:00 2001 From: Christopher Sardegna Date: Tue, 21 Jul 2026 19:56:38 -0700 Subject: [PATCH 3/8] refactor(client): merge wrap-navigation pairs, single-source picker and key-forward paths --- src/app.rs | 91 +++++++++++++++++++++++++++--------------------------- src/ui.rs | 12 ++++--- 2 files changed, 52 insertions(+), 51 deletions(-) diff --git a/src/app.rs b/src/app.rs index d95c4b4..d0fb5ef 100644 --- a/src/app.rs +++ b/src/app.rs @@ -540,29 +540,26 @@ impl App { } } - /// Move the selection one task up in display order; up from the first - /// task wraps to the last. - fn select_up(&mut self) { + /// Move the selection one task through display order, wrapping at either + /// end: `forward` steps down (last wraps to first), else up (first wraps + /// to last). + fn select_wrap(&mut self, forward: bool) { let order = self.display_order(); if order.is_empty() { self.selected_id = None; return; } let pos = self.selected_pos(&order).unwrap_or(0); - self.selected_id = Some(self.views[order[(pos + order.len() - 1) % order.len()]].id); + let step = if forward { 1 } else { order.len() - 1 }; + self.selected_id = Some(self.views[order[(pos + step) % order.len()]].id); + } + + fn select_up(&mut self) { + self.select_wrap(false); } - /// Move the selection one task down in display order; down from the last - /// task wraps to the first. fn select_down(&mut self) { - let order = self.display_order(); - if order.is_empty() { - self.selected_id = None; - return; - } - let pos = self.selected_pos(&order).unwrap_or(0); - let next = (pos + 1) % order.len(); - self.selected_id = Some(self.views[order[next]].id); + self.select_wrap(true); } /// Index within `sections` of the section holding the selected task. @@ -573,34 +570,32 @@ impl App { .position(|(_, idxs)| idxs.iter().any(|&i| self.views[i].id == id)) } - /// Select the first task in the next section, wrapping to the first. - /// With no current selection, select the first section. - fn select_next_section(&mut self) { + /// Select the first task in the adjacent section, wrapping at either end. + /// `forward` moves to the next section (wrapping to the first); else the + /// previous (wrapping to the last). With no current selection, land on the + /// first section going forward, the last going back. + fn select_section_wrap(&mut self, forward: bool) { let sections = self.sections(); if sections.is_empty() { self.selected_id = None; return; } - let next = match self.selected_section(§ions) { - Some(cur) => (cur + 1) % sections.len(), - None => 0, + let len = sections.len(); + let target = match (self.selected_section(§ions), forward) { + (Some(cur), true) => (cur + 1) % len, + (Some(cur), false) => (cur + len - 1) % len, + (None, true) => 0, + (None, false) => len - 1, }; - self.selected_id = Some(self.views[sections[next].1[0]].id); + self.selected_id = Some(self.views[sections[target].1[0]].id); + } + + fn select_next_section(&mut self) { + self.select_section_wrap(true); } - /// Select the first task in the previous section, wrapping to the last. - /// With no current selection, select the last section. fn select_prev_section(&mut self) { - let sections = self.sections(); - if sections.is_empty() { - self.selected_id = None; - return; - } - let prev = match self.selected_section(§ions) { - Some(cur) => (cur + sections.len() - 1) % sections.len(), - None => sections.len() - 1, - }; - self.selected_id = Some(self.views[sections[prev].1[0]].id); + self.select_section_wrap(false); } /// Send the desired watch state when its target or attachment mode changes. @@ -694,7 +689,7 @@ impl App { ClipboardKind::Selection => 's', }; write!(out, "\x1b]52;{k};{}\x07", B64.encode(&text))?; - last = copied_chars(&text); + last = text.chars().count(); } out.flush()?; // Show the confirmation in the attached-mode notice bar. @@ -948,6 +943,13 @@ impl App { self.group_candidates = cands; } + /// Whether the typed group names no existing group: nonempty input, only + /// the always-present Unassigned row. The Enter action and its render hint + /// share this so they agree structurally, not coincidentally. + pub(crate) fn group_is_new(&self) -> bool { + !self.group_input.is_empty() && self.group_candidates.len() < 2 + } + /// Clear the group-picker state and return to the dashboard. fn close_group_picker(&mut self) { self.group_input.clear(); @@ -1212,7 +1214,7 @@ impl App { KeyCode::Enter => { // Enter assigns the highlighted group, or creates the typed // group when no existing name matches. - let group = if !self.group_input.is_empty() && self.group_candidates.len() < 2 { + let group = if self.group_is_new() { Some(self.group_input.as_str().to_string()) } else { self.group_candidates @@ -1283,11 +1285,7 @@ impl App { // Other input returns to live and is forwarded immediately. _ => { self.view_scroll = false; - if let Some(id) = self.focused_id - && let Some((code, mods)) = key_event_to_key(k) - { - self.transport.send(Command::Key { id, code, mods }); - } + self.forward_key(k); } } return Ok(()); @@ -1301,12 +1299,18 @@ impl App { self.send_scrollback(ScrollAction::Up(page)); return Ok(()); } + self.forward_key(k); + Ok(()) + } + + /// Forward a keystroke to the focused task's PTY, dropping keys with no + /// wire encoding. + fn forward_key(&mut self, k: KeyEvent) { if let Some(id) = self.focused_id && let Some((code, mods)) = key_event_to_key(k) { self.transport.send(Command::Key { id, code, mods }); } - Ok(()) } /// Route a clipboard paste by mode. Attached pastes go intact to the core, @@ -1565,11 +1569,6 @@ fn on_key_edit(buf: &mut EditBuffer, k: KeyEvent) -> Option { } } -/// Count the characters reported by the clipboard-copy notice. -fn copied_chars(text: &str) -> usize { - text.chars().count() -} - /// Insert pasted text at the caret after removing control characters. fn paste_into(buf: &mut EditBuffer, s: &str) { for c in s.chars().filter(|c| !c.is_control()) { diff --git a/src/ui.rs b/src/ui.rs index 0cb78b0..00a9770 100644 --- a/src/ui.rs +++ b/src/ui.rs @@ -183,8 +183,10 @@ fn render_dashboard(out: &mut impl Write, app: &App) -> io::Result<()> { // Input modes show a prompt; otherwise show a notice, status, or key hint. let cmd_y = rows.saturating_sub(2); - match cmdline(app) { - Some((line, _)) => put(out, cmd_y, &line, cols)?, + // Computed once: caret placement below reuses this line. + let cmd = cmdline(app); + match &cmd { + Some((line, _)) => put(out, cmd_y, line, cols)?, None => match transient_line(app.notice(), app.status.as_deref()) { Some(line) => put(out, cmd_y, &line, cols)?, None => dim( @@ -214,9 +216,9 @@ fn render_dashboard(out: &mut impl Write, app: &App) -> io::Result<()> { cols, )?; - match cmdline(app) { + match &cmd { Some((line, cx)) => { - let cx = clamp_caret(cx, &line, cols); + let cx = clamp_caret(*cx, line, cols); queue!(out, MoveTo(cx, cmd_y), Show)?; } None => queue!(out, Hide)?, @@ -575,7 +577,7 @@ fn render_pickgroup(out: &mut impl Write, app: &App) -> io::Result<()> { .collect(); // Hint reflects what Enter does: create when the typed text matches nothing // (nothing matched), otherwise act on the highlighted row. - let action = if !app.group_input.is_empty() && app.group_candidates.len() < 2 { + let action = if app.group_is_new() { "enter create" } else { match app.group_candidates.get(app.group_sel) { From 69b777137886fb0c768a7519f9324b77969d5b8b Mon Sep 17 00:00:00 2001 From: Christopher Sardegna Date: Tue, 21 Jul 2026 19:56:48 -0700 Subject: [PATCH 4/8] refactor(emulator): single-source the row-glyph extraction rule --- src/terminal/emulator.rs | 49 +++++++++++++++++++--------------------- 1 file changed, 23 insertions(+), 26 deletions(-) diff --git a/src/terminal/emulator.rs b/src/terminal/emulator.rs index 07cd07d..cbb7683 100644 --- a/src/terminal/emulator.rs +++ b/src/terminal/emulator.rs @@ -9,7 +9,7 @@ use std::{ use alacritty_terminal::{ Term, event::{Event, EventListener}, - grid::{Dimensions, Scroll}, + grid::{Dimensions, Row, Scroll}, index::{Column, Line}, term::{ Config, TermMode, @@ -400,20 +400,7 @@ impl Emulator { for row in top..=bottom { let row_start = out.len(); let line = &grid[Line(row)]; - for col in 0..grid.columns() { - let cell = &line[Column(col)]; - // Spacers have no glyph; terminal tabs occupy visible spaces. - if cell - .flags - .intersects(Flags::WIDE_CHAR_SPACER | Flags::LEADING_WIDE_CHAR_SPACER) - { - continue; - } - out.push(if cell.c == '\t' { ' ' } else { cell.c }); - if let Some(zerowidth) = cell.zerowidth() { - out.extend(zerowidth.iter()); - } - } + push_row_glyphs(&mut out, line); // A soft wrap continues on the next grid row. if line[Column(last_col)].flags.contains(Flags::WRAPLINE) { continue; @@ -599,15 +586,15 @@ fn live_floor_of(term: &Term) -> String { String::new() } -/// Plain text of one live-viewport row, trailing padding trimmed. Rows -/// `0..screen_lines` address live output regardless of the display -/// offset; only display iteration follows the offset. -fn live_row_text_of(term: &Term, row: i32) -> String { - let grid = term.grid(); - let line = &grid[Line(row)]; - let mut text = String::new(); - for col in 0..grid.columns() { - let cell = &line[Column(col)]; +/// Append one grid row's plain-text glyphs to `out`: skip wide-char spacer +/// cells (no glyph of their own), render a tab cell as one space, else the +/// cell's char, then any combining marks. Single-sourced deliberately — +/// scrollback text ([`ObservedTerm::text_with_history`]) and live-row text +/// ([`live_row_text_of`]) must extract glyphs identically or the two views of +/// the same grid diverge. Trailing-space trim is each caller's own policy, not +/// part of this rule. +fn push_row_glyphs(out: &mut String, row: &Row) { + for cell in row { // Spacers have no glyph; terminal tabs occupy visible spaces. if cell .flags @@ -615,11 +602,21 @@ fn live_row_text_of(term: &Term, row: i32) -> String { { continue; } - text.push(if cell.c == '\t' { ' ' } else { cell.c }); + out.push(if cell.c == '\t' { ' ' } else { cell.c }); if let Some(zerowidth) = cell.zerowidth() { - text.extend(zerowidth.iter()); + out.extend(zerowidth.iter()); } } +} + +/// Plain text of one live-viewport row, trailing padding trimmed. Rows +/// `0..screen_lines` address live output regardless of the display +/// offset; only display iteration follows the offset. +fn live_row_text_of(term: &Term, row: i32) -> String { + let grid = term.grid(); + let line = &grid[Line(row)]; + let mut text = String::new(); + push_row_glyphs(&mut text, line); while text.ends_with(' ') { text.pop(); } From 676545d18c4eb1a1108ebd22b2725ba31fe11b9d Mon Sep 17 00:00:00 2001 From: Christopher Sardegna Date: Tue, 21 Jul 2026 19:56:57 -0700 Subject: [PATCH 5/8] refactor: rehome the generic display helpers and calendar math --- src/{terminal => }/format.rs | 22 ++++++++++++++++++++++ src/harness/codex.rs | 28 ++-------------------------- src/harness/mod.rs | 1 - src/main.rs | 3 ++- src/session.rs | 2 +- src/terminal/mod.rs | 5 ++--- src/testutil.rs | 2 +- 7 files changed, 30 insertions(+), 33 deletions(-) rename src/{terminal => }/format.rs (80%) diff --git a/src/terminal/format.rs b/src/format.rs similarity index 80% rename from src/terminal/format.rs rename to src/format.rs index f5ef31b..9946046 100644 --- a/src/terminal/format.rs +++ b/src/format.rs @@ -75,6 +75,19 @@ pub fn pad(s: &str, width: usize) -> String { t } +/// Proleptic Gregorian date for a count of days since 1970-01-01. +pub(crate) fn civil_from_days(days: i64) -> (i64, u32, u32) { + let z = days + 719_468; + let era = if z >= 0 { z } else { z - 146_096 } / 146_097; + let doe = z - era * 146_097; // [0, 146096] + let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146_096) / 365; // [0, 399] + let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); // [0, 365] + let mp = (5 * doy + 2) / 153; // [0, 11] + let d = (doy - (153 * mp + 2) / 5 + 1) as u32; // [1, 31] + let m = if mp < 10 { mp + 3 } else { mp - 9 } as u32; // [1, 12] + (yoe + era * 400 + i64::from(m <= 2), m, d) +} + #[cfg(test)] mod tests { use super::*; @@ -135,4 +148,13 @@ mod tests { // Combining mark: 1 column, so 2 spaces of padding. assert_eq!(pad("e\u{0301}", 3), "e\u{0301} "); } + + #[test] + fn civil_from_days_matches_known_dates() { + assert_eq!(civil_from_days(0), (1970, 1, 1)); + assert_eq!(civil_from_days(19_723), (2024, 1, 1)); // leap year start + assert_eq!(civil_from_days(19_782), (2024, 2, 29)); // leap day + assert_eq!(civil_from_days(20_648), (2026, 7, 14)); + assert_eq!(civil_from_days(-1), (1969, 12, 31)); + } } diff --git a/src/harness/codex.rs b/src/harness/codex.rs index 219153c..84b501b 100644 --- a/src/harness/codex.rs +++ b/src/harness/codex.rs @@ -111,7 +111,7 @@ impl Harness for Codex { let spawn_days = (spawn_ms / 86_400_000) as i64; let mut survivors: Vec = Vec::new(); for day in (spawn_days - 2)..=(spawn_days + 2) { - let (y, m, d) = civil_from_days(day); + let (y, m, d) = crate::format::civil_from_days(day); let dir = root .join("sessions") .join(format!("{y:04}")) @@ -349,21 +349,6 @@ fn line1_cwd_matches(path: &Path, cwd: &Path) -> bool { .is_some_and(|c| Path::new(c) == cwd) } -/// Proleptic Gregorian date for a count of days since 1970-01-01. -/// `pub(crate)` for the shared rollout fixture in `testutil`; the module -/// itself stays private, so the path runs through `harness`'s re-export. -pub(crate) fn civil_from_days(days: i64) -> (i64, u32, u32) { - let z = days + 719_468; - let era = if z >= 0 { z } else { z - 146_096 } / 146_097; - let doe = z - era * 146_097; // [0, 146096] - let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146_096) / 365; // [0, 399] - let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); // [0, 365] - let mp = (5 * doy + 2) / 153; // [0, 11] - let d = (doy - (153 * mp + 2) / 5 + 1) as u32; // [1, 31] - let m = if mp < 10 { mp + 3 } else { mp - 9 } as u32; // [1, 12] - (yoe + era * 400 + i64::from(m <= 2), m, d) -} - #[cfg(test)] mod tests { use std::path::PathBuf; @@ -764,7 +749,7 @@ mod tests { let spawned = SystemTime::UNIX_EPOCH + std::time::Duration::from_millis(spawn_ms); let id = v7_at(spawn_ms + 2_000, 7); - let (y, m, d) = civil_from_days((spawn_ms / 86_400_000) as i64 - 1); + let (y, m, d) = crate::format::civil_from_days((spawn_ms / 86_400_000) as i64 - 1); let dir = home .join("sessions") .join(format!("{y:04}")) @@ -788,15 +773,6 @@ mod tests { let _ = fs::remove_dir_all(&home); } - #[test] - fn civil_from_days_matches_known_dates() { - assert_eq!(civil_from_days(0), (1970, 1, 1)); - assert_eq!(civil_from_days(19_723), (2024, 1, 1)); // leap year start - assert_eq!(civil_from_days(19_782), (2024, 2, 29)); // leap day - assert_eq!(civil_from_days(20_648), (2026, 7, 14)); - assert_eq!(civil_from_days(-1), (1969, 12, 31)); - } - /// The scraper recovers an SGR-split exit hint from the corpus bytes after /// terminal emulation removes the styling. #[test] diff --git a/src/harness/mod.rs b/src/harness/mod.rs index d6f4e9f..c145e22 100644 --- a/src/harness/mod.rs +++ b/src/harness/mod.rs @@ -31,7 +31,6 @@ use std::{ pub use claude::Claude; pub use codex::Codex; -pub(crate) use codex::civil_from_days; pub use grok::Grok; /// Environment variable naming the capture file used by injected assets. diff --git a/src/main.rs b/src/main.rs index 93ce9c4..fefe6a2 100644 --- a/src/main.rs +++ b/src/main.rs @@ -25,6 +25,7 @@ mod protocol; mod session; mod transport; +mod format; mod harness; mod path; mod terminal; @@ -33,7 +34,7 @@ mod terminal; #[cfg(test)] mod testutil; -pub(crate) use terminal::{ansi, emulator, format, frame, input}; +pub(crate) use terminal::{ansi, emulator, frame, input}; use std::{ io::{self, IsTerminal}, diff --git a/src/session.rs b/src/session.rs index 793825f..6368def 100644 --- a/src/session.rs +++ b/src/session.rs @@ -318,7 +318,7 @@ fn civil_utc(t: SystemTime) -> (i64, u32, u32, u64, u64, u64) { .duration_since(SystemTime::UNIX_EPOCH) .unwrap_or_default() .as_secs(); - let (y, m, d) = crate::harness::civil_from_days((secs / 86_400) as i64); + let (y, m, d) = crate::format::civil_from_days((secs / 86_400) as i64); let tod = secs % 86_400; (y, m, d, tod / 3600, (tod % 3600) / 60, tod % 60) } diff --git a/src/terminal/mod.rs b/src/terminal/mod.rs index 71328e7..646d8d5 100644 --- a/src/terminal/mod.rs +++ b/src/terminal/mod.rs @@ -1,10 +1,9 @@ //! Terminal reconstruction: each task's screen is rebuilt from raw PTY bytes, -//! serialized back to ANSI, framed over the wire, and formatted for display; -//! `input` runs the reverse direction, encoding client events into PTY bytes. +//! serialized back to ANSI, and framed over the wire; `input` runs the reverse +//! direction, encoding client events into PTY bytes. pub(crate) mod ansi; pub(crate) mod emulator; -pub(crate) mod format; pub(crate) mod frame; // Differential emulator tests over recorded PTY output. #[cfg(test)] diff --git a/src/testutil.rs b/src/testutil.rs index bfcce3e..32decfc 100644 --- a/src/testutil.rs +++ b/src/testutil.rs @@ -10,7 +10,7 @@ use std::{ time::{Duration, Instant, SystemTime}, }; -use crate::{emulator::Emulator, harness::civil_from_days}; +use crate::{emulator::Emulator, format::civil_from_days}; /// Fresh scratch directory under the system temp dir. Any leftover from a /// previous run is removed first; the pid suffix isolates concurrent suites. From 3949f5220fd9c90cfe7b4d3ba7557fe80fad8bcf Mon Sep 17 00:00:00 2001 From: Christopher Sardegna Date: Tue, 21 Jul 2026 19:57:05 -0700 Subject: [PATCH 6/8] refactor(harness): build the claude settings with jzon's object! literal --- src/harness/assets.rs | 29 +++++++++++++++-------------- 1 file changed, 15 insertions(+), 14 deletions(-) diff --git a/src/harness/assets.rs b/src/harness/assets.rs index 0c5ac3f..d37b7de 100644 --- a/src/harness/assets.rs +++ b/src/harness/assets.rs @@ -54,20 +54,21 @@ exec "$@" /// Build the `claude` settings overlay containing the `SessionStart` hook. fn claude_settings_json() -> String { - let mut hook = jzon::JsonValue::new_object(); - let _ = hook.insert("type", "command"); - let _ = hook.insert("command", format!("cat > \"${}\"", super::CAPTURE_ENV)); - let mut inner = jzon::JsonValue::new_array(); - let _ = inner.push(hook); - let mut matcher = jzon::JsonValue::new_object(); - let _ = matcher.insert("hooks", inner); - let mut starts = jzon::JsonValue::new_array(); - let _ = starts.push(matcher); - let mut hooks = jzon::JsonValue::new_object(); - let _ = hooks.insert("SessionStart", starts); - let mut root = jzon::JsonValue::new_object(); - let _ = root.insert("hooks", hooks); - root.dump() + jzon::object! { + "hooks": { + "SessionStart": [ + { + "hooks": [ + { + "type": "command", + "command": format!("cat > \"${}\"", super::CAPTURE_ENV), + }, + ], + }, + ], + }, + } + .dump() } /// Resolve the capture root from an explicit runtime directory, the platform From 386f592f947556b75d08c1b4023705ac10a58269 Mon Sep 17 00:00:00 2001 From: Christopher Sardegna Date: Tue, 21 Jul 2026 20:09:27 -0700 Subject: [PATCH 7/8] refactor(supervisor): clarify comment on load_config subject parameter --- src/supervisor.rs | 2 +- src/terminal/emulator.rs | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/supervisor.rs b/src/supervisor.rs index 40459b4..9cf533d 100644 --- a/src/supervisor.rs +++ b/src/supervisor.rs @@ -1035,7 +1035,7 @@ impl Supervisor { } /// Resolve the sessions root, run `load` against it, and map failure to a - /// status. `subject` is the " ''" phrase both notices open with. + /// status. `subject` is the ` ''` phrase both notices open with. fn load_config( &mut self, subject: &str, diff --git a/src/terminal/emulator.rs b/src/terminal/emulator.rs index cbb7683..0ed9f0a 100644 --- a/src/terminal/emulator.rs +++ b/src/terminal/emulator.rs @@ -589,7 +589,7 @@ fn live_floor_of(term: &Term) -> String { /// Append one grid row's plain-text glyphs to `out`: skip wide-char spacer /// cells (no glyph of their own), render a tab cell as one space, else the /// cell's char, then any combining marks. Single-sourced deliberately — -/// scrollback text ([`ObservedTerm::text_with_history`]) and live-row text +/// scrollback text ([`Emulator::text_with_history`]) and live-row text /// ([`live_row_text_of`]) must extract glyphs identically or the two views of /// the same grid diverge. Trailing-space trim is each caller's own policy, not /// part of this rule. From 73501addc1dce04248070401da4a8f1c7e7bf018 Mon Sep 17 00:00:00 2001 From: Christopher Sardegna Date: Tue, 21 Jul 2026 20:38:09 -0700 Subject: [PATCH 8/8] refactor: improve documentation clarity across multiple modules --- src/app.rs | 18 ++++++------------ src/format.rs | 4 ++-- src/protocol.rs | 5 ++--- src/protocol_tests.rs | 18 +++++------------- src/supervisor.rs | 11 ++++------- src/terminal/emulator.rs | 10 ++-------- src/ui.rs | 4 +--- 7 files changed, 22 insertions(+), 48 deletions(-) diff --git a/src/app.rs b/src/app.rs index d0fb5ef..777aa97 100644 --- a/src/app.rs +++ b/src/app.rs @@ -540,9 +540,8 @@ impl App { } } - /// Move the selection one task through display order, wrapping at either - /// end: `forward` steps down (last wraps to first), else up (first wraps - /// to last). + /// Move the selection one task forward or backward in display order, + /// wrapping at either end. fn select_wrap(&mut self, forward: bool) { let order = self.display_order(); if order.is_empty() { @@ -570,10 +569,8 @@ impl App { .position(|(_, idxs)| idxs.iter().any(|&i| self.views[i].id == id)) } - /// Select the first task in the adjacent section, wrapping at either end. - /// `forward` moves to the next section (wrapping to the first); else the - /// previous (wrapping to the last). With no current selection, land on the - /// first section going forward, the last going back. + /// Select the first task in the next or previous section, wrapping at + /// either end. Without a selection, choose the first or last section. fn select_section_wrap(&mut self, forward: bool) { let sections = self.sections(); if sections.is_empty() { @@ -943,9 +940,7 @@ impl App { self.group_candidates = cands; } - /// Whether the typed group names no existing group: nonempty input, only - /// the always-present Unassigned row. The Enter action and its render hint - /// share this so they agree structurally, not coincidentally. + /// Return whether nonempty input matches no existing group. pub(crate) fn group_is_new(&self) -> bool { !self.group_input.is_empty() && self.group_candidates.len() < 2 } @@ -1303,8 +1298,7 @@ impl App { Ok(()) } - /// Forward a keystroke to the focused task's PTY, dropping keys with no - /// wire encoding. + /// Forward an encodable keystroke to the focused task's PTY. fn forward_key(&mut self, k: KeyEvent) { if let Some(id) = self.focused_id && let Some((code, mods)) = key_event_to_key(k) diff --git a/src/format.rs b/src/format.rs index 9946046..fd7f6f8 100644 --- a/src/format.rs +++ b/src/format.rs @@ -1,4 +1,4 @@ -//! Small display helpers: relative time and column-bounded truncation. +//! Formatting, display-width, and civil-date helpers. use std::time::Duration; @@ -75,7 +75,7 @@ pub fn pad(s: &str, width: usize) -> String { t } -/// Proleptic Gregorian date for a count of days since 1970-01-01. +/// Convert days since 1970-01-01 to a proleptic Gregorian date. pub(crate) fn civil_from_days(days: i64) -> (i64, u32, u32) { let z = days + 719_468; let era = if z >= 0 { z } else { z - 146_096 } / 146_097; diff --git a/src/protocol.rs b/src/protocol.rs index 4e0b7c5..db00dfa 100644 --- a/src/protocol.rs +++ b/src/protocol.rs @@ -368,8 +368,7 @@ fn path_from_b64(v: &jzon::JsonValue) -> Option { Some(PathBuf::from(os_from_b64(v)?)) } -/// Decode a JSON number as a bounded integer `T`, rejecting negative and -/// out-of-range values: `as_u64` fails a negative, `T::try_from` an overflow. +/// Decode a JSON unsigned integer as `T`, rejecting out-of-range values. fn num_from>(v: &jzon::JsonValue) -> Option { T::try_from(v.as_u64()?).ok() } @@ -578,7 +577,7 @@ pub fn encode_command(cmd: &Command) -> (u8, Vec) { } Command::Watch { id, attached } => { let _ = o.insert("t", "watch"); - // `From>` maps `None` to `Null`, keeping `"id":null`. + // Encode an absent watch ID as explicit JSON null. let _ = o.insert("id", *id); let _ = o.insert("attached", *attached); } diff --git a/src/protocol_tests.rs b/src/protocol_tests.rs index 3e44214..728f1f7 100644 --- a/src/protocol_tests.rs +++ b/src/protocol_tests.rs @@ -256,8 +256,7 @@ fn invalid_base64_is_rejected() { } } -/// Build a `KIND_SCREEN` payload (`[u32 header_len][header]`, empty tail) -/// from a raw header string, for malformed-header tests. +/// Build a `KIND_SCREEN` payload with a raw header and an empty byte tail. fn screen_payload(header: &str) -> Vec { let mut p = Vec::with_capacity(4 + header.len()); p.extend_from_slice(&(header.len() as u32).to_be_bytes()); @@ -265,8 +264,7 @@ fn screen_payload(header: &str) -> Vec { p } -/// The neutral task view the exact-wire-string assertions pin: every -/// optional key absent, every flag false, an empty unfrozen floor preview. +/// Build the neutral task view used by exact wire-format assertions. fn tv(id: u64) -> TaskView { TaskView { id, @@ -464,9 +462,7 @@ fn tasks_frame_name_key_is_optional() { } } -/// The age keys ride the optional-key idiom: a live parked view carries -/// `quiet_ms` and no `finished_ms`, a finished view the reverse, and -/// both round-trip. +/// Live parked and finished views encode only their applicable age field. #[test] fn parked_and_age_fields_round_trip() { let tasks = Event::Tasks(vec![ @@ -492,10 +488,7 @@ fn parked_and_age_fields_round_trip() { assert_eq!(decode_event(k, &p), Some(tasks)); } -/// A frame from a daemon predating `parked` still decodes: `parked` -/// falls back to the idle lifecycle and both ages read as unknown, so -/// skew degrades to the pre-`parked` signal instead of dropping the -/// frame. +/// Missing parked and age fields use lifecycle-derived and unknown defaults. #[test] fn tasks_frame_without_parked_keys_decodes_with_defaults() { let old = r#"{"t":"tasks","tasks":[{"id":1,"command":"x","cwd":"Lw==","tagged":false,"life":"idle","preview":"","started_ms":0},{"id":2,"command":"y","cwd":"Lw==","tagged":false,"life":"active","preview":"","started_ms":0}]}"#; @@ -747,8 +740,7 @@ fn load_recovery_wire_form() { } } -/// The `Screen` event keeps its formatted bytes intact through the raw tail, -/// including non-UTF-8 bytes (0xFF) an ANSI stream really contains. +/// A `Screen` event preserves formatted bytes, including non-UTF-8 values. #[test] fn screen_round_trips_raw_bytes() { let screen = Event::Screen(ScreenView { diff --git a/src/supervisor.rs b/src/supervisor.rs index 9cf533d..00a4bc0 100644 --- a/src/supervisor.rs +++ b/src/supervisor.rs @@ -50,8 +50,7 @@ const MAX_TASKS: usize = 256; /// this limit to bound shell arguments and serialized task snapshots. const MAX_COMMAND_LEN: usize = 64 * 1024; -/// The reasons `materialize` counts an entry as skipped, quoted verbatim in -/// both load notices. +/// Conditions counted as skipped by `materialize`. const SKIP_REASONS: &str = "missing dir, task limit, or command too long"; /// Environment variable overriding per-task terminal history depth. @@ -912,9 +911,7 @@ impl Supervisor { } } - /// Give finished tasks their exit scrape, then snapshot the recipe. - /// `session_config` reads resume IDs through `&self`, so the scrape must - /// precede it (see `scrape_now`). + /// Refresh finished tasks' resume IDs before building the session recipe. fn refreshed_config(&mut self) -> SessionConfig { for t in &mut self.tasks { scrape_now(t); @@ -1034,8 +1031,8 @@ impl Supervisor { Some((spawned, skipped, failed)) } - /// Resolve the sessions root, run `load` against it, and map failure to a - /// status. `subject` is the ` ''` phrase both notices open with. + /// Run a config loader against the sessions root, prefixing errors with + /// `subject`. fn load_config( &mut self, subject: &str, diff --git a/src/terminal/emulator.rs b/src/terminal/emulator.rs index 0ed9f0a..151a681 100644 --- a/src/terminal/emulator.rs +++ b/src/terminal/emulator.rs @@ -586,16 +586,10 @@ fn live_floor_of(term: &Term) -> String { String::new() } -/// Append one grid row's plain-text glyphs to `out`: skip wide-char spacer -/// cells (no glyph of their own), render a tab cell as one space, else the -/// cell's char, then any combining marks. Single-sourced deliberately — -/// scrollback text ([`Emulator::text_with_history`]) and live-row text -/// ([`live_row_text_of`]) must extract glyphs identically or the two views of -/// the same grid diverge. Trailing-space trim is each caller's own policy, not -/// part of this rule. +/// Append a grid row's glyphs, omitting wide-character spacers, mapping tabs +/// to spaces, and preserving combining marks. Callers handle trailing spaces. fn push_row_glyphs(out: &mut String, row: &Row) { for cell in row { - // Spacers have no glyph; terminal tabs occupy visible spaces. if cell .flags .intersects(Flags::WIDE_CHAR_SPACER | Flags::LEADING_WIDE_CHAR_SPACER) diff --git a/src/ui.rs b/src/ui.rs index 00a9770..96dfa16 100644 --- a/src/ui.rs +++ b/src/ui.rs @@ -183,7 +183,6 @@ fn render_dashboard(out: &mut impl Write, app: &App) -> io::Result<()> { // Input modes show a prompt; otherwise show a notice, status, or key hint. let cmd_y = rows.saturating_sub(2); - // Computed once: caret placement below reuses this line. let cmd = cmdline(app); match &cmd { Some((line, _)) => put(out, cmd_y, line, cols)?, @@ -575,8 +574,7 @@ fn render_pickgroup(out: &mut impl Write, app: &App) -> io::Result<()> { .iter() .map(|c| c.label.clone()) .collect(); - // Hint reflects what Enter does: create when the typed text matches nothing - // (nothing matched), otherwise act on the highlighted row. + // Mirror Enter: create unmatched text or act on the highlighted row. let action = if app.group_is_new() { "enter create" } else {