From cddf406ec7f13344f3c37824b3a4457ea045b5ad Mon Sep 17 00:00:00 2001 From: jeremy Date: Mon, 8 Jun 2026 21:27:48 -0700 Subject: [PATCH 01/18] fix(computer): declare all input fields in schema + fix element_at reserved word The computer tool implements every action and ComputerInput field, but parameters_schema() (progressive disclosure) only declared a subset of properties. Models emit only parameters present in the tool's JSON schema, so the undeclared fields could never be sent, making 8 actions impossible to invoke despite being fully implemented: - scroll (needs dx/dy) - drag (needs to_x/to_y) - resize_window (needs w/h) - select_menu (needs menu_path) - window_screenshot (needs window_id) - wait_for (needs contains, timeout_ms) - set_brightness (needs level) - perform_action (needs ax_action) - ocr region variant(needs region) Declare all input fields in the schema. Action *specs* stay in `discover` (progressive disclosure), but the *fields* must be declared or the action is dead on arrival. Trim field descriptions so the always-on schema stays small (~880 tokens) and bump the schema_is_compact bound accordingly. Also fix ax.rs::element_at: the hit-test AppleScript assigned to a local named `result`, which is reserved in AppleScript (holds the last command's value), so element_at always failed with "The variable result is not defined." Rename to `bestHit`. Add a schema_declares_every_input_field regression test so this class of bug (handler requires a field the schema never exposes) can't recur. Fixes #350. --- crates/jcode-app-core/src/tool/computer/ax.rs | 8 +++--- .../jcode-app-core/src/tool/computer/mod.rs | 15 ++++++++++- .../jcode-app-core/src/tool/computer/tests.rs | 25 ++++++++++++++++--- 3 files changed, 40 insertions(+), 8 deletions(-) diff --git a/crates/jcode-app-core/src/tool/computer/ax.rs b/crates/jcode-app-core/src/tool/computer/ax.rs index d1ae8c2e1b..34cd1e737e 100644 --- a/crates/jcode-app-core/src/tool/computer/ax.rs +++ b/crates/jcode-app-core/src/tool/computer/ax.rs @@ -295,7 +295,7 @@ pub fn element_at(app: &str, x: f64, y: f64) -> Result { r#" using terms from application "System Events" on hit(el, px, py, idxPath, best) - set result to best + set bestHit to best try set p to position of el set sz to size of el @@ -312,17 +312,17 @@ using terms from application "System Events" try set t to (title of el as text) end try - set result to idxPath & " " & r & " \"" & t & "\" @(" & x1 & "," & y1 & " " & (item 1 of sz) & "x" & (item 2 of sz) & ")" + set bestHit to idxPath & " " & r & " \"" & t & "\" @(" & x1 & "," & y1 & " " & (item 1 of sz) & "x" & (item 2 of sz) & ")" end if end try try set i to 0 repeat with child in (UI elements of el) set i to i + 1 - set result to my hit(child, px, py, idxPath & "." & i, result) + set bestHit to my hit(child, px, py, idxPath & "." & i, bestHit) end repeat end try - return result + return bestHit end hit end using terms from diff --git a/crates/jcode-app-core/src/tool/computer/mod.rs b/crates/jcode-app-core/src/tool/computer/mod.rs index 4557a89e0a..5392d340a6 100644 --- a/crates/jcode-app-core/src/tool/computer/mod.rs +++ b/crates/jcode-app-core/src/tool/computer/mod.rs @@ -226,7 +226,20 @@ impl Tool for ComputerTool { }, "script": { "type": "string", "description": "AppleScript (run_applescript) or JS (run_jxa) source." }, "depth": { "type": "integer", "description": "Max AX tree depth for ui/find_element (default 12)." }, - "dry_run": { "type": "boolean", "description": "For mutating actions: report the intended action without performing it." } + "to_x": { "type": "number" }, + "to_y": { "type": "number" }, + "dx": { "type": "integer" }, + "dy": { "type": "integer" }, + "w": { "type": "number" }, + "h": { "type": "number" }, + "window_id": { "type": "integer", "description": "window_screenshot id (from list_windows)." }, + "menu_path": { "type": "array", "items": { "type": "string" }, "description": "select_menu path, e.g. [\"File\",\"Save\"]." }, + "ax_action": { "type": "string", "description": "perform_action AX action, e.g. AXShowMenu." }, + "contains": { "type": "string", "description": "wait_for: substring to await." }, + "timeout_ms": { "type": "integer" }, + "region": { "type": "array", "items": { "type": "number" }, "description": "ocr region [x,y,w,h]; omit for full screen." }, + "level": { "type": "number", "description": "set_brightness 0..1." }, + "dry_run": { "type": "boolean", "description": "Mutating actions: report intended action without doing it." } } }) } diff --git a/crates/jcode-app-core/src/tool/computer/tests.rs b/crates/jcode-app-core/src/tool/computer/tests.rs index 4241d1dade..3780ea0819 100644 --- a/crates/jcode-app-core/src/tool/computer/tests.rs +++ b/crates/jcode-app-core/src/tool/computer/tests.rs @@ -97,16 +97,35 @@ fn is_mutating_classifies() { assert!(!super::is_mutating("discover")); } +#[test] +fn schema_declares_every_input_field() { + // Regression guard for the "schema omits half its fields" bug: every field + // `dispatch` can require must be declared in `parameters_schema()`, or the + // model can never send it and the action is dead on arrival. + let tool = ComputerTool::new(); + let schema = tool.parameters_schema(); + let props = schema["properties"].as_object().expect("properties object"); + for field in [ + "action", "category", "x", "y", "to_x", "to_y", "w", "h", "text", "keys", "dx", "dy", + "depth", "app", "role", "title", "value", "element", "ax_action", "menu_path", + "window_id", "script", "contains", "timeout_ms", "region", "level", "dry_run", + ] { + assert!(props.contains_key(field), "schema is missing field `{field}`"); + } +} + #[test] fn schema_is_compact() { // Guard against context bloat: the always-on schema + description must stay - // small. Measured well under this bound; alert if it balloons. + // small. Action *specs* live in `discover` (progressive disclosure), but + // every input *field* must be declared here or the model can't send it, so + // the field set (not the action set) sets the floor. Keep always-on cost + // roughly under ~900 tokens; alert if it balloons past that. let tool = ComputerTool::new(); let schema = serde_json::to_string(&tool.parameters_schema()).unwrap(); let total = tool.description().len() + schema.len(); - // ~4 chars/token; keep always-on cost roughly under ~700 tokens. assert!( - total < 2800, + total < 3500, "computer tool always-on size grew to {total} chars (~{} tokens)", total / 4 ); From 9e49dfafe9af0c18f9f06c77c45632d1a0b8f72f Mon Sep 17 00:00:00 2001 From: jeremy Date: Mon, 8 Jun 2026 21:47:09 -0700 Subject: [PATCH 02/18] fix(computer): prune element_at hit-test + correct osa error classification Two production-quality issues found during exhaustive live testing: 1) element_at walked the ENTIRE AX tree, so on large apps (System Settings) it blew past the scripting timeout (~20s) and failed. A child's AX frame is contained in its parent's, so a parent that doesn't contain the point can't have a matching descendant: prune to only descend into subtrees whose frame contains the point, add a depth bound, and give it an explicit 12s timeout. This turns a full-tree walk into a path-length walk. 2) osascript errors -1719 (errAEIllegalIndex / "Invalid index") were misclassified as "Accessibility permission required", sending users to grant a permission they already had when the real cause was "app not running / no front window / AX path out of range". Extract the mapping into a pure `classify_osa_error`, check permission text first, then map -1719/-1728/"invalid index" to an accurate "target not found" message. Use -25211 (errAXAPIDisabled) for the genuine permission code. Add unit tests covering each branch. Refs #350. --- crates/jcode-app-core/src/tool/computer/ax.rs | 52 ++++++++----- .../jcode-app-core/src/tool/computer/osa.rs | 77 +++++++++++++++++-- 2 files changed, 104 insertions(+), 25 deletions(-) diff --git a/crates/jcode-app-core/src/tool/computer/ax.rs b/crates/jcode-app-core/src/tool/computer/ax.rs index 34cd1e737e..653c424223 100644 --- a/crates/jcode-app-core/src/tool/computer/ax.rs +++ b/crates/jcode-app-core/src/tool/computer/ax.rs @@ -289,37 +289,51 @@ pub fn select_menu(app: &str, path: &[String]) -> Result { /// Return the element at a screen point (role/title), useful to confirm targets. pub fn element_at(app: &str, x: f64, y: f64) -> Result { - // System Events can hit-test via "UI element N" is awkward; use AX position - // matching by walking and finding the deepest element containing the point. + // Hit-test by walking the AX tree, but PRUNE: only descend into a subtree + // whose own frame contains the point. A child's frame is contained in its + // parent's, so a parent that doesn't contain the point can't have a matching + // descendant. This turns a full-tree walk (which times out on huge apps like + // System Settings) into a path-length walk. Also bound the depth defensively. let script = format!( r#" using terms from application "System Events" - on hit(el, px, py, idxPath, best) - set bestHit to best + on inFrame(el, px, py) try set p to position of el set sz to size of el set x1 to item 1 of p set y1 to item 2 of p - set x2 to x1 + (item 1 of sz) - set y2 to y1 + (item 2 of sz) - if px >= x1 and px <= x2 and py >= y1 and py <= y2 then - set r to "" - try - set r to (role of el as text) - end try - set t to "" - try - set t to (title of el as text) - end try - set bestHit to idxPath & " " & r & " \"" & t & "\" @(" & x1 & "," & y1 & " " & (item 1 of sz) & "x" & (item 2 of sz) & ")" + if px >= x1 and px <= (x1 + (item 1 of sz)) and py >= y1 and py <= (y1 + (item 2 of sz)) then + return true end if end try + return false + end inFrame + on hit(el, px, py, idxPath, maxlvl, lvl, best) + set bestHit to best + if lvl > maxlvl then return bestHit + -- Record this element if it contains the point (deepest wins). + if my inFrame(el, px, py) then + set r to "" + try + set r to (role of el as text) + end try + set t to "" + try + set t to (title of el as text) + end try + set p to position of el + set sz to size of el + set bestHit to idxPath & " " & r & " \"" & t & "\" @(" & (item 1 of p) & "," & (item 2 of p) & " " & (item 1 of sz) & "x" & (item 2 of sz) & ")" + end if + -- Only descend into children that themselves contain the point. try set i to 0 repeat with child in (UI elements of el) set i to i + 1 - set bestHit to my hit(child, px, py, idxPath & "." & i, bestHit) + if my inFrame(child, px, py) then + set bestHit to my hit(child, px, py, idxPath & "." & i, maxlvl, lvl + 1, bestHit) + end if end repeat end try return bestHit @@ -330,7 +344,7 @@ tell application "System Events" set frontApp to first application process whose name is {app} try set win to front window of frontApp - set out to my hit(win, {x}, {y}, "", "(none)") + set out to my hit(win, {x}, {y}, "", 40, 0, "(none)") on error errMsg set out to "(error: " & errMsg & ")" end try @@ -341,7 +355,7 @@ end tell x = x, y = y ); - let res = osa::run_applescript(&script)?; + let res = osa::run_applescript_timeout(&script, Duration::from_secs(12))?; Ok(ToolOutput::new(format!("Deepest element at ({x:.0},{y:.0}) in {app}:\n{res}")) .with_title("element_at") .with_metadata(json!({"app": app, "x": x, "y": y}))) diff --git a/crates/jcode-app-core/src/tool/computer/osa.rs b/crates/jcode-app-core/src/tool/computer/osa.rs index 372876a883..66e7b855ef 100644 --- a/crates/jcode-app-core/src/tool/computer/osa.rs +++ b/crates/jcode-app-core/src/tool/computer/osa.rs @@ -41,30 +41,50 @@ fn run(args: &[&str], lang: &str, timeout: Duration) -> Result { return Ok(stdout.trim_end().to_string()); } - let trimmed = stderr.trim(); + bail!("{}", classify_osa_error(stderr.trim(), lang)); +} + +/// Map an osascript stderr message to an actionable error string. Pure so it can +/// be unit-tested without shelling out. Ordering matters: permission errors are +/// the most actionable and have distinctive text, so they are checked before the +/// generic "reference does not resolve" index errors. +fn classify_osa_error(trimmed: &str, lang: &str) -> String { let lower = trimmed.to_lowercase(); + // Permission errors first. The real "not allowed assistive access" denial + // always carries that phrase; -25211 is errAXAPIDisabled. if lower.contains("assistive") || lower.contains("not allowed") - || lower.contains("-1719") + || lower.contains("-25211") || lower.contains("1002") { - bail!( + return format!( "Accessibility permission required. Run the `setup` action, or grant it in \ System Settings > Privacy & Security > Accessibility for your terminal/jcode. \ ({trimmed})" ); } if lower.contains("-1743") || lower.contains("not authorized to send apple events") { - bail!( + return format!( "Automation permission required for the target app. Approve the prompt, or grant it \ in System Settings > Privacy & Security > Automation. ({trimmed})" ); } + // -1719 (errAEIllegalIndex / "Invalid index") and -1728 (errAENoSuchObject / + // "Can't get ...") mean the target reference does not resolve: the app isn't + // running, has no front window, or an AX path index is out of range. This is + // NOT a permission problem, so report it accurately instead of sending the + // user to grant Accessibility (the previous behavior, which was misleading). + if lower.contains("-1719") || lower.contains("-1728") || lower.contains("invalid index") { + return format!( + "target not found: the app may not be running, has no front window, or an AX path \ + index is out of range. Check `list_apps`/`ui` and retry. ({trimmed})" + ); + } if trimmed.is_empty() { - bail!("{lang} failed (no error output)"); + return format!("{lang} failed (no error output)"); } - bail!("{lang} failed: {trimmed}"); + format!("{lang} failed: {trimmed}") } /// Run a command with a wall-clock timeout. Returns (success, stdout, stderr). @@ -154,4 +174,49 @@ mod tests { .to_string(); assert!(err.contains("timed out"), "got: {err}"); } + + #[test] + fn classifies_permission_error() { + let msg = classify_osa_error( + "execution error: System Events got an error: osascript is not allowed assistive access. (-25211)", + "AppleScript", + ); + assert!(msg.contains("Accessibility permission required"), "got: {msg}"); + } + + #[test] + fn classifies_invalid_index_as_not_found_not_permission() { + // Regression: -1719 used to be misreported as "Accessibility permission + // required" even though it means the target reference didn't resolve. + let msg = classify_osa_error( + "execution error: System Events got an error: Can\u{2019}t get application process 1 whose name = \"Codex\". Invalid index. (-1719)", + "AppleScript", + ); + assert!(msg.contains("target not found"), "got: {msg}"); + assert!(!msg.contains("Accessibility permission"), "got: {msg}"); + } + + #[test] + fn classifies_no_such_object_as_not_found() { + let msg = classify_osa_error( + "execution error: System Events got an error: Can\u{2019}t get front window of process \"Foo\". (-1728)", + "AppleScript", + ); + assert!(msg.contains("target not found"), "got: {msg}"); + } + + #[test] + fn classifies_automation_error() { + let msg = classify_osa_error( + "execution error: Not authorized to send Apple events to Finder. (-1743)", + "AppleScript", + ); + assert!(msg.contains("Automation permission required"), "got: {msg}"); + } + + #[test] + fn classifies_generic_error() { + let msg = classify_osa_error("execution error: something weird (-2700)", "JXA"); + assert!(msg.contains("JXA failed"), "got: {msg}"); + } } From fde76a1e1b8615be87ccb90e365433f2cec8b246 Mon Sep 17 00:00:00 2001 From: jeremy Date: Mon, 8 Jun 2026 21:52:54 -0700 Subject: [PATCH 03/18] fix(computer): make wait_for honor its own timeout_ms MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Each wait_for poll dumped the AX tree (depth 10) under run_applescript's 20s default timeout. On a large app a single poll could take ~20s, so a wait_for with timeout_ms=4000 didn't return until ~20s — it ignored its own deadline. Bound each poll's scripting timeout to the time remaining (capped at 3s) and shrink the dump depth to 6 so polls are cheap and the call honors timeout_ms. Refs #350. --- .../jcode-app-core/src/tool/computer/sys.rs | 19 +++++++++++++++---- 1 file changed, 15 insertions(+), 4 deletions(-) diff --git a/crates/jcode-app-core/src/tool/computer/sys.rs b/crates/jcode-app-core/src/tool/computer/sys.rs index d1f091b962..4de8ce225e 100644 --- a/crates/jcode-app-core/src/tool/computer/sys.rs +++ b/crates/jcode-app-core/src/tool/computer/sys.rs @@ -67,7 +67,11 @@ pub fn notify(text: &str, title: Option<&str>) -> Result { /// Poll an app's AX tree until a substring appears (element_appears) or a /// timeout elapses. Cheap structural wait instead of fixed sleeps. pub fn wait_for(app: &str, contains: &str, timeout_ms: u64) -> Result { - let deadline = Instant::now() + Duration::from_millis(timeout_ms.min(60_000)); + let total = Duration::from_millis(timeout_ms.min(60_000)); + let deadline = Instant::now() + total; + // Keep each poll shallow so it returns quickly even on big apps; a deep + // (depth-10) dump of a large tree can itself take many seconds, which would + // blow past `timeout_ms`. Depth 6 captures visible labels/values cheaply. let script = format!( r#" using terms from application "System Events" @@ -91,7 +95,7 @@ end using terms from tell application "System Events" set frontApp to first application process whose name is {app} try - return my dumpEl(front window of frontApp, 0, 10) + return my dumpEl(front window of frontApp, 0, 6) on error return "" end try @@ -100,14 +104,21 @@ end tell app = osa::as_quote(app) ); loop { - let tree = osa::run_applescript(&script).unwrap_or_default(); + // Bound each poll by the time left so the whole call honors timeout_ms, + // and never let a single poll exceed ~3s. + let remaining = deadline.saturating_duration_since(Instant::now()); + if remaining.is_zero() { + bail!("wait_for timed out after {timeout_ms}ms (no '{contains}' in {app})"); + } + let poll_budget = remaining.min(Duration::from_secs(3)); + let tree = osa::run_applescript_timeout(&script, poll_budget).unwrap_or_default(); if tree.contains(contains) { return Ok(ToolOutput::new(format!("matched '{contains}' in {app}"))); } if Instant::now() >= deadline { bail!("wait_for timed out after {timeout_ms}ms (no '{contains}' in {app})"); } - sleep(Duration::from_millis(250)); + sleep(Duration::from_millis(200)); } } From c84ad1762bf5a71bb506254d12c83308e27331f6 Mon Sep 17 00:00:00 2001 From: jeremy Date: Mon, 8 Jun 2026 21:55:05 -0700 Subject: [PATCH 04/18] fix(computer): wait_for depth 8 + include description text for better matching --- crates/jcode-app-core/src/tool/computer/sys.rs | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/crates/jcode-app-core/src/tool/computer/sys.rs b/crates/jcode-app-core/src/tool/computer/sys.rs index 4de8ce225e..ee56c6504e 100644 --- a/crates/jcode-app-core/src/tool/computer/sys.rs +++ b/crates/jcode-app-core/src/tool/computer/sys.rs @@ -69,9 +69,8 @@ pub fn notify(text: &str, title: Option<&str>) -> Result { pub fn wait_for(app: &str, contains: &str, timeout_ms: u64) -> Result { let total = Duration::from_millis(timeout_ms.min(60_000)); let deadline = Instant::now() + total; - // Keep each poll shallow so it returns quickly even on big apps; a deep - // (depth-10) dump of a large tree can itself take many seconds, which would - // blow past `timeout_ms`. Depth 6 captures visible labels/values cheaply. + // Keep each poll bounded by the per-poll timeout below so it returns even on + // big apps. Depth 8 captures most visible labels/values while staying cheap. let script = format!( r#" using terms from application "System Events" @@ -84,6 +83,9 @@ using terms from application "System Events" try set out to out & (value of el as text) & " " end try + try + set out to out & (description of el as text) & " " + end try try repeat with child in (UI elements of el) set out to out & (my dumpEl(child, lvl + 1, maxlvl)) @@ -95,7 +97,7 @@ end using terms from tell application "System Events" set frontApp to first application process whose name is {app} try - return my dumpEl(front window of frontApp, 0, 6) + return my dumpEl(front window of frontApp, 0, 8) on error return "" end try From f852903b7ace1e9ece1927480440e2b26e3e21e1 Mon Sep 17 00:00:00 2001 From: jeremy Date: Mon, 8 Jun 2026 22:43:48 -0700 Subject: [PATCH 05/18] feat(keymap): discover machine key bindings into a snapshot Add a keymap discovery layer in jcode-setup-hints that normalizes both macOS system shortcuts (com.apple.symbolichotkeys) and terminal emulator bindings (Ghostty +list-keybinds) into a shared KeyChord and persists them to ~/.jcode/keymap-snapshot.json. This is the data layer for detecting when a terminal/OS shortcut intercepts a key jcode wants to use. - chord: normalized (cmd/ctrl/alt/shift + key) with cross-source key spelling - macos_hotkeys: decode [ascii, keycode, modmask] triples (pure + tested) - terminal: parse Ghostty effective keybinds (pure + tested) - snapshot: collect/save/load JSON; tolerant of string/int plist params --- Cargo.lock | 1 + crates/jcode-setup-hints/Cargo.toml | 1 + crates/jcode-setup-hints/src/keymap/chord.rs | 195 ++++++++++ .../src/keymap/macos_hotkeys.rs | 345 ++++++++++++++++++ crates/jcode-setup-hints/src/keymap/mod.rs | 159 ++++++++ crates/jcode-setup-hints/src/keymap/source.rs | 40 ++ .../jcode-setup-hints/src/keymap/terminal.rs | 232 ++++++++++++ crates/jcode-setup-hints/src/lib.rs | 2 + 8 files changed, 975 insertions(+) create mode 100644 crates/jcode-setup-hints/src/keymap/chord.rs create mode 100644 crates/jcode-setup-hints/src/keymap/macos_hotkeys.rs create mode 100644 crates/jcode-setup-hints/src/keymap/mod.rs create mode 100644 crates/jcode-setup-hints/src/keymap/source.rs create mode 100644 crates/jcode-setup-hints/src/keymap/terminal.rs diff --git a/Cargo.lock b/Cargo.lock index 06f4b563cb..1a16a927ad 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3994,6 +3994,7 @@ dependencies = [ "jcode-logging", "jcode-storage", "serde", + "serde_json", "tempfile", ] diff --git a/crates/jcode-setup-hints/Cargo.toml b/crates/jcode-setup-hints/Cargo.toml index 7b7356c7d4..f4a6f38596 100644 --- a/crates/jcode-setup-hints/Cargo.toml +++ b/crates/jcode-setup-hints/Cargo.toml @@ -15,6 +15,7 @@ jcode-build-meta = { path = "../jcode-build-meta" } jcode-logging = { path = "../jcode-logging" } jcode-storage = { path = "../jcode-storage" } serde = { version = "1", features = ["derive"] } +serde_json = "1" [target.'cfg(target_os = "macos")'.dependencies] global-hotkey = "0.7" diff --git a/crates/jcode-setup-hints/src/keymap/chord.rs b/crates/jcode-setup-hints/src/keymap/chord.rs new file mode 100644 index 0000000000..5247b17678 --- /dev/null +++ b/crates/jcode-setup-hints/src/keymap/chord.rs @@ -0,0 +1,195 @@ +//! A normalized, platform-independent representation of a key chord. +//! +//! Both jcode's own bindings and the bindings we discover on the machine +//! (terminal config, macOS system hotkeys) are reduced to a [`KeyChord`] so they +//! can be compared for conflicts regardless of where they came from. + +use serde::{Deserialize, Serialize}; + +/// A single key combination: a set of modifiers plus one primary key token. +/// +/// The `key` token is stored in a canonical lowercase form (see +/// [`KeyChord::normalize_key`]). Modifiers use jcode's vocabulary where the +/// macOS Command key maps to `cmd` (equivalent to crossterm's `SUPER`). +#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] +pub struct KeyChord { + #[serde(default, skip_serializing_if = "is_false")] + pub cmd: bool, + #[serde(default, skip_serializing_if = "is_false")] + pub ctrl: bool, + #[serde(default, skip_serializing_if = "is_false")] + pub alt: bool, + #[serde(default, skip_serializing_if = "is_false")] + pub shift: bool, + pub key: String, +} + +fn is_false(b: &bool) -> bool { + !*b +} + +impl KeyChord { + /// Build a chord from a raw key token, normalizing the token. + pub fn new(cmd: bool, ctrl: bool, alt: bool, shift: bool, key: &str) -> Self { + Self { + cmd, + ctrl, + alt, + shift, + key: Self::normalize_key(key), + } + } + + /// A stable, human-readable canonical string such as `cmd+shift+k` or + /// `ctrl+[`. Modifier order is fixed (cmd, ctrl, alt, shift) so two chords + /// that mean the same thing always produce the same string. + pub fn canonical(&self) -> String { + let mut out = String::new(); + if self.cmd { + out.push_str("cmd+"); + } + if self.ctrl { + out.push_str("ctrl+"); + } + if self.alt { + out.push_str("alt+"); + } + if self.shift { + out.push_str("shift+"); + } + out.push_str(&self.key); + out + } + + /// A prettier label for user-facing messages, e.g. `Cmd+Shift+K`. + pub fn display(&self) -> String { + let mut parts: Vec = Vec::new(); + if self.cmd { + parts.push("Cmd".to_string()); + } + if self.ctrl { + parts.push("Ctrl".to_string()); + } + if self.alt { + parts.push("Alt".to_string()); + } + if self.shift { + parts.push("Shift".to_string()); + } + parts.push(pretty_key(&self.key)); + parts.join("+") + } + + /// Normalize a raw key token (from any source) into a canonical token. + /// + /// Handles the differing spellings used by terminals (`arrow_left`, + /// `page_up`, `digit_1`) and macOS virtual keycodes, collapsing them onto a + /// single vocabulary shared with jcode's own keybinding parser. + pub fn normalize_key(raw: &str) -> String { + let k = raw.trim().to_ascii_lowercase(); + match k.as_str() { + // Arrows (ghostty/kitty style -> jcode style) + "arrow_left" | "left" => "left", + "arrow_right" | "right" => "right", + "arrow_up" | "up" => "up", + "arrow_down" | "down" => "down", + // Paging / navigation + "page_up" | "pageup" | "prior" => "pageup", + "page_down" | "pagedown" | "next" => "pagedown", + "home" => "home", + "end" => "end", + "insert" => "insert", + "delete" | "forward_delete" => "delete", + "backspace" => "backspace", + "return" | "enter" => "enter", + "escape" | "esc" => "esc", + "tab" => "tab", + "space" => "space", + // Named punctuation used by various terminals + "comma" => ",", + "period" => ".", + "slash" => "/", + "backslash" => "\\", + "semicolon" => ";", + "apostrophe" | "quote" => "'", + "grave" | "backtick" => "`", + "minus" => "-", + "equal" => "=", + "left_bracket" | "bracketleft" => "[", + "right_bracket" | "bracketright" => "]", + _ => { + // digit_N -> N + if let Some(d) = k.strip_prefix("digit_") { + return d.to_string(); + } + // numpad_N -> N (best effort) + if let Some(d) = k.strip_prefix("numpad_") { + return d.to_string(); + } + // Anything else (single chars, f1..f24, etc.) passes through. + return k; + } + } + .to_string() + } +} + +fn pretty_key(key: &str) -> String { + match key { + "left" => "Left".to_string(), + "right" => "Right".to_string(), + "up" => "Up".to_string(), + "down" => "Down".to_string(), + "pageup" => "PageUp".to_string(), + "pagedown" => "PageDown".to_string(), + "home" => "Home".to_string(), + "end" => "End".to_string(), + "enter" => "Enter".to_string(), + "esc" => "Esc".to_string(), + "tab" => "Tab".to_string(), + "space" => "Space".to_string(), + "backspace" => "Backspace".to_string(), + "delete" => "Delete".to_string(), + other => { + if other.len() == 1 { + other.to_ascii_uppercase() + } else if let Some(rest) = other.strip_prefix('f') { + if rest.chars().all(|c| c.is_ascii_digit()) && !rest.is_empty() { + return format!("F{rest}"); + } + other.to_string() + } else { + other.to_string() + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn canonical_orders_modifiers() { + let c = KeyChord::new(true, false, true, true, "K"); + assert_eq!(c.canonical(), "cmd+alt+shift+k"); + assert_eq!(c.display(), "Cmd+Alt+Shift+K"); + } + + #[test] + fn normalizes_terminal_key_spellings() { + assert_eq!(KeyChord::normalize_key("arrow_left"), "left"); + assert_eq!(KeyChord::normalize_key("page_up"), "pageup"); + assert_eq!(KeyChord::normalize_key("digit_3"), "3"); + assert_eq!(KeyChord::normalize_key("comma"), ","); + assert_eq!(KeyChord::normalize_key("F5"), "f5"); + } + + #[test] + fn equal_chords_compare_equal() { + let a = KeyChord::new(true, false, false, false, "k"); + let b = KeyChord::new(true, false, false, false, "K"); + assert_eq!(a, b); + assert_eq!(a.canonical(), b.canonical()); + } +} diff --git a/crates/jcode-setup-hints/src/keymap/macos_hotkeys.rs b/crates/jcode-setup-hints/src/keymap/macos_hotkeys.rs new file mode 100644 index 0000000000..bdcf3f93de --- /dev/null +++ b/crates/jcode-setup-hints/src/keymap/macos_hotkeys.rs @@ -0,0 +1,345 @@ +//! Decode macOS system keyboard shortcuts from `com.apple.symbolichotkeys`. +//! +//! macOS stores global shortcuts (Spotlight, Mission Control, screenshots, +//! input-source switching, etc.) under the `AppleSymbolicHotKeys` key of the +//! `com.apple.symbolichotkeys` preference domain. Each entry is: +//! +//! ```text +//! = { enabled = 0/1; value = { parameters = (ascii, keycode, modmask); ... }; } +//! ``` +//! +//! We read it with `defaults export ... | plutil -convert json` and decode the +//! `[ascii, keycode, modmask]` triple into a [`KeyChord`]. The pure decoding +//! logic lives here (and is unit-tested); the subprocess plumbing is isolated in +//! [`read_symbolic_hotkeys`]. + +use super::chord::KeyChord; +use super::source::{DiscoveredBinding, KeySource}; + +/// NSEvent modifier flag bits used in the symbolic-hotkeys `modmask`. +const NS_SHIFT: u64 = 0x0002_0000; +const NS_CONTROL: u64 = 0x0004_0000; +const NS_OPTION: u64 = 0x0008_0000; +const NS_COMMAND: u64 = 0x0010_0000; + +/// Human-readable names for well-known symbolic-hotkey IDs. Only used to make +/// the snapshot and warnings legible; unknown IDs still get decoded. +fn action_name(id: i64) -> String { + let name = match id { + 32 => "Mission Control", + 33 => "Mission Control: Application windows", + 36 => "Show Launchpad", + 60 => "Select previous input source", + 61 => "Select next input source", + 64 => "Spotlight: Show search", + 65 => "Spotlight: Show Finder search window", + 79 => "Move left a space", + 81 => "Move right a space", + // Screenshots + 28 => "Screenshot: Save picture of screen", + 29 => "Screenshot: Copy picture of screen", + 30 => "Screenshot: Save picture of selected area", + 31 => "Screenshot: Copy picture of selected area", + 184 => "Screenshot and recording options", + // Misc + 162 => "Show Notification Center", + 163 => "Toggle Do Not Disturb", + 175 => "Dictation", + _ => "", + }; + if name.is_empty() { + format!("macOS hotkey #{id}") + } else { + name.to_string() + } +} + +/// Map a macOS virtual keycode to a normalized key token. Covers the common +/// keys that appear in default system shortcuts. Returns `None` for keys we do +/// not have a stable mapping for (we then fall back to the ASCII parameter). +fn keycode_to_token(keycode: i64) -> Option<&'static str> { + Some(match keycode { + 0 => "a", + 1 => "s", + 2 => "d", + 3 => "f", + 4 => "h", + 5 => "g", + 6 => "z", + 7 => "x", + 8 => "c", + 9 => "v", + 11 => "b", + 12 => "q", + 13 => "w", + 14 => "e", + 15 => "r", + 16 => "y", + 17 => "t", + 18 => "1", + 19 => "2", + 20 => "3", + 21 => "4", + 22 => "6", + 23 => "5", + 24 => "=", + 25 => "9", + 26 => "7", + 27 => "-", + 28 => "8", + 29 => "0", + 30 => "]", + 31 => "o", + 32 => "u", + 33 => "[", + 34 => "i", + 35 => "p", + 37 => "l", + 38 => "j", + 39 => "'", + 40 => "k", + 41 => ";", + 42 => "\\", + 43 => ",", + 44 => "/", + 45 => "n", + 46 => "m", + 47 => ".", + 50 => "`", + 36 => "enter", + 48 => "tab", + 49 => "space", + 51 => "backspace", + 53 => "esc", + // Function keys + 122 => "f1", + 120 => "f2", + 99 => "f3", + 118 => "f4", + 96 => "f5", + 97 => "f6", + 98 => "f7", + 100 => "f8", + 101 => "f9", + 109 => "f10", + 103 => "f11", + 111 => "f12", + // Arrows + 123 => "left", + 124 => "right", + 125 => "down", + 126 => "up", + _ => return None, + }) +} + +/// Decode a single `[ascii, keycode, modmask]` parameter triple into a chord. +/// +/// `ascii == 65535` (and `keycode == 65535`) means "no key assigned", in which +/// case we return `None`. Prefer the virtual keycode for the key token; fall +/// back to the ASCII value when the keycode is unknown. +pub fn decode_parameters(ascii: i64, keycode: i64, modmask: i64) -> Option { + if keycode == 65535 && ascii == 65535 { + return None; + } + let mask = modmask as u64; + let cmd = mask & NS_COMMAND != 0; + let ctrl = mask & NS_CONTROL != 0; + let alt = mask & NS_OPTION != 0; + let shift = mask & NS_SHIFT != 0; + + let key = if let Some(tok) = keycode_to_token(keycode) { + tok.to_string() + } else if ascii > 0 && ascii < 0x10_FFFF && ascii != 65535 { + // Fall back to the literal character from the ascii parameter. + char::from_u32(ascii as u32) + .filter(|c| !c.is_control()) + .map(|c| c.to_ascii_lowercase().to_string())? + } else { + return None; + }; + + Some(KeyChord::new(cmd, ctrl, alt, shift, &key)) +} + +/// One raw symbolic-hotkey entry, as decoded from JSON. +#[derive(Debug, Clone)] +pub struct RawHotkey { + pub id: i64, + pub enabled: bool, + pub parameters: Option<(i64, i64, i64)>, +} + +/// Parse the JSON produced by `plutil -convert json` of the symbolic-hotkeys +/// domain into raw hotkey entries. Tolerates the parameters being encoded as +/// either JSON numbers or numeric strings (macOS does both). +pub fn parse_symbolic_hotkeys_json(json: &str) -> Vec { + let Ok(value) = serde_json::from_str::(json) else { + return Vec::new(); + }; + let Some(map) = value.get("AppleSymbolicHotKeys").and_then(|v| v.as_object()) else { + return Vec::new(); + }; + + let mut out = Vec::new(); + for (id_str, entry) in map { + let Ok(id) = id_str.parse::() else { + continue; + }; + let enabled = match entry.get("enabled") { + Some(serde_json::Value::Bool(b)) => *b, + Some(serde_json::Value::Number(n)) => n.as_i64().unwrap_or(0) != 0, + _ => false, + }; + let parameters = entry + .get("value") + .and_then(|v| v.get("parameters")) + .and_then(|p| p.as_array()) + .and_then(|arr| { + let nums: Vec = arr.iter().filter_map(json_as_i64).collect(); + if nums.len() >= 3 { + Some((nums[0], nums[1], nums[2])) + } else { + None + } + }); + out.push(RawHotkey { + id, + enabled, + parameters, + }); + } + out +} + +fn json_as_i64(v: &serde_json::Value) -> Option { + match v { + serde_json::Value::Number(n) => n.as_i64(), + serde_json::Value::String(s) => s.trim().parse::().ok(), + _ => None, + } +} + +/// Turn raw hotkey entries into discovered bindings, skipping ones that are +/// disabled or have no assignable key. +pub fn hotkeys_to_bindings(raw: &[RawHotkey]) -> Vec { + let mut out = Vec::new(); + for hk in raw { + if !hk.enabled { + continue; + } + let Some((ascii, keycode, modmask)) = hk.parameters else { + continue; + }; + let Some(chord) = decode_parameters(ascii, keycode, modmask) else { + continue; + }; + out.push(DiscoveredBinding { + chord, + source: KeySource::MacosSystem, + action: action_name(hk.id), + raw: format!("symbolichotkey #{}", hk.id), + }); + } + out +} + +/// Read and decode the live macOS symbolic hotkeys via `defaults` + `plutil`. +/// Returns an empty vec on any failure (missing tools, non-macOS, parse error). +#[cfg(target_os = "macos")] +pub fn read_symbolic_hotkeys() -> Vec { + use std::process::Command; + + let export = Command::new("/usr/bin/defaults") + .args(["export", "com.apple.symbolichotkeys", "-"]) + .output(); + let Ok(export) = export else { + return Vec::new(); + }; + if !export.status.success() || export.stdout.is_empty() { + return Vec::new(); + } + + // Pipe the exported plist through plutil to get JSON. + let mut child = match Command::new("/usr/bin/plutil") + .args(["-convert", "json", "-o", "-", "-"]) + .stdin(std::process::Stdio::piped()) + .stdout(std::process::Stdio::piped()) + .stderr(std::process::Stdio::null()) + .spawn() + { + Ok(c) => c, + Err(_) => return Vec::new(), + }; + + if let Some(mut stdin) = child.stdin.take() { + use std::io::Write; + let _ = stdin.write_all(&export.stdout); + // stdin dropped here, closing the pipe. + } + + let Ok(output) = child.wait_with_output() else { + return Vec::new(); + }; + if !output.status.success() { + return Vec::new(); + } + let json = String::from_utf8_lossy(&output.stdout); + let raw = parse_symbolic_hotkeys_json(&json); + hotkeys_to_bindings(&raw) +} + +#[cfg(not(target_os = "macos"))] +pub fn read_symbolic_hotkeys() -> Vec { + Vec::new() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn decodes_spotlight_cmd_space() { + // [32 (space ascii), 49 (space keycode), 0x100000 (cmd)] + let chord = decode_parameters(32, 49, 1_048_576).unwrap(); + assert_eq!(chord.canonical(), "cmd+space"); + } + + #[test] + fn decodes_input_source_ctrl_space() { + // Select previous input source: [32, 49, 0x40000 (ctrl)] + let chord = decode_parameters(32, 49, 262_144).unwrap(); + assert_eq!(chord.canonical(), "ctrl+space"); + } + + #[test] + fn unassigned_returns_none() { + assert!(decode_parameters(65535, 65535, 0).is_none()); + } + + #[test] + fn combined_modifiers_decode() { + // ctrl+option+cmd = 0x40000 | 0x80000 | 0x100000 = 0x1C0000 + let chord = decode_parameters(0, 40, 0x1C_0000).unwrap(); + assert!(chord.cmd && chord.ctrl && chord.alt && !chord.shift); + assert_eq!(chord.key, "k"); + } + + #[test] + fn parses_json_with_string_and_numeric_params() { + let json = r#"{ + "AppleSymbolicHotKeys": { + "64": {"enabled": 1, "value": {"parameters": ["32", "49", "1048576"]}}, + "60": {"enabled": false, "value": {"parameters": [32, 49, 262144]}}, + "999": {"enabled": 1} + } + }"#; + let raw = parse_symbolic_hotkeys_json(json); + assert_eq!(raw.len(), 3); + let bindings = hotkeys_to_bindings(&raw); + // Only #64 is enabled with params. + assert_eq!(bindings.len(), 1); + assert_eq!(bindings[0].chord.canonical(), "cmd+space"); + assert!(bindings[0].action.contains("Spotlight")); + } +} diff --git a/crates/jcode-setup-hints/src/keymap/mod.rs b/crates/jcode-setup-hints/src/keymap/mod.rs new file mode 100644 index 0000000000..10b1890a30 --- /dev/null +++ b/crates/jcode-setup-hints/src/keymap/mod.rs @@ -0,0 +1,159 @@ +//! Keymap discovery: snapshot the key bindings that exist on the machine +//! (macOS system shortcuts + terminal emulator bindings) so jcode can detect +//! when one of them intercepts a key jcode wants to use. +//! +//! This module is the data layer. It: +//! 1. discovers bindings from each source ([`macos_hotkeys`], [`terminal`]), +//! 2. normalizes them to [`KeyChord`]s ([`chord`]), +//! 3. records them in a durable JSON snapshot ([`KeymapSnapshot`]). +//! +//! Conflict detection against jcode's own bindings is built on top of this in a +//! later layer. + +pub mod chord; +pub mod macos_hotkeys; +pub mod source; +pub mod terminal; + +pub use chord::KeyChord; +pub use source::{DiscoveredBinding, KeySource}; + +use serde::{Deserialize, Serialize}; + +/// Schema version for the on-disk snapshot. Bump when the format changes. +const SNAPSHOT_VERSION: u32 = 1; + +/// A durable record of the key bindings discovered on this machine. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct KeymapSnapshot { + pub version: u32, + /// RFC3339-ish timestamp of when the snapshot was taken. + pub captured_at: String, + pub os: String, + /// Detected terminal label (e.g. "Ghostty"), best-effort. + pub terminal: String, + /// Terminal version string if known. + #[serde(default, skip_serializing_if = "String::is_empty")] + pub terminal_version: String, + /// All discovered bindings, across every source. + pub bindings: Vec, +} + +impl KeymapSnapshot { + /// Bindings originating from a particular source. + pub fn from_source(&self, source: KeySource) -> impl Iterator { + self.bindings.iter().filter(move |b| b.source == source) + } +} + +/// Detect the terminal name in a cross-platform, dependency-light way. On macOS +/// we mirror the detection used elsewhere; on other platforms we fall back to +/// `TERM_PROGRAM`/`TERM`. +fn detect_terminal_label() -> String { + let term_program = std::env::var("TERM_PROGRAM").unwrap_or_default(); + let term = std::env::var("TERM").unwrap_or_default(); + if std::env::var("GHOSTTY_RESOURCES_DIR").is_ok() + || term_program.eq_ignore_ascii_case("ghostty") + || term.to_lowercase().contains("ghostty") + { + return "Ghostty".to_string(); + } + match term_program.to_lowercase().as_str() { + "iterm.app" => "iTerm2".to_string(), + "apple_terminal" => "Terminal.app".to_string(), + "wezterm" => "WezTerm".to_string(), + "vscode" => "VS Code terminal".to_string(), + "" => { + if term.to_lowercase().contains("alacritty") { + "Alacritty".to_string() + } else if term.to_lowercase().contains("kitty") { + "kitty".to_string() + } else { + term + } + } + other => other.to_string(), + } +} + +fn now_timestamp() -> String { + // Avoid pulling in chrono here; seconds since epoch is enough to detect a + // stale snapshot, and we render it back as a readable value at display time. + use std::time::{SystemTime, UNIX_EPOCH}; + SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|d| d.as_secs().to_string()) + .unwrap_or_default() +} + +/// Collect all discovered bindings on the current machine. This shells out to +/// the platform tools (`defaults`/`plutil`, `ghostty +list-keybinds`) and so +/// should be called off the hot path / at startup, not per-frame. +pub fn collect_snapshot() -> KeymapSnapshot { + let mut bindings = Vec::new(); + bindings.extend(macos_hotkeys::read_symbolic_hotkeys()); + bindings.extend(terminal::read_ghostty_keybinds()); + + KeymapSnapshot { + version: SNAPSHOT_VERSION, + captured_at: now_timestamp(), + os: std::env::consts::OS.to_string(), + terminal: detect_terminal_label(), + terminal_version: std::env::var("TERM_PROGRAM_VERSION").unwrap_or_default(), + bindings, + } +} + +/// Path of the on-disk keymap snapshot. +pub fn snapshot_path() -> anyhow::Result { + Ok(jcode_storage::jcode_dir()?.join("keymap-snapshot.json")) +} + +/// Collect a fresh snapshot and persist it to `~/.jcode/keymap-snapshot.json`. +/// Returns the snapshot regardless of whether the write succeeded. +pub fn refresh_and_save() -> KeymapSnapshot { + let snapshot = collect_snapshot(); + if let Ok(path) = snapshot_path() { + if let Err(err) = jcode_storage::write_json(&path, &snapshot) { + jcode_logging::warn(&format!("keymap snapshot write failed: {err}")); + } + } + snapshot +} + +/// Load the last persisted snapshot, if any. +pub fn load_snapshot() -> Option { + let path = snapshot_path().ok()?; + jcode_storage::read_json(&path).ok() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn snapshot_roundtrips_through_json() { + let snap = KeymapSnapshot { + version: SNAPSHOT_VERSION, + captured_at: "123".to_string(), + os: "macos".to_string(), + terminal: "Ghostty".to_string(), + terminal_version: "1.3.1".to_string(), + bindings: vec![DiscoveredBinding { + chord: KeyChord::new(true, false, false, false, "k"), + source: KeySource::Terminal, + action: "clear_screen".to_string(), + raw: "super+k=clear_screen".to_string(), + }], + }; + let json = serde_json::to_string(&snap).unwrap(); + let back: KeymapSnapshot = serde_json::from_str(&json).unwrap(); + assert_eq!(back.bindings.len(), 1); + assert_eq!(back.bindings[0].chord.canonical(), "cmd+k"); + assert_eq!( + back.from_source(KeySource::Terminal).count(), + 1, + "should find the terminal binding" + ); + } +} diff --git a/crates/jcode-setup-hints/src/keymap/source.rs b/crates/jcode-setup-hints/src/keymap/source.rs new file mode 100644 index 0000000000..c994eb2f27 --- /dev/null +++ b/crates/jcode-setup-hints/src/keymap/source.rs @@ -0,0 +1,40 @@ +//! Where a discovered key binding came from, and the binding record itself. + +use serde::{Deserialize, Serialize}; + +use super::chord::KeyChord; + +/// The origin of a discovered binding on the machine. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum KeySource { + /// A macOS system-wide shortcut (`com.apple.symbolichotkeys`). + MacosSystem, + /// A binding declared by the terminal emulator (config or built-in default). + Terminal, +} + +impl KeySource { + pub fn label(self) -> &'static str { + match self { + KeySource::MacosSystem => "macOS system shortcut", + KeySource::Terminal => "terminal", + } + } +} + +/// A key binding discovered on the machine that may intercept input before it +/// reaches jcode. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct DiscoveredBinding { + /// The normalized chord this binding triggers on. + pub chord: KeyChord, + /// Which layer owns this binding. + pub source: KeySource, + /// What the binding does, e.g. "Spotlight: Show search" or + /// "copy_to_clipboard:mixed". + pub action: String, + /// The raw declaration we parsed, for debugging (e.g. the original config + /// line or the symbolic-hotkey id). + pub raw: String, +} diff --git a/crates/jcode-setup-hints/src/keymap/terminal.rs b/crates/jcode-setup-hints/src/keymap/terminal.rs new file mode 100644 index 0000000000..84d2052e07 --- /dev/null +++ b/crates/jcode-setup-hints/src/keymap/terminal.rs @@ -0,0 +1,232 @@ +//! Discover key bindings declared by terminal emulators. +//! +//! Different terminals store bindings in different ways. The most reliable +//! approach for Ghostty is to ask it for its *effective* binding set via +//! `ghostty +list-keybinds`, which merges built-in defaults with the user's +//! config. The parsing is pure and unit-tested; only [`read_ghostty_keybinds`] +//! shells out. + +use super::chord::KeyChord; +use super::source::{DiscoveredBinding, KeySource}; + +/// Parse a single Ghostty keybind line of the form: +/// +/// ```text +/// keybind = super+shift+,=reload_config +/// super+backspace=text:\x17 +/// ``` +/// +/// The left side (up to the first top-level `=`) is the trigger; the right side +/// is the action. The trigger is `mod+mod+key`. Returns `None` for lines that +/// are not bindings (comments, blanks, multi-key sequences we do not model). +pub fn parse_ghostty_keybind_line(line: &str) -> Option { + let mut line = line.trim(); + if line.is_empty() || line.starts_with('#') { + return None; + } + // Strip an optional leading `keybind =` / `keybind:` prefix. + if let Some(rest) = line.strip_prefix("keybind") { + let rest = rest.trim_start(); + let rest = rest.strip_prefix('=').or_else(|| rest.strip_prefix(':'))?; + line = rest.trim(); + } + + // Split trigger=action on the first '='. + let eq = line.find('=')?; + let trigger = line[..eq].trim(); + let action = line[eq + 1..].trim(); + if trigger.is_empty() { + return None; + } + + let chord = parse_trigger(trigger)?; + Some(DiscoveredBinding { + chord, + source: KeySource::Terminal, + action: action.to_string(), + raw: line.to_string(), + }) +} + +/// Parse a `mod+mod+key` trigger into a chord. Returns `None` for triggers that +/// describe a multi-key sequence (Ghostty uses `>` between chords) since we only +/// model single chords for conflict detection. +fn parse_trigger(trigger: &str) -> Option { + if trigger.contains('>') { + return None; + } + // Ghostty exposes a few logical triggers (mapped to the platform's native + // shortcut) that are not real key chords. They can never collide with a + // jcode binding, so drop them to keep the snapshot clean. + if matches!( + trigger.to_ascii_lowercase().as_str(), + "copy" | "paste" | "unbind" | "ignore" + ) { + return None; + } + let mut cmd = false; + let mut ctrl = false; + let mut alt = false; + let mut shift = false; + let mut key: Option = None; + + // Split on '+', but a trailing '+' means the key itself is '+'. + let tokens = split_trigger_tokens(trigger); + for tok in tokens { + match tok.to_ascii_lowercase().as_str() { + "super" | "cmd" | "command" => cmd = true, + "ctrl" | "control" => ctrl = true, + "alt" | "opt" | "option" => alt = true, + "shift" => shift = true, + other => { + // Last non-modifier token wins as the key. + key = Some(other.to_string()); + } + } + } + + let key = key?; + Some(KeyChord::new(cmd, ctrl, alt, shift, &key)) +} + +/// Split a trigger on '+' while treating a literal '+' key correctly. For +/// example `super++` is `["super", "+"]` and `ctrl+shift++` is +/// `["ctrl", "shift", "+"]`. +fn split_trigger_tokens(trigger: &str) -> Vec { + let mut tokens: Vec = Vec::new(); + let mut cur = String::new(); + let chars: Vec = trigger.chars().collect(); + for (i, &c) in chars.iter().enumerate() { + if c == '+' { + if cur.is_empty() { + // A '+' with nothing before it is the literal '+' key. + // Only treat it as the key when it is not a separator between + // two names (i.e. previous char was already a separator). + let is_trailing_or_double = i + 1 == chars.len() || chars[i + 1] == '+'; + if is_trailing_or_double || tokens.is_empty() { + tokens.push("+".to_string()); + continue; + } + } + if !cur.is_empty() { + tokens.push(std::mem::take(&mut cur)); + } + } else { + cur.push(c); + } + } + if !cur.is_empty() { + tokens.push(cur); + } + tokens +} + +/// Parse the full output of `ghostty +list-keybinds` (or a Ghostty config file) +/// into discovered bindings. +pub fn parse_ghostty_keybinds(output: &str) -> Vec { + output + .lines() + .filter_map(parse_ghostty_keybind_line) + .collect() +} + +/// Run `ghostty +list-keybinds` and parse its output. Returns an empty vec on +/// any failure. Tries the bundled macOS binary first, then `ghostty` on PATH. +#[cfg(target_os = "macos")] +pub fn read_ghostty_keybinds() -> Vec { + use std::process::Command; + + const CANDIDATES: [&str; 2] = [ + "/Applications/Ghostty.app/Contents/MacOS/ghostty", + "ghostty", + ]; + for bin in CANDIDATES { + let Ok(output) = Command::new(bin).arg("+list-keybinds").output() else { + continue; + }; + if output.status.success() && !output.stdout.is_empty() { + let text = String::from_utf8_lossy(&output.stdout); + return parse_ghostty_keybinds(&text); + } + } + Vec::new() +} + +#[cfg(not(target_os = "macos"))] +pub fn read_ghostty_keybinds() -> Vec { + use std::process::Command; + let Ok(output) = Command::new("ghostty").arg("+list-keybinds").output() else { + return Vec::new(); + }; + if output.status.success() && !output.stdout.is_empty() { + let text = String::from_utf8_lossy(&output.stdout); + return parse_ghostty_keybinds(&text); + } + Vec::new() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parses_listed_keybind() { + let b = parse_ghostty_keybind_line("keybind = super+c=copy_to_clipboard:mixed").unwrap(); + assert_eq!(b.chord.canonical(), "cmd+c"); + assert_eq!(b.action, "copy_to_clipboard:mixed"); + } + + #[test] + fn parses_bare_config_line() { + let b = parse_ghostty_keybind_line("super+backspace=text:\\x17").unwrap(); + assert_eq!(b.chord.canonical(), "cmd+backspace"); + assert_eq!(b.action, "text:\\x17"); + } + + #[test] + fn parses_named_punctuation_key() { + let b = parse_ghostty_keybind_line("keybind = super+shift+,=reload_config").unwrap(); + assert_eq!(b.chord.canonical(), "cmd+shift+,"); + } + + #[test] + fn parses_digit_key() { + let b = parse_ghostty_keybind_line("keybind = super+digit_1=goto_tab:1").unwrap(); + assert_eq!(b.chord.canonical(), "cmd+1"); + } + + #[test] + fn parses_literal_plus_key() { + let b = parse_ghostty_keybind_line("keybind = super++=increase_font_size:1").unwrap(); + assert_eq!(b.chord.canonical(), "cmd++"); + } + + #[test] + fn skips_comments_and_blanks() { + assert!(parse_ghostty_keybind_line("# a comment").is_none()); + assert!(parse_ghostty_keybind_line(" ").is_none()); + } + + #[test] + fn skips_multi_key_sequences() { + assert!(parse_ghostty_keybind_line("keybind = ctrl+a>n=new_window").is_none()); + } + + #[test] + fn skips_logical_copy_paste_triggers() { + assert!(parse_ghostty_keybind_line("keybind = copy=copy_to_clipboard:mixed").is_none()); + assert!(parse_ghostty_keybind_line("keybind = paste=paste_from_clipboard").is_none()); + } + + #[test] + fn parses_full_output() { + let out = "\ +keybind = super+c=copy_to_clipboard:mixed +keybind = super+v=paste_from_clipboard +# comment +keybind = super+enter=new_window +"; + let binds = parse_ghostty_keybinds(out); + assert_eq!(binds.len(), 3); + } +} diff --git a/crates/jcode-setup-hints/src/lib.rs b/crates/jcode-setup-hints/src/lib.rs index c7f2c319e1..c541fbbc73 100644 --- a/crates/jcode-setup-hints/src/lib.rs +++ b/crates/jcode-setup-hints/src/lib.rs @@ -16,6 +16,8 @@ use serde::{Deserialize, Serialize}; use std::io::{self, IsTerminal}; use std::path::PathBuf; +pub mod keymap; + #[cfg(any(test, target_os = "macos"))] mod macos_launcher; #[cfg(any(test, target_os = "macos"))] From 35b4328ed45ad1f82b2139a5acb979d257dfadfd Mon Sep 17 00:00:00 2001 From: jeremy Date: Tue, 9 Jun 2026 00:34:46 -0700 Subject: [PATCH 06/18] feat(keymap): detect keybinding conflicts + add /keys command Build the conflict-detection and reporting layer on top of the keymap snapshot, and surface it through a new /keys command. - conflicts: enumerate jcode's KeybindingsConfig as chords and diff against the machine snapshot, reporting each overlap tied to its config field (deduplicated per field/source) - report: human-readable diagnostics + a compact status line - mod: snapshot_cached_or_refresh() reuses a <1 day old snapshot, else rescans - /keys (alias /keybindings): show diagnostics; /keys refresh forces a rescan - registered in command list, help overlay, and both dispatch chains --- Cargo.lock | 1 + crates/jcode-setup-hints/Cargo.toml | 1 + crates/jcode-setup-hints/src/keymap/chord.rs | 76 +++- .../jcode-setup-hints/src/keymap/conflicts.rs | 331 ++++++++++++++++++ crates/jcode-setup-hints/src/keymap/mod.rs | 33 ++ crates/jcode-setup-hints/src/keymap/report.rs | 160 +++++++++ crates/jcode-setup-hints/src/keymap/source.rs | 2 +- crates/jcode-tui/src/tui/app/commands.rs | 32 ++ crates/jcode-tui/src/tui/app/input.rs | 1 + crates/jcode-tui/src/tui/app/remote.rs | 1 + .../src/tui/app/state_ui_input_helpers.rs | 4 + crates/jcode-tui/src/tui/ui_overlays.rs | 4 + 12 files changed, 644 insertions(+), 2 deletions(-) create mode 100644 crates/jcode-setup-hints/src/keymap/conflicts.rs create mode 100644 crates/jcode-setup-hints/src/keymap/report.rs diff --git a/Cargo.lock b/Cargo.lock index 1a16a927ad..f6c2194d7e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3991,6 +3991,7 @@ dependencies = [ "dirs", "global-hotkey", "jcode-build-meta", + "jcode-config-types", "jcode-logging", "jcode-storage", "serde", diff --git a/crates/jcode-setup-hints/Cargo.toml b/crates/jcode-setup-hints/Cargo.toml index f4a6f38596..2bb23e01ed 100644 --- a/crates/jcode-setup-hints/Cargo.toml +++ b/crates/jcode-setup-hints/Cargo.toml @@ -12,6 +12,7 @@ path = "src/lib.rs" anyhow = "1" dirs = "5" jcode-build-meta = { path = "../jcode-build-meta" } +jcode-config-types = { path = "../jcode-config-types" } jcode-logging = { path = "../jcode-logging" } jcode-storage = { path = "../jcode-storage" } serde = { version = "1", features = ["derive"] } diff --git a/crates/jcode-setup-hints/src/keymap/chord.rs b/crates/jcode-setup-hints/src/keymap/chord.rs index 5247b17678..49f515114e 100644 --- a/crates/jcode-setup-hints/src/keymap/chord.rs +++ b/crates/jcode-setup-hints/src/keymap/chord.rs @@ -80,8 +80,49 @@ impl KeyChord { parts.join("+") } + /// Parse a jcode-style binding string such as `ctrl+k`, `alt+right`, or + /// `cmd+shift+[` into a chord. Mirrors jcode's own keybinding grammar so the + /// conflict detector compares like with like. Returns `None` for empty or + /// explicitly-disabled bindings (`none`/`off`/`disabled`). + pub fn parse(raw: &str) -> Option { + let raw = raw.trim(); + if raw.is_empty() { + return None; + } + if matches!( + raw.to_ascii_lowercase().as_str(), + "none" | "off" | "disabled" + ) { + return None; + } + + let mut cmd = false; + let mut ctrl = false; + let mut alt = false; + let mut shift = false; + let mut key: Option = None; + + for part in raw.split('+').map(str::trim).filter(|s| !s.is_empty()) { + match part.to_ascii_lowercase().as_str() { + "ctrl" | "control" => ctrl = true, + // jcode treats alt/option/meta as Alt. + "alt" | "option" | "meta" => alt = true, + "cmd" | "command" | "super" | "win" | "windows" => cmd = true, + "shift" => shift = true, + // "backtab" / "shift-tab" imply Shift+Tab. + "backtab" | "shift-tab" => { + shift = true; + key = Some("tab".to_string()); + } + other => key = Some(other.to_string()), + } + } + + let key = key?; + Some(Self::new(cmd, ctrl, alt, shift, &key)) + } + /// Normalize a raw key token (from any source) into a canonical token. - /// /// Handles the differing spellings used by terminals (`arrow_left`, /// `page_up`, `digit_1`) and macOS virtual keycodes, collapsing them onto a /// single vocabulary shared with jcode's own keybinding parser. @@ -192,4 +233,37 @@ mod tests { assert_eq!(a, b); assert_eq!(a.canonical(), b.canonical()); } + + #[test] + fn parses_jcode_binding_strings() { + assert_eq!(KeyChord::parse("ctrl+k").unwrap().canonical(), "ctrl+k"); + assert_eq!(KeyChord::parse("alt+right").unwrap().canonical(), "alt+right"); + assert_eq!( + KeyChord::parse("ctrl+shift+tab").unwrap().canonical(), + "ctrl+shift+tab" + ); + // Command/super alias both map to cmd. + assert_eq!(KeyChord::parse("cmd+j").unwrap().canonical(), "cmd+j"); + assert_eq!(KeyChord::parse("super+j").unwrap().canonical(), "cmd+j"); + // backtab implies shift+tab. + assert_eq!(KeyChord::parse("backtab").unwrap().canonical(), "shift+tab"); + } + + #[test] + fn parse_rejects_disabled_and_empty() { + assert!(KeyChord::parse("").is_none()); + assert!(KeyChord::parse(" ").is_none()); + assert!(KeyChord::parse("none").is_none()); + assert!(KeyChord::parse("OFF").is_none()); + assert!(KeyChord::parse("disabled").is_none()); + } + + #[test] + fn parse_matches_discovered_chord() { + // A jcode binding and a terminal binding for the same physical keys must + // compare equal so the conflict detector can pair them. + let jcode = KeyChord::parse("cmd+k").unwrap(); + let terminal = KeyChord::new(true, false, false, false, "k"); + assert_eq!(jcode, terminal); + } } diff --git a/crates/jcode-setup-hints/src/keymap/conflicts.rs b/crates/jcode-setup-hints/src/keymap/conflicts.rs new file mode 100644 index 0000000000..ebeada35b4 --- /dev/null +++ b/crates/jcode-setup-hints/src/keymap/conflicts.rs @@ -0,0 +1,331 @@ +//! Detect conflicts between jcode's own key bindings and the bindings +//! discovered on the machine (terminal emulator + macOS system shortcuts). +//! +//! The flow is: +//! 1. enumerate jcode's configured bindings as `(field, label, KeyChord)` +//! from [`jcode_config_types::KeybindingsConfig`] ([`jcode_bindings`]), +//! 2. index the discovered machine bindings from a [`KeymapSnapshot`], +//! 3. report every overlap as a [`Conflict`] that names the exact config +//! field so a warning can point the user at the right line. +//! +//! All of this is pure given a `KeybindingsConfig` and a `KeymapSnapshot`, so it +//! is fully unit-testable without touching the machine. + +use std::collections::HashMap; + +use jcode_config_types::KeybindingsConfig; + +use super::chord::KeyChord; +use super::source::{DiscoveredBinding, KeySource}; +use super::KeymapSnapshot; + +/// One configured jcode binding, tied back to its config field. +#[derive(Debug, Clone)] +pub struct JcodeBinding { + /// The dotted config path, e.g. `keybindings.model_switch_next`. + pub field: String, + /// Human-friendly description of what the binding does. + pub action: String, + /// The configured value as written, e.g. `ctrl+tab`. + pub raw: String, + /// The parsed chord. + pub chord: KeyChord, +} + +/// A detected conflict between a jcode binding and something on the machine. +#[derive(Debug, Clone)] +pub struct Conflict { + /// The jcode binding that may not reach the app. + pub jcode: JcodeBinding, + /// The machine binding that intercepts it. + pub interceptor: DiscoveredBinding, +} + +impl Conflict { + /// A single-line, user-facing description of the conflict. + pub fn summary(&self) -> String { + format!( + "{} (`{}` = \"{}\") is also bound by your {} to {}", + self.jcode.action, + self.jcode.field, + self.jcode.raw, + self.interceptor.source.label(), + describe_action(&self.interceptor), + ) + } +} + +fn describe_action(b: &DiscoveredBinding) -> String { + match b.source { + KeySource::MacosSystem => b.action.clone(), + KeySource::Terminal => { + if b.action.is_empty() { + "a terminal action".to_string() + } else { + format!("`{}`", b.action) + } + } + } +} + +/// Enumerate jcode's configured bindings as comparable chords. Bindings that are +/// disabled or fail to parse are skipped. Multi-binding fields (the workspace +/// navigation keys accept a comma-separated list) expand into one entry per +/// chord. +pub fn jcode_bindings(cfg: &KeybindingsConfig) -> Vec { + // (field, action, raw-value) for single-chord fields. + let single: &[(&str, &str, &str)] = &[ + ("scroll_up", "Scroll up", cfg.scroll_up.as_str()), + ("scroll_down", "Scroll down", cfg.scroll_down.as_str()), + ("scroll_page_up", "Page up", cfg.scroll_page_up.as_str()), + ("scroll_page_down", "Page down", cfg.scroll_page_down.as_str()), + ( + "model_switch_next", + "Switch to next model", + cfg.model_switch_next.as_str(), + ), + ( + "model_switch_prev", + "Switch to previous model", + cfg.model_switch_prev.as_str(), + ), + ( + "effort_increase", + "Increase reasoning effort", + cfg.effort_increase.as_str(), + ), + ( + "effort_decrease", + "Decrease reasoning effort", + cfg.effort_decrease.as_str(), + ), + ( + "centered_toggle", + "Toggle centered layout", + cfg.centered_toggle.as_str(), + ), + ( + "scroll_prompt_up", + "Jump to previous prompt", + cfg.scroll_prompt_up.as_str(), + ), + ( + "scroll_prompt_down", + "Jump to next prompt", + cfg.scroll_prompt_down.as_str(), + ), + ( + "scroll_bookmark", + "Toggle scroll bookmark", + cfg.scroll_bookmark.as_str(), + ), + ( + "scroll_up_fallback", + "Scroll up (fallback)", + cfg.scroll_up_fallback.as_str(), + ), + ( + "scroll_down_fallback", + "Scroll down (fallback)", + cfg.scroll_down_fallback.as_str(), + ), + ( + "side_panel_toggle", + "Toggle side panel", + cfg.side_panel_toggle.as_str(), + ), + ( + "copy_selection_toggle", + "Toggle copy/selection mode", + cfg.copy_selection_toggle.as_str(), + ), + ( + "diagram_pane_toggle", + "Toggle diagram pane", + cfg.diagram_pane_toggle.as_str(), + ), + ( + "typing_scroll_lock_toggle", + "Toggle typing scroll lock", + cfg.typing_scroll_lock_toggle.as_str(), + ), + ( + "diff_mode_cycle", + "Cycle diff display mode", + cfg.diff_mode_cycle.as_str(), + ), + ( + "info_widget_toggle", + "Toggle info widget", + cfg.info_widget_toggle.as_str(), + ), + ]; + + let mut out = Vec::new(); + for (field, action, raw) in single { + if let Some(chord) = KeyChord::parse(raw) { + out.push(JcodeBinding { + field: format!("keybindings.{field}"), + action: action.to_string(), + raw: raw.to_string(), + chord, + }); + } + } + + // Multi-binding (comma-separated list) fields. + let multi: [(&str, &str, &str); 4] = [ + ("workspace_left", "Move to left workspace", cfg.workspace_left.as_str()), + ("workspace_down", "Move to lower workspace", cfg.workspace_down.as_str()), + ("workspace_up", "Move to upper workspace", cfg.workspace_up.as_str()), + ("workspace_right", "Move to right workspace", cfg.workspace_right.as_str()), + ]; + for (field, action, raw) in multi { + for piece in raw.split(',') { + let piece = piece.trim(); + if let Some(chord) = KeyChord::parse(piece) { + out.push(JcodeBinding { + field: format!("keybindings.{field}"), + action: action.to_string(), + raw: piece.to_string(), + chord, + }); + } + } + } + + out +} + +/// Find conflicts between jcode's configured bindings and the discovered +/// machine bindings in `snapshot`. +/// +/// Conflicts are deduplicated per `(jcode field, interceptor chord, source)` so +/// a single overlap is reported once even if the snapshot lists the same chord +/// multiple times (Ghostty, for example, lists `super+1` and `super+digit_1`). +pub fn detect_conflicts(cfg: &KeybindingsConfig, snapshot: &KeymapSnapshot) -> Vec { + // Index discovered bindings by chord for O(1) lookup. + let mut by_chord: HashMap<&KeyChord, Vec<&DiscoveredBinding>> = HashMap::new(); + for b in &snapshot.bindings { + by_chord.entry(&b.chord).or_default().push(b); + } + + let mut seen: std::collections::HashSet<(String, String, KeySource)> = + std::collections::HashSet::new(); + let mut conflicts = Vec::new(); + + for jcode in jcode_bindings(cfg) { + let Some(interceptors) = by_chord.get(&jcode.chord) else { + continue; + }; + for interceptor in interceptors { + let dedup_key = ( + jcode.field.clone(), + interceptor.chord.canonical(), + interceptor.source, + ); + if !seen.insert(dedup_key) { + continue; + } + conflicts.push(Conflict { + jcode: jcode.clone(), + interceptor: (*interceptor).clone(), + }); + } + } + + conflicts +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::keymap::source::KeySource; + + fn term_binding(canonical_keys: &str, action: &str) -> DiscoveredBinding { + DiscoveredBinding { + chord: KeyChord::parse(canonical_keys).unwrap(), + source: KeySource::Terminal, + action: action.to_string(), + raw: format!("{canonical_keys}={action}"), + } + } + + fn snapshot_with(bindings: Vec) -> KeymapSnapshot { + KeymapSnapshot { + version: 1, + captured_at: "0".to_string(), + os: "macos".to_string(), + terminal: "Ghostty".to_string(), + terminal_version: String::new(), + bindings, + } + } + + #[test] + fn enumerates_default_bindings() { + let cfg = KeybindingsConfig::default(); + let binds = jcode_bindings(&cfg); + // The defaults include model_switch_next = ctrl+tab. + assert!( + binds + .iter() + .any(|b| b.field == "keybindings.model_switch_next" + && b.chord.canonical() == "ctrl+tab"), + "expected model_switch_next ctrl+tab in {binds:#?}" + ); + // Workspace nav defaults are alt+h/j/k/l. + assert!(binds.iter().any(|b| b.field == "keybindings.workspace_left" + && b.chord.canonical() == "alt+h")); + } + + #[test] + fn detects_ghostty_ctrl_tab_conflict() { + let cfg = KeybindingsConfig::default(); + let snapshot = snapshot_with(vec![ + term_binding("ctrl+tab", "next_tab"), + term_binding("ctrl+shift+tab", "previous_tab"), + ]); + let conflicts = detect_conflicts(&cfg, &snapshot); + let fields: Vec<&str> = conflicts.iter().map(|c| c.jcode.field.as_str()).collect(); + assert!(fields.contains(&"keybindings.model_switch_next")); + assert!(fields.contains(&"keybindings.model_switch_prev")); + let summary = conflicts[0].summary(); + assert!(summary.contains("model"), "summary was: {summary}"); + assert!(summary.contains("terminal"), "summary was: {summary}"); + } + + #[test] + fn no_conflict_when_chords_differ() { + let cfg = KeybindingsConfig::default(); + let snapshot = snapshot_with(vec![term_binding("cmd+t", "new_tab")]); + // jcode has no cmd+t binding by default. + assert!(detect_conflicts(&cfg, &snapshot).is_empty()); + } + + #[test] + fn deduplicates_repeated_interceptor_chords() { + let mut cfg = KeybindingsConfig::default(); + cfg.side_panel_toggle = "cmd+1".to_string(); + // Ghostty lists both super+1 and super+digit_1 for goto_tab:1. + let snapshot = snapshot_with(vec![ + term_binding("cmd+1", "goto_tab:1"), + term_binding("cmd+1", "goto_tab:1"), + ]); + let conflicts = detect_conflicts(&cfg, &snapshot); + assert_eq!(conflicts.len(), 1, "duplicate interceptors should collapse"); + } + + #[test] + fn disabled_binding_is_not_reported() { + let mut cfg = KeybindingsConfig::default(); + cfg.model_switch_next = "none".to_string(); + let snapshot = snapshot_with(vec![term_binding("ctrl+tab", "next_tab")]); + let conflicts = detect_conflicts(&cfg, &snapshot); + assert!( + !conflicts + .iter() + .any(|c| c.jcode.field == "keybindings.model_switch_next") + ); + } +} diff --git a/crates/jcode-setup-hints/src/keymap/mod.rs b/crates/jcode-setup-hints/src/keymap/mod.rs index 10b1890a30..21e7aa6ad5 100644 --- a/crates/jcode-setup-hints/src/keymap/mod.rs +++ b/crates/jcode-setup-hints/src/keymap/mod.rs @@ -11,11 +11,15 @@ //! later layer. pub mod chord; +pub mod conflicts; pub mod macos_hotkeys; +pub mod report; pub mod source; pub mod terminal; pub use chord::KeyChord; +pub use conflicts::{Conflict, JcodeBinding, detect_conflicts, jcode_bindings}; +pub use report::{render_report, render_status_line}; pub use source::{DiscoveredBinding, KeySource}; use serde::{Deserialize, Serialize}; @@ -127,6 +131,35 @@ pub fn load_snapshot() -> Option { jcode_storage::read_json(&path).ok() } +/// Maximum age (seconds) before a cached snapshot is considered stale and +/// refreshed. One day balances freshness against the cost of shelling out. +const SNAPSHOT_MAX_AGE_SECS: u64 = 24 * 60 * 60; + +/// Return a usable snapshot, refreshing from the machine only when there is no +/// cached snapshot or the cached one is older than [`SNAPSHOT_MAX_AGE_SECS`]. +/// This is the entry point intended for startup: cheap on the common path, +/// self-healing when stale. +pub fn snapshot_cached_or_refresh() -> KeymapSnapshot { + if let Some(existing) = load_snapshot() { + if existing.version == SNAPSHOT_VERSION && !snapshot_is_stale(&existing) { + return existing; + } + } + refresh_and_save() +} + +fn snapshot_is_stale(snapshot: &KeymapSnapshot) -> bool { + use std::time::{SystemTime, UNIX_EPOCH}; + let Ok(captured) = snapshot.captured_at.parse::() else { + return true; + }; + let now = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|d| d.as_secs()) + .unwrap_or(0); + now.saturating_sub(captured) > SNAPSHOT_MAX_AGE_SECS +} + #[cfg(test)] mod tests { use super::*; diff --git a/crates/jcode-setup-hints/src/keymap/report.rs b/crates/jcode-setup-hints/src/keymap/report.rs new file mode 100644 index 0000000000..b6a22168b6 --- /dev/null +++ b/crates/jcode-setup-hints/src/keymap/report.rs @@ -0,0 +1,160 @@ +//! Human-readable rendering of the keymap snapshot and detected conflicts, +//! shared by the `/keys` command and the startup conflict hint. + +use jcode_config_types::KeybindingsConfig; + +use super::conflicts::{detect_conflicts, Conflict}; +use super::source::KeySource; +use super::KeymapSnapshot; + +/// Render a full diagnostic report: detected terminal, discovered binding +/// counts, and any conflicts with jcode's configured bindings. +pub fn render_report(cfg: &KeybindingsConfig, snapshot: &KeymapSnapshot) -> String { + let mut out = String::new(); + out.push_str("Keymap diagnostics\n"); + out.push_str(&format!( + "Terminal: {}{}\n", + snapshot.terminal, + if snapshot.terminal_version.is_empty() { + String::new() + } else { + format!(" {}", snapshot.terminal_version) + } + )); + out.push_str(&format!("OS: {}\n", snapshot.os)); + + let term_count = snapshot.from_source(KeySource::Terminal).count(); + let sys_count = snapshot.from_source(KeySource::MacosSystem).count(); + out.push_str(&format!( + "Discovered bindings: {term_count} terminal, {sys_count} macOS system\n", + )); + + if term_count == 0 && sys_count == 0 { + out.push_str( + "\nNo machine bindings were discovered. jcode can read Ghostty bindings and macOS\n\ + system shortcuts; other terminals are not yet inspected, so conflicts there will\n\ + not be detected.\n", + ); + } + + let conflicts = detect_conflicts(cfg, snapshot); + out.push('\n'); + if conflicts.is_empty() { + out.push_str("No conflicts found between your jcode keybindings and the machine.\n"); + } else { + out.push_str(&format!( + "{} potential conflict{} found:\n\n", + conflicts.len(), + if conflicts.len() == 1 { "" } else { "s" } + )); + for c in &conflicts { + out.push_str(&render_conflict_block(c)); + out.push('\n'); + } + out.push_str( + "These keys may be captured by your terminal or macOS before jcode sees them.\n\ + To fix: rebind the jcode action in ~/.jcode/config.toml under [keybindings],\n\ + or change the conflicting shortcut in your terminal / macOS settings.\n", + ); + } + + out +} + +fn render_conflict_block(c: &Conflict) -> String { + let interceptor_desc = match c.interceptor.source { + KeySource::MacosSystem => format!("macOS: {}", c.interceptor.action), + KeySource::Terminal => { + if c.interceptor.action.is_empty() { + "terminal action".to_string() + } else { + format!("terminal: {}", c.interceptor.action) + } + } + }; + format!( + " ⚠ {key}\n jcode: {action} ({field} = \"{raw}\")\n taken by {interceptor}\n", + key = c.jcode.chord.display(), + action = c.jcode.action, + field = c.jcode.field, + raw = c.jcode.raw, + interceptor = interceptor_desc, + ) +} + +/// A compact one-line status string suitable for a startup notice, or `None` +/// when there are no conflicts. +pub fn render_status_line(cfg: &KeybindingsConfig, snapshot: &KeymapSnapshot) -> Option { + let conflicts = detect_conflicts(cfg, snapshot); + if conflicts.is_empty() { + return None; + } + let keys: Vec = conflicts + .iter() + .map(|c| c.jcode.chord.display()) + .collect::>() + .into_iter() + .collect(); + Some(format!( + "Keybinding conflict: {} may be intercepted by your terminal/OS. Run /keys for details.", + keys.join(", ") + )) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::keymap::source::DiscoveredBinding; + use crate::keymap::KeyChord; + + fn snapshot_with(bindings: Vec) -> KeymapSnapshot { + KeymapSnapshot { + version: 1, + captured_at: "0".to_string(), + os: "macos".to_string(), + terminal: "Ghostty".to_string(), + terminal_version: "1.3.1".to_string(), + bindings, + } + } + + fn term(keys: &str, action: &str) -> DiscoveredBinding { + DiscoveredBinding { + chord: KeyChord::parse(keys).unwrap(), + source: KeySource::Terminal, + action: action.to_string(), + raw: format!("{keys}={action}"), + } + } + + #[test] + fn report_lists_conflicts_with_field_names() { + let cfg = KeybindingsConfig::default(); + let snap = snapshot_with(vec![term("ctrl+tab", "next_tab")]); + let report = render_report(&cfg, &snap); + assert!(report.contains("Ghostty 1.3.1")); + assert!(report.contains("keybindings.model_switch_next")); + assert!(report.contains("next_tab")); + assert!(report.contains("Ctrl+Tab")); + } + + #[test] + fn report_says_clean_when_no_conflicts() { + let cfg = KeybindingsConfig::default(); + let snap = snapshot_with(vec![term("cmd+t", "new_tab")]); + let report = render_report(&cfg, &snap); + assert!(report.contains("No conflicts found")); + } + + #[test] + fn status_line_present_only_on_conflict() { + let cfg = KeybindingsConfig::default(); + let clean = snapshot_with(vec![term("cmd+t", "new_tab")]); + assert!(render_status_line(&cfg, &clean).is_none()); + + let dirty = snapshot_with(vec![term("ctrl+tab", "next_tab")]); + let line = render_status_line(&cfg, &dirty).unwrap(); + assert!(line.contains("Ctrl+Tab")); + assert!(line.contains("/keys")); + } +} diff --git a/crates/jcode-setup-hints/src/keymap/source.rs b/crates/jcode-setup-hints/src/keymap/source.rs index c994eb2f27..ef54a6ba95 100644 --- a/crates/jcode-setup-hints/src/keymap/source.rs +++ b/crates/jcode-setup-hints/src/keymap/source.rs @@ -5,7 +5,7 @@ use serde::{Deserialize, Serialize}; use super::chord::KeyChord; /// The origin of a discovered binding on the machine. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] pub enum KeySource { /// A macOS system-wide shortcut (`com.apple.symbolichotkeys`). diff --git a/crates/jcode-tui/src/tui/app/commands.rs b/crates/jcode-tui/src/tui/app/commands.rs index 8e2c6e27b5..cf1244e21e 100644 --- a/crates/jcode-tui/src/tui/app/commands.rs +++ b/crates/jcode-tui/src/tui/app/commands.rs @@ -841,6 +841,38 @@ pub(super) fn handle_help_command(app: &mut App, trimmed: &str) -> bool { false } +/// `/keys` shows the keymap diagnostics: detected terminal, discovered terminal +/// and macOS shortcuts, and any conflicts with jcode's own keybindings. +/// `/keys refresh` forces a fresh scan of the machine (otherwise a cached +/// snapshot up to a day old is reused). +pub(super) fn handle_keys_command(app: &mut App, trimmed: &str) -> bool { + let Some(rest) = slash_command_rest(trimmed, "/keys") + .or_else(|| slash_command_rest(trimmed, "/keybindings")) + else { + return false; + }; + + let force_refresh = matches!(rest.trim(), "refresh" | "rescan" | "reload"); + let snapshot = if force_refresh { + crate::setup_hints::keymap::refresh_and_save() + } else { + crate::setup_hints::keymap::snapshot_cached_or_refresh() + }; + + let cfg = crate::config::config(); + let report = crate::setup_hints::keymap::render_report(&cfg.keybindings, &snapshot); + app.push_display_message(DisplayMessage::system(report)); + + if let Some(status) = + crate::setup_hints::keymap::render_status_line(&cfg.keybindings, &snapshot) + { + app.set_status_notice(status); + } else { + app.set_status_notice("No keybinding conflicts detected"); + } + true +} + pub(super) fn handle_model_status_command(app: &mut App, trimmed: &str) -> bool { let Some(rest) = slash_command_rest(trimmed, "/provider-test-coverage") .or_else(|| slash_command_rest(trimmed, "/model-status")) diff --git a/crates/jcode-tui/src/tui/app/input.rs b/crates/jcode-tui/src/tui/app/input.rs index f9a35346c2..d0c5d2e47f 100644 --- a/crates/jcode-tui/src/tui/app/input.rs +++ b/crates/jcode-tui/src/tui/app/input.rs @@ -2761,6 +2761,7 @@ impl App { let trimmed = input.trim(); let handled = commands::handle_help_command(self, trimmed) + || commands::handle_keys_command(self, trimmed) || commands::handle_ssh_command(self, trimmed) || commands::handle_session_command(self, trimmed) || commands::handle_dictation_command(self, trimmed) diff --git a/crates/jcode-tui/src/tui/app/remote.rs b/crates/jcode-tui/src/tui/app/remote.rs index 5f69f0bd3e..8b124404cd 100644 --- a/crates/jcode-tui/src/tui/app/remote.rs +++ b/crates/jcode-tui/src/tui/app/remote.rs @@ -1387,6 +1387,7 @@ async fn parse_and_inject_key( fn handle_disconnected_local_command(app: &mut App, trimmed: &str) -> bool { let handled = super::commands::handle_help_command(app, trimmed) + || super::commands::handle_keys_command(app, trimmed) || super::commands::handle_session_command(app, trimmed) || super::commands::handle_test_command(app, trimmed) || super::commands::handle_disabled_mission_command(app, trimmed) diff --git a/crates/jcode-tui/src/tui/app/state_ui_input_helpers.rs b/crates/jcode-tui/src/tui/app/state_ui_input_helpers.rs index 0b4e159e44..4b5733df59 100644 --- a/crates/jcode-tui/src/tui/app/state_ui_input_helpers.rs +++ b/crates/jcode-tui/src/tui/app/state_ui_input_helpers.rs @@ -112,6 +112,10 @@ const REGISTERED_COMMANDS: &[RegisteredCommand] = &[ RegisteredCommand::public("/subscription", "Show jcode subscription status"), RegisteredCommand::public("/config", "Show or edit configuration"), RegisteredCommand::public("/log", "Mark the current location in the jcode logs"), + RegisteredCommand::public( + "/keys", + "Show keybinding conflicts with your terminal and OS (/keys refresh to rescan)", + ), RegisteredCommand::public( "/diff", "Cycle or set diff display mode (off/inline/full/pinned/file)", diff --git a/crates/jcode-tui/src/tui/ui_overlays.rs b/crates/jcode-tui/src/tui/ui_overlays.rs index 34264780eb..7b4b1722e8 100644 --- a/crates/jcode-tui/src/tui/ui_overlays.rs +++ b/crates/jcode-tui/src/tui/ui_overlays.rs @@ -168,6 +168,10 @@ pub(super) fn draw_help_overlay(frame: &mut Frame, area: Rect, scroll: usize, ap "Show loaded skills and jcode-endorsed recommendations", )); lines.push(help_entry("/info", "Show session info and token usage")); + lines.push(help_entry( + "/keys", + "Show keybinding conflicts with your terminal/OS", + )); lines.push(help_entry("/usage", "Show connected provider usage limits")); lines.push(help_entry("/version", "Show version and build details")); lines.push(help_entry( From 2f37c16fe2f432ed068ca6bea5d29295ec45fd63 Mon Sep 17 00:00:00 2001 From: jeremy Date: Tue, 9 Jun 2026 00:40:48 -0700 Subject: [PATCH 07/18] feat(keymap): one-time startup notice for keybinding conflicts Surface keybinding conflicts proactively at launch, not only via /keys. - conflict_signature(): stable, order-independent signature of a conflict set - SetupHintsState.keymap_conflict_signature: debounce so users are warned once per distinct conflict set and never nagged on every launch - maybe_show_keymap_conflict_hint(&KeybindingsConfig): config-aware startup notice, gated on a real TTY and on the signature changing - wired into the client launch path, deferring to existing setup hints so it never clobbers an early-launch alignment/welcome/terminal tip --- .../jcode-setup-hints/src/keymap/conflicts.rs | 46 +++++++++++++++ crates/jcode-setup-hints/src/keymap/mod.rs | 2 +- crates/jcode-setup-hints/src/lib.rs | 56 +++++++++++++++++++ src/cli/dispatch.rs | 8 ++- 4 files changed, 110 insertions(+), 2 deletions(-) diff --git a/crates/jcode-setup-hints/src/keymap/conflicts.rs b/crates/jcode-setup-hints/src/keymap/conflicts.rs index ebeada35b4..59686968d6 100644 --- a/crates/jcode-setup-hints/src/keymap/conflicts.rs +++ b/crates/jcode-setup-hints/src/keymap/conflicts.rs @@ -237,6 +237,27 @@ pub fn detect_conflicts(cfg: &KeybindingsConfig, snapshot: &KeymapSnapshot) -> V conflicts } +/// A stable signature for a set of conflicts, used to decide whether to re-warn +/// the user. Two runs that find the same conflicts (regardless of order) +/// produce the same signature; any change (new conflict, resolved conflict, +/// rebind) produces a different one. +pub fn conflict_signature(conflicts: &[Conflict]) -> String { + let mut parts: Vec = conflicts + .iter() + .map(|c| { + format!( + "{}|{}|{}", + c.jcode.field, + c.jcode.chord.canonical(), + c.interceptor.chord.canonical() + ) + }) + .collect(); + parts.sort(); + parts.dedup(); + parts.join(";") +} + #[cfg(test)] mod tests { use super::*; @@ -328,4 +349,29 @@ mod tests { .any(|c| c.jcode.field == "keybindings.model_switch_next") ); } + + #[test] + fn signature_is_order_independent_and_changes_on_diff() { + let cfg = KeybindingsConfig::default(); + let snap_a = snapshot_with(vec![ + term_binding("ctrl+tab", "next_tab"), + term_binding("ctrl+shift+tab", "previous_tab"), + ]); + // Same conflicts, reversed discovery order. + let snap_b = snapshot_with(vec![ + term_binding("ctrl+shift+tab", "previous_tab"), + term_binding("ctrl+tab", "next_tab"), + ]); + let sig_a = conflict_signature(&detect_conflicts(&cfg, &snap_a)); + let sig_b = conflict_signature(&detect_conflicts(&cfg, &snap_b)); + assert_eq!(sig_a, sig_b, "signature must be order-independent"); + + let snap_c = snapshot_with(vec![term_binding("ctrl+tab", "next_tab")]); + let sig_c = conflict_signature(&detect_conflicts(&cfg, &snap_c)); + assert_ne!(sig_a, sig_c, "different conflict set => different signature"); + + // No conflicts => empty signature. + let clean = snapshot_with(vec![term_binding("cmd+t", "new_tab")]); + assert_eq!(conflict_signature(&detect_conflicts(&cfg, &clean)), ""); + } } diff --git a/crates/jcode-setup-hints/src/keymap/mod.rs b/crates/jcode-setup-hints/src/keymap/mod.rs index 21e7aa6ad5..65d1c73ca6 100644 --- a/crates/jcode-setup-hints/src/keymap/mod.rs +++ b/crates/jcode-setup-hints/src/keymap/mod.rs @@ -18,7 +18,7 @@ pub mod source; pub mod terminal; pub use chord::KeyChord; -pub use conflicts::{Conflict, JcodeBinding, detect_conflicts, jcode_bindings}; +pub use conflicts::{Conflict, JcodeBinding, conflict_signature, detect_conflicts, jcode_bindings}; pub use report::{render_report, render_status_line}; pub use source::{DiscoveredBinding, KeySource}; diff --git a/crates/jcode-setup-hints/src/lib.rs b/crates/jcode-setup-hints/src/lib.rs index c541fbbc73..1576de72f2 100644 --- a/crates/jcode-setup-hints/src/lib.rs +++ b/crates/jcode-setup-hints/src/lib.rs @@ -66,6 +66,13 @@ pub struct SetupHintsState { /// the hotkey actually fire). `0` = legacy/unknown. #[serde(default)] pub hotkey_listener_version: u32, + /// Canonical signature of the keybinding conflicts we last warned the user + /// about (sorted, joined chord+field pairs). Empty means "no conflicts known + /// / never warned". We only re-show the startup conflict notice when this + /// signature changes, so users are warned once per distinct conflict set and + /// never nagged about the same conflicts on every launch. + #[serde(default)] + pub keymap_conflict_signature: String, } /// Current macOS hotkey listener implementation version. @@ -706,6 +713,55 @@ pub fn maybe_show_setup_hints() -> Option { } } +/// Check whether jcode's keybindings conflict with shortcuts owned by the +/// terminal or the OS, and return a one-time startup notice when the set of +/// conflicts has changed since we last warned. +/// +/// This is config-aware (the caller passes the user's live keybindings) and +/// debounced via a stored signature: a user is warned once per distinct +/// conflict set and never nagged about the same conflicts on subsequent +/// launches. Returns `None` when there are no conflicts, when nothing changed, +/// or when input is not a real TTY. +/// +/// The actual diagnostics are always available on demand via the `/keys` +/// command; this only surfaces the proactive heads-up. +pub fn maybe_show_keymap_conflict_hint( + keybindings: &jcode_config_types::KeybindingsConfig, +) -> Option { + if !io::stdin().is_terminal() || !io::stderr().is_terminal() { + return None; + } + + let snapshot = keymap::snapshot_cached_or_refresh(); + let conflicts = keymap::detect_conflicts(keybindings, &snapshot); + let signature = keymap::conflict_signature(&conflicts); + + let mut state = SetupHintsState::load(); + if signature == state.keymap_conflict_signature { + // Either no conflicts (empty signature already stored) or the same + // conflicts we already told them about. Stay quiet. + return None; + } + + // Record the new signature regardless, so we only warn once per change. + state.keymap_conflict_signature = signature.clone(); + let _ = state.save(); + + if conflicts.is_empty() { + // Conflicts were resolved since last time: update the stored signature + // (done above) but do not show a notice. + return None; + } + + let status = keymap::render_status_line(keybindings, &snapshot)?; + let display = keymap::render_report(keybindings, &snapshot); + Some(StartupHints::with_status_and_display( + status, + "Keybindings", + display, + )) +} + /// Manual `jcode setup-launcher` command. pub fn run_setup_launcher() -> Result<()> { #[cfg(target_os = "macos")] diff --git a/src/cli/dispatch.rs b/src/cli/dispatch.rs index 9c7dddae06..a2737c6d79 100644 --- a/src/cli/dispatch.rs +++ b/src/cli/dispatch.rs @@ -723,7 +723,13 @@ async fn run_default_command(args: Args) -> Result<()> { let startup_hints = if args.fresh_spawn { None } else { - setup_hints::maybe_show_setup_hints() + // Prefer existing setup hints (alignment/welcome/terminal nudges); only + // surface the keybinding-conflict heads-up when nothing else is queued, + // so we never clobber an early-launch tip. The conflict hint is + // self-debouncing (shown once per distinct conflict set). + setup_hints::maybe_show_setup_hints().or_else(|| { + setup_hints::maybe_show_keymap_conflict_hint(&crate::config::config().keybindings) + }) }; startup_profile::mark("setup_hints"); From 2ef2d0082c2d717c436f65967537a10c7591ddf6 Mon Sep 17 00:00:00 2001 From: jeremy Date: Tue, 9 Jun 2026 00:42:05 -0700 Subject: [PATCH 08/18] refactor(keymap): extract pure conflict-hint debounce decision + test Split the warn-once-per-change policy out of maybe_show_keymap_conflict_hint into a pure conflict_hint_decision() so it is unit-testable without I/O. Covers: unchanged, newly-conflicting, resolved-silently, and changed-set. --- crates/jcode-setup-hints/src/lib.rs | 69 +++++++++++++------ .../src/setup_hints_tests.rs | 28 ++++++++ 2 files changed, 75 insertions(+), 22 deletions(-) diff --git a/crates/jcode-setup-hints/src/lib.rs b/crates/jcode-setup-hints/src/lib.rs index 1576de72f2..8462e9261b 100644 --- a/crates/jcode-setup-hints/src/lib.rs +++ b/crates/jcode-setup-hints/src/lib.rs @@ -713,6 +713,33 @@ pub fn maybe_show_setup_hints() -> Option { } } +/// Pure debounce decision for the keybinding-conflict notice. +/// +/// Given the freshly-computed conflict `signature` and the `previous` signature +/// we last stored, decide what to do. Separated from I/O so the +/// warn-once-per-change policy can be unit-tested without touching the machine +/// or the filesystem. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum ConflictHintDecision { + /// Nothing changed since last time; stay silent and leave state untouched. + Unchanged, + /// The conflict set changed but is now empty (resolved); update the stored + /// signature but show nothing. + ResolvedSilently, + /// New or changed conflicts; update the stored signature and show a notice. + Warn, +} + +pub(crate) fn conflict_hint_decision(signature: &str, previous: &str) -> ConflictHintDecision { + if signature == previous { + ConflictHintDecision::Unchanged + } else if signature.is_empty() { + ConflictHintDecision::ResolvedSilently + } else { + ConflictHintDecision::Warn + } +} + /// Check whether jcode's keybindings conflict with shortcuts owned by the /// terminal or the OS, and return a one-time startup notice when the set of /// conflicts has changed since we last warned. @@ -737,29 +764,27 @@ pub fn maybe_show_keymap_conflict_hint( let signature = keymap::conflict_signature(&conflicts); let mut state = SetupHintsState::load(); - if signature == state.keymap_conflict_signature { - // Either no conflicts (empty signature already stored) or the same - // conflicts we already told them about. Stay quiet. - return None; - } - - // Record the new signature regardless, so we only warn once per change. - state.keymap_conflict_signature = signature.clone(); - let _ = state.save(); - - if conflicts.is_empty() { - // Conflicts were resolved since last time: update the stored signature - // (done above) but do not show a notice. - return None; + let decision = conflict_hint_decision(&signature, &state.keymap_conflict_signature); + + match decision { + ConflictHintDecision::Unchanged => None, + ConflictHintDecision::ResolvedSilently => { + state.keymap_conflict_signature = signature; + let _ = state.save(); + None + } + ConflictHintDecision::Warn => { + state.keymap_conflict_signature = signature; + let _ = state.save(); + let status = keymap::render_status_line(keybindings, &snapshot)?; + let display = keymap::render_report(keybindings, &snapshot); + Some(StartupHints::with_status_and_display( + status, + "Keybindings", + display, + )) + } } - - let status = keymap::render_status_line(keybindings, &snapshot)?; - let display = keymap::render_report(keybindings, &snapshot); - Some(StartupHints::with_status_and_display( - status, - "Keybindings", - display, - )) } /// Manual `jcode setup-launcher` command. diff --git a/crates/jcode-setup-hints/src/setup_hints_tests.rs b/crates/jcode-setup-hints/src/setup_hints_tests.rs index 62af9ad5fa..4bd339e4a6 100644 --- a/crates/jcode-setup-hints/src/setup_hints_tests.rs +++ b/crates/jcode-setup-hints/src/setup_hints_tests.rs @@ -213,3 +213,31 @@ fn nudge_budget_caps_at_max_and_persists() { assert_eq!(state.terminal_nudge_count, MAX_TERMINAL_NUDGES); assert!(!state.nudge_budget_remaining()); } + +#[test] +fn conflict_hint_decision_warns_only_when_conflicts_change() { + // No conflicts ever: empty == empty => stay silent. + assert_eq!(conflict_hint_decision("", ""), ConflictHintDecision::Unchanged); + + // New conflicts where there were none: warn. + assert_eq!( + conflict_hint_decision("keybindings.model_switch_next|ctrl+tab|ctrl+tab", ""), + ConflictHintDecision::Warn + ); + + // Same conflicts as last time: stay silent. + let sig = "keybindings.model_switch_next|ctrl+tab|ctrl+tab"; + assert_eq!(conflict_hint_decision(sig, sig), ConflictHintDecision::Unchanged); + + // Conflicts resolved since last time (had some, now none): update silently. + assert_eq!( + conflict_hint_decision("", sig), + ConflictHintDecision::ResolvedSilently + ); + + // Conflict set changed (different conflicts): warn again. + assert_eq!( + conflict_hint_decision("keybindings.scroll_up|ctrl+k|ctrl+k", sig), + ConflictHintDecision::Warn + ); +} From 043aeae51c8a5c150feb3e4678f4398b92168c30 Mon Sep 17 00:00:00 2001 From: jeremy Date: Tue, 9 Jun 2026 00:43:50 -0700 Subject: [PATCH 09/18] test(keymap): cover full conflict-hint path (warn/debounce/resolve) Extract keymap_conflict_hint_for() so the decision + state-update path is testable without TTY/disk I/O, and add an integration test exercising first-warn, debounce, and resolved-silently transitions. --- crates/jcode-setup-hints/src/lib.rs | 43 ++++++++++------ .../src/setup_hints_tests.rs | 51 +++++++++++++++++++ 2 files changed, 78 insertions(+), 16 deletions(-) diff --git a/crates/jcode-setup-hints/src/lib.rs b/crates/jcode-setup-hints/src/lib.rs index 8462e9261b..065ec03bb7 100644 --- a/crates/jcode-setup-hints/src/lib.rs +++ b/crates/jcode-setup-hints/src/lib.rs @@ -760,29 +760,40 @@ pub fn maybe_show_keymap_conflict_hint( } let snapshot = keymap::snapshot_cached_or_refresh(); - let conflicts = keymap::detect_conflicts(keybindings, &snapshot); - let signature = keymap::conflict_signature(&conflicts); - let mut state = SetupHintsState::load(); - let decision = conflict_hint_decision(&signature, &state.keymap_conflict_signature); + let (hint, changed) = keymap_conflict_hint_for(keybindings, &snapshot, &mut state); + if changed { + let _ = state.save(); + } + hint +} + +/// Core of [`maybe_show_keymap_conflict_hint`], separated from TTY detection and +/// disk I/O so the full decision + state-update path is unit-testable. +/// +/// Returns the optional notice and whether `state` was mutated (and therefore +/// should be persisted by the caller). +pub(crate) fn keymap_conflict_hint_for( + keybindings: &jcode_config_types::KeybindingsConfig, + snapshot: &keymap::KeymapSnapshot, + state: &mut SetupHintsState, +) -> (Option, bool) { + let conflicts = keymap::detect_conflicts(keybindings, snapshot); + let signature = keymap::conflict_signature(&conflicts); - match decision { - ConflictHintDecision::Unchanged => None, + match conflict_hint_decision(&signature, &state.keymap_conflict_signature) { + ConflictHintDecision::Unchanged => (None, false), ConflictHintDecision::ResolvedSilently => { state.keymap_conflict_signature = signature; - let _ = state.save(); - None + (None, true) } ConflictHintDecision::Warn => { state.keymap_conflict_signature = signature; - let _ = state.save(); - let status = keymap::render_status_line(keybindings, &snapshot)?; - let display = keymap::render_report(keybindings, &snapshot); - Some(StartupHints::with_status_and_display( - status, - "Keybindings", - display, - )) + let hint = keymap::render_status_line(keybindings, snapshot).map(|status| { + let display = keymap::render_report(keybindings, snapshot); + StartupHints::with_status_and_display(status, "Keybindings", display) + }); + (hint, true) } } } diff --git a/crates/jcode-setup-hints/src/setup_hints_tests.rs b/crates/jcode-setup-hints/src/setup_hints_tests.rs index 4bd339e4a6..715ad186a8 100644 --- a/crates/jcode-setup-hints/src/setup_hints_tests.rs +++ b/crates/jcode-setup-hints/src/setup_hints_tests.rs @@ -241,3 +241,54 @@ fn conflict_hint_decision_warns_only_when_conflicts_change() { ConflictHintDecision::Warn ); } + +#[test] +fn keymap_conflict_hint_full_path_debounces_and_persists_signature() { + use crate::keymap::source::{DiscoveredBinding, KeySource}; + use crate::keymap::{KeyChord, KeymapSnapshot}; + use jcode_config_types::KeybindingsConfig; + + fn snapshot(bindings: Vec) -> KeymapSnapshot { + KeymapSnapshot { + version: 1, + captured_at: "0".to_string(), + os: "macos".to_string(), + terminal: "Ghostty".to_string(), + terminal_version: "1.3.1".to_string(), + bindings, + } + } + fn term(keys: &str, action: &str) -> DiscoveredBinding { + DiscoveredBinding { + chord: KeyChord::parse(keys).unwrap(), + source: KeySource::Terminal, + action: action.to_string(), + raw: format!("{keys}={action}"), + } + } + + let cfg = KeybindingsConfig::default(); + let mut state = SetupHintsState::default(); + + // 1) First time with a real conflict: warn + state changes. + let conflicting = snapshot(vec![term("ctrl+tab", "next_tab")]); + let (hint, changed) = keymap_conflict_hint_for(&cfg, &conflicting, &mut state); + assert!(hint.is_some(), "should warn on first conflict"); + assert!(changed, "state signature should be recorded"); + let (title, body) = hint.unwrap().display_message.unwrap(); + assert_eq!(title, "Keybindings"); + assert!(body.contains("keybindings.model_switch_next")); + assert!(!state.keymap_conflict_signature.is_empty()); + + // 2) Same conflict again: debounced, no state change. + let (hint2, changed2) = keymap_conflict_hint_for(&cfg, &conflicting, &mut state); + assert!(hint2.is_none(), "same conflict set must not re-warn"); + assert!(!changed2, "no state change when nothing changed"); + + // 3) Conflict resolved (clean snapshot): silent, but signature cleared. + let clean = snapshot(vec![term("cmd+t", "new_tab")]); + let (hint3, changed3) = keymap_conflict_hint_for(&cfg, &clean, &mut state); + assert!(hint3.is_none(), "resolved conflicts show nothing"); + assert!(changed3, "signature should be cleared"); + assert!(state.keymap_conflict_signature.is_empty()); +} From 0fde2dc1000e5f09a85c0741c892b29b0018334c Mon Sep 17 00:00:00 2001 From: jeremy Date: Tue, 9 Jun 2026 00:58:48 -0700 Subject: [PATCH 10/18] docs: document keybinding conflict detection and /keys --- docs/KEYMAP_CONFLICTS.md | 88 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 88 insertions(+) create mode 100644 docs/KEYMAP_CONFLICTS.md diff --git a/docs/KEYMAP_CONFLICTS.md b/docs/KEYMAP_CONFLICTS.md new file mode 100644 index 0000000000..1bc344c374 --- /dev/null +++ b/docs/KEYMAP_CONFLICTS.md @@ -0,0 +1,88 @@ +# Keybinding conflict detection + +jcode runs inside a terminal, which runs inside an OS. Both layers can claim a +key chord before it ever reaches jcode (for example Ghostty binding `Ctrl+Tab` +to "next tab", or macOS binding `Cmd+Space` to Spotlight). When that happens, a +jcode keybinding silently does nothing and it is not obvious why. + +This feature discovers the key bindings that exist on the machine, compares them +against jcode's own configured bindings, and warns about overlaps. + +## What it can and cannot detect + +**Can detect (config-declared intercepts):** + +- **macOS system shortcuts** read from `com.apple.symbolichotkeys` (Spotlight, + Mission Control, screenshots, input-source switching, etc.). Only shortcuts + that are *enabled* are considered. +- **Terminal emulator bindings.** Currently Ghostty, via + `ghostty +list-keybinds`, which reports the *effective* binding set (built-in + defaults merged with the user's config). This also catches rewrites such as + Ghostty mapping `Alt+Left`/`Alt+Right` to word-navigation escape sequences. + +**Cannot detect:** + +- Ad-hoc remappers (Karabiner-Elements, BetterTouchTool), window managers, or + global launcher hotkeys that are not stored in a file we read. +- Terminals other than Ghostty (yet). Adding one is a self-contained adapter + (see "Adding a terminal adapter" below). + +It is a snapshot taken at startup, not a live hook, so changes made while jcode +is running are not seen until the snapshot is refreshed. + +## How it surfaces + +- **`/keys`** prints a full report: detected terminal, discovered binding + counts, and each conflict tied to the exact `[keybindings]` config field that + owns it. `/keys refresh` forces a rescan of the machine (otherwise a cached + snapshot up to a day old is reused). +- **Startup notice.** On launch, if the set of conflicts has *changed* since the + last time we warned, jcode shows a one-time heads-up pointing at `/keys`. It is + debounced by a signature of the conflict set, so users are warned once per + distinct set of conflicts and never nagged on every launch. + +## Resolving a conflict + +The report names the conflicting jcode action and its config field, e.g.: + +``` + ⚠ Ctrl+Tab + jcode: Switch to next model (keybindings.model_switch_next = "ctrl+tab") + taken by terminal: next_tab +``` + +To fix, either: + +- rebind the jcode action in `~/.jcode/config.toml` under `[keybindings]` + (e.g. `model_switch_next = "ctrl+shift+m"`), or +- change the conflicting shortcut in your terminal or OS settings. + +## Implementation + +All logic lives in `crates/jcode-setup-hints/src/keymap/`: + +- `chord.rs` - `KeyChord`, a normalized `(cmd/ctrl/alt/shift + key)` that unifies + the different key spellings each source uses, plus `KeyChord::parse` for + jcode's own binding-string grammar. +- `macos_hotkeys.rs` - decode `com.apple.symbolichotkeys` + `[ascii, keycode, modmask]` triples (pure logic + a thin subprocess wrapper). +- `terminal.rs` - parse `ghostty +list-keybinds` output (pure logic + wrapper). +- `source.rs` - `DiscoveredBinding` and its `KeySource`. +- `conflicts.rs` - enumerate `KeybindingsConfig` as chords, diff against a + snapshot, and produce `Conflict`s keyed to config fields. `conflict_signature` + produces the stable signature used for startup debounce. +- `report.rs` - render the human-readable report and the compact status line. +- `mod.rs` - `collect_snapshot` / `refresh_and_save` / `snapshot_cached_or_refresh` + (persisted to `~/.jcode/keymap-snapshot.json`). + +The pure parsing/decoding/diffing functions are unit-tested and do not touch the +machine; only the `read_*` wrappers shell out. + +## Adding a terminal adapter + +1. Add a `read__keybinds()` in `terminal.rs` (or a sibling module) that + produces `Vec` with `source: KeySource::Terminal`, keeping + the parser pure and the subprocess/file read thin. +2. Call it from `collect_snapshot()` in `mod.rs`, ideally gated on the detected + terminal so we do not shell out to tools that are not present. +3. Add unit tests for the parser using sample config/output text. From 69a7202065c2bf004d0c521650a86dd97be9ecfb Mon Sep 17 00:00:00 2001 From: jcode Date: Thu, 25 Jun 2026 19:44:28 -0700 Subject: [PATCH 11/18] docs(scrollwm): integration exploration + synthesis plan --- docs/scrollwm-integration/BRIEF.md | 57 ++ docs/scrollwm-integration/PLAN.md | 100 ++++ .../explorers/control-plane.md | 496 +++++++++++++++++ .../explorers/onboarding-notfound.md | 377 +++++++++++++ .../explorers/onboarding-optin.md | 517 ++++++++++++++++++ .../explorers/repo-distribution.md | 351 ++++++++++++ .../explorers/swarm-orchestration.md | 292 ++++++++++ 7 files changed, 2190 insertions(+) create mode 100644 docs/scrollwm-integration/BRIEF.md create mode 100644 docs/scrollwm-integration/PLAN.md create mode 100644 docs/scrollwm-integration/explorers/control-plane.md create mode 100644 docs/scrollwm-integration/explorers/onboarding-notfound.md create mode 100644 docs/scrollwm-integration/explorers/onboarding-optin.md create mode 100644 docs/scrollwm-integration/explorers/repo-distribution.md create mode 100644 docs/scrollwm-integration/explorers/swarm-orchestration.md diff --git a/docs/scrollwm-integration/BRIEF.md b/docs/scrollwm-integration/BRIEF.md new file mode 100644 index 0000000000..b0a52efc51 --- /dev/null +++ b/docs/scrollwm-integration/BRIEF.md @@ -0,0 +1,57 @@ +# ScrollWM x jcode integration - explorer brief + +You are one of several parallel **headed** explorer agents. Goal: explore how to +integrate **ScrollWM** (a Swift macOS scrolling window manager) with **jcode** +(this Rust TUI agent), and how to link the two repos. Each explorer owns ONE +area, investigates deeply in the real code, and writes a findings doc. + +## The two repos (read-only unless told otherwise) +- ScrollWM: `/Users/jeremy/scrollwm` (Swift, SwiftPM, menu-bar app). + - Control plane: `Sources/WindowLab/ControlServer.swift` (Unix socket), + `ControlCommands.swift` (verbs: ping/status/arrange/release/toggle/focus/ + move/workspace/width/close/display/focus-mode/reload/update/quit), + `ControlCLI.swift` (`scrollwm ` shim), `Config.swift` (keybinds, and a + commented-out `ctrl+opt+j` jcode launcher + `spawn` map). + - Socket path: `~/Library/Application Support/ScrollWM/control.sock`, override + via `SCROLLWM_CONTROL_SOCK`. status returns JSON. + - Install: `scripts/web-install.sh` (curl|bash), Homebrew cask + `1jehuang/scrollwm/scrollwm`, `scripts/install.sh` (build from source). + One permission: Accessibility. git remote: github.com/1jehuang/scrollwm. +- jcode: `/Users/jeremy/jcode` (Rust). Relevant crates: + - `jcode-swarm-core` (multi-agent swarm; `is_headless`), swarm spawn modes in + `jcode-config-types` `SwarmSpawnMode` (visible/headless/inline/auto), + spawn wiring in `jcode-app-core/src/server/client_lifecycle.rs`. + - `jcode-terminal-launch` (`open -na Ghostty ...` visible terminal spawn), + `jcode-setup-hints` (startup nudges, `~/.jcode/setup_hints.json`, + macOS terminal/hotkey setup, the nudge cap pattern). + - Onboarding: `jcode-tui/src/tui/app/onboarding_flow.rs` (state machine, + `OnboardingPhase`), `onboarding_flow_control.rs` (transitions), + `ui_onboarding.rs` (render of decision rows with Yes/No selectors), + `jcode-app-core/src/external_auth.rs` + (`pending_external_auth_review_candidates` = what is searched/found). + - There is a `~/Desktop/scrollwm + jcode demo.mov` already. + +## The product goals (from the user) +1. Integrate jcode with ScrollWM: when jcode spawns **headed** swarm agents, + ScrollWM should arrange them nicely (strip columns / workspaces), and jcode + should be able to drive ScrollWM (focus the active agent, etc). +2. Onboarding opt-in: during jcode onboarding, offer to install + set up + ScrollWM (Accessibility permission) alongside jcode. +3. Onboarding "not found" transparency: on any onboarding decision row (e.g. the + external-login import walkthrough), ALSO show the list of things we searched + for but did NOT find, beneath the row. Add scrolling if the list overflows. + +## Your deliverable +Write `docs/scrollwm-integration/explorers/.md` in the jcode repo with: +- Concrete integration design for your area (with file/function anchors). +- Exact code-change sketch (what to add/modify, where), API surface, data flow. +- Risks, edge cases, alternatives, and a recommended approach. +- A short "minimal first PR" you'd ship for your area. +Keep it tight and concrete. Cite real symbols/paths. Do NOT make production code +changes; this is exploration. You MAY build small throwaway probes. + +## Rules +- macOS live machine. ScrollWM safety contract: NEVER arrange the user's real + windows in a test; only sandbox/disposable windows. Read its docs first. +- Report back to the coordinator when done (swarm report), with the path to your + doc and a 5-line summary. diff --git a/docs/scrollwm-integration/PLAN.md b/docs/scrollwm-integration/PLAN.md new file mode 100644 index 0000000000..2ff621b670 --- /dev/null +++ b/docs/scrollwm-integration/PLAN.md @@ -0,0 +1,100 @@ +# ScrollWM x jcode integration - plan (synthesis) + +Synthesized from 5 parallel explorers (docs in `explorers/`). Both repos are +yours: `1jehuang/jcode` (Rust TUI agent) and `1jehuang/scrollwm` (Swift macOS +scrolling WM). They already ship together in the `1jehuang/homebrew-jstack` tap +via a `jstack` bundle cask. + +## The vision + +```mermaid +flowchart LR + subgraph Onboard[jcode onboarding] + A[Login / import] --> B[Searched, not found list] + B --> C[Install ScrollWM? Yes/No] + end + C -->|Yes| D[web-install scrollwm + Accessibility] + subgraph Run[Headed swarm] + E[jcode spawns N visible Ghostty agents] --> F[jcode-scrollwm client] + F -->|socket| G[ScrollWM control plane] + G --> H[agents tiled into strip columns / workspace] + end +``` + +## How the two repos link (no submodule) + +- Keep two repos. The runtime coupling is a **capability handshake over + `scrollwm status`/`version` JSON**, never a build-time dependency. jcode is the + client; ScrollWM stays jcode-agnostic. +- Distribution link already exists: `homebrew-jstack` `jstack` cask installs both + (`depends_on cask: scrollwm`). We just add cross-suggest caveats + a canonical + `scrollwm/docs/INTEGRATION.md` (wire spec) that jcode links. +- Versioning: ScrollWM `status` gains `version`, `protocol` (int), `capabilities` + / `verbs`. jcode gates optional verbs on `protocol`/`verbs`, degrades + gracefully when absent or old. + +## Workstreams (each shippable independently) + +### WS1 - Onboarding: "Searched, not found" + scrolling (USER ASK #2) +Source of truth for "what we searched": `external_auth.rs` +`pending_external_auth_review_candidates` (Codex, Claude Code, Gemini CLI, +Copilot, Cursor, OpenCode, pi). Add `external_auth_search_report()` returning +`{found, not_found}` using the existing `*_exists()` presence helpers (presence, +NOT consent). Render a scrollable "Searched, not found:" panel under the decision +row in `ui_onboarding.rs` (`Paragraph::scroll((offset,0))`, offset on `App`, +keys `PgUp/PgDn`+`Ctrl-U/D`, disjoint from the Yes/No `h/l/j/k` keys). This is +the generalizable mechanism: ScrollWM later becomes one more search target. + +### WS2 - Onboarding: ScrollWM install opt-in (USER ASK #1b) +New `OnboardingPhase::ScrollWmOptIn { yes_highlighted, shown_at }` inserted at the +single choke point `onboarding_show_suggestions` (split into a gate + +`onboarding_finish_to_suggestions`). macOS-only, gated on +`!scrollwm_app_installed()` (check `~/Applications/ScrollWM.app` + `/Applications`) +and a persisted `scrollwm_optin_answered` in `setup_hints.json`. Default +highlight = **No** (never install on timeout). On Yes: async install via the +web-installer (download to temp then `bash`), result delivered through a new +`Bus::ScrollWmInstallCompleted` event; Accessibility grant is ScrollWM-owned. +Renders through the existing Yes/No welcome machinery. + +### WS3 - jcode-scrollwm control client (the link) +New crate `jcode-scrollwm`: blocking `UnixStream` client to +`~/Library/Application Support/ScrollWM/control.sock` (env override +`SCROLLWM_CONTROL_SOCK`), `#[cfg(not(macos))]` no-op stub. API: `is_running()` +(ping), `hello()`/`status()` (serde structs), `arrange()`, `focus_index()`, +`focus_title()`, `workspace*`, `reload_config()`. Loopback unit tests (temp +`UnixListener` echoing canned replies) - no ScrollWM needed in CI. Modeled on +`jcode-mobile-sim::send_request` + Swift `ControlClient.send`. + +### WS4 - Swarm orchestration (USER ASK #1a) +After `register_visible_spawned_member` in `comm_session.rs`, `tokio::spawn` a +best-effort `reconcile_scrollwm_after_spawn`: gate on config + `is_running()`, +poll `status` to find the agent's column by **title** (jcode already sets a +unique window title via `resumed_window_title`), then `focus` it. Default does +NOT call `arrange` (that grabs the whole Space). Config: +`integrations.scrollwm { enabled=false, arrange_on_spawn=false, focus_active=true, +workspaces=false }` + `JCODE_SCROLLWM*` env. Fire-and-log; never gate spawn. + +### WS5 - ScrollWM-side additions (separate scrollwm repo PR) +- `status`/`version` gains `version`+`protocol`+`capabilities`/`verbs`. +- New verbs: `arrange-pids`, `focus-title`, `focus-pid`, `workspace-new`, + (optional, guarded) `spawn-strip`. All additive, ~5-15 lines each; + `arrange(pidFilter:)` already exists. +- `docs/INTEGRATION.md` canonical contract + a conformance test. +- Reverse hook: a `spawn` binding (`ctrl+opt+j -> jcode`) jcode can opt-in write. + +## Recommended build order + +1. **WS1** (self-contained, pure jcode, immediate user value; establishes the + scrolling + "not found" mechanism ScrollWM reuses). +2. **WS3** (the client; unit-testable offline; unblocks WS4). +3. **WS2** (onboarding opt-in; can show live ScrollWM status via WS3). +4. **WS4** (wire swarm spawn -> ScrollWM focus). +5. **WS5** (ScrollWM repo PR: handshake fields + verbs + INTEGRATION.md). + +## Hard safety rules (from ScrollWM) +- Never `arrange` the real desktop in a test; use `scrollwm sandbox` + + `SCROLLWM_CONTROL_SOCK`. +- jcode only drives the control plane; never enumerates/moves windows itself. +- Never send `close`/`release`/`quit` for the user's real arrangement. +- All ScrollWM I/O on detached tasks with short timeouts; every failure is a + quiet no-op + one log line. ScrollWM must never gate jcode functionality. diff --git a/docs/scrollwm-integration/explorers/control-plane.md b/docs/scrollwm-integration/explorers/control-plane.md new file mode 100644 index 0000000000..7e8978ffd7 --- /dev/null +++ b/docs/scrollwm-integration/explorers/control-plane.md @@ -0,0 +1,496 @@ +# ScrollWM x jcode - Control Plane (explorer: control-plane) + +How jcode drives ScrollWM and how ScrollWM launches jcode. The link is +ScrollWM's Unix-domain control socket + the `scrollwm ` CLI shim. + +Anchors (ScrollWM, `/Users/jeremy/scrollwm`): +- `Sources/WindowLab/ControlServer.swift` - socket bind/listen, `ControlSocket.path()`, + `ControlClient.send` (the reference Swift client). +- `Sources/WindowLab/ControlCommands.swift` - `handleControlCommand`, `controlStatusJSON`. +- `Sources/WindowLab/ControlCLI.swift` - `runControlCLI`, launch-on-`notRunning`. +- `Sources/WindowLab/main.swift` - `controlVerbs` set, `scrollwm` help text. +- `Sources/WindowLab/ScrollWMApp.swift` - `arrange(pidFilter:)`, `focus(index:)`, + `controlColumns()`, `floatingWindows`, `tileFloating`, `startControlServer`. +- `Sources/WindowLab/Config.swift` - `spawn` map (commented `ctrl+opt+j` jcode launcher), + `KeyAction.spawnTerminal`. + +Anchors (jcode, `/Users/jeremy/jcode`): +- `crates/jcode-terminal-launch/src/lib.rs` - terminal spawn (`open -na Ghostty ...`). +- `crates/jcode-app-core/src/session_launch.rs` - `resumed_window_title`, `spawn_resume_in_new_terminal_with_provider`. +- `crates/jcode-app-core/src/server/comm_session.rs` - `spawn_visible_session_window`, swarm spawn flow. +- `crates/jcode-config-types/src/lib.rs` - `SwarmSpawnMode { Visible, Headless, Auto }`. +- `crates/jcode-mobile-sim/src/lib.rs` - reference Rust Unix-socket line client (`send_request`). + +Ground-truth probe (this machine, read-only): socket exists at +`~/Library/Application Support/ScrollWM/control.sock` (`srw-------`, mode 0600, +owner `jeremy`); `scrollwm` is on PATH at `/opt/homebrew/bin/scrollwm` (cask +symlink to `ScrollWM.app/Contents/MacOS/ScrollWM`); installed bundle +`CFBundleShortVersionString = 0.1.6`; ScrollWM not currently running (`ping` +returned the "isn't running" hint). Cask version pin is `0.1.5`. + +--- + +## 1. Full verb inventory + +Source of truth: `controlVerbs` in `main.swift` + the `switch` in +`ControlCommands.handleControlCommand`. Replies are one human line except +`status` (JSON). Lines beginning `error:` map to CLI exit code 2; +`notRunning` maps to exit 3 (`ControlCLI.printReply`). + +| Verb (aliases) | Args | Reply (success) | Needs managing? | jcode use | +|---|---|---|---|---| +| `ping` | - | `pong` | no | YES - liveness/handshake | +| `status` | - | JSON snapshot | no | YES - detect + read strip/columns | +| `arrange` | - | `ok: arranged N windows` / `error: nothing to arrange` | no (starts it; idempotent resync if already) | YES - adopt after spawning agents | +| `release` | - | `ok: released, all windows restored` | no | maybe - "un-manage" teardown | +| `toggle` | - | `ok: arranged N` / `ok: released` | no | no (non-deterministic) | +| `focus` | `next\|prev\|left\|right\|N` | `ok: focused column K (title)` | yes | YES - focus active agent (by index today) | +| `move` | `left\|right\|up\|down` | `ok: moved to column K` | yes | maybe - reorder agent columns | +| `workspace` (`ws`) | `up\|down\|N` (or none=query) | `ok: on workspace K of M` | yes | YES - put a swarm on its own workspace | +| `width` | `25\|50\|75\|100\|0.0-1.0` | `ok: set focused width to P%` | yes | maybe - widen focused agent | +| `close` | - | `ok: closed ` | yes | no (jcode owns its own lifecycle; risky) | +| `display` | `next\|main\|primary\|largest\|N` (or none=list) | `ok: displays: ...` | no | maybe - pin swarm strip to a monitor | +| `focus-mode` (`focusmode`) | `fit\|centered` (or none=query) | `ok: focus-mode set to X` | no | no | +| `reload` (`reload-config`) | - | `ok: config reloaded` | no | YES (after onboarding writes a `spawn` binding) | +| `tutorial` | - | `ok: opened tutorial` | no | no | +| `update` (`update-check`) | `[--install]` | update status line | no | no | +| `quit` | - | `ok: quitting (windows restored)` | no | no (never kill the user's WM) | + +`status` JSON fields (`controlStatusJSON`): `managing` (bool), `focusMode`, +`windowCount`; and when managing: `focusedColumn` (1-based), `workspace`, +`workspaceCount`, `floatingCount`, `floating[]` (`app`/`title`/`canTile`), and +`columns[]` from `controlColumns()`: `index` (1-based), `app`, `title`, `width` +(px int), `focused` (bool), `healthy` (bool). + +Gap that matters for jcode: **`status` carries no `version` and no PID per +column.** Columns are identified only by `app`+`title`. jcode's spawned terminal +windows do carry a unique title (`resumed_window_title`, e.g. `🛰 jcode/<name> +<label>`), so title is a usable key today; PID is not exposed. + +### jcode subset (what we'd actually call) +- Detect/handshake: `ping`, `status` (+ a new `version`/`hello`, see below). +- Arrange after a headed spawn batch: `arrange` (idempotent: also resyncs while + managing, so it doubles as "re-adopt the new windows"). +- Focus the active agent: `focus N` today; `focus title <...>` proposed. +- Place a swarm on its own surface: `workspace N`, optionally `display N`. +- Post-onboarding: `reload` after writing a `spawn` binding into ScrollWM config. + +--- + +## 2. Chosen transport: socket-direct from Rust, CLI only as a launch fallback + +Two options: + +(a) **Shell out to `scrollwm <verb>`** - spawn `/opt/homebrew/bin/scrollwm +arrange`, parse stdout/exit code. + +(b) **Speak the socket protocol directly from Rust** - `UnixStream::connect` +the control.sock, write `"<verb args>\n"`, half-close write side +(`shutdown(SHUT_WR)`), read the reply to EOF, trim. This is exactly what +`ControlClient.send` does in Swift and what `jcode-mobile-sim::send_request` +already does in Rust. + +**Recommendation: (b) socket-direct is the primary transport; (a) CLI is a +narrow fallback used only to *launch* ScrollWM when the socket is absent.** + +Rationale: +- **No dependency on PATH / install layout.** `scrollwm` may be a Homebrew + symlink, a `~/.local/bin` link (`scripts/install.sh`), or absent while the app + is running from `~/Applications`. The socket path is deterministic + (`~/Library/Application Support/ScrollWM/control.sock`, override + `SCROLLWM_CONTROL_SOCK`) and computed identically by app and client - jcode can + reproduce it with one line and never shell out. +- **Latency + no fork.** A spawn of the universal `WindowLab` binary pays + process startup, AppKit framework load (`ControlCLI` imports AppKit), and + LaunchServices lookups on the `notRunning` path. A direct connect is a single + syscall round-trip on the same machine; this matters when arranging after each + of N headed spawns. +- **Structured errors.** Direct connect distinguishes `ENOENT`/`ECONNREFUSED` + (= not running / stale socket) from real I/O errors, mirroring + `ControlClient.Failure.notRunning`. Shelling out collapses everything into an + exit code + an English string on stderr. +- **No quoting hazard.** Proposed title/path-bearing verbs (`focus title ...`, + `spawn-strip <cmd>`) avoid a second layer of shell-arg quoting if jcode writes + the protocol line itself. +- **Protocol is trivial + stable.** One request line in, one reply line out, + newline-terminated, ASCII. The framing is already battle-tested by the Swift + CLI and fuzzed (`FuzzController.swift`). + +Where CLI wins, and where we keep it: **launching** a not-running ScrollWM. +`ControlCLI` already implements bundle-relative launch + 6s poll +(`launchRunningApp` / `retryAfterLaunch`). Reimplementing app launch in Rust +(`NSWorkspace.open`, bundle-id `dev.scrollwm.app`) is avoidable: if the socket is +absent and the user opted in, jcode can run `scrollwm arrange` (or +`open -a ScrollWM`) once to start it, then switch to socket-direct for everything +after. So: **socket for steady-state; `scrollwm`/`open` for cold start only.** + +Transport decision is encapsulated behind one Rust client type so a future +switch (or a CLI-only mode for hardened environments) is a one-file change. + +--- + +## 3. Proposed NEW ScrollWM verbs for jcode + +Design constraints honored: every verb returns one line (or JSON for queries); +`error:`-prefixed on failure; safe to add behind the existing `switch` in +`handleControlCommand` + the `controlVerbs` set in `main.swift`; PID-filtered +arrange already exists (`arrange(pidFilter:)`), so most of this is plumbing the +filter/keys through the parser. + +### 3.1 `version` (a.k.a. capability handshake) - REQUIRED +``` +scrollwm version +``` +Reply (JSON, single line): +```json +{"name":"ScrollWM","version":"0.1.6","protocol":1,"verbs":["ping","status","arrange","focus","workspace","display","reload","focus-pid","focus-title","arrange-pids","spawn-strip","workspace-new"]} +``` +Why: `status` has no version; jcode needs feature detection that does not depend +on the installed bundle's Info.plist (the running app may differ from the +on-disk app mid-update). `protocol` is a monotonic integer jcode gates on. +Implementation: `Bundle.main.infoDictionary["CFBundleShortVersionString"]` +(already read in `Updater.swift`) + a static verb list. Trivial, no managing +required. Alternatively fold these three keys (`version`, `protocol`, `verbs`) +into `status` and keep `version` as a thin alias. + +### 3.2 `arrange-pids <pid...>` - adopt specific processes +``` +scrollwm arrange-pids 4123 4147 4190 +``` +Reply: `ok: arranged 3 of 3 windows (2 new)` or +`error: no manageable windows for pids 4123` (none had an on-screen std window). +Why: jcode wants to adopt exactly the agent terminals it just spawned, not +"every window on the Space." The controller **already** supports this: +`func arrange(pidFilter: Set<pid_t>? = nil)` enumerates `AXSource.windows(forPID:)` +per pid. New verb just parses ints and calls `arrange(pidFilter:)`. Note the +plumbing caveat: when **already managing**, today's `arrange` early-returns into a +resync that ignores `pidFilter` - so for "adopt these new pids while already +managing" the verb should route to the same per-strip resync/insert path the +auto-adopt uses, or force a scoped re-adopt of the given pids. +Caveat for jcode: the terminal *window* belongs to the terminal app process +(Ghostty/Terminal), not to the `jcode` child; see 4.3 for how jcode gets pids. + +### 3.3 `focus-pid <pid>` and `focus-title <substring>` +``` +scrollwm focus-pid 4147 +scrollwm focus-title "jcode/aqua" +``` +Reply: `ok: focused column 3 (🛰 jcode/aqua main)` or +`error: no managed column matches pid 4147` / `error: no managed column title contains "jcode/aqua"`. +Why: today `focus` is index-only, but jcode tracks agents by session +title/identity, not by volatile column index (which shifts as columns are +added/moved/closed). Implementation: search `engine.slots` for the matching +`window.pid` or `window.title.contains(substring)` (case-insensitive), then +`focus(index:)`. `controlColumns()` already exposes title; pid would need to be +threaded through (small). +**`focus-title` is the pragmatic MVP** because jcode controls the window title +deterministically (`resumed_window_title`) and does not reliably have the +terminal-window pid (4.3). Prefer an exact match on a jcode-issued token (e.g. the +session id embedded in the label) over a fuzzy contains. + +### 3.4 `workspace-new` - create/switch to a fresh vertical workspace +``` +scrollwm workspace-new +``` +Reply: `ok: on workspace 4 of 4 (new)`. +Why: a jcode swarm should land on its own niri-style workspace so it does not +shuffle the user's existing columns. Engine semantics already support this +("going down past the last workspace makes a new empty one" - +`Config.swift` doc + `switchWorkspace`). Verb = "switch down until a new empty +workspace exists, return its index," giving jcode a deterministic target without +guessing counts from `status`. + +### 3.5 `spawn-strip <command...>` - reverse direction, run a command into the strip +``` +scrollwm spawn-strip /Users/jeremy/.local/bin/jcode --resume <id> +``` +Reply: `ok: spawning into strip (will adopt on appear)`. +Why: symmetry with ScrollWM's own `cmd+return` "new terminal into strip" +(`KeyAction.spawnTerminal` / `Terminals.swift`). Lets ScrollWM be the one that +opens the terminal *and* guarantees adoption via its AX-observer fast path +(`SpawnLatencyTest`), instead of jcode spawning blind and then asking for an +arrange. Lower priority than 3.1-3.4: jcode already owns terminal spawning +(`jcode-terminal-launch`), and routing through ScrollWM adds a trust surface +(arbitrary command execution over the socket). If added, it should be **opt-in** +and ideally restricted (e.g. only argv whose program basename is `jcode`, or +gated by a config flag), because the socket is the app's RCE boundary. + +### Exact CLI syntax + reply summary +``` +scrollwm version -> {"name":...,"version":...,"protocol":1,"verbs":[...]} +scrollwm arrange-pids <pid> [pid...] -> ok: arranged A of B windows (N new) | error: ... +scrollwm focus-pid <pid> -> ok: focused column K (title) | error: no managed column matches pid <pid> +scrollwm focus-title <substr> -> ok: focused column K (title) | error: no managed column title contains "<substr>" +scrollwm workspace-new -> ok: on workspace K of M (new) +scrollwm spawn-strip <argv...> -> ok: spawning into strip (will adopt on appear) | error: ... +``` +All additive; each is ~5-15 lines in `ControlCommands.handleControlCommand` plus +one entry in `controlVerbs`. None changes existing verb behavior. + +--- + +## 4. Rust client API sketch + +### 4.1 Where it lives +New crate **`jcode-scrollwm`** (`crates/jcode-scrollwm`), workspace member, +`publish = false`, deps `serde`/`serde_json`/`anyhow`/`dirs` (+ `libc` only if we +want `SHUT_WR`; `std::os::unix::net::UnixStream::shutdown(Write)` suffices, no +libc). Rationale for a dedicated crate (not folding into +`jcode-terminal-launch`): it is consumed by `jcode-app-core` +(`comm_session.rs` spawn path) and by onboarding (`jcode-tui` / +`jcode-setup-hints`), and it owns a platform integration with its own +detection/version logic; keeping it standalone keeps `jcode-terminal-launch` +focused on terminal emulators and keeps the macOS-only surface isolable behind +`#[cfg(target_os = "macos")]` with a no-op stub elsewhere. + +The whole client is **synchronous + blocking** (one tiny round-trip; mirrors the +Swift CLI and is callable from non-async contexts), with an optional +`tokio::task::spawn_blocking` wrapper for the async spawn path. + +### 4.2 API +```rust +// crates/jcode-scrollwm/src/lib.rs + +/// Resolved like ScrollWM's ControlSocket.path(): $SCROLLWM_CONTROL_SOCK or +/// ~/Library/Application Support/ScrollWM/control.sock. +pub fn control_socket_path() -> PathBuf; + +#[derive(Debug)] +pub enum ScrollWmError { + NotRunning, // ENOENT / ECONNREFUSED on connect + Io(std::io::Error), + Protocol(String), // reply began with "error:" + Unsupported { verb: &'static str, have: u32, need: u32 }, // version gate +} + +/// One short-lived connect/send/recv round-trip. Verb line in, trimmed reply out. +/// Never auto-launches. Times out fast (default ~750ms connect+read). +pub fn send_raw(line: &str) -> Result<String, ScrollWmError>; + +#[derive(Clone, Debug)] +pub struct ScrollWm { socket: PathBuf, timeout: Duration } + +impl ScrollWm { + pub fn discover() -> Self; // default socket + timeout + pub fn with_socket(path: PathBuf) -> Self; // for SCROLLWM_CONTROL_SOCK / sandbox + + // --- detection / handshake --- + pub fn is_running(&self) -> bool; // ping == "pong" + pub fn hello(&self) -> Result<ScrollWmInfo, ScrollWmError>; // `version`, falls back to status+bundle + pub fn status(&self) -> Result<StripStatus, ScrollWmError>; + + // --- actions jcode uses --- + pub fn arrange(&self) -> Result<ArrangeOutcome, ScrollWmError>; + pub fn arrange_pids(&self, pids: &[u32]) -> Result<ArrangeOutcome, ScrollWmError>; // needs protocol>=1 + pub fn focus_index(&self, one_based: usize) -> Result<(), ScrollWmError>; + pub fn focus_title(&self, needle: &str) -> Result<(), ScrollWmError>; // needs protocol>=1 + pub fn focus_pid(&self, pid: u32) -> Result<(), ScrollWmError>; // needs protocol>=1 + pub fn workspace(&self, target: WorkspaceTarget) -> Result<WorkspaceState, ScrollWmError>; + pub fn workspace_new(&self) -> Result<WorkspaceState, ScrollWmError>; // needs protocol>=1 + pub fn display(&self, sel: &str) -> Result<String, ScrollWmError>; + pub fn reload_config(&self) -> Result<(), ScrollWmError>; + + // --- cold start (CLI/`open` fallback, opt-in) --- + pub fn ensure_running(&self, launch: LaunchPolicy) -> Result<(), ScrollWmError>; +} + +#[derive(serde::Deserialize, Debug)] +pub struct ScrollWmInfo { pub name: String, pub version: String, + #[serde(default)] pub protocol: u32, #[serde(default)] pub verbs: Vec<String> } + +#[derive(serde::Deserialize, Debug)] +pub struct StripStatus { pub managing: bool, pub focus_mode: String, + pub window_count: u32, pub focused_column: Option<u32>, + pub workspace: Option<u32>, pub workspace_count: Option<u32>, + #[serde(default)] pub columns: Vec<Column> } + +#[derive(serde::Deserialize, Debug)] +pub struct Column { pub index: u32, pub app: String, pub title: String, + pub width: u32, pub focused: bool, pub healthy: bool } + +pub enum WorkspaceTarget { Up, Down, Index(u32) } +pub enum LaunchPolicy { Never, ViaCli, ViaOpen } // Never = pure socket; others shell out once +pub struct ArrangeOutcome { pub arranged: u32, pub new: u32, pub already_managing: bool } +``` +Wire details (matching `ControlServer`): connect `UnixStream`; write +`format!("{line}\n")`; `stream.shutdown(Shutdown::Write)`; read to EOF; trim; if +it starts with `error:` -> `Protocol`. Replies for non-JSON verbs are parsed +leniently (e.g. extract the trailing `column K`), but jcode should prefer +re-reading `status` for authoritative state rather than scraping prose. + +### 4.3 Getting pids (the hard part) - how `arrange_pids` / `focus_pid` are fed +jcode's headed spawn (`spawn_visible_session_window` -> +`spawn_command_in_new_terminal`) runs `open -na Ghostty ...` / +`osascript ... do script` and **detaches**, so the returned child pid is the +short-lived `open`/`osascript`, not the terminal window's process. The adoptable +AX window belongs to the terminal app (e.g. `com.mitchellh.ghostty`), shared +across all its windows -> a single pid maps to many windows, so `focus_pid` on the +terminal pid is ambiguous. +Consequence: **prefer title-keyed control.** jcode already sets a unique terminal +title per session (`resumed_window_title`), so `focus_title`/title-matched adopt +are the reliable primitives. `arrange` (whole-Space) + `focus_title` covers the +core flow without any pid at all. `arrange_pids`/`focus_pid` stay useful for +non-terminal/native windows and future direct-window spawns, but are not on the +critical path for the swarm-terminal use case. + +--- + +## 5. Handshake / version negotiation + +Sequence jcode runs before driving anything (cached for the session, re-checked +on `NotRunning`): +1. `ping` -> `"pong"`? If connect fails with `NotRunning`, ScrollWM is absent; + surface the onboarding/opt-in path, do not error hard. +2. `hello()`: + - Send `version`. If recognized, parse `{version, protocol, verbs}`. + - If `version` is unknown (older ScrollWM replies + `error: unknown command 'version'...`), fall back: treat `protocol = 0`, + read `status` for capability presence, and read the bundle's + `CFBundleShortVersionString` only as a display string. +3. Gate optional verbs on `protocol`/`verbs`: call `arrange_pids` only if + `protocol >= 1` (or `verbs.contains("arrange-pids")`); otherwise degrade to + plain `arrange` + `focus_title`. + +This makes jcode forward/backward compatible: a new jcode against an old +ScrollWM degrades to the v0 verb set; an old jcode against a new ScrollWM just +ignores extra `verbs`. The negotiated `protocol` integer (not the marketing +version) is the contract; bump it in ScrollWM whenever a verb's wire shape +changes. + +--- + +## 6. Failure modes & handling + +| Condition | Detection | jcode behavior | +|---|---|---| +| ScrollWM not installed | `control_socket_path()` parent dir/socket absent; `which scrollwm` empty | Treat as opt-out; only offer install during onboarding; never block spawning. | +| Installed but not running | connect -> `ENOENT`/`ECONNREFUSED` -> `NotRunning` (matches `ControlClient.Failure.notRunning`) | If user opted in, `ensure_running(ViaCli)` once (run `scrollwm arrange` or `open -a ScrollWM`, poll ~6s like `retryAfterLaunch`); else skip arranging, log once. | +| Stale socket file (crash) | connect -> `ECONNREFUSED` | Same as not-running; the app `unlink`s + rebinds on next start (`ControlServer.start`). | +| Older ScrollWM (no new verbs) | `version` unknown / `verbs` lacks the verb | Degrade to v0 verbs; never send an unsupported verb (avoids `error: unknown command`). | +| Not AX-trusted / session locked | `arrange*` returns `error: ...` (controller refuses via `LifecycleMonitor.sessionIsActive()`) | Surface as a one-time hint ("grant ScrollWM Accessibility"); do not retry-loop. | +| `focus*` while dormant | `error: not managing; run scrollwm arrange first` | Send `arrange` (or `arrange_pids`) first, then retry focus. | +| `arrange` with nothing to adopt | `error: nothing to arrange ...` | Benign; agents may not have opened windows yet -> brief retry/backoff, then give up quietly. | +| Slow/hung app (main-thread blocked) | read exceeds client timeout | Time out (~750ms), return `Io`; never block the spawn path; retry at most once. | +| Wrong-owner socket / perms | connect -> `EPERM`/`EACCES` | `Io`; do not escalate. Socket is `chmod 0600` + per-user `Application Support`, so cross-user contact should not happen. | +| Sandbox/dev instance | honor `SCROLLWM_CONTROL_SOCK` via `with_socket` | Tests/dev never touch the real session (same override ScrollWM's sandbox uses). | + +Cross-cutting rule: **ScrollWM control is best-effort and must never gate jcode +functionality.** Every call is fire-and-log on failure; the swarm spawn succeeds +whether or not the WM cooperates. + +--- + +## 7. Should jcode bundle/own a `scrollwm` integration helper? + +**No bundling of the ScrollWM binary; yes to owning a thin client + an opt-in +installer hook.** +- Do NOT vendor or ship `scrollwm`/`ScrollWM.app` inside jcode. It needs the + Accessibility TCC grant, ships its own signed/notarized bundle + in-app + updater (`Updater.swift`, cask `auto_updates true`), and lives on its own + release cadence. jcode embedding it would fork the trust + update story. +- DO own `jcode-scrollwm` (the Rust client above) - small, no extra runtime + deps, macOS-gated. +- DO add an **opt-in onboarding action** that installs ScrollWM via its + published channel (Homebrew cask `1jehuang/scrollwm/scrollwm`, or + `scripts/web-install.sh` curl|bash) and links the `scrollwm` CLI - reusing + ScrollWM's own installer rather than reimplementing it. This belongs to the + onboarding explorer; control-plane just exposes `is_running()`/`hello()` so + onboarding can show live status. +- Detection over assumption: jcode resolves the socket path itself and never + assumes `scrollwm` is on PATH for steady-state calls. + +--- + +## 8. Reverse direction: ScrollWM's `spawn` keybind launching jcode + +`Config.swift` already ships the recipe, commented out, in `defaultFileContents`: +```jsonc +"spawn": { + "ctrl+opt+j": "open -na Ghostty --args --working-directory=$HOME/scrollwm --command=$HOME/.local/bin/jcode", + "ctrl+opt+return": "open -na Ghostty" +} +``` +`spawn` maps a chord -> `/bin/sh -c <cmd>` global hotkey (`spawnBindings()`), +adopted into the strip by ScrollWM's normal new-window fast path. This is the +clean reverse hook: a user opts into "ctrl+opt+j opens jcode in the strip." + +jcode's onboarding (opt-in) can offer to write this binding: +- Compute jcode's launch command (resolve the real `jcode` path like + `client_update_candidate` / `current_exe`), pick the user's best terminal + (jcode already knows this via `jcode-terminal-launch`), and write a `spawn` + entry into `~/Library/Application Support/ScrollWM/config.json`. +- Because ScrollWM's config is JSONC with comment-stripping and is the single + source of truth (`ScrollWMConfig.load/parse`), jcode should do a minimal, + idempotent merge (add the key only if absent; never rewrite the user's file + destructively), then call **`reload`** over the socket so it takes effect live + (no relaunch). +- Use Ghostty's `--command=` not `-e` (the default file's comment calls out that + `-e` triggers Ghostty's per-launch security prompt). + +Net: jcode -> ScrollWM uses the socket; ScrollWM -> jcode uses one `spawn` +binding. Two thin, independent hooks, each opt-in. + +--- + +## 9. Security model + +- **Transport scope:** Unix socket under per-user `~/Library/Application + Support/ScrollWM/`, `chmod 0600` (`ControlServer.start`), no network, no + entitlement. Reaching it already implies same-user filesystem access. Verified + live: `srw-------` owned by the user. jcode (same user) is the intended caller. +- **No new surface from jcode's side:** the client only connects out; it never + binds or listens. +- **`spawn-strip` is the one risky proposed verb** (arbitrary command over the + socket). Recommendation: keep it opt-in / restricted (program allowlist or + config flag) or omit it for the first cut, since jcode can spawn terminals + itself. +- **Config writes** (reverse hook) are an additive, idempotent JSONC merge of a + single `spawn` key, never a clobber, then a `reload` - the user can read/revert + the one line. +- **Never destructive to the user's session:** jcode must not send `close`, + `release` of the user's real arrangement, or `quit`; control is limited to + arrange/focus/workspace for surfaces jcode itself created. + +--- + +## 10. Minimal first PR + +Scope: pure jcode-side, additive, macOS-gated, no behavior change unless +ScrollWM is present and the user opts in. Lands the transport + handshake; new +ScrollWM verbs are a separate ScrollWM-side PR. + +1. **New crate `jcode-scrollwm`** (`crates/jcode-scrollwm`): + - `control_socket_path()` (env override + Application Support default). + - Blocking `send_raw()` (connect / write+`shutdown(Write)` / read-to-EOF / + trim; `ENOENT|ECONNREFUSED -> NotRunning`; `error:` -> `Protocol`), modeled + on `jcode-mobile-sim::send_request` + Swift `ControlClient.send`. + - `ScrollWm { is_running(), hello() (version with status+bundle fallback), + status(), arrange(), focus_index(), focus_title()-via best-effort, + workspace(), reload_config() }`. + - `#[cfg(not(target_os = "macos"))]` no-op stub returning `NotRunning`. + - Unit tests: parse a captured `status` JSON + `version` JSON; assert + `error:`-prefixed reply maps to `Protocol`; assert socket-path resolution + honors `SCROLLWM_CONTROL_SOCK`. (A loopback test can bind a temp + `UnixListener` that echoes canned replies - no ScrollWM needed.) +2. **Add to workspace** `members` in root `Cargo.toml`; depend from + `jcode-app-core`. +3. **Wire one call** in the visible swarm-spawn path + (`comm_session.rs`, after `spawn_visible_session_window` succeeds): if + `ScrollWm::discover().is_running()` and a config flag + (`agents.scrollwm_integration`, default off) is set, fire a best-effort + `arrange()` (and later `focus_title(<session label>)`). Strictly fire-and-log; + never affects spawn success. +4. **Config flag** in `jcode-config-types` (`AgentsConfig`): + `scrollwm_integration: bool` (default `false`), so this is inert until a user + (or onboarding) enables it. + +Explicitly out of scope for PR 1 (follow-ups): the new ScrollWM verbs +(`version`, `arrange-pids`, `focus-pid/title`, `workspace-new`, `spawn-strip`) as +a ScrollWM repo PR; the onboarding install + `spawn`-binding writer; the +`ensure_running` cold-start launcher. + +Validation for PR 1: `cargo test -p jcode-scrollwm` (offline, loopback); +manual live check against the running app limited to `ping`/`status`/`version` +plus a `scrollwm sandbox`-spawned disposable strip for any `arrange`/`focus` +exercise - **never** `arrange` the real desktop in a test (ScrollWM safety +contract; use `SCROLLWM_CONTROL_SOCK` + sandbox). diff --git a/docs/scrollwm-integration/explorers/onboarding-notfound.md b/docs/scrollwm-integration/explorers/onboarding-notfound.md new file mode 100644 index 0000000000..e7ba3510d9 --- /dev/null +++ b/docs/scrollwm-integration/explorers/onboarding-notfound.md @@ -0,0 +1,377 @@ +# Explorer: onboarding "searched, not found" rows (+ scroll) + +Area: On jcode onboarding decision rows, ALSO render the list of things we +searched for but did **not** find, beneath the row, with scrolling when it +overflows the small centered welcome area. + +This doc is exploration only. No production code changed. All anchors are real +symbols/paths in this repo. + +--- + +## 0. TL;DR / recommended approach + +- The auth probes today only *return what was found* + (`pending_external_auth_review_candidates`, `external_auth.rs:209`). They never + surface "we looked here and found nothing". To show "searched / not found" we + must enumerate the **full search space** ourselves and subtract the found set. +- Add a pure, side-effect-free `external_auth_search_report()` in + `jcode-app-core/src/external_auth.rs` that returns both `found: + Vec<ExternalAuthReviewCandidate>` and `not_found: Vec<AuthSearchTarget>`. It + reuses the existing per-source `*_exists()` / path helpers so it stays in lock + step with the real detectors. +- Capture the `not_found` list once, when the flow arms the import walkthrough + (`begin_onboarding_flow_at_login`, `onboarding_flow_control.rs:162`), and store + it on `ImportReview` so it lives exactly as long as the decision rows. +- Render it under the existing Yes/No row in + `ui_onboarding.rs::welcome_body_lines` (the `OnboardingWelcomeKind::Login` arm, + lines 81-184) as a dedicated, **independently scrollable** sub-paragraph. +- Scrolling: keep the simplest mechanism that fits the current "centered + `Paragraph`" design - a `Paragraph::scroll((offset, 0))` over just the + not-found block, with a `u16` scroll offset stored on `App` + (`onboarding_notfound_scroll`) and clamped to content height. Drive it with + `PgUp`/`PgDn` and `Ctrl-U`/`Ctrl-D` (NOT Up/Down/j/k - those already toggle + Yes/No). + +This generalizes cleanly: ScrollWM (and any future probe) becomes one more +`AuthSearchTarget`/`SearchTarget` entry with `found=false`. + +--- + +## 1. Full inventory: what `pending_external_auth_review_candidates` searches for + +`jcode-app-core/src/external_auth.rs:209` runs six families of probe and pushes +an `ExternalAuthReviewCandidate` only when a usable, **unconsented** credential +is present. Below is every artifact it looks at, the path, the function that +decides "present", and how to know "not found". + +Paths are sandbox-aware: all `crate::storage::user_home_path(...)` lookups honor +`JCODE_HOME` (the onboarding sandbox / tests), so the doc shows the real-home +form. The Copilot/Cursor helpers special-case `JCODE_HOME` explicitly. + +### 1a. Shared external sources (`auth::external::unconsented_sources`) +`external.rs:63`, iterating `SOURCES = [OpenCode, Pi]` (`external.rs:45`): + +| Source | Path (`ExternalAuthSource::path`, `external.rs:37`) | "present" gate | "not found" signal | +|---|---|---|---| +| OpenCode `auth.json` | `~/.local/share/opencode/auth.json` | `path.exists()` && `!source_allowed` && `source_has_supported_auth` | file absent, or parses but holds no supported provider/API key | +| pi `auth.json` | `~/.pi/agent/auth.json` | same | same | + +These are multi-provider files: `source_provider_labels` (`external.rs:71`) +scans each for OpenAI/Codex, Claude, Gemini, Antigravity, GitHub Copilot, and +OpenRouter/API-key providers. `source_has_supported_auth` (`external.rs:239`) is +the catch-all "is there anything importable here" check. For the **not-found** +UI we treat the *file family* (OpenCode / pi) as the search target; whether a +given provider key is inside is a secondary detail. + +`source_allowed` (`external.rs:178`) means already-consented -> not a *pending* +candidate. See §1g for how consent interacts with "not found". + +### 1b. Codex legacy (`auth::codex`) +- Path: `~/.codex/auth.json` (`legacy_auth_path`, `codex.rs:117`). +- Present: `legacy_auth_source_exists()` (`codex.rs:155`, just `path.exists()`). +- Pending candidate: `has_unconsented_legacy_credentials()` (`codex.rs:161`) = + `exists && !legacy_auth_allowed()`. +- **Not found**: `legacy_auth_source_exists() == false`. + +### 1c. Claude Code (`auth::claude`) +- Path probed by onboarding: `~/.claude/.credentials.json` + (`claude_code_path`, `claude.rs:173`). +- `preferred_external_auth_source()` (`claude.rs:362`) actually checks + `[ClaudeCode, OpenCode]` in order, but `external_auth.rs:234` only pushes a + candidate when the winner is the **ClaudeCode** variant (OpenCode-anthropic is + covered by §1a). So for this row the search target is the Claude Code file. +- Pending: `has_unconsented_external_auth()` (`claude.rs:371`) returns + `Some(ClaudeCode)` when present and not consented. +- **Not found**: `claude_code_path()` does not exist (i.e. + `preferred_external_auth_source()` is `None` or resolves to OpenCode only). + +### 1d. Gemini CLI (`auth::gemini`) +- Path: `~/.gemini/oauth_creds.json` (`gemini_cli_oauth_path`, `gemini.rs:169`). +- Present: `gemini_cli_auth_source_exists()` (`gemini.rs:173`). +- Pending: `has_unconsented_cli_auth()` (`gemini.rs:179`) = `exists && !allowed`. +- **Not found**: `gemini_cli_auth_source_exists() == false`. + +### 1e. GitHub Copilot (`auth::copilot`) +`preferred_external_auth_source()` (`copilot.rs:351`) checks, in order: +1. `~/.copilot/config.json` (`ConfigJson`; `copilot_cli_dir`, `copilot.rs:425`) +2. `~/.config/github-copilot/hosts.json` (`HostsJson`; `legacy_copilot_config_dir`, `copilot.rs:434`, honors `XDG_CONFIG_HOME`) +3. `~/.config/github-copilot/apps.json` (`AppsJson`) +4. OpenCode `auth.json` w/ copilot oauth (excluded here - belongs to §1a) +5. pi `auth.json` w/ copilot oauth (excluded here) + +`external_auth.rs:254` pushes a candidate only when the winner is **not** +`OpenCodeAuth | PiAuth`. Pending: `has_unconsented_external_auth()` +(`copilot.rs:385`). +- **Not found**: none of `config.json` / `hosts.json` / `apps.json` exist (the + three Copilot-native files), i.e. preferred source is `None` or only the + shared OpenCode/pi variants. + +### 1f. Cursor (`auth::cursor`) +`preferred_external_auth_source()` (`cursor.rs:145`) prefers: +1. `~/.cursor/auth.json` (macOS) (`cursor_auth_file_path`, `cursor.rs:359`; + Windows `%APPDATA%/Cursor/auth.json`, Linux `~/.config/cursor/auth.json`). +2. Cursor IDE `state.vscdb` (`cursor_vscdb_paths`, `cursor.rs:235`), macOS: + `~/Library/Application Support/Cursor/User/globalStorage/state.vscdb` (+ + lowercase `cursor` variant); Linux `~/.config/Cursor/...`; Windows + `%AppData%/Roaming/Cursor/...`. +- Pending: `has_unconsented_external_auth()` (`cursor.rs:160`). +- **Not found**: `preferred_external_auth_source() == None` (neither auth.json + nor any vscdb path exists). + +### 1g. Important nuance: "not found" vs "found but already trusted" + +The pending-candidate probes fold two axes together: **presence** and +**consent** (`source_allowed` / `*_allowed`). For the "searched, not found" UI we +want presence only: + +- "Searched, **not found**" = the credential artifact does not exist at all. +- A file that exists but was already consented is *found and imported*, not "not + found"; it simply won't appear as a pending row. (Optionally render it as a + third state "already trusted" later - out of scope for the first PR.) + +So the not-found detector must call the `*_exists()` / `preferred_*().is_some()` +helpers, **not** the `has_unconsented_*` helpers (which return `None` for the +already-trusted case and would mislabel a trusted login as "not found"). + +### 1h. Canonical search-target list (what the UI enumerates) + +| family id | display | representative path | present check | +|---|---|---|---| +| `codex` | Codex (`~/.codex/auth.json`) | `codex::legacy_auth_file_path()` | `codex::legacy_auth_source_exists()` | +| `claude_code` | Claude Code | `~/.claude/.credentials.json` | claude code path exists | +| `gemini_cli` | Gemini CLI | `gemini::gemini_cli_oauth_path()` | `gemini::gemini_cli_auth_source_exists()` | +| `copilot` | GitHub Copilot CLI | `~/.copilot/config.json` (+hosts/apps) | any copilot-native file exists | +| `cursor` | Cursor | `~/.cursor/auth.json` or `state.vscdb` | `cursor::preferred_external_auth_source().is_some()` | +| `opencode` | OpenCode | `~/.local/share/opencode/auth.json` | `ExternalAuthSource::OpenCode.path().exists()` | +| `pi` | pi | `~/.pi/agent/auth.json` | `ExternalAuthSource::Pi.path().exists()` | +| `scrollwm` *(future)* | ScrollWM | `~/Library/Application Support/ScrollWM/control.sock` / app bundle | socket/app present | + +`not_found = { target | !present(target) && target not in found }`. + +--- + +## 2. Data model: searched set vs found set + +Add to `jcode-app-core/src/external_auth.rs` (core layer; usable by CLI + TUI): + +```rust +/// One artifact the auto-import flow probes for, with whether it was located. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct AuthSearchTarget { + /// Stable family id ("codex", "claude_code", "cursor", "scrollwm", ...). + pub family: &'static str, + /// Human label for the row ("Codex", "Claude Code", "GitHub Copilot CLI"). + pub label: String, + /// Representative path we looked at (best-effort; may not be the only one). + pub path: String, + /// True when a credential artifact exists (consent is a separate axis). + pub present: bool, +} + +/// Outcome of a single import-detection sweep: what we found and what we +/// looked for but did not find. `found` mirrors today's +/// `pending_external_auth_review_candidates`; `not_found` is the new part. +#[derive(Debug, Clone, PartialEq, Eq, Default)] +pub struct ExternalAuthSearchReport { + pub found: Vec<ExternalAuthReviewCandidate>, + pub not_found: Vec<AuthSearchTarget>, +} + +pub fn external_auth_search_report() -> Result<ExternalAuthSearchReport> { ... } +``` + +Implementation shape (pure, no mutation, no network): + +1. `found = pending_external_auth_review_candidates()?` (unchanged behavior). +2. Build the canonical target list (§1h). For each family compute `present` via + the existing `*_exists()` / path helpers. +3. `not_found = targets.into_iter().filter(|t| !t.present).collect()`. (We key + "not found" purely on presence; a present-but-trusted source is excluded from + `found` candidates anyway and can be added as a separate "already trusted" + list later.) +4. Keep `pending_external_auth_review_candidates()` as the thin public API the + rest of the code already uses; `external_auth_search_report` is additive. + +TUI side - the not-found list must outlive a single render and be available to +the stateless `ui_onboarding` renderer via `TuiState`: + +- Extend `ImportReview` (`onboarding_flow.rs:62`) with: + ```rust + /// Auth/source families we probed but did not find, for the + /// "Searched, not found" panel under the decision row. + pub(crate) not_found: Vec<crate::external_auth::AuthSearchTarget>, + ``` + Populate it in `ImportReview::new` from the same report. Even when + `found.is_empty()` we may still want to show not-found, so also see §5 note on + the `LoginOpenAi` arm. +- Add a small render-friendly snapshot to `OnboardingWelcomeKind::Login` / + `LoginImportPrompt` (`mod.rs:641` / `:668`), e.g. + `pub not_found: Vec<NotFoundRow>` where `NotFoundRow { label: String, path: + String }`, populated in `onboarding_welcome_kind` + (`state_ui_input_helpers.rs:1151`). +- Scroll offset is *view* state, so it lives on `App`, not in the flow model: + `onboarding_notfound_scroll: u16` (init `0` in both `App` constructors, + `tui_lifecycle.rs:360` and `:738`). Expose it through `TuiState` + (`onboarding_notfound_scroll()` + the not-found rows accessor) mirroring the + existing `onboarding_welcome_kind` wiring (`tui_state.rs:1586`, `mod.rs:424`). + +--- + +## 3. Render sketch (under the decision row) + +Today the `Login` arm (`ui_onboarding.rs:81-184`) builds centered `Line`s ending +with the Yes/No row and the hint/countdown lines, then the whole `Vec<Line>` is +drawn as one centered `Paragraph` (`draw_onboarding_welcome`, `:355`). The +not-found block should sit **beneath** that, and because it can overflow it needs +its own scroll region rather than being folded into the single centered +paragraph. + +Proposed layout change in `draw_onboarding_welcome`: when the welcome kind is +`Login { not_found }` and `!not_found.is_empty()`, split off a bottom band: + +``` +[ top pad ] +[ telemetry header ] +[ donut ] +[ body: title ... Import X? ... Yes/No ... hints ] <- existing centered Paragraph +[ gap ] +[ "Searched, not found:" panel ] <- NEW, scrollable +``` + +Panel content (`build_not_found_lines`): + +``` +Searched, not found: + • Cursor ~/.cursor/auth.json + • Gemini CLI ~/.gemini/oauth_creds.json + • GitHub Copilot ~/.copilot/config.json + • pi ~/.pi/agent/auth.json + ↓ 3 more (PgDn / Ctrl-D to scroll) +``` + +- Header line dim+italic; each entry dim, with a bullet + label + faded path. +- The panel is its own `Rect` of height `H = min(rows_needed, available_band)`. +- Centered to match the rest, but left-aligned *within* the band reads better for + a list; either is fine. Keep the existing `Alignment::Center` for v1 to match + the card aesthetic, or left-align the panel only. + +--- + +## 4. Scrolling design (state + keys + widget) + +### Widget: `Paragraph::scroll` (recommended, minimal) +The current code is already `Paragraph`-based, so the smallest viable mechanism +is `Paragraph::new(not_found_lines).scroll((offset, 0))` into the panel `Rect`. +- Pros: zero new widget state machinery, matches existing rendering, trivial to + clamp. +- Cons: we compute clamping ourselves (offset must be bounded by + `content_height.saturating_sub(panel_height)`). + +Alternative considered: `List` + `ListState` + `Scrollbar`. Gives built-in +offset tracking and a visible scrollbar, but adds a stateful widget and a +`ListState` to thread through `TuiState` for a read-only, non-selectable list. +Overkill for v1; revisit if we later want per-row selection (e.g. "retry this +probe"). Recommendation: ship `Paragraph::scroll`, optionally add a thin +`Scrollbar` overlay on the panel `Rect` for the affordance (it's stateless given +`content_len`, `position`, `viewport_len`). + +### State +- `App.onboarding_notfound_scroll: u16` (offset in lines). Reset to `0` whenever + the reviewed candidate advances (`commit_current`) or the phase leaves + `Login`, so each row starts at the top of its (shared) not-found list. +- Clamp on render: `offset = offset.min(max_offset)` where + `max_offset = content_height.saturating_sub(panel_height)`. + +### Keys +Handled in the import-review key path +(`handle_onboarding_import_review_key`, `onboarding_flow_control.rs:417`) BEFORE +the Yes/No movement keys, and also in the `import.is_none()` recovery branch so +scrolling works even when the list shows after all candidates are declined. + +Avoid collisions: `Up/Down/j/k/Tab` already toggle Yes/No; `Left/Right/h/l` set +Yes/No. Use a disjoint set for the not-found scroll: + +| Key | Action | +|---|---| +| `PgDn` / `Ctrl-D` | scroll not-found down (offset += page/half-page) | +| `PgUp` / `Ctrl-U` | scroll not-found up | +| (optional) `Ctrl-E` / `Ctrl-Y` | line down / line up | + +Each returns `true` (consumed) only when the not-found list is non-empty and +actually overflows; otherwise fall through so the keys keep any global meaning. +The dispatch already routes through `handle_onboarding_continue_prompt_key` +(`input.rs:2061`, remote `key_handling.rs:272`), so no new dispatch site is +needed - just add the branch inside the existing handler. + +--- + +## 5. Risks / edge cases + +- **Consent axis confusion** (§1g): must detect not-found via presence helpers, + not `has_unconsented_*`. Otherwise an already-trusted Codex login shows up as + "not found", which is wrong and alarming. This is the single biggest + correctness trap. +- **`path()` returns `Result`**: several path helpers can error (no `$HOME`). + Render best-effort (`unwrap_or` the relative path string); never panic. +- **Sandbox/`JCODE_HOME` divergence**: Copilot/Cursor special-case `JCODE_HOME` + (`copilot.rs:425/434`, `cursor.rs:383`); use the same helpers so the displayed + path matches what was actually probed in a sandbox/test. +- **`LoginOpenAi` / no-imports path**: when nothing is found, + `begin_onboarding_flow_at_login` builds `import = None` and shows + `LoginOpenAi` (`onboarding_flow_control.rs:177`, `:269`). That's exactly the + case where "searched, not found" is most useful (everything is not-found). + v1 can scope to the `Login{import:Some}` arm; a fast follow should also thread + the not-found list into `LoginOpenAi` so the empty-handed first run still shows + what was checked. +- **Vertical budget**: the centered card already shrinks the donut to fit + (`:368`). Adding a panel competes for rows; clamp panel height and rely on + scrolling rather than letting it push the Yes/No row off-screen. Guard the + `area.height < N` small-terminal fallback (`:356`). +- **Probe cost / flicker**: compute the report once at flow start (it touches the + filesystem), store it on `ImportReview`; do NOT re-probe every frame inside + `welcome_body_lines` (that runs each render and would hit disk on the draw + path). Cursor's vscdb path even shells out to `sqlite3` for *loading* - the + presence check is only `path.exists()`, so keep it to existence, never load. +- **Key collisions**: `Ctrl-U` is a common "clear input" binding elsewhere; since + onboarding consumes keys via `handle_onboarding_continue_prompt_key` before the + global handlers (`input.rs:2061`), scoping the scroll keys to the active Login + phase avoids hijacking them globally. Verify against `input.rs` global + shortcuts before binding `Ctrl-U/Ctrl-D`; `PgUp/PgDn` are the safer default. +- **Remote mode parity**: the welcome kind is rendered from `TuiState`, so this + works in remote mode as long as the not-found rows are snapshotted into + `OnboardingWelcomeKind` (don't reach into local-only state from the renderer). + +--- + +## 6. Minimal first PR + +Scope: show + scroll a "Searched, not found" panel for the import-walkthrough +decision rows only (the `OnboardingPhase::Login { import: Some }` case). + +1. `jcode-app-core/src/external_auth.rs`: add `AuthSearchTarget`, + `ExternalAuthSearchReport`, and `external_auth_search_report()` built on the + existing presence helpers (§1h). Add a unit test asserting that a sandbox with + only `~/.codex/auth.json` present yields `found=[codex]` and `not_found` + contains claude_code/gemini_cli/copilot/cursor/opencode/pi. +2. `onboarding_flow.rs`: add `ImportReview.not_found: Vec<AuthSearchTarget>`, + populate in `ImportReview::new` (callers in `begin_onboarding_flow_at_login`, + `onboarding_flow_control.rs:168`, pass the report's `not_found`). +3. `mod.rs`: extend `LoginImportPrompt` with `not_found: Vec<NotFoundRow>`; + populate in `onboarding_welcome_kind` (`state_ui_input_helpers.rs:1151`). +4. `app.rs` + `tui_lifecycle.rs`: add `onboarding_notfound_scroll: u16` (init 0); + `TuiState` accessor (`tui_state.rs`, default `0` in `mod.rs:424`). +5. `ui_onboarding.rs`: render the panel beneath the body via a dedicated + `Paragraph::scroll((offset,0))` `Rect`, with clamp + a "↓ N more" / scrollbar + affordance; reserve the band in `draw_onboarding_welcome`. +6. `onboarding_flow_control.rs`: in `handle_onboarding_import_review_key` add + `PgUp/PgDn` (+`Ctrl-U/Ctrl-D`) -> adjust `app.onboarding_notfound_scroll`, + clamped; reset to 0 on `commit_current`. Add a test that PgDn increments and + clamps, and that it does not disturb the Yes/No highlight. +7. Golden/render test: extend `tests/onboarding_golden.rs` / + `tests/onboarding_flow.rs` to assert the "Searched, not found:" header and at + least one absent source appear under the decision row. + +Explicitly out of scope for PR1 (fast follows): the `LoginOpenAi` empty-handed +arm, an "already trusted" third state, and the ScrollWM probe row (slots in as +one more `AuthSearchTarget` once §1h's `scrollwm` present-check exists). diff --git a/docs/scrollwm-integration/explorers/onboarding-optin.md b/docs/scrollwm-integration/explorers/onboarding-optin.md new file mode 100644 index 0000000000..7c98c2a25c --- /dev/null +++ b/docs/scrollwm-integration/explorers/onboarding-optin.md @@ -0,0 +1,517 @@ +# Onboarding opt-in: install + set up ScrollWM during jcode onboarding + +Explorer: `onboarding-optin`. Area: add an OPT-IN onboarding step that offers to +install ScrollWM (and grant Accessibility) while a new user is setting up jcode. +No production code changed; this is a concrete design + minimal-first-PR plan. + +## TL;DR + +Add one new terminal-ish onboarding phase, `ScrollWmOptIn`, that sits *after* +login/transcript and *before* `Suggestions`. It is a Yes/No decision row that +renders through the exact same welcome-screen machinery as the existing +"Log in to OpenAI?" / import prompts. It only appears on macOS, only when +ScrollWM is not already installed, and only when the user has not already +answered (persisted in `setup_hints.json`). On **Yes** we shell out to the +ScrollWM web installer asynchronously and surface progress/result via a Bus +event; the Accessibility grant is handled by ScrollWM itself on first launch, so +jcode never touches TCC. On **No**/skip/timeout we record the choice and never +ask again. + +## 1. Where this slots into the state machine + +Anchors: +- `crates/jcode-tui/src/tui/app/onboarding_flow.rs` - `OnboardingPhase` enum + (lines ~145-188), `OnboardingFlow`, `DECISION_TIMEOUT` (60s). +- `crates/jcode-tui/src/tui/app/onboarding_flow_control.rs` - transitions + (`onboarding_after_model_select` ~230, `onboarding_open_transcript_picker` + ~630, `onboarding_show_suggestions` ~743), key handling + (`handle_onboarding_continue_prompt_key` ~298), tick/auto-timeout + (`onboarding_tick` ~1186). +- `crates/jcode-tui/src/tui/ui_onboarding.rs` - `welcome_body_lines` renders the + Yes/No rows (the `LoginOpenAi` arm at lines ~185-248 is the template). +- `crates/jcode-tui/src/tui/mod.rs` - `OnboardingWelcomeKind` enum (~640-663). +- `crates/jcode-tui/src/tui/app/state_ui_input_helpers.rs` - + `onboarding_welcome_kind()` (~1151) maps phase -> welcome kind, and + `onboarding_flow_drives_welcome()` (~1197) lists phases that own the welcome + body. + +Current resting flow (post-login): + +``` +ModelSelect + -> onboarding_after_model_select() + external transcripts? -> TranscriptPick (resume picker) + else -> onboarding_show_suggestions() -> Suggestions +``` + +New flow inserts the opt-in right before we land on `Suggestions`. Critically, +the opt-in must run on **both** the "has transcripts" and "no transcripts" +paths, and after the resume picker closes into a new session. The clean single +choke point is `onboarding_show_suggestions()`: every terminal path calls it +(directly, via `onboarding_fallback_to_session_search`, and from the resume +picker's "Start a new session"). So we gate there: + +``` +onboarding_show_suggestions() // becomes a thin wrapper + -> if should_offer_scrollwm() { enter ScrollWmOptIn } + else { onboarding_finish_to_suggestions() } +``` + +Renaming the current body of `onboarding_show_suggestions` to +`onboarding_finish_to_suggestions` (the real "render suggestion cards + validate +model" work) keeps the transcript-pick code paths untouched while giving us one +insertion point. The opt-in's Yes/No answer then calls +`onboarding_finish_to_suggestions()` so the user always ends on the normal +new-session screen, regardless of choice. + +```mermaid +flowchart TD + MS[ModelSelect] --> AMS{external transcripts?} + AMS -- yes --> TP[TranscriptPick] + AMS -- no --> SUG[onboarding_show_suggestions] + TP -->|start new / resume done| SUG + SUG --> GATE{should_offer_scrollwm?} + GATE -- no --> FIN[finish_to_suggestions -> Suggestions] + GATE -- yes --> OPT[ScrollWmOptIn Yes/No] + OPT -- No/skip/timeout --> FIN + OPT -- Yes --> RUN[spawn installer async] --> FIN +``` + +## 2. Exact enum / state additions + +### 2a. `OnboardingPhase` (onboarding_flow.rs) + +```rust +/// Offer to install ScrollWM (a scrolling window manager that arranges jcode's +/// headed swarm agents). macOS only, shown once when ScrollWM is not already +/// installed and the user has not previously answered. Highlightable Yes/No +/// with the shared DECISION_TIMEOUT countdown; the default (and timeout choice) +/// is "No" so we never install software on a silent timeout. +ScrollWmOptIn { + /// Which option is highlighted (true = "Yes, install ScrollWM"). + yes_highlighted: bool, + /// When the prompt was shown, for the countdown. + shown_at: Instant, +}, +``` + +Default highlight is **No** here (unlike `LoginOpenAi`, whose default is Yes). +Installing third-party software is not a required step and is irreversible-ish, +so a timeout/Enter must not silently install. Set `yes_highlighted: false` on +entry. + +`decision_seconds_remaining()` / `decision_timed_out()` (~288-314): add a +`ScrollWmOptIn { shown_at, .. }` arm mirroring `ContinuePrompt`. + +### 2b. `OnboardingWelcomeKind` (tui/mod.rs) + +```rust +/// "Install ScrollWM?" opt-in with a highlightable Yes/No selector and a live +/// decision countdown. Carries whether an install is already running so the +/// card can switch to a progress line. +ScrollWmOptIn { + yes_highlighted: bool, + seconds_left: u64, + /// None = waiting for a decision; Some(state) = install in flight / result. + progress: Option<ScrollWmInstallProgress>, +}, +``` + +```rust +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum ScrollWmInstallProgress { + Running, // "Installing ScrollWM..." + Succeeded, // "ScrollWM installed - grant Accessibility..." + Failed { detail: String } +} +``` + +### 2c. Welcome-kind mapping + gating + +- `onboarding_welcome_kind()` (state_ui_input_helpers.rs ~1151): add a + `ScrollWmOptIn` arm building `OnboardingWelcomeKind::ScrollWmOptIn { .. }`, + reading `seconds_left` like the `ContinuePrompt` arm and `progress` from a new + `self.scrollwm_install_progress` field (see 2d). +- `onboarding_flow_drives_welcome()` (~1197): add + `Some(OnboardingPhase::ScrollWmOptIn { .. })` to the match so the welcome + screen stays up during the prompt. + +### 2d. `App` state + persistence helper + +ScrollWM opt-in answer + install state must persist so we never nag and survive +restarts. Reuse the existing `setup_hints.json` (already the new-user signal at +onboarding_flow_control.rs ~117) via `jcode-setup-hints`'s `SetupHintsState` +(accessible from the tui crate as `crate::setup_hints::SetupHintsState` because +`jcode-app-core` re-exports `jcode_setup_hints::*` in +`crates/jcode-app-core/src/setup_hints.rs`). + +Add to `SetupHintsState` (jcode-setup-hints/src/lib.rs, `#[serde(default)]` so +old files load): + +```rust +/// True once the user has answered the onboarding "install ScrollWM?" prompt +/// (Yes or No or skip). When set we never show the opt-in again. +#[serde(default)] +pub scrollwm_optin_answered: bool, +/// True if they chose Yes and we kicked off (or completed) an install. Lets a +/// later launch show a "finish granting Accessibility" hint without re-asking. +#[serde(default)] +pub scrollwm_install_started: bool, +``` + +Transient (non-persisted) UI field on `App` for the in-flight progress line: + +```rust +scrollwm_install_progress: Option<ScrollWmInstallProgress>, +``` + +## 3. Detection: is ScrollWM already installed? + +Pure, cheap, no subprocess. Matches the README install locations +(`~/Applications` default, `/Applications` via `SCROLLWM_DEST`): + +```rust +#[cfg(target_os = "macos")] +fn scrollwm_app_installed() -> bool { + let home = std::env::var_os("HOME").map(std::path::PathBuf::from); + let candidates = [ + home.map(|h| h.join("Applications/ScrollWM.app")), + Some(std::path::PathBuf::from("/Applications/ScrollWM.app")), + ]; + candidates.into_iter().flatten().any(|p| p.is_dir()) +} +#[cfg(not(target_os = "macos"))] +fn scrollwm_app_installed() -> bool { false } +``` + +Live-verified on this machine: `~/Applications/ScrollWM.app` exists, so on this +box the opt-in would self-suppress, which is the desired behavior. We can +additionally detect a `scrollwm` CLI on PATH (`install.sh` symlinks it), but the +`.app` check is the canonical signal and avoids PATH ambiguity. + +`should_offer_scrollwm()` combines everything: + +```rust +fn should_offer_scrollwm(&self) -> bool { + cfg!(target_os = "macos") + && !self.is_remote // local TUI only; AX is local + && !self.onboarding_preview_mode_skip() // honor preview semantics + && !scrollwm_app_installed() + && !SetupHintsState::load().scrollwm_optin_answered +} +``` + +Remote/client sessions are excluded: ScrollWM + Accessibility are inherently +about the local desktop, and the swarm-arrange integration (other explorers) +only makes sense where the headed terminals actually live. + +## 4. Render sketch (matches existing Yes/No rows) + +New arm in `welcome_body_lines` (ui_onboarding.rs), structurally identical to +the `LoginOpenAi` arm (lines ~185-248). Decision state: + +``` + Set up ScrollWM? (welcome_accent, bold) + jcode can arrange your swarm agents into a tidy (dim) + scrolling strip. One permission (Accessibility). (dim) + + Yes No (No highlighted by default) + + Left/right or h/l to move, Enter or Space to choose (y / n also work). (dim) + Skips automatically in 60s. (dim) +``` + +The Yes/No row reuses the exact span recipe already in the file: + +```rust +let (yes_style, no_style) = if yes_highlighted { /* yes reversed/bold */ } + else { /* no reversed/bold */ }; +lines.push(Line::from(vec![ + Span::styled(" Yes ", yes_style), + Span::raw(" "), + Span::styled(" No ", no_style), +]).alignment(align)); +``` + +Install-in-flight state (`progress = Some(Running)`) replaces the Yes/No row +with a single status line so the card stops accepting input visually: + +``` + Installing ScrollWM... (this opens a menu-bar app) (welcome_accent) + When it finishes, grant Accessibility in System Settings. (dim) +``` + +`Succeeded` / `Failed { detail }` render a one-line ✓/✕ result before the flow +advances to `Suggestions`. We keep the donut + telemetry header above, exactly +like the other phases (handled by `draw_onboarding_welcome`, no change needed). + +Status-bar notice mirrors `update_onboarding_login_openai_status`: +`"Set up ScrollWM? [No] - hl to move, Enter to choose, skips in {n}s"`. + +## 5. Key handling + transitions (onboarding_flow_control.rs) + +- In `handle_onboarding_continue_prompt_key` (~298), add a + `Some(OnboardingPhase::ScrollWmOptIn { .. })` arm calling a new + `handle_onboarding_scrollwm_optin_key`, gated `if self.scrollwm_install_progress.is_none()` + (ignore keys while an install is running; mirror the `inline_interactive_state` + guard used by the other arms). +- `handle_onboarding_scrollwm_optin_key`: copy + `handle_onboarding_login_openai_key` (~462) verbatim - Left/h -> Yes, + Right/l -> No, Up/Down/k/j/Tab toggle, y/n commit, Enter/Space commit + highlighted - calling `onboarding_answer_scrollwm_optin(bool)`. +- Entry helper: + +```rust +fn onboarding_enter_scrollwm_optin(&mut self) { + if let Some(flow) = self.onboarding_flow.as_mut() { + flow.phase = OnboardingPhase::ScrollWmOptIn { + yes_highlighted: false, // default No: never install on timeout + shown_at: Instant::now(), + }; + } + self.update_onboarding_scrollwm_optin_status(); +} +``` + +- Answer: + +```rust +pub(super) fn onboarding_answer_scrollwm_optin(&mut self, wants_install: bool) { + // Persist the answer immediately so we never re-ask, regardless of outcome. + let mut st = SetupHintsState::load(); + st.scrollwm_optin_answered = true; + if wants_install { st.scrollwm_install_started = true; } + let _ = st.save(); + + if wants_install { + self.scrollwm_install_progress = Some(ScrollWmInstallProgress::Running); + self.spawn_scrollwm_install(); // async, see section 6 + // Stay in ScrollWmOptIn so the card shows the Running line; the Bus + // event handler advances to suggestions when the install resolves. + } else { + self.onboarding_finish_to_suggestions(); + } +} +``` + +- `onboarding_tick` (~1186): add a `ScrollWmOptIn { yes_highlighted, shown_at }` + arm next to `ContinuePrompt`. On `decision_timed_out` with no install running, + auto-answer **No** (`onboarding_answer_scrollwm_optin(false)`), never Yes. If + an install is already running, do nothing (let the Bus event drive it); keep + the countdown notice fresh otherwise. + +## 6. Install command choice + async + progress + +### Command choice + +Use the **web installer**, invoked non-interactively, preferring Homebrew only +when no network/asset path is desired. Decision matrix: + +| Option | Command | Pros | Cons | Verdict | +|---|---|---|---|---| +| Web installer | `curl -fsSL .../web-install.sh \| bash` | matches README "recommended"; no sudo; installs to `~/Applications`; strips quarantine; idempotent; auto-launches (triggers AX onboarding) | pipes curl\|bash; needs network | **Primary** | +| Homebrew cask | `brew install --cask 1jehuang/scrollwm/scrollwm` | trusted package manager; user can `brew upgrade` later | requires brew; slower; auto-taps a third-party repo; does not auto-launch | **Fallback when `brew` exists and user/policy prefers it** | +| Build from source | `git clone + scripts/install.sh` | no prebuilt needed | requires Swift toolchain; minutes-long; wrong for onboarding | Rejected | + +Recommended concrete invocation (avoid `curl | bash` foot-guns by downloading to +a temp file first, then running it; still the same script): + +```rust +// pseudo: download web-install.sh to a temp path, then `bash <path>` +// env: leave SCROLLWM_DEST unset (defaults to ~/Applications, where the AX +// grant sticks - the README warns against running from Downloads/translocation) +``` + +Rationale for primary = web installer: it is the path the ScrollWM README marks +"recommended", it places the app in `~/Applications` (so Accessibility persists, +avoiding App Translocation), it strips the Gatekeeper quarantine for the ad-hoc +build, and it `open`s the app at the end, which is exactly what kicks off +ScrollWM's own one-step Accessibility onboarding. We do **not** want jcode +poking at TCC/Accessibility itself. + +A small enhancement: if `brew` is on PATH (verified present here: +`/opt/homebrew/bin/brew`) we may prefer the cask so updates flow through brew; +make this a documented branch, defaulting to the web installer for determinism. + +### Sync vs async + +**Async, always.** The install downloads a release asset and runs `ditto`/ +`open`; it can take many seconds and must not block the TUI event loop. Pattern: +copy `onboarding_spawn_model_validation` (onboarding_flow_control.rs ~848) - +`tokio::spawn` the work, publish a Bus event on completion, handle it on the UI +thread. Reusing the Bus keeps remote/local symmetry and matches the existing +async onboarding mechanics (`OnboardingModelValidated`). + +```rust +fn spawn_scrollwm_install(&self) { + let session_id = self.session.id.clone(); + let use_brew = scrollwm_prefer_brew(); // brew on PATH && policy + self.set_status_notice("Installing ScrollWM..."); + tokio::spawn(async move { + let result = run_scrollwm_install(use_brew).await; // Result<(), String> + crate::bus::Bus::global().publish( + crate::bus::BusEvent::ScrollWmInstallCompleted( + crate::bus::ScrollWmInstallCompleted { + session_id, + ok: result.is_ok(), + detail: result.err(), + })); + }); +} +``` + +`run_scrollwm_install` runs the chosen command via `tokio::process::Command` +with a generous timeout (e.g. 180s), capturing combined output; on non-zero exit +return a trimmed last-line error (reuse the `onboarding_trim_validation_error` +style condenser). + +### New Bus event + +`crates/jcode-base/src/bus.rs` - add next to `OnboardingModelValidated` +(struct ~127, enum arm ~365): + +```rust +#[derive(Clone, Debug)] +pub struct ScrollWmInstallCompleted { + pub session_id: String, + pub ok: bool, + pub detail: Option<String>, +} +// in enum BusEvent: +ScrollWmInstallCompleted(ScrollWmInstallCompleted), +``` + +Handle it in both `tui/app/local.rs` (~179) and `tui/app/remote.rs` (~485) +dispatch tables, calling `handle_scrollwm_install_completed`: + +```rust +pub(super) fn handle_scrollwm_install_completed( + &mut self, ev: crate::bus::ScrollWmInstallCompleted) -> bool { + if ev.session_id != self.session.id { return false; } + self.scrollwm_install_progress = Some(match ev.ok { + true => ScrollWmInstallProgress::Succeeded, + false => ScrollWmInstallProgress::Failed { + detail: ev.detail.unwrap_or_else(|| "install failed".into()) }, + }); + // Push a one-line system message, then advance to the normal screen. + let msg = if ev.ok { + "ScrollWM installed. Grant **Accessibility** when System Settings opens; \ + it then arranges your windows automatically." + } else { + "ScrollWM install didn't finish. You can install it later: \ + curl -fsSL https://raw.githubusercontent.com/1jehuang/scrollwm/main/scripts/web-install.sh | bash" + }; + self.push_display_message(crate::tui::DisplayMessage::system(msg.into())); + self.scrollwm_install_progress = None; + self.onboarding_finish_to_suggestions(); + true +} +``` + +### Interaction with the Accessibility grant + +jcode does nothing special for Accessibility. The web installer ends with +`open "$ScrollWM.app"`, and ScrollWM's first-run `OnboardingWindow.swift` opens +System Settings -> Privacy & Security -> Accessibility and continues +automatically once the switch is flipped (README "First launch"). So the grant +is a ScrollWM-owned, out-of-band step. jcode's only responsibility is the +post-install message telling the user a Settings pane will appear. We +deliberately keep `scrollwm_install_started=true` persisted so a *future* launch +could show a gentle one-time "finish granting Accessibility" hint via the +existing `setup_hints` startup-hint channel (`startup_hints_for_launch`), but +that is a follow-up, not part of the opt-in itself. + +## 7. Persistence + "never nag" guarantees + +- The answer is written to `setup_hints.json` (`scrollwm_optin_answered`) + *before* any async work, in `onboarding_answer_scrollwm_optin`, and also when + the tick auto-skips on timeout. So a crash mid-install never re-prompts. +- `should_offer_scrollwm()` reads both the persisted flag and the on-disk + `.app` presence, so a manual install elsewhere also suppresses the prompt. +- No nudge cap needed (unlike `MAX_TERMINAL_NUDGES`) because this is a strict + one-shot during first-run onboarding, not a recurring startup nudge. If we + ever wanted to re-surface it for users who skipped (e.g. once, much later), the + setup-hints nudge-count pattern (`record_nudge_shown` / `nudge_budget_remaining`, + lib.rs ~146-155) is the ready-made mechanism - but default is "ask exactly + once". + +## 8. Risks + edge cases + +- **Silent install on timeout (worst risk).** Mitigated by defaulting highlight + to **No** and making the timeout auto-answer No. Never spawn an installer + without an explicit Yes. +- **`curl | bash` trust.** We are running a third-party script over the network. + Mitigations: download to a temp file then execute (so it is inspectable/ + loggable), pin via `SCROLLWM_VERSION` if we want reproducibility, prefer the + Homebrew cask when `brew` exists. Document clearly in the card that this + installs ScrollWM from github.com/1jehuang/scrollwm. +- **Network failure / no release asset.** Installer exits non-zero -> + `Failed { detail }` -> friendly message with the manual command; flow still + advances to Suggestions. Never blocks onboarding completion. +- **App Translocation / wrong dir.** Avoided by leaving `SCROLLWM_DEST` unset + (defaults to `~/Applications`); never install to `/tmp` or Downloads. +- **Already installed but stale.** We suppress when `.app` exists; we do not + auto-update during onboarding (ScrollWM self-updates). Correct and least + surprising. +- **Remote/client mode.** Excluded entirely (`!self.is_remote`) - Accessibility + is local-desktop-only and the installer must run on the user's machine. +- **Non-macOS.** `cfg!(target_os = "macos")` gate -> phase never entered; the + enum arm still compiles cross-platform (no macOS-only types in the phase). +- **Onboarding preview mode** (`/onboarding-preview`): allow the phase to render + for design review but make the Yes path a no-op stub (don't actually install) + when `self.onboarding_preview_mode` is set. +- **Tests:** `setup_hints.json` is read at gate time; the existing + `with_temp_jcode_home` test harness (used throughout + `tui/app/tests/onboarding_flow.rs`) isolates it, so unit tests can assert + "answered -> not offered", "macOS+not-installed -> offered", and the + timeout-answers-No behavior without touching the real home. + +## 9. Alternatives considered + +- **Make it a post-onboarding `setup_hints` nudge instead of an onboarding + phase.** Simpler (no enum/render changes), reuses `MAX_TERMINAL_NUDGES`. But + it misses the "set up ScrollWM *while* setting up jcode" intent in the brief + and feels like nagging. Use the phase; keep the nudge channel as a possible + later "finish Accessibility" reminder only. +- **A dedicated `/scrollwm` slash command + no onboarding step.** Good to have + regardless (discoverability), but does not satisfy the opt-in-during-onboarding + goal. Recommend shipping both eventually; the command can reuse + `run_scrollwm_install`. +- **Install synchronously with a blocking spinner.** Rejected: blocks the event + loop, no clean cancel, worse UX than the async Bus pattern already used for + model validation. + +## 10. Minimal first PR + +Smallest shippable slice that is correct and never nags, macOS-gated: + +1. `jcode-base/src/bus.rs`: add `ScrollWmInstallCompleted` struct + `BusEvent` + arm. +2. `jcode-setup-hints/src/lib.rs`: add `scrollwm_optin_answered` (+ + `scrollwm_install_started`) `#[serde(default)]` fields. +3. `onboarding_flow.rs`: add `OnboardingPhase::ScrollWmOptIn { yes_highlighted, + shown_at }`; extend `decision_seconds_remaining`/`decision_timed_out`. +4. `tui/mod.rs`: add `OnboardingWelcomeKind::ScrollWmOptIn { .. }` + + `ScrollWmInstallProgress`. +5. `state_ui_input_helpers.rs`: map the phase in `onboarding_welcome_kind()` and + add it to `onboarding_flow_drives_welcome()`. +6. `ui_onboarding.rs`: render arm (clone of `LoginOpenAi`) incl. Running/result + line. +7. `onboarding_flow_control.rs`: split `onboarding_show_suggestions` into a + gate + `onboarding_finish_to_suggestions`; add + `should_offer_scrollwm`, `scrollwm_app_installed`, + `onboarding_enter_scrollwm_optin`, `onboarding_answer_scrollwm_optin`, + `handle_onboarding_scrollwm_optin_key`, `spawn_scrollwm_install`, + `run_scrollwm_install` (web installer, async, timeout), and the + `ScrollWmOptIn` arms in `handle_onboarding_continue_prompt_key` and + `onboarding_tick` (timeout -> No). +8. `tui/app/local.rs` + `remote.rs`: dispatch `ScrollWmInstallCompleted` -> + `handle_scrollwm_install_completed`. +9. Tests in `tui/app/tests/onboarding_flow.rs` (with `with_temp_jcode_home`): + offered iff macOS+not-installed+unanswered; answering persists + `scrollwm_optin_answered`; timeout auto-answers No; Yes sets `Running` + progress; `ScrollWmInstallCompleted` advances to `Suggestions`. + +PR scope keeps the actual download command behind `run_scrollwm_install` so the +network behavior is one easily-reviewed function, and every flow path still ends +on the normal new-session screen. diff --git a/docs/scrollwm-integration/explorers/repo-distribution.md b/docs/scrollwm-integration/explorers/repo-distribution.md new file mode 100644 index 0000000000..cb6409acd2 --- /dev/null +++ b/docs/scrollwm-integration/explorers/repo-distribution.md @@ -0,0 +1,351 @@ +# Explorer: repo-distribution + +How to LINK `1jehuang/jcode` and `1jehuang/scrollwm` at the project / +distribution level, the version-compat + feature-detection handshake, the +Homebrew tap relationship, the cross-repo docs plan, and the scripted +marquee demo. Both repos are owned by the same user but ship on different +toolchains (Rust/cargo vs Swift/SwiftPM) and cadences. + +## TL;DR recommendation + +- **Repo linking: keep two separate repos (option a) + a small versioned + integration contract.** Do NOT submodule. The integration contract is a + short spec file that lives canonically in `scrollwm` (it owns the wire + format) and is mirrored/linked from `jcode`. The runtime coupling is a + **capability handshake over the existing `scrollwm status` JSON**, not a + build-time dependency. +- **Distribution: the linking already exists** - the `1jehuang/homebrew-jstack` + tap has a `jstack` bundle cask (`depends_on cask: scrollwm`). Lean into it: + jcode onboarding suggests `brew install --cask 1jehuang/jstack/scrollwm` (or + the web-install one-liner), scrollwm's docs/Config reference jcode. +- **Coupling is one-directional at the wire level, advertised both ways:** + jcode is the *client* (drives scrollwm via `scrollwm <verb>` / the control + socket); scrollwm stays jcode-agnostic but ships a commented jcode launcher + and a `jcode`-aware spawn hint. Each ships independently; neither build + depends on the other. + +--- + +## 1. The four repo-linking options, scored + +| Option | What it is | Pros | Cons | Verdict | +|---|---|---|---|---| +| (a) Separate repos + integration contract | Two repos, a versioned `INTEGRATION.md`/`integration.json` spec defining the socket verbs + status JSON + capability flags | Independent release cadence (Rust vs Swift CI are totally different); no cross-language build coupling; each repo stays buildable in isolation; mirrors how they already ship | Contract can drift if not tested; needs a cheap conformance check | **CHOSEN** | +| (b) git submodule | jcode vendors scrollwm (or vice versa) as a submodule | Single checkout; pinned SHA | Forces a Swift toolchain into jcode CI for no benefit (jcode never *builds* scrollwm); painful submodule UX; couples release trains; the runtime artifact is a `.app` + a CLI, not source | Reject | +| (c) Thin shared "integration" doc in one repo | A spec file in one repo only | Low cost | Where it lives becomes a bikeshed; the *other* repo has no anchor | Subsumed by (a): the contract is exactly this, but **dual-anchored** | +| (d) Homebrew tap relationship | Casks advertise/depend on each other | Real install-time linking; already half-built (`jstack` cask) | Only covers *install*, not the runtime handshake | **Adopt alongside (a)** | + +**Why (a)+(d), concretely:** jcode is Rust shipping via GitHub Releases + +`scripts/quick-release.sh` + CI updating `1jehuang/homebrew-jcode` and AUR +(`RELEASING.md`). scrollwm is a Swift `.app` shipping via +`scripts/web-install.sh`, `Casks/scrollwm.rb`, and an in-app self-updater +(`Updater.swift`, `UpdateCoordinator.swift`, `SemVer.swift`). These are +fundamentally different release pipelines. A submodule would drag the Swift +SDK / codesigning world into jcode's `cargo`/osxcross pipeline for zero gain, +because **jcode never compiles scrollwm** - it talks to a running app over a +Unix socket. So the only thing that must be shared is the *wire contract*, +which is a doc + a tiny JSON, not code. + +--- + +## 2. Version compatibility + feature detection scheme + +### 2.1 The handshake transport already exists + +scrollwm exposes a Unix socket at +`~/Library/Application Support/ScrollWM/control.sock` +(override `SCROLLWM_CONTROL_SOCK`), driven by +`ControlServer.swift` -> `ControlCommands.handleControlCommand` +(`Sources/WindowLab/ControlCommands.swift`). The `scrollwm` CLI shim +(`ControlCLI.swift`) connects, sends one line, prints the reply, and exits with +a meaningful code (`0` ok / `2` `error:` / `3` not-running). **This is the +handshake channel.** jcode should detect + drive scrollwm by shelling out to +`scrollwm status` (or connecting to the socket directly), exactly like the +existing setup-hint pattern in `jcode-setup-hints`. + +### 2.2 Gap to close (the ONE production change this area needs) + +`controlStatusJSON()` in `ControlCommands.swift` currently emits: + +```json +{ "managing": false, "focusMode": "...", "windowCount": 0, ... } +``` + +It has **no version and no capability list**. Add three fields so jcode can do +feature detection without sniffing the app bundle: + +```jsonc +{ + "managing": true, + "focusMode": "follow", + "windowCount": 3, + // --- new: integration handshake --- + "version": "0.2.0", // CFBundleShortVersionString (SemVer.swift parses it) + "protocol": 1, // integer control-protocol revision, bumped on breaking verb/JSON change + "capabilities": [ // feature flags jcode can branch on + "arrange", "focus", "move", "workspace", "width", + "display", "focus-mode", "spawn-adopt" + ] +} +``` + +`version` is already available app-side via `Bundle.main +.infoDictionary["CFBundleShortVersionString"]` (see +`CodeSigning.swift:104-106` and `Updater.swift:28-29`). `protocol` is a new +monotonically-increasing integer constant living next to the verb switch. +`capabilities` is derived from the verb set so it never drifts from reality. + +**Why protocol + capabilities, not just version:** jcode shouldn't hardcode +"scrollwm >= 0.2.0 has feature X". It should test `capabilities.contains("X")`. +The `protocol` integer is the coarse compatibility gate ("do we even speak the +same language"); `capabilities` is fine-grained feature detection. This is the +standard handshake-not-version-pinning pattern and lets either side ship +independently. + +### 2.3 jcode-side detection (mirror the setup-hints nudge pattern) + +Add a `jcode-scrollwm` detection helper (new tiny crate or a module under +`jcode-setup-hints`, which already owns `~/.jcode/setup_hints.json` and the +"nudge cap" pattern in `macos_launcher.rs`). Detection ladder: + +1. `which scrollwm` present? (installed CLI shim) -> `Installed`. +2. `scrollwm status` exits 0 with parseable JSON -> `Running`, capture + `{version, protocol, capabilities}`. +3. exit 3 / "isn't running" -> `InstalledNotRunning`. +4. not found -> `NotInstalled` (this is what onboarding offers to fix). + +Cache the result (with a short TTL) in `setup_hints.json` so jcode doesn't +shell out on every spawn. Gate *driving* features on `capabilities`; gate the +whole integration on `protocol >= JCODE_MIN_SCROLLWM_PROTOCOL` (a jcode +constant). On `protocol` newer than jcode knows, degrade gracefully: use only +the verbs jcode understands, never error. + +### 2.4 Compatibility policy (documented in the contract) + +- `protocol` bumps ONLY on a breaking change to an existing verb's args or the + status JSON shape. Adding a new verb or a new capability flag is **non-breaking** + (bump nothing; new flag appears in `capabilities`). +- jcode declares `JCODE_MIN_SCROLLWM_PROTOCOL` and `JCODE_KNOWN_SCROLLWM_PROTOCOL`. + If `status.protocol < min` -> treat as "incompatible, suggest `brew upgrade`". + If `min <= status.protocol` -> use `capabilities` for everything else. +- scrollwm guarantees backward-compatible replies within a `protocol` rev. + +--- + +## 3. Homebrew / tap relationship + +### 3.1 Current state (already linked!) + +`/Users/jeremy/homebrew-jstack` (`git@github.com:1jehuang/homebrew-jstack.git`) +already ships three casks: + +- `jcode.rb` - CLI only, `conflicts_with cask: jstack`. +- `scrollwm.rb` - the `.app` + `scrollwm` CLI shim, `depends_on macos: :sonoma`. +- `jstack.rb` - **the bundle**: installs the jcode binary AND + `depends_on cask: "1jehuang/jstack/scrollwm"`, `conflicts_with jcode`. + +`scripts/update.sh` regenerates all three from each repo's latest GitHub +release + `SHA256SUMS`. So `brew install --cask 1jehuang/jstack/jstack` already +installs both in one shot. Note jcode *also* publishes to a separate +`1jehuang/homebrew-jcode` tap from its release CI (`RELEASING.md`, +`release.yml:333`) - that one is jcode-only and unaware of scrollwm. + +### 3.2 Recommended tap topology + +Keep the split, make the bundle the front door: + +``` +1jehuang/homebrew-jstack (the integration tap, hand/CI-maintained) + +- jstack -> jcode + scrollwm bundle <- marketed entry point + +- jcode -> CLI only + +- scrollwm -> app only + +1jehuang/homebrew-jcode (jcode's own CI-updated tap, unchanged) + +- jcode -> CLI only, scrollwm-agnostic +``` + +Concrete tap improvements (small, low-risk): + +1. **`scrollwm.rb` -> `jcode.rb` cross-suggest via caveats.** scrollwm's cask + `caveats` should mention: "Pairs with jcode: `brew install --cask + 1jehuang/jstack/jcode` - jcode can auto-tile its agent windows in ScrollWM." +2. **`jcode.rb` caveats already say "Run `jcode`".** Add a one-liner: "Tip: + install ScrollWM (`brew install --cask 1jehuang/jstack/scrollwm`) to let + `jcode` tile headed swarm agents." +3. **Wire scrollwm into jcode's release CI tap optionally**: jcode's CI updates + `homebrew-jcode`. Have a small follow-on step (or the existing + `homebrew-jstack/scripts/update.sh` on a schedule) keep `jstack.rb` pinned to + the latest jcode release so the bundle never lags. Today `update.sh` is + manual; a nightly GitHub Action in the tap repo calling it closes the loop + without coupling either product's CI. + +The runtime handshake (section 2) means the casks do NOT need version-locked +`depends_on` between jcode and scrollwm - jcode feature-detects at runtime, so +mismatched cask versions degrade gracefully instead of failing to install. + +--- + +## 4. Cross-repo docs plan + +Dual-anchored contract, each side links the other. Canonical wire spec lives in +scrollwm (it owns the socket). + +``` +scrollwm/docs/INTEGRATION.md <- CANONICAL contract: + - socket path + env override + - every verb + args + reply shape + - the status JSON schema incl. version/protocol/capabilities + - protocol/compat policy + - "jcode is a first-class client" section + link to jcode docs + +jcode/docs/scrollwm-integration/CONTRACT.md <- thin pointer: + - "ScrollWM owns the wire contract: <scrollwm link>" + - jcode's constants: JCODE_MIN/KNOWN_SCROLLWM_PROTOCOL + - the detection ladder + which capabilities jcode uses + - install/onboarding UX cross-links +``` + +Plus light touches: + +- **scrollwm `README.md`**: a "Use with jcode" section (tile your AI agents). +- **jcode `README.md`**: a "Window management (macOS): ScrollWM" section with + the `brew install --cask 1jehuang/jstack/scrollwm` line. +- **homebrew-jstack `README.md`** already does the bundle pitch well; add a + one-line "How they integrate" pointer to `scrollwm/docs/INTEGRATION.md`. +- **Conformance guard:** a tiny test in scrollwm asserting `controlStatusJSON()` + emits `version`/`protocol`/`capabilities` and that `capabilities` matches the + live verb set, so the doc/code never drift (lands as a `*Tests.swift` per the + scrollwm swarm convention). + +--- + +## 5. The marquee end-to-end demo (scripted) + +Goal flow from the brief: **install jcode -> onboarding offers ScrollWM -> +spawn a headed swarm -> ScrollWM tiles the agents.** + +### 5.1 The narrative beats + +1. `brew install --cask 1jehuang/jstack/jstack` (or `curl | bash` web-install, + or `jcode` already installed). +2. First `jcode` run -> onboarding. After login/import phases + (`OnboardingPhase` in `jcode-tui/.../onboarding_flow.rs`), a NEW opt-in row: + "Install ScrollWM to auto-tile your agent windows? [Yes/No]" rendered like + the existing Yes/No decision rows (`ui_onboarding.rs`, + `draw_onboarding_welcome`). Yes -> run the scrollwm web-install one-liner / + `brew install --cask`, then open System Settings for the single + Accessibility grant (scrollwm handles the rest; it auto-continues on grant + per `web-install.sh` next-steps). +3. User asks jcode to do work; jcode spawns a **headed** swarm with + `swarm_spawn_mode = visible` (`SwarmSpawnMode::Visible`, + `jcode-config-types`), each agent a new Ghostty window via + `spawn_visible_session_window` -> `session_launch` -> + `terminal_launch::spawn_command_in_new_terminal` (`build_spawn_command`, + `open -na Ghostty ...`). +4. jcode, having detected scrollwm (section 2.3), drives it: after spawning the + N agent windows it calls `scrollwm arrange` once, then `scrollwm focus <n>` + to spotlight the active agent. ScrollWM tiles them into strip columns / + workspaces. (This is the actual integration the other explorers design; this + doc just scripts/demos it.) + +### 5.2 Reproducible demo script (throwaway, lives in this doc) + +A safe, sandbox-respecting capture script. **Obeys the scrollwm golden rule:** +it uses ScrollWM's own `sandbox` disposable windows for any pre-flight and only +arranges jcode's freshly-spawned agent windows, never the user's real windows. + +```bash +#!/usr/bin/env bash +# docs/scrollwm-integration/demo/run_demo.sh (throwaway capture harness) +set -euo pipefail + +# 0. Preconditions ---------------------------------------------------------- +command -v scrollwm >/dev/null || { echo "install scrollwm first"; exit 1; } +command -v jcode >/dev/null || { echo "install jcode first"; exit 1; } + +# 1. Handshake / feature detection (the section-2 contract in action) ------- +status="$(scrollwm status 2>/dev/null || echo '{}')" +echo "ScrollWM status: $status" +# (Once version/protocol/capabilities land, branch on them here.) + +# 2. Make sure ScrollWM is managing an EMPTY strip (no real windows) -------- +# Launch it dormant; do NOT 'arrange' the real desktop. +open -a ScrollWM || true +sleep 1 + +# 3. Spawn a headed jcode swarm (visible Ghostty windows) ------------------- +# Configure visible spawns, then ask jcode to fan out work. +jcode --set agents.swarm_spawn_mode=visible \ + -p 'Spawn 3 headed swarm agents that each summarize one file in ./src, \ + then arrange them with ScrollWM.' + +# 4. Tile + spotlight: jcode does this internally once the integration ships; +# here we show the verbs explicitly for the demo voiceover. +scrollwm arrange # tile the agent windows into the strip +scrollwm focus 1 # spotlight agent #1 +sleep 1; scrollwm focus next # walk across agents for the camera +sleep 1; scrollwm focus next + +# 5. Teardown: release restores every window to where it was ---------------- +scrollwm release +``` + +For an automated screen capture, wrap with macOS `screencapture -v` (or reuse +the repo's `scripts/record_demo.sh` / `capture_demo.sh` patterns, which are +currently niri/Linux-specific and would get a macOS sibling). The existing +`~/Desktop/scrollwm + jcode demo.mov` (23 MB, recorded 2026-06-25) is the +reference look; this script reproduces it deterministically. + +### 5.3 What makes the demo "marquee" + +- Single command to install both (`brew install --cask 1jehuang/jstack/jstack`). +- One permission (Accessibility) the whole way. +- The "wow": you tell jcode to do parallel work, three terminals fly in, and + ScrollWM instantly tiles them into a scrolling strip you can scrub across with + `cmd+h`/`cmd+l` while agents work. Then `release` and your desktop is exactly + as it was - the safety story is part of the pitch. + +--- + +## 6. Risks, edge cases, alternatives + +- **Drift between doc and socket:** mitigated by the conformance test in 5/§4 + and by deriving `capabilities` from the live verb set, not a hand-list. +- **scrollwm not running when jcode wants to tile:** `scrollwm arrange` already + auto-launches for `arrange`/`toggle` (`ControlCLI.swift launchVerbs`); jcode + should rely on that and tolerate exit 3. +- **Golden rule violations in demos/tests:** never `scrollwm arrange` in a test + against the live desktop; use scrollwm `sandbox` windows or only + jcode-spawned windows. The demo script above is written to honor this. +- **Cask version skew:** acceptable by design - runtime handshake degrades; do + NOT add tight `depends_on` version pins between jcode and scrollwm casks. +- **Two jcode taps (`homebrew-jcode` vs `homebrew-jstack`):** keep both; + document that `jstack`/`jcode` casks conflict (already encoded via + `conflicts_with`). The README already calls this out. +- **Alternative considered - jcode bundles a scrollwm client lib:** unnecessary; + the socket + `scrollwm` CLI is the public API. A thin Rust wrapper around + `scrollwm <verb>` (or a raw `UnixStream`) is all jcode needs. + +--- + +## 7. Minimal first PR for this area + +Two tiny, independently-shippable PRs (one per repo) + a docs commit: + +1. **scrollwm PR** (`ControlCommands.swift`): add `version`, `protocol` (=1), + and `capabilities` to `controlStatusJSON()`, plus a `ControlStatusTests.swift` + asserting the fields exist and `capabilities` matches the verb set. Create + `scrollwm/docs/INTEGRATION.md` (canonical contract). No behavior change to + window management. ~40 lines + test. +2. **jcode PR** (docs + constants only, no production wiring yet): add + `jcode/docs/scrollwm-integration/CONTRACT.md` pointing at the scrollwm spec, + define `JCODE_MIN_SCROLLWM_PROTOCOL`/`JCODE_KNOWN_SCROLLWM_PROTOCOL` + constants (in the future `jcode-scrollwm` module), and add a README "Window + management: ScrollWM" section with the brew line. +3. **homebrew-jstack PR**: add cross-suggest `caveats` to `jcode.rb` and + `scrollwm.rb`, and a nightly Action calling `scripts/update.sh` so `jstack` + tracks the latest jcode release automatically. + +These three land in any order, each repo stays green, and together they +establish the version handshake + the advertised-but-decoupled relationship the +later integration PRs (spawn/tile wiring, onboarding opt-in row) build on. diff --git a/docs/scrollwm-integration/explorers/swarm-orchestration.md b/docs/scrollwm-integration/explorers/swarm-orchestration.md new file mode 100644 index 0000000000..abad25e50d --- /dev/null +++ b/docs/scrollwm-integration/explorers/swarm-orchestration.md @@ -0,0 +1,292 @@ +# Explorer: swarm-orchestration (jcode → ScrollWM) + +How jcode's multi-agent swarm (headed/visible spawns) should drive ScrollWM so +each spawned agent window lands in a strip column (and optionally a workspace), +and how jcode focuses the active agent's window. + +Scope: the **TUI/CLI agent** (root jcode), package `jcode`. Read-only study; no +production code changed. + +--- + +## 1. What actually happens today (code anchors) + +### jcode visible-spawn pipeline +`swarm spawn` / `fill_slots` / `run_plan` (coordinator) emits a `CommSpawn` +request that lands here: + +- `crates/jcode-app-core/src/server/client_lifecycle.rs:2044` + — `Request::CommSpawn { … spawn_mode }` → `parse_swarm_spawn_mode` → + `handle_comm_spawn`. +- `crates/jcode-app-core/src/server/comm_session.rs:694` — `handle_comm_spawn` + (idempotency key, coordinator guard) → `spawn_swarm_agent`. +- `comm_session.rs:469` — `spawn_swarm_agent`. Resolves `SwarmSpawnMode` + (`comm_session.rs:494`: `spawn_mode.unwrap_or(agents_config.swarm_spawn_mode)`), + then for `Visible | Auto` calls `prepare_visible_spawn_session(…, spawn_visible_session_window)` + (`comm_session.rs:514-525`). +- `comm_session.rs:348` — `prepare_visible_spawn_session`: creates the persisted + session, persists a headed startup message, then runs the launch closure. +- `comm_session.rs:96` — `spawn_visible_session_window` → + `session_launch::spawn_resume_in_new_terminal_with_provider`. +- `crates/jcode-app-core/src/session_launch.rs:77` — builds a `TerminalCommand` + (`--fresh-spawn --resume <id>`, title from `resumed_window_title`) and calls + `terminal_launch::spawn_command_in_new_terminal`. +- `crates/jcode-terminal-launch/src/lib.rs:293` — macOS Ghostty branch: + `open -na Ghostty --args -e /bin/bash -lc '<jcode --resume …>'`. +- Back in `spawn_swarm_agent`, on success the member is registered as visible: + `register_visible_spawned_member` (`comm_session.rs:396`, called at + `comm_session.rs:587-601`) with `is_headless: false`, a `friendly_name` + (`crate::id::extract_session_name`), `status = spawned|running`. This is the + natural hook point for ScrollWM reconcile. + +Key properties: +- The launch is **fire-and-forget**: `open -na Ghostty …` returns immediately; + the new jcode process boots in its own window asynchronously (~0.3–2 s). +- The spawned jcode sets its **terminal/window title** via OSC (crossterm + `SetTitle`) in `update_terminal_title` + (`crates/jcode-tui/src/tui/app/tui_lifecycle_runtime.rs:64`), format roughly + `"<icon> jcode <session-label>"` (+ `" [self-dev]"` for canary). Title is also + `resumed_window_title` (`session_launch.rs:33`). **ScrollWM reads exactly this + AX title**, so jcode already names its windows in a matchable way. +- `friendly_name` (e.g. `swarm-orchestration`) is the swarm-facing identity and + is derivable from the session id; it appears inside the session label. + +### ScrollWM control plane (what we can drive) +- Socket: `~/Library/Application Support/ScrollWM/control.sock`, override + `SCROLLWM_CONTROL_SOCK` (`Sources/WindowLab/ControlServer.swift:21`). Line + protocol: connect → write `"<verb> args\n"` → `shutdown(WR)` → read one reply + line. `chmod 0600`, owner-only. +- Verbs (`Sources/WindowLab/ControlCommands.swift:15`): `ping`(→`pong`), + `status`(→JSON), `arrange`, `release`, `toggle`, `focus <next|prev|left|right|N>`, + `move <left|right|up|down>`, `workspace <up|down|N>`, `width <25|50|75|100|f>`, + `close`, `display`, `focus-mode`, `reload`, `update`, `quit`. +- `status` JSON (`controlStatusJSON` `ControlCommands.swift:158` + + `controlColumns` `ScrollWMApp.swift:970`) returns, when managing: + `managing`, `windowCount`, `focusedColumn`, `workspace`, `workspaceCount`, + and `columns: [{ index, app, title, width, focused, healthy }]`. **This is the + bridge**: jcode can map `session → column index` by matching `title`/`app`. +- New-window auto-adopt: while ScrollWM `isManaging`, a freshly opened window is + adopted automatically right-of-focus via the `kAXWindowCreated` observer + + `LifecycleMonitor.resync` (`Sources/WindowLab/LifecycleMonitor.swift:146,245`), + snapped to `spawnWidth` (`TeleportEngine.adopt`/`applySpawnWidth`). So if + ScrollWM is already managing, **jcode does not need to place windows at all** — + they land in columns in spawn order; jcode only needs to (optionally) focus. +- The `scrollwm` CLI shim (`Sources/WindowLab/ControlCLI.swift`) is on PATH at + `/opt/homebrew/bin/scrollwm`; `arrange`/`toggle` will even launch the app. + +### Two facts that shape the design (verified live) +1. **Socket presence ≠ app running.** On this machine the socket file exists but + `scrollwm ping` reports "isn't running" (stale socket → `connect()` returns + `ECONNREFUSED`). Presence MUST be probed with `ping`→`pong`, never `stat`. + `ControlClient.send` already maps `ENOENT`/`ECONNREFUSED` → `notRunning` + (`ControlServer.swift:173-176`). +2. **`scrollwm focus` is column-index / next-prev only** — there is no + focus-by-title/app verb. To "focus the active agent's window" jcode must read + `status`, find the column whose `title` matches the agent's window, then + `focus N`. (Alternatively add a `focus-window <substr>` verb to ScrollWM; see + alternatives.) + +--- + +## 2. Design + +### 2.1 New thin client in jcode: `scrollwm` control module +Add `crates/jcode-app-core/src/scrollwm.rs` (mirrors ScrollWM's `ControlClient` +in ~120 lines of std `UnixStream`; no new deps). Public surface: + +```rust +pub struct Scrollwm { sock: PathBuf } // resolves SCROLLWM_CONTROL_SOCK or default +impl Scrollwm { + pub fn resolve() -> Self; // env override → ~/Library/Application Support/ScrollWM/control.sock + pub fn available(&self) -> bool; // send("ping") == "pong"; false on ENOENT/ECONNREFUSED/timeout + pub fn send(&self, line: &str) -> Result<String>; // connect, write+\n, shutdown(WR), read, ~250ms timeout + pub fn status(&self) -> Result<ScrollwmStatus>; // parse status JSON + pub fn arrange(&self) -> Result<String>; + pub fn focus_column(&self, one_based: usize) -> Result<String>; +} + +// PURE, unit-testable: pick the column for a spawned agent window. +pub fn column_for_window(status: &ScrollwmStatus, want: &WindowMatch) -> Option<usize>; +``` + +Why a direct socket (not shelling `scrollwm`): synchronous, no PATH dependency, +no `open`/launch side effects, trivially graceful when absent. Keep an optional +`scrollwm` CLI fallback only if the binary is found and the socket call fails. + +### 2.2 Window matching (naming so ScrollWM can match) +jcode already sets a matchable title. To make matching robust and cheap, match +on **`app == "Ghostty"` (or the resolved terminal) AND `title` contains the +agent's session label/short-id**. The session short-id is unique and already in +`resumed_window_title` via `terminal_session_label_for_id` +(`crates/jcode-base/src/process_title.rs:55`). + +Hardening (small, optional follow-up): inject a stable, greppable marker into the +spawned window title, e.g. prefix `⟦jcode:<shortid>⟧`, or pass an env var +`JCODE_SCROLLWM_TAG=<shortid>` into the Ghostty spawn so the title is +deterministic even before the agent renders its own title. `WindowMatch` carries +`{ app: Option<&str>, title_contains: Vec<String> }` and `column_for_window` +prefers an exact tag hit, then falls back to session-label substring. + +### 2.3 Reconcile flow (after spawn) +Hook a non-blocking reconcile right after `register_visible_spawned_member` +(`comm_session.rs:587`). Do NOT block the spawn path on socket/window I/O. + +```mermaid +sequenceDiagram + participant Coord as jcode coordinator (server) + participant OS as open -na Ghostty + participant Agent as new jcode window + participant SW as ScrollWM (socket) + Coord->>OS: spawn_visible_session_window (existing) + Coord->>Coord: register_visible_spawned_member (existing) + Coord-->>Coord: tokio::spawn reconcile_scrollwm(session_id, label) + Note over Coord,SW: reconcile task (bounded, ~3s) + Coord->>SW: ping + alt not running / disabled + Coord-->>Coord: no-op (graceful) + else available + OS-->>Agent: window appears (~0.3-2s) + Agent->>Agent: SetTitle(OSC) -> ScrollWM AX-adopts column + loop poll up to ~3s + Coord->>SW: status (JSON) + Coord->>Coord: column_for_window(status,label) + end + opt arrange_on_spawn && !status.managing + Coord->>SW: arrange (opt-in only) + end + Coord->>SW: focus N (only when this agent is the "active" one) + end +``` + +Reconcile algorithm (`reconcile_scrollwm_after_spawn`): +1. Gate on config (`integrations.scrollwm.enabled`) and `available()` (ping). + Absent/disabled → return (no-op). +2. Read `status`. If `!managing`: with default config, **do nothing** (auto-adopt + only happens while managing). If `arrange_on_spawn` is opted in, call + `arrange` once to start managing (caveat: arrange adopts the *whole current + Space* — see Risks). +3. Poll `status` (e.g. 6×500 ms) until `column_for_window` resolves the new + agent's column (the window may not exist yet). On miss after the budget, stop + quietly — the window still got auto-adopted; we just don't force focus. +4. Focus policy: only `focus N` when this agent should be the active/foreground + one. Default: focus the **just-spawned** agent (matches niri/PaperWM "follow + the newest window"), which is also ScrollWM's own auto-adopt behavior, so this + is usually a no-op confirm. A future `swarm focus <agent>` could drive + `focus_column` for the user's chosen active agent. + +### 2.4 Per-agent column / workspace assignment +- **Columns (default, minimal):** spawning N agents sequentially yields N + Ghostty windows; ScrollWM auto-adopts each right-of-focus, so columns come out + in spawn order with zero placement code. Good enough for v1. +- **Workspaces (opt-in, later):** to put each agent (or each batch) on its own + vertical workspace, after focusing the agent's column call + `move down` (`moveFocusedToWorkspace`) or `workspace N`. e.g. coordinator on + ws1, workers fanned across ws2…wsN. Keep behind config; it reorders the user's + view and is easy to get wrong. + +### 2.5 Config + env +Add to `AgentsConfig`/a new `IntegrationsConfig` in +`crates/jcode-config-types/src/lib.rs` (next to `SwarmSpawnMode`, +`config-types:407`): + +```rust +pub struct ScrollwmConfig { + pub enabled: bool, // default false (opt-in); "auto" = use if ping ok + pub arrange_on_spawn: bool, // default false (don't grab the user's desktop) + pub focus_active: bool, // default true: focus the spawned/active agent + pub workspaces: bool, // default false: per-agent workspace fan-out +} +``` +Env overrides for quick testing: `JCODE_SCROLLWM=1`, `JCODE_SCROLLWM_ARRANGE=1`, +mirroring existing env-over-config patterns. + +--- + +## 3. API / data-flow sketch (jcode side) + +``` +spawn_swarm_agent (comm_session.rs:469) + └─ register_visible_spawned_member (…:587) + └─ tokio::spawn reconcile_scrollwm_after_spawn(session_id, friendly_name) + ├─ cfg = config().integrations.scrollwm (gate) + ├─ sw = Scrollwm::resolve(); if !sw.available() { return } // ping + ├─ st = sw.status()? → ScrollwmStatus { managing, columns[…] } + ├─ (opt-in) if cfg.arrange_on_spawn && !st.managing { sw.arrange()?; } + ├─ poll: column_for_window(&st, &WindowMatch{ app:"Ghostty", + │ title_contains:[shortid, label] }) + └─ if cfg.focus_active && let Some(n) = col { sw.focus_column(n)?; } +``` + +`ScrollwmStatus` = thin serde struct over the `status` JSON +(`{ managing, windowCount, focusedColumn, columns:[{index,app,title,focused}] }`). +`column_for_window` is a pure function → unit-testable from canned JSON. + +--- + +## 4. Risks / edge cases + +- **`arrange` grabs the whole Space, not just jcode windows.** `arrange` + (`ScrollWMApp.swift:645`) adopts *every* manageable current-Space window + (the user's editor, browser, …) into the strip. Auto-triggering it from a + swarm spawn would rearrange the user's entire desktop. Mitigation: default + `arrange_on_spawn=false`; rely on auto-adopt when the user has *already* + arranged. Never call `arrange` implicitly. +- **Async window appearance → focus race.** Window/title may lag the socket + call by up to ~2 s. Mitigation: bounded `status` poll keyed on title match; + give up silently (the window is still adopted). +- **Stale socket.** Must `ping` (not `stat`); `ECONNREFUSED`/`ENOENT`/timeout → + treat as absent. Use a short connect/read timeout (~250 ms) so a wedged app + never stalls a spawn. +- **Focus-by-index drift.** Column indices change as windows are added/closed. + Always re-derive N from a fresh `status` title match immediately before + `focus N`; never cache N. +- **Title collisions.** Two agents in the same repo share a similar label; + disambiguate with the session short-id (unique) and prefer it in matching. +- **Multi-display / multi-workspace.** `status.columns` reflect the active + strip/workspace only; on multi-display the agent may be on another strip. + v1: best-effort, no-op on miss. +- **Don't block / don't panic the server.** All ScrollWM I/O on a detached + task with timeouts; every error path is a quiet no-op + one log line. +- **Safety contract.** jcode only ever drives ScrollWM's *control plane*; it + never enumerates/moves windows itself. ScrollWM's sandbox/Space rules remain + authoritative. Tests use `SCROLLWM_CONTROL_SOCK` to point at a sandbox app + (`WindowLab sandbox`) — never the real session. + +### Alternatives considered +- **Add `focus-window <substr>` + `arrange-mine <pidlist>` verbs to ScrollWM.** + Cleaner long-term (jcode passes the spawned PID/title; ScrollWM matches and can + scope adoption to jcode's own windows, avoiding the "grab whole desktop" + problem). Recommended as the *second* PR, on the ScrollWM side. jcode already + knows spawned PIDs are unavailable from `open -na` directly, but the new + window's title/app are enough to match. +- **Shell out to `scrollwm <verb>`** instead of a socket client: simplest, but + PATH-dependent, spawns a process per call, and `arrange`/`toggle` can *launch* + the app (unwanted side effect). Use only as fallback. +- **niri-style `spawn` binding** (the commented `ctrl+opt+j` in + `Config.swift:539`): user-driven, not swarm-driven; orthogonal to this. + +--- + +## 5. Minimal first PR + +Ship the smallest safe, useful slice: **detect ScrollWM, focus the freshly +spawned agent's column; never rearrange the user's desktop.** + +1. `crates/jcode-app-core/src/scrollwm.rs` (new): `Scrollwm` UnixStream client + (`resolve`, `available`/ping, `send`, `status`) + `ScrollwmStatus` serde + the + pure `column_for_window`. Register `pub mod scrollwm;` in the crate. +2. `crates/jcode-config-types/src/lib.rs`: add `ScrollwmConfig { enabled=false, + arrange_on_spawn=false, focus_active=true, workspaces=false }` under a new + `integrations` field; env overrides `JCODE_SCROLLWM*`. +3. `comm_session.rs` `spawn_swarm_agent`: after `register_visible_spawned_member` + (`:587`), `tokio::spawn(reconcile_scrollwm_after_spawn(session_id, label))` + that gates on config + `available()`, polls `status` for the agent's column by + title/short-id, and `focus_column(n)` when `focus_active`. No `arrange` unless + `arrange_on_spawn` is explicitly on. Pure no-op when ScrollWM is absent. +4. Tests: unit-test `column_for_window` against canned `status` JSON (exact tag, + label-substring, no-match). Manual/throwaway probe against `WindowLab sandbox` + + `SCROLLWM_CONTROL_SOCK` to confirm ping/status/focus round-trips. + +Deliberately deferred to follow-ups: `arrange_on_spawn`, per-agent workspaces, +the title `⟦jcode:<shortid>⟧` marker / `JCODE_SCROLLWM_TAG`, and the ScrollWM-side +`focus-window` / scoped `arrange-mine` verbs. From f752d200981841bae51c4eb41ab3b2d27edb06ca Mon Sep 17 00:00:00 2001 From: jcode <jcode@local> Date: Thu, 25 Jun 2026 20:03:03 -0700 Subject: [PATCH 12/18] feat(onboarding): show 'Searched, not found' sources under login decision rows Surfaces the credential sources jcode probed during first-run detection but did not find, beneath the import/login decision rows, with PgUp/PgDn scrolling when the list overflows the welcome card. - jcode-app-core: add external_auth_search_report()/external_auth_search_targets() keyed on presence (not consent) so already-trusted logins never read as 'not found' - onboarding flow carries login_not_found; welcome kinds gain not_found rows + scroll - ui_onboarding renders a scrollable 'Searched, not found' panel - PgUp/PgDn scroll, clamped, disjoint from the Yes/No h/l/j/k keys; reset on row advance - tests: core family coverage, welcome-kind rows, scroll clamp, render assertions --- crates/jcode-app-core/src/external_auth.rs | 162 ++++++++++++++++++ crates/jcode-tui/src/tui/app.rs | 4 + .../jcode-tui/src/tui/app/onboarding_flow.rs | 25 ++- .../src/tui/app/onboarding_flow_control.rs | 94 +++++++++- .../src/tui/app/state_ui_input_helpers.rs | 23 ++- .../src/tui/app/tests/onboarding_flow.rs | 118 ++++++++++++- crates/jcode-tui/src/tui/app/tui_lifecycle.rs | 2 + crates/jcode-tui/src/tui/mod.rs | 28 ++- crates/jcode-tui/src/tui/ui.rs | 2 +- crates/jcode-tui/src/tui/ui_onboarding.rs | 98 ++++++++++- crates/jcode-tui/src/tui/ui_tests/mod.rs | 8 + .../jcode-tui/src/tui/ui_tests/onboarding.rs | 60 +++++++ 12 files changed, 610 insertions(+), 14 deletions(-) diff --git a/crates/jcode-app-core/src/external_auth.rs b/crates/jcode-app-core/src/external_auth.rs index 80aa225a94..0fa5e6a202 100644 --- a/crates/jcode-app-core/src/external_auth.rs +++ b/crates/jcode-app-core/src/external_auth.rs @@ -278,6 +278,141 @@ pub fn pending_external_auth_review_candidates() -> Result<Vec<ExternalAuthRevie Ok(candidates) } +/// One external credential source the auto-import sweep probes for, plus +/// whether an artifact was actually located on disk. +/// +/// "Present" here means *only* presence of the credential artifact, NOT +/// whether it was consented/imported. A present-but-already-trusted source is +/// still `present: true`; it simply won't show up as a pending candidate. The +/// onboarding "searched, not found" UI lists the targets with `present == false`. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct AuthSearchTarget { + /// Stable family id ("codex", "claude_code", "cursor", ...). + pub family: &'static str, + /// Human label for the row ("Codex", "Claude Code", "GitHub Copilot"). + pub label: String, + /// Representative path we looked at (best-effort; some families probe + /// several files and this is the canonical one). + pub path: String, + /// True when a credential artifact exists on disk. + pub present: bool, +} + +/// Outcome of one import-detection sweep: what we found (the same pending +/// candidates the rest of the flow consumes) and the full set of sources we +/// probed, each flagged present/absent. `not_found()` derives the +/// "searched, but nothing here" list for the onboarding card. +#[derive(Debug, Clone, Default)] +pub struct ExternalAuthSearchReport { + /// Importable, unconsented candidates (mirrors + /// [`pending_external_auth_review_candidates`]). + pub found: Vec<ExternalAuthReviewCandidate>, + /// Every source family we probed, with its presence flag. + pub targets: Vec<AuthSearchTarget>, +} + +impl ExternalAuthSearchReport { + /// The probed source families that did not have a credential artifact. + pub fn not_found(&self) -> Vec<&AuthSearchTarget> { + self.targets.iter().filter(|t| !t.present).collect() + } +} + +/// Best-effort string form of a path-returning helper, for display only. +fn search_path_string(path: Result<std::path::PathBuf>, fallback: &str) -> String { + path.map(|p| p.display().to_string()) + .unwrap_or_else(|_| fallback.to_string()) +} + +/// Build the canonical list of credential families the import sweep looks at, +/// each tagged with whether an artifact is present. Keyed on **presence only** +/// (via the existing `*_exists()` / `preferred_*().is_some()` helpers), so an +/// already-trusted login reads as present and never shows up as "not found". +/// +/// Keep this in lockstep with [`pending_external_auth_review_candidates`]: every +/// family probed there should appear here. +pub fn external_auth_search_targets() -> Vec<AuthSearchTarget> { + use auth::external::ExternalAuthSource; + + let copilot_present = { + use auth::copilot::ExternalCopilotAuthSource as C; + [C::ConfigJson, C::HostsJson, C::AppsJson] + .into_iter() + .any(|s| s.path().exists()) + }; + + vec![ + AuthSearchTarget { + family: "codex", + label: "Codex".to_string(), + path: search_path_string(auth::codex::legacy_auth_file_path(), "~/.codex/auth.json"), + present: auth::codex::legacy_auth_source_exists(), + }, + AuthSearchTarget { + family: "claude_code", + label: "Claude Code".to_string(), + path: "~/.claude/.credentials.json".to_string(), + present: matches!( + auth::claude::preferred_external_auth_source(), + Some(auth::claude::ExternalClaudeAuthSource::ClaudeCode) + ), + }, + AuthSearchTarget { + family: "gemini_cli", + label: "Gemini CLI".to_string(), + path: search_path_string( + auth::gemini::gemini_cli_oauth_path(), + "~/.gemini/oauth_creds.json", + ), + present: auth::gemini::gemini_cli_auth_source_exists(), + }, + AuthSearchTarget { + family: "copilot", + label: "GitHub Copilot".to_string(), + path: "~/.copilot/config.json".to_string(), + present: copilot_present, + }, + AuthSearchTarget { + family: "cursor", + label: "Cursor".to_string(), + path: search_path_string(auth::cursor::cursor_auth_file_path(), "~/.cursor/auth.json"), + present: auth::cursor::preferred_external_auth_source().is_some(), + }, + AuthSearchTarget { + family: "opencode", + label: "OpenCode".to_string(), + path: search_path_string( + ExternalAuthSource::OpenCode.path(), + "~/.local/share/opencode/auth.json", + ), + present: ExternalAuthSource::OpenCode + .path() + .map(|p| p.exists()) + .unwrap_or(false), + }, + AuthSearchTarget { + family: "pi", + label: "pi".to_string(), + path: search_path_string(ExternalAuthSource::Pi.path(), "~/.pi/agent/auth.json"), + present: ExternalAuthSource::Pi + .path() + .map(|p| p.exists()) + .unwrap_or(false), + }, + ] +} + +/// Run the import-detection sweep and return both the importable candidates and +/// the full searched-target list (with presence flags). Additive wrapper over +/// [`pending_external_auth_review_candidates`] used by onboarding to show a +/// "Searched, not found" panel beneath the import decision rows. +pub fn external_auth_search_report() -> Result<ExternalAuthSearchReport> { + Ok(ExternalAuthSearchReport { + found: pending_external_auth_review_candidates()?, + targets: external_auth_search_targets(), + }) +} + pub fn parse_external_auth_review_selection(input: &str, count: usize) -> Result<Vec<usize>> { let trimmed = input.trim(); if trimmed.is_empty() { @@ -684,4 +819,31 @@ mod render_markdown_tests { vec![("openai", "import")] ); } + + #[test] + fn search_targets_cover_every_probed_family() { + // The "searched, not found" UI relies on this canonical family list + // staying in lockstep with `pending_external_auth_review_candidates`. + let targets = super::external_auth_search_targets(); + let families: Vec<&str> = targets.iter().map(|t| t.family).collect(); + for expected in [ + "codex", + "claude_code", + "gemini_cli", + "copilot", + "cursor", + "opencode", + "pi", + ] { + assert!( + families.contains(&expected), + "missing search family {expected}; got {families:?}" + ); + } + // Every target carries a non-empty label and representative path. + for t in &targets { + assert!(!t.label.is_empty(), "empty label for {}", t.family); + assert!(!t.path.is_empty(), "empty path for {}", t.family); + } + } } diff --git a/crates/jcode-tui/src/tui/app.rs b/crates/jcode-tui/src/tui/app.rs index 926db0991c..9d27d4eac9 100644 --- a/crates/jcode-tui/src/tui/app.rs +++ b/crates/jcode-tui/src/tui/app.rs @@ -817,6 +817,10 @@ pub struct App { /// Active guided first-run onboarding flow (model select -> continue -> /// transcript pick -> suggestions). `None` when not onboarding. onboarding_flow: Option<onboarding_flow::OnboardingFlow>, + /// Scroll offset (in lines) into the onboarding "Searched, not found" panel + /// rendered beneath the login decision rows. Reset to 0 when the decision + /// row advances or the login phase changes. + onboarding_notfound_scroll: u16, /// One-shot guard: have we evaluated whether to auto-start the onboarding /// flow on startup yet? The fresh-install path logs in at the CLI before the /// TUI launches, so no in-TUI login event fires; this lets us still begin the diff --git a/crates/jcode-tui/src/tui/app/onboarding_flow.rs b/crates/jcode-tui/src/tui/app/onboarding_flow.rs index 929eac0219..00aa874db5 100644 --- a/crates/jcode-tui/src/tui/app/onboarding_flow.rs +++ b/crates/jcode-tui/src/tui/app/onboarding_flow.rs @@ -249,6 +249,12 @@ impl OnboardingPendingValidation { #[derive(Clone, Debug)] pub(crate) struct OnboardingFlow { pub(crate) phase: OnboardingPhase, + /// Credential families we probed during first-run detection but did not + /// find. Surfaced as a "Searched, not found" panel beneath the login + /// decision rows (both the per-candidate import walkthrough and the + /// "Log in to OpenAI?" prompt where nothing was found at all). Empty when + /// detection was skipped or every probed source was present. + pub(crate) login_not_found: Vec<crate::external_auth::AuthSearchTarget>, } impl OnboardingFlow { @@ -258,6 +264,7 @@ impl OnboardingFlow { pub(crate) fn begin() -> Self { Self { phase: OnboardingPhase::ModelSelect, + login_not_found: Vec::new(), } } @@ -267,6 +274,16 @@ impl OnboardingFlow { /// simple "Log in to OpenAI?" Yes/No instead of dropping straight to the /// provider picker. pub(crate) fn begin_at_login(import: Option<ImportReview>) -> Self { + Self::begin_at_login_with_not_found(import, Vec::new()) + } + + /// Like [`OnboardingFlow::begin_at_login`], but also records the probed + /// credential families that were absent, for the "Searched, not found" + /// panel. + pub(crate) fn begin_at_login_with_not_found( + import: Option<ImportReview>, + login_not_found: Vec<crate::external_auth::AuthSearchTarget>, + ) -> Self { let phase = match import { Some(review) => OnboardingPhase::Login { import: Some(review), @@ -275,7 +292,10 @@ impl OnboardingFlow { yes_highlighted: true, }, }; - Self { phase } + Self { + phase, + login_not_found, + } } /// Whether the flow is actively driving the UI. @@ -365,6 +385,7 @@ mod tests { fn done_phase_is_inactive() { let flow = OnboardingFlow { phase: OnboardingPhase::Done, + login_not_found: Vec::new(), }; assert!(!flow.is_active()); } @@ -378,6 +399,7 @@ mod tests { yes_highlighted: true, shown_at: past, }, + login_not_found: Vec::new(), }; // The continue prompt now shares the longer DECISION_TIMEOUT with the // import and telemetry prompts (not the short AUTO_ADVANCE). @@ -393,6 +415,7 @@ mod tests { yes_highlighted: true, shown_at: Instant::now(), }, + login_not_found: Vec::new(), }; let remaining = flow.decision_seconds_remaining().unwrap(); assert!( diff --git a/crates/jcode-tui/src/tui/app/onboarding_flow_control.rs b/crates/jcode-tui/src/tui/app/onboarding_flow_control.rs index c601893f39..e1b6dca643 100644 --- a/crates/jcode-tui/src/tui/app/onboarding_flow_control.rs +++ b/crates/jcode-tui/src/tui/app/onboarding_flow_control.rs @@ -13,6 +13,9 @@ use crossterm::event::KeyCode; use std::cell::RefCell; use std::time::Instant; +/// Lines scrolled per PgUp/PgDn in the "Searched, not found" onboarding panel. +const NOT_FOUND_SCROLL_STEP: i32 = 3; + impl App { /// Whether the guided onboarding flow is currently driving the UI. pub(super) fn onboarding_flow_active(&self) -> bool { @@ -164,18 +167,30 @@ impl App { return; } // Detect importable external logins and, if any, build a per-candidate - // yes/no walkthrough rendered by the onboarding welcome screen. - let import = match crate::external_auth::pending_external_auth_review_candidates() { - Ok(candidates) => ImportReview::new(candidates), + // yes/no walkthrough rendered by the onboarding welcome screen. The + // search report also carries the families we probed but did not find, so + // the card can show a "Searched, not found" panel beneath the decision. + let (import, login_not_found) = match crate::external_auth::external_auth_search_report() { + Ok(report) => { + let not_found = report + .not_found() + .into_iter() + .cloned() + .collect::<Vec<_>>(); + (ImportReview::new(report.found), not_found) + } Err(err) => { crate::logging::error(&format!( "onboarding: failed to inspect external login sources: {err}" )); - None + (None, Vec::new()) } }; let had_imports = import.is_some(); - self.onboarding_flow = Some(OnboardingFlow::begin_at_login(import)); + self.onboarding_flow = Some(OnboardingFlow::begin_at_login_with_not_found( + import, + login_not_found, + )); // The login prompt is rendered by the onboarding welcome screen // (`onboarding_welcome_kind`) so it survives in remote mode. if had_imports { @@ -296,6 +311,29 @@ impl App { /// highlighted default (Yes -> OpenAI sign-in, No -> provider picker). /// Returns true if the key was consumed. pub(super) fn handle_onboarding_continue_prompt_key(&mut self, code: KeyCode) -> bool { + // The login decision cards can show a scrollable "Searched, not found" + // panel beneath the Yes/No row. PgUp/PgDn scroll it without disturbing + // the highlighted choice (which uses h/l/j/k/arrows). These keys are a + // no-op (and fall through) when no overflowing not-found list is shown. + if matches!( + self.onboarding_phase(), + Some(OnboardingPhase::Login { .. }) | Some(OnboardingPhase::LoginOpenAi { .. }) + ) && self.inline_interactive_state.is_none() + { + match code { + KeyCode::PageDown => { + if self.onboarding_scroll_not_found(NOT_FOUND_SCROLL_STEP) { + return true; + } + } + KeyCode::PageUp => { + if self.onboarding_scroll_not_found(-NOT_FOUND_SCROLL_STEP) { + return true; + } + } + _ => {} + } + } match self.onboarding_phase() { Some(OnboardingPhase::Login { import }) => { // No detected imports remaining: this is the recovery fallback @@ -418,6 +456,9 @@ impl App { // Mutate the live review in place, and report whether the walkthrough // finished so we can kick off the import outside the borrow. let mut finished = false; + // Whether this key advanced to the next candidate (committed a row), so + // we can reset the not-found scroll only on a real advance. + let mut advanced = false; { let Some(review) = self.onboarding_import_review_mut() else { return false; @@ -433,13 +474,16 @@ impl App { KeyCode::Char('y') | KeyCode::Char('Y') => { review.set_yes(true); finished = review.commit_current(); + advanced = true; } KeyCode::Char('n') | KeyCode::Char('N') => { review.set_yes(false); finished = review.commit_current(); + advanced = true; } KeyCode::Enter | KeyCode::Char(' ') => { finished = review.commit_current(); + advanced = true; } _ => return false, } @@ -447,11 +491,51 @@ impl App { if finished { self.onboarding_finish_import_review(); } else { + if advanced { + // Advancing to the next candidate restarts the not-found panel + // at the top so each decision row begins from the same place. + self.reset_onboarding_not_found_scroll(); + } self.update_onboarding_import_review_status(); } true } + /// Number of "Searched, not found" rows for the active login flow (0 when + /// there is no flow or nothing was probed/absent). + fn onboarding_not_found_len(&self) -> usize { + self.onboarding_flow + .as_ref() + .map(|flow| flow.login_not_found.len()) + .unwrap_or(0) + } + + /// Scroll the not-found panel by `delta` lines (positive = down), clamped to + /// the panel's scrollable range. Returns `true` if the offset changed (the + /// key was consumed), `false` when the list doesn't overflow or is already + /// at the requested edge (so the key can fall through to other handlers). + fn onboarding_scroll_not_found(&mut self, delta: i32) -> bool { + let total = self.onboarding_not_found_len(); + let current = self.onboarding_notfound_scroll; + let raw = (current as i32 + delta).max(0); + let clamped = crate::tui::ui::onboarding::clamp_not_found_scroll( + total, + raw.min(u16::MAX as i32) as u16, + ); + if clamped == current { + return false; + } + self.onboarding_notfound_scroll = clamped; + true + } + + /// Reset the not-found scroll offset to the top. Called whenever the login + /// decision row advances or the login phase (re)starts so each decision + /// begins at the top of the shared not-found list. + pub(super) fn reset_onboarding_not_found_scroll(&mut self) { + self.onboarding_notfound_scroll = 0; + } + /// Handle a key while the "Log in to OpenAI?" prompt is up. Yes/No sit side /// by side (default highlight is "Yes"): /// - Left / h -> highlight "Yes" diff --git a/crates/jcode-tui/src/tui/app/state_ui_input_helpers.rs b/crates/jcode-tui/src/tui/app/state_ui_input_helpers.rs index 4b5733df59..30a9098a3a 100644 --- a/crates/jcode-tui/src/tui/app/state_ui_input_helpers.rs +++ b/crates/jcode-tui/src/tui/app/state_ui_input_helpers.rs @@ -1151,6 +1151,21 @@ impl App { pub fn onboarding_welcome_kind(&self) -> crate::tui::OnboardingWelcomeKind { use crate::tui::OnboardingWelcomeKind; use crate::tui::app::onboarding_flow::OnboardingPhase; + // Families we probed but did not find, shared across the login phases. + let not_found_rows: Vec<crate::tui::NotFoundRow> = self + .onboarding_flow + .as_ref() + .map(|flow| { + flow.login_not_found + .iter() + .map(|t| crate::tui::NotFoundRow { + label: t.label.clone(), + path: t.path.clone(), + }) + .collect() + }) + .unwrap_or_default(); + let not_found_scroll = self.onboarding_notfound_scroll; match self.onboarding_phase() { Some(OnboardingPhase::Login { import }) => { let prompt = import.as_ref().and_then(|review| { @@ -1165,11 +1180,17 @@ impl App { seconds_left: review.seconds_remaining(), }) }); - OnboardingWelcomeKind::Login { import: prompt } + OnboardingWelcomeKind::Login { + import: prompt, + not_found: not_found_rows, + not_found_scroll, + } } Some(OnboardingPhase::LoginOpenAi { yes_highlighted }) => { OnboardingWelcomeKind::LoginOpenAi { yes_highlighted: *yes_highlighted, + not_found: not_found_rows, + not_found_scroll, } } Some(OnboardingPhase::ModelSelect) => OnboardingWelcomeKind::Suggestions, diff --git a/crates/jcode-tui/src/tui/app/tests/onboarding_flow.rs b/crates/jcode-tui/src/tui/app/tests/onboarding_flow.rs index 467e67acfe..0f7e19ee26 100644 --- a/crates/jcode-tui/src/tui/app/tests/onboarding_flow.rs +++ b/crates/jcode-tui/src/tui/app/tests/onboarding_flow.rs @@ -71,7 +71,7 @@ fn login_welcome_kind_shows_first_import_candidate() { }; } match app.onboarding_welcome_kind() { - OnboardingWelcomeKind::Login { import: Some(prompt) } => { + OnboardingWelcomeKind::Login { import: Some(prompt), .. } => { assert_eq!(prompt.provider_summary, "OpenAI/Codex"); assert_eq!(prompt.source_name, "Codex auth.json"); assert_eq!(prompt.position, 1); @@ -166,7 +166,8 @@ fn login_openai_phase_is_default_when_no_imports() { assert!(matches!( app.onboarding_welcome_kind(), OnboardingWelcomeKind::LoginOpenAi { - yes_highlighted: true + yes_highlighted: true, + .. } )); }); @@ -706,3 +707,116 @@ fn remote_post_login_validation_waits_for_catalog_refresh() { assert!(app.onboarding_pending_validation_ready_to_fire()); }); } + +#[test] +fn login_welcome_kind_carries_not_found_rows() { + use crate::external_auth::{AuthSearchTarget, ExternalAuthReviewCandidate}; + use crate::tui::OnboardingWelcomeKind; + use crate::tui::app::onboarding_flow::ImportReview; + + let mut app = create_test_app(); + app.onboarding_flow = None; + app.begin_onboarding_flow_at_login(); + + // Arm a one-candidate import walkthrough and inject a not-found list. + let review = + ImportReview::new(vec![ExternalAuthReviewCandidate::fixture("Cursor", "Cursor")]).unwrap(); + if let Some(flow) = app.onboarding_flow.as_mut() { + flow.phase = OnboardingPhase::Login { + import: Some(review), + }; + flow.login_not_found = vec![ + AuthSearchTarget { + family: "codex", + label: "Codex".to_string(), + path: "~/.codex/auth.json".to_string(), + present: false, + }, + AuthSearchTarget { + family: "gemini_cli", + label: "Gemini CLI".to_string(), + path: "~/.gemini/oauth_creds.json".to_string(), + present: false, + }, + ]; + } + + match app.onboarding_welcome_kind() { + OnboardingWelcomeKind::Login { not_found, .. } => { + let labels: Vec<&str> = not_found.iter().map(|r| r.label.as_str()).collect(); + assert_eq!(labels, vec!["Codex", "Gemini CLI"]); + } + other => panic!("expected Login welcome, got {other:?}"), + } +} + +#[test] +fn not_found_panel_scrolls_with_pgdn_and_clamps() { + use crate::external_auth::AuthSearchTarget; + use crate::tui::app::onboarding_flow::OnboardingFlow; + + let mut app = create_test_app(); + // LoginOpenAi phase (no detected imports) with a long not-found list so the + // panel overflows its visible window and becomes scrollable. 12 rows with a + // 5-row visible window gives a max scroll offset of 7. + let targets: Vec<AuthSearchTarget> = (0..12) + .map(|i| AuthSearchTarget { + family: "x", + label: format!("Source {i}"), + path: format!("~/path/{i}"), + present: false, + }) + .collect(); + let mut flow = OnboardingFlow::begin_at_login_with_not_found(None, targets); + flow.phase = OnboardingPhase::LoginOpenAi { + yes_highlighted: true, + }; + app.onboarding_flow = Some(flow); + assert_eq!(app.onboarding_notfound_scroll, 0); + + // PgDn scrolls down by the step (3). + assert!(app.handle_onboarding_continue_prompt_key(KeyCode::PageDown)); + assert_eq!(app.onboarding_notfound_scroll, 3); + assert!(app.handle_onboarding_continue_prompt_key(KeyCode::PageDown)); + assert_eq!(app.onboarding_notfound_scroll, 6); + + // Next PgDn clamps to the max offset (12 rows - 5 visible = 7). + assert!(app.handle_onboarding_continue_prompt_key(KeyCode::PageDown)); + assert_eq!(app.onboarding_notfound_scroll, 7); + + // Already at the bottom: PgDn no longer consumes the key (falls through). + assert!(!app.handle_onboarding_continue_prompt_key(KeyCode::PageDown)); + + // PgUp scrolls back up and eventually clamps at 0. + assert!(app.handle_onboarding_continue_prompt_key(KeyCode::PageUp)); + assert_eq!(app.onboarding_notfound_scroll, 4); + assert!(app.handle_onboarding_continue_prompt_key(KeyCode::PageUp)); + assert!(app.handle_onboarding_continue_prompt_key(KeyCode::PageUp)); + assert_eq!(app.onboarding_notfound_scroll, 0); + assert!(!app.handle_onboarding_continue_prompt_key(KeyCode::PageUp)); +} + +#[test] +fn not_found_panel_no_scroll_when_list_fits() { + use crate::external_auth::AuthSearchTarget; + use crate::tui::app::onboarding_flow::OnboardingFlow; + + let mut app = create_test_app(); + // Only 3 rows: fits within the visible window, so PgDn is a no-op and the + // key falls through (not consumed) for other handlers. + let targets: Vec<AuthSearchTarget> = (0..3) + .map(|i| AuthSearchTarget { + family: "x", + label: format!("Source {i}"), + path: format!("~/path/{i}"), + present: false, + }) + .collect(); + let mut flow = OnboardingFlow::begin_at_login_with_not_found(None, targets); + flow.phase = OnboardingPhase::LoginOpenAi { + yes_highlighted: true, + }; + app.onboarding_flow = Some(flow); + assert!(!app.handle_onboarding_continue_prompt_key(KeyCode::PageDown)); + assert_eq!(app.onboarding_notfound_scroll, 0); +} diff --git a/crates/jcode-tui/src/tui/app/tui_lifecycle.rs b/crates/jcode-tui/src/tui/app/tui_lifecycle.rs index dbccf193a9..c602d3bd2b 100644 --- a/crates/jcode-tui/src/tui/app/tui_lifecycle.rs +++ b/crates/jcode-tui/src/tui/app/tui_lifecycle.rs @@ -358,6 +358,7 @@ impl App { startup_submit_deferred_reason: None, onboarding_preview_mode: false, onboarding_flow: None, + onboarding_notfound_scroll: 0, onboarding_startup_checked: false, onboarding_pending_model_validation: None, copy_badge_ui: CopyBadgeUiState::default(), @@ -736,6 +737,7 @@ impl App { startup_submit_deferred_reason: None, onboarding_preview_mode: false, onboarding_flow: None, + onboarding_notfound_scroll: 0, onboarding_startup_checked: false, onboarding_pending_model_validation: None, copy_badge_ui: CopyBadgeUiState::default(), diff --git a/crates/jcode-tui/src/tui/mod.rs b/crates/jcode-tui/src/tui/mod.rs index a9e445c90f..888481c44d 100644 --- a/crates/jcode-tui/src/tui/mod.rs +++ b/crates/jcode-tui/src/tui/mod.rs @@ -646,11 +646,25 @@ pub enum OnboardingWelcomeKind { /// walking the user through them one at a time (a yes/no prompt per login). /// When `None`, there was nothing to import and the card points the user at /// the provider picker. - Login { import: Option<LoginImportPrompt> }, + Login { + import: Option<LoginImportPrompt>, + /// Credential families we probed but did not find, rendered as a + /// scrollable "Searched, not found" panel beneath the decision row. + not_found: Vec<NotFoundRow>, + /// Current scroll offset (in lines) into the not-found panel. + not_found_scroll: u16, + }, /// Ask the user whether to log in to OpenAI (no detected imports). A /// highlightable Yes/No selector; `yes_highlighted` reflects the current /// choice. Yes starts the OpenAI sign-in, No opens the provider picker. - LoginOpenAi { yes_highlighted: bool }, + LoginOpenAi { + yes_highlighted: bool, + /// Credential families we probed but did not find. On a fresh install + /// with no detected logins this is the full searched set. + not_found: Vec<NotFoundRow>, + /// Current scroll offset (in lines) into the not-found panel. + not_found_scroll: u16, + }, /// "Continue where you left off in <cli>?" with a highlightable Yes/No /// selector and a live decision countdown (seconds remaining). ContinuePrompt { @@ -662,6 +676,16 @@ pub enum OnboardingWelcomeKind { Suggestions, } +/// One row in the "Searched, not found" onboarding panel: a credential source +/// jcode probed during first-run detection but did not find. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct NotFoundRow { + /// Human label (e.g. "Claude Code", "Cursor"). + pub label: String, + /// Representative path that was probed (e.g. "~/.cursor/auth.json"). + pub path: String, +} + /// Render-friendly snapshot of the current step in the per-candidate login /// import walkthrough. Describes which detected login is being reviewed and /// which Yes/No option is currently highlighted. diff --git a/crates/jcode-tui/src/tui/ui.rs b/crates/jcode-tui/src/tui/ui.rs index 2e1fa03574..1168cf3686 100644 --- a/crates/jcode-tui/src/tui/ui.rs +++ b/crates/jcode-tui/src/tui/ui.rs @@ -89,7 +89,7 @@ mod memory_ui; #[path = "ui_messages.rs"] mod messages; #[path = "ui_onboarding.rs"] -mod onboarding; +pub(crate) mod onboarding; #[path = "ui_overlays.rs"] mod overlays; #[path = "ui_pinned.rs"] diff --git a/crates/jcode-tui/src/tui/ui_onboarding.rs b/crates/jcode-tui/src/tui/ui_onboarding.rs index 9723b9df6c..2802104e57 100644 --- a/crates/jcode-tui/src/tui/ui_onboarding.rs +++ b/crates/jcode-tui/src/tui/ui_onboarding.rs @@ -22,12 +22,96 @@ use ratatui::{prelude::*, widgets::Paragraph}; const DONUT_HEIGHT: u16 = 12; const TELEMETRY_LINES: u16 = 4; const GAP: u16 = 1; +/// Maximum number of "Searched, not found" rows shown at once before the panel +/// scrolls. Keeps the centered welcome card from overflowing on short +/// terminals; the rest are reachable via the scroll keys. +const NOT_FOUND_VISIBLE: usize = 5; /// Accent color for the welcome title. fn welcome_accent() -> Color { rgb(138, 180, 248) } +/// Clamp a not-found scroll offset to the maximum that still shows a full +/// window of rows (so scrolling never strands the panel past its end). +pub(crate) fn clamp_not_found_scroll(total: usize, scroll: u16) -> u16 { + let max_offset = total.saturating_sub(NOT_FOUND_VISIBLE); + scroll.min(max_offset as u16) +} + +/// Build the "Searched, not found" panel lines for a login decision card. +/// +/// Renders a dim header, then a scrolled window of up to [`NOT_FOUND_VISIBLE`] +/// rows (`label path`) plus "↑/↓ N more" affordances when the list overflows. +/// Returns an empty vec when there is nothing to show. +fn not_found_panel_lines( + rows: &[crate::tui::NotFoundRow], + scroll: u16, +) -> Vec<Line<'static>> { + if rows.is_empty() { + return Vec::new(); + } + let align = Alignment::Center; + let dim = Style::default().fg(dim_color()); + let mut lines: Vec<Line<'static>> = Vec::new(); + lines.push(Line::from("")); + lines.push( + Line::from(Span::styled( + format!("Searched, not found ({})", rows.len()), + Style::default() + .fg(dim_color()) + .add_modifier(Modifier::ITALIC), + )) + .alignment(align), + ); + + let total = rows.len(); + let overflow = total > NOT_FOUND_VISIBLE; + let offset = clamp_not_found_scroll(total, scroll) as usize; + let end = (offset + NOT_FOUND_VISIBLE).min(total); + + if overflow && offset > 0 { + lines.push( + Line::from(Span::styled(format!("↑ {} more", offset), dim)).alignment(align), + ); + } + // Widest label in the *current window* for a light column alignment. + let label_w = rows[offset..end] + .iter() + .map(|r| r.label.chars().count()) + .max() + .unwrap_or(0); + for row in &rows[offset..end] { + let pad = label_w.saturating_sub(row.label.chars().count()); + lines.push( + Line::from(vec![ + Span::styled("• ", dim), + Span::styled( + row.label.clone(), + Style::default().fg(rgb(170, 170, 170)), + ), + Span::raw(" ".repeat(pad + 2)), + Span::styled(row.path.clone(), dim), + ]) + .alignment(align), + ); + } + if overflow && end < total { + lines.push( + Line::from(Span::styled( + format!("↓ {} more (PgUp/PgDn to scroll)", total - end), + dim, + )) + .alignment(align), + ); + } else if overflow { + lines.push( + Line::from(Span::styled("(PgUp/PgDn to scroll)", dim)).alignment(align), + ); + } + lines +} + /// Grayed telemetry notice shown at the very top of the onboarding screen. fn telemetry_header_lines(width: u16) -> Vec<Line<'static>> { let align = Alignment::Center; @@ -78,7 +162,11 @@ fn welcome_body_lines(app: &dyn TuiState) -> Vec<Line<'static>> { use crate::tui::OnboardingWelcomeKind; match app.onboarding_welcome_kind() { - OnboardingWelcomeKind::Login { import } => { + OnboardingWelcomeKind::Login { + import, + not_found, + not_found_scroll, + } => { lines.push(Line::from("")); match import { None => { @@ -180,9 +268,14 @@ fn welcome_body_lines(app: &dyn TuiState) -> Vec<Line<'static>> { ); } } + lines.extend(not_found_panel_lines(¬_found, not_found_scroll)); return lines; } - OnboardingWelcomeKind::LoginOpenAi { yes_highlighted } => { + OnboardingWelcomeKind::LoginOpenAi { + yes_highlighted, + not_found, + not_found_scroll, + } => { lines.push(Line::from("")); lines.push( Line::from(Span::styled( @@ -244,6 +337,7 @@ fn welcome_body_lines(app: &dyn TuiState) -> Vec<Line<'static>> { )) .alignment(align), ); + lines.extend(not_found_panel_lines(¬_found, not_found_scroll)); return lines; } OnboardingWelcomeKind::ContinuePrompt { diff --git a/crates/jcode-tui/src/tui/ui_tests/mod.rs b/crates/jcode-tui/src/tui/ui_tests/mod.rs index 93fca80b79..4123fe48a9 100644 --- a/crates/jcode-tui/src/tui/ui_tests/mod.rs +++ b/crates/jcode-tui/src/tui/ui_tests/mod.rs @@ -135,6 +135,9 @@ struct TestState { chat_native_scrollbar: bool, onboarding_preview: bool, suggestions: Vec<(String, String)>, + /// Optional explicit welcome-screen body kind for onboarding render tests. + /// When `None`, the default (`Suggestions`) is used. + onboarding_welcome_kind: Option<crate::tui::OnboardingWelcomeKind>, compacted_hidden_user_prompts: usize, reasoning_retained: Option<String>, reasoning_collapse: Option<(String, f32)>, @@ -453,6 +456,11 @@ impl crate::tui::TuiState for TestState { fn onboarding_preview_mode(&self) -> bool { self.onboarding_preview } + fn onboarding_welcome_kind(&self) -> crate::tui::OnboardingWelcomeKind { + self.onboarding_welcome_kind + .clone() + .unwrap_or(crate::tui::OnboardingWelcomeKind::Suggestions) + } fn cache_ttl_status(&self) -> Option<crate::tui::CacheTtlInfo> { None } diff --git a/crates/jcode-tui/src/tui/ui_tests/onboarding.rs b/crates/jcode-tui/src/tui/ui_tests/onboarding.rs index bd67ac4d16..029ee0f015 100644 --- a/crates/jcode-tui/src/tui/ui_tests/onboarding.rs +++ b/crates/jcode-tui/src/tui/ui_tests/onboarding.rs @@ -107,3 +107,63 @@ fn onboarding_welcome_centers_within_tall_area() { "content should be vertically padded from the top:\n{text}" ); } + +#[test] +fn onboarding_login_card_renders_searched_not_found_panel() { + use crate::tui::{NotFoundRow, OnboardingWelcomeKind}; + + let not_found = vec![ + NotFoundRow { + label: "Codex".to_string(), + path: "~/.codex/auth.json".to_string(), + }, + NotFoundRow { + label: "Cursor".to_string(), + path: "~/.cursor/auth.json".to_string(), + }, + ]; + let state = TestState { + onboarding_preview: true, + onboarding_welcome_kind: Some(OnboardingWelcomeKind::LoginOpenAi { + yes_highlighted: true, + not_found, + not_found_scroll: 0, + }), + ..Default::default() + }; + let text = render_onboarding(&state, 80, 40); + assert!( + text.contains("Searched, not found"), + "should render the not-found header:\n{text}" + ); + assert!( + text.contains("Codex") && text.contains("Cursor"), + "should list the absent sources:\n{text}" + ); +} + +#[test] +fn onboarding_not_found_panel_shows_scroll_affordance_when_overflowing() { + use crate::tui::{NotFoundRow, OnboardingWelcomeKind}; + + let not_found: Vec<NotFoundRow> = (0..9) + .map(|i| NotFoundRow { + label: format!("Source {i}"), + path: format!("~/path/{i}"), + }) + .collect(); + let state = TestState { + onboarding_preview: true, + onboarding_welcome_kind: Some(OnboardingWelcomeKind::LoginOpenAi { + yes_highlighted: true, + not_found, + not_found_scroll: 0, + }), + ..Default::default() + }; + let text = render_onboarding(&state, 80, 44); + assert!( + text.contains("more") && text.contains("scroll"), + "overflowing panel should show a scroll affordance:\n{text}" + ); +} From d1f61b36a195398b190685459f8e6c20d218af9e Mon Sep 17 00:00:00 2001 From: jcode <jcode@local> Date: Thu, 25 Jun 2026 20:03:49 -0700 Subject: [PATCH 13/18] test(onboarding): inject welcome kind in render tests for not-found panel --- crates/jcode-tui/src/tui/ui_tests/onboarding.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/crates/jcode-tui/src/tui/ui_tests/onboarding.rs b/crates/jcode-tui/src/tui/ui_tests/onboarding.rs index 029ee0f015..c432694f17 100644 --- a/crates/jcode-tui/src/tui/ui_tests/onboarding.rs +++ b/crates/jcode-tui/src/tui/ui_tests/onboarding.rs @@ -167,3 +167,4 @@ fn onboarding_not_found_panel_shows_scroll_affordance_when_overflowing() { "overflowing panel should show a scroll affordance:\n{text}" ); } + From bf96e93ddbafecfa63a714836a06dd15eb8398f4 Mon Sep 17 00:00:00 2001 From: jcode <jcode@local> Date: Thu, 25 Jun 2026 20:18:40 -0700 Subject: [PATCH 14/18] feat(onboarding): opt-in to install ScrollWM during first-run setup Adds a one-time 'Set up ScrollWM?' onboarding step (macOS, local sessions) shown before the suggestion cards when ScrollWM isn't already installed and the user hasn't answered. On Yes it runs the ScrollWM web installer in the background and surfaces progress; the Accessibility grant is ScrollWM-owned. Default highlight is No and the timeout never installs. - OnboardingPhase::ScrollWmOptIn + ScrollWmInstallProgress render kind - gate: macOS + local + not-installed + unanswered (pure, unit-tested) - async install via web-install.sh (download-to-temp then bash, 180s cap) - result delivered via Bus::ScrollWmInstallCompleted -> advances to suggestions - persisted in setup_hints.json (scrollwm_optin_answered / install_started) - Yes/No keys mirror the existing decision rows; tick auto-skips to No - tests: gate logic, enter/answer/skip, Yes->Running, install-completed, render --- crates/jcode-base/src/bus.rs | 14 + crates/jcode-setup-hints/src/lib.rs | 9 + crates/jcode-tui/src/tui/app.rs | 4 + crates/jcode-tui/src/tui/app/local.rs | 3 + .../jcode-tui/src/tui/app/onboarding_flow.rs | 28 +- .../src/tui/app/onboarding_flow_control.rs | 337 ++++++++++++++++++ crates/jcode-tui/src/tui/app/remote.rs | 3 + .../src/tui/app/state_ui_input_helpers.rs | 13 + .../src/tui/app/tests/onboarding_flow.rs | 106 ++++++ crates/jcode-tui/src/tui/app/tui_lifecycle.rs | 2 + crates/jcode-tui/src/tui/mod.rs | 21 ++ crates/jcode-tui/src/tui/ui_onboarding.rs | 118 ++++++ .../jcode-tui/src/tui/ui_tests/onboarding.rs | 45 +++ 13 files changed, 696 insertions(+), 7 deletions(-) diff --git a/crates/jcode-base/src/bus.rs b/crates/jcode-base/src/bus.rs index 7e8d89a4a7..d8cfc74d4b 100644 --- a/crates/jcode-base/src/bus.rs +++ b/crates/jcode-base/src/bus.rs @@ -144,6 +144,18 @@ pub struct InputShellCompleted { pub result: crate::message::InputShellResult, } +/// Result of the onboarding "Set up ScrollWM?" background install. Published by +/// the async installer task and consumed on the UI thread to advance the flow. +#[derive(Clone, Debug)] +pub struct ScrollWmInstallCompleted { + /// Session the install was started for; stale results are ignored. + pub session_id: String, + /// Whether the installer command exited successfully. + pub ok: bool, + /// Optional short failure detail shown after a failed install. + pub detail: Option<String>, +} + #[derive(Clone, Debug)] pub enum ClipboardPasteKind { Smart, @@ -363,6 +375,8 @@ pub enum BusEvent { LoginCompleted(LoginCompleted), /// First-run onboarding finished validating the auto-selected default model. OnboardingModelValidated(OnboardingModelValidated), + /// Onboarding "Set up ScrollWM?" background install finished + ScrollWmInstallCompleted(ScrollWmInstallCompleted), /// Local `!cmd` shell command completed from the input line InputShellCompleted(InputShellCompleted), /// Clipboard paste/image URL work completed off the UI thread diff --git a/crates/jcode-setup-hints/src/lib.rs b/crates/jcode-setup-hints/src/lib.rs index 065ec03bb7..ed24eb325d 100644 --- a/crates/jcode-setup-hints/src/lib.rs +++ b/crates/jcode-setup-hints/src/lib.rs @@ -73,6 +73,15 @@ pub struct SetupHintsState { /// never nagged about the same conflicts on every launch. #[serde(default)] pub keymap_conflict_signature: String, + /// True once the user has answered the onboarding "Set up ScrollWM?" opt-in + /// (Yes, No, or a timeout skip). When set we never show the opt-in again. + #[serde(default)] + pub scrollwm_optin_answered: bool, + /// True if the user chose "Yes" and we kicked off (or completed) a ScrollWM + /// install. Lets a later launch optionally remind the user to finish + /// granting Accessibility without re-asking the opt-in. + #[serde(default)] + pub scrollwm_install_started: bool, } /// Current macOS hotkey listener implementation version. diff --git a/crates/jcode-tui/src/tui/app.rs b/crates/jcode-tui/src/tui/app.rs index 9d27d4eac9..b1d3fcf12e 100644 --- a/crates/jcode-tui/src/tui/app.rs +++ b/crates/jcode-tui/src/tui/app.rs @@ -821,6 +821,10 @@ pub struct App { /// rendered beneath the login decision rows. Reset to 0 when the decision /// row advances or the login phase changes. onboarding_notfound_scroll: u16, + /// Live progress of the onboarding "Set up ScrollWM?" background install. + /// `None` while waiting for the user's decision; `Some(..)` once an install + /// is running or has finished (drives the card's status line). + scrollwm_install_progress: Option<crate::tui::ScrollWmInstallProgress>, /// One-shot guard: have we evaluated whether to auto-start the onboarding /// flow on startup yet? The fresh-install path logs in at the CLI before the /// TUI launches, so no in-TUI login event fires; this lets us still begin the diff --git a/crates/jcode-tui/src/tui/app/local.rs b/crates/jcode-tui/src/tui/app/local.rs index b696bbb9c8..a446940008 100644 --- a/crates/jcode-tui/src/tui/app/local.rs +++ b/crates/jcode-tui/src/tui/app/local.rs @@ -179,6 +179,9 @@ pub(super) fn handle_bus_event( Ok(BusEvent::OnboardingModelValidated(result)) => { app.handle_onboarding_model_validated(result) } + Ok(BusEvent::ScrollWmInstallCompleted(ev)) => { + app.handle_scrollwm_install_completed(ev) + } Ok(BusEvent::ModelsUpdated) => { app.invalidate_model_picker_cache(); true diff --git a/crates/jcode-tui/src/tui/app/onboarding_flow.rs b/crates/jcode-tui/src/tui/app/onboarding_flow.rs index 00aa874db5..92c20f29b9 100644 --- a/crates/jcode-tui/src/tui/app/onboarding_flow.rs +++ b/crates/jcode-tui/src/tui/app/onboarding_flow.rs @@ -181,6 +181,19 @@ pub(crate) enum OnboardingPhase { }, /// Single-select transcript picker with a 10s auto-select of the latest. TranscriptPick { cli: ExternalCli, shown_at: Instant }, + /// Offer to install ScrollWM (a scrolling window manager that arranges + /// jcode's headed swarm agents into a tidy strip). macOS only; shown once + /// when ScrollWM is not already installed and the user hasn't answered + /// before. Highlightable Yes/No with a [`DECISION_TIMEOUT`] countdown whose + /// default (and timeout choice) is **No** so we never install software on a + /// silent timeout. Once the user picks Yes the install runs in the + /// background and `App::scrollwm_install_progress` drives the card. + ScrollWmOptIn { + /// Which option is highlighted (true = "Yes, install ScrollWM"). + yes_highlighted: bool, + /// When the prompt was shown, for the countdown. + shown_at: Instant, + }, /// Existing prompt-suggestion cards (resting / "No" state). Suggestions, /// Flow finished; nothing onboarding-specific to render. @@ -273,13 +286,6 @@ impl OnboardingFlow { /// were detected. When no logins were detected (`import` is `None`) we ask a /// simple "Log in to OpenAI?" Yes/No instead of dropping straight to the /// provider picker. - pub(crate) fn begin_at_login(import: Option<ImportReview>) -> Self { - Self::begin_at_login_with_not_found(import, Vec::new()) - } - - /// Like [`OnboardingFlow::begin_at_login`], but also records the probed - /// credential families that were absent, for the "Searched, not found" - /// panel. pub(crate) fn begin_at_login_with_not_found( import: Option<ImportReview>, login_not_found: Vec<crate::external_auth::AuthSearchTarget>, @@ -315,6 +321,11 @@ impl OnboardingFlow { .saturating_sub(shown_at.elapsed()) .as_secs(), ), + OnboardingPhase::ScrollWmOptIn { shown_at, .. } => Some( + DECISION_TIMEOUT + .saturating_sub(shown_at.elapsed()) + .as_secs(), + ), _ => None, } } @@ -329,6 +340,9 @@ impl OnboardingFlow { OnboardingPhase::ContinuePrompt { shown_at, .. } => { shown_at.elapsed() >= DECISION_TIMEOUT } + OnboardingPhase::ScrollWmOptIn { shown_at, .. } => { + shown_at.elapsed() >= DECISION_TIMEOUT + } _ => false, } } diff --git a/crates/jcode-tui/src/tui/app/onboarding_flow_control.rs b/crates/jcode-tui/src/tui/app/onboarding_flow_control.rs index e1b6dca643..8f40a2e4a0 100644 --- a/crates/jcode-tui/src/tui/app/onboarding_flow_control.rs +++ b/crates/jcode-tui/src/tui/app/onboarding_flow_control.rs @@ -378,6 +378,14 @@ impl App { Some(OnboardingPhase::ContinuePrompt { .. }) => { self.handle_onboarding_continue_choice_key(code) } + Some(OnboardingPhase::ScrollWmOptIn { .. }) => { + // Ignore keys while an install is running (the card shows a + // progress line, not the Yes/No row). + if self.scrollwm_install_progress.is_some() { + return false; + } + self.handle_onboarding_scrollwm_optin_key(code) + } _ => false, } } @@ -536,6 +544,70 @@ impl App { self.onboarding_notfound_scroll = 0; } + /// Handle a key while the "Set up ScrollWM?" opt-in is up. Yes/No sit side + /// by side (default highlight is "No"): + /// - Left / h -> highlight "Yes" + /// - Right / l -> highlight "No" + /// - Up / Down / k / j / Tab -> toggle + /// - y / Y -> install ScrollWM; n / N / Esc -> skip + /// - Enter / Space -> commit the highlighted choice + fn handle_onboarding_scrollwm_optin_key(&mut self, code: KeyCode) -> bool { + { + let Some(flow) = self.onboarding_flow.as_mut() else { + return false; + }; + let OnboardingPhase::ScrollWmOptIn { yes_highlighted, .. } = &mut flow.phase else { + return false; + }; + match code { + KeyCode::Left | KeyCode::Char('h') => { + *yes_highlighted = true; + self.update_onboarding_scrollwm_optin_status(); + return true; + } + KeyCode::Right | KeyCode::Char('l') => { + *yes_highlighted = false; + self.update_onboarding_scrollwm_optin_status(); + return true; + } + KeyCode::Up + | KeyCode::Down + | KeyCode::Char('k') + | KeyCode::Char('j') + | KeyCode::Tab => { + *yes_highlighted = !*yes_highlighted; + self.update_onboarding_scrollwm_optin_status(); + return true; + } + _ => {} + } + } + // Commit paths re-borrow `self`, so handle them after the mutable phase + // borrow above is released. + match code { + KeyCode::Char('y') | KeyCode::Char('Y') => { + self.onboarding_answer_scrollwm_optin(true); + true + } + KeyCode::Char('n') | KeyCode::Char('N') | KeyCode::Esc => { + self.onboarding_answer_scrollwm_optin(false); + true + } + KeyCode::Enter | KeyCode::Char(' ') => { + let wants_install = matches!( + self.onboarding_phase(), + Some(OnboardingPhase::ScrollWmOptIn { + yes_highlighted: true, + .. + }) + ); + self.onboarding_answer_scrollwm_optin(wants_install); + true + } + _ => false, + } + } + /// Handle a key while the "Log in to OpenAI?" prompt is up. Yes/No sit side /// by side (default highlight is "Yes"): /// - Left / h -> highlight "Yes" @@ -825,6 +897,20 @@ impl App { /// kick off a single lightweight live validation of the auto-selected /// default model and report it as one tidy "ready"/"failed" line. pub(super) fn onboarding_show_suggestions(&mut self) { + // Before landing on the suggestion cards, offer the one-time ScrollWM + // opt-in (macOS, not installed, not yet answered). On any other + // platform / state this is a no-op and we fall through immediately. + if self.should_offer_scrollwm_optin() { + self.onboarding_enter_scrollwm_optin(); + return; + } + self.onboarding_finish_to_suggestions(); + } + + /// Render the starter suggestion cards and kick off the new-session model + /// validation. This is the real terminal step of onboarding; the ScrollWM + /// opt-in (when shown) calls this once the user answers. + pub(super) fn onboarding_finish_to_suggestions(&mut self) { if let Some(flow) = self.onboarding_flow.as_mut() { flow.phase = OnboardingPhase::Suggestions; } @@ -848,6 +934,138 @@ impl App { self.onboarding_validate_default_model(); } + /// Whether to offer the one-time "Set up ScrollWM?" opt-in. macOS only, + /// local (non-remote) sessions only, only when ScrollWM is not already + /// installed and the user hasn't answered the prompt before. + /// + /// Disabled under `cfg!(test)` so unit tests that walk to the suggestion + /// cards stay deterministic regardless of the host's real `~/Applications` + /// / setup-hints state; the decision logic itself is covered by + /// [`scrollwm_optin_should_offer`] and the wiring by direct-phase tests. + fn should_offer_scrollwm_optin(&self) -> bool { + if cfg!(test) { + return false; + } + scrollwm_optin_should_offer( + cfg!(target_os = "macos"), + self.is_remote, + scrollwm_app_installed(), + crate::setup_hints::SetupHintsState::load().scrollwm_optin_answered, + ) + } + + /// Enter the ScrollWM opt-in phase. Default highlight is **No** so a + /// timeout / accidental Enter never installs third-party software. + pub(super) fn onboarding_enter_scrollwm_optin(&mut self) { + self.scrollwm_install_progress = None; + if let Some(flow) = self.onboarding_flow.as_mut() { + flow.phase = OnboardingPhase::ScrollWmOptIn { + yes_highlighted: false, + shown_at: Instant::now(), + }; + } + self.update_onboarding_scrollwm_optin_status(); + } + + /// Refresh the status notice with the ScrollWM opt-in countdown. + fn update_onboarding_scrollwm_optin_status(&mut self) { + let remaining = self + .onboarding_flow + .as_ref() + .and_then(OnboardingFlow::decision_seconds_remaining) + .unwrap_or(0); + self.set_status_notice(format!( + "Set up ScrollWM? [No] - hl to move, Enter to choose, skips in {remaining}s" + )); + } + + /// Record the opt-in answer and either kick off the install (Yes) or fall + /// through to the suggestion cards (No). The answer is persisted before any + /// async work so we never re-ask, even if the install crashes. + pub(super) fn onboarding_answer_scrollwm_optin(&mut self, wants_install: bool) { + let mut state = crate::setup_hints::SetupHintsState::load(); + state.scrollwm_optin_answered = true; + if wants_install { + state.scrollwm_install_started = true; + } + let _ = state.save(); + + if wants_install { + self.scrollwm_install_progress = Some(crate::tui::ScrollWmInstallProgress::Running); + self.update_onboarding_scrollwm_optin_status(); + self.spawn_scrollwm_install(); + // Stay in the ScrollWmOptIn phase so the card shows the Running + // line; the Bus completion handler advances to the suggestions. + } else { + self.scrollwm_install_progress = None; + self.onboarding_finish_to_suggestions(); + } + } + + /// Spawn the background ScrollWM install (web installer). Fire-and-forget; + /// the result is delivered via [`crate::bus::BusEvent::ScrollWmInstallCompleted`] + /// and handled on the UI thread. Mirrors the async model-validation pattern. + fn spawn_scrollwm_install(&mut self) { + let session_id = self.session.id.clone(); + self.set_status_notice("Installing ScrollWM…"); + // The TUI always runs inside a tokio runtime in production; guard the + // spawn so unit tests (which build an App outside an async context) can + // exercise the opt-in answer path without panicking on a missing runtime. + let Ok(handle) = tokio::runtime::Handle::try_current() else { + crate::logging::warn( + "scrollwm: no tokio runtime; skipping background install spawn", + ); + return; + }; + handle.spawn(async move { + let result = run_scrollwm_install().await; + crate::bus::Bus::global().publish(crate::bus::BusEvent::ScrollWmInstallCompleted( + crate::bus::ScrollWmInstallCompleted { + session_id, + ok: result.is_ok(), + detail: result.err(), + }, + )); + }); + } + + /// Handle the background ScrollWM install result on the UI thread: show a + /// one-line outcome and advance onboarding to the suggestion cards. + /// Returns true if the event was for this session (consumed). + pub(super) fn handle_scrollwm_install_completed( + &mut self, + ev: crate::bus::ScrollWmInstallCompleted, + ) -> bool { + if ev.session_id != self.session.id { + return false; + } + let message = if ev.ok { + "ScrollWM installed. Grant **Accessibility** when System Settings opens; \ + it then arranges your windows automatically." + .to_string() + } else { + let detail = ev + .detail + .unwrap_or_else(|| "the installer did not finish".to_string()); + format!( + "ScrollWM install didn't finish ({detail}). You can install it later:\n \ + curl -fsSL https://raw.githubusercontent.com/1jehuang/scrollwm/main/scripts/web-install.sh | bash" + ) + }; + self.scrollwm_install_progress = None; + // Only drive the flow forward if we are still parked on the opt-in + // card; otherwise just surface the message. + let in_optin = matches!( + self.onboarding_phase(), + Some(OnboardingPhase::ScrollWmOptIn { .. }) + ); + self.push_display_message(DisplayMessage::system(message)); + if in_optin { + self.onboarding_finish_to_suggestions(); + } + true + } + /// Friendly label for the active default model, including the reasoning /// effort tier when one applies (e.g. "GPT-5.5 (low)"). Used by the /// onboarding new-session validation line. @@ -1330,6 +1548,24 @@ impl App { self.update_onboarding_continue_prompt_status(cli); return true; } + Some(OnboardingPhase::ScrollWmOptIn { + yes_highlighted, .. + }) => { + // While an install is running, let the Bus completion handler + // drive the flow; don't auto-skip out from under it. + if self.scrollwm_install_progress.is_some() { + return false; + } + if decision_timed_out { + // Timeout default is "No": never install on a silent skip, + // regardless of which option is currently highlighted. + let _ = yes_highlighted; + self.onboarding_answer_scrollwm_optin(false); + return true; + } + self.update_onboarding_scrollwm_optin_status(); + return true; + } _ => {} } @@ -1338,3 +1574,104 @@ impl App { false } } + +/// Whether ScrollWM.app is already installed. Checks the README install +/// locations: `~/Applications` (default web-install / Homebrew target) and the +/// system `/Applications`. Always false off macOS. +fn scrollwm_app_installed() -> bool { + #[cfg(target_os = "macos")] + { + let mut candidates: Vec<std::path::PathBuf> = Vec::new(); + if let Some(home) = dirs::home_dir() { + candidates.push(home.join("Applications/ScrollWM.app")); + } + candidates.push(std::path::PathBuf::from("/Applications/ScrollWM.app")); + candidates.into_iter().any(|p| p.is_dir()) + } + #[cfg(not(target_os = "macos"))] + { + false + } +} + +/// Pure decision for the ScrollWM opt-in gate, extracted for unit testing. +/// Offer the opt-in only on macOS, in a local (non-remote) session, when +/// ScrollWM is not already installed and the user hasn't answered before. +pub(crate) fn scrollwm_optin_should_offer( + is_macos: bool, + is_remote: bool, + already_installed: bool, + already_answered: bool, +) -> bool { + is_macos && !is_remote && !already_installed && !already_answered +} + +/// URL of the ScrollWM web installer (the README "recommended" path). +const SCROLLWM_WEB_INSTALL_URL: &str = + "https://raw.githubusercontent.com/1jehuang/scrollwm/main/scripts/web-install.sh"; + +/// Run the ScrollWM web installer non-interactively. Downloads the installer +/// script to a temp file (so it is inspectable/loggable rather than a blind +/// `curl | bash`), then executes it with `bash`. Leaves `SCROLLWM_DEST` unset so +/// the app lands in `~/Applications`, where the Accessibility grant persists +/// (avoiding App Translocation). Returns a short error string on failure. +/// +/// macOS only; on other platforms this is an immediate error (the opt-in is +/// never shown there, so this is just a safety net). +async fn run_scrollwm_install() -> Result<(), String> { + #[cfg(target_os = "macos")] + { + use tokio::process::Command; + use tokio::time::{Duration, timeout}; + + let run = async { + // 1. Download the installer script to a temp file. + let tmp = std::env::temp_dir().join(format!( + "scrollwm-web-install-{}.sh", + std::process::id() + )); + let curl = Command::new("curl") + .args(["-fsSL", SCROLLWM_WEB_INSTALL_URL, "-o"]) + .arg(&tmp) + .output() + .await + .map_err(|e| format!("could not run curl: {e}"))?; + if !curl.status.success() { + let err = String::from_utf8_lossy(&curl.stderr); + return Err(format!( + "download failed: {}", + err.trim().lines().last().unwrap_or("unknown error") + )); + } + + // 2. Execute the downloaded installer with bash. + let out = Command::new("bash") + .arg(&tmp) + .output() + .await + .map_err(|e| format!("could not run installer: {e}"))?; + let _ = tokio::fs::remove_file(&tmp).await; + if out.status.success() { + Ok(()) + } else { + let err = String::from_utf8_lossy(&out.stderr); + Err(err + .trim() + .lines() + .last() + .unwrap_or("installer exited with an error") + .to_string()) + } + }; + + // Generous cap so a stuck download never wedges the flow forever. + match timeout(Duration::from_secs(180), run).await { + Ok(result) => result, + Err(_) => Err("timed out after 180s".to_string()), + } + } + #[cfg(not(target_os = "macos"))] + { + Err("ScrollWM is macOS-only".to_string()) + } +} diff --git a/crates/jcode-tui/src/tui/app/remote.rs b/crates/jcode-tui/src/tui/app/remote.rs index 8b124404cd..755c64e198 100644 --- a/crates/jcode-tui/src/tui/app/remote.rs +++ b/crates/jcode-tui/src/tui/app/remote.rs @@ -485,6 +485,9 @@ pub(super) async fn handle_bus_event( Ok(BusEvent::OnboardingModelValidated(result)) => { app.handle_onboarding_model_validated(result) } + Ok(BusEvent::ScrollWmInstallCompleted(ev)) => { + app.handle_scrollwm_install_completed(ev) + } Ok(BusEvent::UpdateStatus(status)) => { app.handle_update_status(status); true diff --git a/crates/jcode-tui/src/tui/app/state_ui_input_helpers.rs b/crates/jcode-tui/src/tui/app/state_ui_input_helpers.rs index 30a9098a3a..4fb1886ace 100644 --- a/crates/jcode-tui/src/tui/app/state_ui_input_helpers.rs +++ b/crates/jcode-tui/src/tui/app/state_ui_input_helpers.rs @@ -1207,6 +1207,18 @@ impl App { seconds_left, } } + Some(OnboardingPhase::ScrollWmOptIn { + yes_highlighted, + shown_at, + }) => { + let total = crate::tui::app::onboarding_flow::DECISION_TIMEOUT.as_secs(); + let seconds_left = total.saturating_sub(shown_at.elapsed().as_secs()); + OnboardingWelcomeKind::ScrollWmOptIn { + yes_highlighted: *yes_highlighted, + seconds_left, + progress: self.scrollwm_install_progress.clone(), + } + } _ => OnboardingWelcomeKind::Suggestions, } } @@ -1222,6 +1234,7 @@ impl App { Some(OnboardingPhase::Login { .. }) | Some(OnboardingPhase::LoginOpenAi { .. }) | Some(OnboardingPhase::ContinuePrompt { .. }) + | Some(OnboardingPhase::ScrollWmOptIn { .. }) ) } diff --git a/crates/jcode-tui/src/tui/app/tests/onboarding_flow.rs b/crates/jcode-tui/src/tui/app/tests/onboarding_flow.rs index 0f7e19ee26..fc979e8088 100644 --- a/crates/jcode-tui/src/tui/app/tests/onboarding_flow.rs +++ b/crates/jcode-tui/src/tui/app/tests/onboarding_flow.rs @@ -820,3 +820,109 @@ fn not_found_panel_no_scroll_when_list_fits() { assert!(!app.handle_onboarding_continue_prompt_key(KeyCode::PageDown)); assert_eq!(app.onboarding_notfound_scroll, 0); } + +#[test] +fn scrollwm_optin_gate_logic() { + use super::onboarding_flow_control::scrollwm_optin_should_offer; + // Offered only on macOS, local, not installed, not answered. + assert!(scrollwm_optin_should_offer(true, false, false, false)); + // Suppressed when already installed. + assert!(!scrollwm_optin_should_offer(true, false, true, false)); + // Suppressed when already answered. + assert!(!scrollwm_optin_should_offer(true, false, false, true)); + // Suppressed in remote sessions. + assert!(!scrollwm_optin_should_offer(true, true, false, false)); + // Suppressed off macOS. + assert!(!scrollwm_optin_should_offer(false, false, false, false)); +} + +#[test] +fn scrollwm_optin_enter_defaults_to_no() { + let mut app = onboarding_test_app(); + app.onboarding_enter_scrollwm_optin(); + assert!(matches!( + app.onboarding_phase(), + Some(OnboardingPhase::ScrollWmOptIn { + yes_highlighted: false, + .. + }) + )); +} + +#[test] +fn scrollwm_optin_no_advances_to_suggestions_and_persists() { + with_temp_jcode_home(|| { + let mut app = onboarding_test_app(); + app.onboarding_enter_scrollwm_optin(); + app.onboarding_answer_scrollwm_optin(false); + assert!(matches!( + app.onboarding_phase(), + Some(OnboardingPhase::Suggestions) + )); + // The answer is persisted so we never re-ask. + assert!(crate::setup_hints::SetupHintsState::load().scrollwm_optin_answered); + }); +} + +#[test] +fn scrollwm_optin_yes_sets_running_progress() { + with_temp_jcode_home(|| { + let mut app = onboarding_test_app(); + app.onboarding_enter_scrollwm_optin(); + app.onboarding_answer_scrollwm_optin(true); + // Stays on the opt-in card with a Running progress line; the install + // runs in the background and is resolved via the Bus event. + assert!(matches!( + app.onboarding_phase(), + Some(OnboardingPhase::ScrollWmOptIn { .. }) + )); + let state = crate::setup_hints::SetupHintsState::load(); + assert!(state.scrollwm_optin_answered); + assert!(state.scrollwm_install_started); + }); +} + +#[test] +fn scrollwm_install_completed_advances_to_suggestions() { + with_temp_jcode_home(|| { + let mut app = onboarding_test_app(); + app.onboarding_enter_scrollwm_optin(); + app.onboarding_answer_scrollwm_optin(true); + let session_id = app.session.id.clone(); + let consumed = app.handle_scrollwm_install_completed( + crate::bus::ScrollWmInstallCompleted { + session_id, + ok: true, + detail: None, + }, + ); + assert!(consumed); + assert!(matches!( + app.onboarding_phase(), + Some(OnboardingPhase::Suggestions) + )); + }); +} + +#[test] +fn scrollwm_optin_key_yes_no_toggle_and_skip() { + with_temp_jcode_home(|| { + let mut app = onboarding_test_app(); + app.onboarding_enter_scrollwm_optin(); + // Left highlights Yes; Right highlights No. + assert!(app.handle_onboarding_continue_prompt_key(KeyCode::Left)); + assert!(matches!( + app.onboarding_phase(), + Some(OnboardingPhase::ScrollWmOptIn { + yes_highlighted: true, + .. + }) + )); + // 'n' skips and advances to suggestions. + assert!(app.handle_onboarding_continue_prompt_key(KeyCode::Char('n'))); + assert!(matches!( + app.onboarding_phase(), + Some(OnboardingPhase::Suggestions) + )); + }); +} diff --git a/crates/jcode-tui/src/tui/app/tui_lifecycle.rs b/crates/jcode-tui/src/tui/app/tui_lifecycle.rs index c602d3bd2b..9972b21b3e 100644 --- a/crates/jcode-tui/src/tui/app/tui_lifecycle.rs +++ b/crates/jcode-tui/src/tui/app/tui_lifecycle.rs @@ -359,6 +359,7 @@ impl App { onboarding_preview_mode: false, onboarding_flow: None, onboarding_notfound_scroll: 0, + scrollwm_install_progress: None, onboarding_startup_checked: false, onboarding_pending_model_validation: None, copy_badge_ui: CopyBadgeUiState::default(), @@ -738,6 +739,7 @@ impl App { onboarding_preview_mode: false, onboarding_flow: None, onboarding_notfound_scroll: 0, + scrollwm_install_progress: None, onboarding_startup_checked: false, onboarding_pending_model_validation: None, copy_badge_ui: CopyBadgeUiState::default(), diff --git a/crates/jcode-tui/src/tui/mod.rs b/crates/jcode-tui/src/tui/mod.rs index 888481c44d..db02599235 100644 --- a/crates/jcode-tui/src/tui/mod.rs +++ b/crates/jcode-tui/src/tui/mod.rs @@ -672,10 +672,31 @@ pub enum OnboardingWelcomeKind { yes_highlighted: bool, seconds_left: u64, }, + /// "Set up ScrollWM?" opt-in with a highlightable Yes/No selector and a + /// live decision countdown. `progress` is `None` while waiting for the + /// decision and `Some(..)` once a background install is running / finished, + /// at which point the card shows a status line instead of the Yes/No row. + ScrollWmOptIn { + yes_highlighted: bool, + seconds_left: u64, + progress: Option<ScrollWmInstallProgress>, + }, /// The starter prompt-suggestion cards (default). Suggestions, } +/// Progress of the onboarding "Set up ScrollWM?" background install, shown in +/// place of the Yes/No row once the user opts in. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum ScrollWmInstallProgress { + /// The installer is downloading / installing. + Running, + /// Install finished successfully (user must still grant Accessibility). + Succeeded, + /// Install failed; carries a short reason. + Failed { detail: String }, +} + /// One row in the "Searched, not found" onboarding panel: a credential source /// jcode probed during first-run detection but did not find. #[derive(Debug, Clone, PartialEq, Eq)] diff --git a/crates/jcode-tui/src/tui/ui_onboarding.rs b/crates/jcode-tui/src/tui/ui_onboarding.rs index 2802104e57..48441451fb 100644 --- a/crates/jcode-tui/src/tui/ui_onboarding.rs +++ b/crates/jcode-tui/src/tui/ui_onboarding.rs @@ -398,6 +398,124 @@ fn welcome_body_lines(app: &dyn TuiState) -> Vec<Line<'static>> { ); return lines; } + OnboardingWelcomeKind::ScrollWmOptIn { + yes_highlighted, + seconds_left, + progress, + } => { + use crate::tui::ScrollWmInstallProgress; + lines.push(Line::from("")); + lines.push( + Line::from(Span::styled( + "Set up ScrollWM?", + Style::default() + .fg(welcome_accent()) + .add_modifier(Modifier::BOLD), + )) + .alignment(align), + ); + lines.push( + Line::from(Span::styled( + "Arrange your swarm agents into a tidy scrolling strip.", + Style::default().fg(dim_color()), + )) + .alignment(align), + ); + lines.push( + Line::from(Span::styled( + "One permission (Accessibility). macOS only.", + Style::default().fg(dim_color()), + )) + .alignment(align), + ); + lines.push(Line::from("")); + + match progress { + // Install in flight / finished: show a status line instead of + // the Yes/No row so the card stops inviting input. + Some(ScrollWmInstallProgress::Running) => { + lines.push( + Line::from(Span::styled( + "Installing ScrollWM… (this opens a menu-bar app)", + Style::default() + .fg(welcome_accent()) + .add_modifier(Modifier::BOLD), + )) + .alignment(align), + ); + lines.push( + Line::from(Span::styled( + "When it finishes, grant Accessibility in System Settings.", + Style::default().fg(dim_color()), + )) + .alignment(align), + ); + } + Some(ScrollWmInstallProgress::Succeeded) => { + lines.push( + Line::from(Span::styled( + "✓ ScrollWM installed — grant Accessibility to finish.", + Style::default() + .fg(welcome_accent()) + .add_modifier(Modifier::BOLD), + )) + .alignment(align), + ); + } + Some(ScrollWmInstallProgress::Failed { detail }) => { + lines.push( + Line::from(Span::styled( + format!("✕ Install didn't finish: {detail}"), + Style::default().fg(dim_color()), + )) + .alignment(align), + ); + } + None => { + // Yes / No options; the highlighted one is bold + accented. + // Default highlight is "No" (never install on a timeout). + let (yes_style, no_style) = if yes_highlighted { + ( + Style::default() + .fg(welcome_accent()) + .add_modifier(Modifier::BOLD | Modifier::REVERSED), + Style::default().fg(dim_color()), + ) + } else { + ( + Style::default().fg(dim_color()), + Style::default() + .fg(welcome_accent()) + .add_modifier(Modifier::BOLD | Modifier::REVERSED), + ) + }; + lines.push( + Line::from(vec![ + Span::styled(" Yes ", yes_style), + Span::raw(" "), + Span::styled(" No ", no_style), + ]) + .alignment(align), + ); + lines.push(Line::from("")); + lines.push( + Line::from(Span::styled( + "Left/right or h/l to move, Enter or Space to choose (y / n also work).", + Style::default().fg(dim_color()), + )) + .alignment(align), + ); + lines.push( + Line::from(Span::styled( + format!("Skips automatically in {seconds_left}s."), + Style::default().fg(dim_color()), + )) + .alignment(align), + ); + } + } + return lines; + } OnboardingWelcomeKind::Suggestions => {} } diff --git a/crates/jcode-tui/src/tui/ui_tests/onboarding.rs b/crates/jcode-tui/src/tui/ui_tests/onboarding.rs index c432694f17..d2c81e7142 100644 --- a/crates/jcode-tui/src/tui/ui_tests/onboarding.rs +++ b/crates/jcode-tui/src/tui/ui_tests/onboarding.rs @@ -168,3 +168,48 @@ fn onboarding_not_found_panel_shows_scroll_affordance_when_overflowing() { ); } + + +#[test] +fn onboarding_scrollwm_optin_card_renders_decision_and_progress() { + use crate::tui::{OnboardingWelcomeKind, ScrollWmInstallProgress}; + + // Decision state: shows the pitch + Yes/No + countdown. + let decision = TestState { + onboarding_preview: true, + onboarding_welcome_kind: Some(OnboardingWelcomeKind::ScrollWmOptIn { + yes_highlighted: false, + seconds_left: 60, + progress: None, + }), + ..Default::default() + }; + let text = render_onboarding(&decision, 80, 34); + assert!(text.contains("Set up ScrollWM?"), "pitch title:\n{text}"); + assert!(text.contains("Accessibility"), "permission note:\n{text}"); + assert!( + text.contains("Yes") && text.contains("No"), + "Yes/No row:\n{text}" + ); + assert!(text.contains("Skips automatically"), "countdown:\n{text}"); + + // Running state: shows the install progress line, no Yes/No countdown. + let running = TestState { + onboarding_preview: true, + onboarding_welcome_kind: Some(OnboardingWelcomeKind::ScrollWmOptIn { + yes_highlighted: false, + seconds_left: 60, + progress: Some(ScrollWmInstallProgress::Running), + }), + ..Default::default() + }; + let text = render_onboarding(&running, 80, 34); + assert!( + text.contains("Installing ScrollWM"), + "running progress line:\n{text}" + ); + assert!( + !text.contains("Skips automatically"), + "countdown should be gone while installing:\n{text}" + ); +} From 4ab63b4b9aac105d45f3c4161943e70ae649b39a Mon Sep 17 00:00:00 2001 From: jcode <jcode@local> Date: Thu, 25 Jun 2026 20:22:44 -0700 Subject: [PATCH 15/18] feat(scrollwm): add jcode-scrollwm control-client crate The Rust counterpart to ScrollWM's Swift ControlClient: a small, blocking, best-effort client for ScrollWM's Unix control socket. This is the code-level link between the two repos, used to detect ScrollWM and drive it (arrange, focus columns, focus-by-title, read strip status). - ScrollWm client: discover()/with_socket(), is_running (ping), hello (version handshake with status fallback), status (serde over controlStatusJSON shape), arrange, focus_index/next/prev, focus_title - socket path resolution honors SCROLLWM_CONTROL_SOCK (sandbox/tests) and defaults to ~/Library/Application Support/ScrollWM/control.sock - ENOENT/ECONNREFUSED map to NotRunning; error: replies -> Protocol; non-unix stub - pure column_for_title helper for title-keyed focus - tests: path resolution, JSON round-trips, NotRunning, and loopback tests that exercise the real connect/write/read protocol via a temp UnixListener --- Cargo.lock | 11 + Cargo.toml | 2 + crates/jcode-scrollwm/Cargo.toml | 11 + crates/jcode-scrollwm/src/lib.rs | 540 +++++++++++++++++++++++++++++++ 4 files changed, 564 insertions(+) create mode 100644 crates/jcode-scrollwm/Cargo.toml create mode 100644 crates/jcode-scrollwm/src/lib.rs diff --git a/Cargo.lock b/Cargo.lock index f6c2194d7e..cd93531f2d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3319,6 +3319,7 @@ dependencies = [ "jcode-provider-metadata", "jcode-provider-openai", "jcode-provider-openrouter", + "jcode-scrollwm", "jcode-selfdev-types", "jcode-session-types", "jcode-setup-hints", @@ -3965,6 +3966,16 @@ dependencies = [ "unicode-width 0.2.0", ] +[[package]] +name = "jcode-scrollwm" +version = "0.1.0" +dependencies = [ + "anyhow", + "dirs", + "serde", + "serde_json", +] + [[package]] name = "jcode-selfdev-types" version = "0.1.0" diff --git a/Cargo.toml b/Cargo.toml index 98b257fdde..6f5650b701 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -34,6 +34,7 @@ members = [ "crates/jcode-swarm-core", "crates/jcode-protocol", "crates/jcode-selfdev-types", + "crates/jcode-scrollwm", "crates/jcode-session-types", "crates/jcode-setup-hints", "crates/jcode-storage", @@ -226,6 +227,7 @@ jcode-plan = { path = "crates/jcode-plan" } jcode-swarm-core = { path = "crates/jcode-swarm-core" } jcode-protocol = { path = "crates/jcode-protocol" } jcode-selfdev-types = { path = "crates/jcode-selfdev-types" } +jcode-scrollwm = { path = "crates/jcode-scrollwm" } jcode-session-types = { path = "crates/jcode-session-types" } jcode-setup-hints = { path = "crates/jcode-setup-hints" } jcode-storage = { path = "crates/jcode-storage" } diff --git a/crates/jcode-scrollwm/Cargo.toml b/crates/jcode-scrollwm/Cargo.toml new file mode 100644 index 0000000000..d570be963a --- /dev/null +++ b/crates/jcode-scrollwm/Cargo.toml @@ -0,0 +1,11 @@ +[package] +name = "jcode-scrollwm" +version = "0.1.0" +edition = "2024" +publish = false + +[dependencies] +anyhow = "1" +dirs = "5" +serde = { version = "1", features = ["derive"] } +serde_json = "1" diff --git a/crates/jcode-scrollwm/src/lib.rs b/crates/jcode-scrollwm/src/lib.rs new file mode 100644 index 0000000000..fa986c2456 --- /dev/null +++ b/crates/jcode-scrollwm/src/lib.rs @@ -0,0 +1,540 @@ +//! A small, dependency-light client for the **ScrollWM** control plane. +//! +//! ScrollWM (a macOS scrolling window manager, <https://github.com/1jehuang/scrollwm>) +//! exposes a Unix-domain control socket. A client connects, writes one +//! newline-terminated command line, half-closes its write side, and reads one +//! reply line back. This crate is the Rust counterpart to ScrollWM's Swift +//! `ControlClient.send`, used by jcode to: +//! +//! - detect whether ScrollWM is installed / running (`is_running`, `hello`), +//! - read the live strip layout (`status`), and +//! - drive it (`arrange`, `focus_index`, `focus_title`, `workspace`, ...), +//! e.g. to tile headed swarm-agent windows into the strip. +//! +//! ## Design +//! - **Blocking + short-lived.** Each call is one connect/write/read round-trip +//! on a local socket, mirroring the Swift CLI. Callers that need async wrap it +//! in `spawn_blocking`. +//! - **Best-effort.** ScrollWM is optional: when it is absent the client returns +//! [`ScrollWmError::NotRunning`] (mapped from `ENOENT`/`ECONNREFUSED`) and +//! never panics, so jcode can degrade gracefully. +//! - **No side effects.** The client only ever connects out; it never launches +//! ScrollWM (cold-start is a separate, opt-in concern) and never binds. +//! - **macOS-shaped, cross-platform-safe.** The Unix-socket path compiles on any +//! `unix`; on non-unix targets the calls return `NotRunning` so callers don't +//! need their own `cfg` gates. +//! +//! ## Wire contract (matches `ControlServer.swift` / `ControlCommands.swift`) +//! Request: `"<verb> [args]\n"`. Reply: one line; lines beginning with `error:` +//! denote a command-level failure. `status`/`version` reply with a JSON object. + +use std::path::PathBuf; +use std::time::Duration; + +/// Default per-call timeout for connect/read. Local socket round-trips are sub- +/// millisecond; this only guards against a wedged (main-thread-blocked) app so a +/// ScrollWM hiccup never stalls a jcode spawn. +pub const DEFAULT_TIMEOUT: Duration = Duration::from_millis(750); + +/// Environment override for the control socket path (matches ScrollWM's +/// `SCROLLWM_CONTROL_SOCK`, used by its sandbox mode and by jcode tests). +pub const SOCKET_ENV: &str = "SCROLLWM_CONTROL_SOCK"; + +/// Errors talking to the ScrollWM control socket. +#[derive(Debug)] +pub enum ScrollWmError { + /// No socket file (`ENOENT`) or a stale socket (`ECONNREFUSED`): ScrollWM is + /// not running. This is the expected "absent" case, not a hard error. + NotRunning, + /// A lower-level I/O failure (connect/write/read/timeout) other than the + /// not-running case above. + Io(std::io::Error), + /// The server replied with an `error:`-prefixed line (command-level failure). + Protocol(String), + /// A reply that should have been JSON (`status`, `version`) could not be + /// parsed. + Parse(String), + /// This platform has no Unix-domain ScrollWM socket (non-unix targets). + Unsupported, +} + +impl std::fmt::Display for ScrollWmError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + ScrollWmError::NotRunning => write!(f, "ScrollWM is not running"), + ScrollWmError::Io(e) => write!(f, "ScrollWM I/O error: {e}"), + ScrollWmError::Protocol(m) => write!(f, "ScrollWM error: {m}"), + ScrollWmError::Parse(m) => write!(f, "ScrollWM reply parse error: {m}"), + ScrollWmError::Unsupported => write!(f, "ScrollWM is not supported on this platform"), + } + } +} + +impl std::error::Error for ScrollWmError {} + +/// Resolve the control socket path the same way ScrollWM does: +/// `$SCROLLWM_CONTROL_SOCK`, else +/// `~/Library/Application Support/ScrollWM/control.sock`. +pub fn control_socket_path() -> PathBuf { + if let Some(override_path) = std::env::var_os(SOCKET_ENV) + && !override_path.is_empty() + { + return PathBuf::from(override_path); + } + // ScrollWM uses the macOS Application Support directory. `dirs` resolves the + // platform's data dir; on macOS that is exactly Library/Application Support. + let base = dirs::data_dir().unwrap_or_else(|| { + // Fallback for the unusual case `dirs` can't resolve a data dir. + let home = dirs::home_dir().unwrap_or_else(|| PathBuf::from(".")); + home.join("Library/Application Support") + }); + base.join("ScrollWM").join("control.sock") +} + +/// A handle to the ScrollWM control socket. Cheap to construct and clone; each +/// method opens a fresh short-lived connection. +#[derive(Clone, Debug)] +pub struct ScrollWm { + socket: PathBuf, + timeout: Duration, +} + +impl Default for ScrollWm { + fn default() -> Self { + Self::discover() + } +} + +impl ScrollWm { + /// Construct using the default (env-aware) socket path and timeout. + pub fn discover() -> Self { + Self { + socket: control_socket_path(), + timeout: DEFAULT_TIMEOUT, + } + } + + /// Construct against an explicit socket path (sandbox / tests). + pub fn with_socket(socket: impl Into<PathBuf>) -> Self { + Self { + socket: socket.into(), + timeout: DEFAULT_TIMEOUT, + } + } + + /// Override the per-call timeout. + pub fn with_timeout(mut self, timeout: Duration) -> Self { + self.timeout = timeout; + self + } + + /// The resolved socket path this client talks to. + pub fn socket_path(&self) -> &std::path::Path { + &self.socket + } + + /// Send one verb line and return the trimmed reply. An `error:`-prefixed + /// reply is surfaced as [`ScrollWmError::Protocol`]. Connecting to a missing + /// or stale socket yields [`ScrollWmError::NotRunning`]. + pub fn send(&self, line: &str) -> Result<String, ScrollWmError> { + let reply = send_raw(&self.socket, line, self.timeout)?; + if let Some(rest) = reply.strip_prefix("error:") { + return Err(ScrollWmError::Protocol(rest.trim().to_string())); + } + Ok(reply) + } + + /// Liveness probe: `ping` -> `pong`. False on any error (not running, I/O, + /// timeout), so callers can branch without matching the error. + pub fn is_running(&self) -> bool { + matches!(self.send("ping"), Ok(reply) if reply == "pong") + } + + /// Capability handshake. Prefers the `version` verb (newer ScrollWM); when + /// that verb is unknown (older builds reply `error: unknown command`), falls + /// back to deriving `{managing, ...}` presence from `status` with + /// `protocol = 0` so feature detection still degrades gracefully. + pub fn hello(&self) -> Result<ScrollWmInfo, ScrollWmError> { + match self.send("version") { + Ok(reply) => serde_json::from_str::<ScrollWmInfo>(&reply) + .map_err(|e| ScrollWmError::Parse(e.to_string())), + // Older ScrollWM without the `version` verb: synthesize a v0 info + // from a successful `status` so callers still get a usable handshake. + Err(ScrollWmError::Protocol(_)) => { + self.status()?; + Ok(ScrollWmInfo { + name: "ScrollWM".to_string(), + version: String::new(), + protocol: 0, + verbs: Vec::new(), + }) + } + Err(e) => Err(e), + } + } + + /// Read the live strip status (managing flag, columns, workspace, ...). + pub fn status(&self) -> Result<StripStatus, ScrollWmError> { + let reply = self.send("status")?; + serde_json::from_str::<StripStatus>(&reply).map_err(|e| ScrollWmError::Parse(e.to_string())) + } + + /// Adopt the current Space's windows into the strip (idempotent: also + /// re-syncs when already managing). NOTE: this affects *every* manageable + /// window on the Space, so callers should only invoke it intentionally. + pub fn arrange(&self) -> Result<String, ScrollWmError> { + self.send("arrange") + } + + /// Focus a column by 1-based index (the CLI is 1-based; the engine is + /// 0-based internally). + pub fn focus_index(&self, one_based: usize) -> Result<String, ScrollWmError> { + self.send(&format!("focus {one_based}")) + } + + /// Focus the next / previous column. + pub fn focus_next(&self) -> Result<String, ScrollWmError> { + self.send("focus next") + } + + /// Focus the previous column. + pub fn focus_prev(&self) -> Result<String, ScrollWmError> { + self.send("focus prev") + } + + /// Focus the managed column whose window title contains `needle` + /// (case-insensitive), resolved from a fresh [`status`](Self::status) read. + /// + /// jcode names its spawned terminal windows deterministically, so matching by + /// title is the reliable way to focus a specific agent without depending on + /// volatile column indices. Returns [`ScrollWmError::Protocol`] when nothing + /// matches (mirroring how the native verbs report "not found"). + pub fn focus_title(&self, needle: &str) -> Result<String, ScrollWmError> { + let status = self.status()?; + match column_for_title(&status, needle) { + Some(index) => self.focus_index(index), + None => Err(ScrollWmError::Protocol(format!( + "no managed column title contains \"{needle}\"" + ))), + } + } +} + +/// One short-lived connect/write/read round-trip against a Unix socket path. +/// Writes `line` (adding a trailing newline if absent), half-closes the write +/// side, reads the reply to EOF, and returns it trimmed. ENOENT/ECONNREFUSED map +/// to [`ScrollWmError::NotRunning`]. +#[cfg(unix)] +pub fn send_raw( + socket: &std::path::Path, + line: &str, + timeout: Duration, +) -> Result<String, ScrollWmError> { + use std::io::{Read, Write}; + use std::net::Shutdown; + use std::os::unix::net::UnixStream; + + let mut stream = match UnixStream::connect(socket) { + Ok(s) => s, + Err(e) => { + return Err(match e.kind() { + std::io::ErrorKind::NotFound | std::io::ErrorKind::ConnectionRefused => { + ScrollWmError::NotRunning + } + _ => ScrollWmError::Io(e), + }); + } + }; + stream + .set_read_timeout(Some(timeout)) + .map_err(ScrollWmError::Io)?; + stream + .set_write_timeout(Some(timeout)) + .map_err(ScrollWmError::Io)?; + + let mut msg = line.to_string(); + if !msg.ends_with('\n') { + msg.push('\n'); + } + stream + .write_all(msg.as_bytes()) + .map_err(ScrollWmError::Io)?; + // Half-close so the server sees EOF and flushes its single-line reply. + let _ = stream.shutdown(Shutdown::Write); + + let mut buf = Vec::new(); + stream.read_to_end(&mut buf).map_err(ScrollWmError::Io)?; + Ok(String::from_utf8_lossy(&buf).trim().to_string()) +} + +/// Non-unix stub: there is no ScrollWM socket, so every call is "not running". +#[cfg(not(unix))] +pub fn send_raw( + _socket: &std::path::Path, + _line: &str, + _timeout: Duration, +) -> Result<String, ScrollWmError> { + Err(ScrollWmError::Unsupported) +} + +/// The capability handshake (`version` verb), used for feature detection. New +/// fields are additive; unknown ones are ignored. +#[derive(Debug, Clone, serde::Deserialize, serde::Serialize, Default)] +pub struct ScrollWmInfo { + /// Product name (e.g. "ScrollWM"). + #[serde(default)] + pub name: String, + /// Marketing version (e.g. "0.1.6"); display-only. + #[serde(default)] + pub version: String, + /// Monotonic control-protocol revision; the coarse compatibility gate. + #[serde(default)] + pub protocol: u32, + /// Supported verb names, for fine-grained feature detection. + #[serde(default)] + pub verbs: Vec<String>, +} + +impl ScrollWmInfo { + /// Whether this ScrollWM advertises a given verb (only meaningful when the + /// `verbs` list is populated, i.e. `version` was supported). + pub fn supports_verb(&self, verb: &str) -> bool { + self.verbs.iter().any(|v| v == verb) + } +} + +/// Snapshot of the strip returned by the `status` verb. Mirrors +/// `controlStatusJSON()` / `controlColumns()` in ScrollWM. Optional fields are +/// only present while managing. +#[derive(Debug, Clone, serde::Deserialize, serde::Serialize, Default)] +pub struct StripStatus { + /// Whether ScrollWM is actively managing windows (vs. dormant). + #[serde(default)] + pub managing: bool, + /// Current focus mode ("fit" / "centered"), display-only. + #[serde(default, rename = "focusMode")] + pub focus_mode: String, + /// Total managed window count. + #[serde(default, rename = "windowCount")] + pub window_count: u32, + /// 1-based index of the focused column, when managing. + #[serde(default, rename = "focusedColumn")] + pub focused_column: Option<u32>, + /// Current vertical workspace (1-based), when managing. + #[serde(default)] + pub workspace: Option<u32>, + /// Total vertical workspace count, when managing. + #[serde(default, rename = "workspaceCount")] + pub workspace_count: Option<u32>, + /// The columns of the active strip, left to right. + #[serde(default)] + pub columns: Vec<Column>, +} + +/// One column (window) of the strip. +#[derive(Debug, Clone, serde::Deserialize, serde::Serialize, Default)] +pub struct Column { + /// 1-based column index. + #[serde(default)] + pub index: u32, + /// Owning application name (e.g. "Ghostty"). + #[serde(default)] + pub app: String, + /// Window title (jcode sets a unique per-session title here). + #[serde(default)] + pub title: String, + /// Column width in pixels. + #[serde(default)] + pub width: u32, + /// Whether this column currently has focus. + #[serde(default)] + pub focused: bool, + /// Whether ScrollWM considers this window healthy/managed. + #[serde(default)] + pub healthy: bool, +} + +/// Pure helper: find the 1-based index of the first column whose title contains +/// `needle` (case-insensitive). Returns `None` when nothing matches. Extracted +/// for unit testing without a live ScrollWM. +pub fn column_for_title(status: &StripStatus, needle: &str) -> Option<usize> { + let needle = needle.to_ascii_lowercase(); + status + .columns + .iter() + .find(|c| c.title.to_ascii_lowercase().contains(&needle)) + .map(|c| c.index as usize) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn socket_path_honors_env_override() { + // Safe: serial within this test; restores the var afterward. + let prev = std::env::var_os(SOCKET_ENV); + unsafe { std::env::set_var(SOCKET_ENV, "/tmp/scrollwm-test.sock") }; + assert_eq!( + control_socket_path(), + PathBuf::from("/tmp/scrollwm-test.sock") + ); + match prev { + Some(v) => unsafe { std::env::set_var(SOCKET_ENV, v) }, + None => unsafe { std::env::remove_var(SOCKET_ENV) }, + } + } + + #[test] + fn default_socket_path_ends_with_scrollwm_control_sock() { + let prev = std::env::var_os(SOCKET_ENV); + unsafe { std::env::remove_var(SOCKET_ENV) }; + let path = control_socket_path(); + assert!( + path.ends_with("ScrollWM/control.sock"), + "unexpected default socket path: {}", + path.display() + ); + if let Some(v) = prev { + unsafe { std::env::set_var(SOCKET_ENV, v) }; + } + } + + fn status_with_titles(titles: &[&str]) -> StripStatus { + StripStatus { + managing: true, + columns: titles + .iter() + .enumerate() + .map(|(i, t)| Column { + index: (i + 1) as u32, + app: "Ghostty".to_string(), + title: (*t).to_string(), + ..Default::default() + }) + .collect(), + ..Default::default() + } + } + + #[test] + fn column_for_title_matches_case_insensitively() { + let status = status_with_titles(&["🛰 jcode/aqua main", "Browser", "🛰 jcode/coral docs"]); + assert_eq!(column_for_title(&status, "jcode/AQUA"), Some(1)); + assert_eq!(column_for_title(&status, "coral"), Some(3)); + assert_eq!(column_for_title(&status, "nope"), None); + } + + #[test] + fn status_json_round_trips_control_shape() { + // The exact field names ScrollWM emits (camelCase) must deserialize. + let json = r#"{ + "managing": true, + "focusMode": "fit", + "windowCount": 2, + "focusedColumn": 1, + "workspace": 1, + "workspaceCount": 1, + "columns": [ + {"index": 1, "app": "Ghostty", "title": "🛰 jcode/aqua", "width": 800, "focused": true, "healthy": true}, + {"index": 2, "app": "Safari", "title": "Docs", "width": 600, "focused": false, "healthy": true} + ] + }"#; + let status: StripStatus = serde_json::from_str(json).expect("parse status"); + assert!(status.managing); + assert_eq!(status.window_count, 2); + assert_eq!(status.focused_column, Some(1)); + assert_eq!(status.columns.len(), 2); + assert_eq!(column_for_title(&status, "aqua"), Some(1)); + } + + #[test] + fn version_json_parses_capabilities() { + let json = r#"{"name":"ScrollWM","version":"0.2.0","protocol":1,"verbs":["ping","status","arrange","focus-title"]}"#; + let info: ScrollWmInfo = serde_json::from_str(json).expect("parse version"); + assert_eq!(info.protocol, 1); + assert!(info.supports_verb("focus-title")); + assert!(!info.supports_verb("spawn-strip")); + } + + #[test] + fn missing_socket_reports_not_running() { + let client = ScrollWm::with_socket("/tmp/jcode-scrollwm-nonexistent-12345.sock"); + assert!(!client.is_running()); + match client.status() { + Err(ScrollWmError::NotRunning) => {} + other => panic!("expected NotRunning, got {other:?}"), + } + } +} + +#[cfg(all(test, unix))] +mod loopback_tests { + use super::*; + use std::io::{Read, Write}; + use std::os::unix::net::UnixListener; + + /// Spin up a one-shot Unix listener that replies with `reply` to the first + /// connection, so we can exercise the real connect/write/read path without a + /// live ScrollWM. Returns the socket path; the listener thread self-cleans. + fn spawn_echo_server(reply: &'static str) -> PathBuf { + let dir = std::env::temp_dir(); + let path = dir.join(format!( + "jcode-scrollwm-loopback-{}-{}.sock", + std::process::id(), + // cheap unique-ish suffix + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos() + )); + let _ = std::fs::remove_file(&path); + let listener = UnixListener::bind(&path).expect("bind loopback socket"); + let thread_path = path.clone(); + std::thread::spawn(move || { + if let Ok((mut stream, _)) = listener.accept() { + // Drain the request (until the client half-closes its write side). + let mut req = Vec::new(); + let mut buf = [0u8; 256]; + loop { + match stream.read(&mut buf) { + Ok(0) => break, + Ok(n) => req.extend_from_slice(&buf[..n]), + Err(_) => break, + } + } + let _ = stream.write_all(reply.as_bytes()); + } + let _ = std::fs::remove_file(&thread_path); + }); + path + } + + #[test] + fn ping_round_trips_over_loopback() { + let path = spawn_echo_server("pong\n"); + let client = ScrollWm::with_socket(path); + assert!(client.is_running()); + } + + #[test] + fn error_reply_maps_to_protocol_error() { + let path = spawn_echo_server("error: not managing\n"); + let client = ScrollWm::with_socket(path); + match client.send("focus 1") { + Err(ScrollWmError::Protocol(m)) => assert_eq!(m, "not managing"), + other => panic!("expected Protocol error, got {other:?}"), + } + } + + #[test] + fn status_reply_parses_over_loopback() { + let path = spawn_echo_server( + r#"{"managing":true,"windowCount":1,"columns":[{"index":1,"app":"Ghostty","title":"jcode/x","focused":true}]}"#, + ); + let client = ScrollWm::with_socket(path); + let status = client.status().expect("status"); + assert!(status.managing); + assert_eq!(status.columns.len(), 1); + } +} From aa28116fc8213e5b31946a13a06d832eb6fbf3d6 Mon Sep 17 00:00:00 2001 From: jcode <jcode@local> Date: Thu, 25 Jun 2026 20:28:56 -0700 Subject: [PATCH 16/18] feat(swarm): focus headed swarm-agent windows via ScrollWM (opt-in) When agents.scrollwm.enabled and ScrollWM is running, focus each freshly spawned headed agent's strip column by matching its unique session name in the window title. Best-effort and fully detached: spawn never blocks on socket or window I/O, and it is a quiet no-op when ScrollWM is absent/not managing. - AgentsConfig.scrollwm: { enabled=false, focus_active=true, arrange_on_spawn=false } - env overrides JCODE_SCROLLWM / _FOCUS_ACTIVE / _ARRANGE_ON_SPAWN + known-vars - maybe_reconcile_scrollwm_after_spawn() hooked after register_visible_spawned_member, polls status up to ~3s for the agent column, never calls arrange unless opted in - documented in the default config; config env-override test added --- Cargo.lock | 1 + crates/jcode-app-core/Cargo.toml | 1 + .../jcode-app-core/src/server/comm_session.rs | 62 +++++++++++++++++++ crates/jcode-base/src/config.rs | 3 + crates/jcode-base/src/config/default_file.rs | 11 ++++ crates/jcode-base/src/config/env_overrides.rs | 17 +++++ crates/jcode-base/src/config_tests.rs | 25 ++++++++ crates/jcode-config-types/src/lib.rs | 34 ++++++++++ 8 files changed, 154 insertions(+) diff --git a/Cargo.lock b/Cargo.lock index cd93531f2d..00b34eac00 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3439,6 +3439,7 @@ dependencies = [ "jcode-protocol", "jcode-provider-core", "jcode-provider-metadata", + "jcode-scrollwm", "jcode-selfdev-types", "jcode-session-types", "jcode-setup-hints", diff --git a/crates/jcode-app-core/Cargo.toml b/crates/jcode-app-core/Cargo.toml index 6df0729211..861abf84b1 100644 --- a/crates/jcode-app-core/Cargo.toml +++ b/crates/jcode-app-core/Cargo.toml @@ -116,6 +116,7 @@ jcode-plan = { path = "../jcode-plan" } jcode-swarm-core = { path = "../jcode-swarm-core" } jcode-protocol = { path = "../jcode-protocol" } jcode-selfdev-types = { path = "../jcode-selfdev-types" } +jcode-scrollwm = { path = "../jcode-scrollwm" } jcode-session-types = { path = "../jcode-session-types" } jcode-setup-hints = { path = "../jcode-setup-hints" } jcode-storage = { path = "../jcode-storage" } diff --git a/crates/jcode-app-core/src/server/comm_session.rs b/crates/jcode-app-core/src/server/comm_session.rs index 070f6e76ae..163224bf4a 100644 --- a/crates/jcode-app-core/src/server/comm_session.rs +++ b/crates/jcode-app-core/src/server/comm_session.rs @@ -462,6 +462,64 @@ async fn register_visible_spawned_member( broadcast_swarm_status(swarm_id, swarm_members, swarms_by_id).await; } +/// Best-effort ScrollWM reconcile after a headed agent window is spawned. +/// +/// Gated on `agents.scrollwm.enabled`. Spawns a short, detached task (never +/// blocking or failing the spawn) that waits for ScrollWM's auto-adopt to place +/// the new window, then focuses the agent's strip column by matching its unique +/// session name in the window title. When ScrollWM is absent / not running / +/// not managing, every step is a quiet no-op. We never call `arrange` unless the +/// user explicitly opted into `arrange_on_spawn` (it adopts the whole Space). +fn maybe_reconcile_scrollwm_after_spawn(session_id: &str) { + // macOS only: ScrollWM is a macOS window manager. + if !cfg!(target_os = "macos") { + return; + } + let cfg = crate::config::config().agents.scrollwm; + if !cfg.enabled { + return; + } + // The agent's session name is unique and embedded in its window title + // (`resumed_window_title` -> `terminal_session_label_for_id`), so it is a + // reliable focus key without depending on volatile column indices. + let needle = crate::process_title::session_name(session_id); + if needle.trim().is_empty() { + return; + } + tokio::spawn(async move { + // Run the blocking socket I/O off the async reactor. + let _ = tokio::task::spawn_blocking(move || { + let sw = jcode_scrollwm::ScrollWm::discover(); + if !sw.is_running() { + return; + } + // Optionally start managing (adopts the whole Space) before focusing. + if cfg.arrange_on_spawn { + match sw.status() { + Ok(status) if !status.managing => { + let _ = sw.arrange(); + } + _ => {} + } + } + if !cfg.focus_active { + return; + } + // The window appears asynchronously (open -na Ghostty detaches and the + // new jcode process sets its OSC title ~0.3-2s later). Poll a bounded + // number of times for the agent's column, then give up quietly. + for _ in 0..6 { + match sw.focus_title(&needle) { + Ok(_) => return, + Err(jcode_scrollwm::ScrollWmError::NotRunning) => return, + Err(_) => std::thread::sleep(std::time::Duration::from_millis(500)), + } + } + }) + .await; + }); +} + #[expect( clippy::too_many_arguments, reason = "server-side swarm spawning needs session, swarm state, provider, and event sinks together" @@ -598,6 +656,10 @@ pub(super) async fn spawn_swarm_agent( swarm_event_tx, ) .await; + // Best-effort: ask ScrollWM (if the user opted in and it is running) to + // focus the freshly-spawned agent window. Detached so socket/window I/O + // never blocks or fails the spawn. + maybe_reconcile_scrollwm_after_spawn(&new_session_id); } let swarm_state = SwarmState { members: Arc::clone(swarm_members), diff --git a/crates/jcode-base/src/config.rs b/crates/jcode-base/src/config.rs index badd8261f2..f1928e2ca5 100644 --- a/crates/jcode-base/src/config.rs +++ b/crates/jcode-base/src/config.rs @@ -125,6 +125,9 @@ const CONFIG_ENV_KEYS: &[&str] = &[ "JCODE_SCROLL_PROMPT_UP_KEY", "JCODE_SCROLL_UP_FALLBACK_KEY", "JCODE_SCROLL_UP_KEY", + "JCODE_SCROLLWM", + "JCODE_SCROLLWM_ARRANGE_ON_SPAWN", + "JCODE_SCROLLWM_FOCUS_ACTIVE", "JCODE_SEARXNG_URL", "JCODE_SHOW_DIFFS", "JCODE_SHOW_THINKING", diff --git a/crates/jcode-base/src/config/default_file.rs b/crates/jcode-base/src/config/default_file.rs index 40aa995cde..da71e71127 100644 --- a/crates/jcode-base/src/config/default_file.rs +++ b/crates/jcode-base/src/config/default_file.rs @@ -281,6 +281,17 @@ swarm_spawn_mode = "visible" # # Whether the memory sidecar handles relevance/extraction. # memory_sidecar_enabled = false +# +# ScrollWM integration (macOS): when you spawn headed swarm agents and the +# ScrollWM window manager (https://github.com/1jehuang/scrollwm) is running, +# jcode can focus each new agent's window in the scrolling strip. Best-effort: +# a no-op when ScrollWM is absent, and it never rearranges your desktop unless +# you explicitly enable arrange_on_spawn. Env overrides: JCODE_SCROLLWM, +# JCODE_SCROLLWM_FOCUS_ACTIVE, JCODE_SCROLLWM_ARRANGE_ON_SPAWN. +# [agents.scrollwm] +# enabled = false # master switch (opt-in) +# focus_active = true # focus the just-spawned agent's strip column +# arrange_on_spawn = false # adopt the whole Space into the strip (use with care) [ambient] # Ambient mode: background agent that maintains your codebase diff --git a/crates/jcode-base/src/config/env_overrides.rs b/crates/jcode-base/src/config/env_overrides.rs index 9569475386..6077f8caa3 100644 --- a/crates/jcode-base/src/config/env_overrides.rs +++ b/crates/jcode-base/src/config/env_overrides.rs @@ -311,6 +311,23 @@ impl Config { self.agents.memory_sidecar_enabled = parsed; } } + // ScrollWM window-manager integration (macOS). Quick toggles for + // testing the headed-swarm tiling without editing the config file. + if let Ok(v) = std::env::var("JCODE_SCROLLWM") { + if let Some(parsed) = parse_env_bool(&v) { + self.agents.scrollwm.enabled = parsed; + } + } + if let Ok(v) = std::env::var("JCODE_SCROLLWM_FOCUS_ACTIVE") { + if let Some(parsed) = parse_env_bool(&v) { + self.agents.scrollwm.focus_active = parsed; + } + } + if let Ok(v) = std::env::var("JCODE_SCROLLWM_ARRANGE_ON_SPAWN") { + if let Some(parsed) = parse_env_bool(&v) { + self.agents.scrollwm.arrange_on_spawn = parsed; + } + } // Web search if let Ok(v) = std::env::var("JCODE_WEBSEARCH_ENGINE") diff --git a/crates/jcode-base/src/config_tests.rs b/crates/jcode-base/src/config_tests.rs index 8566df9875..fabfbf704c 100644 --- a/crates/jcode-base/src/config_tests.rs +++ b/crates/jcode-base/src/config_tests.rs @@ -88,6 +88,31 @@ fn test_env_override_swarm_spawn_mode() { restore_env_var("JCODE_SWARM_SPAWN_MODE", prev); } +#[test] +fn test_env_override_scrollwm_integration() { + let _guard = crate::storage::lock_test_env(); + let prev_enabled = std::env::var_os("JCODE_SCROLLWM"); + let prev_arrange = std::env::var_os("JCODE_SCROLLWM_ARRANGE_ON_SPAWN"); + + // Default is opt-out. + let cfg = Config::default(); + assert!(!cfg.agents.scrollwm.enabled); + assert!(cfg.agents.scrollwm.focus_active); + assert!(!cfg.agents.scrollwm.arrange_on_spawn); + + crate::env::set_var("JCODE_SCROLLWM", "1"); + crate::env::set_var("JCODE_SCROLLWM_ARRANGE_ON_SPAWN", "true"); + let mut cfg = Config::default(); + cfg.apply_env_overrides(); + assert!(cfg.agents.scrollwm.enabled); + assert!(cfg.agents.scrollwm.arrange_on_spawn); + // Unset override leaves the default intact. + assert!(cfg.agents.scrollwm.focus_active); + + restore_env_var("JCODE_SCROLLWM", prev_enabled); + restore_env_var("JCODE_SCROLLWM_ARRANGE_ON_SPAWN", prev_arrange); +} + #[test] fn test_env_override_swarm_model() { let _guard = crate::storage::lock_test_env(); diff --git a/crates/jcode-config-types/src/lib.rs b/crates/jcode-config-types/src/lib.rs index 0c7e94bb80..258406dd3e 100644 --- a/crates/jcode-config-types/src/lib.rs +++ b/crates/jcode-config-types/src/lib.rs @@ -409,6 +409,40 @@ pub struct AgentsConfig { pub memory_model: Option<String>, /// Whether memory should use the sidecar for relevance/extraction. pub memory_sidecar_enabled: bool, + /// ScrollWM window-manager integration for headed swarm spawns (macOS). + pub scrollwm: ScrollwmIntegrationConfig, +} + +/// ScrollWM (macOS scrolling window manager) integration settings for headed +/// swarm spawns. All best-effort: when ScrollWM is absent every option is a +/// no-op and never affects spawning. +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] +#[serde(default)] +pub struct ScrollwmIntegrationConfig { + /// Master switch. When true and ScrollWM is running, jcode drives it after + /// spawning a headed agent (e.g. focus the new agent's strip column). + /// Default `false` (opt-in) so jcode never touches the window manager unless + /// the user asks. + pub enabled: bool, + /// After spawning a headed agent, focus its column in the strip (matched by + /// the agent's unique session name in the window title). Default `true`, + /// only meaningful when `enabled`. + pub focus_active: bool, + /// Call `arrange` to adopt the agent windows into the strip when ScrollWM is + /// not already managing. WARNING: `arrange` adopts the *entire* current + /// Space, so this is `false` by default; jcode otherwise relies on + /// ScrollWM's auto-adopt of new windows while it is already managing. + pub arrange_on_spawn: bool, +} + +impl Default for ScrollwmIntegrationConfig { + fn default() -> Self { + Self { + enabled: false, + focus_active: true, + arrange_on_spawn: false, + } + } } /// How swarm-created agents should be spawned. From 8055c8c064237abc2594b1d37d000f5582110aad Mon Sep 17 00:00:00 2001 From: jcode <jcode@local> Date: Thu, 25 Jun 2026 20:34:10 -0700 Subject: [PATCH 17/18] docs(scrollwm): cross-repo CONTRACT.md + plan status; fix loopback test flake MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - docs/scrollwm-integration/CONTRACT.md points at the canonical scrollwm/docs/INTEGRATION.md and records jcode's client/config/onboarding surface - PLAN.md marks all five workstreams shipped - jcode-scrollwm loopback tests: use short /tmp paths + atomic counter so parallel binds never collide (macOS sun_path 104-byte limit) — 9/9 stable --- crates/jcode-scrollwm/src/lib.rs | 18 +++++----- docs/scrollwm-integration/CONTRACT.md | 49 +++++++++++++++++++++++++++ docs/scrollwm-integration/PLAN.md | 13 +++++++ 3 files changed, 72 insertions(+), 8 deletions(-) create mode 100644 docs/scrollwm-integration/CONTRACT.md diff --git a/crates/jcode-scrollwm/src/lib.rs b/crates/jcode-scrollwm/src/lib.rs index fa986c2456..0a7dd3b880 100644 --- a/crates/jcode-scrollwm/src/lib.rs +++ b/crates/jcode-scrollwm/src/lib.rs @@ -478,15 +478,17 @@ mod loopback_tests { /// connection, so we can exercise the real connect/write/read path without a /// live ScrollWM. Returns the socket path; the listener thread self-cleans. fn spawn_echo_server(reply: &'static str) -> PathBuf { - let dir = std::env::temp_dir(); - let path = dir.join(format!( - "jcode-scrollwm-loopback-{}-{}.sock", + // Keep the path SHORT: macOS sockaddr_un.sun_path is capped at 104 + // bytes, and the default temp dir (/var/folders/.../T/) is already long. + // Use /tmp + a process-unique atomic counter so parallel tests never + // collide and never overflow the limit. + use std::sync::atomic::{AtomicU32, Ordering}; + static COUNTER: AtomicU32 = AtomicU32::new(0); + let n = COUNTER.fetch_add(1, Ordering::Relaxed); + let path = PathBuf::from(format!( + "/tmp/jcode-sw-lb-{}-{}.sock", std::process::id(), - // cheap unique-ish suffix - std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .unwrap() - .as_nanos() + n )); let _ = std::fs::remove_file(&path); let listener = UnixListener::bind(&path).expect("bind loopback socket"); diff --git a/docs/scrollwm-integration/CONTRACT.md b/docs/scrollwm-integration/CONTRACT.md new file mode 100644 index 0000000000..9170fc82ef --- /dev/null +++ b/docs/scrollwm-integration/CONTRACT.md @@ -0,0 +1,49 @@ +# jcode <-> ScrollWM integration contract (jcode side) + +The **canonical** wire contract lives in the ScrollWM repo: +<https://github.com/1jehuang/scrollwm/blob/main/docs/INTEGRATION.md> + +This file is the jcode-side pointer + the constants/decisions jcode commits to. + +## What jcode ships + +- **`crates/jcode-scrollwm`** - a small, blocking, best-effort Rust client for + ScrollWM's Unix control socket (the Rust counterpart to ScrollWM's Swift + `ControlClient.send`). Detects ScrollWM (`is_running`, `hello`), reads the + strip (`status`), and drives it (`arrange`, `focus_index`, `focus_title`). +- **`agents.scrollwm` config** (in `jcode-config-types`) - the opt-in switch for + using ScrollWM when spawning headed swarm agents: + - `enabled` (default `false`) + - `focus_active` (default `true`) - focus the just-spawned agent's column + - `arrange_on_spawn` (default `false`) - adopt the whole Space (use with care) + - env overrides: `JCODE_SCROLLWM`, `JCODE_SCROLLWM_FOCUS_ACTIVE`, + `JCODE_SCROLLWM_ARRANGE_ON_SPAWN` +- **Onboarding opt-in** - a one-time "Set up ScrollWM?" step installs ScrollWM + via its web installer (`crates/jcode-tui` onboarding flow). macOS only. + +## Compatibility stance + +- jcode gates the integration on `ScrollWm::is_running()` (ping), and treats a + missing/old ScrollWM as simply absent: every control call is fire-and-log, so + ScrollWM never affects jcode functionality. +- jcode reads the `version`/`protocol`/`capabilities` handshake (ScrollWM + protocol >= 1) but currently only uses verbs present since protocol 0 + (`status`, `arrange`, `focus`), so it works against any ScrollWM that exposes + the socket. New ScrollWM verbs (`focus-title`, `arrange-pids`, ...) will be + gated on `capabilities.contains(...)` as jcode adopts them. + +## Where the runtime coupling lives in jcode + +- Detection + drive: `jcode-scrollwm` (client) + + `jcode-app-core/src/server/comm_session.rs::maybe_reconcile_scrollwm_after_spawn` + (the hook after a headed agent spawns). +- Config: `jcode-config-types::ScrollwmIntegrationConfig`, + env overrides in `jcode-base/src/config/env_overrides.rs`. +- Onboarding: `jcode-tui/src/tui/app/onboarding_flow*.rs`. + +## Distribution link + +The two products ship independently but are bundled in the +`1jehuang/homebrew-jstack` tap: the `jstack` cask installs jcode and +`depends_on cask: scrollwm`. No build-time dependency exists between the repos; +the only shared surface is the wire contract above. diff --git a/docs/scrollwm-integration/PLAN.md b/docs/scrollwm-integration/PLAN.md index 2ff621b670..77053dd89b 100644 --- a/docs/scrollwm-integration/PLAN.md +++ b/docs/scrollwm-integration/PLAN.md @@ -5,6 +5,19 @@ yours: `1jehuang/jcode` (Rust TUI agent) and `1jehuang/scrollwm` (Swift macOS scrolling WM). They already ship together in the `1jehuang/homebrew-jstack` tap via a `jstack` bundle cask. +## Status (all workstreams shipped) + +| WS | What | State | +|----|------|-------| +| WS1 | Onboarding "Searched, not found" list + scrolling | ✅ jcode, tested + visually verified | +| WS2 | Onboarding "Set up ScrollWM?" install opt-in | ✅ jcode, tested + visually verified | +| WS3 | `jcode-scrollwm` control client crate | ✅ jcode, 9 tests incl loopback; live handshake verified | +| WS4 | Headed swarm spawn -> ScrollWM focus (opt-in) | ✅ jcode, config + env + tests | +| WS5 | ScrollWM `version` handshake + `INTEGRATION.md` + conformance test | ✅ scrollwm repo | + +The canonical wire contract is `scrollwm/docs/INTEGRATION.md`; the jcode-side +pointer is `CONTRACT.md` in this folder. + ## The vision ```mermaid From 1677d567af831bec5cfb30c66a5cad7ca9975135 Mon Sep 17 00:00:00 2001 From: jcode <jcode@local> Date: Thu, 25 Jun 2026 20:34:40 -0700 Subject: [PATCH 18/18] docs(readme): document ScrollWM window-management integration for headed swarms --- README.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/README.md b/README.md index 34bb4fc6f4..43a45dfc13 100644 --- a/README.md +++ b/README.md @@ -317,6 +317,12 @@ Spawn two or more agents in the same repo, and they will automatically be manage Agents are also able to spawn their own swarms autonomously. They have a swarm tool which allows them to spawn in their own teamates to accomplish tasks in parallel. Doing so turns the main agent into a coordinator and the spawned agents into workers. Groups of agents, their messaging channels, their completion statuses, etc are all automatically managed. This can be done headlessly or headed. +### Window management on macOS: ScrollWM + +When you spawn **headed** swarm agents on macOS, jcode can tile them with [ScrollWM](https://github.com/1jehuang/scrollwm), a scrolling window manager (PaperWM-style). With `agents.scrollwm.enabled = true` (and ScrollWM running), jcode focuses each freshly spawned agent's window in the scrolling strip so a wall of agent terminals stays navigable. It's best-effort: a no-op when ScrollWM isn't installed, and it never rearranges your desktop unless you opt into `arrange_on_spawn`. + +jcode's first-run onboarding offers to install ScrollWM for you (one permission: Accessibility). Or install both at once: `brew install --cask 1jehuang/jstack/jstack`. See [`docs/scrollwm-integration/CONTRACT.md`](docs/scrollwm-integration/CONTRACT.md) for the integration details. + --- ## OAuth and Providers