diff --git a/Cargo.lock b/Cargo.lock index 06f4b563cb..00b34eac00 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", @@ -3438,6 +3439,7 @@ dependencies = [ "jcode-protocol", "jcode-provider-core", "jcode-provider-metadata", + "jcode-scrollwm", "jcode-selfdev-types", "jcode-session-types", "jcode-setup-hints", @@ -3965,6 +3967,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" @@ -3991,9 +4003,11 @@ dependencies = [ "dirs", "global-hotkey", "jcode-build-meta", + "jcode-config-types", "jcode-logging", "jcode-storage", "serde", + "serde_json", "tempfile", ] 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/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 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/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, + /// Every source family we probed, with its presence flag. + pub targets: Vec, +} + +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, 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 { + 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 { + 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> { 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-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-app-core/src/tool/computer/ax.rs b/crates/jcode-app-core/src/tool/computer/ax.rs index d1ae8c2e1b..653c424223 100644 --- a/crates/jcode-app-core/src/tool/computer/ax.rs +++ b/crates/jcode-app-core/src/tool/computer/ax.rs @@ -289,40 +289,54 @@ 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 result 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 result 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 result to my hit(child, px, py, idxPath & "." & i, result) + 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 result + return bestHit end hit end using terms from @@ -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/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/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}"); + } } diff --git a/crates/jcode-app-core/src/tool/computer/sys.rs b/crates/jcode-app-core/src/tool/computer/sys.rs index d1f091b962..ee56c6504e 100644 --- a/crates/jcode-app-core/src/tool/computer/sys.rs +++ b/crates/jcode-app-core/src/tool/computer/sys.rs @@ -67,7 +67,10 @@ 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 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" @@ -80,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)) @@ -91,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, 10) + return my dumpEl(front window of frontApp, 0, 8) on error return "" end try @@ -100,14 +106,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)); } } 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 ); 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, +} + #[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-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, /// 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. 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..0a7dd3b880 --- /dev/null +++ b/crates/jcode-scrollwm/src/lib.rs @@ -0,0 +1,542 @@ +//! A small, dependency-light client for the **ScrollWM** control plane. +//! +//! ScrollWM (a macOS scrolling window manager, ) +//! 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: `" [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) -> 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 { + 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 { + match self.send("version") { + Ok(reply) => serde_json::from_str::(&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 { + let reply = self.send("status")?; + serde_json::from_str::(&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 { + 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 { + self.send(&format!("focus {one_based}")) + } + + /// Focus the next / previous column. + pub fn focus_next(&self) -> Result { + self.send("focus next") + } + + /// Focus the previous column. + pub fn focus_prev(&self) -> Result { + 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 { + 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 { + 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 { + 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, +} + +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, + /// Current vertical workspace (1-based), when managing. + #[serde(default)] + pub workspace: Option, + /// Total vertical workspace count, when managing. + #[serde(default, rename = "workspaceCount")] + pub workspace_count: Option, + /// The columns of the active strip, left to right. + #[serde(default)] + pub columns: Vec, +} + +/// 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 { + 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 { + // 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(), + n + )); + 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); + } +} diff --git a/crates/jcode-setup-hints/Cargo.toml b/crates/jcode-setup-hints/Cargo.toml index 7b7356c7d4..2bb23e01ed 100644 --- a/crates/jcode-setup-hints/Cargo.toml +++ b/crates/jcode-setup-hints/Cargo.toml @@ -12,9 +12,11 @@ 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"] } +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..49f515114e --- /dev/null +++ b/crates/jcode-setup-hints/src/keymap/chord.rs @@ -0,0 +1,269 @@ +//! 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("+") + } + + /// 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. + 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()); + } + + #[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..59686968d6 --- /dev/null +++ b/crates/jcode-setup-hints/src/keymap/conflicts.rs @@ -0,0 +1,377 @@ +//! 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 +} + +/// 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::*; + 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") + ); + } + + #[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/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..65d1c73ca6 --- /dev/null +++ b/crates/jcode-setup-hints/src/keymap/mod.rs @@ -0,0 +1,192 @@ +//! 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 conflicts; +pub mod macos_hotkeys; +pub mod report; +pub mod source; +pub mod terminal; + +pub use chord::KeyChord; +pub use conflicts::{Conflict, JcodeBinding, conflict_signature, detect_conflicts, jcode_bindings}; +pub use report::{render_report, render_status_line}; +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() +} + +/// 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::*; + + #[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/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 new file mode 100644 index 0000000000..ef54a6ba95 --- /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, Hash, 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..ed24eb325d 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"))] @@ -64,6 +66,22 @@ 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, + /// 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. @@ -704,6 +722,91 @@ 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. +/// +/// 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 mut state = SetupHintsState::load(); + 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 conflict_hint_decision(&signature, &state.keymap_conflict_signature) { + ConflictHintDecision::Unchanged => (None, false), + ConflictHintDecision::ResolvedSilently => { + state.keymap_conflict_signature = signature; + (None, true) + } + ConflictHintDecision::Warn => { + state.keymap_conflict_signature = signature; + 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) + } + } +} + /// Manual `jcode setup-launcher` command. pub fn run_setup_launcher() -> Result<()> { #[cfg(target_os = "macos")] diff --git a/crates/jcode-setup-hints/src/setup_hints_tests.rs b/crates/jcode-setup-hints/src/setup_hints_tests.rs index 62af9ad5fa..715ad186a8 100644 --- a/crates/jcode-setup-hints/src/setup_hints_tests.rs +++ b/crates/jcode-setup-hints/src/setup_hints_tests.rs @@ -213,3 +213,82 @@ 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 + ); +} + +#[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()); +} diff --git a/crates/jcode-tui/src/tui/app.rs b/crates/jcode-tui/src/tui/app.rs index 926db0991c..b1d3fcf12e 100644 --- a/crates/jcode-tui/src/tui/app.rs +++ b/crates/jcode-tui/src/tui/app.rs @@ -817,6 +817,14 @@ pub struct App { /// Active guided first-run onboarding flow (model select -> continue -> /// transcript pick -> suggestions). `None` when not onboarding. onboarding_flow: Option, + /// 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, + /// 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, /// 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/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/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 929eac0219..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. @@ -249,6 +262,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, } impl OnboardingFlow { @@ -258,6 +277,7 @@ impl OnboardingFlow { pub(crate) fn begin() -> Self { Self { phase: OnboardingPhase::ModelSelect, + login_not_found: Vec::new(), } } @@ -266,7 +286,10 @@ 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) -> Self { + pub(crate) fn begin_at_login_with_not_found( + import: Option, + login_not_found: Vec, + ) -> Self { let phase = match import { Some(review) => OnboardingPhase::Login { import: Some(review), @@ -275,7 +298,10 @@ impl OnboardingFlow { yes_highlighted: true, }, }; - Self { phase } + Self { + phase, + login_not_found, + } } /// Whether the flow is actively driving the UI. @@ -295,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, } } @@ -309,6 +340,9 @@ impl OnboardingFlow { OnboardingPhase::ContinuePrompt { shown_at, .. } => { shown_at.elapsed() >= DECISION_TIMEOUT } + OnboardingPhase::ScrollWmOptIn { shown_at, .. } => { + shown_at.elapsed() >= DECISION_TIMEOUT + } _ => false, } } @@ -365,6 +399,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 +413,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 +429,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..8f40a2e4a0 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::>(); + (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 @@ -340,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, } } @@ -418,6 +464,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 +482,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 +499,115 @@ 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 "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" @@ -741,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; } @@ -764,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. @@ -1246,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; + } _ => {} } @@ -1254,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 = 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 5f69f0bd3e..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 @@ -1387,6 +1390,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..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 @@ -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)", @@ -1147,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 = 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| { @@ -1161,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, @@ -1182,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, } } @@ -1197,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 467e67acfe..fc979e8088 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,222 @@ 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 = (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 = (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); +} + +#[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 dbccf193a9..9972b21b3e 100644 --- a/crates/jcode-tui/src/tui/app/tui_lifecycle.rs +++ b/crates/jcode-tui/src/tui/app/tui_lifecycle.rs @@ -358,6 +358,8 @@ impl App { startup_submit_deferred_reason: None, 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(), @@ -736,6 +738,8 @@ impl App { startup_submit_deferred_reason: None, 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 a9e445c90f..db02599235 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 }, + Login { + import: Option, + /// Credential families we probed but did not find, rendered as a + /// scrollable "Searched, not found" panel beneath the decision row. + not_found: Vec, + /// 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, + /// Current scroll offset (in lines) into the not-found panel. + not_found_scroll: u16, + }, /// "Continue where you left off in ?" with a highlightable Yes/No /// selector and a live decision countdown (seconds remaining). ContinuePrompt { @@ -658,10 +672,41 @@ 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, + }, /// 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)] +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..48441451fb 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> { + if rows.is_empty() { + return Vec::new(); + } + let align = Alignment::Center; + let dim = Style::default().fg(dim_color()); + let mut lines: Vec> = 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> { let align = Alignment::Center; @@ -78,7 +162,11 @@ fn welcome_body_lines(app: &dyn TuiState) -> Vec> { 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> { ); } } + 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> { )) .alignment(align), ); + lines.extend(not_found_panel_lines(¬_found, not_found_scroll)); return lines; } OnboardingWelcomeKind::ContinuePrompt { @@ -304,6 +398,124 @@ fn welcome_body_lines(app: &dyn TuiState) -> Vec> { ); 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_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( 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, compacted_hidden_user_prompts: usize, reasoning_retained: Option, 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 { 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..d2c81e7142 100644 --- a/crates/jcode-tui/src/tui/ui_tests/onboarding.rs +++ b/crates/jcode-tui/src/tui/ui_tests/onboarding.rs @@ -107,3 +107,109 @@ 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 = (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}" + ); +} + + + +#[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}" + ); +} 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. 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/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: + + +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 new file mode 100644 index 0000000000..77053dd89b --- /dev/null +++ b/docs/scrollwm-integration/PLAN.md @@ -0,0 +1,113 @@ +# 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. + +## 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 +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. 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");