// 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; } /** Leader used when the daemon does not name one — an older daemon, or an * unreachable `/api/info`. Mirrors the TUI's `[tui].leader` default. `ctrl-a` * is select-all in a browser, so a set that owns the leader * `preventDefault`s it. */ export const DEFAULT_LEADER = "ctrl-a"; /** Every command the web client has, for one leader. The single list: labels, * chords and grouping live here and nowhere else. Chords behind the leader are * built from it (#159), so the configured leader — served on `/api/info` as * `leader` — is what the overlay renders and the sets dispatch. */ export function commandSpecs(leader: string = DEFAULT_LEADER): readonly CommandSpec[] { return SPECS.map(({ leaderKeys, ...spec }) => leaderKeys ? { ...spec, keys: leaderKeys(leader) } : spec, ); } /** A spec whose chords are fixed, or a spec that builds them from the leader. */ type SpecSource = CommandSpec & { leaderKeys?: (leader: string) => string[] }; const SPECS: readonly SpecSource[] = [ { 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: "bar.open", label: "Open the command bar", group: "Sessions", keys: ["ctrl-k"], scope: "global", }, { id: "pane.literalCtrlK", label: "Send a literal Ctrl-K to the pane", group: "Pane", keys: [`${DEFAULT_LEADER} ctrl-k`], leaderKeys: (leader) => [`${leader} ctrl-k`], scope: "global", }, { id: "pane.literalLeader", label: "Send a literal leader key to the pane", group: "Pane", keys: [`${DEFAULT_LEADER} ${DEFAULT_LEADER}`], leaderKeys: (leader) => [`${leader} ${leader}`], scope: "global", }, { id: "focus.toggle", label: "Move the keyboard between the session list and the pane", group: "Session list", keys: [`${DEFAULT_LEADER} h`], leaderKeys: (leader) => [`${leader} 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: "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", }, ]; /** The command list under the default leader. Iteration order everywhere else * (the help overlay, the bar) comes from here. */ export const COMMAND_SPECS: readonly CommandSpec[] = commandSpecs(); /** 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. `leader` picks * the chords behind the leader; omit it for a set that has none. */ export function defineCommands( handlers: Record void>, leader: string = DEFAULT_LEADER, ): Command[] { const byId = new Map(commandSpecs(leader).map((s) => [s.id, s])); return Object.entries(handlers).map(([id, run]) => { const spec = byId.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(); } /** The raw control byte for a canonical `ctrl-` chord — `ctrl-a` is * `0x01` — mirroring the TUI's `encode_key`. Built from the chord string * itself so a different leader sends its own byte, not a hardcoded one. */ export function controlByte(chord: string): string { const m = /^ctrl-([a-z])$/.exec(chord); if (!m) throw new Error(`not a plain ctrl chord: ${chord}`); return String.fromCharCode(m[1].charCodeAt(0) - 96); } 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). */ export function isTerminalTextarea(target: EventTarget | null): boolean { const el = target as HTMLElement | null; return !!el && typeof el.className === "string" && el.classList?.contains("xterm-helper-textarea"); } /** True where the command bar's own keys must stay out of the way: a real form * field, and not the pane. `ctrl-k` is intercepted ahead of xterm * (docs/COMMAND-BAR.md, "Opening"), so a focused terminal opens the bar, while * the spawn form's inputs keep their own Ctrl-K. */ export function isBarTypingTarget(target: EventTarget | null): boolean { return isTypingTarget(target) && !isTerminalTextarea(target); } // ---- 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 ---- /** 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; } }