diff --git a/crates/maestro-cli/src/sessions.rs b/crates/maestro-cli/src/sessions.rs index 2b83457..cac1729 100644 --- a/crates/maestro-cli/src/sessions.rs +++ b/crates/maestro-cli/src/sessions.rs @@ -251,6 +251,7 @@ mod tests { activity_since: chrono::Utc::now(), current_cwd: None, blocked_work: None, + last_focused_at: None, } } diff --git a/crates/maestro-daemon/src/session.rs b/crates/maestro-daemon/src/session.rs index ecfea91..d59f92e 100644 --- a/crates/maestro-daemon/src/session.rs +++ b/crates/maestro-daemon/src/session.rs @@ -285,33 +285,50 @@ impl SessionManager { /// Subscribe to a session's PTY broadcast and return the replay buffer /// (NAP-229). Callers send the buffer before pumping the live receiver /// so freshly-attached clients see the last screenful immediately. + /// + /// Opening an attach is focusing (#145): it stamps + /// [`Session::last_focused_at`], the same field `set_focused` stamps, so + /// a client that only ever attaches sorts identically to one that + /// reports focus explicitly. pub async fn subscribe_pty_with_replay( &self, id: Uuid, ) -> Option<(Vec, broadcast::Receiver>)> { let tmux = self.inner.tmux.clone(); - let sessions = self.inner.sessions.lock().await; - let rec = sessions.get(&id)?; - // For a live session, tmux's own screen beats the byte tail: the tail - // starts mid-stream, so a full-screen program (an agent TUI) replayed - // from it renders as garbage until something forces a repaint. A dead - // session has no pane left to capture, so it keeps the byte tail. - let alive = rec.session.status == SessionStatus::Running; - // The capture shells out to tmux while the replay lock is held, which - // parks the reader for the length of one tmux round-trip. That only - // happens on attach, and the alternative is a seam in the stream. - Some(rec.bridge.subscribe_with_snapshot(|| { - if !alive { - return None; - } - match tmux.capture_pane(id) { - Ok(snapshot) => snapshot, - Err(e) => { - tracing::warn!(error = ?e, %id, "capture-pane failed; replaying raw bytes"); - None + let (result, updated) = { + let mut sessions = self.inner.sessions.lock().await; + let rec = sessions.get_mut(&id)?; + rec.session.last_focused_at = Some(Utc::now()); + let updated = rec.session.clone(); + // For a live session, tmux's own screen beats the byte tail: the + // tail starts mid-stream, so a full-screen program (an agent TUI) + // replayed from it renders as garbage until something forces a + // repaint. A dead session has no pane left to capture, so it + // keeps the byte tail. + let alive = rec.session.status == SessionStatus::Running; + // The capture shells out to tmux while the replay lock is held, + // which parks the reader for the length of one tmux round-trip. + // That only happens on attach, and the alternative is a seam in + // the stream. + let result = rec.bridge.subscribe_with_snapshot(|| { + if !alive { + return None; } - } - })) + match tmux.capture_pane(id) { + Ok(snapshot) => snapshot, + Err(e) => { + tracing::warn!(error = ?e, %id, "capture-pane failed; replaying raw bytes"); + None + } + } + }); + (result, updated) + }; + let _ = self + .inner + .events + .send(CatalogEvent::SessionUpdated { session: updated }); + Some(result) } pub async fn has(&self, id: Uuid) -> bool { @@ -448,6 +465,7 @@ impl SessionManager { // The watcher fills this in on its first blocked-work pass; a // fresh tree has nothing in the way anyway. blocked_work: None, + last_focused_at: None, }; if let Err(e) = self.inner.sidecar.write(&session) { @@ -652,6 +670,7 @@ impl SessionManager { current_cwd: None, blocked_work: None, + last_focused_at: None, }, }; let fifo_path = self.inner.fifo_dir.join(format!("{id}.fifo")); @@ -838,11 +857,26 @@ impl SessionManager { /// Record whether a client's pane is currently showing `id`. Read by the /// activity pass's next tick to decide whether an `AwaitingInput` glow /// has been looked at long enough to clear (`apply_focus_suppression`). - /// An unknown session is [`SessionError::NotFound`]. + /// Gaining focus also stamps [`Session::last_focused_at`] (#145) — losing + /// it does not, since the command bar's ordering cares about the last + /// time the operator arrived, not when they left. An unknown session is + /// [`SessionError::NotFound`]. pub async fn set_focused(&self, id: Uuid, focused: bool) -> Result<(), SessionError> { - let mut sessions = self.inner.sessions.lock().await; - let rec = sessions.get_mut(&id).ok_or(SessionError::NotFound(id))?; - rec.focused_since = focused.then(std::time::Instant::now); + let updated = { + let mut sessions = self.inner.sessions.lock().await; + let rec = sessions.get_mut(&id).ok_or(SessionError::NotFound(id))?; + rec.focused_since = focused.then(std::time::Instant::now); + if focused { + rec.session.last_focused_at = Some(Utc::now()); + } + focused.then(|| rec.session.clone()) + }; + if let Some(session) = updated { + let _ = self + .inner + .events + .send(CatalogEvent::SessionUpdated { session }); + } Ok(()) } } diff --git a/crates/maestro-daemon/tests/local_api.rs b/crates/maestro-daemon/tests/local_api.rs index d46622c..9861796 100644 --- a/crates/maestro-daemon/tests/local_api.rs +++ b/crates/maestro-daemon/tests/local_api.rs @@ -383,6 +383,41 @@ async fn patch_unknown_session_returns_404() { assert_eq!(resp.status(), reqwest::StatusCode::NOT_FOUND); } +/// #145: `POST /.../focus` with `focused: true` over HTTP stamps +/// `last_focused_at`, observable on the next `GET /api/sessions`. +#[tokio::test] +async fn post_focus_stamps_last_focused_at() { + let h = spawn("naps-laptop").await; + let session = h + .manager + .create(CreateSessionRequest { + cwd: "/tmp".into(), + command: "sleep 60".into(), + env: HashMap::new(), + metadata: HashMap::new(), + worktree: None, + }) + .await + .unwrap(); + assert_eq!(session.last_focused_at, None); + + let resp = reqwest::Client::new() + .post(url(h.addr, &format!("/api/sessions/{}/focus", session.id))) + .json(&serde_json::json!({ "focused": true })) + .send() + .await + .unwrap(); + assert_eq!(resp.status(), reqwest::StatusCode::NO_CONTENT); + + let sessions: Vec = reqwest::get(url(h.addr, "/api/sessions")) + .await + .unwrap() + .json() + .await + .unwrap(); + assert!(sessions[0].last_focused_at.is_some()); +} + #[tokio::test] async fn delete_owned_session_kills_it() { let h = spawn("naps-laptop").await; diff --git a/crates/maestro-daemon/tests/session_manager.rs b/crates/maestro-daemon/tests/session_manager.rs index 49c6fd3..650dc34 100644 --- a/crates/maestro-daemon/tests/session_manager.rs +++ b/crates/maestro-daemon/tests/session_manager.rs @@ -1091,3 +1091,109 @@ async fn spawned_sessions_export_maestro_session_id_and_url() { fx.manager.kill(s.id, false).await.expect("kill"); } + +/// docs/COMMAND-BAR.md, empty-query ordering: a session that has never been +/// focused carries no `last_focused_at` at all, which is what lets a client +/// sort it after every session that has one (#145). +#[tokio::test] +async fn a_freshly_created_session_has_never_been_focused() { + let fx = DaemonFixture::new(); + let cwd = TempDir::new().unwrap(); + + let s = fx + .manager + .create(CreateSessionRequest { + cwd: cwd.path().to_path_buf(), + command: "sleep 30".into(), + env: HashMap::new(), + metadata: HashMap::new(), + worktree: None, + }) + .await + .expect("create"); + + assert_eq!(s.last_focused_at, None); + assert_eq!(fx.manager.snapshot().await[0].last_focused_at, None); +} + +/// #145: `POST /.../focus` reporting `focused: true` stamps +/// `last_focused_at` and announces it on the event stream; reporting +/// `focused: false` (losing focus) leaves the stamp alone, since the bar +/// orders by when the operator last arrived, not when they left. +#[tokio::test] +async fn set_focused_true_stamps_last_focused_at() { + let fx = DaemonFixture::new(); + let cwd = TempDir::new().unwrap(); + + let s = fx + .manager + .create(CreateSessionRequest { + cwd: cwd.path().to_path_buf(), + command: "sleep 30".into(), + env: HashMap::new(), + metadata: HashMap::new(), + worktree: None, + }) + .await + .expect("create"); + + let mut events = fx.manager.events(); + let before = chrono::Utc::now(); + fx.manager.set_focused(s.id, true).await.expect("focus"); + + let updated = fx.manager.snapshot().await[0].clone(); + let stamp = updated.last_focused_at.expect("stamped on focus"); + assert!(stamp >= before); + + let mut saw_update = false; + while let Ok(event) = tokio::time::timeout(Duration::from_millis(200), events.recv()).await { + if let Ok(CatalogEvent::SessionUpdated { session }) = event { + if session.id == s.id && session.last_focused_at == Some(stamp) { + saw_update = true; + break; + } + } + } + assert!(saw_update, "expected a SessionUpdated carrying the stamp"); + + fx.manager.set_focused(s.id, false).await.expect("unfocus"); + let after_unfocus = fx.manager.snapshot().await[0].clone(); + assert_eq!( + after_unfocus.last_focused_at, + Some(stamp), + "losing focus must not clear the last-focused stamp" + ); +} + +/// #145: opening `/api/attach/:id` is focusing too — it must stamp the same +/// field `set_focused` does, so a client that only ever attaches sorts +/// identically to one that reports focus explicitly. +#[tokio::test] +async fn attaching_stamps_last_focused_at() { + let fx = DaemonFixture::new(); + let cwd = TempDir::new().unwrap(); + + let s = fx + .manager + .create(CreateSessionRequest { + cwd: cwd.path().to_path_buf(), + command: "sleep 30".into(), + env: HashMap::new(), + metadata: HashMap::new(), + worktree: None, + }) + .await + .expect("create"); + assert_eq!(s.last_focused_at, None); + + let before = chrono::Utc::now(); + let (_replay, _rx) = fx + .manager + .subscribe_pty_with_replay(s.id) + .await + .expect("known session"); + + let updated = fx.manager.snapshot().await[0].clone(); + let stamp = updated.last_focused_at.expect("attach stamps focus"); + assert!(stamp >= before); +} diff --git a/crates/maestro-daemon/tests/sidecar.rs b/crates/maestro-daemon/tests/sidecar.rs index 0274844..2934d3a 100644 --- a/crates/maestro-daemon/tests/sidecar.rs +++ b/crates/maestro-daemon/tests/sidecar.rs @@ -31,6 +31,7 @@ fn sample(id: Uuid) -> Session { activity_since: chrono::Utc::now(), current_cwd: Some(PathBuf::from("/tmp/work/crates")), blocked_work: None, + last_focused_at: None, } } diff --git a/crates/maestro-protocol/src/display.rs b/crates/maestro-protocol/src/display.rs index fe2deda..474949d 100644 --- a/crates/maestro-protocol/src/display.rs +++ b/crates/maestro-protocol/src/display.rs @@ -248,6 +248,7 @@ mod tests { activity_since: Utc::now(), current_cwd: None, blocked_work: None, + last_focused_at: None, } } diff --git a/crates/maestro-protocol/src/lib.rs b/crates/maestro-protocol/src/lib.rs index 696a28f..68fbeab 100644 --- a/crates/maestro-protocol/src/lib.rs +++ b/crates/maestro-protocol/src/lib.rs @@ -108,6 +108,14 @@ pub struct Session { /// from daemons that predate the field stay forward-compatible. #[serde(default)] pub blocked_work: Option, + /// Wall-clock time this session last became the pane a client was + /// showing (#145) — stamped by both `POST /.../focus` and opening + /// `/api/attach/:id`, since either one is the operator moving there. + /// `None` until the session has been focused at least once, which is + /// also how the command bar's empty-query ordering (`docs/COMMAND-BAR.md`) + /// sorts a never-focused session after every session that has one. + #[serde(default)] + pub last_focused_at: Option>, } /// Default for [`Session::last_activity_at`] when an older payload omits it. @@ -847,6 +855,7 @@ mod tests { .with_timezone(&Utc), current_cwd: Some(PathBuf::from("/home/naps62/projects/maestro/crates")), blocked_work: None, + last_focused_at: None, } } @@ -896,6 +905,28 @@ mod tests { ); } + #[test] + fn last_focused_at_defaults_to_none_on_legacy_payload() { + // Pre-#145 payload omits the field; `None` means never-focused. + let json = serde_json::json!({ + "id": Uuid::from_u128(1), + "cwd": "/tmp", + "command": "bash", + "status": "Running", + "created_at": "2026-05-24T12:00:00Z" + }); + let s: Session = serde_json::from_value(json).unwrap(); + assert_eq!(s.last_focused_at, None); + + let mut focused = sample_session(); + focused.last_focused_at = Some( + DateTime::parse_from_rfc3339("2026-05-24T12:06:00Z") + .unwrap() + .with_timezone(&Utc), + ); + assert_eq!(focused, round_trip(&focused)); + } + #[test] fn activity_since_defaults_to_epoch_on_legacy_payload() { // A daemon predating #102 omits `activity_since`; it must deserialize diff --git a/crates/maestro-testing/src/lib.rs b/crates/maestro-testing/src/lib.rs index 0a1c535..5a192fa 100644 --- a/crates/maestro-testing/src/lib.rs +++ b/crates/maestro-testing/src/lib.rs @@ -46,6 +46,7 @@ pub fn session(id: Uuid, cwd: &str, command: &str) -> Session { activity_since: chrono::Utc::now(), current_cwd: None, blocked_work: None, + last_focused_at: None, } } diff --git a/crates/maestro-tui/src/app.rs b/crates/maestro-tui/src/app.rs index 0870265..d8b2092 100644 --- a/crates/maestro-tui/src/app.rs +++ b/crates/maestro-tui/src/app.rs @@ -1665,6 +1665,7 @@ mod tests { activity_since: chrono::Utc::now(), current_cwd: None, blocked_work: None, + last_focused_at: None, }) .collect(), }); @@ -1845,6 +1846,7 @@ mod tests { activity_since: chrono::Utc::now(), current_cwd: None, blocked_work: None, + last_focused_at: None, }], }); app.dispatch(Action::SidebarNext); @@ -1871,6 +1873,7 @@ mod tests { activity_since: Utc::now(), current_cwd: None, blocked_work: None, + last_focused_at: None, } } @@ -2732,6 +2735,7 @@ mod tests { current_cwd: None, blocked_work: None, + last_focused_at: None, }], }); app.dispatch(Action::KillHighlighted); @@ -2766,6 +2770,7 @@ mod tests { activity_since: Utc::now(), current_cwd: None, blocked_work: None, + last_focused_at: None, }], }); } diff --git a/crates/maestro-tui/src/catalog.rs b/crates/maestro-tui/src/catalog.rs index ec589b2..36f39d5 100644 --- a/crates/maestro-tui/src/catalog.rs +++ b/crates/maestro-tui/src/catalog.rs @@ -222,6 +222,7 @@ mod tests { activity_since: chrono::Utc::now(), current_cwd: None, blocked_work: None, + last_focused_at: None, } } diff --git a/crates/maestro-tui/src/sidebar.rs b/crates/maestro-tui/src/sidebar.rs index 6689ae2..b217dd1 100644 --- a/crates/maestro-tui/src/sidebar.rs +++ b/crates/maestro-tui/src/sidebar.rs @@ -461,6 +461,7 @@ mod tests { activity_since: Utc::now(), current_cwd: None, blocked_work: None, + last_focused_at: None, } } @@ -1123,6 +1124,7 @@ mod tests { activity_since: Utc::now(), current_cwd: None, blocked_work: None, + last_focused_at: None, }; let sb = Sidebar { sessions: vec![s], diff --git a/web/src/attach.ts b/web/src/attach.ts index e85be1a..0dc1492 100644 --- a/web/src/attach.ts +++ b/web/src/attach.ts @@ -5,6 +5,7 @@ import "@xterm/xterm/css/xterm.css"; import { attachWsUrl } from "./routes.js"; import { FOCUS_SIDEBAR_EVENT } from "./catalog.js"; +import { CommandSet, defineCommands } from "./commands.js"; import { escapeHtml } from "./html.js"; import { INITIAL_RECONNECT_MS, MAX_RECONNECT_MS } from "./reconnect.js"; import { applyModsToChar, cursorSeq, tildeSeq } from "./keys.js"; @@ -13,6 +14,15 @@ import { splitCommand, toolIconSvg } from "./toolIcon.js"; import type { ClientControl, Uuid } from "./types.js"; import { WebglPolicy } from "./webglRenderer.js"; +// The pane's half of the command registry (#148): plain Escape hands the +// keyboard back to the session list, the pair to the list's Tab. +const paneKeys = new CommandSet( + defineCommands({ + "pane.focusList": () => document.dispatchEvent(new CustomEvent(FOCUS_SIDEBAR_EVENT)), + }), + { preventDefault: false }, +); + /** One budget for the whole page — see webglRenderer.ts for why contexts are * rationed rather than handed to every connection. */ const webglPolicy = new WebglPolicy(); @@ -56,8 +66,6 @@ export interface AttachOptions { shouldReconnect?: (sessionId: Uuid, code: number) => boolean; } - - /** What the header shows about the session (name / command). Optional — falls * back to a short session id when the session isn't known yet. */ export interface AttachInfo { @@ -210,12 +218,12 @@ export function createAttachConnection( } // Plain Escape hands the keyboard back to the session list instead of the - // shell/app. `false` tells xterm to skip its own handling of this event. + // shell/app. `false` tells xterm to skip its own handling of this event — + // which is also why the set doesn't `preventDefault`: swallowing here is + // xterm's return value, not the browser's. term.attachCustomKeyEventHandler((e) => { - if (e.type !== "keydown" || e.key !== "Escape") return true; - if (e.ctrlKey || e.altKey || e.metaKey || e.shiftKey) return true; - document.dispatchEvent(new CustomEvent(FOCUS_SIDEBAR_EVENT)); - return false; + if (e.type !== "keydown") return true; + return !paneKeys.handle(e); }); // Touch scrolling: xterm's built-in touch handling is jittery (drags crawl a diff --git a/web/src/catalog.ts b/web/src/catalog.ts index 8473a67..d248295 100644 --- a/web/src/catalog.ts +++ b/web/src/catalog.ts @@ -2,7 +2,8 @@ import { escapeHtml } from "./html.js"; import { ApiRequestError, getDaemonInfo, killSession, renameSession } from "./api.js"; import { openForceKillDialog } from "./killDialog.js"; import { openContextMenu } from "./contextMenu.js"; -import { isTypingTarget, toggleHelp } from "./help.js"; +import { toggleHelp } from "./help.js"; +import { CommandSet, defineCommands } from "./commands.js"; import { CatalogStore } from "./events.js"; import type { InTheWay, Session } from "./types.js"; import { @@ -669,24 +670,30 @@ export function renderSidebar(store: CatalogStore, opts: SidebarOptions = {}): S applyNav(); }; - treeHost.addEventListener("keydown", (e) => { - if (e.ctrlKey || e.altKey || e.metaKey) return; - // The inline rename field lives inside a row and owns its own keys. - if (isTypingTarget(e.target)) return; - if (e.key === "j" || e.key === "ArrowDown") moveNav(1); - else if (e.key === "k" || e.key === "ArrowUp") moveNav(-1); - else if (e.key === "Enter") activateNav(); - else if (e.key === "Tab") document.dispatchEvent(new CustomEvent(FOCUS_PANE_EVENT)); - else if (e.key === "c") toggleHighlightedFolder(); - else if (e.key === "x") { - const hit = highlightedSession(); - if (hit) void requestKill(hit.session, sessionName(hit.session)); - } else if (e.key === "r") { - const hit = highlightedSession(); - if (hit) beginRename(hit.row, hit.session); - } else return; - e.preventDefault(); - }); + // The session list's half of the command registry (#148). One set per + // sidebar, so a second sidebar (tests, hot reload) drives its own rows; the + // labels and chords still come from the one registry. + const listKeys = new CommandSet( + defineCommands({ + "list.next": () => moveNav(1), + "list.prev": () => moveNav(-1), + "list.activate": () => activateNav(), + "list.toggleFolder": () => toggleHighlightedFolder(), + "list.rename": () => { + const hit = highlightedSession(); + if (hit) beginRename(hit.row, hit.session); + }, + "list.kill": () => { + const hit = highlightedSession(); + if (hit) void requestKill(hit.session, sessionName(hit.session)); + }, + "list.focusPane": () => document.dispatchEvent(new CustomEvent(FOCUS_PANE_EVENT)), + }), + ); + + // The inline rename field lives inside a row and owns its own keys; the set's + // plain-key rule keeps this walk out of it. + treeHost.addEventListener("keydown", (e) => listKeys.handle(e)); /** Signature of what is currently on screen — see `treeSignature`. */ let renderedSignature: string | null = null; diff --git a/web/src/commands.test.ts b/web/src/commands.test.ts new file mode 100644 index 0000000..4bb658c --- /dev/null +++ b/web/src/commands.test.ts @@ -0,0 +1,236 @@ +// @vitest-environment happy-dom +import { describe, expect, it, vi } from "vitest"; + +import { + COMMAND_SPECS, + CommandSet, + DEFAULT_LEADER, + canonicalizeKey, + defineCommands, + isTypingTarget, + keyCaps, + publishedCommands, +} from "./commands.js"; + +function key(k: string, init: KeyboardEventInit = {}): KeyboardEvent { + return new KeyboardEvent("keydown", { key: k, cancelable: true, ...init }); +} + +function el(html: string): HTMLElement { + const d = document.createElement("div"); + d.innerHTML = html; + return d.firstElementChild as HTMLElement; +} + +/** Dispatch on a target, so `isTypingTarget` sees something other than null. */ +function pressOn(set: CommandSet, target: HTMLElement, k: string, init?: KeyboardEventInit): boolean { + const e = key(k, init); + Object.defineProperty(e, "target", { value: target }); + return set.handle(e); +} + +describe("command specs", () => { + it("has a unique id and at least one chord per command", () => { + const ids = COMMAND_SPECS.map((s) => s.id); + expect(new Set(ids).size).toBe(ids.length); + for (const spec of COMMAND_SPECS) { + expect(spec.keys.length).toBeGreaterThan(0); + expect(spec.label.length).toBeGreaterThan(0); + } + }); + + it("binds each chord once within a scope", () => { + const seen = new Set(); + for (const spec of COMMAND_SPECS) { + for (const k of spec.keys) { + const slot = `${spec.scope}:${k}`; + expect(seen.has(slot)).toBe(false); + seen.add(slot); + } + } + }); + + it("rejects a handler for an id that isn't in the registry", () => { + expect(() => defineCommands({ "nope.missing": () => {} })).toThrow(/unknown command id/); + }); + + it("carries every spec field onto the defined command", () => { + const [cmd] = defineCommands({ "spawn.open": () => {} }); + expect(cmd).toMatchObject({ id: "spawn.open", keys: ["n"], scope: "global" }); + }); +}); + +describe("canonicalizeKey", () => { + it("spells keys the way the TUI's InputKey::canonical does", () => { + expect(canonicalizeKey(key("n"))).toBe("n"); + expect(canonicalizeKey(key("N", { shiftKey: true }))).toBe("N"); + expect(canonicalizeKey(key("?", { shiftKey: true }))).toBe("?"); + expect(canonicalizeKey(key("a", { ctrlKey: true }))).toBe("ctrl-a"); + expect(canonicalizeKey(key("Escape"))).toBe("esc"); + expect(canonicalizeKey(key("Enter"))).toBe("enter"); + expect(canonicalizeKey(key("Tab"))).toBe("tab"); + expect(canonicalizeKey(key("Tab", { shiftKey: true }))).toBe("backtab"); + expect(canonicalizeKey(key("ArrowDown"))).toBe("down"); + }); + + it("folds a modified letter to lower case with an explicit shift-", () => { + expect(canonicalizeKey(key("H", { ctrlKey: true, shiftKey: true }))).toBe("ctrl-shift-h"); + expect(canonicalizeKey(key("h", { ctrlKey: true, shiftKey: true }))).toBe("ctrl-shift-h"); + }); + + it("has no canonical form for a modifier pressed alone", () => { + expect(canonicalizeKey(key("Control", { ctrlKey: true }))).toBeNull(); + expect(canonicalizeKey(key("Shift", { shiftKey: true }))).toBeNull(); + }); +}); + +describe("keyCaps", () => { + it("renders one cap per modifier and key", () => { + expect(keyCaps("ctrl-shift-h")).toEqual(["Ctrl", "Shift", "H"]); + expect(keyCaps("enter")).toEqual(["Enter"]); + expect(keyCaps("j")).toEqual(["j"]); + expect(keyCaps("N")).toEqual(["N"]); + expect(keyCaps("?")).toEqual(["?"]); + expect(keyCaps("ctrl-a ctrl-k")).toEqual(["Ctrl", "A", "then", "Ctrl", "K"]); + }); +}); + +describe("CommandSet", () => { + it("runs the command bound to a chord, and swallows the browser default", () => { + const run = vi.fn(); + const set = new CommandSet(defineCommands({ "spawn.open": run })); + const e = key("n"); + expect(set.handle(e)).toBe(true); + expect(run).toHaveBeenCalledTimes(1); + expect(e.defaultPrevented).toBe(true); + }); + + it("leaves an unbound key alone", () => { + const set = new CommandSet(defineCommands({ "spawn.open": vi.fn() })); + const e = key("q"); + expect(set.handle(e)).toBe(false); + expect(e.defaultPrevented).toBe(false); + }); + + it("treats a modified key as a different binding", () => { + const run = vi.fn(); + const set = new CommandSet(defineCommands({ "spawn.open": run })); + expect(set.handle(key("n", { ctrlKey: true }))).toBe(false); + expect(set.handle(key("n", { metaKey: true }))).toBe(false); + expect(run).not.toHaveBeenCalled(); + }); + + it("runs every alias of a command", () => { + const run = vi.fn(); + const set = new CommandSet(defineCommands({ "list.next": run })); + set.handle(key("j")); + set.handle(key("ArrowDown")); + expect(run).toHaveBeenCalledTimes(2); + }); + + it("keeps plain keys deaf inside a form field, and chorded ones live", () => { + const plain = vi.fn(); + const chorded = vi.fn(); + const set = new CommandSet( + defineCommands({ "spawn.open": plain, "focus.toggle": chorded }), + ); + const input = el(""); + expect(pressOn(set, input, "n")).toBe(false); + expect(plain).not.toHaveBeenCalled(); + expect(pressOn(set, input, "H", { ctrlKey: true, shiftKey: true })).toBe(true); + expect(chorded).toHaveBeenCalledTimes(1); + }); + + it("keeps pane commands live inside the terminal's textarea", () => { + const run = vi.fn(); + const set = new CommandSet(defineCommands({ "pane.focusList": run }), { + preventDefault: false, + }); + const textarea = el(''); + const e = key("Escape"); + Object.defineProperty(e, "target", { value: textarea }); + expect(set.handle(e)).toBe(true); + expect(run).toHaveBeenCalledTimes(1); + // xterm is told to swallow by the return value, not by the browser default. + expect(e.defaultPrevented).toBe(false); + }); +}); + +describe("the leader", () => { + it("defaults to the TUI's ctrl-a", () => { + expect(DEFAULT_LEADER).toBe("ctrl-a"); + }); + + it("holds the leader as a partial chord instead of selecting the page", () => { + const set = new CommandSet(defineCommands({ "spawn.open": vi.fn() }), { + leader: DEFAULT_LEADER, + }); + const e = key("a", { ctrlKey: true }); + expect(set.handle(e)).toBe(true); + expect(e.defaultPrevented).toBe(true); + }); + + it("swallows the key after the leader rather than running its plain binding", () => { + const run = vi.fn(); + const set = new CommandSet(defineCommands({ "spawn.open": run }), { + leader: DEFAULT_LEADER, + }); + set.handle(key("a", { ctrlKey: true })); + expect(set.handle(key("n"))).toBe(true); + expect(run).not.toHaveBeenCalled(); + // The next key is an ordinary one again. + set.handle(key("n")); + expect(run).toHaveBeenCalledTimes(1); + }); + + it("forgets a held leader once the chord timeout passes", () => { + const run = vi.fn(); + let now = 0; + const set = new CommandSet(defineCommands({ "spawn.open": run }), { + leader: DEFAULT_LEADER, + chordTimeoutMs: 1000, + now: () => now, + }); + set.handle(key("a", { ctrlKey: true })); + now = 1500; + set.handle(key("n")); + expect(run).toHaveBeenCalledTimes(1); + }); + + it("leaves select-all alone in a real form field", () => { + const set = new CommandSet(defineCommands({ "spawn.open": vi.fn() }), { + leader: DEFAULT_LEADER, + }); + const input = el(""); + const e = key("a", { ctrlKey: true }); + Object.defineProperty(e, "target", { value: input }); + expect(set.handle(e)).toBe(false); + expect(e.defaultPrevented).toBe(false); + }); +}); + +describe("publishing", () => { + it("lists a constructed set's commands, replacing an id rather than doubling it", () => { + new CommandSet(defineCommands({ "list.kill": () => {} })); + new CommandSet(defineCommands({ "list.kill": () => {} })); + const ids = publishedCommands().map((c) => c.id); + expect(ids.filter((id) => id === "list.kill")).toEqual(["list.kill"]); + }); + + it("keeps published commands in registry order", () => { + const order = COMMAND_SPECS.map((s) => s.id); + const ids = publishedCommands().map((c) => c.id); + expect(ids).toEqual(order.filter((id) => ids.includes(id))); + }); +}); + +describe("isTypingTarget", () => { + it("is true for form fields and editable nodes, false otherwise", () => { + expect(isTypingTarget(el(""))).toBe(true); + expect(isTypingTarget(el(""))).toBe(true); + expect(isTypingTarget(el(""))).toBe(true); + expect(isTypingTarget(el('
'))).toBe(true); + expect(isTypingTarget(el(""))).toBe(false); + expect(isTypingTarget(null)).toBe(false); + }); +}); diff --git a/web/src/commands.ts b/web/src/commands.ts new file mode 100644 index 0000000..e378da2 --- /dev/null +++ b/web/src/commands.ts @@ -0,0 +1,386 @@ +// The web client's command registry (#148). One entry per command — a stable +// id, a plain-language label, the key chord it answers to, and a `run()` — so +// there is exactly one list of what the client can do. The `?` overlay renders +// from it (`help.ts`), and every keybinding is dispatched through it; a command +// added here is bound and documented in the same edit. +// +// Keys are canonical strings, the same spelling the TUI's `InputKey::canonical` +// produces (`ctrl-shift-h`, `enter`, `esc`, `n`, `N`) and chords are those +// joined by a space (`ctrl-a ctrl-k`). Matching the TUI's spelling is what lets +// docs/COMMAND-BAR.md describe one key model for both clients. + +/** Where a command's keys are listened for. `global` is the window, `list` the + * sidebar's session tree, `pane` the attached terminal, `overlay` the help + * dialog while it is up. */ +export type CommandScope = "global" | "list" | "pane" | "overlay"; + +export interface CommandSpec { + /** Stable identifier. Never rendered; safe to bind config and tests to. */ + id: string; + /** Plain-language label — what the `?` overlay and (later) the command bar + * show. */ + label: string; + /** Help-overlay section. Groups render in the order they first appear here. */ + group: string; + /** Canonical chords that run it. The first is the one shown to the operator; + * the rest are aliases (`j` and `down`). */ + keys: string[]; + scope: CommandScope; +} + +export interface Command extends CommandSpec { + run: () => void; +} + +/** Every command the web client has. The single list this issue exists to + * create: labels, chords and grouping live here and nowhere else. */ +export const COMMAND_SPECS: readonly CommandSpec[] = [ + { + id: "spawn.open", + label: "Open the spawn form", + group: "Sessions", + keys: ["n"], + scope: "global", + }, + { + id: "spawn.fastForward", + label: "Spawn a session with every default resolved", + group: "Sessions", + keys: ["N"], + scope: "global", + }, + { + id: "focus.toggle", + label: "Move the keyboard between the session list and the pane", + group: "Session list", + keys: ["ctrl-shift-h"], + scope: "global", + }, + { + id: "list.next", + label: "Highlight the next session", + group: "Session list", + keys: ["j", "down"], + scope: "list", + }, + { + id: "list.prev", + label: "Highlight the previous session", + group: "Session list", + keys: ["k", "up"], + scope: "list", + }, + { + id: "list.activate", + label: "Open the highlighted session, or expand a folder", + group: "Session list", + keys: ["enter"], + scope: "list", + }, + { + id: "list.toggleFolder", + label: "Collapse or expand the highlighted folder", + group: "Session list", + keys: ["c"], + scope: "list", + }, + { + id: "list.rename", + label: "Rename the highlighted session", + group: "Session list", + keys: ["r"], + scope: "list", + }, + { + id: "list.kill", + label: "Kill the highlighted session", + group: "Session list", + keys: ["x"], + scope: "list", + }, + { + id: "list.focusPane", + label: "Send the keyboard to the pane", + group: "Session list", + keys: ["tab"], + scope: "list", + }, + { + id: "pane.focusList", + label: "Hand the keyboard back to the session list, from the pane", + group: "Session list", + keys: ["esc"], + scope: "pane", + }, + { + id: "help.toggle", + label: "Show or hide this help", + group: "Help", + keys: ["?"], + scope: "global", + }, + { + id: "help.close", + label: "Close the help overlay or an open menu", + group: "Help", + keys: ["esc"], + scope: "overlay", + }, + { + id: "debug.toggleHud", + label: "Toggle the debug HUD", + group: "Debug", + keys: ["ctrl-shift-d"], + scope: "global", + }, +]; + +const SPEC_BY_ID = new Map(COMMAND_SPECS.map((s) => [s.id, s])); + +/** Attach handlers to specs, by id. Throws on an unknown id, so a typo is a + * test failure rather than a key that silently does nothing. */ +export function defineCommands(handlers: Record void>): Command[] { + return Object.entries(handlers).map(([id, run]) => { + const spec = SPEC_BY_ID.get(id); + if (!spec) throw new Error(`unknown command id: ${id}`); + return { ...spec, run }; + }); +} + +// ---- what the help overlay renders ---- + +const published = new Map(); + +/** Record commands as bound. Re-registering an id replaces it, so a second + * sidebar (tests, hot reload) doesn't duplicate rows in the overlay. */ +export function publishCommands(cmds: Command[]): void { + for (const c of cmds) published.set(c.id, c); +} + +/** Every bound command, in `COMMAND_SPECS` order. */ +export function publishedCommands(): Command[] { + return COMMAND_SPECS.map((s) => published.get(s.id)).filter((c): c is Command => c !== undefined); +} + +// ---- key canonicalisation ---- + +const NAMED: Record = { + Escape: "esc", + Enter: "enter", + Tab: "tab", + Backspace: "backspace", + ArrowUp: "up", + ArrowDown: "down", + ArrowLeft: "left", + ArrowRight: "right", + Home: "home", + End: "end", + PageUp: "pageup", + PageDown: "pagedown", + Delete: "delete", + " ": "space", +}; + +/** The canonical string for one keydown. Mirrors the TUI's + * `InputKey::canonical`: modifier prefixes in ctrl/alt/shift order, then the + * key. A shifted character already spells itself (`N`, `?`), so `shift-` is + * only written when another modifier is present and the char was folded to + * lower case. */ +export function canonicalizeKey(e: KeyboardEvent): string | null { + if (!e.key) return null; + // Modifier keys pressed alone are never a binding. + if (e.key === "Control" || e.key === "Alt" || e.key === "Shift" || e.key === "Meta") return null; + const named = NAMED[e.key]; + if (named === "tab" && e.shiftKey) return "backtab"; + let out = ""; + if (e.ctrlKey) out += "ctrl-"; + if (e.altKey) out += "alt-"; + if (e.metaKey) out += "meta-"; + if (named) { + if (e.shiftKey) out += "shift-"; + return out + named; + } + if (e.key.length === 1) { + if (out === "") return e.key; + if (e.shiftKey) out += "shift-"; + return out + e.key.toLowerCase(); + } + if (e.shiftKey) out += "shift-"; + return out + e.key.toLowerCase(); +} + +const MOD_PREFIX = /^(ctrl|alt|meta|shift)-/; + +/** True when a chord carries a modifier or more than one key. Plain single-key + * chords are the ones that stay deaf inside form fields. */ +function isChorded(chord: string): boolean { + return chord.includes(" ") || MOD_PREFIX.test(chord); +} + +/** True when the event target swallows plain keystrokes — a form field, or the + * hidden textarea xterm types into. Every plain single-key binding is deaf in + * the same places. */ +export function isTypingTarget(target: EventTarget | null): boolean { + const el = target as HTMLElement | null; + if (!el || typeof el.tagName !== "string") return false; + return ( + el.tagName === "INPUT" || + el.tagName === "TEXTAREA" || + el.tagName === "SELECT" || + el.isContentEditable + ); +} + +/** xterm's offscreen textarea. It is a typing target like any other, but it is + * also where the leader has to work — a pane is exactly where the operator + * needs the escape hatch (docs/COMMAND-BAR.md). */ +function isTerminalTextarea(target: EventTarget | null): boolean { + const el = target as HTMLElement | null; + return !!el && typeof el.className === "string" && el.classList?.contains("xterm-helper-textarea"); +} + +// ---- display ---- + +const CAPS: Record = { + ctrl: "Ctrl", + alt: "Alt", + meta: "Meta", + shift: "Shift", + esc: "Esc", + enter: "Enter", + tab: "Tab", + backtab: "Shift Tab", + backspace: "Backspace", + up: "Up", + down: "Down", + left: "Left", + right: "Right", + home: "Home", + end: "End", + pageup: "PgUp", + pagedown: "PgDn", + delete: "Del", + space: "Space", +}; + +/** Keycaps for a chord, left to right: `ctrl-shift-h` → Ctrl, Shift, H. A + * multi-key chord separates its keys with a `then` cap. */ +export function keyCaps(chord: string): string[] { + const caps: string[] = []; + for (const [i, key] of chord.split(" ").entries()) { + if (i > 0) caps.push("then"); + const parts = key.split("-"); + // A bare `-` key splits into two empties; treat it as the key itself. + if (parts.every((p) => p === "")) { + caps.push("-"); + continue; + } + const last = parts.pop() as string; + const mods = parts.filter((p) => p !== ""); + for (const m of mods) caps.push(CAPS[m] ?? m); + caps.push(CAPS[last] ?? (mods.length > 0 && last.length === 1 ? last.toUpperCase() : last)); + } + return caps; +} + +// ---- dispatch ---- + +/** Default leader, mirroring the TUI's `[tui].leader`. `ctrl-a` is select-all + * in a browser, so a set that owns the leader `preventDefault`s it. */ +export const DEFAULT_LEADER = "ctrl-a"; + +/** How long a partial chord waits for its next key — the TUI's + * `chord_timeout_ms` default. */ +export const DEFAULT_CHORD_TIMEOUT_MS = 1000; + +export interface CommandSetOptions { + /** Canonical leader key. A set given one holds every leader press as a + * pending chord, whether or not anything is bound behind it yet. */ + leader?: string | null; + chordTimeoutMs?: number; + /** Call `preventDefault()` on a handled key. Off for the terminal's own key + * handler, which swallows by returning `false` to xterm instead. */ + preventDefault?: boolean; + /** Clock, for tests. */ + now?: () => number; +} + +/** A group of commands listening on one surface. Resolves a keydown to a + * command the way the TUI's `ChordState` does: hold while the sequence is a + * strict prefix of some binding, otherwise resolve and dispatch. */ +export class CommandSet { + private readonly byChord = new Map(); + private readonly leader: string | null; + private readonly timeout: number; + private readonly preventDefault: boolean; + private readonly now: () => number; + private held: string[] = []; + private lastPress = 0; + + constructor( + readonly commands: Command[], + opts: CommandSetOptions = {}, + ) { + for (const c of commands) for (const k of c.keys) this.byChord.set(k, c); + this.leader = opts.leader ?? null; + this.timeout = opts.chordTimeoutMs ?? DEFAULT_CHORD_TIMEOUT_MS; + this.preventDefault = opts.preventDefault ?? true; + this.now = opts.now ?? (() => Date.now()); + publishCommands(commands); + } + + /** Whether any binding starts with `candidate` and continues. */ + private isPrefix(candidate: string): boolean { + const marker = `${candidate} `; + for (const chord of this.byChord.keys()) if (chord.startsWith(marker)) return true; + return false; + } + + /** Feed one keydown. Returns true when the key was consumed — dispatched, or + * held as a partial chord. */ + handle(e: KeyboardEvent): boolean { + const key = canonicalizeKey(e); + if (key === null) return false; + if (this.held.length > 0 && this.now() - this.lastPress > this.timeout) this.held = []; + const candidate = this.held.length > 0 ? `${this.held.join(" ")} ${key}` : key; + this.lastPress = this.now(); + + // The leader is a prefix by construction, so it stops being select-all + // before anything is bound behind it (#149 binds the first chord). + if (this.leader !== null && this.held.length === 0 && key === this.leader) { + if (isTypingTarget(e.target) && !isTerminalTextarea(e.target)) return false; + this.held = [key]; + if (this.preventDefault) e.preventDefault(); + return true; + } + + if (this.isPrefix(candidate)) { + this.held.push(key); + if (this.preventDefault) e.preventDefault(); + return true; + } + + const wasChord = this.held.length > 0; + this.held = []; + const cmd = this.byChord.get(candidate); + if (!cmd) { + // A chord that resolved to nothing is still consumed: the leader took + // the key, so it must not also fall through to a plain binding. + return wasChord; + } + // Plain single-key bindings are deaf where typing happens; chorded ones are + // not, and neither are the pane's and the overlay's, which are only fed + // from the surface that owns them. + if ( + !isChorded(candidate) && + cmd.scope !== "pane" && + cmd.scope !== "overlay" && + isTypingTarget(e.target) + ) { + return false; + } + if (this.preventDefault) e.preventDefault(); + cmd.run(); + return true; + } +} diff --git a/web/src/debug.ts b/web/src/debug.ts index 5dfcc0b..93623d1 100644 --- a/web/src/debug.ts +++ b/web/src/debug.ts @@ -2,6 +2,8 @@ // survives reloads, flipped by a keybind (Ctrl+Shift+D), and force-enabled by // `?hud` in dev. A `maestro-hud-toggle` event lets the HUD re-render on change. +import { CommandSet, defineCommands } from "./commands.js"; + const STORAGE_KEY = "maestro:hud"; export const HUD_TOGGLE_EVENT = "maestro-hud-toggle"; @@ -31,12 +33,9 @@ export function toggleHud(): void { setHudEnabled(!isHudEnabled()); } +const hudKeys = new CommandSet(defineCommands({ "debug.toggleHud": () => toggleHud() })); + /** Global keybind: Ctrl+Shift+D toggles the debug HUD. */ export function installHudKeybind(): void { - window.addEventListener("keydown", (e) => { - if (e.ctrlKey && e.shiftKey && (e.key === "D" || e.key === "d")) { - e.preventDefault(); - toggleHud(); - } - }); + window.addEventListener("keydown", (e) => hudKeys.handle(e)); } diff --git a/web/src/help.test.ts b/web/src/help.test.ts index 660e06d..f5b9e70 100644 --- a/web/src/help.test.ts +++ b/web/src/help.test.ts @@ -1,14 +1,8 @@ // @vitest-environment happy-dom import { afterEach, beforeEach, describe, expect, it } from "vitest"; -import { - SHORTCUTS, - closeHelp, - installHelpKeybind, - isHelpOpen, - isTypingTarget, - openHelp, -} from "./help.js"; +import { keyCaps, publishedCommands } from "./commands.js"; +import { closeHelp, groupedCommands, installHelpKeybind, isHelpOpen, openHelp } from "./help.js"; beforeEach(() => { installHelpKeybind(); @@ -30,14 +24,24 @@ function rows(): string[] { } describe("help overlay", () => { - it("renders every registered shortcut, grouped", () => { + it("renders exactly the commands the registry has bound, grouped", () => { openHelp(); - const groups = [...document.querySelectorAll(".help-group-title")].map((h) => h.textContent); - expect(groups).toEqual(SHORTCUTS.map((g) => g.title)); - expect(rows()).toEqual(SHORTCUTS.flatMap((g) => g.items.map((s) => s.keys.join(" ")))); - expect(document.querySelectorAll(".help-desc")).toHaveLength( - SHORTCUTS.reduce((n, g) => n + g.items.length, 0), + const groups = groupedCommands(); + expect([...document.querySelectorAll(".help-group-title")].map((h) => h.textContent)).toEqual( + groups.map((g) => g.title), ); + expect(rows()).toEqual( + groups.flatMap((g) => g.items.map((c) => keyCaps(c.keys[0]).join(" "))), + ); + expect([...document.querySelectorAll(".help-desc")].map((d) => d.textContent)).toEqual( + groups.flatMap((g) => g.items.map((c) => c.label)), + ); + }); + + it("names each row by its command id, so a row can't drift from its command", () => { + openHelp(); + expect([...document.querySelectorAll(".help-row")].map((r) => r.dataset.commandId)) + .toEqual(publishedCommands().map((c) => c.id)); }); it("opens on ? and closes on a second ?", () => { @@ -94,19 +98,3 @@ describe("help overlay", () => { expect(document.querySelectorAll(".help-overlay")).toHaveLength(1); }); }); - -describe("isTypingTarget", () => { - it("is true for form fields and editable nodes, false otherwise", () => { - const el = (html: string) => { - const d = document.createElement("div"); - d.innerHTML = html; - return d.firstElementChild as HTMLElement; - }; - expect(isTypingTarget(el(""))).toBe(true); - expect(isTypingTarget(el(""))).toBe(true); - expect(isTypingTarget(el(""))).toBe(true); - expect(isTypingTarget(el('
'))).toBe(true); - expect(isTypingTarget(el(""))).toBe(false); - expect(isTypingTarget(null)).toBe(false); - }); -}); diff --git a/web/src/help.ts b/web/src/help.ts index da97c92..22fd80f 100644 --- a/web/src/help.ts +++ b/web/src/help.ts @@ -1,69 +1,15 @@ /** Keyboard-shortcut overlay (`?`), the web counterpart of the TUI's help - * overlay. One registry drives both the rendering and what we claim is bound, - * so a binding added to `main.ts` without an entry here reads as missing. */ + * overlay. It renders the command registry (`commands.ts`) — the same list + * that dispatches the keys — so a binding can never be documented as one + * thing and do another. */ -export interface Shortcut { - /** Keycaps, rendered left to right as separate . */ - keys: string[]; - description: string; -} - -export interface ShortcutGroup { - title: string; - items: Shortcut[]; -} - -export const SHORTCUTS: ShortcutGroup[] = [ - { - title: "Sessions", - items: [ - { keys: ["n"], description: "Open the spawn form" }, - { keys: ["N"], description: "Spawn a session with every default resolved" }, - ], - }, - { - title: "Session list", - items: [ - { - keys: ["Ctrl", "Shift", "H"], - description: "Move the keyboard between the session list and the pane", - }, - { keys: ["j"], description: "Highlight the next session" }, - { keys: ["k"], description: "Highlight the previous session" }, - { keys: ["Enter"], description: "Open the highlighted session, or expand a folder" }, - { keys: ["c"], description: "Collapse or expand the highlighted folder" }, - { keys: ["r"], description: "Rename the highlighted session" }, - { keys: ["x"], description: "Kill the highlighted session" }, - { keys: ["Tab"], description: "Send the keyboard to the pane" }, - { keys: ["Esc"], description: "Hand the keyboard back to the session list, from the pane" }, - ], - }, - { - title: "Help", - items: [ - { keys: ["?"], description: "Show or hide this help" }, - { keys: ["Esc"], description: "Close the help overlay or an open menu" }, - ], - }, - { - title: "Debug", - items: [{ keys: ["Ctrl", "Shift", "D"], description: "Toggle the debug HUD" }], - }, -]; - -/** True when the event target swallows plain keystrokes — a form field, or the - * hidden textarea xterm types into. Shared with `main.ts` so every global - * single-key binding is deaf in the same places. */ -export function isTypingTarget(target: EventTarget | null): boolean { - const el = target as HTMLElement | null; - if (!el || typeof el.tagName !== "string") return false; - return ( - el.tagName === "INPUT" || - el.tagName === "TEXTAREA" || - el.tagName === "SELECT" || - el.isContentEditable - ); -} +import { + CommandSet, + defineCommands, + keyCaps, + publishedCommands, + type Command, +} from "./commands.js"; let open: { el: HTMLElement; dispose: () => void } | null = null; @@ -108,7 +54,7 @@ export function openHelp(): HTMLElement { head.append(title, close); panel.appendChild(head); - for (const group of SHORTCUTS) { + for (const group of groupedCommands()) { const section = document.createElement("section"); section.className = "help-group"; const heading = document.createElement("h3"); @@ -116,19 +62,20 @@ export function openHelp(): HTMLElement { heading.textContent = group.title; const list = document.createElement("dl"); list.className = "help-list"; - for (const item of group.items) { + for (const cmd of group.items) { const row = document.createElement("div"); row.className = "help-row"; + row.dataset.commandId = cmd.id; const keys = document.createElement("dt"); keys.className = "help-keys"; - for (const key of item.keys) { + for (const key of keyCaps(cmd.keys[0])) { const cap = document.createElement("kbd"); cap.textContent = key; keys.appendChild(cap); } const desc = document.createElement("dd"); desc.className = "help-desc"; - desc.textContent = item.description; + desc.textContent = cmd.label; row.append(keys, desc); list.appendChild(row); } @@ -153,6 +100,26 @@ export function openHelp(): HTMLElement { return el; } +/** The registry's commands, in registry order, bucketed into the help + * overlay's sections. Groups appear in the order their first command does. */ +export function groupedCommands(): { title: string; items: Command[] }[] { + const groups: { title: string; items: Command[] }[] = []; + for (const cmd of publishedCommands()) { + let group = groups.find((g) => g.title === cmd.group); + if (!group) { + group = { title: cmd.group, items: [] }; + groups.push(group); + } + group.items.push(cmd); + } + return groups; +} + +// The overlay's own two commands. `help.close` is `overlay`-scoped: it is only +// fed keys while the dialog is up. +const helpKeys = new CommandSet(defineCommands({ "help.toggle": () => toggleHelp() })); +const overlayKeys = new CommandSet(defineCommands({ "help.close": () => closeHelp() })); + let installed = false; /** Global keybind: `?` toggles the overlay, Escape closes it. Idempotent, so a @@ -161,16 +128,7 @@ export function installHelpKeybind(): void { if (installed) return; installed = true; window.addEventListener("keydown", (e) => { - if (e.key === "Escape" && open) { - e.preventDefault(); - closeHelp(); - return; - } - // Shift is how `?` is typed on most layouts; the others would be a - // different binding entirely. - if (e.key !== "?" || e.ctrlKey || e.altKey || e.metaKey) return; - if (isTypingTarget(e.target)) return; - e.preventDefault(); - toggleHelp(); + if (open && overlayKeys.handle(e)) return; + helpKeys.handle(e); }); } diff --git a/web/src/main.ts b/web/src/main.ts index 648710b..c75de4a 100644 --- a/web/src/main.ts +++ b/web/src/main.ts @@ -6,7 +6,8 @@ import { renderSidebar } from "./catalog.js"; import { ConnectionPool } from "./connections.js"; import { installDrawer, installDrawerSwipe, requestToggleDrawer } from "./drawer.js"; import { installHudKeybind } from "./debug.js"; -import { installHelpKeybind, isHelpOpen, isTypingTarget } from "./help.js"; +import { installHelpKeybind, isHelpOpen } from "./help.js"; +import { CommandSet, DEFAULT_LEADER, defineCommands } from "./commands.js"; import { DaemonLink } from "./daemonLink.js"; import { CatalogStore } from "./events.js"; import { installRestartOverlay } from "./restartOverlay.js"; @@ -361,37 +362,48 @@ async function requestFastForward(): Promise { }); } -// Keyboard pair (#67): `n` opens the spawn modal, `N` fast-forwards a spawn. -// Ignored while typing anywhere (form fields, the terminal's hidden -// textarea, search) so an attached session's keys are never hijacked. -document.addEventListener("keydown", (e) => { - if (e.ctrlKey || e.altKey || e.metaKey) return; - if (isTypingTarget(e.target)) return; - // The help overlay owns the keyboard while it is up. - if (isHelpOpen()) return; - if (e.key === "n") { - e.preventDefault(); - requestOpenSpawn(); - } else if (e.key === "N") { - e.preventDefault(); - void requestFastForward(); - } -}); +// The global half of the command registry (#148). Two sets, because they +// differ in one thing: the help overlay owns the keyboard while it is up, so +// the plain keys go deaf behind it while the focus chord does not — an +// attached terminal must never be able to trap the focus keys. +// +// `n` opens the spawn modal, `N` fast-forwards a spawn (#67). Both are plain +// keys, so `isTypingTarget` keeps them out of form fields and the terminal's +// hidden textarea. +const spawnKeys = new CommandSet( + defineCommands({ + "spawn.open": () => requestOpenSpawn(), + "spawn.fastForward": () => void requestFastForward(), + }), +); // Ctrl+Shift+H moves the keyboard between the session list and the pane, the // web counterpart of the TUI's `ctrl-w h`. It has to be a chord: an attached // terminal owns every plain keystroke, so nothing unmodified could ever get // the focus back out of it. +// +// This set also owns the leader (`ctrl-a`, the TUI's default): it holds the +// key as a partial chord and takes select-all off it, so the escape hatch +// docs/COMMAND-BAR.md describes has somewhere to land (#149). +const focusKeys = new CommandSet( + defineCommands({ + "focus.toggle": () => { + if (sidebar.hasListFocus()) { + pool.focusTerminal(); + } else { + drawer.open(); // no-op on desktop; on mobile the list is off-canvas + sidebar.focusList(); + } + }, + }), + { leader: DEFAULT_LEADER }, +); + window.addEventListener("keydown", (e) => { - if (!e.ctrlKey || !e.shiftKey || e.altKey || e.metaKey) return; - if (e.key !== "H" && e.key !== "h") return; - e.preventDefault(); - if (sidebar.hasListFocus()) { - pool.focusTerminal(); - } else { - drawer.open(); // no-op on desktop; on mobile the list is off-canvas - sidebar.focusList(); - } + if (focusKeys.handle(e)) return; + // The help overlay owns the keyboard while it is up. + if (isHelpOpen()) return; + spawnKeys.handle(e); }); // Tab in the session list hands the keyboard to the pane; Escape in the pane diff --git a/web/src/matcher.test.ts b/web/src/matcher.test.ts new file mode 100644 index 0000000..094c089 --- /dev/null +++ b/web/src/matcher.test.ts @@ -0,0 +1,201 @@ +import { describe, expect, it } from "vitest"; + +import { + matchAndRank, + type CommandCandidate, + type DirectoryCandidate, + type MatchCandidate, + type MatchResult, + type SessionCandidate, +} from "./matcher.js"; + +function session( + name: string, + directory?: string, + command?: string, + tool?: string, +): SessionCandidate { + return { kind: "session", fields: { name, directory, command, tool } }; +} + +function command(label: string): CommandCandidate { + return { kind: "command", fields: { label } }; +} + +function directory(path: string): DirectoryCandidate { + return { kind: "directory", fields: { path } }; +} + +function label(result: MatchResult): string { + switch (result.candidate.kind) { + case "session": + return `session:${result.candidate.fields.name}`; + case "command": + return `command:${result.candidate.fields.label}`; + case "directory": + return `directory:${result.candidate.fields.path}`; + } +} + +function positions(result: MatchResult): number[] { + return result.ranges.flatMap(([start, end]) => + Array.from({ length: end - start }, (_, offset) => start + offset), + ); +} + +function expectResults( + query: string, + candidates: MatchCandidate[], + expected: Array<[label: string, score: number, field?: string, positions?: number[]]>, +): void { + const results = matchAndRank(query, candidates); + + expect(results.map((result) => [label(result), result.score])).toEqual( + expected.map(([candidateLabel, score]) => [candidateLabel, score]), + ); + expect(results.map((result) => result.field)).toEqual( + expected.map(([, , field]) => field), + ); + expect(results.map(positions)).toEqual(expected.map(([, , , matched]) => matched ?? [])); +} + +describe("matchAndRank fixtures", () => { + it("F1: an empty query keeps input order", () => { + expectResults( + "", + [ + session("kill", "~/dev/api", "bash"), + session("killer-app", "~/dev/killer-app", "claude --resume", "claude"), + command("Kill selected session"), + ], + [ + ["session:kill", 0], + ["session:killer-app", 0], + ["command:Kill selected session", 0], + ], + ); + }); + + it("F2: an exact session name beats a prefix and kind breaks a tie", () => { + expectResults( + "kill", + [ + session("kill", "~/dev/api", "bash"), + session("killer-app", "~/dev/killer-app", "claude --resume", "claude"), + command("Kill selected session"), + ], + [ + ["session:kill", 178, "name", [0, 1, 2, 3]], + ["session:killer-app", 146, "name", [0, 1, 2, 3]], + ["command:Kill selected session", 146, "label", [0, 1, 2, 3]], + ], + ); + }); + + it("F3: path segment boundaries beat mid-segment matches", () => { + expectResults( + "foo", + [directory("~/dev/foobar"), directory("~/src/myfoo"), directory("~/foo")], + [ + ["directory:~/foo", 78, "path", [2, 3, 4]], + ["directory:~/dev/foobar", 74, "path", [6, 7, 8]], + ["directory:~/src/myfoo", 56, "path", [8, 9, 10]], + ], + ); + }); + + it("F4: a query matching nothing returns no candidates", () => { + expectResults("zzz", [session("kill", "~/dev/api", "bash"), command("Kill selected session")], []); + }); + + it("F5: the highest-scoring embedding can differ from the greedy embedding", () => { + expectResults( + "mtu", + [session("maestro-tui"), session("maestro-daemon"), session("mtui-scratch")], + [ + ["session:mtui-scratch", 122, "name", [0, 1, 2]], + ["session:maestro-tui", 91, "name", [0, 8, 9]], + ], + ); + }); + + it("F6: a name beats a weighted directory match", () => { + expectResults( + "api", + [session("api", "~/dev/web", "bash"), session("worker", "~/dev/api", "bash")], + [ + ["session:api", 154, "name", [0, 1, 2]], + ["session:worker", 50, "directory", [6, 7, 8]], + ], + ); + }); + + it("F7: matching is case-insensitive and field length breaks a tie", () => { + expectResults( + "api", + [session("API-Gateway"), session("apiary")], + [ + ["session:apiary", 122, "name", [0, 1, 2]], + ["session:API-Gateway", 122, "name", [0, 1, 2]], + ], + ); + }); + + it("F8: the exact tool field beats the command field", () => { + expectResults( + "claude", + [session("nameless", "~/dev/x", "claude --resume", "claude"), session("clay", "~/dev/y", "bash")], + [["session:nameless", 186, "tool", [0, 1, 2, 3, 4, 5]]], + ); + }); + + it("F9: a separator earns a word-boundary bonus", () => { + expectResults( + "ws", + [session("websearch"), session("web-search")], + [ + ["session:web-search", 71, "name", [0, 4]], + ["session:websearch", 56, "name", [0, 3]], + ], + ); + }); + + it("F10: command labels rank by boundary and initial gap", () => { + expectResults( + "se", + [command("Kill selected session"), command("Create a session"), command("Show help")], + [ + ["command:Show help", 53, "label", [0, 6]], + ["command:Kill selected session", 51, "label", [5, 6]], + ["command:Create a session", 47, "label", [9, 10]], + ], + ); + }); + + it("F11: directory matches separate on their initial gap", () => { + expectResults( + "dev", + [ + session("api", "~/dev/api", "bash"), + session("notes", "~/documents/dev-notes", "nvim"), + session("scratch", "~/srv/devops", "bash"), + ], + [ + ["session:api", 54, "directory", [2, 3, 4]], + ["session:scratch", 50, "directory", [6, 7, 8]], + ["session:notes", 44, "directory", [12, 13, 14]], + ], + ); + }); + + it("F12: an exact field beats a prefix", () => { + expectResults( + "quit", + [command("Quit Maestro"), command("Quit")], + [ + ["command:Quit", 178, "label", [0, 1, 2, 3]], + ["command:Quit Maestro", 146, "label", [0, 1, 2, 3]], + ], + ); + }); +}); diff --git a/web/src/matcher.ts b/web/src/matcher.ts new file mode 100644 index 0000000..00adb5c --- /dev/null +++ b/web/src/matcher.ts @@ -0,0 +1,224 @@ +export type CandidateKind = "session" | "command" | "directory"; + +export interface SessionCandidate { + kind: "session"; + fields: { + name: string; + directory?: string; + command?: string; + tool?: string; + }; +} + +export interface CommandCandidate { + kind: "command"; + fields: { label: string }; +} + +export interface DirectoryCandidate { + kind: "directory"; + fields: { path: string }; +} + +export type MatchCandidate = SessionCandidate | CommandCandidate | DirectoryCandidate; +export type MatchFieldName = "name" | "directory" | "command" | "tool" | "label" | "path"; +export type MatchRange = readonly [start: number, end: number]; + +export interface MatchResult { + candidate: C; + score: number; + field: MatchFieldName | undefined; + ranges: MatchRange[]; +} + +interface Field { + name: MatchFieldName; + text: string; + weight: number; +} + +interface Embedding { + score: number; + positions: number[]; +} + +interface RankedResult extends MatchResult { + matchedText: string; + inputIndex: number; +} + +const KIND_ORDER: Record = { + session: 0, + command: 1, + directory: 2, +}; + +function fields(candidate: MatchCandidate): Field[] { + switch (candidate.kind) { + case "session": { + const values: Array<[MatchFieldName, string | undefined, number]> = [ + ["name", candidate.fields.name, 0], + ["directory", candidate.fields.directory, -24], + ["command", candidate.fields.command, -32], + ["tool", candidate.fields.tool, -40], + ]; + return values.flatMap(([name, text, weight]) => + text === undefined ? [] : [{ name, text, weight }], + ); + } + case "command": + return [{ name: "label", text: candidate.fields.label, weight: 0 }]; + case "directory": + return [{ name: "path", text: candidate.fields.path, weight: 0 }]; + } +} + +function normalizeQuery(query: string): string { + return query.toLowerCase().replace(/\s/g, ""); +} + +function boundaryBonus(text: string, position: number): number { + if (position === 0) return 18; + + const previous = text[position - 1]; + if ("/\\-_ .:".includes(previous)) return 16; + + const current = text[position]; + const isUppercase = current !== current.toLowerCase() && current === current.toUpperCase(); + const previousIsLowercase = + previous !== previous.toUpperCase() && previous === previous.toLowerCase(); + if (isUppercase && previousIsLowercase) return 12; + + const isDigit = current >= "0" && current <= "9"; + const previousIsDigit = previous >= "0" && previous <= "9"; + if (isDigit && !previousIsDigit) return 12; + + return 0; +} + +function contribution(text: string, position: number, previousPosition: number): number { + const gap = position - previousPosition - 1; + return 16 + boundaryBonus(text, position) + (gap === 0 ? 8 : -Math.min(gap, 12)); +} + +function isBetterEmbedding(candidate: Embedding, incumbent: Embedding | undefined): boolean { + if (!incumbent || candidate.score !== incumbent.score) { + return !incumbent || candidate.score > incumbent.score; + } + for (let index = 0; index < candidate.positions.length; index++) { + if (candidate.positions[index] !== incumbent.positions[index]) { + return candidate.positions[index] < incumbent.positions[index]; + } + } + return false; +} + +function bestEmbedding(query: string, text: string): Embedding | undefined { + const lowerText = text.toLowerCase(); + let previousStates = new Map(); + + for (let queryIndex = 0; queryIndex < query.length; queryIndex++) { + const states = new Map(); + for (let position = 0; position < lowerText.length; position++) { + if (lowerText[position] !== query[queryIndex]) continue; + + if (queryIndex === 0) { + states.set(position, { + score: contribution(text, position, -1), + positions: [position], + }); + continue; + } + + for (const [previousPosition, previous] of previousStates) { + if (previousPosition >= position) continue; + const candidate = { + score: previous.score + contribution(text, position, previousPosition), + positions: [...previous.positions, position], + }; + if (isBetterEmbedding(candidate, states.get(position))) states.set(position, candidate); + } + } + if (states.size === 0) return undefined; + previousStates = states; + } + + let best: Embedding | undefined; + for (const embedding of previousStates.values()) { + if (isBetterEmbedding(embedding, best)) best = embedding; + } + return best; +} + +function toRanges(positions: number[]): MatchRange[] { + const ranges: MatchRange[] = []; + for (const position of positions) { + const previous = ranges[ranges.length - 1]; + if (previous && previous[1] === position) { + ranges[ranges.length - 1] = [previous[0], position + 1]; + } else { + ranges.push([position, position + 1]); + } + } + return ranges; +} + +function compareByCodePoint(left: string, right: string): number { + const leftPoints = Array.from(left, (character) => character.codePointAt(0)!); + const rightPoints = Array.from(right, (character) => character.codePointAt(0)!); + const length = Math.min(leftPoints.length, rightPoints.length); + for (let index = 0; index < length; index++) { + if (leftPoints[index] !== rightPoints[index]) return leftPoints[index] - rightPoints[index]; + } + return leftPoints.length - rightPoints.length; +} + +export function matchAndRank( + query: string, + candidates: readonly C[], +): MatchResult[] { + const normalizedQuery = normalizeQuery(query); + if (normalizedQuery === "") { + return candidates.map((candidate) => ({ candidate, score: 0, field: undefined, ranges: [] })); + } + + const results: RankedResult[] = []; + candidates.forEach((candidate, inputIndex) => { + let best: + | { field: Field; score: number; positions: number[] } + | undefined; + + for (const field of fields(candidate)) { + const embedding = bestEmbedding(normalizedQuery, field.text); + if (!embedding) continue; + + const compactText = field.text.toLowerCase().replace(/\s/g, ""); + const adjustment = + compactText === normalizedQuery ? 64 : compactText.startsWith(normalizedQuery) ? 32 : 0; + const score = embedding.score + adjustment + field.weight; + if (!best || score > best.score) best = { field, score, positions: embedding.positions }; + } + + if (best) { + results.push({ + candidate, + score: best.score, + field: best.field.name, + ranges: toRanges(best.positions), + matchedText: best.field.text, + inputIndex, + }); + } + }); + + results.sort( + (left, right) => + right.score - left.score || + KIND_ORDER[left.candidate.kind] - KIND_ORDER[right.candidate.kind] || + left.matchedText.length - right.matchedText.length || + compareByCodePoint(left.matchedText, right.matchedText) || + left.inputIndex - right.inputIndex, + ); + + return results.map(({ candidate, score, field, ranges }) => ({ candidate, score, field, ranges })); +} diff --git a/web/src/types.ts b/web/src/types.ts index 53e6a2c..b18c86f 100644 --- a/web/src/types.ts +++ b/web/src/types.ts @@ -50,6 +50,12 @@ export interface Session { * create one. `base_repo` is the repository's main checkout, which is what * the session tree groups by (#99). */ worktree?: SessionWorktree | null; + /** Wall-clock of when this session last became the pane a client was showing + * (#145) — stamped by both `POST /api/sessions/:id/focus` and opening + * `/api/attach/:id`. Absent until the session has been focused once, which is + * how the command bar's empty query sorts a never-focused session after every + * session that has one (`docs/COMMAND-BAR.md`). */ + last_focused_at?: IsoTimestamp | null; } /** A session's daemon-created worktree (`SessionWorktree` on the wire). */