From 328711cadeb9e29468dd33c1c41fd0c6297db26e Mon Sep 17 00:00:00 2001 From: Miguel Palhas Date: Thu, 3 Sep 2026 13:47:35 +0100 Subject: [PATCH 01/14] refactor(maestro): route web keys through a command registry MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit One entry per command — stable id, plain-language label, key chord, run() — replacing the ad-hoc keydown listeners in main.ts, catalog.ts, debug.ts and attach.ts and the parallel SHORTCUTS constant in help.ts. The `?` overlay now renders the registry, so a binding and its documentation cannot disagree. Keys are canonicalised the way the TUI's `InputKey::canonical` spells them, and chords resolve like its `ChordState`: hold while the sequence is a strict prefix, otherwise dispatch. That gives the web a leader, `ctrl-a` by default like `[tui].leader`, held as a partial chord and `preventDefault`ed so it stops being select-all — the escape hatch in docs/COMMAND-BAR.md needs somewhere to land (#149). The leader stays out of real form fields, where select-all is still what the operator wants. Same keys, same effects: plain single-key bindings remain deaf inside form fields and the terminal's hidden textarea, chorded ones do not. Refs #148 Co-Authored-By: Claude Opus 5 --- web/src/attach.ts | 22 ++- web/src/catalog.ts | 45 +++-- web/src/commands.test.ts | 236 ++++++++++++++++++++++++ web/src/commands.ts | 386 +++++++++++++++++++++++++++++++++++++++ web/src/debug.ts | 11 +- web/src/help.test.ts | 48 ++--- web/src/help.ts | 116 ++++-------- web/src/main.ts | 64 ++++--- 8 files changed, 761 insertions(+), 167 deletions(-) create mode 100644 web/src/commands.test.ts create mode 100644 web/src/commands.ts 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 39fc7ce..b74f3fc 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 { @@ -661,24 +662,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 From 950a899b3cc9ab7e9e1abd18855cd56fd5150083 Mon Sep 17 00:00:00 2001 From: Miguel Palhas Date: Thu, 3 Sep 2026 13:55:02 +0100 Subject: [PATCH 02/14] feat(maestro): add command bar matcher --- web/src/matcher.test.ts | 201 +++++++++++++++++++++++++++++++++++ web/src/matcher.ts | 224 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 425 insertions(+) create mode 100644 web/src/matcher.test.ts create mode 100644 web/src/matcher.ts 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 })); +} From 9d9aef119d2e9c389e82c1d58ffc04342f351170 Mon Sep 17 00:00:00 2001 From: Miguel Palhas Date: Thu, 3 Sep 2026 14:05:36 +0100 Subject: [PATCH 03/14] feat(maestro): stamp last-focused time on the session (#145) Add Session::last_focused_at, shipped on the wire like every other field. Both POST /api/sessions/:id/focus (focused: true) and opening /api/attach/:id stamp it and emit SessionUpdated, so a client that only attaches sorts the same as one that reports focus explicitly. Losing focus does not clear it, and a never-focused session has None, which is what lets the command bar's empty-query ordering (docs/COMMAND-BAR.md) sort it after every session that has focused. --- crates/maestro-cli/src/sessions.rs | 1 + crates/maestro-daemon/src/session.rs | 84 +++++++++----- crates/maestro-daemon/tests/local_api.rs | 35 ++++++ .../maestro-daemon/tests/session_manager.rs | 106 ++++++++++++++++++ crates/maestro-daemon/tests/sidecar.rs | 1 + crates/maestro-protocol/src/display.rs | 1 + crates/maestro-protocol/src/lib.rs | 31 +++++ crates/maestro-testing/src/lib.rs | 1 + crates/maestro-tui/src/app.rs | 5 + crates/maestro-tui/src/catalog.rs | 1 + crates/maestro-tui/src/sidebar.rs | 2 + 11 files changed, 243 insertions(+), 25 deletions(-) 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], From 5df23b8714d98fb80f6d491cebd4dd57ea04389b Mon Sep 17 00:00:00 2001 From: Miguel Palhas Date: Thu, 3 Sep 2026 14:16:12 +0100 Subject: [PATCH 04/14] fix(maestro): mirror last_focused_at in the web session type The daemon ships the field (#145) but web/src/types.ts is a hand-kept mirror of the protocol crate, so it did not know about it. #149 sorts the command bar's empty query by it. --- web/src/types.ts | 6 ++++++ 1 file changed, 6 insertions(+) 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). */ From 8dde63a2ab27b05d8ef55e1f01fa03a235fe36cf Mon Sep 17 00:00:00 2001 From: Miguel Palhas Date: Thu, 3 Sep 2026 14:28:42 +0100 Subject: [PATCH 05/14] feat(maestro): add the web ctrl-k command bar The overlay, the query line and the session half of the results (docs/COMMAND-BAR.md): an empty query lists sessions most recently focused first, typing ranks through `matchAndRank` with the winning field's ranges highlighted, and results render under kind headers ordered by their best-scoring member. Enter dispatches `SESSION_SELECT_EVENT`, the path the sidebar's click and the hash router already share, so a bar attach cannot leave the layout in a state a click could not produce. The global key listener moves to the capture phase, which #148 left alone: an attached xterm swallows every keystroke it is given, so ctrl-k has to be seen ahead of the terminal. Co-Authored-By: Claude Opus 5 --- web/src/commandBar.test.ts | 309 ++++++++++++++++++++++++++++++ web/src/commandBar.ts | 381 +++++++++++++++++++++++++++++++++++++ web/src/commands.ts | 7 + web/src/main.ts | 50 ++++- web/src/styles.css | 122 ++++++++++++ 5 files changed, 862 insertions(+), 7 deletions(-) create mode 100644 web/src/commandBar.test.ts create mode 100644 web/src/commandBar.ts diff --git a/web/src/commandBar.test.ts b/web/src/commandBar.test.ts new file mode 100644 index 0000000..bc1135d --- /dev/null +++ b/web/src/commandBar.test.ts @@ -0,0 +1,309 @@ +// @vitest-environment happy-dom +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { SESSION_SELECT_EVENT } from "./catalog.js"; +import { + barGroups, + barRows, + closeCommandBar, + isCommandBarOpen, + openCommandBar, + orderSessionsForBar, + sessionBarCandidates, + sessionCandidate, +} from "./commandBar.js"; +import { COMMAND_SPECS } from "./commands.js"; +import type { Session } from "./types.js"; + +function session(id: string, over: Partial = {}): Session { + return { + id, + cwd: "/home/me/dev/api", + command: "bash", + env: {}, + metadata: { name: id }, + status: "Running", + created_at: "2026-01-01T00:00:00Z", + ...over, + }; +} + +afterEach(() => { + closeCommandBar(); + document.body.replaceChildren(); +}); + +function press(key: string, init: KeyboardEventInit = {}): void { + const input = document.querySelector(".cmdbar-input") as HTMLInputElement; + input.dispatchEvent(new KeyboardEvent("keydown", { key, bubbles: true, ...init })); +} + +function type(value: string): void { + const input = document.querySelector(".cmdbar-input") as HTMLInputElement; + input.value = value; + input.dispatchEvent(new Event("input", { bubbles: true })); +} + +function rowNames(): string[] { + return [...document.querySelectorAll(".cmdbar-row-name")].map((r) => r.textContent ?? ""); +} + +function activeName(): string | undefined { + return document.querySelector(".cmdbar-row.is-active .cmdbar-row-name")?.textContent ?? undefined; +} + +describe("empty-query ordering", () => { + it("sorts most recently focused first", () => { + const older = session("older", { last_focused_at: "2026-01-01T10:00:00Z" }); + const newer = session("newer", { last_focused_at: "2026-01-01T11:00:00Z" }); + expect(orderSessionsForBar([older, newer]).map((s) => s.id)).toEqual(["newer", "older"]); + }); + + it("sorts a never-focused session after every focused one, newest created first", () => { + const focused = session("focused", { + last_focused_at: "2020-01-01T00:00:00Z", + created_at: "2020-01-01T00:00:00Z", + }); + const oldFresh = session("old-fresh", { created_at: "2026-01-01T00:00:00Z" }); + const newFresh = session("new-fresh", { created_at: "2026-06-01T00:00:00Z" }); + expect(orderSessionsForBar([oldFresh, newFresh, focused]).map((s) => s.id)).toEqual([ + "focused", + "new-fresh", + "old-fresh", + ]); + }); + + it("sorts dead sessions after live ones regardless of recency", () => { + const deadRecent = session("dead", { + status: "Dead", + last_focused_at: "2026-06-01T00:00:00Z", + }); + const liveStale = session("live", { last_focused_at: "2020-01-01T00:00:00Z" }); + const liveNever = session("never"); + expect(orderSessionsForBar([deadRecent, liveNever, liveStale]).map((s) => s.id)).toEqual([ + "live", + "never", + "dead", + ]); + }); + + it("treats a null last_focused_at as never focused", () => { + const nulled = session("nulled", { last_focused_at: null }); + const stamped = session("stamped", { last_focused_at: "2020-01-01T00:00:00Z" }); + expect(orderSessionsForBar([nulled, stamped]).map((s) => s.id)).toEqual(["stamped", "nulled"]); + }); +}); + +describe("session candidates", () => { + it("carries the candidate table's fields, with the directory abbreviated", () => { + const s = session("api", { + metadata: { name: "api" }, + command: "claude --resume", + current_cwd: "/home/me/dev/api", + }); + expect(sessionCandidate(s, "/home/me")).toEqual({ + kind: "session", + session: s, + fields: { + name: "api", + directory: "~/dev/api", + command: "claude --resume", + tool: "claude", + }, + }); + }); + + it("leaves the tool out when the program has no mark, and groups a worktree by its repo", () => { + const s = session("edit", { + metadata: {}, + command: "hx src/", + worktree: { path: "/home/me/dev/api/wt/x", base_repo: "/home/me/dev/api" }, + }); + const candidate = sessionCandidate(s, "/home/me"); + expect(candidate.fields.tool).toBeUndefined(); + expect(candidate.fields.directory).toBe("~/dev/api"); + // No `metadata.name`: the sidebar's fallback is the command line. + expect(candidate.fields.name).toBe("hx src/"); + }); + + it("builds candidates in the empty-query order", () => { + const a = session("a", { last_focused_at: "2026-01-01T10:00:00Z" }); + const b = session("b", { last_focused_at: "2026-01-01T11:00:00Z" }); + expect(sessionBarCandidates([a, b]).map((c) => c.session.id)).toEqual(["b", "a"]); + }); +}); + +describe("rows and grouping", () => { + it("an empty query keeps every session in input order", () => { + const candidates = sessionBarCandidates([session("a"), session("b")]); + expect(barRows("", candidates).map((r) => r.candidate.session.id)).toEqual(["a", "b"]); + }); + + it("groups by kind without reordering the ranking", () => { + const candidates = sessionBarCandidates([ + session("worker", { metadata: { name: "worker" }, current_cwd: "/home/me/dev/api" }), + session("api", { metadata: { name: "api" }, current_cwd: "/home/me/dev/web" }), + ]); + const rows = barRows("api", candidates); + const groups = barGroups(rows); + expect(groups).toHaveLength(1); + expect(groups[0].title).toBe("Sessions"); + expect(groups[0].results.map((r) => r.candidate.session.id)).toEqual( + rows.map((r) => r.candidate.session.id), + ); + // F6: the name beats the directory, and the directory match highlights + // inside the directory. + expect(groups[0].results[0].candidate.session.id).toBe("api"); + expect(groups[0].results[1].field).toBe("directory"); + }); +}); + +describe("the overlay", () => { + const sessions = [ + session("api", { + metadata: { name: "api" }, + current_cwd: "/home/me/dev/api", + last_focused_at: "2026-01-01T11:00:00Z", + }), + session("worker", { + metadata: { name: "worker" }, + current_cwd: "/home/me/dev/worker", + last_focused_at: "2026-01-01T10:00:00Z", + }), + session("notes", { metadata: { name: "notes" }, current_cwd: "/home/me/notes" }), + ]; + + function open(over: Parameters[0] | null = null) { + return openCommandBar(over ?? { sessions, homeDir: "/home/me", onOpenSession: () => {} }); + } + + it("is a registry command, so the help overlay lists it", () => { + const spec = COMMAND_SPECS.find((s) => s.id === "bar.open"); + expect(spec?.keys).toEqual(["ctrl-k"]); + expect(spec?.scope).toBe("global"); + }); + + it("lists sessions by recency under a kind header on an empty query", () => { + open(); + expect([...document.querySelectorAll(".cmdbar-group-title")].map((h) => h.textContent)).toEqual([ + "Sessions", + ]); + expect(rowNames()).toEqual(["api", "worker", "notes"]); + expect(activeName()).toBe("api"); + }); + + it("opening twice leaves one overlay", () => { + open(); + open(); + expect(document.querySelectorAll(".cmdbar-overlay")).toHaveLength(1); + expect(isCommandBarOpen()).toBe(true); + }); + + it("ranks a typed query through the matcher and highlights the matched range", () => { + open(); + type("wor"); + expect(rowNames()).toEqual(["worker"]); + const marks = [...document.querySelectorAll(".cmdbar-row-name mark")].map((m) => m.textContent); + expect(marks).toEqual(["wor"]); + }); + + it("highlights inside the directory when that is the field that matched", () => { + open(); + type("notes"); + const row = document.querySelector(".cmdbar-row") as HTMLElement; + expect(row.dataset.sessionId).toBe("notes"); + type("dev/wor"); + expect(rowNames()).toEqual(["worker"]); + expect( + [...document.querySelectorAll(".cmdbar-row-meta mark")].map((m) => m.textContent).join(""), + ).toBe("dev/wor"); + }); + + it("renders a no-results state rather than an empty list", () => { + open(); + type("zzz"); + expect(rowNames()).toEqual([]); + expect(document.querySelector(".cmdbar-empty")?.textContent).toBe("No matches"); + }); + + it("moves the highlight with the arrows and ctrl-p/ctrl-n, without wrapping", () => { + open(); + press("ArrowUp"); + expect(activeName()).toBe("api"); + press("ArrowDown"); + expect(activeName()).toBe("worker"); + press("n", { ctrlKey: true }); + expect(activeName()).toBe("notes"); + press("ArrowDown"); + expect(activeName()).toBe("notes"); + press("p", { ctrlKey: true }); + expect(activeName()).toBe("worker"); + }); + + it("puts the highlight back on the best row whenever the query changes", () => { + open(); + press("ArrowDown"); + expect(activeName()).toBe("worker"); + type("o"); + expect(activeName()).toBe(document.querySelector(".cmdbar-row-name")?.textContent); + }); + + it("opens the highlighted session through the shared open path on Enter", () => { + const seen: string[] = []; + const listener = (e: Event) => seen.push((e as CustomEvent).detail); + document.addEventListener(SESSION_SELECT_EVENT, listener); + openCommandBar({ sessions, homeDir: "/home/me" }); + press("ArrowDown"); + press("Enter"); + document.removeEventListener(SESSION_SELECT_EVENT, listener); + expect(seen).toEqual(["worker"]); + expect(isCommandBarOpen()).toBe(false); + }); + + it("Enter on an empty result list does nothing", () => { + const onOpenSession = vi.fn(); + open({ sessions, homeDir: "/home/me", onOpenSession }); + type("zzz"); + press("Enter"); + expect(onOpenSession).not.toHaveBeenCalled(); + expect(isCommandBarOpen()).toBe(true); + }); + + it("closes on Escape and on a press outside the panel", () => { + open(); + press("Escape"); + expect(isCommandBarOpen()).toBe(false); + + open(); + (document.querySelector(".cmdbar-overlay") as HTMLElement).dispatchEvent( + new MouseEvent("mousedown", { bubbles: true }), + ); + expect(isCommandBarOpen()).toBe(false); + }); + + it("stays open for a press inside the panel", () => { + open(); + (document.querySelector(".cmdbar-panel") as HTMLElement).dispatchEvent( + new MouseEvent("mousedown", { bubbles: true }), + ); + expect(isCommandBarOpen()).toBe(true); + }); + + it("returns the keyboard to whatever held it when it opened", () => { + const before = document.createElement("button"); + document.body.appendChild(before); + before.focus(); + open(); + expect(document.activeElement).toBe(document.querySelector(".cmdbar-input")); + press("Escape"); + expect(document.activeElement).toBe(before); + }); + + it("clicking a row opens that session", () => { + const onOpenSession = vi.fn(); + open({ sessions, homeDir: "/home/me", onOpenSession }); + (document.querySelectorAll(".cmdbar-row")[1] as HTMLElement).click(); + expect(onOpenSession).toHaveBeenCalledWith("worker"); + expect(isCommandBarOpen()).toBe(false); + }); +}); diff --git a/web/src/commandBar.ts b/web/src/commandBar.ts new file mode 100644 index 0000000..8ec33e7 --- /dev/null +++ b/web/src/commandBar.ts @@ -0,0 +1,381 @@ +// The `ctrl-k` command bar (docs/COMMAND-BAR.md): a query line over a result +// list, opened from the sidebar, closed with Escape. +// +// Sessions are the only kind here (#149); the sigils, the action page and the +// terminal-focus binding land in #150-#152. + +import { SESSION_SELECT_EVENT } from "./catalog.js"; +import { + matchAndRank, + type CandidateKind, + type MatchRange, + type MatchResult, + type SessionCandidate, +} from "./matcher.js"; +import { splitCommand } from "./toolIcon.js"; +import { abbreviateCwd, sessionName, sessionTreePath, type Session } from "./types.js"; + +/** A matcher candidate that remembers which session it came from, so a result + * can be acted on without a second lookup. The extra property is invisible to + * the matcher, which only ever reads `kind` and `fields`. */ +export type SessionBarCandidate = SessionCandidate & { session: Session }; + +export type BarCandidate = SessionBarCandidate; +export type BarResult = MatchResult; + +/** One kind's slice of the ranked list, in rank order. */ +export interface BarGroup { + kind: CandidateKind; + title: string; + results: BarResult[]; +} + +const KIND_TITLES: Record = { + session: "Sessions", + command: "Commands", + directory: "Directories", +}; + +// ---- candidates ---- + +function focusedAt(s: Session): number | null { + const stamp = s.last_focused_at; + if (typeof stamp !== "string" || stamp === "") return null; + const ms = Date.parse(stamp); + return Number.isNaN(ms) ? null : ms; +} + +function createdAt(s: Session): number { + const ms = Date.parse(s.created_at ?? ""); + return Number.isNaN(ms) ? 0 : ms; +} + +/** The empty-query order (docs/COMMAND-BAR.md, "Empty query"): most recently + * focused first; a session that has never been focused sorts after every + * session that has, most recently created first among those; and a dead + * session sorts after every live one regardless of recency, because a session + * you cannot attach to is not where you just were. + * + * This is also the input order for a non-empty query, which is what the + * matcher's last tie-break preserves. */ +export function orderSessionsForBar(sessions: readonly Session[]): Session[] { + return [...sessions].sort((a, b) => { + const deadA = a.status === "Dead" ? 1 : 0; + const deadB = b.status === "Dead" ? 1 : 0; + if (deadA !== deadB) return deadA - deadB; + + const focusA = focusedAt(a); + const focusB = focusedAt(b); + if ((focusA === null) !== (focusB === null)) return focusA === null ? 1 : -1; + if (focusA !== null && focusB !== null && focusA !== focusB) return focusB - focusA; + + return createdAt(b) - createdAt(a); + }); +} + +/** Build one session's candidate: the fields docs/COMMAND-BAR.md's candidate + * table names, with the directory abbreviated the way the sidebar shows it. */ +export function sessionCandidate(s: Session, homeDir?: string): SessionBarCandidate { + const treePath = sessionTreePath(s); + const directory = treePath ? abbreviateCwd(treePath, homeDir) : undefined; + const tool = splitCommand(s.command).tool ?? undefined; + return { + kind: "session", + session: s, + fields: { + name: sessionName(s), + ...(directory ? { directory } : {}), + ...(s.command ? { command: s.command } : {}), + ...(tool ? { tool } : {}), + }, + }; +} + +/** Every session as a candidate, in the empty-query order. */ +export function sessionBarCandidates( + sessions: readonly Session[], + homeDir?: string, +): SessionBarCandidate[] { + return orderSessionsForBar(sessions).map((s) => sessionCandidate(s, homeDir)); +} + +// ---- grouping ---- + +/** Bucket a ranked list by kind for rendering. Grouping is a rendering of the + * ranking, never a reordering of it: a group holds its matches in rank order, + * and groups appear in the order their best-scoring member does — so the first + * row under the first header is the globally best result. */ +export function barGroups(results: readonly BarResult[]): BarGroup[] { + const groups: BarGroup[] = []; + for (const result of results) { + const kind = result.candidate.kind; + let group = groups.find((g) => g.kind === kind); + if (!group) { + group = { kind, title: KIND_TITLES[kind], results: [] }; + groups.push(group); + } + group.results.push(result); + } + return groups; +} + +/** The rows a query produces, flat and in rank order — what the highlight walks + * and what Enter acts on. Headers are labels, not rows, so they are never in + * this list. */ +export function barRows(query: string, candidates: readonly BarCandidate[]): BarResult[] { + return matchAndRank(query, candidates); +} + +// ---- rendering ---- + +/** Text with its matched ranges wrapped in ``. Ranges come from the + * matcher as `[start, end)` character pairs over the field that won. */ +export function renderHighlighted(text: string, ranges: readonly MatchRange[]): DocumentFragment { + const frag = document.createDocumentFragment(); + let cursor = 0; + for (const [start, end] of ranges) { + if (start > cursor) frag.append(text.slice(cursor, start)); + const mark = document.createElement("mark"); + mark.className = "cmdbar-hit"; + mark.textContent = text.slice(start, end); + frag.append(mark); + cursor = end; + } + if (cursor < text.length) frag.append(text.slice(cursor)); + return frag; +} + +/** The secondary line under a row's name: the field the match was found in when + * that was not the name, so a session found through its directory says so. */ +function metaText(result: BarResult): string | undefined { + const fields = result.candidate.fields; + switch (result.field) { + case "directory": + return fields.directory; + case "command": + return fields.command; + case "tool": + return fields.tool; + default: + return fields.directory; + } +} + +function renderRow(result: BarResult, index: number): HTMLElement { + const row = document.createElement("div"); + row.className = "cmdbar-row"; + row.setAttribute("role", "option"); + row.id = `cmdbar-row-${index}`; + row.dataset.index = String(index); + row.dataset.sessionId = result.candidate.session.id; + if (result.candidate.session.status === "Dead") row.classList.add("is-dead"); + + const name = document.createElement("span"); + name.className = "cmdbar-row-name"; + const nameText = result.candidate.fields.name; + name.append( + result.field === "name" ? renderHighlighted(nameText, result.ranges) : nameText, + ); + row.appendChild(name); + + const meta = metaText(result); + if (meta) { + const el = document.createElement("span"); + el.className = "cmdbar-row-meta"; + const highlighted = result.field !== undefined && result.field !== "name"; + el.append(highlighted ? renderHighlighted(meta, result.ranges) : meta); + row.appendChild(el); + } + return row; +} + +// ---- the overlay ---- + +export interface CommandBarOptions { + /** Sessions to build candidates from, as the store has them right now. */ + sessions: readonly Session[]; + /** The daemon's home dir, for the directory field's `~/…` abbreviation. */ + homeDir?: string; + /** Open the highlighted session. Defaults to the shared open path — the + * sidebar's click event — which is what the contract requires; tests pass + * their own. */ + onOpenSession?: (sessionId: string) => void; +} + +interface OpenBar { + el: HTMLElement; + dispose: () => void; +} + +let current: OpenBar | null = null; + +export function isCommandBarOpen(): boolean { + return current !== null; +} + +export function closeCommandBar(): void { + current?.dispose(); +} + +/** Mount the bar. Opening while one is up replaces it, so a stray second call + * can never stack two overlays. */ +export function openCommandBar(opts: CommandBarOptions): HTMLElement { + closeCommandBar(); + + // Closing returns the keyboard to whatever held it when the bar opened: a + // round trip through the bar never silently moves focus. + const previouslyFocused = document.activeElement as HTMLElement | null; + const openSession = + opts.onOpenSession ?? + ((id: string) => document.dispatchEvent(new CustomEvent(SESSION_SELECT_EVENT, { detail: id }))); + + const candidates: BarCandidate[] = sessionBarCandidates(opts.sessions, opts.homeDir); + + const el = document.createElement("div"); + el.className = "cmdbar-overlay"; + el.setAttribute("role", "dialog"); + el.setAttribute("aria-modal", "true"); + el.setAttribute("aria-label", "Command bar"); + + const panel = document.createElement("div"); + panel.className = "cmdbar-panel"; + + const input = document.createElement("input"); + input.type = "text"; + input.className = "cmdbar-input"; + input.setAttribute("role", "combobox"); + input.setAttribute("aria-expanded", "true"); + input.setAttribute("aria-controls", "cmdbar-results"); + input.setAttribute("aria-label", "Search sessions"); + input.setAttribute("autocomplete", "off"); + input.spellcheck = false; + input.placeholder = "Go to a session…"; + + const results = document.createElement("div"); + results.className = "cmdbar-results"; + results.id = "cmdbar-results"; + results.setAttribute("role", "listbox"); + + panel.append(input, results); + el.appendChild(panel); + document.body.appendChild(el); + + let rows: BarResult[] = []; + let highlighted = 0; + + function paintHighlight(): void { + for (const row of results.querySelectorAll(".cmdbar-row")) { + const active = Number(row.dataset.index) === highlighted; + row.classList.toggle("is-active", active); + row.setAttribute("aria-selected", active ? "true" : "false"); + if (active) { + row.scrollIntoView({ block: "nearest" }); + input.setAttribute("aria-activedescendant", row.id); + } + } + if (rows.length === 0) input.removeAttribute("aria-activedescendant"); + } + + function render(): void { + rows = barRows(input.value, candidates); + // The highlight starts on the globally best row every time the list + // changes: an old index would point at a different result. + highlighted = 0; + results.replaceChildren(); + + if (rows.length === 0) { + const empty = document.createElement("div"); + empty.className = "cmdbar-empty"; + empty.textContent = "No matches"; + results.appendChild(empty); + paintHighlight(); + return; + } + + let index = 0; + for (const group of barGroups(rows)) { + const header = document.createElement("div"); + header.className = "cmdbar-group-title"; + header.setAttribute("role", "presentation"); + header.textContent = group.title; + results.appendChild(header); + for (const result of group.results) { + const at = index; + const row = renderRow(result, at); + row.addEventListener("click", () => activate(at)); + results.appendChild(row); + index += 1; + } + } + paintHighlight(); + } + + /** The highlight does not wrap: it stops at the first and last row. */ + function move(delta: number): void { + if (rows.length === 0) return; + highlighted = Math.min(rows.length - 1, Math.max(0, highlighted + delta)); + paintHighlight(); + } + + function activate(index: number): void { + const result = rows[index]; + if (!result) return; + const sessionId = result.candidate.session.id; + close(); + openSession(sessionId); + } + + function onKeydown(e: KeyboardEvent): void { + switch (e.key) { + case "Escape": + e.preventDefault(); + close(); + return; + case "Enter": + e.preventDefault(); + activate(highlighted); + return; + case "ArrowDown": + e.preventDefault(); + move(1); + return; + case "ArrowUp": + e.preventDefault(); + move(-1); + return; + case "n": + if (e.ctrlKey) { + e.preventDefault(); + move(1); + } + return; + case "p": + if (e.ctrlKey) { + e.preventDefault(); + move(-1); + } + return; + } + } + + function close(): void { + if (current?.el !== el) return; + current = null; + el.remove(); + // Restoring focus is what makes the bar a view over the app rather than a + // place in it; a detached node cannot take it. + if (previouslyFocused?.isConnected) previouslyFocused.focus(); + } + + input.addEventListener("keydown", onKeydown); + input.addEventListener("input", render); + // A press on the scrim closes; one inside the panel is left alone. + el.addEventListener("mousedown", (ev) => { + if (!panel.contains(ev.target as Node)) close(); + }); + + current = { el, dispose: close }; + render(); + input.focus(); + return el; +} diff --git a/web/src/commands.ts b/web/src/commands.ts index e378da2..72a409d 100644 --- a/web/src/commands.ts +++ b/web/src/commands.ts @@ -49,6 +49,13 @@ export const COMMAND_SPECS: readonly CommandSpec[] = [ keys: ["N"], scope: "global", }, + { + id: "bar.open", + label: "Open the command bar", + group: "Sessions", + keys: ["ctrl-k"], + scope: "global", + }, { id: "focus.toggle", label: "Move the keyboard between the session list and the pane", diff --git a/web/src/main.ts b/web/src/main.ts index c75de4a..ec3b788 100644 --- a/web/src/main.ts +++ b/web/src/main.ts @@ -7,7 +7,8 @@ import { ConnectionPool } from "./connections.js"; import { installDrawer, installDrawerSwipe, requestToggleDrawer } from "./drawer.js"; import { installHudKeybind } from "./debug.js"; import { installHelpKeybind, isHelpOpen } from "./help.js"; -import { CommandSet, DEFAULT_LEADER, defineCommands } from "./commands.js"; +import { CommandSet, DEFAULT_LEADER, defineCommands, isTypingTarget } from "./commands.js"; +import { isCommandBarOpen, openCommandBar } from "./commandBar.js"; import { DaemonLink } from "./daemonLink.js"; import { CatalogStore } from "./events.js"; import { installRestartOverlay } from "./restartOverlay.js"; @@ -80,6 +81,15 @@ async function bootApp(): Promise { const store = new CatalogStore(); +// The daemon's home dir, for the command bar's `~/…` directory abbreviation. +// Absolute paths are a fine fallback until it lands. +let homeDir: string | undefined; +void getDaemonInfo() + .then((info) => (homeDir = info.home_dir)) + .catch(() => { + /* absolute paths are a fine fallback */ + }); + // The daemon's identity and liveness: names the release in the sidebar's status // dot, and blocks the UI behind the restart overlay while the daemon is away // (docs/RESTART.md). The events socket is the trigger — it dies first. @@ -399,12 +409,38 @@ const focusKeys = new CommandSet( { leader: DEFAULT_LEADER }, ); -window.addEventListener("keydown", (e) => { - if (focusKeys.handle(e)) return; - // The help overlay owns the keyboard while it is up. - if (isHelpOpen()) return; - spawnKeys.handle(e); -}); +// Ctrl+K opens the command bar (docs/COMMAND-BAR.md). It is a registry command +// like any other, so the `?` overlay lists it without a second list. +const barKeys = new CommandSet( + defineCommands({ + "bar.open": () => { + // Opening while a session is previewed discards the preview first, like + // any other real change (docs/PREVIEW.md): the bar is not previewed into. + schedulePreview.cancel(); + mv?.previewSession(null); + openCommandBar({ sessions: [...store.snapshot().sessions.values()], homeDir }); + }, + }), +); + +// Capture phase: an attached xterm swallows every keystroke it is given, so the +// bar's binding has to be seen ahead of the terminal (docs/COMMAND-BAR.md). +window.addEventListener( + "keydown", + (e) => { + // The bar owns the keyboard while it is up; its own input handles the keys. + if (isCommandBarOpen()) return; + if (focusKeys.handle(e)) return; + // The help overlay owns the keyboard while it is up. + if (isHelpOpen()) return; + // Opening the bar over a focused terminal is #152; until then the pane's + // hidden textarea keeps its Ctrl+K, like any other typing target. + if (!isTypingTarget(e.target) && barKeys.handle(e)) return; + spawnKeys.handle(e); + }, + true, +); + // Tab in the session list hands the keyboard to the pane; Escape in the pane // hands it back — the pair the TUI gets from `tab` and `esc`. diff --git a/web/src/styles.css b/web/src/styles.css index c4e3242..78e092f 100644 --- a/web/src/styles.css +++ b/web/src/styles.css @@ -1425,6 +1425,128 @@ input.leaf-rename:focus { color: var(--muted); } +/* ---------- command bar (Ctrl+K) — docs/COMMAND-BAR.md ---------- */ +/* Same family of surface as the spawn modal and the help overlay: the overlay + tokens below are theirs, not a third set. */ +.cmdbar-overlay { + position: fixed; + inset: 0; + z-index: var(--z-modal); + display: grid; + grid-template-rows: 12vh auto 1fr; + justify-items: center; + padding: var(--sp-4); + background: oklch(0 0 0 / 0.5); + backdrop-filter: blur(4px); +} + +.cmdbar-panel { + grid-row: 2; + display: flex; + flex-direction: column; + width: min(560px, 100%); + max-height: min(60vh, 520px); + overflow: hidden; + background: var(--surface); + border: 1px solid var(--border-strong); + border-radius: var(--r-3); + box-shadow: var(--shadow-2); +} + +.cmdbar-input { + appearance: none; + background: none; + border: 0; + border-bottom: 1px solid var(--border); + border-radius: 0; + color: var(--ink); + font-family: var(--font-mono); + font-size: var(--fs-15); + padding: var(--sp-3) var(--sp-4); +} + +.cmdbar-input:focus-visible { + outline: none; +} + +.cmdbar-results { + overflow-y: auto; + padding: var(--sp-2) 0; +} + +.cmdbar-group-title { + padding: var(--sp-2) var(--sp-4) var(--sp-1); + font-size: var(--fs-11); + font-weight: 600; + letter-spacing: 0.08em; + text-transform: uppercase; + color: var(--faint); +} + +.cmdbar-row { + display: flex; + align-items: baseline; + gap: var(--sp-3); + padding: var(--sp-1) var(--sp-4); + cursor: pointer; +} + +.cmdbar-row.is-active { + background: var(--surface-3); +} + +.cmdbar-row-name { + font-size: var(--fs-13); + color: var(--ink); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +/* A dead session still lists — it just is not where you just were. */ +.cmdbar-row.is-dead .cmdbar-row-name { + color: var(--muted); +} + +.cmdbar-row-meta { + flex: 1 1 auto; + min-width: 0; + font-size: var(--fs-11); + color: var(--faint); + text-align: right; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.cmdbar-hit { + background: none; + color: var(--accent); + font-weight: 600; +} + +.cmdbar-empty { + padding: var(--sp-3) var(--sp-4); + font-size: var(--fs-13); + color: var(--muted); +} + +/* Phone: the bottom-sheet treatment the spawn modal already uses. */ +@media (max-width: 600px) { + .cmdbar-overlay { + grid-template-rows: 1fr auto; + padding: 0; + } + + .cmdbar-panel { + grid-row: 2; + width: 100%; + max-height: 70vh; + border-radius: var(--r-3) var(--r-3) 0 0; + border-bottom: 0; + } +} + /* Line 2 — dot + duration in the state's colour, then muted fragments. The state word itself is never spelled out here; the colour carries it. */ .card-line2 { From 83741d720337a7d15787875d387422093bcba840 Mon Sep 17 00:00:00 2001 From: Miguel Palhas Date: Thu, 3 Sep 2026 16:27:11 +0100 Subject: [PATCH 06/14] feat(maestro): add Rust match-and-rank matcher for command bar Ports the docs/COMMAND-BAR.md scoring algorithm (dynamic program over query/text positions, word-boundary bonuses, whole-field adjustment, four-level tie-break) to crates/maestro-tui, matching web/src/matcher.ts. Fixtures F1-F12 from the doc are ported as tests, asserting order, scores, field and ranges. --- crates/maestro-tui/src/lib.rs | 1 + crates/maestro-tui/src/matcher.rs | 750 ++++++++++++++++++++++++++++++ 2 files changed, 751 insertions(+) create mode 100644 crates/maestro-tui/src/matcher.rs diff --git a/crates/maestro-tui/src/lib.rs b/crates/maestro-tui/src/lib.rs index d180427..80745b7 100644 --- a/crates/maestro-tui/src/lib.rs +++ b/crates/maestro-tui/src/lib.rs @@ -13,6 +13,7 @@ pub mod emulators; pub mod fast_forward; pub mod input; pub mod keymap; +pub mod matcher; pub mod overlays; pub mod palette; pub mod pane; diff --git a/crates/maestro-tui/src/matcher.rs b/crates/maestro-tui/src/matcher.rs new file mode 100644 index 0000000..e552b17 --- /dev/null +++ b/crates/maestro-tui/src/matcher.rs @@ -0,0 +1,750 @@ +//! Pure match-and-rank function for the `ctrl-k` command bar. +//! +//! Implements exactly the algorithm in `docs/COMMAND-BAR.md` ("What a +//! candidate is", "Match and rank", "Fixtures"). Must produce the same order +//! and scores as `web/src/matcher.ts` for the same input. + +use std::collections::HashMap; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum CandidateKind { + Session, + Command, + Directory, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum MatchFieldName { + Name, + Directory, + Command, + Tool, + Label, + Path, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct SessionCandidate { + pub name: String, + pub directory: Option, + pub command: Option, + pub tool: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct CommandCandidate { + pub label: String, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct DirectoryCandidate { + pub path: String, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum MatchCandidate { + Session(SessionCandidate), + Command(CommandCandidate), + Directory(DirectoryCandidate), +} + +pub type MatchRange = (usize, usize); + +#[derive(Debug, PartialEq, Eq)] +pub struct MatchResult<'c> { + pub candidate: &'c MatchCandidate, + pub score: i32, + pub field: Option, + pub ranges: Vec, +} + +struct Field<'c> { + name: MatchFieldName, + text: &'c str, + weight: i32, +} + +impl MatchCandidate { + fn kind(&self) -> CandidateKind { + match self { + MatchCandidate::Session(_) => CandidateKind::Session, + MatchCandidate::Command(_) => CandidateKind::Command, + MatchCandidate::Directory(_) => CandidateKind::Directory, + } + } + + fn fields(&self) -> Vec> { + match self { + MatchCandidate::Session(session) => { + let mut fields = vec![Field { + name: MatchFieldName::Name, + text: &session.name, + weight: 0, + }]; + if let Some(directory) = &session.directory { + fields.push(Field { + name: MatchFieldName::Directory, + text: directory, + weight: -24, + }); + } + if let Some(command) = &session.command { + fields.push(Field { + name: MatchFieldName::Command, + text: command, + weight: -32, + }); + } + if let Some(tool) = &session.tool { + fields.push(Field { + name: MatchFieldName::Tool, + text: tool, + weight: -40, + }); + } + fields + } + MatchCandidate::Command(command) => vec![Field { + name: MatchFieldName::Label, + text: &command.label, + weight: 0, + }], + MatchCandidate::Directory(directory) => vec![Field { + name: MatchFieldName::Path, + text: &directory.path, + weight: 0, + }], + } + } +} + +fn kind_order(kind: CandidateKind) -> u8 { + match kind { + CandidateKind::Session => 0, + CandidateKind::Command => 1, + CandidateKind::Directory => 2, + } +} + +fn lower_char(c: char) -> char { + c.to_lowercase().next().unwrap_or(c) +} + +fn lower_chars(chars: &[char]) -> Vec { + chars.iter().copied().map(lower_char).collect() +} + +fn normalize_query(query: &str) -> Vec { + query + .chars() + .filter(|c| !c.is_whitespace()) + .map(lower_char) + .collect() +} + +const SEPARATORS: [char; 6] = ['/', '\\', '-', '_', '.', ':']; + +fn boundary_bonus(text: &[char], position: usize) -> i32 { + if position == 0 { + return 18; + } + + let previous = text[position - 1]; + if previous == ' ' || SEPARATORS.contains(&previous) { + return 16; + } + + let current = text[position]; + if current.is_uppercase() && previous.is_lowercase() { + return 12; + } + + let is_digit = current.is_ascii_digit(); + let previous_is_digit = previous.is_ascii_digit(); + if is_digit && !previous_is_digit { + return 12; + } + + 0 +} + +fn contribution(text: &[char], position: usize, previous_position: isize) -> i32 { + let gap = position as isize - previous_position - 1; + 16 + boundary_bonus(text, position) + if gap == 0 { 8 } else { -gap.min(12) as i32 } +} + +#[derive(Clone)] +struct Embedding { + score: i32, + positions: Vec, +} + +fn is_better_embedding(candidate: &Embedding, incumbent: Option<&Embedding>) -> bool { + let Some(incumbent) = incumbent else { + return true; + }; + if candidate.score != incumbent.score { + return candidate.score > incumbent.score; + } + for (c, i) in candidate.positions.iter().zip(incumbent.positions.iter()) { + if c != i { + return c < i; + } + } + false +} + +/// The dynamic program over `(query index, text position)` described in +/// "Which embedding": greedy leftmost-first matching is insufficient (e.g. +/// `mtu` against `maestro-tui`), so every reachable position at each query +/// index carries its best score and position vector forward. +fn best_embedding(query: &[char], text: &[char]) -> Option { + let lower_text = lower_chars(text); + let mut previous_states: HashMap = HashMap::new(); + + for (query_index, &qc) in query.iter().enumerate() { + let mut states: HashMap = HashMap::new(); + + for (position, &text_char) in lower_text.iter().enumerate() { + if text_char != qc { + continue; + } + + if query_index == 0 { + states.insert( + position, + Embedding { + score: contribution(text, position, -1), + positions: vec![position], + }, + ); + continue; + } + + for (&previous_position, previous) in previous_states.iter() { + if previous_position >= position { + continue; + } + let mut positions = previous.positions.clone(); + positions.push(position); + let candidate = Embedding { + score: previous.score + + contribution(text, position, previous_position as isize), + positions, + }; + if is_better_embedding(&candidate, states.get(&position)) { + states.insert(position, candidate); + } + } + } + + if states.is_empty() { + return None; + } + previous_states = states; + } + + let mut best: Option = None; + for embedding in previous_states.into_values() { + if is_better_embedding(&embedding, best.as_ref()) { + best = Some(embedding); + } + } + best +} + +fn to_ranges(positions: &[usize]) -> Vec { + let mut ranges: Vec = Vec::new(); + for &position in positions { + if let Some(last) = ranges.last_mut() { + if last.1 == position { + last.1 = position + 1; + continue; + } + } + ranges.push((position, position + 1)); + } + ranges +} + +fn compact(text: &str) -> String { + text.chars() + .filter(|c| !c.is_whitespace()) + .map(lower_char) + .collect() +} + +struct Ranked<'c> { + candidate: &'c MatchCandidate, + score: i32, + field: Option, + ranges: Vec, + matched_text: String, + input_index: usize, +} + +/// `(query, candidates) -> ranked subset with matched ranges`. Knows nothing +/// about sessions, panes or the network — see docs/COMMAND-BAR.md. +pub fn match_and_rank<'c>(query: &str, candidates: &'c [MatchCandidate]) -> Vec> { + let normalized_query = normalize_query(query); + + if normalized_query.is_empty() { + return candidates + .iter() + .map(|candidate| MatchResult { + candidate, + score: 0, + field: None, + ranges: Vec::new(), + }) + .collect(); + } + + let mut results: Vec> = Vec::new(); + + for (input_index, candidate) in candidates.iter().enumerate() { + let mut best: Option<(MatchFieldName, &str, i32, Vec)> = None; + + for field in candidate.fields() { + let text_chars: Vec = field.text.chars().collect(); + let Some(embedding) = best_embedding(&normalized_query, &text_chars) else { + continue; + }; + + let compact_text = compact(field.text); + let normalized_query_str: String = normalized_query.iter().collect(); + let adjustment = if compact_text == normalized_query_str { + 64 + } else if compact_text.starts_with(&normalized_query_str) { + 32 + } else { + 0 + }; + let score = embedding.score + adjustment + field.weight; + + let is_better = match &best { + None => true, + Some((_, _, best_score, _)) => score > *best_score, + }; + if is_better { + best = Some((field.name, field.text, score, embedding.positions)); + } + } + + if let Some((field_name, field_text, score, positions)) = best { + results.push(Ranked { + candidate, + score, + field: Some(field_name), + ranges: to_ranges(&positions), + matched_text: field_text.to_string(), + input_index, + }); + } + } + + results.sort_by(|a, b| { + b.score + .cmp(&a.score) + .then_with(|| kind_order(a.candidate.kind()).cmp(&kind_order(b.candidate.kind()))) + .then_with(|| { + a.matched_text + .chars() + .count() + .cmp(&b.matched_text.chars().count()) + }) + .then_with(|| a.matched_text.cmp(&b.matched_text)) + .then_with(|| a.input_index.cmp(&b.input_index)) + }); + + results + .into_iter() + .map(|ranked| MatchResult { + candidate: ranked.candidate, + score: ranked.score, + field: ranked.field, + ranges: ranked.ranges, + }) + .collect() +} + +#[cfg(test)] +mod tests { + use super::*; + + fn session( + name: &str, + directory: Option<&str>, + command: Option<&str>, + tool: Option<&str>, + ) -> MatchCandidate { + MatchCandidate::Session(SessionCandidate { + name: name.to_string(), + directory: directory.map(str::to_string), + command: command.map(str::to_string), + tool: tool.map(str::to_string), + }) + } + + fn command(label: &str) -> MatchCandidate { + MatchCandidate::Command(CommandCandidate { + label: label.to_string(), + }) + } + + fn directory(path: &str) -> MatchCandidate { + MatchCandidate::Directory(DirectoryCandidate { + path: path.to_string(), + }) + } + + fn label(candidate: &MatchCandidate) -> String { + match candidate { + MatchCandidate::Session(s) => format!("session:{}", s.name), + MatchCandidate::Command(c) => format!("command:{}", c.label), + MatchCandidate::Directory(d) => format!("directory:{}", d.path), + } + } + + fn positions(result: &MatchResult) -> Vec { + result + .ranges + .iter() + .flat_map(|&(start, end)| start..end) + .collect() + } + + #[allow(clippy::type_complexity)] + fn expect_results( + query: &str, + candidates: &[MatchCandidate], + expected: &[(&str, i32, Option, &[usize])], + ) { + let results = match_and_rank(query, candidates); + + let actual: Vec<(String, i32)> = results + .iter() + .map(|r| (label(r.candidate), r.score)) + .collect(); + let expected_labels: Vec<(String, i32)> = expected + .iter() + .map(|(l, s, _, _)| (l.to_string(), *s)) + .collect(); + assert_eq!(actual, expected_labels, "order/scores for query {query:?}"); + + let actual_fields: Vec> = results.iter().map(|r| r.field).collect(); + let expected_fields: Vec> = + expected.iter().map(|(_, _, f, _)| *f).collect(); + assert_eq!(actual_fields, expected_fields, "fields for query {query:?}"); + + let actual_positions: Vec> = results.iter().map(positions).collect(); + let expected_positions: Vec> = + expected.iter().map(|(_, _, _, p)| p.to_vec()).collect(); + assert_eq!( + actual_positions, expected_positions, + "positions for query {query:?}" + ); + } + + #[test] + fn f1_empty_query_keeps_input_order() { + expect_results( + "", + &[ + session("kill", Some("~/dev/api"), Some("bash"), None), + session( + "killer-app", + Some("~/dev/killer-app"), + Some("claude --resume"), + Some("claude"), + ), + command("Kill selected session"), + ], + &[ + ("session:kill", 0, None, &[]), + ("session:killer-app", 0, None, &[]), + ("command:Kill selected session", 0, None, &[]), + ], + ); + } + + #[test] + fn f2_exact_session_name_beats_prefix_kind_breaks_tie() { + expect_results( + "kill", + &[ + session("kill", Some("~/dev/api"), Some("bash"), None), + session( + "killer-app", + Some("~/dev/killer-app"), + Some("claude --resume"), + Some("claude"), + ), + command("Kill selected session"), + ], + &[ + ( + "session:kill", + 178, + Some(MatchFieldName::Name), + &[0, 1, 2, 3], + ), + ( + "session:killer-app", + 146, + Some(MatchFieldName::Name), + &[0, 1, 2, 3], + ), + ( + "command:Kill selected session", + 146, + Some(MatchFieldName::Label), + &[0, 1, 2, 3], + ), + ], + ); + } + + #[test] + fn f3_path_segment_boundaries_beat_mid_segment() { + expect_results( + "foo", + &[ + directory("~/dev/foobar"), + directory("~/src/myfoo"), + directory("~/foo"), + ], + &[ + ( + "directory:~/foo", + 78, + Some(MatchFieldName::Path), + &[2, 3, 4], + ), + ( + "directory:~/dev/foobar", + 74, + Some(MatchFieldName::Path), + &[6, 7, 8], + ), + ( + "directory:~/src/myfoo", + 56, + Some(MatchFieldName::Path), + &[8, 9, 10], + ), + ], + ); + } + + #[test] + fn f4_no_match_returns_empty() { + expect_results( + "zzz", + &[ + session("kill", Some("~/dev/api"), Some("bash"), None), + command("Kill selected session"), + ], + &[], + ); + } + + #[test] + fn f5_highest_scoring_embedding_beats_greedy() { + expect_results( + "mtu", + &[ + session("maestro-tui", None, None, None), + session("maestro-daemon", None, None, None), + session("mtui-scratch", None, None, None), + ], + &[ + ( + "session:mtui-scratch", + 122, + Some(MatchFieldName::Name), + &[0, 1, 2], + ), + ( + "session:maestro-tui", + 91, + Some(MatchFieldName::Name), + &[0, 8, 9], + ), + ], + ); + } + + #[test] + fn f6_name_beats_weighted_directory() { + expect_results( + "api", + &[ + session("api", Some("~/dev/web"), Some("bash"), None), + session("worker", Some("~/dev/api"), Some("bash"), None), + ], + &[ + ("session:api", 154, Some(MatchFieldName::Name), &[0, 1, 2]), + ( + "session:worker", + 50, + Some(MatchFieldName::Directory), + &[6, 7, 8], + ), + ], + ); + } + + #[test] + fn f7_case_insensitive_length_breaks_tie() { + expect_results( + "api", + &[ + session("API-Gateway", None, None, None), + session("apiary", None, None, None), + ], + &[ + ( + "session:apiary", + 122, + Some(MatchFieldName::Name), + &[0, 1, 2], + ), + ( + "session:API-Gateway", + 122, + Some(MatchFieldName::Name), + &[0, 1, 2], + ), + ], + ); + } + + #[test] + fn f8_exact_tool_beats_command_field() { + expect_results( + "claude", + &[ + session( + "nameless", + Some("~/dev/x"), + Some("claude --resume"), + Some("claude"), + ), + session("clay", Some("~/dev/y"), Some("bash"), None), + ], + &[( + "session:nameless", + 186, + Some(MatchFieldName::Tool), + &[0, 1, 2, 3, 4, 5], + )], + ); + } + + #[test] + fn f9_separator_earns_word_boundary_bonus() { + expect_results( + "ws", + &[ + session("websearch", None, None, None), + session("web-search", None, None, None), + ], + &[ + ( + "session:web-search", + 71, + Some(MatchFieldName::Name), + &[0, 4], + ), + ("session:websearch", 56, Some(MatchFieldName::Name), &[0, 3]), + ], + ); + } + + #[test] + fn f10_command_labels_rank_by_boundary_and_gap() { + expect_results( + "se", + &[ + command("Kill selected session"), + command("Create a session"), + command("Show help"), + ], + &[ + ( + "command:Show help", + 53, + Some(MatchFieldName::Label), + &[0, 6], + ), + ( + "command:Kill selected session", + 51, + Some(MatchFieldName::Label), + &[5, 6], + ), + ( + "command:Create a session", + 47, + Some(MatchFieldName::Label), + &[9, 10], + ), + ], + ); + } + + #[test] + fn f11_directory_matches_separate_on_initial_gap() { + expect_results( + "dev", + &[ + session("api", Some("~/dev/api"), Some("bash"), None), + session("notes", Some("~/documents/dev-notes"), Some("nvim"), None), + session("scratch", Some("~/srv/devops"), Some("bash"), None), + ], + &[ + ( + "session:api", + 54, + Some(MatchFieldName::Directory), + &[2, 3, 4], + ), + ( + "session:scratch", + 50, + Some(MatchFieldName::Directory), + &[6, 7, 8], + ), + ( + "session:notes", + 44, + Some(MatchFieldName::Directory), + &[12, 13, 14], + ), + ], + ); + } + + #[test] + fn f12_exact_beats_prefix() { + expect_results( + "quit", + &[command("Quit Maestro"), command("Quit")], + &[ + ( + "command:Quit", + 178, + Some(MatchFieldName::Label), + &[0, 1, 2, 3], + ), + ( + "command:Quit Maestro", + 146, + Some(MatchFieldName::Label), + &[0, 1, 2, 3], + ), + ], + ); + } +} From dc783778c8e751cabfac2c54eda159aefd60824f Mon Sep 17 00:00:00 2001 From: Miguel Palhas Date: Thu, 3 Sep 2026 16:42:21 +0100 Subject: [PATCH 07/14] feat(maestro): add the ctrl-k command bar to the TUI Opens from Normal and Insert mode as an ordinary keymap entry, so it is rebindable and the help overlay picks it up from the live keymap. An empty query lists sessions most recently focused first, matching the web bar's `orderSessionsForBar`; typing ranks through `matcher.rs` with the matched ranges highlighted, grouped under kind headers. Enter attaches through the same path the sidebar's attach uses, Escape closes and leaves focus and mode where they were. --- TASK.md | 115 ++++ crates/maestro-protocol/src/display.rs | 53 ++ crates/maestro-tui/src/action.rs | 18 + crates/maestro-tui/src/app.rs | 231 ++++++++ crates/maestro-tui/src/command_bar.rs | 758 +++++++++++++++++++++++++ crates/maestro-tui/src/config.rs | 11 +- crates/maestro-tui/src/lib.rs | 1 + crates/maestro-tui/src/render.rs | 15 + crates/maestro-tui/src/state.rs | 4 + 9 files changed, 1204 insertions(+), 2 deletions(-) create mode 100644 TASK.md create mode 100644 crates/maestro-tui/src/command_bar.rs diff --git a/TASK.md b/TASK.md new file mode 100644 index 0000000..796f01f --- /dev/null +++ b/TASK.md @@ -0,0 +1,115 @@ +# Task: Gitea issue #153 — the TUI command bar (action, overlay, session results) + +You are an autonomous worker in a blitz over the **Command bar** milestone of +the `naps62/maestro` Gitea repo (`https://git.naps.pt`, `$GITEA_TOKEN` is in +`~/.env.claude` — `source` it). + +Both of your dependencies are merged and on your base branch. + +## Read first + +1. **`docs/COMMAND-BAR.md`** — the contract. "Opening", "Structure", "Empty + query" and "Keys" are yours. Implement what it says. +2. The issue body: + `curl -sS -H "Authorization: token $GITEA_TOKEN" https://git.naps.pt/api/v1/repos/naps62/maestro/issues/153 | jq -r .body` +3. `CLAUDE.md` — conventions. **TDD by default**; `cargo fmt` and + `cargo clippy --all-targets -- -D warnings` before every commit. +4. What you build on, all merged: + - `crates/maestro-tui/src/matcher.rs` (#147) — the match-and-rank function + and its candidate types. **Do not reimplement matching or reorder its + results.** + - `crates/maestro-protocol` — `Session::last_focused_at` (#145), the + daemon's recency stamp. + - `crates/maestro-tui/src/`: `action.rs`, `keymap.rs`, `config.rs` + (`DEFAULT_NORMAL` / `DEFAULT_INSERT`), `state.rs` (`OverlayKind`), + `overlays.rs`, `app.rs` (`handle_overlay_input` and the dispatcher), + `text_input.rs` (the edit buffer the rename prompt already uses). +5. **`web/src/commandBar.ts`** (#149) — the web bar implementing this same + contract, already merged. Read it, especially `orderSessionsForBar`. Where + the doc leaves room, match what it does; the two clients disagreeing is the + failure this contract exists to prevent. + +## What to build + +- **`Action::OpenCommandBar`** — new variant, with its `parse` arm, `Display` + arm, `help_label`, and dispatcher arm. `Action::parse` and `Display` must + round-trip, as the existing tests require of every variant. +- **Bind `ctrl-k` in both `DEFAULT_NORMAL` and `DEFAULT_INSERT`**, so it opens + from the sidebar and from inside a pane. Because it is an ordinary keymap + entry it stays rebindable, and the help overlay — which builds its lines from + the live keymap — picks it up with no second list. +- **A new `OverlayKind` variant**, routed in `handle_overlay_input` alongside + Spawn, KillConfirm, Rename and Help. Follow how `Rename` keeps its edit + buffer on the app rather than in the enum, so the overlay enum stays + comparable. +- **Empty query lists sessions, most recently focused first**, by + `last_focused_at`. The doc fixes what happens to never-focused and dead + sessions — follow it exactly, and match `orderSessionsForBar`. +- **Typing fuzzy-matches sessions** through `matcher.rs`, with matched ranges + highlighted in the rendered row. Build candidates from the fields the doc's + candidate table names, directory abbreviated as the sidebar shows it. +- **Results render under kind headers**, groups ordered by best-scoring member, + highlight starting on the globally best row. Only sessions exist as a kind + here, but build the grouping so #154 can add commands and directories without + restructuring it. +- **Enter attaches the highlighted session.** Escape closes. Up/down and + `ctrl-p`/`ctrl-n` move the highlight, which does not wrap; headers are never + landed on. + +**Module name**: `palette.rs` is the colour palette. Do not put this there and +do not name your module `palette`. + +## Non-goals — later issues own these, do not build them + +- The `>` and `~` sigils (#154). +- Tab drilling into a session's action page (#155). +- The leader sending a literal Ctrl-K to the pane (#156). + +Build the grouping and query line so those slot in without a rewrite, but do +not implement them. + +## Ground rules + +- You are on `command-bar/153-tui-bar`, cut from `origin/main`. **Merge + `origin/blitz/command-bar` first** (merge, never rebase) — everything you + depend on is there and not on `main`. Merge it again if it moves under you. +- Stay inside `crates/`. Do not touch `web/`, and do not modify `matcher.rs`; + if you believe it is wrong, comment on the issue instead. +- Integration tests use **real tmux** — don't mock it; isolate with a temp + `$HOME` and `TMUX_TMPDIR` the way the existing tests do. `overlays.rs` shows + how the existing overlays are tested against a `TestBackend`. +- Run the suite at most once at the end, through the gate: + `/home/naps62/.claude/skills/tracker-common/scripts/gate.sh -- cargo test --workspace`. + Exit 75 means no slot — a normal outcome, not a failure: say so and push + anyway. Exit 137 is the memory cap, not a test failure. +- Report the **aggregate test count**, not one crate's. +- Commit convention: `feat(maestro): ...`. +- Bugs or problems outside this issue's scope: **comment them on issue #153**, + do not fix them silently. Earlier workers' reports changed the scope of two + later issues before they started. +- Never wait for input. + +## Exit criteria + +Do not end your final reply until ALL of these are true: + +1. `Action::OpenCommandBar` exists, round-trips through `parse`/`Display`, and + has a plain-language `help_label`. +2. `ctrl-k` is bound in both the Normal and Insert default keymaps and opens + the bar from each. +3. Empty query lists sessions by recency, with the doc's rules for + never-focused and dead sessions, matching `orderSessionsForBar`. +4. Typing ranks through `matcher.rs` with ranges highlighted, under kind + headers. +5. Enter attaches; Escape, arrows and `ctrl-p`/`ctrl-n` behave as the doc's key + table says. +6. `cargo fmt` clean, `cargo clippy --all-targets -- -D warnings` clean, suite + green (or the gate returned 75/137 and you said which). +7. Committed on `command-bar/153-tui-bar` and the branch **pushed** to + `origin`. + +Do **not** merge into `blitz/command-bar` and do **not** close the issue — the +orchestrator does both. + +If you catch yourself summarizing progress before those seven are true, you +have stopped too early — keep going. diff --git a/crates/maestro-protocol/src/display.rs b/crates/maestro-protocol/src/display.rs index 474949d..9aac71b 100644 --- a/crates/maestro-protocol/src/display.rs +++ b/crates/maestro-protocol/src/display.rs @@ -202,6 +202,41 @@ pub fn command_program(command: &str) -> String { } } +/// Program name (already basenamed and lowercased) -> the tool id its mark +/// stands for. Mirrors the web's `BY_PROGRAM` in `web/src/toolIcon.ts`; +/// recognition is deliberately narrow, so an unrecognised program has no tool. +const BY_PROGRAM: [(&str, &str); 17] = [ + ("claude", "claude"), + ("claude-code", "claude"), + ("codex", "codex"), + ("opencode", "opencode"), + ("gemini", "gemini"), + ("aider", "aider"), + ("cursor", "cursor"), + ("cursor-agent", "cursor"), + ("pi", "pi"), + ("bash", "shell"), + ("zsh", "shell"), + ("sh", "shell"), + ("fish", "shell"), + ("dash", "shell"), + ("ksh", "shell"), + ("nu", "shell"), + ("xonsh", "shell"), +]; + +/// The tool id a command line's icon stands for (`claude`, `codex`, +/// `opencode`, ...), or `None` when the program is not one we have a mark for. +/// The command bar matches this field (`docs/COMMAND-BAR.md`), so it must name +/// the same tools the web's `splitCommand().tool` does. +pub fn command_tool(command: &str) -> Option<&'static str> { + let program = command_program(command).to_lowercase(); + BY_PROGRAM + .iter() + .find(|(name, _)| *name == program) + .map(|(_, tool)| *tool) +} + /// `FOO=bar`, the shell's own prefix for one command's environment. fn is_env_assignment(word: &str) -> bool { let mut chars = word.chars(); @@ -476,4 +511,22 @@ mod tests { assert_eq!(command_program("sudo"), "sudo"); assert_eq!(command_program(""), ""); } + + /// The command bar's `tool` field (docs/COMMAND-BAR.md) names the same + /// tools the web's `splitCommand().tool` does. + #[test] + fn command_tool_names_the_mark_the_card_shows() { + assert_eq!(command_tool("claude --resume"), Some("claude")); + assert_eq!(command_tool("npx opencode"), Some("opencode")); + assert_eq!(command_tool("/bin/zsh -l"), Some("shell")); + assert_eq!(command_tool("CLAUDE"), Some("claude")); + } + + /// Narrow on purpose: an unrecognised program has no mark, so the + /// candidate has no `tool` field to match rather than a wrong one. + #[test] + fn command_tool_is_absent_for_an_unrecognised_program() { + assert_eq!(command_tool("hx src/main.rs"), None); + assert_eq!(command_tool(""), None); + } } diff --git a/crates/maestro-tui/src/action.rs b/crates/maestro-tui/src/action.rs index d821a6d..7134a9d 100644 --- a/crates/maestro-tui/src/action.rs +++ b/crates/maestro-tui/src/action.rs @@ -53,6 +53,9 @@ pub enum Action { RenameHighlighted, // Chrome + /// Open the `ctrl-k` command bar (`docs/COMMAND-BAR.md`) — a query line + /// over the sessions, ranked by the shared matcher. + OpenCommandBar, ToggleSidebar, ShowHelp, Quit, @@ -94,6 +97,7 @@ impl Action { Action::FastForwardSpawn => "Create session with defaults".into(), Action::KillHighlighted => "Kill selected session".into(), Action::RenameHighlighted => "Rename selected session".into(), + Action::OpenCommandBar => "Go to a session".into(), Action::ToggleSidebar => "Show or hide sidebar".into(), Action::ShowHelp => "Show help".into(), Action::Quit => "Quit Maestro".into(), @@ -120,6 +124,7 @@ impl Action { "FastForwardSpawn" => return Ok(Action::FastForwardSpawn), "KillHighlighted" => return Ok(Action::KillHighlighted), "RenameHighlighted" => return Ok(Action::RenameHighlighted), + "OpenCommandBar" => return Ok(Action::OpenCommandBar), "ToggleSidebar" => return Ok(Action::ToggleSidebar), "ShowHelp" => return Ok(Action::ShowHelp), "Quit" => return Ok(Action::Quit), @@ -239,10 +244,23 @@ mod tests { ); } + /// The command bar is an ordinary keymap entry, so it must round-trip + /// like every other action (#153). + #[test] + fn open_command_bar_round_trips() { + assert_eq!( + Action::parse("OpenCommandBar").unwrap(), + Action::OpenCommandBar + ); + assert_eq!(Action::OpenCommandBar.to_string(), "OpenCommandBar"); + assert_eq!(Action::OpenCommandBar.help_label(), "Go to a session"); + } + #[test] fn display_round_trips() { for a in [ Action::EnterInsert, + Action::OpenCommandBar, Action::RenameHighlighted, Action::Quit, Action::OpenSpawnModal, diff --git a/crates/maestro-tui/src/app.rs b/crates/maestro-tui/src/app.rs index a9a8e4a..089a808 100644 --- a/crates/maestro-tui/src/app.rs +++ b/crates/maestro-tui/src/app.rs @@ -23,6 +23,7 @@ use uuid::Uuid; use crate::action::Action; use crate::catalog::CatalogStore; +use crate::command_bar::{BarOutcome, BarTarget, CommandBar}; use crate::config::TuiConfig; use crate::daemon_link::DaemonLink; use crate::draft::{DraftInputResult, DraftPane}; @@ -78,6 +79,9 @@ pub struct App { /// Edit buffer behind [`OverlayKind::Rename`] (#125), kept here for the /// same reason the worktree entries are. rename_input: TextInput, + /// Query buffer and result list behind [`OverlayKind::CommandBar`] (#153), + /// kept here for the same reason the rename buffer is. + command_bar: Option, region: RegionMap, last_sent_size: HashMap, clicks: ClickTracker, @@ -210,6 +214,7 @@ impl App { link, http, rename_input: TextInput::default(), + command_bar: None, draft_tx, draft_rx, region: RegionMap::default(), @@ -430,6 +435,32 @@ impl App { (OverlayDecision::Consumed, Vec::new()) } }, + // The bar owns every key while it is up: the keymap must not see + // `j`, `x` or `q` as commands mid-query. + Some(OverlayKind::CommandBar) => { + let outcome = match self.command_bar.as_mut() { + Some(bar) => bar.handle_key(key), + None => BarOutcome::Close, + }; + match outcome { + BarOutcome::Stay => (OverlayDecision::Consumed, Vec::new()), + BarOutcome::Close => { + self.close_command_bar(); + (OverlayDecision::Consumed, Vec::new()) + } + BarOutcome::Open(BarTarget::Session(sid)) => { + self.close_command_bar(); + // The same open path the sidebar's own attach uses, so + // a bar attach can never leave the layout in a state a + // highlight-and-enter could not produce. + self.state.sidebar_highlight = Some(SidebarItem::Session(sid)); + ( + OverlayDecision::Consumed, + self.dispatch(Action::AttachHighlighted), + ) + } + } + } Some(OverlayKind::Help) => { self.state.dismiss_overlay(); (OverlayDecision::Consumed, Vec::new()) @@ -475,6 +506,7 @@ impl App { }; self.state.show_overlay(OverlayKind::Help); } + Action::OpenCommandBar => self.open_command_bar(), Action::OpenSpawnModal => { self.open_spawn_modal(); effects.push(Effect::LoadDraft); @@ -537,6 +569,10 @@ impl App { /// highlight already sitting on the attached session — clears at once. fn refresh_preview(&mut self) { let wanted = match (self.state.focus, &self.state.sidebar_highlight) { + // The bar is a view over the app, not a place in it: while it is + // up the sidebar highlight behind it previews nothing + // (`docs/COMMAND-BAR.md`, "Focus round-trip"). + _ if self.state.overlay() == Some(&OverlayKind::CommandBar) => None, (Focus::Sidebar, Some(SidebarItem::Session(sid))) => Some(*sid), _ => None, }; @@ -588,6 +624,25 @@ impl App { } } + /// Put the command bar up over whatever is behind it (#153). Opening + /// while a session is previewed discards the preview first, like any + /// other real change (`docs/PREVIEW.md`); focus and mode are left alone, + /// so closing gives the keyboard back to whatever held it. + fn open_command_bar(&mut self) { + let snapshot = self.catalog.snapshot(); + self.command_bar = Some(CommandBar::new(&snapshot.sessions, &self.home_dir)); + self.pending_preview = None; + self.clear_preview(); + self.state.show_overlay(OverlayKind::CommandBar); + } + + /// Take the bar down, dropping its query. Every exit goes through here so + /// a stale buffer can never outlive the overlay. + fn close_command_bar(&mut self) { + self.command_bar = None; + self.state.dismiss_overlay(); + } + fn clear_preview(&mut self) { if self.state.preview.take().is_some() { self.epoch = self.epoch.wrapping_add(1); @@ -1222,6 +1277,7 @@ impl App { draft: self.draft.as_ref(), help: &self.help, rename_input: &self.rename_input, + command_bar: self.command_bar.as_ref(), link: &link_status, }; @@ -2716,6 +2772,181 @@ mod tests { .is_none()); } + // -- command bar (#153) ------------------------------------------------- + + fn seed_two_sessions(app: &mut App) -> (Uuid, Uuid) { + use chrono::Utc; + use maestro_protocol::{CatalogEvent, Session, SessionStatus}; + let make = |n: u128, name: &str, focused: Option>| Session { + id: Uuid::from_u128(n), + command: "bash".into(), + cwd: std::path::PathBuf::from("/"), + env: Default::default(), + metadata: HashMap::from([( + "name".to_string(), + serde_json::Value::String(name.to_string()), + )]), + worktree: None, + status: SessionStatus::Running, + created_at: Utc::now(), + foreground: None, + activity: Default::default(), + activity_detail: None, + last_activity_at: Utc::now(), + activity_since: Utc::now(), + current_cwd: None, + blocked_work: None, + last_focused_at: focused, + }; + let older = Utc::now() - chrono::Duration::seconds(60); + app.catalog.apply_event(CatalogEvent::Snapshot { + sessions: vec![ + make(1, "alpha", Some(older)), + make(2, "bravo", Some(Utc::now())), + ], + }); + (Uuid::from_u128(1), Uuid::from_u128(2)) + } + + /// `ctrl-k` is an ordinary keymap entry, bound in both modes so the bar + /// opens from the sidebar and from inside a pane (`docs/COMMAND-BAR.md`). + #[test] + fn ctrl_k_is_bound_to_the_command_bar_in_both_modes() { + let app = make_app(); + assert_eq!( + app.cfg.keymap.resolve(Mode::Normal, "ctrl-k"), + Some(Action::OpenCommandBar) + ); + assert_eq!( + app.cfg.keymap.resolve(Mode::Insert, "ctrl-k"), + Some(Action::OpenCommandBar) + ); + } + + /// The help overlay builds its lines from the live keymap, so the bar + /// appears there with no second list to maintain. + #[test] + fn the_help_overlay_lists_the_command_bar() { + let app = make_app(); + let lines = render_help_lines(&app.cfg); + assert!( + lines + .iter() + .any(|l| l.contains("ctrl-k") && l.contains("Go to a session")), + "{lines:#?}" + ); + } + + #[test] + fn opening_the_bar_lists_sessions_most_recently_focused_first() { + let mut app = make_app(); + let (alpha, bravo) = seed_two_sessions(&mut app); + app.dispatch(Action::OpenCommandBar); + assert_eq!(app.state.overlay(), Some(&OverlayKind::CommandBar)); + let bar = app.command_bar.as_ref().unwrap(); + assert_eq!(bar.query(), ""); + assert_eq!( + bar.rows() + .iter() + .map(|r| match r.target { + BarTarget::Session(id) => id, + }) + .collect::>(), + vec![bravo, alpha] + ); + } + + /// Opening from Insert mode leaves the keyboard where it was, so closing + /// gives it back — the bar is a view over the app, not a place in it. + #[test] + fn opening_from_insert_mode_leaves_focus_and_mode_alone() { + let mut app = make_app(); + seed_two_sessions(&mut app); + app.state.attach_to_focused(Uuid::from_u128(1)); + assert_eq!( + (app.state.focus, app.state.mode), + (Focus::Pane, Mode::Insert) + ); + + app.dispatch(Action::OpenCommandBar); + assert_eq!(app.state.overlay(), Some(&OverlayKind::CommandBar)); + assert_eq!( + (app.state.focus, app.state.mode), + (Focus::Pane, Mode::Insert) + ); + + let (decision, effects) = app.handle_overlay_input(&InputKey::Esc); + assert_eq!(decision, OverlayDecision::Consumed); + assert!(effects.is_empty()); + assert!(app.state.overlay().is_none()); + assert!(app.command_bar.is_none()); + assert_eq!( + (app.state.focus, app.state.mode), + (Focus::Pane, Mode::Insert) + ); + } + + /// Enter goes through the same open path the sidebar's attach uses. + #[test] + fn enter_attaches_the_highlighted_session() { + let mut app = make_app(); + let (_alpha, bravo) = seed_two_sessions(&mut app); + app.dispatch(Action::OpenCommandBar); + let (decision, effects) = app.handle_overlay_input(&InputKey::Enter); + assert_eq!(decision, OverlayDecision::Consumed); + assert_eq!(effects, vec![Effect::Attach(bravo)]); + assert!(app.state.overlay().is_none()); + assert!(app.command_bar.is_none()); + assert_eq!(app.state.active_session(), Some(bravo)); + } + + /// Down moves the highlight, so Enter opens the second row instead. + #[test] + fn the_highlight_moves_before_enter_attaches() { + let mut app = make_app(); + let (alpha, _bravo) = seed_two_sessions(&mut app); + app.dispatch(Action::OpenCommandBar); + app.handle_overlay_input(&InputKey::Down); + let (_, effects) = app.handle_overlay_input(&InputKey::Enter); + assert_eq!(effects, vec![Effect::Attach(alpha)]); + assert_eq!(app.state.active_session(), Some(alpha)); + } + + /// Typing goes to the query, not to the keymap: `q` must not quit and `x` + /// must not raise a kill confirm while the bar is up. + #[test] + fn typing_reaches_the_query_rather_than_the_keymap() { + let mut app = make_app(); + seed_two_sessions(&mut app); + app.dispatch(Action::OpenCommandBar); + for c in "bra".chars() { + let (decision, _) = app.handle_overlay_input(&InputKey::Char { + c, + ctrl: false, + alt: false, + shift: false, + }); + assert_eq!(decision, OverlayDecision::Consumed); + } + let bar = app.command_bar.as_ref().unwrap(); + assert_eq!(bar.query(), "bra"); + assert_eq!(bar.rows().len(), 1); + } + + /// The bar is not previewed into (`docs/PREVIEW.md`): opening discards a + /// preview and the highlight behind it raises no new one. + #[test] + fn opening_the_bar_discards_a_preview() { + let mut app = make_app(); + let (alpha, _bravo) = seed_two_sessions(&mut app); + app.state.sidebar_highlight = Some(SidebarItem::Session(alpha)); + app.state.preview = Some(alpha); + + app.dispatch(Action::OpenCommandBar); + assert_eq!(app.state.preview, None); + assert_eq!(app.pending_preview, None); + } + #[test] fn kill_highlighted_opens_confirm() { use chrono::Utc; diff --git a/crates/maestro-tui/src/command_bar.rs b/crates/maestro-tui/src/command_bar.rs new file mode 100644 index 0000000..c2fce8f --- /dev/null +++ b/crates/maestro-tui/src/command_bar.rs @@ -0,0 +1,758 @@ +//! The `ctrl-k` command bar (`docs/COMMAND-BAR.md`): a query line over a +//! result list, opened from Normal or Insert mode, closed with Escape. +//! +//! Sessions are the only kind here (#153); the sigils (#154), the session +//! action page (#155) and the leader's literal Ctrl-K (#156) land later. The +//! grouping below is already kind-generic so those slot in without a rewrite. +//! +//! Ranking is [`crate::matcher`]'s job and nothing here reorders it — the +//! groups are a rendering of the ranked list, in the order their best-scoring +//! member appears. The web bar (`web/src/commandBar.ts`) implements the same +//! contract; the two must agree. + +use maestro_protocol::display::{abbreviate_dir, command_tool, session_tree_path}; +use maestro_protocol::{Session, SessionStatus}; +use ratatui::layout::Rect; +use ratatui::style::{Modifier, Style}; +use ratatui::text::{Line, Span}; +use ratatui::widgets::{Block, Borders, Clear, Paragraph}; +use ratatui::Frame; +use uuid::Uuid; + +use crate::input::InputKey; +use crate::matcher::{ + match_and_rank, CandidateKind, MatchCandidate, MatchFieldName, MatchRange, SessionCandidate, +}; +use crate::overlays::centered; +use crate::palette; +use crate::text_input::TextInput; + +/// What Enter on a row does. Only sessions exist today; `>` commands and `~` +/// directories (#154) become further variants. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum BarTarget { + Session(Uuid), +} + +/// One row of the result list, already resolved to what it renders and what it +/// opens. Headers are labels, not rows, so they are never in this list. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct BarRow { + pub kind: CandidateKind, + pub target: BarTarget, + /// Primary text — the name as the sidebar shows it. + pub name: String, + /// Secondary text: the field the match was found in when that was not the + /// name, so a session found through its directory says so. + pub meta: Option, + /// The field that won, and the ranges inside it. `None` for an empty + /// query, which matches everything and highlights nothing. + pub field: Option, + pub ranges: Vec, + pub dead: bool, +} + +impl BarRow { + /// Whether the highlight belongs on [`Self::meta`] rather than the name. + fn meta_is_matched(&self) -> bool { + !matches!(self.field, None | Some(MatchFieldName::Name)) + } +} + +/// One kind's slice of the ranked list, as a header plus the rows under it. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct BarGroup { + pub kind: CandidateKind, + pub title: &'static str, + /// Index of this group's first row in [`CommandBar::rows`]. + pub start: usize, + pub len: usize, +} + +fn kind_title(kind: CandidateKind) -> &'static str { + match kind { + CandidateKind::Session => "Sessions", + CandidateKind::Command => "Commands", + CandidateKind::Directory => "Directories", + } +} + +/// What a keystroke asked the bar to do. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum BarOutcome { + /// The bar stays up. + Stay, + /// Escape: close and give the keyboard back. + Close, + /// Enter on a row: close and open what it points at. + Open(BarTarget), +} + +/// The empty-query order (`docs/COMMAND-BAR.md`, "Empty query"): most recently +/// focused first; a session that has never been focused sorts after every +/// session that has, most recently created first among those; and a dead +/// session sorts after every live one regardless of recency, because a session +/// you cannot attach to is not where you just were. +/// +/// This is also the input order for a non-empty query, which is what the +/// matcher's last tie-break preserves. Mirrors the web's +/// `orderSessionsForBar`. +pub fn order_sessions_for_bar(sessions: &[Session]) -> Vec<&Session> { + let mut ordered: Vec<&Session> = sessions.iter().collect(); + ordered.sort_by(|a, b| { + let dead = |s: &Session| u8::from(s.status == SessionStatus::Dead); + dead(a) + .cmp(&dead(b)) + .then_with(|| { + a.last_focused_at + .is_none() + .cmp(&b.last_focused_at.is_none()) + }) + .then_with(|| match (a.last_focused_at, b.last_focused_at) { + (Some(x), Some(y)) => y.cmp(&x), + _ => std::cmp::Ordering::Equal, + }) + .then_with(|| b.created_at.cmp(&a.created_at)) + }); + ordered +} + +/// One session's candidate: the fields `docs/COMMAND-BAR.md`'s candidate table +/// names, with the directory abbreviated the way the sidebar shows it. +pub fn session_candidate(session: &Session, home_dir: &str) -> SessionCandidate { + SessionCandidate { + name: crate::sidebar::name_for(session), + directory: session_tree_path(session).map(|path| abbreviate_dir(&path, home_dir)), + command: (!session.command.is_empty()).then(|| session.command.clone()), + tool: command_tool(&session.command).map(str::to_string), + } +} + +/// The bar's state while it is up: the query buffer, the candidates it was +/// opened over, the rows the current query produces, and the highlight. +#[derive(Debug)] +pub struct CommandBar { + input: TextInput, + /// Matcher input, in the empty-query order. Never reordered — the matcher's + /// last tie-break is what keeps that order meaningful. + candidates: Vec, + /// What each candidate opens, parallel to `candidates`. + targets: Vec, + /// Whether each candidate's session is dead, parallel to `candidates`. + dead: Vec, + rows: Vec, + highlight: usize, +} + +impl CommandBar { + /// Open the bar over the sessions the catalog has right now. + pub fn new(sessions: &[Session], home_dir: &str) -> Self { + let ordered = order_sessions_for_bar(sessions); + let candidates = ordered + .iter() + .map(|s| MatchCandidate::Session(session_candidate(s, home_dir))) + .collect(); + let targets = ordered.iter().map(|s| BarTarget::Session(s.id)).collect(); + let dead = ordered + .iter() + .map(|s| s.status == SessionStatus::Dead) + .collect(); + let mut bar = Self { + input: TextInput::default(), + candidates, + targets, + dead, + rows: Vec::new(), + highlight: 0, + }; + bar.recompute(); + bar + } + + pub fn query(&self) -> &str { + self.input.value() + } + + pub fn rows(&self) -> &[BarRow] { + &self.rows + } + + pub fn highlight(&self) -> usize { + self.highlight + } + + /// The row Enter would open. + pub fn selected(&self) -> Option<&BarRow> { + self.rows.get(self.highlight) + } + + /// Group the ranked rows by kind for rendering. Grouping never reorders: + /// a group holds its matches in rank order, and groups appear in the order + /// their best-scoring member does — so the first row under the first + /// header is the globally best result. + pub fn groups(&self) -> Vec { + let mut groups: Vec = Vec::new(); + for (index, row) in self.rows.iter().enumerate() { + match groups.iter_mut().find(|g| g.kind == row.kind) { + Some(group) => group.len += 1, + None => groups.push(BarGroup { + kind: row.kind, + title: kind_title(row.kind), + start: index, + len: 1, + }), + } + } + groups + } + + /// Move the highlight. It does not wrap: it stops at the first and last + /// row. + pub fn move_highlight(&mut self, delta: isize) { + if self.rows.is_empty() { + self.highlight = 0; + return; + } + let last = self.rows.len() as isize - 1; + let next = (self.highlight as isize + delta).clamp(0, last); + self.highlight = next as usize; + } + + /// Feed the bar a key. Everything the key table (`docs/COMMAND-BAR.md`, + /// "Keys") does not claim is ordinary editing. + pub fn handle_key(&mut self, key: &InputKey) -> BarOutcome { + match key { + InputKey::Esc => BarOutcome::Close, + InputKey::Enter => match self.selected() { + Some(row) => BarOutcome::Open(row.target), + // Nothing to open: the bar stays up rather than closing on a + // key that did nothing. + None => BarOutcome::Stay, + }, + InputKey::Up + | InputKey::Char { + c: 'p', ctrl: true, .. + } => { + self.move_highlight(-1); + BarOutcome::Stay + } + InputKey::Down + | InputKey::Char { + c: 'n', ctrl: true, .. + } => { + self.move_highlight(1); + BarOutcome::Stay + } + // Tab drills into the highlighted session's action page (#155). + // Until then it is inert rather than a literal tab in the query. + InputKey::Tab | InputKey::BackTab => BarOutcome::Stay, + other => { + let before = self.input.value().to_string(); + self.input.handle_key(other); + if self.input.value() != before { + self.recompute(); + } + BarOutcome::Stay + } + } + } + + /// Re-rank for the current query. The highlight starts on the globally + /// best row every time the list changes: an old index would point at a + /// different result. + fn recompute(&mut self) { + let rows: Vec = { + let results = match_and_rank(self.input.value(), &self.candidates); + results + .iter() + .map(|result| { + let index = self + .candidates + .iter() + .position(|c| std::ptr::eq(c, result.candidate)) + .expect("every result borrows one of this bar's candidates"); + let MatchCandidate::Session(session) = result.candidate else { + unreachable!("sessions are the only kind the bar builds (#153)") + }; + BarRow { + kind: CandidateKind::Session, + target: self.targets[index], + name: session.name.clone(), + meta: meta_text(session, result.field), + field: result.field, + ranges: result.ranges.clone(), + dead: self.dead[index], + } + }) + .collect() + }; + self.rows = rows; + self.highlight = 0; + } + + /// Draw the bar and return where the terminal cursor belongs. + pub fn render(&self, f: &mut Frame, parent: Rect) -> Option<(u16, u16)> { + let pal = palette::active(); + let height = parent.height.saturating_sub(4).clamp(5, 20); + let area = centered(parent, 72, height); + f.render_widget(Clear, area); + let block = Block::default() + .title(" go to ") + .borders(Borders::ALL) + .style(pal.card.base()) + .border_style(pal.border_style(true)); + let inner = block.inner(area); + f.render_widget(block, area); + if inner.height < 3 || inner.width == 0 { + return None; + } + let row = |offset: u16| Rect { + x: inner.x, + y: inner.y + offset, + width: inner.width, + height: 1, + }; + + // Query line, then the results, then the key hint on the last row. + let query = row(0); + let col = self + .input + .render(query, f.buffer_mut(), pal.card.text_style()); + f.render_widget( + Paragraph::new(Line::from(Span::styled( + "[enter] open [esc] close", + pal.card.dim_style(), + ))), + row(inner.height - 1), + ); + + let list_height = inner.height.saturating_sub(2) as usize; + if list_height > 0 { + for (offset, line) in self.visible_lines(list_height).into_iter().enumerate() { + f.render_widget(Paragraph::new(line), row(1 + offset as u16)); + } + } + Some((col, query.y)) + } + + /// The result lines that fit in `height`, scrolled so the highlighted row + /// is on screen. + fn visible_lines(&self, height: usize) -> Vec> { + let pal = palette::active(); + if self.rows.is_empty() { + return vec![Line::from(Span::styled( + "No matches", + pal.card.muted_style(), + ))]; + } + // Headers and rows, in one list, remembering which line the highlight + // sits on so the scroll can keep it visible. + let mut lines: Vec> = Vec::new(); + let mut highlight_line = 0usize; + for group in self.groups() { + lines.push(Line::from(Span::styled( + group.title.to_string(), + pal.group_row_style(), + ))); + for index in group.start..group.start + group.len { + if index == self.highlight { + highlight_line = lines.len(); + } + lines.push(self.row_line(index)); + } + } + let scroll = highlight_line.saturating_sub(height.saturating_sub(1)); + lines.into_iter().skip(scroll).take(height).collect() + } + + fn row_line(&self, index: usize) -> Line<'static> { + let pal = palette::active(); + let row = &self.rows[index]; + let selected = index == self.highlight; + let surface = pal.surface(selected); + let base = if row.dead { + surface.dim_style() + } else { + surface.text_style() + }; + let hit = base.add_modifier(Modifier::BOLD | Modifier::UNDERLINED); + + let mut spans = vec![Span::styled(if selected { "› " } else { " " }, base)]; + let name_ranges: &[MatchRange] = if row.meta_is_matched() { + &[] + } else { + &row.ranges + }; + spans.extend(highlighted_spans(&row.name, name_ranges, base, hit)); + if let Some(meta) = &row.meta { + let meta_style = surface.muted_style(); + let meta_hit = meta_style.add_modifier(Modifier::BOLD | Modifier::UNDERLINED); + spans.push(Span::styled(" ", base)); + let meta_ranges: &[MatchRange] = if row.meta_is_matched() { + &row.ranges + } else { + &[] + }; + spans.extend(highlighted_spans(meta, meta_ranges, meta_style, meta_hit)); + } + Line::from(spans) + } +} + +/// The secondary line under a row's name: the field the match was found in +/// when that was not the name, so a session found through its directory says +/// so. Mirrors the web's `metaText`. +fn meta_text(session: &SessionCandidate, field: Option) -> Option { + match field { + Some(MatchFieldName::Directory) => session.directory.clone(), + Some(MatchFieldName::Command) => session.command.clone(), + Some(MatchFieldName::Tool) => session.tool.clone(), + _ => session.directory.clone(), + } +} + +/// Split `text` into styled spans, with the matcher's `[start, end)` character +/// ranges carrying `hit` and everything else `base`. +fn highlighted_spans( + text: &str, + ranges: &[MatchRange], + base: Style, + hit: Style, +) -> Vec> { + let chars: Vec = text.chars().collect(); + let slice = |from: usize, to: usize| -> String { + chars[from.min(chars.len())..to.min(chars.len())] + .iter() + .collect() + }; + let mut spans = Vec::new(); + let mut cursor = 0usize; + for &(start, end) in ranges { + if start > cursor { + spans.push(Span::styled(slice(cursor, start), base)); + } + spans.push(Span::styled(slice(start, end), hit)); + cursor = end; + } + if cursor < chars.len() { + spans.push(Span::styled(slice(cursor, chars.len()), base)); + } + spans +} + +#[cfg(test)] +mod tests { + use super::*; + use chrono::{DateTime, TimeZone, Utc}; + use ratatui::backend::TestBackend; + use ratatui::Terminal; + use std::collections::HashMap; + use std::path::PathBuf; + + fn at(secs: i64) -> DateTime { + Utc.timestamp_opt(1_700_000_000 + secs, 0).unwrap() + } + + fn session(n: u128, command: &str) -> Session { + Session { + id: Uuid::from_u128(n), + cwd: PathBuf::from("/home/me/dev/api"), + command: command.to_string(), + env: HashMap::new(), + metadata: HashMap::new(), + worktree: None, + status: SessionStatus::Running, + created_at: at(0), + foreground: None, + activity: Default::default(), + activity_detail: None, + last_activity_at: at(0), + activity_since: at(0), + current_cwd: None, + blocked_work: None, + last_focused_at: None, + } + } + + fn named(n: u128, name: &str) -> Session { + let mut s = session(n, "bash"); + s.metadata + .insert("name".into(), serde_json::Value::String(name.to_string())); + s + } + + fn ids(rows: &[BarRow]) -> Vec { + rows.iter() + .map(|r| match r.target { + BarTarget::Session(id) => id, + }) + .collect() + } + + fn typed(bar: &mut CommandBar, text: &str) { + for c in text.chars() { + bar.handle_key(&InputKey::Char { + c, + ctrl: false, + alt: false, + shift: false, + }); + } + } + + #[test] + fn empty_query_lists_sessions_most_recently_focused_first() { + let mut a = named(1, "alpha"); + a.last_focused_at = Some(at(10)); + let mut b = named(2, "bravo"); + b.last_focused_at = Some(at(30)); + let mut c = named(3, "charlie"); + c.last_focused_at = Some(at(20)); + + let bar = CommandBar::new(&[a, b, c], "/home/me"); + assert_eq!( + ids(bar.rows()), + vec![Uuid::from_u128(2), Uuid::from_u128(3), Uuid::from_u128(1)] + ); + // Every row matches with no ranges, so nothing is highlighted inside. + assert!(bar.rows().iter().all(|r| r.ranges.is_empty())); + } + + #[test] + fn never_focused_sessions_sort_after_focused_ones_newest_first() { + let mut focused = named(1, "focused"); + focused.last_focused_at = Some(at(5)); + let mut older = named(2, "older"); + older.created_at = at(10); + let mut newer = named(3, "newer"); + newer.created_at = at(20); + + let bar = CommandBar::new(&[older, newer, focused], "/home/me"); + assert_eq!( + ids(bar.rows()), + vec![Uuid::from_u128(1), Uuid::from_u128(3), Uuid::from_u128(2)] + ); + } + + /// A session you cannot attach to is not where you just were. + #[test] + fn dead_sessions_sort_after_live_ones_however_recent() { + let mut dead = named(1, "dead"); + dead.status = SessionStatus::Dead; + dead.last_focused_at = Some(at(99)); + let live = named(2, "live"); + + let bar = CommandBar::new(&[dead, live], "/home/me"); + assert_eq!( + ids(bar.rows()), + vec![Uuid::from_u128(2), Uuid::from_u128(1)] + ); + assert!(bar.rows()[1].dead); + } + + #[test] + fn typing_ranks_through_the_matcher_and_returns_ranges() { + let mut bar = CommandBar::new(&[named(1, "web-search"), named(2, "websearch")], "/home/me"); + typed(&mut bar, "ws"); + // F9: the `s` after `-` earns the boundary bonus. + assert_eq!( + ids(bar.rows()), + vec![Uuid::from_u128(1), Uuid::from_u128(2)] + ); + assert_eq!(bar.rows()[0].ranges, vec![(0, 1), (4, 5)]); + assert_eq!(bar.rows()[0].field, Some(MatchFieldName::Name)); + } + + #[test] + fn a_query_matching_nothing_leaves_no_rows() { + let mut bar = CommandBar::new(&[named(1, "alpha")], "/home/me"); + typed(&mut bar, "zzz"); + assert!(bar.rows().is_empty()); + assert!(bar.selected().is_none()); + } + + /// A session found through its directory highlights inside the directory, + /// which is also what the row's meta text shows. + #[test] + fn a_directory_match_highlights_in_the_meta_text() { + let mut worker = named(1, "worker"); + worker.cwd = PathBuf::from("/home/me/dev/api"); + let mut bar = CommandBar::new(&[worker], "/home/me"); + typed(&mut bar, "api"); + assert_eq!(bar.rows()[0].field, Some(MatchFieldName::Directory)); + assert_eq!(bar.rows()[0].meta.as_deref(), Some("~/dev/api")); + assert_eq!(bar.rows()[0].ranges, vec![(6, 9)]); + } + + /// The candidate's `tool` is the mark the card's icon stands for, so + /// `claude` reaches a session whose name says nothing about it. + #[test] + fn the_tool_field_is_a_candidate_field() { + let mut nameless = named(1, "nameless"); + nameless.command = "claude --resume".into(); + let mut bar = CommandBar::new(&[nameless, named(2, "clay")], "/home/me"); + typed(&mut bar, "claude"); + assert_eq!(ids(bar.rows()), vec![Uuid::from_u128(1)]); + assert_eq!(bar.rows()[0].field, Some(MatchFieldName::Tool)); + } + + #[test] + fn results_group_under_kind_headers_with_the_best_row_first() { + let bar = CommandBar::new(&[named(1, "alpha"), named(2, "bravo")], "/home/me"); + let groups = bar.groups(); + assert_eq!(groups.len(), 1); + assert_eq!(groups[0].title, "Sessions"); + assert_eq!(groups[0].kind, CandidateKind::Session); + assert_eq!((groups[0].start, groups[0].len), (0, 2)); + // The highlight starts on the globally best row. + assert_eq!(bar.highlight(), 0); + } + + #[test] + fn arrows_and_ctrl_pn_move_the_highlight_without_wrapping() { + let mut bar = CommandBar::new( + &[named(1, "alpha"), named(2, "bravo"), named(3, "charlie")], + "/home/me", + ); + assert_eq!(bar.handle_key(&InputKey::Down), BarOutcome::Stay); + assert_eq!(bar.highlight(), 1); + bar.handle_key(&InputKey::Char { + c: 'n', + ctrl: true, + alt: false, + shift: false, + }); + assert_eq!(bar.highlight(), 2); + // Stops at the last row rather than wrapping to the first. + bar.handle_key(&InputKey::Down); + assert_eq!(bar.highlight(), 2); + bar.handle_key(&InputKey::Char { + c: 'p', + ctrl: true, + alt: false, + shift: false, + }); + assert_eq!(bar.highlight(), 1); + bar.handle_key(&InputKey::Up); + bar.handle_key(&InputKey::Up); + assert_eq!(bar.highlight(), 0); + } + + #[test] + fn enter_opens_the_highlighted_session_and_escape_closes() { + let mut bar = CommandBar::new(&[named(1, "alpha"), named(2, "bravo")], "/home/me"); + bar.handle_key(&InputKey::Down); + assert_eq!( + bar.handle_key(&InputKey::Enter), + BarOutcome::Open(BarTarget::Session(Uuid::from_u128(2))) + ); + assert_eq!(bar.handle_key(&InputKey::Esc), BarOutcome::Close); + } + + #[test] + fn enter_with_no_results_leaves_the_bar_up() { + let mut bar = CommandBar::new(&[named(1, "alpha")], "/home/me"); + typed(&mut bar, "zzz"); + assert_eq!(bar.handle_key(&InputKey::Enter), BarOutcome::Stay); + } + + #[test] + fn editing_the_query_re_ranks_and_resets_the_highlight() { + let mut bar = CommandBar::new(&[named(1, "alpha"), named(2, "alpine")], "/home/me"); + bar.handle_key(&InputKey::Down); + assert_eq!(bar.highlight(), 1); + typed(&mut bar, "alp"); + assert_eq!(bar.query(), "alp"); + assert_eq!(bar.highlight(), 0); + bar.handle_key(&InputKey::Backspace); + assert_eq!(bar.query(), "al"); + assert_eq!(bar.rows().len(), 2); + } + + /// Backspacing back to an empty query is not a special key: the list + /// becomes the recency order again. + #[test] + fn backspacing_to_an_empty_query_restores_the_recency_order() { + let mut recent = named(1, "alpha"); + recent.last_focused_at = Some(at(50)); + let older = named(2, "alpine"); + let mut bar = CommandBar::new(&[older, recent], "/home/me"); + typed(&mut bar, "alpi"); + assert_eq!(ids(bar.rows()), vec![Uuid::from_u128(2)]); + for _ in 0..4 { + bar.handle_key(&InputKey::Backspace); + } + assert_eq!(bar.query(), ""); + assert_eq!( + ids(bar.rows()), + vec![Uuid::from_u128(1), Uuid::from_u128(2)] + ); + } + + fn render_to_string(bar: &CommandBar, w: u16, h: u16) -> String { + let backend = TestBackend::new(w, h); + let mut terminal = Terminal::new(backend).unwrap(); + terminal + .draw(|f| { + bar.render(f, f.area()); + }) + .unwrap(); + let buf = terminal.backend().buffer().clone(); + let mut out = String::new(); + for y in 0..buf.area.height { + for x in 0..buf.area.width { + out.push_str(buf[(x, y)].symbol()); + } + out.push('\n'); + } + out + } + + #[test] + fn renders_the_query_the_headers_and_the_rows() { + let mut bar = CommandBar::new(&[named(1, "alpha"), named(2, "alpine")], "/home/me"); + typed(&mut bar, "alp"); + let rendered = render_to_string(&bar, 90, 20); + assert!(rendered.contains("go to"), "{rendered}"); + assert!(rendered.contains("alp"), "{rendered}"); + assert!(rendered.contains("Sessions"), "{rendered}"); + assert!(rendered.contains("alpha"), "{rendered}"); + assert!(rendered.contains("alpine"), "{rendered}"); + assert!(rendered.contains("[esc] close"), "{rendered}"); + } + + #[test] + fn renders_a_no_results_state() { + let mut bar = CommandBar::new(&[named(1, "alpha")], "/home/me"); + typed(&mut bar, "zzz"); + let rendered = render_to_string(&bar, 90, 20); + assert!(rendered.contains("No matches"), "{rendered}"); + } + + /// The matched characters are drawn bold, so the row shows why it matched. + #[test] + fn matched_ranges_are_styled_apart_from_the_rest_of_the_row() { + let mut bar = CommandBar::new(&[named(1, "web-search")], "/home/me"); + typed(&mut bar, "ws"); + let backend = TestBackend::new(90, 20); + let mut terminal = Terminal::new(backend).unwrap(); + terminal + .draw(|f| { + bar.render(f, f.area()); + }) + .unwrap(); + let buf = terminal.backend().buffer().clone(); + let mut bold: Vec = Vec::new(); + for y in 0..buf.area.height { + for x in 0..buf.area.width { + let cell = &buf[(x, y)]; + if cell.style().add_modifier.contains(Modifier::BOLD) + && cell.style().add_modifier.contains(Modifier::UNDERLINED) + { + bold.push(cell.symbol().to_string()); + } + } + } + assert_eq!(bold, vec!["w", "s"], "bold cells: {bold:?}"); + } +} diff --git a/crates/maestro-tui/src/config.rs b/crates/maestro-tui/src/config.rs index fe9a4b8..507024f 100644 --- a/crates/maestro-tui/src/config.rs +++ b/crates/maestro-tui/src/config.rs @@ -65,6 +65,9 @@ const DEFAULT_NORMAL: &[(&str, &str)] = &[ ("pageup", "ScrollPanePageUp"), ("pagedown", "ScrollPanePageDown"), // Chrome + // The command bar (#153). An ordinary entry, so it is rebindable and the + // help overlay picks it up from the live keymap. + ("ctrl-k", "OpenCommandBar"), ("ctrl-w b", "ToggleSidebar"), ("?", "ShowHelp"), ("ctrl-w q", "Quit"), @@ -75,13 +78,17 @@ const DEFAULT_NORMAL: &[(&str, &str)] = &[ ("mouse:dblclick(sidebar-row)", "AttachHighlighted"), ]; -/// Default Insert-mode bindings — deliberately empty. Every key typed in +/// Default Insert-mode bindings — almost empty on purpose. Every key typed in /// Insert mode belongs to the session, `esc` included: full-screen apps read /// it constantly, so binding it here would make it unreachable. The only way /// back to the sidebar is the leader (`LeaveInsert`), which /// [`TuiConfig::load`] inserts dynamically so rebinding the leader rebinds /// the Insert escape with it. -const DEFAULT_INSERT: &[(&str, &str)] = &[]; +/// +/// The one exception is the command bar (#153): `ctrl-k` opens it from inside +/// a pane too, so `docs/COMMAND-BAR.md`'s "from anywhere" holds. Readline's +/// kill-to-end-of-line is given back through the leader (#156). +const DEFAULT_INSERT: &[(&str, &str)] = &[("ctrl-k", "OpenCommandBar")]; impl TuiConfig { /// Build a runtime config from a [`TuiSection`] (typically the `tui` diff --git a/crates/maestro-tui/src/lib.rs b/crates/maestro-tui/src/lib.rs index 80745b7..55c99f4 100644 --- a/crates/maestro-tui/src/lib.rs +++ b/crates/maestro-tui/src/lib.rs @@ -6,6 +6,7 @@ pub mod action; pub mod app; pub mod catalog; pub mod cli; +pub mod command_bar; pub mod config; pub mod daemon_link; pub mod draft; diff --git a/crates/maestro-tui/src/render.rs b/crates/maestro-tui/src/render.rs index 5ecf71b..7d697f3 100644 --- a/crates/maestro-tui/src/render.rs +++ b/crates/maestro-tui/src/render.rs @@ -86,6 +86,9 @@ pub struct RenderInputs<'a> { pub help: &'a HelpOverlay, /// Edit buffer behind the rename prompt (#125). pub rename_input: &'a crate::text_input::TextInput, + /// The command bar behind [`OverlayKind::CommandBar`] (#153), when one is + /// up. + pub command_bar: Option<&'a crate::command_bar::CommandBar>, /// Liveness + release of the daemon, for the status bar's indicator and the /// restart overlay (`docs/RESTART.md`). pub link: &'a crate::daemon_link::LinkStatus, @@ -198,6 +201,14 @@ pub fn draw(f: &mut Frame, inputs: RenderInputs<'_>) -> RegionMap { } map.modal = Some(area); } + Some(OverlayKind::CommandBar) => { + if let Some(bar) = inputs.command_bar { + if let Some((x, y)) = bar.render(f, area) { + f.set_cursor_position((x, y)); + } + map.modal = Some(area); + } + } Some(OverlayKind::Help) => { inputs.help.render(f, area); map.modal = Some(area); @@ -428,6 +439,7 @@ mod tests { state: &state, help: &help, rename_input: &crate::text_input::TextInput::default(), + command_bar: None, link, }; let backend = ratatui::backend::TestBackend::new(120, 24); @@ -524,6 +536,7 @@ mod tests { state: &state, help: &help, rename_input: &crate::text_input::TextInput::default(), + command_bar: None, link: &online_link(), }; @@ -578,6 +591,7 @@ mod tests { state: &state, help: &help, rename_input: &crate::text_input::TextInput::default(), + command_bar: None, link: &online_link(), }; let backend = ratatui::backend::TestBackend::new(80, 24); @@ -619,6 +633,7 @@ mod tests { state: &state, help: &help, rename_input: &crate::text_input::TextInput::default(), + command_bar: None, link: &online_link(), }; let backend = ratatui::backend::TestBackend::new(80, 24); diff --git a/crates/maestro-tui/src/state.rs b/crates/maestro-tui/src/state.rs index 6614cec..4487e0e 100644 --- a/crates/maestro-tui/src/state.rs +++ b/crates/maestro-tui/src/state.rs @@ -52,6 +52,10 @@ pub enum OverlayKind { /// so this stays comparable. Rename(Uuid), Help, + /// The `ctrl-k` command bar (#153). Its query buffer and result list live + /// on `App` as [`crate::command_bar::CommandBar`], for the same reason the + /// rename buffer does — so this stays comparable. + CommandBar, Toast(String), } From e6a254b7349da9acb35d0b4f67d8f7ad305729a1 Mon Sep 17 00:00:00 2001 From: Miguel Palhas Date: Thu, 3 Sep 2026 16:47:34 +0100 Subject: [PATCH 08/14] chore(maestro): drop the worker task file committed with #153 TASK.md is a blitz worker's prompt, not repo content. --- TASK.md | 115 -------------------------------------------------------- 1 file changed, 115 deletions(-) delete mode 100644 TASK.md diff --git a/TASK.md b/TASK.md deleted file mode 100644 index 796f01f..0000000 --- a/TASK.md +++ /dev/null @@ -1,115 +0,0 @@ -# Task: Gitea issue #153 — the TUI command bar (action, overlay, session results) - -You are an autonomous worker in a blitz over the **Command bar** milestone of -the `naps62/maestro` Gitea repo (`https://git.naps.pt`, `$GITEA_TOKEN` is in -`~/.env.claude` — `source` it). - -Both of your dependencies are merged and on your base branch. - -## Read first - -1. **`docs/COMMAND-BAR.md`** — the contract. "Opening", "Structure", "Empty - query" and "Keys" are yours. Implement what it says. -2. The issue body: - `curl -sS -H "Authorization: token $GITEA_TOKEN" https://git.naps.pt/api/v1/repos/naps62/maestro/issues/153 | jq -r .body` -3. `CLAUDE.md` — conventions. **TDD by default**; `cargo fmt` and - `cargo clippy --all-targets -- -D warnings` before every commit. -4. What you build on, all merged: - - `crates/maestro-tui/src/matcher.rs` (#147) — the match-and-rank function - and its candidate types. **Do not reimplement matching or reorder its - results.** - - `crates/maestro-protocol` — `Session::last_focused_at` (#145), the - daemon's recency stamp. - - `crates/maestro-tui/src/`: `action.rs`, `keymap.rs`, `config.rs` - (`DEFAULT_NORMAL` / `DEFAULT_INSERT`), `state.rs` (`OverlayKind`), - `overlays.rs`, `app.rs` (`handle_overlay_input` and the dispatcher), - `text_input.rs` (the edit buffer the rename prompt already uses). -5. **`web/src/commandBar.ts`** (#149) — the web bar implementing this same - contract, already merged. Read it, especially `orderSessionsForBar`. Where - the doc leaves room, match what it does; the two clients disagreeing is the - failure this contract exists to prevent. - -## What to build - -- **`Action::OpenCommandBar`** — new variant, with its `parse` arm, `Display` - arm, `help_label`, and dispatcher arm. `Action::parse` and `Display` must - round-trip, as the existing tests require of every variant. -- **Bind `ctrl-k` in both `DEFAULT_NORMAL` and `DEFAULT_INSERT`**, so it opens - from the sidebar and from inside a pane. Because it is an ordinary keymap - entry it stays rebindable, and the help overlay — which builds its lines from - the live keymap — picks it up with no second list. -- **A new `OverlayKind` variant**, routed in `handle_overlay_input` alongside - Spawn, KillConfirm, Rename and Help. Follow how `Rename` keeps its edit - buffer on the app rather than in the enum, so the overlay enum stays - comparable. -- **Empty query lists sessions, most recently focused first**, by - `last_focused_at`. The doc fixes what happens to never-focused and dead - sessions — follow it exactly, and match `orderSessionsForBar`. -- **Typing fuzzy-matches sessions** through `matcher.rs`, with matched ranges - highlighted in the rendered row. Build candidates from the fields the doc's - candidate table names, directory abbreviated as the sidebar shows it. -- **Results render under kind headers**, groups ordered by best-scoring member, - highlight starting on the globally best row. Only sessions exist as a kind - here, but build the grouping so #154 can add commands and directories without - restructuring it. -- **Enter attaches the highlighted session.** Escape closes. Up/down and - `ctrl-p`/`ctrl-n` move the highlight, which does not wrap; headers are never - landed on. - -**Module name**: `palette.rs` is the colour palette. Do not put this there and -do not name your module `palette`. - -## Non-goals — later issues own these, do not build them - -- The `>` and `~` sigils (#154). -- Tab drilling into a session's action page (#155). -- The leader sending a literal Ctrl-K to the pane (#156). - -Build the grouping and query line so those slot in without a rewrite, but do -not implement them. - -## Ground rules - -- You are on `command-bar/153-tui-bar`, cut from `origin/main`. **Merge - `origin/blitz/command-bar` first** (merge, never rebase) — everything you - depend on is there and not on `main`. Merge it again if it moves under you. -- Stay inside `crates/`. Do not touch `web/`, and do not modify `matcher.rs`; - if you believe it is wrong, comment on the issue instead. -- Integration tests use **real tmux** — don't mock it; isolate with a temp - `$HOME` and `TMUX_TMPDIR` the way the existing tests do. `overlays.rs` shows - how the existing overlays are tested against a `TestBackend`. -- Run the suite at most once at the end, through the gate: - `/home/naps62/.claude/skills/tracker-common/scripts/gate.sh -- cargo test --workspace`. - Exit 75 means no slot — a normal outcome, not a failure: say so and push - anyway. Exit 137 is the memory cap, not a test failure. -- Report the **aggregate test count**, not one crate's. -- Commit convention: `feat(maestro): ...`. -- Bugs or problems outside this issue's scope: **comment them on issue #153**, - do not fix them silently. Earlier workers' reports changed the scope of two - later issues before they started. -- Never wait for input. - -## Exit criteria - -Do not end your final reply until ALL of these are true: - -1. `Action::OpenCommandBar` exists, round-trips through `parse`/`Display`, and - has a plain-language `help_label`. -2. `ctrl-k` is bound in both the Normal and Insert default keymaps and opens - the bar from each. -3. Empty query lists sessions by recency, with the doc's rules for - never-focused and dead sessions, matching `orderSessionsForBar`. -4. Typing ranks through `matcher.rs` with ranges highlighted, under kind - headers. -5. Enter attaches; Escape, arrows and `ctrl-p`/`ctrl-n` behave as the doc's key - table says. -6. `cargo fmt` clean, `cargo clippy --all-targets -- -D warnings` clean, suite - green (or the gate returned 75/137 and you said which). -7. Committed on `command-bar/153-tui-bar` and the branch **pushed** to - `origin`. - -Do **not** merge into `blitz/command-bar` and do **not** close the issue — the -orchestrator does both. - -If you catch yourself summarizing progress before those seven are true, you -have stopped too early — keep going. From 9fe9fe4628002a1e3beb0e35f7bb097b763ff1d9 Mon Sep 17 00:00:00 2001 From: Miguel Palhas Date: Thu, 3 Sep 2026 16:56:41 +0100 Subject: [PATCH 09/14] feat(maestro): add the web command bar's > and ~ sigils `>` narrows the bar to commands, matched on the label only with the chord shown beside each row and deduped by action. `~` narrows to directories over recent dirs and the project list, filled in from /api/dirs as a path is typed; Enter fast-forward-spawns there, falling back to the pre-filled spawn form when a default cannot resolve. Without a sigil the list stays mixed. Co-Authored-By: Claude Opus 5 --- web/src/commandBar.test.ts | 210 +++++++++++++++++++++- web/src/commandBar.ts | 346 +++++++++++++++++++++++++++++++++---- web/src/main.ts | 62 ++++++- web/src/styles.css | 19 ++ 4 files changed, 594 insertions(+), 43 deletions(-) diff --git a/web/src/commandBar.test.ts b/web/src/commandBar.test.ts index bc1135d..bb2cc6a 100644 --- a/web/src/commandBar.test.ts +++ b/web/src/commandBar.test.ts @@ -6,13 +6,19 @@ import { barGroups, barRows, closeCommandBar, + commandBarCandidates, + dedupeCommands, + directoryBarCandidates, + directoryCandidate, isCommandBarOpen, openCommandBar, orderSessionsForBar, + parseBarQuery, sessionBarCandidates, sessionCandidate, + SPAWN_DIRECTORY_EVENT, } from "./commandBar.js"; -import { COMMAND_SPECS } from "./commands.js"; +import { COMMAND_SPECS, type Command } from "./commands.js"; import type { Session } from "./types.js"; function session(id: string, over: Partial = {}): Session { @@ -173,8 +179,12 @@ describe("the overlay", () => { session("notes", { metadata: { name: "notes" }, current_cwd: "/home/me/notes" }), ]; + // Sessions only: the mixed list's command half is the sigil describe's + // business, and the registry is whatever another test file happened to bind. function open(over: Parameters[0] | null = null) { - return openCommandBar(over ?? { sessions, homeDir: "/home/me", onOpenSession: () => {} }); + return openCommandBar( + over ?? { sessions, homeDir: "/home/me", commands: [], onOpenSession: () => {} }, + ); } it("is a registry command, so the help overlay lists it", () => { @@ -252,7 +262,7 @@ describe("the overlay", () => { const seen: string[] = []; const listener = (e: Event) => seen.push((e as CustomEvent).detail); document.addEventListener(SESSION_SELECT_EVENT, listener); - openCommandBar({ sessions, homeDir: "/home/me" }); + openCommandBar({ sessions, homeDir: "/home/me", commands: [] }); press("ArrowDown"); press("Enter"); document.removeEventListener(SESSION_SELECT_EVENT, listener); @@ -262,7 +272,7 @@ describe("the overlay", () => { it("Enter on an empty result list does nothing", () => { const onOpenSession = vi.fn(); - open({ sessions, homeDir: "/home/me", onOpenSession }); + open({ sessions, homeDir: "/home/me", commands: [], onOpenSession }); type("zzz"); press("Enter"); expect(onOpenSession).not.toHaveBeenCalled(); @@ -301,9 +311,199 @@ describe("the overlay", () => { it("clicking a row opens that session", () => { const onOpenSession = vi.fn(); - open({ sessions, homeDir: "/home/me", onOpenSession }); + open({ sessions, homeDir: "/home/me", commands: [], onOpenSession }); (document.querySelectorAll(".cmdbar-row")[1] as HTMLElement).click(); expect(onOpenSession).toHaveBeenCalledWith("worker"); expect(isCommandBarOpen()).toBe(false); }); }); + +// ---- the sigils (#150) ---- + +function command(id: string, label: string, keys: string[], run = () => {}): Command { + return { id, label, group: "Test", keys, scope: "global", run }; +} + +const flush = () => new Promise((resolve) => setTimeout(resolve, 0)); + +function rowKeys(): string[][] { + return [...document.querySelectorAll(".cmdbar-row-keys")].map((el) => + [...el.querySelectorAll("kbd")].map((k) => k.textContent ?? ""), + ); +} + +describe("parsing the query line", () => { + it("reads a sigil only in first position", () => { + expect(parseBarQuery(">kill")).toEqual({ sigil: ">", query: "kill" }); + expect(parseBarQuery("~/dev")).toEqual({ sigil: "~", query: "/dev" }); + expect(parseBarQuery("")).toEqual({ sigil: null, query: "" }); + expect(parseBarQuery("api")).toEqual({ sigil: null, query: "api" }); + }); + + it("leaves a sigil character elsewhere in the query literal", () => { + expect(parseBarQuery("api>x")).toEqual({ sigil: null, query: "api>x" }); + // The case that matters: the second `~` of a directory query reaches the + // matcher. + expect(parseBarQuery("~~/dev")).toEqual({ sigil: "~", query: "~/dev" }); + }); + + it("returns to the mixed list when the sigil is backspaced away", () => { + expect(parseBarQuery(">").sigil).toBe(">"); + expect(parseBarQuery("").sigil).toBeNull(); + }); +}); + +describe("command candidates", () => { + it("dedupes by action, not by chord", () => { + const run = () => {}; + const kill = command("a.kill", "Kill the session", ["x"], run); + const alias = command("b.kill", "Kill the session", ["ctrl-x"], run); + const other = command("c.rename", "Rename the session", ["r"]); + expect(dedupeCommands([kill, alias, other]).map((c) => c.id)).toEqual(["a.kill", "c.rename"]); + }); + + it("matches on the label only, and keeps the chord for display", () => { + const candidates = commandBarCandidates([command("a", "Open the spawn form", ["ctrl-k"])]); + expect(candidates[0].fields).toEqual({ label: "Open the spawn form" }); + expect(candidates[0].command.keys).toEqual(["ctrl-k"]); + }); +}); + +describe("directory candidates", () => { + it("abbreviates the path it shows and keeps the one it spawns in", () => { + const c = directoryCandidate("/home/me/dev/api", "/home/me"); + expect(c).toEqual({ kind: "directory", path: "/home/me/dev/api", fields: { path: "~/dev/api" } }); + }); + + it("expands a typed `~` and dedupes by the directory spawned in", () => { + const paths = ["~/dev/api", "/home/me/dev/api", "/tmp/scratch"]; + expect(directoryBarCandidates(paths, "/home/me").map((c) => c.path)).toEqual([ + "/home/me/dev/api", + "/tmp/scratch", + ]); + }); +}); + +describe("the sigils in the overlay", () => { + const sessions = [session("api", { metadata: { name: "api" }, current_cwd: "/home/me/dev/api" })]; + const ran: string[] = []; + const commands = [ + command("spawn.open", "Open the spawn form", ["n"], () => ran.push("spawn.open")), + command("list.kill", "Kill the highlighted session", ["x"], () => ran.push("list.kill")), + ]; + + afterEach(() => { + ran.length = 0; + }); + + function open(over: Partial[0]> = {}) { + return openCommandBar({ + sessions, + homeDir: "/home/me", + commands, + onOpenSession: () => {}, + loadDirectories: async () => [], + listDirEntries: async () => [], + onSpawnDirectory: () => {}, + ...over, + }); + } + + it("lists every command in registry order under `>`, with its chord and no header", () => { + open(); + type(">"); + expect(rowNames()).toEqual(["Open the spawn form", "Kill the highlighted session"]); + expect(rowKeys()).toEqual([["n"], ["x"]]); + expect(document.querySelectorAll(".cmdbar-group-title")).toHaveLength(0); + }); + + it("matches a command on its label and never on its chord", () => { + open(); + type(">kill"); + expect(rowNames()).toEqual(["Kill the highlighted session"]); + // `n` and `x` are chords, not label text: querying one finds nothing that + // the label does not also justify. + type(">ctrlx"); + expect(rowNames()).toEqual([]); + }); + + it("runs the highlighted command on Enter", () => { + open(); + type(">kill"); + press("Enter"); + expect(ran).toEqual(["list.kill"]); + expect(isCommandBarOpen()).toBe(false); + }); + + it("keeps the list mixed with no sigil, and returns to it when the sigil goes", () => { + open(); + type("s"); + expect([...document.querySelectorAll(".cmdbar-group-title")].map((h) => h.textContent)).toEqual([ + "Sessions", + "Commands", + ]); + type(">spawn"); + expect(rowNames()).toEqual(["Open the spawn form"]); + // Backspacing the sigil away is ordinary editing. + type("spawn"); + expect(rowNames()).toContain("Open the spawn form"); + type(""); + expect(rowNames()).toEqual(["api"]); + }); + + it("lists recent dirs then projects under `~`", async () => { + open({ loadDirectories: async () => ["/home/me/dev/api", "/srv/www"] }); + type("~"); + await flush(); + expect(rowNames()).toEqual(["~/dev/api", "/srv/www"]); + expect(document.querySelectorAll(".cmdbar-group-title")).toHaveLength(0); + }); + + it("fills the list in from /api/dirs as a path is typed", async () => { + const asked: string[] = []; + open({ + loadDirectories: async () => ["/home/me/notes"], + listDirEntries: async (dir) => { + asked.push(dir); + return ["maestro", "api"]; + }, + }); + // The second `~` is a literal query character and reaches the matcher. + type("~~/dev/mae"); + await flush(); + expect(asked).toEqual(["~/dev/"]); + expect(rowNames()).toEqual(["~/dev/maestro"]); + }); + + it("spawns in the highlighted directory on Enter", async () => { + const spawned: string[] = []; + open({ + loadDirectories: async () => ["~/dev/api"], + onSpawnDirectory: (path) => spawned.push(path), + }); + type("~api"); + await flush(); + press("Enter"); + expect(spawned).toEqual(["/home/me/dev/api"]); + expect(isCommandBarOpen()).toBe(false); + }); + + it("raises the spawn event when the caller supplies no handler", async () => { + const seen: string[] = []; + const listener = (e: Event) => seen.push((e as CustomEvent).detail); + document.addEventListener(SPAWN_DIRECTORY_EVENT, listener); + openCommandBar({ + sessions, + homeDir: "/home/me", + commands, + onOpenSession: () => {}, + loadDirectories: async () => ["/home/me/dev/api"], + listDirEntries: async () => [], + }); + type("~"); + await flush(); + press("Enter"); + document.removeEventListener(SPAWN_DIRECTORY_EVENT, listener); + expect(seen).toEqual(["/home/me/dev/api"]); + }); +}); diff --git a/web/src/commandBar.ts b/web/src/commandBar.ts index 8ec33e7..a0ad6d5 100644 --- a/web/src/commandBar.ts +++ b/web/src/commandBar.ts @@ -1,33 +1,47 @@ // The `ctrl-k` command bar (docs/COMMAND-BAR.md): a query line over a result // list, opened from the sidebar, closed with Escape. // -// Sessions are the only kind here (#149); the sigils, the action page and the -// terminal-focus binding land in #150-#152. +// Three kinds live here: sessions and commands, ranked together by default, +// and directories, which need the `~` sigil. The action page and the +// terminal-focus binding land in #151-#152. +import { listDir, projects as fetchProjects, recentDirs } from "./api.js"; import { SESSION_SELECT_EVENT } from "./catalog.js"; +import { keyCaps, publishedCommands, type Command } from "./commands.js"; import { matchAndRank, type CandidateKind, + type CommandCandidate, + type DirectoryCandidate, type MatchRange, type MatchResult, type SessionCandidate, } from "./matcher.js"; +import { splitPath } from "./paths.js"; import { splitCommand } from "./toolIcon.js"; import { abbreviateCwd, sessionName, sessionTreePath, type Session } from "./types.js"; -/** A matcher candidate that remembers which session it came from, so a result - * can be acted on without a second lookup. The extra property is invisible to - * the matcher, which only ever reads `kind` and `fields`. */ +/** A matcher candidate that remembers what it came from, so a result can be + * acted on without a second lookup. The extra property is invisible to the + * matcher, which only ever reads `kind` and `fields`. */ export type SessionBarCandidate = SessionCandidate & { session: Session }; +export type CommandBarCandidate = CommandCandidate & { command: Command }; +/** `fields.path` is the abbreviated path the operator reads and the matcher + * ranks; `path` is the one a spawn is made in. */ +export type DirectoryBarCandidate = DirectoryCandidate & { path: string }; -export type BarCandidate = SessionBarCandidate; +export type BarCandidate = SessionBarCandidate | CommandBarCandidate | DirectoryBarCandidate; export type BarResult = MatchResult; +/** Raised when Enter lands on a directory row. The bar picks the directory; the + * shell owns the spawn, which is the same fast-forward path `N` takes. */ +export const SPAWN_DIRECTORY_EVENT = "cmdbar-spawn-directory"; + /** One kind's slice of the ranked list, in rank order. */ -export interface BarGroup { +export interface BarGroup { kind: CandidateKind; title: string; - results: BarResult[]; + results: MatchResult[]; } const KIND_TITLES: Record = { @@ -36,6 +50,29 @@ const KIND_TITLES: Record = { directory: "Directories", }; +// ---- the sigils ---- + +export type BarSigil = ">" | "~"; + +export interface BarQuery { + /** The sigil in force, or null for the mixed list. */ + sigil: BarSigil | null; + /** What the matcher sees: everything after a sigil, or the whole query. */ + query: string; +} + +/** Split a raw query line into its sigil and the query proper. + * + * A sigil is only a sigil in first position: `>` or `~` anywhere else is a + * literal query character, which is what a directory query with a second `~` + * in it depends on. Backspacing past a sigil needs no special case either — + * the first character goes and the query is a mixed one again. */ +export function parseBarQuery(raw: string): BarQuery { + const first = raw[0]; + if (first === ">" || first === "~") return { sigil: first, query: raw.slice(1) }; + return { sigil: null, query: raw }; +} + // ---- candidates ---- function focusedAt(s: Session): number | null { @@ -99,14 +136,92 @@ export function sessionBarCandidates( return orderSessionsForBar(sessions).map((s) => sessionCandidate(s, homeDir)); } +/** Drop the commands that would list twice. Several chords can be bound to one + * action — the registry already carries its aliases on one entry, but nothing + * stops two entries running the same handler, and listed naively that is two + * identical rows. The dedupe is by **action**, never by chord: the chord is + * displayed, so a command with two of them is still one row. */ +export function dedupeCommands(commands: readonly Command[]): Command[] { + const seenRun = new Set(); + const seenLabel = new Set(); + const kept: Command[] = []; + for (const command of commands) { + if (seenRun.has(command.run) || seenLabel.has(command.label)) continue; + seenRun.add(command.run); + seenLabel.add(command.label); + kept.push(command); + } + return kept; +} + +/** Every bound command as a candidate, in registry order. Only the label is a + * field: the chord is displayed beside the row, never matched, because + * matching it would make `>` results depend on a user's rebinds. */ +export function commandBarCandidates(commands: readonly Command[]): CommandBarCandidate[] { + return dedupeCommands(commands).map((command) => ({ + kind: "command", + command, + fields: { label: command.label }, + })); +} + +/** `~/dev/api` back to `/home/me/dev/api` — the inverse of `abbreviateCwd`, for + * a path the operator typed or `/api/dirs` completed. */ +export function expandHome(path: string, homeDir?: string): string { + if (!homeDir) return path; + if (path === "~") return homeDir; + return path.startsWith("~/") ? `${homeDir}${path.slice(1)}` : path; +} + +export function directoryCandidate(path: string, homeDir?: string): DirectoryBarCandidate { + const absolute = expandHome(path, homeDir); + return { kind: "directory", path: absolute, fields: { path: abbreviateCwd(absolute, homeDir) } }; +} + +/** Candidates for `~`, deduped by the directory they spawn in and keeping the + * first mention of each — recent dirs (newest first) ahead of the project + * list, which is the empty-query order the contract asks for. */ +export function directoryBarCandidates( + paths: readonly string[], + homeDir?: string, +): DirectoryBarCandidate[] { + const seen = new Set(); + const out: DirectoryBarCandidate[] = []; + for (const path of paths) { + if (path === "") continue; + const candidate = directoryCandidate(path, homeDir); + if (seen.has(candidate.path)) continue; + seen.add(candidate.path); + out.push(candidate); + } + return out; +} + +/** The directory list the bar starts from: what the spawn form already knows — + * `GET /api/recent-dirs` and the project list — either alone if the other + * fails. */ +export async function loadBarDirectories(): Promise { + const [recents, discovered] = await Promise.all([ + recentDirs() + .then((r) => r.dirs.map((d) => d.cwd)) + .catch(() => [] as string[]), + fetchProjects() + .then((list) => list.map((p) => p.path)) + .catch(() => [] as string[]), + ]); + return [...recents, ...discovered]; +} + // ---- grouping ---- /** Bucket a ranked list by kind for rendering. Grouping is a rendering of the * ranking, never a reordering of it: a group holds its matches in rank order, * and groups appear in the order their best-scoring member does — so the first * row under the first header is the globally best result. */ -export function barGroups(results: readonly BarResult[]): BarGroup[] { - const groups: BarGroup[] = []; +export function barGroups( + results: readonly MatchResult[], +): BarGroup[] { + const groups: BarGroup[] = []; for (const result of results) { const kind = result.candidate.kind; let group = groups.find((g) => g.kind === kind); @@ -122,7 +237,10 @@ export function barGroups(results: readonly BarResult[]): BarGroup[] { /** The rows a query produces, flat and in rank order — what the highlight walks * and what Enter acts on. Headers are labels, not rows, so they are never in * this list. */ -export function barRows(query: string, candidates: readonly BarCandidate[]): BarResult[] { +export function barRows( + query: string, + candidates: readonly C[], +): MatchResult[] { return matchAndRank(query, candidates); } @@ -145,9 +263,25 @@ export function renderHighlighted(text: string, ranges: readonly MatchRange[]): return frag; } -/** The secondary line under a row's name: the field the match was found in when - * that was not the name, so a session found through its directory says so. */ +/** The name line of a row: the session's name, the command's label, or the + * directory's path. */ +function rowName(candidate: BarCandidate): string { + switch (candidate.kind) { + case "session": + return candidate.fields.name; + case "command": + return candidate.fields.label; + case "directory": + return candidate.fields.path; + } +} + +/** The secondary line under a session row: the field the match was found in + * when that was not the name, so a session found through its directory says + * so. Only sessions have one — a command shows keycaps instead, and a + * directory's path is already the name. */ function metaText(result: BarResult): string | undefined { + if (result.candidate.kind !== "session") return undefined; const fields = result.candidate.fields; switch (result.field) { case "directory": @@ -162,22 +296,44 @@ function metaText(result: BarResult): string | undefined { } function renderRow(result: BarResult, index: number): HTMLElement { + const candidate = result.candidate; const row = document.createElement("div"); row.className = "cmdbar-row"; row.setAttribute("role", "option"); row.id = `cmdbar-row-${index}`; row.dataset.index = String(index); - row.dataset.sessionId = result.candidate.session.id; - if (result.candidate.session.status === "Dead") row.classList.add("is-dead"); + row.dataset.kind = candidate.kind; + if (candidate.kind === "session") { + row.dataset.sessionId = candidate.session.id; + if (candidate.session.status === "Dead") row.classList.add("is-dead"); + } else if (candidate.kind === "command") { + row.dataset.commandId = candidate.command.id; + } else { + row.dataset.path = candidate.path; + } const name = document.createElement("span"); name.className = "cmdbar-row-name"; - const nameText = result.candidate.fields.name; - name.append( - result.field === "name" ? renderHighlighted(nameText, result.ranges) : nameText, - ); + const nameText = rowName(candidate); + const nameMatched = + result.field === "name" || result.field === "label" || result.field === "path"; + name.append(nameMatched ? renderHighlighted(nameText, result.ranges) : nameText); row.appendChild(name); + // The chord is teaching material, not a match target: it is rendered from + // the live registry entry and never fed to the matcher. + if (candidate.kind === "command") { + const keys = document.createElement("span"); + keys.className = "cmdbar-row-keys"; + for (const cap of keyCaps(candidate.command.keys[0])) { + const kbd = document.createElement("kbd"); + kbd.textContent = cap; + keys.appendChild(kbd); + } + row.appendChild(keys); + return row; + } + const meta = metaText(result); if (meta) { const el = document.createElement("span"); @@ -189,6 +345,12 @@ function renderRow(result: BarResult, index: number): HTMLElement { return row; } +const PLACEHOLDERS: Record<"mixed" | BarSigil, string> = { + mixed: "Go to a session…", + ">": "Run a command…", + "~": "Spawn in a directory…", +}; + // ---- the overlay ---- export interface CommandBarOptions { @@ -200,6 +362,18 @@ export interface CommandBarOptions { * sidebar's click event — which is what the contract requires; tests pass * their own. */ onOpenSession?: (sessionId: string) => void; + /** The command registry to list under `>`. Defaults to every bound command, + * in registry order. */ + commands?: readonly Command[]; + /** The directory list behind `~`, fetched once on first use. */ + loadDirectories?: () => Promise; + /** One directory's subdirectories, for filling the list in as a path is + * typed. Defaults to `GET /api/dirs`. */ + listDirEntries?: (dir: string) => Promise; + /** Spawn a session in the highlighted directory. Defaults to raising + * `SPAWN_DIRECTORY_EVENT`, which the shell turns into a fast-forward + * spawn. */ + onSpawnDirectory?: (path: string) => void; } interface OpenBar { @@ -228,8 +402,21 @@ export function openCommandBar(opts: CommandBarOptions): HTMLElement { const openSession = opts.onOpenSession ?? ((id: string) => document.dispatchEvent(new CustomEvent(SESSION_SELECT_EVENT, { detail: id }))); + const spawnDirectory = + opts.onSpawnDirectory ?? + ((path: string) => + document.dispatchEvent(new CustomEvent(SPAWN_DIRECTORY_EVENT, { detail: path }))); + const loadDirectories = opts.loadDirectories ?? loadBarDirectories; + const listEntries = + opts.listDirEntries ?? ((dir: string) => listDir(dir).then((listing) => listing.entries)); - const candidates: BarCandidate[] = sessionBarCandidates(opts.sessions, opts.homeDir); + const sessionCandidates: SessionBarCandidate[] = sessionBarCandidates( + opts.sessions, + opts.homeDir, + ); + const commandCandidates: CommandBarCandidate[] = commandBarCandidates( + opts.commands ?? publishedCommands(), + ); const el = document.createElement("div"); el.className = "cmdbar-overlay"; @@ -246,10 +433,10 @@ export function openCommandBar(opts: CommandBarOptions): HTMLElement { input.setAttribute("role", "combobox"); input.setAttribute("aria-expanded", "true"); input.setAttribute("aria-controls", "cmdbar-results"); - input.setAttribute("aria-label", "Search sessions"); + input.setAttribute("aria-label", "Search sessions, commands and directories"); input.setAttribute("autocomplete", "off"); input.spellcheck = false; - input.placeholder = "Go to a session…"; + input.placeholder = PLACEHOLDERS.mixed; const results = document.createElement("div"); results.className = "cmdbar-results"; @@ -263,6 +450,90 @@ export function openCommandBar(opts: CommandBarOptions): HTMLElement { let rows: BarResult[] = []; let highlighted = 0; + // `~`'s candidates: the recents-and-projects list, fetched once, plus + // whatever `/api/dirs` has filled in for the directory being typed. + let directories: DirectoryBarCandidate[] = []; + let directoriesRequested = false; + let probed: DirectoryBarCandidate[] = []; + let probedDir: string | null = null; + const listings = new Map(); + + function alive(): boolean { + return current?.el === el; + } + + /** Fetch the recents-and-projects list the first time `~` is used. */ + function ensureDirectories(): void { + if (directoriesRequested) return; + directoriesRequested = true; + loadDirectories() + .then((paths) => { + directories = directoryBarCandidates(paths, opts.homeDir); + if (alive()) render(); + }) + .catch(() => { + /* no directories is a shorter list, not an error state */ + }); + } + + /** Fill the list in from `GET /api/dirs` once the query looks like a path: + * the directory it names is listed and its children join the candidates, so + * `~` reaches a directory that was never spawned in. */ + function probeDirectories(query: string): void { + const { dir } = splitPath(query); + if (dir === "") { + probed = []; + probedDir = null; + return; + } + if (dir === probedDir) return; + probedDir = dir; + const cached = listings.get(dir); + if (cached) { + probed = directoryBarCandidates( + cached.map((entry) => `${dir}${entry}`), + opts.homeDir, + ); + return; + } + probed = []; + listEntries(dir) + .then((entries) => { + listings.set(dir, entries); + if (!alive() || probedDir !== dir) return; + probed = directoryBarCandidates( + entries.map((entry) => `${dir}${entry}`), + opts.homeDir, + ); + render(); + }) + .catch(() => { + /* an unreadable directory contributes nothing */ + }); + } + + /** What the matcher sees for the query line as it stands. With no sigil the + * list is mixed — sessions and commands together — except on an empty query, + * where the bar is a session switcher. */ + function candidatesFor(parsed: BarQuery): BarCandidate[] { + switch (parsed.sigil) { + case ">": + return [...commandCandidates]; + case "~": { + ensureDirectories(); + probeDirectories(parsed.query); + const seen = new Set(); + return [...directories, ...probed].filter((c) => + seen.has(c.path) ? false : (seen.add(c.path), true), + ); + } + default: + return parsed.query === "" + ? [...sessionCandidates] + : [...sessionCandidates, ...commandCandidates]; + } + } + function paintHighlight(): void { for (const row of results.querySelectorAll(".cmdbar-row")) { const active = Number(row.dataset.index) === highlighted; @@ -277,7 +548,9 @@ export function openCommandBar(opts: CommandBarOptions): HTMLElement { } function render(): void { - rows = barRows(input.value, candidates); + const parsed = parseBarQuery(input.value); + input.placeholder = PLACEHOLDERS[parsed.sigil ?? "mixed"]; + rows = barRows(parsed.query, candidatesFor(parsed)); // The highlight starts on the globally best row every time the list // changes: an old index would point at a different result. highlighted = 0; @@ -294,11 +567,14 @@ export function openCommandBar(opts: CommandBarOptions): HTMLElement { let index = 0; for (const group of barGroups(rows)) { - const header = document.createElement("div"); - header.className = "cmdbar-group-title"; - header.setAttribute("role", "presentation"); - header.textContent = group.title; - results.appendChild(header); + // While a sigil is in force there is one kind, so the header is dropped. + if (parsed.sigil === null) { + const header = document.createElement("div"); + header.className = "cmdbar-group-title"; + header.setAttribute("role", "presentation"); + header.textContent = group.title; + results.appendChild(header); + } for (const result of group.results) { const at = index; const row = renderRow(result, at); @@ -320,9 +596,19 @@ export function openCommandBar(opts: CommandBarOptions): HTMLElement { function activate(index: number): void { const result = rows[index]; if (!result) return; - const sessionId = result.candidate.session.id; + const candidate = result.candidate; close(); - openSession(sessionId); + switch (candidate.kind) { + case "session": + openSession(candidate.session.id); + return; + case "command": + candidate.command.run(); + return; + case "directory": + spawnDirectory(candidate.path); + return; + } } function onKeydown(e: KeyboardEvent): void { diff --git a/web/src/main.ts b/web/src/main.ts index ec3b788..fa1c46a 100644 --- a/web/src/main.ts +++ b/web/src/main.ts @@ -8,7 +8,7 @@ import { installDrawer, installDrawerSwipe, requestToggleDrawer } from "./drawer import { installHudKeybind } from "./debug.js"; import { installHelpKeybind, isHelpOpen } from "./help.js"; import { CommandSet, DEFAULT_LEADER, defineCommands, isTypingTarget } from "./commands.js"; -import { isCommandBarOpen, openCommandBar } from "./commandBar.js"; +import { isCommandBarOpen, openCommandBar, SPAWN_DIRECTORY_EVENT } from "./commandBar.js"; import { DaemonLink } from "./daemonLink.js"; import { CatalogStore } from "./events.js"; import { installRestartOverlay } from "./restartOverlay.js"; @@ -17,6 +17,7 @@ import { createSession, fetchNames, getDaemonInfo, + gitRoot, projects as fetchProjects, } from "./api.js"; import { @@ -337,6 +338,16 @@ function mountSpawned(session: Session, placement?: { newTab?: boolean }): void // a new branch named after that name. Anything unresolvable (no projects // known yet, no command, no generated name) opens the form pre-filled with // what did resolve instead. +async function spawnPresets(): Promise { + try { + const info = await getDaemonInfo(); + if (info.presets?.length) return info.presets; + } catch { + /* keep fallbacks */ + } + return FALLBACK_PRESETS; +} + async function requestFastForward(): Promise { const [discovered, name] = await Promise.all([ fetchProjects().catch(() => [] as Project[]), @@ -344,13 +355,7 @@ async function requestFastForward(): Promise { .then((batch) => batch.names[0]) .catch(() => undefined), ]); - let presets: SpawnPreset[] = FALLBACK_PRESETS; - try { - const info = await getDaemonInfo(); - if (info.presets?.length) presets = info.presets; - } catch { - /* keep fallbacks */ - } + const presets = await spawnPresets(); const resolved = resolveFastForward(discovered, presets, name, focusedProjectCwd()); const req = buildFastForwardRequest(resolved); if (req) { @@ -372,6 +377,47 @@ async function requestFastForward(): Promise { }); } +// Enter on a `~` row in the command bar (docs/COMMAND-BAR.md): the same +// fast-forward spawn, aimed at the chosen directory instead of the most recent +// project. A directory the project scan never saw stands in as its own project; +// one outside any repo spawns without a worktree, since there is no repo to cut +// a branch from. Anything still unresolved opens the pre-filled form. +async function requestSpawnInDirectory(path: string): Promise { + const [discovered, name] = await Promise.all([ + fetchProjects().catch(() => [] as Project[]), + fetchNames(1) + .then((batch) => batch.names[0]) + .catch(() => undefined), + ]); + const presets = await spawnPresets(); + const known = discovered.find((p) => p.path === path); + const project: Project = known ?? { path, name: path.split("/").filter(Boolean).pop() ?? path }; + const resolved = resolveFastForward([project], presets, name, path); + const req = buildFastForwardRequest(resolved); + if (req) { + const inRepo = + known !== undefined || + (await gitRoot(path) + .then((r) => !!r.root) + .catch(() => false)); + if (!inRepo) delete req.worktree; + try { + mountSpawned(await createSession(req)); + return; + } catch { + // A failed POST degrades to the pre-filled form, never a dead row. + } + } + openSpawnModal({ + seed: fastForwardSeed(resolved), + onSuccess: (session) => mountSpawned(session), + }); +} + +document.addEventListener(SPAWN_DIRECTORY_EVENT, (ev) => { + void requestSpawnInDirectory((ev as CustomEvent).detail); +}); + // 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 diff --git a/web/src/styles.css b/web/src/styles.css index 71ab291..4b293b4 100644 --- a/web/src/styles.css +++ b/web/src/styles.css @@ -1517,6 +1517,25 @@ input.leaf-rename:focus { text-overflow: ellipsis; } +/* A command row's chord, shown so the bar teaches the keymap. */ +.cmdbar-row-keys { + flex: 1 1 auto; + display: flex; + justify-content: flex-end; + gap: var(--sp-1); + min-width: 0; +} + +.cmdbar-row-keys kbd { + font-family: var(--font-mono); + font-size: var(--fs-11); + color: var(--muted); + background: var(--surface-2); + border: 1px solid var(--border); + border-radius: var(--r-1); + padding: 0 var(--sp-1); +} + .cmdbar-hit { background: none; color: var(--accent); From b2cbb23fe220ecda3c9d5567e48e821c2a001157 Mon Sep 17 00:00:00 2001 From: Miguel Palhas Date: Thu, 3 Sep 2026 17:03:19 +0100 Subject: [PATCH 10/14] feat(maestro): open the bar with ctrl-k over a focused pane MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The global listener already ran in the capture phase, but deferred to isTypingTarget, so xterm's hidden textarea kept Ctrl-K. The bar now treats that textarea as not a typing target, while real form fields — the spawn form's inputs — keep their own Ctrl-K. Leader then ctrl-k (ctrl-a ctrl-k) sends the literal 0x0B to the focused pane through the leader chord the command set already resolves, so readline's kill-to-end-of-line stays reachable. The pool grows a sendToFocused for it, over a new AttachConnection.sendInput. Co-Authored-By: Claude Opus 5 --- web/src/attach.ts | 7 ++++ web/src/commandBar.test.ts | 35 ++++++++++++++++++ web/src/commands.test.ts | 73 +++++++++++++++++++++++++++++++++++++ web/src/commands.ts | 25 ++++++++++--- web/src/connections.test.ts | 40 ++++++++++++++++++++ web/src/connections.ts | 12 ++++++ web/src/main.ts | 16 +++++--- 7 files changed, 198 insertions(+), 10 deletions(-) diff --git a/web/src/attach.ts b/web/src/attach.ts index 0dc1492..3886680 100644 --- a/web/src/attach.ts +++ b/web/src/attach.ts @@ -50,6 +50,10 @@ export interface AttachConnection { onUnmount(): void; /** Put the keyboard back into this terminal — what leaving the sidebar does. */ focusTerminal(): void; + /** Type raw bytes straight into the PTY, bypassing the terminal's own key + * handling — the leader's literal Ctrl-K escape hatch + * (docs/COMMAND-BAR.md). */ + sendInput(data: string): void; /** Fully disconnect: close the WS, dispose the terminal, remove the element. */ dispose(): void; } @@ -506,6 +510,9 @@ export function createAttachConnection( focusTerminal() { term.focus(); }, + // The closure above: the same bytes-out path keystrokes and the mobile key + // bar already take. + sendInput, dispose() { if (disposed) return; disposed = true; diff --git a/web/src/commandBar.test.ts b/web/src/commandBar.test.ts index bb2cc6a..bf4ebd4 100644 --- a/web/src/commandBar.test.ts +++ b/web/src/commandBar.test.ts @@ -507,3 +507,38 @@ describe("the sigils in the overlay", () => { expect(seen).toEqual(["/home/me/dev/api"]); }); }); + +describe("the focus round-trip over a pane (#152)", () => { + function terminalTextarea(): HTMLTextAreaElement { + const ta = document.createElement("textarea"); + ta.className = "xterm-helper-textarea"; + document.body.appendChild(ta); + return ta; + } + + it("hands the keyboard back to the terminal that had it", () => { + const ta = terminalTextarea(); + ta.focus(); + openCommandBar({ sessions: [], commands: [] }); + expect(document.activeElement).toBe(document.querySelector(".cmdbar-input")); + press("Escape"); + expect(document.activeElement).toBe(ta); + }); + + it("reports no focus change to the daemon, opening or closing", () => { + const fetchMock = vi.fn(() => Promise.resolve(new Response(null, { status: 204 }))); + vi.stubGlobal("fetch", fetchMock); + try { + const ta = terminalTextarea(); + ta.focus(); + openCommandBar({ sessions: [], commands: [] }); + press("Escape"); + const focusCalls = fetchMock.mock.calls.filter(([url]: unknown[]) => + String(url).includes("/focus"), + ); + expect(focusCalls).toEqual([]); + } finally { + vi.unstubAllGlobals(); + } + }); +}); diff --git a/web/src/commands.test.ts b/web/src/commands.test.ts index 4bb658c..5202ab5 100644 --- a/web/src/commands.test.ts +++ b/web/src/commands.test.ts @@ -7,6 +7,8 @@ import { DEFAULT_LEADER, canonicalizeKey, defineCommands, + isBarTypingTarget, + isTerminalTextarea, isTypingTarget, keyCaps, publishedCommands, @@ -234,3 +236,74 @@ describe("isTypingTarget", () => { expect(isTypingTarget(null)).toBe(false); }); }); + +describe("isTerminalTextarea", () => { + it("recognises xterm's offscreen textarea, and nothing else", () => { + expect(isTerminalTextarea(el(''))).toBe(true); + expect(isTerminalTextarea(el(""))).toBe(false); + expect(isTerminalTextarea(el(""))).toBe(false); + expect(isTerminalTextarea(null)).toBe(false); + }); +}); + +describe("isBarTypingTarget", () => { + it("holds the bar's keys out of real form fields", () => { + expect(isBarTypingTarget(el(""))).toBe(true); + expect(isBarTypingTarget(el(""))).toBe(true); + }); + + it("does not count the terminal's textarea, so ctrl-k reaches the bar there", () => { + expect(isBarTypingTarget(el(''))).toBe(false); + expect(isBarTypingTarget(el(""))).toBe(false); + expect(isBarTypingTarget(null)).toBe(false); + }); +}); + +describe("ctrl-k over a focused pane (#152)", () => { + const terminal = () => el(''); + + /** The global listener's routing: the bar's set only sees a key that is not + * aimed at a real form field. */ + function route(set: CommandSet, target: HTMLElement, k: string, init?: KeyboardEventInit) { + if (isBarTypingTarget(target)) return false; + return pressOn(set, target, k, init); + } + + it("opens the bar when the terminal has the keyboard", () => { + const open = vi.fn(); + const set = new CommandSet(defineCommands({ "bar.open": open })); + expect(route(set, terminal(), "k", { ctrlKey: true })).toBe(true); + expect(open).toHaveBeenCalledTimes(1); + }); + + it("leaves the spawn form's inputs their own Ctrl-K", () => { + const open = vi.fn(); + const set = new CommandSet(defineCommands({ "bar.open": open })); + expect(route(set, el(""), "k", { ctrlKey: true })).toBe(false); + expect(open).not.toHaveBeenCalled(); + }); + + it("sends the literal 0x0B after the leader, without opening the bar", () => { + const open = vi.fn(); + const literal = vi.fn(); + const bar = new CommandSet(defineCommands({ "bar.open": open })); + const set = new CommandSet(defineCommands({ "pane.literalCtrlK": literal }), { + leader: DEFAULT_LEADER, + }); + const target = terminal(); + expect(pressOn(set, target, "a", { ctrlKey: true })).toBe(true); + const e = key("k", { ctrlKey: true }); + Object.defineProperty(e, "target", { value: target }); + expect(set.handle(e)).toBe(true); + expect(literal).toHaveBeenCalledTimes(1); + // The leader's set consumed the key, so the bar's set never sees it. + expect(open).not.toHaveBeenCalled(); + expect(bar.handle(e)).toBe(true); // sanity: it would have opened otherwise + expect(open).toHaveBeenCalledTimes(1); + }); + + it("binds the escape hatch behind the default leader", () => { + const spec = COMMAND_SPECS.find((s) => s.id === "pane.literalCtrlK"); + expect(spec?.keys).toEqual([`${DEFAULT_LEADER} ctrl-k`]); + }); +}); diff --git a/web/src/commands.ts b/web/src/commands.ts index 72a409d..1d8ff61 100644 --- a/web/src/commands.ts +++ b/web/src/commands.ts @@ -32,6 +32,10 @@ export interface Command extends CommandSpec { run: () => void; } +/** 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"; + /** 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[] = [ @@ -56,6 +60,13 @@ export const COMMAND_SPECS: readonly CommandSpec[] = [ keys: ["ctrl-k"], scope: "global", }, + { + id: "pane.literalCtrlK", + label: "Send a literal Ctrl-K to the pane", + group: "Pane", + keys: [`${DEFAULT_LEADER} ctrl-k`], + scope: "global", + }, { id: "focus.toggle", label: "Move the keyboard between the session list and the pane", @@ -241,11 +252,19 @@ export function isTypingTarget(target: EventTarget | null): boolean { /** 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 { +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 = { @@ -292,10 +311,6 @@ export function keyCaps(chord: string): string[] { // ---- 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; diff --git a/web/src/connections.test.ts b/web/src/connections.test.ts index 27622de..9464504 100644 --- a/web/src/connections.test.ts +++ b/web/src/connections.test.ts @@ -3,6 +3,23 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import { ConnectionPool } from "./connections.js"; +// The pane's own bytes-out path, stubbed: the escape hatch only needs to know +// the pool reaches the focused connection with them. +const sent: { id: string; data: string }[] = []; +vi.mock("./attach.js", () => ({ + createAttachConnection: (id: string) => ({ + sessionId: id, + el: document.createElement("div"), + onMount() {}, + onUnmount() {}, + focusTerminal() {}, + sendInput(data: string) { + sent.push({ id, data }); + }, + dispose() {}, + }), +})); + afterEach(() => { vi.unstubAllGlobals(); }); @@ -85,3 +102,26 @@ describe("ConnectionPool.setFocus", () => { await Promise.resolve(); }); }); + +describe("ConnectionPool.sendToFocused", () => { + it("types the bytes into the focused session's PTY", () => { + mockFetch(); + sent.length = 0; + const pool = new ConnectionPool(); + const pane = document.createElement("div"); + pool.mount(A, pane); + pool.setFocus(A); + + expect(pool.sendToFocused("\x0b")).toBe(true); + expect(sent).toEqual([{ id: A, data: "\x0b" }]); + }); + + it("is a no-op with nothing focused", () => { + mockFetch(); + sent.length = 0; + const pool = new ConnectionPool(); + + expect(pool.sendToFocused("\x0b")).toBe(false); + expect(sent).toEqual([]); + }); +}); diff --git a/web/src/connections.ts b/web/src/connections.ts index 53b7082..1f17afe 100644 --- a/web/src/connections.ts +++ b/web/src/connections.ts @@ -159,6 +159,18 @@ export class ConnectionPool { this.cache.get(this.focusedId)?.focusTerminal(); } + /** Type raw bytes into the focused session's PTY. Returns false when there is + * nothing focused, or its connection is gone — the leader's literal Ctrl-K + * is a no-op over the welcome pane rather than an error + * (docs/COMMAND-BAR.md). */ + sendToFocused(data: string): boolean { + if (this.focusedId === null) return false; + const conn = this.cache.get(this.focusedId); + if (!conn) return false; + conn.sendInput(data); + return true; + } + /** Fully disconnect + forget a session. */ close(id: Uuid): void { const conn = this.cache.get(id); diff --git a/web/src/main.ts b/web/src/main.ts index fa1c46a..6811186 100644 --- a/web/src/main.ts +++ b/web/src/main.ts @@ -7,7 +7,7 @@ import { ConnectionPool } from "./connections.js"; import { installDrawer, installDrawerSwipe, requestToggleDrawer } from "./drawer.js"; import { installHudKeybind } from "./debug.js"; import { installHelpKeybind, isHelpOpen } from "./help.js"; -import { CommandSet, DEFAULT_LEADER, defineCommands, isTypingTarget } from "./commands.js"; +import { CommandSet, DEFAULT_LEADER, defineCommands, isBarTypingTarget } from "./commands.js"; import { isCommandBarOpen, openCommandBar, SPAWN_DIRECTORY_EVENT } from "./commandBar.js"; import { DaemonLink } from "./daemonLink.js"; import { CatalogStore } from "./events.js"; @@ -424,7 +424,7 @@ document.addEventListener(SPAWN_DIRECTORY_EVENT, (ev) => { // 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 +// keys, so `isTypingTarget` inside the set keeps them out of form fields and the terminal's // hidden textarea. const spawnKeys = new CommandSet( defineCommands({ @@ -451,6 +451,11 @@ const focusKeys = new CommandSet( sidebar.focusList(); } }, + // The escape hatch: binding ctrl-k takes readline's kill-to-end-of-line + // away from every pane, and leader ctrl-k gives it back as the literal + // 0x0B byte (docs/COMMAND-BAR.md). It resolves in this set, ahead of the + // bar's, so the chord never opens the bar. + "pane.literalCtrlK": () => pool.sendToFocused("\x0b"), }), { leader: DEFAULT_LEADER }, ); @@ -479,9 +484,10 @@ window.addEventListener( if (focusKeys.handle(e)) return; // The help overlay owns the keyboard while it is up. if (isHelpOpen()) return; - // Opening the bar over a focused terminal is #152; until then the pane's - // hidden textarea keeps its Ctrl+K, like any other typing target. - if (!isTypingTarget(e.target) && barKeys.handle(e)) return; + // The pane's hidden textarea is not a typing target for the bar: ctrl-k is + // seen ahead of xterm and opens the bar (#152). A real form field — the + // spawn form's inputs — keeps its own Ctrl+K. + if (!isBarTypingTarget(e.target) && barKeys.handle(e)) return; spawnKeys.handle(e); }, true, From 9d6b69cc49a0e41e2e00ce65707c5d74c857efed Mon Sep 17 00:00:00 2001 From: Miguel Palhas Date: Thu, 3 Sep 2026 17:17:27 +0100 Subject: [PATCH 11/14] feat(maestro): add the TUI command bar's > and ~ sigils MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `>` narrows to commands from the live keymap for the current mode, matched on the action's help_label only — the chord is displayed, never matched, so results cannot depend on a user's rebinds. Rows dedupe by action, not by chord: the default map binds both `enter` and `tab` to AttachHighlighted, and AttachHighlighted and ToggleAttachHighlighted share a label because they are the same match arm. `~` narrows to recent dirs plus the project list. Enter takes the fast-forward path pinned to that directory, with no recency fallback, so a directory outside the project scan opens the pre-filled form rather than spawning elsewhere. Both fall out of parsing the query line, as in web/src/commandBar.ts: a sigil is only a sigil in first position, and backspacing past one is just the query becoming mixed again. --- crates/maestro-tui/src/app.rs | 322 +++++++++++++- crates/maestro-tui/src/command_bar.rs | 582 ++++++++++++++++++++++--- crates/maestro-tui/src/fast_forward.rs | 67 +++ 3 files changed, 896 insertions(+), 75 deletions(-) diff --git a/crates/maestro-tui/src/app.rs b/crates/maestro-tui/src/app.rs index 089a808..7ecb642 100644 --- a/crates/maestro-tui/src/app.rs +++ b/crates/maestro-tui/src/app.rs @@ -168,6 +168,14 @@ pub enum Effect { /// Fast-forward spawn (#67): resolve every default from the daemon and /// spawn with no form; unresolvable defaults open a pre-filled form. FastForward, + /// The same fast path pinned to one directory — the command bar's `~` + /// (#154). Nothing may override the directory the operator named. + FastForwardIn(PathBuf), + /// Fetch the command bar's `~` candidates: recent dirs and the project + /// list. `bar` guards a load landing in a bar that was reopened since. + LoadBarDirectories { + bar: u64, + }, /// The form's target changed: probe `/api/gitroot` so the worktree /// section knows its base repo (#65). `seq` guards stale answers. ProbeGitRoot { @@ -386,6 +394,8 @@ impl App { Effect::Kill(id) => self.submit_kill(id).await, Effect::LoadDraft => self.spawn_load_draft(), Effect::FastForward => self.spawn_fast_forward(), + Effect::FastForwardIn(dir) => self.spawn_fast_forward_in(dir), + Effect::LoadBarDirectories { bar } => self.spawn_load_bar_directories(bar), Effect::ProbeGitRoot { seq, path } => self.spawn_probe_git_root(seq, path), Effect::LoadBranches => self.spawn_load_branches(), Effect::Rename { id, name } => self.submit_rename(id, &name).await, @@ -459,6 +469,16 @@ impl App { self.dispatch(Action::AttachHighlighted), ) } + // The bar picks the action and hands off; it never + // reimplements one. + BarOutcome::Open(BarTarget::Command(action)) => { + self.close_command_bar(); + (OverlayDecision::Consumed, self.dispatch(action)) + } + BarOutcome::Open(BarTarget::Directory(dir)) => { + self.close_command_bar(); + (OverlayDecision::Consumed, vec![Effect::FastForwardIn(dir)]) + } } } Some(OverlayKind::Help) => { @@ -506,7 +526,7 @@ impl App { }; self.state.show_overlay(OverlayKind::Help); } - Action::OpenCommandBar => self.open_command_bar(), + Action::OpenCommandBar => effects.extend(self.open_command_bar()), Action::OpenSpawnModal => { self.open_spawn_modal(); effects.push(Effect::LoadDraft); @@ -628,12 +648,21 @@ impl App { /// while a session is previewed discards the preview first, like any /// other real change (`docs/PREVIEW.md`); focus and mode are left alone, /// so closing gives the keyboard back to whatever held it. - fn open_command_bar(&mut self) { + fn open_command_bar(&mut self) -> Vec { let snapshot = self.catalog.snapshot(); - self.command_bar = Some(CommandBar::new(&snapshot.sessions, &self.home_dir)); + // The keymap for the mode the bar was opened from, which is the one + // the operator would be typing into behind it. Same source the help + // overlay reads, so the two can never disagree. + let keymap = self.cfg.keymap.entries(self.state.mode); + let bar = CommandBar::new(&snapshot.sessions, &keymap, &self.home_dir); + let id = bar.id(); + self.command_bar = Some(bar); self.pending_preview = None; self.clear_preview(); self.state.show_overlay(OverlayKind::CommandBar); + // `~`'s candidates are a network round trip; the bar opens without + // them and fills in when they land. + vec![Effect::LoadBarDirectories { bar: id }] } /// Take the bar down, dropping its query. Every exit goes through here so @@ -990,18 +1019,27 @@ impl App { projects, names, focused_cwd, + pinned, } = update { - self.fast_forward_spawn(info, projects, names, focused_cwd) + self.fast_forward_spawn(info, projects, names, focused_cwd, pinned) .await; return; } + // Addressed to the bar, not to a form: a load that outlived its bar is + // dropped rather than filling in the next one. + if let DraftUpdate::BarDirectories { bar, paths } = update { + if let Some(open) = self.command_bar.as_mut().filter(|b| b.id() == bar) { + open.set_directories(&paths); + } + return; + } let want = match &update { DraftUpdate::Data { draft, .. } | DraftUpdate::GitRoot { draft, .. } | DraftUpdate::Branches { draft, .. } => *draft, - // Handled above; it targets no particular draft. - DraftUpdate::FastForward { .. } => unreachable!(), + // Handled above; they target no particular draft. + DraftUpdate::FastForward { .. } | DraftUpdate::BarDirectories { .. } => unreachable!(), }; // Match by identity: dismissing the form and opening another while a // load is in flight must not let the old answer land in the new form. @@ -1036,7 +1074,9 @@ impl App { DraftUpdate::Branches { cwd, branches, .. } => { draft.set_branches(&cwd, branches); } - DraftUpdate::FastForward { .. } => unreachable!(), + DraftUpdate::FastForward { .. } | DraftUpdate::BarDirectories { .. } => { + unreachable!() + } } if follow_up_branches { self.spawn_load_branches(); @@ -1064,16 +1104,52 @@ impl App { projects: projects.ok(), names, focused_cwd, + pinned: None, }); }); } + /// The same fast path, pinned to the directory the command bar's `~` row + /// named (#154). Off the loop for the same reason as [`Self:: + /// spawn_fast_forward`]: a cold `/api/projects` walks all of `$HOME`. + fn spawn_fast_forward_in(&self, dir: PathBuf) { + let (daemon, http, base, tx) = self.loader_handles(); + tokio::spawn(async move { + let (info, projects) = tokio::join!(daemon.daemon_info(), daemon.list_projects()); + let names = fetch_names(&http, &base, crate::draft::NAME_BATCH_SIZE).await; + let _ = tx.send(DraftUpdate::FastForward { + info: info.ok(), + projects: projects.ok(), + names, + focused_cwd: None, + pinned: Some(dir), + }); + }); + } + + /// Fetch `~`'s candidates: the spawn form's own two sources, recent dirs + /// (newest first) ahead of the project list. Either failing is a shorter + /// list, not an error state. + fn spawn_load_bar_directories(&self, bar: u64) { + let daemon = self.daemon.clone(); + let tx = self.draft_tx.clone(); + tokio::spawn(async move { + let (recents, projects) = tokio::join!(daemon.recent_dirs(), daemon.list_projects()); + let mut paths: Vec = recents + .map(|r| r.dirs.into_iter().map(|d| d.cwd).collect()) + .unwrap_or_default(); + paths.extend(projects.unwrap_or_default().into_iter().map(|p| p.path)); + let _ = tx.send(DraftUpdate::BarDirectories { bar, paths }); + }); + } + async fn fast_forward_spawn( &mut self, info: Option, projects: Option>, names: Vec, focused_cwd: Option, + pinned: Option, ) { // Keep the home dir: without it the pre-filled draft renders // unabbreviated paths and "Other directory…" seeds `/` instead of @@ -1087,13 +1163,21 @@ impl App { _ => Vec::new(), }; let discovered = projects.unwrap_or_default(); - let resolved = crate::fast_forward::resolve( - &discovered, - &presets, - names.first().cloned(), - Utc::now(), - focused_cwd.as_deref(), - ); + // A pinned directory is the operator's own pick, so it takes no + // fallback: a directory outside the project scan resolves no project, + // which is an unresolvable default and opens the form seeded with it. + let resolved = match pinned.as_deref() { + Some(dir) => { + crate::fast_forward::resolve_at(dir, &discovered, &presets, names.first().cloned()) + } + None => crate::fast_forward::resolve( + &discovered, + &presets, + names.first().cloned(), + Utc::now(), + focused_cwd.as_deref(), + ), + }; if let Some(req) = resolved.build_request() { self.submit_spawn(req).await; return; @@ -1105,8 +1189,10 @@ impl App { }; draft.home_dir = home_dir; // The fast path resolved its own project; its pick wins over the - // focused session's, so the seed never lands on top of it. - draft.seed_cwd = None; + // focused session's, so the seed never lands on top of it. A pinned + // directory is the exception: it is the seed, whether or not the scan + // knew it as a project. + draft.seed_cwd = pinned; if !presets.is_empty() { draft.set_presets(presets); } @@ -1463,7 +1549,12 @@ enum DraftUpdate { projects: Option>, names: Vec, focused_cwd: Option, + /// The command bar pinned the spawn to this directory (#154): nothing + /// — not the focused session, not the recency ranking — may move it. + pinned: Option, }, + /// The command bar's `~` candidates arrived (#154). + BarDirectories { bar: u64, paths: Vec }, } /// `GET /api/names?count=N`. Free function: the spawned loaders have no `self`. @@ -2222,6 +2313,102 @@ mod tests { assert_eq!(req["worktree"]["create_branch"], json!(true)); } + /// The command bar's `~` pins the spawn: the directory the operator named + /// wins over the recency ranking that plain fast-forward would follow. + #[tokio::test] + async fn a_pinned_directory_spawns_there_not_in_the_freshest_project() { + use axum::routing::{get, post}; + use axum::Json; + use serde_json::json; + use std::sync::{Arc, Mutex}; + + async fn info() -> Json { + Json(json!({ + "hostname": "box", "version": "0.1.0", "home_dir": "/home/u", + "presets": [{"id": "claude", "label": "Claude", "command": "claude"}] + })) + } + async fn projects() -> Json { + Json(json!([ + {"path": "/repos/fresh", "name": "fresh", "base_repo": "/repos/fresh", + "last_activity": chrono::Utc::now().to_rfc3339(), "last_command": "aider"}, + {"path": "/repos/pinned", "name": "pinned", "base_repo": "/repos/pinned", + "last_activity": (chrono::Utc::now() - chrono::TimeDelta::days(3)).to_rfc3339(), + "last_command": "zsh"} + ])) + } + async fn names() -> Json { + Json(json!({"names": ["crimson-otter"]})) + } + let posted: Arc>> = Arc::default(); + let seen = posted.clone(); + async fn spawn_session( + axum::extract::State(seen): axum::extract::State>>>, + Json(body): Json, + ) -> Json { + seen.lock().unwrap().push(body); + Json(json!({ + "id": uuid::Uuid::from_u128(78).to_string(), + "cwd": "/repos/pinned", "command": "zsh", + "status": "Running", "created_at": chrono::Utc::now().to_rfc3339() + })) + } + + let app_router = axum::Router::new() + .route(maestro_protocol::routes::INFO, get(info)) + .route(maestro_protocol::routes::PROJECTS, get(projects)) + .route(maestro_protocol::routes::NAMES, get(names)) + .route( + maestro_protocol::routes::SESSIONS, + post(spawn_session).with_state(seen), + ); + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let base = format!("http://{}", listener.local_addr().unwrap()); + tokio::spawn(async move { axum::serve(listener, app_router).await.unwrap() }); + + let mut app = make_app_at(&base); + app.perform(vec![Effect::FastForwardIn(PathBuf::from("/repos/pinned"))]) + .await; + let update = tokio::time::timeout(Duration::from_secs(5), app.draft_rx.recv()) + .await + .expect("load completed") + .expect("channel open"); + app.apply_draft_update(update).await; + + let posts = posted.lock().unwrap(); + assert_eq!(posts.len(), 1, "one immediate POST, no form shown"); + assert_eq!(posts[0]["cwd"], json!("/repos/pinned")); + assert_eq!(posts[0]["command"], json!("zsh")); + } + + /// A directory the project scan does not know resolves no project, which + /// is an unresolvable default: the form opens seeded with it rather than + /// the spawn silently landing somewhere else. + #[tokio::test] + async fn a_pinned_directory_outside_the_scan_opens_the_prefilled_form() { + let mut app = make_app(); + app.fast_forward_spawn( + None, + Some(vec![maestro_protocol::Project { + path: PathBuf::from("/repos/fresh"), + name: "fresh".into(), + is_worktree: false, + base_repo: None, + last_activity: Some(Utc::now()), + last_command: Some("aider".into()), + }]), + vec!["crimson-otter".into()], + None, + Some(PathBuf::from("/elsewhere")), + ) + .await; + assert_eq!(app.state.overlay(), Some(&OverlayKind::Spawn)); + let draft = app.draft.as_ref().expect("form is up"); + // The seed is dropped by the picker when no project matches it, so + // the operator picks by hand — but nothing spawned in `/repos/fresh`. + assert_ne!(draft.picked_cwd, Some(PathBuf::from("/repos/fresh"))); + } + /// `sync_focus` reports every path that moves `Focus::Pane`: attaching, /// switching which session the pane shows, and leaving the pane back to /// the sidebar. A sidebar highlight alone must report nothing. @@ -2850,12 +3037,115 @@ mod tests { .iter() .map(|r| match r.target { BarTarget::Session(id) => id, + ref other => panic!("expected a session row, got {other:?}"), }) .collect::>(), vec![bravo, alpha] ); } + /// `~`'s candidates are a network round trip, so opening asks for them + /// rather than blocking the keystroke on one. + #[test] + fn opening_the_bar_asks_for_its_directories() { + let mut app = make_app(); + let effects = app.dispatch(Action::OpenCommandBar); + let bar = app.command_bar.as_ref().unwrap().id(); + assert_eq!(effects, vec![Effect::LoadBarDirectories { bar }]); + } + + fn type_into_bar(app: &mut App, text: &str) { + for c in text.chars() { + app.handle_overlay_input(&InputKey::Char { + c, + ctrl: false, + alt: false, + shift: false, + }); + } + } + + /// `>` lists the live keymap for the current mode, matched on the label + /// alone, and Enter hands the action to the ordinary dispatcher. + #[test] + fn enter_on_a_command_row_dispatches_the_action() { + let mut app = make_app(); + app.dispatch(Action::OpenCommandBar); + type_into_bar(&mut app, ">defaults"); + let row = app.command_bar.as_ref().unwrap().selected().unwrap(); + assert_eq!(row.name, "Create session with defaults"); + assert_eq!( + row.target, + BarTarget::Command(Action::FastForwardSpawn), + "chords: {:?}", + row.chords + ); + + let (decision, effects) = app.handle_overlay_input(&InputKey::Enter); + assert_eq!(decision, OverlayDecision::Consumed); + assert_eq!(effects, vec![Effect::FastForward]); + assert!(app.state.overlay().is_none()); + assert!(app.command_bar.is_none()); + } + + /// The row shows the chord bound to it, and both chords when the map binds + /// two — but the chord is never what matched. + #[test] + fn a_command_row_carries_its_chords() { + let mut app = make_app(); + app.dispatch(Action::OpenCommandBar); + type_into_bar(&mut app, ">open selected"); + let row = app.command_bar.as_ref().unwrap().selected().unwrap(); + assert_eq!(row.name, "Open selected item"); + assert!( + row.chords.iter().any(|c| c == "enter"), + "chords: {:?}", + row.chords + ); + assert!( + row.chords.len() > 1, + "the default map binds more than one chord to it: {:?}", + row.chords + ); + } + + #[test] + fn enter_on_a_directory_row_fast_forward_spawns_there() { + let mut app = make_app(); + app.dispatch(Action::OpenCommandBar); + app.command_bar + .as_mut() + .unwrap() + .set_directories(&[PathBuf::from("/repos/api")]); + type_into_bar(&mut app, "~api"); + let (decision, effects) = app.handle_overlay_input(&InputKey::Enter); + assert_eq!(decision, OverlayDecision::Consumed); + assert_eq!( + effects, + vec![Effect::FastForwardIn(PathBuf::from("/repos/api"))] + ); + assert!(app.state.overlay().is_none()); + } + + /// A load that outlived its bar fills in nothing: the reopened bar has its + /// own identity. + #[tokio::test] + async fn a_stale_directory_load_is_dropped() { + let mut app = make_app(); + app.dispatch(Action::OpenCommandBar); + let stale = app.command_bar.as_ref().unwrap().id(); + app.handle_overlay_input(&InputKey::Esc); + app.dispatch(Action::OpenCommandBar); + + app.apply_draft_update(DraftUpdate::BarDirectories { + bar: stale, + paths: vec![PathBuf::from("/repos/api")], + }) + .await; + type_into_bar(&mut app, "~"); + assert!(app.command_bar.as_ref().unwrap().rows().is_empty()); + } + /// Opening from Insert mode leaves the keyboard where it was, so closing /// gives it back — the bar is a view over the app, not a place in it. #[test] diff --git a/crates/maestro-tui/src/command_bar.rs b/crates/maestro-tui/src/command_bar.rs index c2fce8f..7a8d4d7 100644 --- a/crates/maestro-tui/src/command_bar.rs +++ b/crates/maestro-tui/src/command_bar.rs @@ -1,15 +1,17 @@ //! The `ctrl-k` command bar (`docs/COMMAND-BAR.md`): a query line over a //! result list, opened from Normal or Insert mode, closed with Escape. //! -//! Sessions are the only kind here (#153); the sigils (#154), the session -//! action page (#155) and the leader's literal Ctrl-K (#156) land later. The -//! grouping below is already kind-generic so those slot in without a rewrite. +//! Three kinds live here: sessions and commands, ranked together by default, +//! and directories, which need the `~` sigil (#154). The session action page +//! (#155) and the leader's literal Ctrl-K (#156) land later. //! //! Ranking is [`crate::matcher`]'s job and nothing here reorders it — the //! groups are a rendering of the ranked list, in the order their best-scoring //! member appears. The web bar (`web/src/commandBar.ts`) implements the same //! contract; the two must agree. +use std::path::{Path, PathBuf}; + use maestro_protocol::display::{abbreviate_dir, command_tool, session_tree_path}; use maestro_protocol::{Session, SessionStatus}; use ratatui::layout::Rect; @@ -19,19 +21,73 @@ use ratatui::widgets::{Block, Borders, Clear, Paragraph}; use ratatui::Frame; use uuid::Uuid; +use crate::action::Action; use crate::input::InputKey; use crate::matcher::{ - match_and_rank, CandidateKind, MatchCandidate, MatchFieldName, MatchRange, SessionCandidate, + match_and_rank, CandidateKind, CommandCandidate, DirectoryCandidate, MatchCandidate, + MatchFieldName, MatchRange, SessionCandidate, }; use crate::overlays::centered; use crate::palette; use crate::text_input::TextInput; -/// What Enter on a row does. Only sessions exist today; `>` commands and `~` -/// directories (#154) become further variants. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] +/// Identity for routing an async directory load back to *this* bar: a load in +/// flight must not land in a bar the operator closed and reopened. +static NEXT_BAR_ID: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(1); + +/// What Enter on a row does. +#[derive(Debug, Clone, PartialEq, Eq)] pub enum BarTarget { Session(Uuid), + /// Dispatch the action the row stands for. The chord is displayed beside + /// the row but is not part of the target: two chords bound to one action + /// are one row. + Command(Action), + /// Fast-forward spawn in this directory, absolute — the abbreviated form + /// is what the row shows and what the matcher ranked. + Directory(PathBuf), +} + +/// The first character of the query, when it is one of the two that narrow the +/// list to a single kind. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum BarSigil { + /// `>` — commands from the live keymap. + Command, + /// `~` — recent dirs and the project list. + Directory, +} + +/// A raw query line split into its sigil and the query proper. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct BarQuery<'q> { + pub sigil: Option, + /// What the matcher sees: everything after a sigil, or the whole query. + pub query: &'q str, +} + +/// Split a raw query line into its sigil and the query proper. +/// +/// A sigil is only a sigil in first position: `>` or `~` anywhere else is a +/// literal query character, which is what a directory query with a second `~` +/// in it depends on. Backspacing past a sigil needs no special case either — +/// the first character goes and the query is a mixed one again. Mirrors the +/// web's `parseBarQuery`. +pub fn parse_bar_query(raw: &str) -> BarQuery<'_> { + let sigil = match raw.chars().next() { + Some('>') => BarSigil::Command, + Some('~') => BarSigil::Directory, + _ => { + return BarQuery { + sigil: None, + query: raw, + } + } + }; + BarQuery { + sigil: Some(sigil), + query: &raw[1..], + } } /// One row of the result list, already resolved to what it renders and what it @@ -50,6 +106,9 @@ pub struct BarRow { pub field: Option, pub ranges: Vec, pub dead: bool, + /// Every chord bound to a command row's action, displayed beside it and + /// never matched. Empty for every other kind. + pub chords: Vec, } impl BarRow { @@ -78,7 +137,7 @@ fn kind_title(kind: CandidateKind) -> &'static str { } /// What a keystroke asked the bar to do. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[derive(Debug, Clone, PartialEq, Eq)] pub enum BarOutcome { /// The bar stays up. Stay, @@ -128,40 +187,112 @@ pub fn session_candidate(session: &Session, home_dir: &str) -> SessionCandidate } } +/// One command row: a distinct [`Action`] and every chord bound to it. +/// +/// Dedupe is by **action, never by chord** — the default map binds both +/// `enter` and `tab` to `AttachHighlighted`, and listed naively that is two +/// identical rows. Actions that share a `help_label` collapse too: +/// `AttachHighlighted` and `ToggleAttachHighlighted` are the same match arm in +/// `app.rs` and genuinely do the same thing, so they are one row carrying both +/// chords rather than two rows a reader cannot tell apart. `Noop` is left out: +/// it exists to suppress a default binding, not as something to run. +/// +/// Input order is the keymap's own (`Keymap::entries` sorts by chord), and the +/// first mention of each action keeps its place — the registry order the +/// contract asks an empty `>` query to list. +pub fn command_entries(entries: &[(String, Action)]) -> Vec<(Action, Vec)> { + let mut out: Vec<(Action, Vec)> = Vec::new(); + for (chord, action) in entries { + if *action == Action::Noop { + continue; + } + let label = action.help_label(); + match out + .iter_mut() + .find(|(kept, _)| kept == action || kept.help_label() == label) + { + Some((_, chords)) => { + if !chords.iter().any(|c| c == chord) { + chords.push(chord.clone()); + } + } + None => out.push((action.clone(), vec![chord.clone()])), + } + } + out +} + +/// One candidate plus what it renders and what Enter does with it. The matcher +/// never sees anything but `candidate`. +#[derive(Debug, Clone, PartialEq, Eq)] +struct BarEntry { + candidate: MatchCandidate, + target: BarTarget, + dead: bool, + chords: Vec, +} + +impl BarEntry { + fn candidate_kind(&self) -> CandidateKind { + match self.candidate { + MatchCandidate::Session(_) => CandidateKind::Session, + MatchCandidate::Command(_) => CandidateKind::Command, + MatchCandidate::Directory(_) => CandidateKind::Directory, + } + } +} + /// The bar's state while it is up: the query buffer, the candidates it was /// opened over, the rows the current query produces, and the highlight. #[derive(Debug)] pub struct CommandBar { + /// Identity for routing an async directory load back to this bar. + id: u64, input: TextInput, - /// Matcher input, in the empty-query order. Never reordered — the matcher's - /// last tie-break is what keeps that order meaningful. - candidates: Vec, - /// What each candidate opens, parallel to `candidates`. - targets: Vec, - /// Whether each candidate's session is dead, parallel to `candidates`. - dead: Vec, + home_dir: String, + /// Matcher input per kind, in the empty-query order. Never reordered — + /// the matcher's last tie-break is what keeps that order meaningful. + sessions: Vec, + commands: Vec, + directories: Vec, rows: Vec, highlight: usize, } impl CommandBar { - /// Open the bar over the sessions the catalog has right now. - pub fn new(sessions: &[Session], home_dir: &str) -> Self { + /// Open the bar over the sessions the catalog has right now and the live + /// keymap for the current mode. Directories arrive later + /// ([`Self::set_directories`]) — they are a network round trip and the + /// keystroke must not wait on one. + pub fn new(sessions: &[Session], keymap: &[(String, Action)], home_dir: &str) -> Self { let ordered = order_sessions_for_bar(sessions); - let candidates = ordered + let sessions = ordered .iter() - .map(|s| MatchCandidate::Session(session_candidate(s, home_dir))) + .map(|s| BarEntry { + candidate: MatchCandidate::Session(session_candidate(s, home_dir)), + target: BarTarget::Session(s.id), + dead: s.status == SessionStatus::Dead, + chords: Vec::new(), + }) .collect(); - let targets = ordered.iter().map(|s| BarTarget::Session(s.id)).collect(); - let dead = ordered - .iter() - .map(|s| s.status == SessionStatus::Dead) + let commands = command_entries(keymap) + .into_iter() + .map(|(action, chords)| BarEntry { + candidate: MatchCandidate::Command(CommandCandidate { + label: action.help_label(), + }), + target: BarTarget::Command(action), + dead: false, + chords, + }) .collect(); let mut bar = Self { + id: NEXT_BAR_ID.fetch_add(1, std::sync::atomic::Ordering::Relaxed), input: TextInput::default(), - candidates, - targets, - dead, + home_dir: home_dir.to_string(), + sessions, + commands, + directories: Vec::new(), rows: Vec::new(), highlight: 0, }; @@ -169,6 +300,34 @@ impl CommandBar { bar } + pub fn id(&self) -> u64 { + self.id + } + + /// Hand the bar `~`'s candidates once the daemon has answered: recent dirs + /// (newest first) ahead of the project list, deduped by the directory a + /// spawn would land in, keeping the first mention of each. + pub fn set_directories(&mut self, paths: &[PathBuf]) { + let mut seen: Vec<&Path> = Vec::new(); + let mut entries = Vec::new(); + for path in paths { + if path.as_os_str().is_empty() || seen.contains(&path.as_path()) { + continue; + } + seen.push(path.as_path()); + entries.push(BarEntry { + candidate: MatchCandidate::Directory(DirectoryCandidate { + path: abbreviate_dir(path, &self.home_dir), + }), + target: BarTarget::Directory(path.clone()), + dead: false, + chords: Vec::new(), + }); + } + self.directories = entries; + self.recompute(); + } + pub fn query(&self) -> &str { self.input.value() } @@ -224,7 +383,7 @@ impl CommandBar { match key { InputKey::Esc => BarOutcome::Close, InputKey::Enter => match self.selected() { - Some(row) => BarOutcome::Open(row.target), + Some(row) => BarOutcome::Open(row.target.clone()), // Nothing to open: the bar stays up rather than closing on a // key that did nothing. None => BarOutcome::Stay, @@ -257,31 +416,58 @@ impl CommandBar { } } + /// The sigil in force for the query as it stands. + pub fn sigil(&self) -> Option { + parse_bar_query(self.input.value()).sigil + } + + /// The candidates the query line asks for. A sigil narrows to one kind; + /// with no sigil the list is mixed — sessions and commands ranked together + /// — except on an empty query, where the bar is a session switcher. + fn active_entries(&self, parsed: BarQuery<'_>) -> Vec<&BarEntry> { + match parsed.sigil { + Some(BarSigil::Command) => self.commands.iter().collect(), + Some(BarSigil::Directory) => self.directories.iter().collect(), + None if parsed.query.is_empty() => self.sessions.iter().collect(), + None => self.sessions.iter().chain(self.commands.iter()).collect(), + } + } + /// Re-rank for the current query. The highlight starts on the globally /// best row every time the list changes: an old index would point at a /// different result. fn recompute(&mut self) { let rows: Vec = { - let results = match_and_rank(self.input.value(), &self.candidates); - results + let parsed = parse_bar_query(self.input.value()); + let entries = self.active_entries(parsed); + let candidates: Vec = + entries.iter().map(|e| e.candidate.clone()).collect(); + match_and_rank(parsed.query, &candidates) .iter() .map(|result| { - let index = self - .candidates + let index = candidates .iter() .position(|c| std::ptr::eq(c, result.candidate)) - .expect("every result borrows one of this bar's candidates"); - let MatchCandidate::Session(session) = result.candidate else { - unreachable!("sessions are the only kind the bar builds (#153)") + .expect("every result borrows one of this query's candidates"); + let entry = entries[index]; + let (name, meta) = match &entry.candidate { + MatchCandidate::Session(session) => { + (session.name.clone(), meta_text(session, result.field)) + } + // A command's label and a directory's path are the row + // itself; neither has a second field to fall back to. + MatchCandidate::Command(command) => (command.label.clone(), None), + MatchCandidate::Directory(directory) => (directory.path.clone(), None), }; BarRow { - kind: CandidateKind::Session, - target: self.targets[index], - name: session.name.clone(), - meta: meta_text(session, result.field), + kind: entry.candidate_kind(), + target: entry.target.clone(), + name, + meta, field: result.field, ranges: result.ranges.clone(), - dead: self.dead[index], + dead: entry.dead, + chords: entry.chords.clone(), } }) .collect() @@ -297,7 +483,11 @@ impl CommandBar { let area = centered(parent, 72, height); f.render_widget(Clear, area); let block = Block::default() - .title(" go to ") + .title(match self.sigil() { + Some(BarSigil::Command) => " run a command ", + Some(BarSigil::Directory) => " spawn in ", + None => " go to ", + }) .borders(Borders::ALL) .style(pal.card.base()) .border_style(pal.border_style(true)); @@ -349,11 +539,16 @@ impl CommandBar { // sits on so the scroll can keep it visible. let mut lines: Vec> = Vec::new(); let mut highlight_line = 0usize; + // While a sigil is in force there is one kind, so the header is + // dropped — it would name what the whole list already is. + let headers = self.sigil().is_none(); for group in self.groups() { - lines.push(Line::from(Span::styled( - group.title.to_string(), - pal.group_row_style(), - ))); + if headers { + lines.push(Line::from(Span::styled( + group.title.to_string(), + pal.group_row_style(), + ))); + } for index in group.start..group.start + group.len { if index == self.highlight { highlight_line = lines.len(); @@ -384,6 +579,12 @@ impl CommandBar { &row.ranges }; spans.extend(highlighted_spans(&row.name, name_ranges, base, hit)); + // The chord is teaching material, not a match target: it comes from + // the live keymap and is never fed to the matcher. + if !row.chords.is_empty() { + spans.push(Span::styled(" ", base)); + spans.push(Span::styled(row.chords.join(", "), surface.muted_style())); + } if let Some(meta) = &row.meta { let meta_style = surface.muted_style(); let meta_hit = meta_style.add_modifier(Modifier::BOLD | Modifier::UNDERLINED); @@ -485,6 +686,7 @@ mod tests { rows.iter() .map(|r| match r.target { BarTarget::Session(id) => id, + ref other => panic!("expected a session row, got {other:?}"), }) .collect() } @@ -509,7 +711,7 @@ mod tests { let mut c = named(3, "charlie"); c.last_focused_at = Some(at(20)); - let bar = CommandBar::new(&[a, b, c], "/home/me"); + let bar = CommandBar::new(&[a, b, c], &[], "/home/me"); assert_eq!( ids(bar.rows()), vec![Uuid::from_u128(2), Uuid::from_u128(3), Uuid::from_u128(1)] @@ -527,7 +729,7 @@ mod tests { let mut newer = named(3, "newer"); newer.created_at = at(20); - let bar = CommandBar::new(&[older, newer, focused], "/home/me"); + let bar = CommandBar::new(&[older, newer, focused], &[], "/home/me"); assert_eq!( ids(bar.rows()), vec![Uuid::from_u128(1), Uuid::from_u128(3), Uuid::from_u128(2)] @@ -542,7 +744,7 @@ mod tests { dead.last_focused_at = Some(at(99)); let live = named(2, "live"); - let bar = CommandBar::new(&[dead, live], "/home/me"); + let bar = CommandBar::new(&[dead, live], &[], "/home/me"); assert_eq!( ids(bar.rows()), vec![Uuid::from_u128(2), Uuid::from_u128(1)] @@ -552,7 +754,11 @@ mod tests { #[test] fn typing_ranks_through_the_matcher_and_returns_ranges() { - let mut bar = CommandBar::new(&[named(1, "web-search"), named(2, "websearch")], "/home/me"); + let mut bar = CommandBar::new( + &[named(1, "web-search"), named(2, "websearch")], + &[], + "/home/me", + ); typed(&mut bar, "ws"); // F9: the `s` after `-` earns the boundary bonus. assert_eq!( @@ -565,7 +771,7 @@ mod tests { #[test] fn a_query_matching_nothing_leaves_no_rows() { - let mut bar = CommandBar::new(&[named(1, "alpha")], "/home/me"); + let mut bar = CommandBar::new(&[named(1, "alpha")], &[], "/home/me"); typed(&mut bar, "zzz"); assert!(bar.rows().is_empty()); assert!(bar.selected().is_none()); @@ -577,7 +783,7 @@ mod tests { fn a_directory_match_highlights_in_the_meta_text() { let mut worker = named(1, "worker"); worker.cwd = PathBuf::from("/home/me/dev/api"); - let mut bar = CommandBar::new(&[worker], "/home/me"); + let mut bar = CommandBar::new(&[worker], &[], "/home/me"); typed(&mut bar, "api"); assert_eq!(bar.rows()[0].field, Some(MatchFieldName::Directory)); assert_eq!(bar.rows()[0].meta.as_deref(), Some("~/dev/api")); @@ -590,7 +796,7 @@ mod tests { fn the_tool_field_is_a_candidate_field() { let mut nameless = named(1, "nameless"); nameless.command = "claude --resume".into(); - let mut bar = CommandBar::new(&[nameless, named(2, "clay")], "/home/me"); + let mut bar = CommandBar::new(&[nameless, named(2, "clay")], &[], "/home/me"); typed(&mut bar, "claude"); assert_eq!(ids(bar.rows()), vec![Uuid::from_u128(1)]); assert_eq!(bar.rows()[0].field, Some(MatchFieldName::Tool)); @@ -598,7 +804,7 @@ mod tests { #[test] fn results_group_under_kind_headers_with_the_best_row_first() { - let bar = CommandBar::new(&[named(1, "alpha"), named(2, "bravo")], "/home/me"); + let bar = CommandBar::new(&[named(1, "alpha"), named(2, "bravo")], &[], "/home/me"); let groups = bar.groups(); assert_eq!(groups.len(), 1); assert_eq!(groups[0].title, "Sessions"); @@ -612,6 +818,7 @@ mod tests { fn arrows_and_ctrl_pn_move_the_highlight_without_wrapping() { let mut bar = CommandBar::new( &[named(1, "alpha"), named(2, "bravo"), named(3, "charlie")], + &[], "/home/me", ); assert_eq!(bar.handle_key(&InputKey::Down), BarOutcome::Stay); @@ -640,7 +847,7 @@ mod tests { #[test] fn enter_opens_the_highlighted_session_and_escape_closes() { - let mut bar = CommandBar::new(&[named(1, "alpha"), named(2, "bravo")], "/home/me"); + let mut bar = CommandBar::new(&[named(1, "alpha"), named(2, "bravo")], &[], "/home/me"); bar.handle_key(&InputKey::Down); assert_eq!( bar.handle_key(&InputKey::Enter), @@ -651,14 +858,14 @@ mod tests { #[test] fn enter_with_no_results_leaves_the_bar_up() { - let mut bar = CommandBar::new(&[named(1, "alpha")], "/home/me"); + let mut bar = CommandBar::new(&[named(1, "alpha")], &[], "/home/me"); typed(&mut bar, "zzz"); assert_eq!(bar.handle_key(&InputKey::Enter), BarOutcome::Stay); } #[test] fn editing_the_query_re_ranks_and_resets_the_highlight() { - let mut bar = CommandBar::new(&[named(1, "alpha"), named(2, "alpine")], "/home/me"); + let mut bar = CommandBar::new(&[named(1, "alpha"), named(2, "alpine")], &[], "/home/me"); bar.handle_key(&InputKey::Down); assert_eq!(bar.highlight(), 1); typed(&mut bar, "alp"); @@ -676,7 +883,7 @@ mod tests { let mut recent = named(1, "alpha"); recent.last_focused_at = Some(at(50)); let older = named(2, "alpine"); - let mut bar = CommandBar::new(&[older, recent], "/home/me"); + let mut bar = CommandBar::new(&[older, recent], &[], "/home/me"); typed(&mut bar, "alpi"); assert_eq!(ids(bar.rows()), vec![Uuid::from_u128(2)]); for _ in 0..4 { @@ -689,6 +896,251 @@ mod tests { ); } + fn keymap(pairs: &[(&str, Action)]) -> Vec<(String, Action)> { + pairs + .iter() + .map(|(chord, action)| (chord.to_string(), action.clone())) + .collect() + } + + fn labels(rows: &[BarRow]) -> Vec { + rows.iter().map(|r| r.name.clone()).collect() + } + + // ---- sigil parsing ---- + + #[test] + fn a_leading_sigil_is_a_sigil_and_the_rest_is_the_query() { + assert_eq!( + parse_bar_query(">kill"), + BarQuery { + sigil: Some(BarSigil::Command), + query: "kill" + } + ); + assert_eq!( + parse_bar_query("~dev"), + BarQuery { + sigil: Some(BarSigil::Directory), + query: "dev" + } + ); + assert_eq!( + parse_bar_query("api"), + BarQuery { + sigil: None, + query: "api" + } + ); + assert_eq!( + parse_bar_query(""), + BarQuery { + sigil: None, + query: "" + } + ); + } + + /// A directory query is where a second `~` is most likely to be typed, and + /// it must reach the matcher. + #[test] + fn a_sigil_anywhere_but_first_is_a_literal_query_character() { + assert_eq!( + parse_bar_query("~~/dev"), + BarQuery { + sigil: Some(BarSigil::Directory), + query: "~/dev" + } + ); + assert_eq!( + parse_bar_query("a>b"), + BarQuery { + sigil: None, + query: "a>b" + } + ); + } + + // ---- `>` commands ---- + + /// The default map binds both `enter` and `tab` to `AttachHighlighted`; + /// listed naively that is two identical rows. + #[test] + fn command_rows_dedupe_by_action_and_keep_every_chord() { + let entries = command_entries(&keymap(&[ + ("enter", Action::AttachHighlighted), + ("tab", Action::AttachHighlighted), + ("q", Action::Quit), + ])); + assert_eq!( + entries, + vec![ + ( + Action::AttachHighlighted, + vec!["enter".to_string(), "tab".to_string()] + ), + (Action::Quit, vec!["q".to_string()]), + ] + ); + } + + /// `AttachHighlighted` and `ToggleAttachHighlighted` share a `help_label` + /// because they are the same match arm and genuinely do the same thing. + /// Two rows a reader cannot tell apart is the bug; relabelling them would + /// describe behaviour that does not exist. + #[test] + fn actions_sharing_a_label_collapse_into_one_row() { + let entries = command_entries(&keymap(&[ + ("enter", Action::AttachHighlighted), + ("o", Action::ToggleAttachHighlighted), + ])); + assert_eq!(entries.len(), 1); + assert_eq!(entries[0].1, vec!["enter".to_string(), "o".to_string()]); + } + + /// `Noop` exists to suppress a default binding, not as something to run. + #[test] + fn noop_bindings_are_not_commands() { + let entries = command_entries(&keymap(&[("x", Action::Noop), ("q", Action::Quit)])); + assert_eq!(entries, vec![(Action::Quit, vec!["q".to_string()])]); + } + + #[test] + fn the_command_sigil_narrows_to_commands_matched_on_their_label() { + let map = keymap(&[ + ("K", Action::KillHighlighted), + ("q", Action::Quit), + ("r", Action::RenameHighlighted), + ]); + let mut bar = CommandBar::new(&[named(1, "kill-me")], &map, "/home/me"); + typed(&mut bar, ">kill"); + assert_eq!( + labels(bar.rows()), + vec!["Kill selected session".to_string()] + ); + assert!(bar.rows().iter().all(|r| r.kind == CandidateKind::Command)); + assert_eq!(bar.rows()[0].chords, vec!["K".to_string()]); + } + + /// Matching the chord would make `>` results depend on a user's rebinds. + #[test] + fn a_chord_is_displayed_and_never_matched() { + let map = keymap(&[("zzz", Action::Quit)]); + let mut bar = CommandBar::new(&[], &map, "/home/me"); + typed(&mut bar, ">zzz"); + assert!(bar.rows().is_empty(), "{:?}", labels(bar.rows())); + bar.handle_key(&InputKey::Backspace); + bar.handle_key(&InputKey::Backspace); + bar.handle_key(&InputKey::Backspace); + assert_eq!(labels(bar.rows()), vec!["Quit Maestro".to_string()]); + assert_eq!(bar.rows()[0].chords, vec!["zzz".to_string()]); + } + + #[test] + fn enter_on_a_command_row_dispatches_its_action() { + let map = keymap(&[("q", Action::Quit)]); + let mut bar = CommandBar::new(&[], &map, "/home/me"); + typed(&mut bar, ">quit"); + assert_eq!( + bar.handle_key(&InputKey::Enter), + BarOutcome::Open(BarTarget::Command(Action::Quit)) + ); + } + + /// An empty `>` query lists every command in keymap order. + #[test] + fn an_empty_command_query_lists_the_whole_keymap() { + let map = keymap(&[("K", Action::KillHighlighted), ("q", Action::Quit)]); + let mut bar = CommandBar::new(&[named(1, "alpha")], &map, "/home/me"); + typed(&mut bar, ">"); + assert_eq!( + labels(bar.rows()), + vec![ + "Kill selected session".to_string(), + "Quit Maestro".to_string() + ] + ); + } + + // ---- `~` directories ---- + + #[test] + fn the_directory_sigil_narrows_to_directories_deduped_and_abbreviated() { + let mut bar = CommandBar::new(&[named(1, "alpha")], &[], "/home/me"); + bar.set_directories(&[ + PathBuf::from("/home/me/dev/api"), + PathBuf::from("/home/me/dev/api"), + PathBuf::from("/srv/www"), + ]); + typed(&mut bar, "~"); + assert_eq!( + labels(bar.rows()), + vec!["~/dev/api".to_string(), "/srv/www".to_string()] + ); + assert!(bar + .rows() + .iter() + .all(|r| r.kind == CandidateKind::Directory)); + } + + #[test] + fn enter_on_a_directory_row_spawns_there() { + let mut bar = CommandBar::new(&[], &[], "/home/me"); + bar.set_directories(&[PathBuf::from("/home/me/dev/api")]); + typed(&mut bar, "~api"); + assert_eq!( + bar.handle_key(&InputKey::Enter), + BarOutcome::Open(BarTarget::Directory(PathBuf::from("/home/me/dev/api"))) + ); + } + + // ---- the mixed list ---- + + #[test] + fn no_sigil_ranks_sessions_and_commands_together_under_kind_headers() { + let map = keymap(&[("K", Action::KillHighlighted)]); + let mut bar = CommandBar::new(&[named(1, "killer")], &map, "/home/me"); + typed(&mut bar, "kill"); + let groups = bar.groups(); + assert_eq!( + groups.iter().map(|g| g.title).collect::>(), + vec!["Sessions", "Commands"] + ); + assert_eq!( + labels(bar.rows()), + vec!["killer".to_string(), "Kill selected session".to_string()] + ); + } + + /// An empty bar is a session switcher: commands and directories need a + /// sigil or a query to appear. + #[test] + fn an_empty_query_lists_sessions_only() { + let map = keymap(&[("q", Action::Quit)]); + let bar = CommandBar::new(&[named(1, "alpha")], &map, "/home/me"); + assert_eq!(labels(bar.rows()), vec!["alpha".to_string()]); + } + + /// The sigil is one character of the query like any other: deleting it is + /// not a special key. + #[test] + fn backspacing_past_a_sigil_returns_to_the_mixed_list() { + let map = keymap(&[("K", Action::KillHighlighted)]); + let mut bar = CommandBar::new(&[named(1, "kill-me")], &map, "/home/me"); + typed(&mut bar, ">kill"); + assert_eq!(bar.rows().len(), 1); + assert_eq!(bar.sigil(), Some(BarSigil::Command)); + bar.handle_key(&InputKey::Home); + bar.handle_key(&InputKey::Right); + bar.handle_key(&InputKey::Backspace); + assert_eq!(bar.query(), "kill"); + assert_eq!(bar.sigil(), None); + assert_eq!( + labels(bar.rows()), + vec!["kill-me".to_string(), "Kill selected session".to_string()] + ); + } + fn render_to_string(bar: &CommandBar, w: u16, h: u16) -> String { let backend = TestBackend::new(w, h); let mut terminal = Terminal::new(backend).unwrap(); @@ -710,7 +1162,7 @@ mod tests { #[test] fn renders_the_query_the_headers_and_the_rows() { - let mut bar = CommandBar::new(&[named(1, "alpha"), named(2, "alpine")], "/home/me"); + let mut bar = CommandBar::new(&[named(1, "alpha"), named(2, "alpine")], &[], "/home/me"); typed(&mut bar, "alp"); let rendered = render_to_string(&bar, 90, 20); assert!(rendered.contains("go to"), "{rendered}"); @@ -721,9 +1173,21 @@ mod tests { assert!(rendered.contains("[esc] close"), "{rendered}"); } + /// While a sigil is in force there is one kind, so the kind header goes. + #[test] + fn a_sigil_drops_the_kind_header_and_renders_the_chord() { + let map = keymap(&[("K", Action::KillHighlighted)]); + let mut bar = CommandBar::new(&[named(1, "alpha")], &map, "/home/me"); + typed(&mut bar, ">kill"); + let rendered = render_to_string(&bar, 90, 20); + assert!(rendered.contains("Kill selected session"), "{rendered}"); + assert!(rendered.contains(" K"), "{rendered}"); + assert!(!rendered.contains("Commands"), "{rendered}"); + } + #[test] fn renders_a_no_results_state() { - let mut bar = CommandBar::new(&[named(1, "alpha")], "/home/me"); + let mut bar = CommandBar::new(&[named(1, "alpha")], &[], "/home/me"); typed(&mut bar, "zzz"); let rendered = render_to_string(&bar, 90, 20); assert!(rendered.contains("No matches"), "{rendered}"); @@ -732,7 +1196,7 @@ mod tests { /// The matched characters are drawn bold, so the row shows why it matched. #[test] fn matched_ranges_are_styled_apart_from_the_rest_of_the_row() { - let mut bar = CommandBar::new(&[named(1, "web-search")], "/home/me"); + let mut bar = CommandBar::new(&[named(1, "web-search")], &[], "/home/me"); typed(&mut bar, "ws"); let backend = TestBackend::new(90, 20); let mut terminal = Terminal::new(backend).unwrap(); diff --git a/crates/maestro-tui/src/fast_forward.rs b/crates/maestro-tui/src/fast_forward.rs index 287ddca..b4b6004 100644 --- a/crates/maestro-tui/src/fast_forward.rs +++ b/crates/maestro-tui/src/fast_forward.rs @@ -49,6 +49,33 @@ pub fn resolve( .into_iter() .next() }); + defaults(project, presets, handle) +} + +/// Resolve the defaults for a spawn pinned to one directory — the command +/// bar's `~` (#154), where the operator named the directory and nothing may +/// override it. Unlike [`resolve`] there is no recency fallback: a directory +/// the project scan does not know leaves `project` `None`, which is an +/// unresolvable default, so the keystroke opens the pre-filled form instead of +/// spawning somewhere else. +pub fn resolve_at( + dir: &std::path::Path, + projects: &[Project], + presets: &[SpawnPreset], + handle: Option, +) -> FastForwardDefaults { + let project = projects.iter().find(|p| p.path == dir).cloned(); + defaults(project, presets, handle) +} + +/// The project's last command, or the first non-custom preset, wrapped up with +/// the rest of the defaults. Shared by both resolvers so the two entry points +/// can never settle on different commands. +fn defaults( + project: Option, + presets: &[SpawnPreset], + handle: Option, +) -> FastForwardDefaults { let command = project .as_ref() .and_then(|p| p.last_command.clone()) @@ -159,6 +186,46 @@ mod tests { } } + /// The command bar's `~` names the directory; nothing may override it. + #[test] + fn resolve_at_pins_the_directory_over_the_recency_ranking() { + let projects = vec![ + project("/repos/fresh", 0, Some("aider")), + project("/repos/pinned", 30, Some("zsh")), + ]; + let resolved = resolve_at( + std::path::Path::new("/repos/pinned"), + &projects, + &[preset("claude", "claude")], + Some("otter".into()), + ); + assert_eq!( + resolved.project.as_ref().map(|p| p.path.clone()), + Some(PathBuf::from("/repos/pinned")) + ); + assert_eq!(resolved.command.as_deref(), Some("zsh")); + let req = resolved.build_request().expect("every default resolved"); + assert_eq!(req.cwd, PathBuf::from("/repos/pinned")); + } + + /// A directory the scan does not know resolves no project, so there is no + /// request to build — the caller opens the pre-filled form instead of + /// spawning somewhere else. + #[test] + fn resolve_at_takes_no_fallback_for_an_unknown_directory() { + let projects = vec![project("/repos/fresh", 0, Some("aider"))]; + let resolved = resolve_at( + std::path::Path::new("/elsewhere"), + &projects, + &[preset("claude", "claude")], + Some("otter".into()), + ); + assert_eq!(resolved.project, None); + // The preset still resolves; the missing project is what blocks it. + assert_eq!(resolved.command.as_deref(), Some("claude")); + assert_eq!(resolved.build_request(), None); + } + #[test] fn resolves_every_default_from_the_most_recent_project() { let projects = vec![ From 3eb1d7ddbeb94687836edc1d3368b485be2c7a2b Mon Sep 17 00:00:00 2001 From: Miguel Palhas Date: Thu, 3 Sep 2026 17:27:13 +0100 Subject: [PATCH 12/14] feat(maestro): drill into a session's action page with tab MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Tab on a highlighted session in the web command bar opens that session's action page in the same overlay: kill, rename, spawn similar, still fuzzy searchable and headed by the session's name. Escape, or backspace at an empty query, returns to the result list with the previous query intact. Each action raises the flow the card already has. The two that lived inside the sidebar get an event apiece — the confirm-and-blockers kill and the inline rename prompt — so the bar picks the action and nothing more; spawn similar reuses the existing SPAWN_SIMILAR_EVENT. --- web/src/barActions.test.ts | 131 +++++++++++++++++++++++++++ web/src/catalog.ts | 49 ++++++++++ web/src/commandBar.test.ts | 181 ++++++++++++++++++++++++++++++++++++- web/src/commandBar.ts | 162 +++++++++++++++++++++++++++++++-- web/src/styles.css | 9 ++ 5 files changed, 521 insertions(+), 11 deletions(-) create mode 100644 web/src/barActions.test.ts diff --git a/web/src/barActions.test.ts b/web/src/barActions.test.ts new file mode 100644 index 0000000..0720de6 --- /dev/null +++ b/web/src/barActions.test.ts @@ -0,0 +1,131 @@ +// @vitest-environment happy-dom +// +// The sidebar's half of the command bar's action page (#151): the bar picks +// kill or rename and raises the flow the card already has, so what the page +// runs is what the ✕ button and the `r` key run. +import { afterEach, beforeAll, describe, expect, it } from "vitest"; + +import { KILL_SESSION_EVENT, RENAME_SESSION_EVENT, renderSidebar } from "./catalog.js"; +import type { CatalogListener, CatalogStore } from "./events.js"; +import type { Session } from "./types.js"; + +beforeAll(() => { + Object.defineProperty(window, "localStorage", { + value: { getItem: () => null, setItem: () => {} }, + }); +}); + +let mounted: { el: HTMLElement; dispose: () => void } | null = null; + +afterEach(() => { + document.querySelector(".kill-dialog")?.remove(); + mounted?.dispose(); + mounted = null; + document.body.replaceChildren(); +}); + +function session(overrides: Partial = {}): Session { + return { + id: "aaaaaaaa-0000-0000-0000-0000000000ff", + cwd: "/home/naps/tea/maestro", + command: "claude", + env: {}, + metadata: { name: "amber-falcon" }, + status: "Running", + created_at: "2026-08-24T10:00:00Z", + ...overrides, + }; +} + +function storeWith(sessions: Session[]): CatalogStore { + const state = { sessions: new Map(sessions.map((s) => [s.id, s])) }; + return { + snapshot: () => state, + subscribe: (fn: CatalogListener) => { + fn(state); + return () => {}; + }, + } as unknown as CatalogStore; +} + +function mount(s: Session): HTMLElement { + const handle = renderSidebar(storeWith([s])); + mounted = handle; + document.body.appendChild(handle.el); + return handle.el; +} + +function raise(name: string, s: Session): void { + document.dispatchEvent(new CustomEvent(name, { detail: s })); +} + +/** Let the kill chain's awaits (fetch, its json body) settle. */ +async function flush(): Promise { + for (let i = 0; i < 8; i++) await Promise.resolve(); +} + +describe("kill from the action page", () => { + it("goes through the daemon, without a first confirmation", async () => { + const calls: string[] = []; + window.fetch = ((url: string, init?: RequestInit) => { + calls.push(`${init?.method ?? "GET"} ${url}`); + return Promise.resolve(new Response(null, { status: 204 })); + }) as typeof fetch; + const s = session(); + mount(s); + raise(KILL_SESSION_EVENT, s); + await flush(); + expect(calls).toContain(`DELETE /api/sessions/${s.id}`); + expect(document.querySelector(".kill-dialog")).toBeNull(); + }); + + it("raises the existing confirm-and-blockers dialog on a guarded refusal", async () => { + window.fetch = ((url: string) => { + if (String(url).includes("force=true")) { + return Promise.resolve(new Response(null, { status: 204 })); + } + return Promise.resolve( + new Response( + JSON.stringify({ + error: { + code: "kill_guarded", + message: "worktree has unsaved work", + unsaved_work: { untracked_files: 1 }, + }, + }), + { status: 409, headers: { "content-type": "application/json" } }, + ), + ); + }) as typeof fetch; + const s = session({ blocked_work: { untracked_files: 1 } }); + mount(s); + raise(KILL_SESSION_EVENT, s); + await flush(); + const dialog = document.querySelector(".kill-dialog"); + expect(dialog?.getAttribute("aria-label")).toBe("Kill amber-falcon"); + expect(dialog?.textContent).toContain("1 untracked file"); + }); +}); + +describe("rename from the action page", () => { + it("expands the folder hiding the card, since the prompt lives in the card", () => { + window.fetch = (() => Promise.reject(new Error("offline"))) as typeof fetch; + const s = session(); + const el = mount(s); + const folder = el.querySelector(".tree-folder-header"); + folder?.click(); + expect(el.querySelector(`[data-session-id="${s.id}"]`)).toBeNull(); + raise(RENAME_SESSION_EVENT, s); + expect(el.querySelector(".leaf-rename")?.value).toBe("amber-falcon"); + }); + + it("raises the card's own prompt, pre-filled with the session's name", () => { + window.fetch = (() => Promise.reject(new Error("offline"))) as typeof fetch; + const s = session(); + const el = mount(s); + raise(RENAME_SESSION_EVENT, s); + const input = el.querySelector(".leaf-rename"); + expect(input?.value).toBe("amber-falcon"); + expect(input?.getAttribute("aria-label")).toBe("Rename amber-falcon"); + }); +}); diff --git a/web/src/catalog.ts b/web/src/catalog.ts index d248295..55674e9 100644 --- a/web/src/catalog.ts +++ b/web/src/catalog.ts @@ -7,6 +7,7 @@ import { CommandSet, defineCommands } from "./commands.js"; import { CatalogStore } from "./events.js"; import type { InTheWay, Session } from "./types.js"; import { + abbreviateCwd, abbreviateFolderName, blockerStats, customSessionName, @@ -16,6 +17,7 @@ import { sessionBlockers, isProvisionalName, sessionName, + sessionTreePath, } from "./types.js"; import { splitCommand, toolIconSvg } from "./toolIcon.js"; import { @@ -50,6 +52,17 @@ export const FOCUS_SIDEBAR_EVENT = "catalog-focus-sidebar"; * ("Spawn similar"). Detail is the session being cloned. */ export const SPAWN_SIMILAR_EVENT = "catalog-spawn-similar"; +/** Ask the sidebar to kill a session — the command bar's action page picking + * `kill` (#151). Detail is the session. Handled here, so the kill goes + * through the same confirm-and-blockers flow the card's ✕ does + * (`docs/WORKTREES.md`); the bar never reimplements it. */ +export const KILL_SESSION_EVENT = "catalog-kill-session"; + +/** Ask the sidebar to rename a session — the command bar's action page picking + * `rename` (#151). Detail is the session. The prompt is the card's own inline + * field, pre-filled with the session's name (`docs/SIDEBAR.md`). */ +export const RENAME_SESSION_EVENT = "catalog-rename-session"; + /** A session row was clicked (issue #133). The row's `href` drives normal * hash navigation, but a split/drag can move focus without ever touching * `location.hash` — so a click back onto a session whose hash still @@ -745,6 +758,40 @@ export function renderSidebar(store: CatalogStore, opts: SidebarOptions = {}): S }; document.addEventListener("catalog-rerender", rerenderHandler); + // The command bar's action page (#151) picks an action and hands it here: + // both flows are the ones the card already has, reached by session rather + // than by row. + const killHandler = (ev: Event) => { + const s = (ev as CustomEvent).detail; + void requestKill(s, sessionName(s)); + }; + const renameHandler = (ev: Event) => { + const s = (ev as CustomEvent).detail; + const row = rowForSession(s); + if (row) beginRename(row, s); + }; + document.addEventListener(KILL_SESSION_EVENT, killHandler); + document.addEventListener(RENAME_SESSION_EVENT, renameHandler); + + /** The card a session is rendered as, expanding whatever folder hides it + * first: the rename prompt lives inside the card, so a collapsed folder + * would otherwise swallow the request. */ + function rowForSession(s: Session): HTMLElement | null { + const find = () => treeHost.querySelector(`.tree-leaf[data-session-id="${s.id}"]`); + const found = find(); + if (found) return found; + // Folder paths are the abbreviated directory's segments joined back up + // (see buildTree), so the session's own path is normalised the same way + // before it is compared against them. + const cwd = sessionTreePath(s); + const path = (cwd ? abbreviateCwd(cwd, homeDir) : "").split("/").filter(Boolean).join("/"); + for (const folder of [...collapsed]) { + if (path === folder || path.startsWith(`${folder}/`)) collapsed.delete(folder); + } + rerenderHandler(); + return find(); + } + // "Spawn similar" (#28) is dispatched here and handled in main.ts, where the // multi-view controller can seed the spawn modal with the session's dir/command. @@ -794,6 +841,8 @@ export function renderSidebar(store: CatalogStore, opts: SidebarOptions = {}): S // refusing to refresh. renamingId = null; document.removeEventListener("catalog-rerender", rerenderHandler); + document.removeEventListener(KILL_SESSION_EVENT, killHandler); + document.removeEventListener(RENAME_SESSION_EVENT, renameHandler); unsubscribe(); }, }; diff --git a/web/src/commandBar.test.ts b/web/src/commandBar.test.ts index bf4ebd4..abb8bd9 100644 --- a/web/src/commandBar.test.ts +++ b/web/src/commandBar.test.ts @@ -1,7 +1,12 @@ // @vitest-environment happy-dom import { afterEach, describe, expect, it, vi } from "vitest"; -import { SESSION_SELECT_EVENT } from "./catalog.js"; +import { + KILL_SESSION_EVENT, + RENAME_SESSION_EVENT, + SESSION_SELECT_EVENT, + SPAWN_SIMILAR_EVENT, +} from "./catalog.js"; import { barGroups, barRows, @@ -15,6 +20,7 @@ import { orderSessionsForBar, parseBarQuery, sessionBarCandidates, + sessionActionCandidates, sessionCandidate, SPAWN_DIRECTORY_EVENT, } from "./commandBar.js"; @@ -542,3 +548,176 @@ describe("the focus round-trip over a pane (#152)", () => { } }); }); + +describe("the session action page (#151)", () => { + const sessions = [ + session("api", { + metadata: { name: "api" }, + current_cwd: "/home/me/dev/api", + last_focused_at: "2026-01-01T11:00:00Z", + }), + session("worker", { + metadata: { name: "worker" }, + current_cwd: "/home/me/dev/worker", + last_focused_at: "2026-01-01T10:00:00Z", + }), + ]; + + function open(over: Partial[0]> = {}) { + return openCommandBar({ + sessions, + homeDir: "/home/me", + commands: [], + onOpenSession: () => {}, + ...over, + }); + } + + function headerText(): string | undefined { + return document.querySelector(".cmdbar-page-header")?.textContent ?? undefined; + } + + it("lists kill, rename and spawn similar for the highlighted session", () => { + open(); + press("Tab"); + expect(rowNames()).toEqual(["Kill session", "Rename session", "Spawn similar"]); + expect(activeName()).toBe("Kill session"); + }); + + it("names the session being acted on", () => { + open(); + press("ArrowDown"); + press("Tab"); + expect(headerText()).toContain("worker"); + }); + + it("drops the kind headers, since the page is one kind", () => { + open(); + press("Tab"); + expect(document.querySelectorAll(".cmdbar-group-title")).toHaveLength(0); + }); + + it("stays fuzzy-searchable over the three actions", () => { + open(); + press("Tab"); + type("ren"); + expect(rowNames()).toEqual(["Rename session"]); + type("spwn"); + expect(rowNames()).toEqual(["Spawn similar"]); + type("zzz"); + expect(document.querySelector(".cmdbar-empty")?.textContent).toBe("No matches"); + }); + + it("does nothing on a command row", () => { + openCommandBar({ + sessions, + homeDir: "/home/me", + commands: [{ ...COMMAND_SPECS[0], run: () => {} } as Command], + onOpenSession: () => {}, + }); + type(">"); + const before = rowNames(); + press("Tab"); + expect(rowNames()).toEqual(before); + expect(headerText()).toBeUndefined(); + }); + + it("does nothing on a directory row", async () => { + open({ loadDirectories: () => Promise.resolve(["/home/me/dev/api"]) }); + type("~"); + await Promise.resolve(); + press("Tab"); + expect(rowNames()).toEqual(["~/dev/api"]); + expect(headerText()).toBeUndefined(); + }); + + it("returns to the result list on Escape, with the previous query intact", () => { + open(); + type("wor"); + press("Tab"); + expect(headerText()).toContain("worker"); + press("Escape"); + expect(isCommandBarOpen()).toBe(true); + expect(headerText()).toBeUndefined(); + expect((document.querySelector(".cmdbar-input") as HTMLInputElement).value).toBe("wor"); + expect(rowNames()).toEqual(["worker"]); + }); + + it("returns to the result list on backspace at an empty query", () => { + open(); + type("wor"); + press("Tab"); + type(""); + press("Backspace"); + expect(headerText()).toBeUndefined(); + expect((document.querySelector(".cmdbar-input") as HTMLInputElement).value).toBe("wor"); + expect(rowNames()).toEqual(["worker"]); + }); + + it("keeps backspace ordinary while the page's query has text", () => { + open(); + press("Tab"); + type("ren"); + press("Backspace"); + expect(headerText()).toContain("api"); + expect(rowNames()).toEqual(["Rename session"]); + }); + + it("closes the bar when the page's Escape has already been used", () => { + open(); + press("Tab"); + press("Escape"); + press("Escape"); + expect(isCommandBarOpen()).toBe(false); + }); + + it("raises kill, rename and spawn similar with the session, closing the bar", () => { + const seen: string[] = []; + for (const [hook, key] of [ + ["onKillSession", "Kill session"], + ["onRenameSession", "Rename session"], + ["onSpawnSimilar", "Spawn similar"], + ] as const) { + open({ [hook]: (s: Session) => seen.push(`${hook}:${s.id}`) }); + type("wor"); + press("Tab"); + type(key); + press("Enter"); + expect(isCommandBarOpen()).toBe(false); + } + expect(seen).toEqual([ + "onKillSession:worker", + "onRenameSession:worker", + "onSpawnSimilar:worker", + ]); + }); + + it("hands each action to the flow it already has, by default", () => { + const seen: string[] = []; + const listeners: Array<[string, EventListener]> = [ + [KILL_SESSION_EVENT, (e) => seen.push(`kill:${(e as CustomEvent).detail.id}`)], + [RENAME_SESSION_EVENT, (e) => seen.push(`rename:${(e as CustomEvent).detail.id}`)], + [SPAWN_SIMILAR_EVENT, (e) => seen.push(`similar:${(e as CustomEvent).detail.id}`)], + ]; + for (const [name, fn] of listeners) document.addEventListener(name, fn); + try { + for (const action of ["Kill session", "Rename session", "Spawn similar"]) { + open(); + press("Tab"); + type(action); + press("Enter"); + } + } finally { + for (const [name, fn] of listeners) document.removeEventListener(name, fn); + } + expect(seen).toEqual(["kill:api", "rename:api", "similar:api"]); + }); + + it("offers no action beyond the three", () => { + expect(sessionActionCandidates().map((c) => c.action.id)).toEqual([ + "kill", + "rename", + "spawn-similar", + ]); + }); +}); diff --git a/web/src/commandBar.ts b/web/src/commandBar.ts index a0ad6d5..cd37d06 100644 --- a/web/src/commandBar.ts +++ b/web/src/commandBar.ts @@ -2,11 +2,16 @@ // list, opened from the sidebar, closed with Escape. // // Three kinds live here: sessions and commands, ranked together by default, -// and directories, which need the `~` sigil. The action page and the -// terminal-focus binding land in #151-#152. +// and directories, which need the `~` sigil. `tab` on a session drills into +// its action page (#151), which is the same overlay over three actions. import { listDir, projects as fetchProjects, recentDirs } from "./api.js"; -import { SESSION_SELECT_EVENT } from "./catalog.js"; +import { + KILL_SESSION_EVENT, + RENAME_SESSION_EVENT, + SESSION_SELECT_EVENT, + SPAWN_SIMILAR_EVENT, +} from "./catalog.js"; import { keyCaps, publishedCommands, type Command } from "./commands.js"; import { matchAndRank, @@ -30,7 +35,13 @@ export type CommandBarCandidate = CommandCandidate & { command: Command }; * ranks; `path` is the one a spawn is made in. */ export type DirectoryBarCandidate = DirectoryCandidate & { path: string }; -export type BarCandidate = SessionBarCandidate | CommandBarCandidate | DirectoryBarCandidate; +/** One row of a session's action page. It is a `command` to the matcher — a + * single `label` field, ranked exactly like a registry command — and carries + * the action it stands for instead of a registry entry. */ +export type ActionBarCandidate = CommandCandidate & { action: SessionAction }; + +export type BarCandidate = + SessionBarCandidate | CommandBarCandidate | ActionBarCandidate | DirectoryBarCandidate; export type BarResult = MatchResult; /** Raised when Enter lands on a directory row. The bar picks the directory; the @@ -73,6 +84,40 @@ export function parseBarQuery(raw: string): BarQuery { return { sigil: null, query: raw }; } +// ---- the session action page ---- + +/** The actions a session's action page offers, and no others: the bar picks + * one and hands off to the flow that already implements it + * (docs/COMMAND-BAR.md, "Drill-in"). */ +export type SessionActionId = "kill" | "rename" | "spawn-similar"; + +export interface SessionAction { + id: SessionActionId; + label: string; +} + +export const SESSION_ACTIONS: readonly SessionAction[] = [ + { id: "kill", label: "Kill session" }, + { id: "rename", label: "Rename session" }, + { id: "spawn-similar", label: "Spawn similar" }, +]; + +/** The action page's candidates, in the order above — which is the order an + * empty query lists them in, since the matcher keeps input order. */ +export function sessionActionCandidates(): ActionBarCandidate[] { + return SESSION_ACTIONS.map((action) => ({ + kind: "command", + action, + fields: { label: action.label }, + })); +} + +/** Narrow a `command` candidate to an action row. Both share the matcher's + * `command` kind, so what separates them is what they carry. */ +export function isActionCandidate(candidate: BarCandidate): candidate is ActionBarCandidate { + return candidate.kind === "command" && "action" in candidate; +} + // ---- candidates ---- function focusedAt(s: Session): number | null { @@ -306,6 +351,8 @@ function renderRow(result: BarResult, index: number): HTMLElement { if (candidate.kind === "session") { row.dataset.sessionId = candidate.session.id; if (candidate.session.status === "Dead") row.classList.add("is-dead"); + } else if (isActionCandidate(candidate)) { + row.dataset.actionId = candidate.action.id; } else if (candidate.kind === "command") { row.dataset.commandId = candidate.command.id; } else { @@ -322,7 +369,7 @@ function renderRow(result: BarResult, index: number): HTMLElement { // The chord is teaching material, not a match target: it is rendered from // the live registry entry and never fed to the matcher. - if (candidate.kind === "command") { + if (candidate.kind === "command" && !isActionCandidate(candidate)) { const keys = document.createElement("span"); keys.className = "cmdbar-row-keys"; for (const cap of keyCaps(candidate.command.keys[0])) { @@ -374,6 +421,13 @@ export interface CommandBarOptions { * `SPAWN_DIRECTORY_EVENT`, which the shell turns into a fast-forward * spawn. */ onSpawnDirectory?: (path: string) => void; + /** The action page's three actions (#151). Each defaults to raising the + * event the flow that already implements it listens on — the sidebar's + * confirm-and-blockers kill, its inline rename prompt, the spawn form + * seeded from the session. The bar picks the action and nothing more. */ + onKillSession?: (session: Session) => void; + onRenameSession?: (session: Session) => void; + onSpawnSimilar?: (session: Session) => void; } interface OpenBar { @@ -406,6 +460,14 @@ export function openCommandBar(opts: CommandBarOptions): HTMLElement { opts.onSpawnDirectory ?? ((path: string) => document.dispatchEvent(new CustomEvent(SPAWN_DIRECTORY_EVENT, { detail: path }))); + const sessionEvent = + (name: string) => + (session: Session): void => { + document.dispatchEvent(new CustomEvent(name, { detail: session })); + }; + const killSession = opts.onKillSession ?? sessionEvent(KILL_SESSION_EVENT); + const renameSession = opts.onRenameSession ?? sessionEvent(RENAME_SESSION_EVENT); + const spawnSimilar = opts.onSpawnSimilar ?? sessionEvent(SPAWN_SIMILAR_EVENT); const loadDirectories = opts.loadDirectories ?? loadBarDirectories; const listEntries = opts.listDirEntries ?? ((dir: string) => listDir(dir).then((listing) => listing.entries)); @@ -450,6 +512,13 @@ export function openCommandBar(opts: CommandBarOptions): HTMLElement { let rows: BarResult[] = []; let highlighted = 0; + // The action page (#151). `page` is the session being acted on, `stashed` + // the result-list query it was drilled into from — backing out restores it, + // so the round trip costs the operator nothing. + let page: Session | null = null; + let stashed = ""; + let header: HTMLElement | null = null; + // `~`'s candidates: the recents-and-projects list, fetched once, plus // whatever `/api/dirs` has filled in for the directory being typed. let directories: DirectoryBarCandidate[] = []; @@ -516,6 +585,7 @@ export function openCommandBar(opts: CommandBarOptions): HTMLElement { * list is mixed — sessions and commands together — except on an empty query, * where the bar is a session switcher. */ function candidatesFor(parsed: BarQuery): BarCandidate[] { + if (page) return sessionActionCandidates(); switch (parsed.sigil) { case ">": return [...commandCandidates]; @@ -547,9 +617,27 @@ export function openCommandBar(opts: CommandBarOptions): HTMLElement { if (rows.length === 0) input.removeAttribute("aria-activedescendant"); } + /** The line naming the session being acted on, so the target is never + * ambiguous. Present only while the action page is up. */ + function paintHeader(): void { + header?.remove(); + header = null; + if (!page) return; + header = document.createElement("div"); + header.className = "cmdbar-page-header"; + header.setAttribute("role", "presentation"); + header.textContent = `Actions for "${sessionName(page)}"`; + panel.insertBefore(header, results); + } + function render(): void { - const parsed = parseBarQuery(input.value); - input.placeholder = PLACEHOLDERS[parsed.sigil ?? "mixed"]; + // On the action page the query line is over the three actions, so there is + // no sigil to read: `>` and `~` are ordinary characters there. + const parsed = page ? { sigil: null, query: input.value } : parseBarQuery(input.value); + input.placeholder = page + ? `Act on ${sessionName(page)}…` + : PLACEHOLDERS[parsed.sigil ?? "mixed"]; + paintHeader(); rows = barRows(parsed.query, candidatesFor(parsed)); // The highlight starts on the globally best row every time the list // changes: an old index would point at a different result. @@ -567,8 +655,9 @@ export function openCommandBar(opts: CommandBarOptions): HTMLElement { let index = 0; for (const group of barGroups(rows)) { - // While a sigil is in force there is one kind, so the header is dropped. - if (parsed.sigil === null) { + // While a sigil is in force there is one kind, so the header is + // dropped — and the action page is one kind too. + if (parsed.sigil === null && !page) { const header = document.createElement("div"); header.className = "cmdbar-group-title"; header.setAttribute("role", "presentation"); @@ -593,10 +682,50 @@ export function openCommandBar(opts: CommandBarOptions): HTMLElement { paintHighlight(); } + /** `tab`: drill into the highlighted session's action page. No effect on any + * other kind, and none on the page itself, which has no session rows. */ + function drillIn(): void { + const candidate = rows[highlighted]?.candidate; + if (page || candidate?.kind !== "session") return; + page = candidate.session; + stashed = input.value; + input.value = ""; + render(); + } + + /** Escape, or backspace at an empty query: back to the result list with the + * query the drill-in was made from. */ + function leavePage(): void { + page = null; + input.value = stashed; + stashed = ""; + render(); + } + + function runAction(action: SessionAction, session: Session): void { + close(); + switch (action.id) { + case "kill": + killSession(session); + return; + case "rename": + renameSession(session); + return; + case "spawn-similar": + spawnSimilar(session); + return; + } + } + function activate(index: number): void { const result = rows[index]; if (!result) return; const candidate = result.candidate; + if (isActionCandidate(candidate)) { + // The page cannot be up without a session to act on. + if (page) runAction(candidate.action, page); + return; + } close(); switch (candidate.kind) { case "session": @@ -615,7 +744,20 @@ export function openCommandBar(opts: CommandBarOptions): HTMLElement { switch (e.key) { case "Escape": e.preventDefault(); - close(); + // On an action page Escape is one level up, not the way out. + if (page) leavePage(); + else close(); + return; + case "Tab": + e.preventDefault(); + drillIn(); + return; + case "Backspace": + // Ordinary editing, except at an empty query on the action page. + if (page && input.value === "") { + e.preventDefault(); + leavePage(); + } return; case "Enter": e.preventDefault(); diff --git a/web/src/styles.css b/web/src/styles.css index 4b293b4..238365c 100644 --- a/web/src/styles.css +++ b/web/src/styles.css @@ -1472,6 +1472,15 @@ input.leaf-rename:focus { padding: var(--sp-2) 0; } +/* The action page's header (#151): names the session being acted on, so the + target of kill/rename/spawn-similar is never ambiguous. */ +.cmdbar-page-header { + padding: var(--sp-2) var(--sp-4); + border-bottom: 1px solid var(--border); + font-size: var(--fs-12); + color: var(--muted); +} + .cmdbar-group-title { padding: var(--sp-2) var(--sp-4) var(--sp-1); font-size: var(--fs-11); From 1b260b5c207e54cf5fc5ed5814d12f6757658afc Mon Sep 17 00:00:00 2001 From: Miguel Palhas Date: Thu, 3 Sep 2026 17:35:24 +0100 Subject: [PATCH 13/14] feat(maestro): drill into a session's actions with tab MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `tab` on a highlighted session opens its action page inside the command bar: kill, rename, spawn similar, still fuzzy-searchable, with the overlay titled after the session. Escape or backspace at an empty query returns to the result list with the previous query intact. Each action raises the flow the TUI already has — `KillConfirm`, the rename prompt pre-filled, and the spawn form, now seeded from that session's directory and command rather than the focused one. Co-Authored-By: Claude Opus 5 --- crates/maestro-tui/src/app.rs | 160 +++++++++++-- crates/maestro-tui/src/command_bar.rs | 328 ++++++++++++++++++++++++-- crates/maestro-tui/src/draft.rs | 68 ++++++ 3 files changed, 525 insertions(+), 31 deletions(-) diff --git a/crates/maestro-tui/src/app.rs b/crates/maestro-tui/src/app.rs index 7ecb642..d864582 100644 --- a/crates/maestro-tui/src/app.rs +++ b/crates/maestro-tui/src/app.rs @@ -23,7 +23,7 @@ use uuid::Uuid; use crate::action::Action; use crate::catalog::CatalogStore; -use crate::command_bar::{BarOutcome, BarTarget, CommandBar}; +use crate::command_bar::{BarOutcome, BarTarget, CommandBar, SessionActionId}; use crate::config::TuiConfig; use crate::daemon_link::DaemonLink; use crate::draft::{DraftInputResult, DraftPane}; @@ -479,6 +479,25 @@ impl App { self.close_command_bar(); (OverlayDecision::Consumed, vec![Effect::FastForwardIn(dir)]) } + // The action page raises the flow this client already has + // — for kill that is `KillConfirm`, which the TUI puts in + // front of every kill (#160). The bar names the target; + // it never reimplements the flow. + BarOutcome::Open(BarTarget::SessionAction { session, action }) => { + self.close_command_bar(); + let effects = match action { + SessionActionId::Kill => { + self.confirm_kill(session); + Vec::new() + } + SessionActionId::Rename => { + self.prompt_rename(session); + Vec::new() + } + SessionActionId::SpawnSimilar => self.open_spawn_similar(session), + }; + (OverlayDecision::Consumed, effects) + } } } Some(OverlayKind::Help) => { @@ -555,24 +574,12 @@ impl App { } Action::KillHighlighted => { if let Some(sid) = self.pick_sidebar_target() { - self.state.show_overlay(OverlayKind::KillConfirm(sid)); + self.confirm_kill(sid); } } Action::RenameHighlighted => { if let Some(sid) = self.pick_sidebar_target() { - // Pre-fill with the custom name only: a session that has - // none starts empty rather than with its command already - // typed in, which Enter would then pin as the name. - self.rename_input = TextInput::with_value( - self.catalog - .snapshot() - .sessions - .iter() - .find(|s| s.id == sid) - .and_then(crate::sidebar::custom_name_for) - .unwrap_or_default(), - ); - self.state.show_overlay(OverlayKind::Rename(sid)); + self.prompt_rename(sid); } } } @@ -858,6 +865,30 @@ impl App { Some(None) } + /// Raise the kill confirmation for one session (`docs/WORKTREES.md`). + /// Shared by the keymap's own kill and the command bar's action page + /// (#155), which picks the action and hands off rather than reimplementing + /// it; the blockers the dialog names are read at render time. + fn confirm_kill(&mut self, sid: Uuid) { + self.state.show_overlay(OverlayKind::KillConfirm(sid)); + } + + /// Raise the rename prompt for one session, pre-filled with its custom + /// name only: a session that has none starts empty rather than with its + /// command already typed in, which Enter would then pin as the name. + fn prompt_rename(&mut self, sid: Uuid) { + self.rename_input = TextInput::with_value( + self.catalog + .snapshot() + .sessions + .iter() + .find(|s| s.id == sid) + .and_then(crate::sidebar::custom_name_for) + .unwrap_or_default(), + ); + self.state.show_overlay(OverlayKind::Rename(sid)); + } + /// Put the spawn form up with a fresh draft behind it, preselecting the /// project of the session the operator was last on — so `n` then Enter /// spawns another session there, the way "spawn similar" would. A @@ -871,6 +902,30 @@ impl App { self.state.open_spawn_modal(); } + /// "Spawn similar" (#155): the spawn form seeded from one session's + /// directory and command, rather than from whatever the operator was last + /// looking at. Same base-repository rule as the plain seed, and the name + /// is left out on purpose — a clone is a new session and takes its own + /// generated handle, like `web/src/spawnSimilar.ts`. + fn open_spawn_similar(&mut self, sid: Uuid) -> Vec { + let session = self + .catalog + .snapshot() + .sessions + .iter() + .find(|s| s.id == sid) + .cloned(); + self.open_spawn_modal(); + if let (Some(session), Some(draft)) = (session, self.draft.as_mut()) { + draft.seed_cwd = Some(match &session.worktree { + Some(wt) => wt.base_repo.clone(), + None => session.cwd.clone(), + }); + draft.seed_command = (!session.command.is_empty()).then(|| session.command.clone()); + } + vec![Effect::LoadDraft] + } + /// The project of the session the operator was last on — its base /// repository for a worktree session, its cwd otherwise. Shared by the /// plain spawn modal seed and fast-forward's project pick (#67 review): @@ -3379,4 +3434,79 @@ mod tests { }] ); } + + // ---- the session action page (#155) ---- + + /// Drill into the second row's session, then pick an action by name. + fn drill_and_pick(app: &mut App, action: &str) -> (OverlayDecision, Vec) { + app.dispatch(Action::OpenCommandBar); + app.handle_overlay_input(&InputKey::Down); + app.handle_overlay_input(&InputKey::Tab); + type_into_bar(app, action); + app.handle_overlay_input(&InputKey::Enter) + } + + /// The bar picks the action and hands off: kill goes through the confirm + /// this client puts in front of every kill, not straight to the daemon. + #[test] + fn the_action_page_raises_the_existing_kill_confirm() { + let mut app = make_app(); + let (alpha, _bravo) = seed_two_sessions(&mut app); + let (decision, effects) = drill_and_pick(&mut app, "kill"); + assert_eq!(decision, OverlayDecision::Consumed); + assert!(effects.is_empty(), "the confirm is the whole effect"); + assert_eq!(app.state.overlay(), Some(&OverlayKind::KillConfirm(alpha))); + assert!(app.command_bar.is_none()); + } + + #[test] + fn the_action_page_raises_the_existing_rename_prompt_prefilled() { + let mut app = make_app(); + let (alpha, _bravo) = seed_two_sessions(&mut app); + let (decision, effects) = drill_and_pick(&mut app, "rename"); + assert_eq!(decision, OverlayDecision::Consumed); + assert!(effects.is_empty()); + assert_eq!(app.state.overlay(), Some(&OverlayKind::Rename(alpha))); + assert_eq!(app.rename_input.value(), "alpha"); + assert!(app.command_bar.is_none()); + } + + /// Spawn similar opens the spawn form seeded from that session's directory + /// and command — not from whatever the operator was last looking at. + #[test] + fn the_action_page_raises_the_spawn_form_seeded_from_the_session() { + let mut app = make_app(); + seed_two_sessions(&mut app); + let (decision, effects) = drill_and_pick(&mut app, "spawn"); + assert_eq!(decision, OverlayDecision::Consumed); + assert_eq!(effects, vec![Effect::LoadDraft]); + assert_eq!(app.state.overlay(), Some(&OverlayKind::Spawn)); + let draft = app.draft.as_ref().unwrap(); + assert_eq!(draft.seed_cwd, Some(std::path::PathBuf::from("/"))); + assert_eq!(draft.seed_command.as_deref(), Some("bash")); + assert!(app.command_bar.is_none()); + } + + /// Backing out is free: the result-list query survives the round trip. + #[test] + fn escape_on_the_action_page_returns_to_the_result_list() { + let mut app = make_app(); + seed_two_sessions(&mut app); + app.dispatch(Action::OpenCommandBar); + type_into_bar(&mut app, "alp"); + app.handle_overlay_input(&InputKey::Tab); + assert!(app.command_bar.as_ref().unwrap().on_action_page()); + + let (decision, _) = app.handle_overlay_input(&InputKey::Esc); + assert_eq!(decision, OverlayDecision::Consumed); + let bar = app.command_bar.as_ref().expect("escape backs out, not out"); + assert!(!bar.on_action_page()); + assert_eq!(bar.query(), "alp"); + assert_eq!(app.state.overlay(), Some(&OverlayKind::CommandBar)); + + // A second escape is the way out again. + app.handle_overlay_input(&InputKey::Esc); + assert!(app.command_bar.is_none()); + assert!(app.state.overlay().is_none()); + } } diff --git a/crates/maestro-tui/src/command_bar.rs b/crates/maestro-tui/src/command_bar.rs index 7a8d4d7..3b2081b 100644 --- a/crates/maestro-tui/src/command_bar.rs +++ b/crates/maestro-tui/src/command_bar.rs @@ -2,8 +2,9 @@ //! result list, opened from Normal or Insert mode, closed with Escape. //! //! Three kinds live here: sessions and commands, ranked together by default, -//! and directories, which need the `~` sigil (#154). The session action page -//! (#155) and the leader's literal Ctrl-K (#156) land later. +//! and directories, which need the `~` sigil (#154). `tab` on a session drills +//! into its action page (#155), the same overlay over three actions. The +//! leader's literal Ctrl-K (#156) lands later. //! //! Ranking is [`crate::matcher`]'s job and nothing here reorders it — the //! groups are a rendering of the ranked list, in the order their best-scoring @@ -46,8 +47,42 @@ pub enum BarTarget { /// Fast-forward spawn in this directory, absolute — the abbreviated form /// is what the row shows and what the matcher ranked. Directory(PathBuf), + /// A row of the action page: run this action against this session (#155). + SessionAction { + session: Uuid, + action: SessionActionId, + }, } +/// The actions a session's action page offers, and no others: the bar picks +/// one and hands off to the flow that already implements it +/// (`docs/COMMAND-BAR.md`, "Drill-in"). Mirrors the web's `SessionActionId`. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum SessionActionId { + Kill, + Rename, + SpawnSimilar, +} + +impl SessionActionId { + /// The row's text, which is also the only field the matcher sees. + pub fn label(self) -> &'static str { + match self { + Self::Kill => "Kill session", + Self::Rename => "Rename session", + Self::SpawnSimilar => "Spawn similar", + } + } +} + +/// The page's actions in the order an empty query lists them, since the +/// matcher keeps input order. +pub const SESSION_ACTIONS: [SessionActionId; 3] = [ + SessionActionId::Kill, + SessionActionId::Rename, + SessionActionId::SpawnSimilar, +]; + /// The first character of the query, when it is one of the two that narrow the /// list to a single kind. #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -255,10 +290,23 @@ pub struct CommandBar { sessions: Vec, commands: Vec, directories: Vec, + /// The action page while it is up (#155), and the result-list query it was + /// drilled into from — backing out restores that query, so the round trip + /// costs the operator nothing. + page: Option, + stashed: String, rows: Vec, highlight: usize, } +/// The session action page while it is up: the name the overlay titles itself +/// with, and the three action rows the query line runs over. +#[derive(Debug)] +struct ActionPage { + name: String, + actions: Vec, +} + impl CommandBar { /// Open the bar over the sessions the catalog has right now and the live /// keymap for the current mode. Directories arrive later @@ -293,6 +341,8 @@ impl CommandBar { sessions, commands, directories: Vec::new(), + page: None, + stashed: String::new(), rows: Vec::new(), highlight: 0, }; @@ -381,7 +431,15 @@ impl CommandBar { /// "Keys") does not claim is ordinary editing. pub fn handle_key(&mut self, key: &InputKey) -> BarOutcome { match key { - InputKey::Esc => BarOutcome::Close, + // On the action page Escape is one level up, not the way out. + InputKey::Esc => { + if self.page.is_some() { + self.leave_page(); + BarOutcome::Stay + } else { + BarOutcome::Close + } + } InputKey::Enter => match self.selected() { Some(row) => BarOutcome::Open(row.target.clone()), // Nothing to open: the bar stays up rather than closing on a @@ -402,9 +460,19 @@ impl CommandBar { self.move_highlight(1); BarOutcome::Stay } - // Tab drills into the highlighted session's action page (#155). - // Until then it is inert rather than a literal tab in the query. - InputKey::Tab | InputKey::BackTab => BarOutcome::Stay, + // Tab drills into the highlighted session's action page (#155); + // shift-tab is inert rather than a literal tab in the query. + InputKey::Tab => { + self.drill_in(); + BarOutcome::Stay + } + InputKey::BackTab => BarOutcome::Stay, + // Ordinary editing, except at an empty query on the action page, + // where it is the second way back to the result list. + InputKey::Backspace if self.page.is_some() && self.input.value().is_empty() => { + self.leave_page(); + BarOutcome::Stay + } other => { let before = self.input.value().to_string(); self.input.handle_key(other); @@ -416,15 +484,75 @@ impl CommandBar { } } + /// `tab`: drill into the highlighted session's action page. No effect on + /// any other kind, and none on the page itself, which has no session rows. + fn drill_in(&mut self) { + if self.page.is_some() { + return; + } + let Some(row) = self.selected() else { + return; + }; + let BarTarget::Session(session) = row.target else { + return; + }; + let name = row.name.clone(); + let actions = SESSION_ACTIONS + .iter() + .map(|&action| BarEntry { + candidate: MatchCandidate::Command(CommandCandidate { + label: action.label().to_string(), + }), + target: BarTarget::SessionAction { session, action }, + dead: false, + chords: Vec::new(), + }) + .collect(); + self.page = Some(ActionPage { name, actions }); + self.stashed = self.input.value().to_string(); + self.input = TextInput::default(); + self.recompute(); + } + + /// Escape, or backspace at an empty query: back to the result list with + /// the query the drill-in was made from. + fn leave_page(&mut self) { + self.page = None; + self.input = TextInput::with_value(std::mem::take(&mut self.stashed)); + self.recompute(); + } + + /// Whether the action page is up. The session it acts on is in every one + /// of its rows' targets. + pub fn on_action_page(&self) -> bool { + self.page.is_some() + } + + /// What the matcher sees. On the action page the whole query is the query: + /// a sigil narrows the result list to one kind, and the page is one kind + /// already. + fn parsed_query(&self) -> BarQuery<'_> { + match self.page { + Some(_) => BarQuery { + sigil: None, + query: self.input.value(), + }, + None => parse_bar_query(self.input.value()), + } + } + /// The sigil in force for the query as it stands. pub fn sigil(&self) -> Option { - parse_bar_query(self.input.value()).sigil + self.parsed_query().sigil } /// The candidates the query line asks for. A sigil narrows to one kind; /// with no sigil the list is mixed — sessions and commands ranked together /// — except on an empty query, where the bar is a session switcher. fn active_entries(&self, parsed: BarQuery<'_>) -> Vec<&BarEntry> { + if let Some(page) = &self.page { + return page.actions.iter().collect(); + } match parsed.sigil { Some(BarSigil::Command) => self.commands.iter().collect(), Some(BarSigil::Directory) => self.directories.iter().collect(), @@ -438,7 +566,7 @@ impl CommandBar { /// different result. fn recompute(&mut self) { let rows: Vec = { - let parsed = parse_bar_query(self.input.value()); + let parsed = self.parsed_query(); let entries = self.active_entries(parsed); let candidates: Vec = entries.iter().map(|e| e.candidate.clone()).collect(); @@ -482,12 +610,16 @@ impl CommandBar { let height = parent.height.saturating_sub(4).clamp(5, 20); let area = centered(parent, 72, height); f.render_widget(Clear, area); + // The page names the session being acted on, so the target is never + // ambiguous. + let title = match (&self.page, self.sigil()) { + (Some(page), _) => format!(" act on {} ", page.name), + (None, Some(BarSigil::Command)) => " run a command ".to_string(), + (None, Some(BarSigil::Directory)) => " spawn in ".to_string(), + (None, None) => " go to ".to_string(), + }; let block = Block::default() - .title(match self.sigil() { - Some(BarSigil::Command) => " run a command ", - Some(BarSigil::Directory) => " spawn in ", - None => " go to ", - }) + .title(title) .borders(Borders::ALL) .style(pal.card.base()) .border_style(pal.border_style(true)); @@ -510,7 +642,11 @@ impl CommandBar { .render(query, f.buffer_mut(), pal.card.text_style()); f.render_widget( Paragraph::new(Line::from(Span::styled( - "[enter] open [esc] close", + if self.page.is_some() { + "[enter] run [esc] back" + } else { + "[enter] open [esc] close" + }, pal.card.dim_style(), ))), row(inner.height - 1), @@ -540,8 +676,9 @@ impl CommandBar { let mut lines: Vec> = Vec::new(); let mut highlight_line = 0usize; // While a sigil is in force there is one kind, so the header is - // dropped — it would name what the whole list already is. - let headers = self.sigil().is_none(); + // dropped — it would name what the whole list already is. The action + // page is one kind too. + let headers = self.sigil().is_none() && self.page.is_none(); for group in self.groups() { if headers { lines.push(Line::from(Span::styled( @@ -1219,4 +1356,163 @@ mod tests { } assert_eq!(bold, vec!["w", "s"], "bold cells: {bold:?}"); } + + // ---- the session action page (#155) ---- + + fn targets(rows: &[BarRow]) -> Vec { + rows.iter().map(|r| r.target.clone()).collect() + } + + #[test] + fn tab_on_a_session_opens_its_action_page() { + let mut bar = CommandBar::new(&[named(1, "alpha")], &[], "/home/me"); + bar.handle_key(&InputKey::Tab); + assert!(bar.on_action_page()); + assert_eq!( + labels(bar.rows()), + vec!["Kill session", "Rename session", "Spawn similar"] + ); + assert_eq!( + targets(bar.rows()), + SESSION_ACTIONS + .iter() + .map(|&action| BarTarget::SessionAction { + session: Uuid::from_u128(1), + action, + }) + .collect::>() + ); + } + + /// Tab has no effect on any other kind, and none on the page itself, which + /// has no session rows. + #[test] + fn tab_on_a_command_or_directory_row_does_nothing() { + let keymap = vec![("x".to_string(), Action::KillHighlighted)]; + let mut bar = CommandBar::new(&[named(1, "alpha")], &keymap, "/home/me"); + typed(&mut bar, ">"); + bar.handle_key(&InputKey::Tab); + assert!(!bar.on_action_page()); + assert_eq!(bar.query(), ">"); + + let mut bar = CommandBar::new(&[], &[], "/home/me"); + bar.set_directories(&[PathBuf::from("/home/me/dev/api")]); + typed(&mut bar, "~"); + bar.handle_key(&InputKey::Tab); + assert!(!bar.on_action_page()); + } + + #[test] + fn tab_on_the_action_page_does_not_drill_further() { + let mut bar = CommandBar::new(&[named(1, "alpha")], &[], "/home/me"); + bar.handle_key(&InputKey::Tab); + bar.handle_key(&InputKey::Tab); + assert!(bar.on_action_page()); + assert_eq!(labels(bar.rows()).len(), 3); + } + + /// The page drills into the highlighted session, not the first one. + #[test] + fn the_page_acts_on_the_highlighted_session() { + let mut bar = CommandBar::new(&[named(1, "alpha"), named(2, "bravo")], &[], "/home/me"); + bar.move_highlight(1); + bar.handle_key(&InputKey::Tab); + assert_eq!( + bar.rows()[0].target, + BarTarget::SessionAction { + session: Uuid::from_u128(2), + action: SessionActionId::Kill, + } + ); + } + + #[test] + fn the_action_page_stays_fuzzy_searchable() { + let mut bar = CommandBar::new(&[named(1, "alpha")], &[], "/home/me"); + bar.handle_key(&InputKey::Tab); + typed(&mut bar, "ren"); + assert_eq!(labels(bar.rows()), vec!["Rename session"]); + assert_eq!( + bar.selected().map(|r| r.target.clone()), + Some(BarTarget::SessionAction { + session: Uuid::from_u128(1), + action: SessionActionId::Rename, + }) + ); + } + + /// A sigil narrows the result list to one kind; the page is one kind + /// already, so `>` there is a query character like any other. + #[test] + fn a_sigil_is_an_ordinary_character_on_the_action_page() { + let mut bar = CommandBar::new(&[named(1, "alpha")], &[], "/home/me"); + bar.handle_key(&InputKey::Tab); + typed(&mut bar, ">"); + assert_eq!(bar.sigil(), None); + assert!(bar.rows().is_empty()); + } + + #[test] + fn escape_returns_to_the_result_list_with_the_query_intact() { + let mut bar = CommandBar::new(&[named(1, "alpha"), named(2, "bravo")], &[], "/home/me"); + typed(&mut bar, "alp"); + assert_eq!(bar.handle_key(&InputKey::Tab), BarOutcome::Stay); + assert_eq!(bar.query(), "", "the page starts on an empty query"); + // Escape is one level up, not the way out. + assert_eq!(bar.handle_key(&InputKey::Esc), BarOutcome::Stay); + assert!(!bar.on_action_page()); + assert_eq!(bar.query(), "alp"); + assert_eq!(labels(bar.rows()), vec!["alpha"]); + // Off the page it closes the bar again. + assert_eq!(bar.handle_key(&InputKey::Esc), BarOutcome::Close); + } + + #[test] + fn backspace_at_an_empty_query_returns_to_the_result_list() { + let mut bar = CommandBar::new(&[named(1, "alpha")], &[], "/home/me"); + typed(&mut bar, "alp"); + bar.handle_key(&InputKey::Tab); + bar.handle_key(&InputKey::Backspace); + assert!(!bar.on_action_page()); + assert_eq!(bar.query(), "alp"); + } + + /// Backspace with something typed is ordinary editing of the page's own + /// query, not the way out. + #[test] + fn backspace_on_a_non_empty_page_query_edits_it() { + let mut bar = CommandBar::new(&[named(1, "alpha")], &[], "/home/me"); + typed(&mut bar, "alp"); + bar.handle_key(&InputKey::Tab); + typed(&mut bar, "kx"); + bar.handle_key(&InputKey::Backspace); + assert!(bar.on_action_page()); + assert_eq!(bar.query(), "k"); + assert_eq!(labels(bar.rows()), vec!["Kill session"]); + } + + #[test] + fn enter_on_the_page_opens_the_action_for_that_session() { + let mut bar = CommandBar::new(&[named(1, "alpha")], &[], "/home/me"); + bar.handle_key(&InputKey::Tab); + typed(&mut bar, "spawn"); + assert_eq!( + bar.handle_key(&InputKey::Enter), + BarOutcome::Open(BarTarget::SessionAction { + session: Uuid::from_u128(1), + action: SessionActionId::SpawnSimilar, + }) + ); + } + + #[test] + fn the_action_page_titles_itself_with_the_session_it_acts_on() { + let mut bar = CommandBar::new(&[named(1, "amber-falcon")], &[], "/home/me"); + bar.handle_key(&InputKey::Tab); + let rendered = render_to_string(&bar, 90, 20); + assert!(rendered.contains("act on amber-falcon"), "{rendered}"); + assert!(rendered.contains("Kill session"), "{rendered}"); + // One kind, so the group header is dropped the way a sigil drops it. + assert!(!rendered.contains("Commands"), "{rendered}"); + } } diff --git a/crates/maestro-tui/src/draft.rs b/crates/maestro-tui/src/draft.rs index eb2d869..e89e562 100644 --- a/crates/maestro-tui/src/draft.rs +++ b/crates/maestro-tui/src/draft.rs @@ -228,6 +228,10 @@ pub struct DraftPane { /// respawns there without touching the picker. Consumed by the first /// [`Self::set_projects`]; `None` once applied or when nothing seeded it. pub seed_cwd: Option, + /// Command to preselect once the preset list lands — "spawn similar"'s + /// half of the clone (#155). Consumed by the first [`Self::set_presets`]; + /// `None` once applied or when nothing seeded it. + pub seed_command: Option, /// The repository picked in step one ([`group_key`] of its main box), /// `None` until a box is picked. Looked up fresh in `groups` rather than /// cached, so a project-list refresh can't leave it stale. @@ -291,6 +295,7 @@ impl Default for DraftPane { id: NEXT_DRAFT_ID.fetch_add(1, std::sync::atomic::Ordering::Relaxed), presets, selected_preset: 0, + seed_command: None, custom_command: crate::text_input::TextInput::default(), groups: Vec::new(), filter: crate::text_input::TextInput::default(), @@ -342,6 +347,27 @@ impl DraftPane { .iter() .position(|p| !p.command.is_empty()) .unwrap_or(0); + self.apply_seed_command(); + } + + /// Land a seeded command on the tile that runs it: its own preset when the + /// daemon offers one, otherwise the custom escape hatch with the command + /// typed in. + fn apply_seed_command(&mut self) { + let Some(command) = self.seed_command.take() else { + return; + }; + match self.presets.iter().position(|p| p.command == command) { + Some(index) => self.selected_preset = index, + None => { + self.selected_preset = self + .presets + .iter() + .position(|p| p.command.is_empty()) + .unwrap_or(self.selected_preset); + self.custom_command = crate::text_input::TextInput::with_value(command); + } + } } /// Replace the project snapshot: sort fresh-first (#54), group by @@ -1688,6 +1714,48 @@ mod tests { assert_eq!(draft.presets.last().unwrap().id, "custom"); } + /// "Spawn similar" (#155) seeds the cloned session's command: it lands on + /// the tile that runs it when the daemon offers one. + #[test] + fn a_seeded_command_selects_its_own_preset() { + let mut draft = DraftPane { + seed_command: Some("aider".into()), + ..DraftPane::default() + }; + draft.set_presets(vec![ + SpawnPreset { + id: "claude".into(), + label: "Claude".into(), + command: "claude".into(), + }, + SpawnPreset { + id: "aider".into(), + label: "Aider".into(), + command: "aider".into(), + }, + ]); + assert_eq!(draft.command(), "aider"); + assert!(!draft.is_custom()); + assert_eq!(draft.seed_command, None, "consumed by the first load"); + } + + /// A command no preset runs falls back to the custom tile with the command + /// typed in, so the clone still spawns what it cloned. + #[test] + fn a_seeded_command_with_no_preset_fills_the_custom_tile() { + let mut draft = DraftPane { + seed_command: Some("bash -l".into()), + ..DraftPane::default() + }; + draft.set_presets(vec![SpawnPreset { + id: "claude".into(), + label: "Claude".into(), + command: "claude".into(), + }]); + assert!(draft.is_custom()); + assert_eq!(draft.command(), "bash -l"); + } + #[test] fn left_right_cycles_presets_wrapping_into_custom() { let mut draft = DraftPane { From c1c283c0f8799ee489418800c9f403cef97e3496 Mon Sep 17 00:00:00 2001 From: Miguel Palhas Date: Thu, 3 Sep 2026 17:55:50 +0100 Subject: [PATCH 14/14] feat(maestro): give readline's kill-to-end-of-line back via the leader MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ctrl-k now opens the command bar in both modes (#153), taking readline's kill-to-end-of-line away from every pane. Leader then ctrl-k — ctrl-a ctrl-k by default — sends the literal 0x0B byte to the focused pane instead, built from the configured leader so rebinding it rebinds the chord too. Chording a second binding off the leader exposed a gap in ChordState: a key bound both alone and as a chord prefix (ctrl-a alone, and ctrl-a ctrl-k) would get stuck waiting on the next keystroke, discarding its own meaning the moment that keystroke didn't continue the chord. Feed now resolves the held prefix to its own binding when that happens and hands the breaking key back via take_replay for the caller to process fresh — otherwise, pressing leader then any other key would never leave Insert mode again. --- crates/maestro-tui/src/action.rs | 21 ++++ crates/maestro-tui/src/app.rs | 177 ++++++++++++++++++++++++++----- crates/maestro-tui/src/config.rs | 36 +++++++ crates/maestro-tui/src/input.rs | 74 ++++++++++++- 4 files changed, 277 insertions(+), 31 deletions(-) diff --git a/crates/maestro-tui/src/action.rs b/crates/maestro-tui/src/action.rs index 7134a9d..25e8e70 100644 --- a/crates/maestro-tui/src/action.rs +++ b/crates/maestro-tui/src/action.rs @@ -56,6 +56,10 @@ pub enum Action { /// Open the `ctrl-k` command bar (`docs/COMMAND-BAR.md`) — a query line /// over the sessions, ranked by the shared matcher. OpenCommandBar, + /// Escape hatch (#156): leader then `ctrl-k` sends the literal `0x0B` + /// byte to the focused pane instead of opening the command bar, giving + /// readline's kill-to-end-of-line back. + SendLiteralCtrlK, ToggleSidebar, ShowHelp, Quit, @@ -98,6 +102,7 @@ impl Action { Action::KillHighlighted => "Kill selected session".into(), Action::RenameHighlighted => "Rename selected session".into(), Action::OpenCommandBar => "Go to a session".into(), + Action::SendLiteralCtrlK => "Send literal Ctrl-K to session".into(), Action::ToggleSidebar => "Show or hide sidebar".into(), Action::ShowHelp => "Show help".into(), Action::Quit => "Quit Maestro".into(), @@ -125,6 +130,7 @@ impl Action { "KillHighlighted" => return Ok(Action::KillHighlighted), "RenameHighlighted" => return Ok(Action::RenameHighlighted), "OpenCommandBar" => return Ok(Action::OpenCommandBar), + "SendLiteralCtrlK" => return Ok(Action::SendLiteralCtrlK), "ToggleSidebar" => return Ok(Action::ToggleSidebar), "ShowHelp" => return Ok(Action::ShowHelp), "Quit" => return Ok(Action::Quit), @@ -256,11 +262,26 @@ mod tests { assert_eq!(Action::OpenCommandBar.help_label(), "Go to a session"); } + /// The escape hatch (#156) is an ordinary action like any other. + #[test] + fn send_literal_ctrl_k_round_trips() { + assert_eq!( + Action::parse("SendLiteralCtrlK").unwrap(), + Action::SendLiteralCtrlK + ); + assert_eq!(Action::SendLiteralCtrlK.to_string(), "SendLiteralCtrlK"); + assert_eq!( + Action::SendLiteralCtrlK.help_label(), + "Send literal Ctrl-K to session" + ); + } + #[test] fn display_round_trips() { for a in [ Action::EnterInsert, Action::OpenCommandBar, + Action::SendLiteralCtrlK, Action::RenameHighlighted, Action::Quit, Action::OpenSpawnModal, diff --git a/crates/maestro-tui/src/app.rs b/crates/maestro-tui/src/app.rs index d864582..f283bd3 100644 --- a/crates/maestro-tui/src/app.rs +++ b/crates/maestro-tui/src/app.rs @@ -306,6 +306,7 @@ impl App { return Ok(true); } } + self.chord.take_replay(); return Ok(false); } @@ -325,32 +326,46 @@ impl App { return Ok(false); } - let known = self.cfg.keymap.known_bindings(self.state.mode); - match self.chord.feed(&key, &known) { - ChordResult::Pending => Ok(false), - ChordResult::Resolved(canon) => { - let act = self.cfg.keymap.resolve(self.state.mode, &canon); - if matches!(act, Some(Action::Quit)) { - return Ok(true); + // A key bumped out of an abandoned chord (#156) is replayed as its + // own keystroke, against the mode dispatch may have just changed. + let mut current_key = key; + let mut result = self.chord.feed( + ¤t_key, + &self.cfg.keymap.known_bindings(self.state.mode), + ); + loop { + let canon = match result { + ChordResult::Pending => return Ok(false), + ChordResult::Resolved(canon) => canon, + }; + let act = self.cfg.keymap.resolve(self.state.mode, &canon); + if matches!(act, Some(Action::Quit)) { + return Ok(true); + } + let pty_owns_keys = + self.state.mode == Mode::Insert && matches!(self.state.focus, Focus::Pane); + match act { + // `Noop` suppresses a binding, and a key with no binding in + // Insert mode is the session's — so a key bound to `Noop` + // reaches the pty too. That is the escape hatch for taking + // a default binding back. + Some(Action::Noop) | None if pty_owns_keys => { + let effects = self.forward_to_session(¤t_key).into_iter().collect(); + self.perform(effects).await; } - let pty_owns_keys = - self.state.mode == Mode::Insert && matches!(self.state.focus, Focus::Pane); - match act { - // `Noop` suppresses a binding, and a key with no binding in - // Insert mode is the session's — so a key bound to `Noop` - // reaches the pty too. That is the escape hatch for taking - // a default binding back. - Some(Action::Noop) | None if pty_owns_keys => { - let effects = self.forward_to_session(&key).into_iter().collect(); - self.perform(effects).await; - } - Some(act) => { - let effects = self.dispatch(act); - self.perform(effects).await; - } - None => {} + Some(act) => { + let effects = self.dispatch(act); + self.perform(effects).await; } - Ok(false) + None => {} + } + match self.chord.take_replay() { + Some(replay_key) => { + let known = self.cfg.keymap.known_bindings(self.state.mode); + result = self.chord.feed(&replay_key, &known); + current_key = replay_key; + } + None => return Ok(false), } } } @@ -546,6 +561,10 @@ impl App { self.state.show_overlay(OverlayKind::Help); } Action::OpenCommandBar => effects.extend(self.open_command_bar()), + // Escape hatch (#156): the literal byte, not the bar. + Action::SendLiteralCtrlK => { + effects.extend(self.forward_to_session(&InputKey::ctrl_char('k'))); + } Action::OpenSpawnModal => { self.open_spawn_modal(); effects.push(Effect::LoadDraft); @@ -2912,6 +2931,55 @@ mod tests { CtEvent::Key(crossterm::event::KeyEvent::new(code, mods)) } + fn ctrl_key_event(c: char) -> CtEvent { + key_event( + crossterm::event::KeyCode::Char(c), + crossterm::event::KeyModifiers::CONTROL, + ) + } + + /// Drive a sequence of key events through an app in Insert mode with a + /// session attached, and report the app afterwards plus every chunk of + /// bytes that reached that session, in order. + async fn drive_insert_sequence( + section: &TuiSection, + events: &[CtEvent], + ) -> (App, Vec>) { + use crate::session_task::{SessionHandle, SessionState}; + use std::sync::atomic::{AtomicU64, AtomicU8}; + use std::sync::{Arc, Mutex}; + use tokio::sync::mpsc; + + let sid = Uuid::from_u128(41); + let mut app = App::new( + TuiConfig::load(section).unwrap(), + CatalogStore::default(), + DaemonClient::new("http://127.0.0.1:7070"), + DaemonLink::default(), + ); + let (tx, mut rx) = mpsc::channel(8); + app.emulators.insert( + sid, + SessionHandle { + tx, + screen: Arc::new(Mutex::new(vt100::Parser::new(24, 80, 0))), + state: Arc::new(AtomicU8::new(SessionState::Live as u8)), + revision: Arc::new(AtomicU64::new(0)), + }, + ); + app.state.attach_to_focused(sid); + app.state.mode = Mode::Insert; + app.state.focus = Focus::Pane; + for ev in events { + app.handle_event(ev.clone()).await.unwrap(); + } + let mut sent = Vec::new(); + while let Ok(SessionInput::Bytes(bytes)) = rx.try_recv() { + sent.push(bytes); + } + (app, sent) + } + /// Escape belongs to the session: full-screen apps (vim, Claude Code) read /// it constantly, so no Insert-mode default may swallow it. The leader is /// the way back to the sidebar. @@ -2930,19 +2998,74 @@ mod tests { /// `Noop` suppresses a binding, and in Insert mode a key with no binding is /// the session's — so binding a key to `Noop` hands it to the pty. + /// + /// Uses `ctrl-x`, not the leader: the leader is also a chord prefix + /// (#156), so a single fed keystroke on it resolves as `Pending`, not + /// `Resolved` — a different mechanism, covered separately below. #[tokio::test] async fn a_noop_insert_binding_forwards_the_key_to_the_session() { let mut section = TuiSection::default(); - section.keymap_insert.insert("ctrl-a".into(), "Noop".into()); + section.keymap_insert.insert("ctrl-x".into(), "Noop".into()); let sent = bytes_sent_for( §ion, key_event( - crossterm::event::KeyCode::Char('a'), + crossterm::event::KeyCode::Char('x'), crossterm::event::KeyModifiers::CONTROL, ), ) .await; - assert_eq!(sent, Some(vec![0x01])); + assert_eq!(sent, Some(vec![0x18])); + } + + /// Leader then `ctrl-k` sends the literal byte and never opens the bar + /// (#156) — the default leader, `ctrl-a`. + #[tokio::test] + async fn leader_then_ctrl_k_sends_the_literal_byte_and_does_not_open_the_bar() { + let (app, sent) = drive_insert_sequence( + &TuiSection::default(), + &[ctrl_key_event('a'), ctrl_key_event('k')], + ) + .await; + assert_eq!(sent, vec![vec![0x0b]]); + assert_eq!(app.state.overlay(), None); + assert_eq!(app.state.mode, Mode::Insert, "still in Insert, not left"); + } + + /// The escape hatch is built from the configured leader, not a hardcoded + /// `ctrl-a` — rebinding `[tui].leader` rebinds it too (#156). + #[tokio::test] + async fn a_rebound_leader_still_reaches_the_literal_ctrl_k_escape() { + let section = TuiSection { + leader: Some("ctrl-b".into()), + ..Default::default() + }; + let (app, sent) = + drive_insert_sequence(§ion, &[ctrl_key_event('b'), ctrl_key_event('k')]).await; + assert_eq!(sent, vec![vec![0x0b]]); + assert_eq!(app.state.overlay(), None); + } + + /// Leader alone, followed by an ordinary key, still leaves Insert mode — + /// the escape hatch chord doesn't cost the leader its existing meaning + /// (#156). + #[tokio::test] + async fn leader_alone_still_leaves_insert_when_followed_by_another_key() { + let (app, sent) = drive_insert_sequence( + &TuiSection::default(), + &[ + ctrl_key_event('a'), + key_event( + crossterm::event::KeyCode::Char('j'), + crossterm::event::KeyModifiers::NONE, + ), + ], + ) + .await; + assert!( + sent.is_empty(), + "no bytes for the leader chord itself: {sent:?}" + ); + assert_eq!(app.state.mode, Mode::Normal, "leader still leaves Insert"); } #[test] diff --git a/crates/maestro-tui/src/config.rs b/crates/maestro-tui/src/config.rs index 507024f..3362c23 100644 --- a/crates/maestro-tui/src/config.rs +++ b/crates/maestro-tui/src/config.rs @@ -146,6 +146,15 @@ impl TuiConfig { keymap .set(Mode::Insert, &leader.canonical(), "LeaveInsert") .expect("leader canonical form is valid by construction"); + // Leader, ctrl-k → the literal-Ctrl-K escape hatch (#156), built from + // the same leader spec so rebinding leader rebinds this chord too. + keymap + .set( + Mode::Insert, + &format!("{} ctrl-k", leader.canonical()), + "SendLiteralCtrlK", + ) + .expect("leader canonical form is valid by construction"); for (input, action) in §ion.keymap_normal { keymap.set(Mode::Normal, input, action).map_err(|source| { @@ -278,6 +287,33 @@ mod tests { assert_eq!(cfg.keymap.resolve(Mode::Insert, "ctrl-a"), None); } + /// The literal-Ctrl-K escape hatch (#156) is chorded off the default + /// leader, `ctrl-a ctrl-k` — not the command bar. + #[test] + fn default_leader_escapes_a_literal_ctrl_k() { + let cfg = TuiConfig::load(&TuiSection::default()).unwrap(); + assert_eq!( + cfg.keymap.resolve(Mode::Insert, "ctrl-a ctrl-k"), + Some(Action::SendLiteralCtrlK), + ); + } + + /// Rebinding the leader must rebind the literal-Ctrl-K chord with it, not + /// just the Insert escape — proving the chord is built from the + /// configured leader rather than a hardcoded `ctrl-a`. + #[test] + fn rebinding_leader_moves_the_literal_ctrl_k_chord() { + let mut section = TuiSection::default(); + section.leader = Some("ctrl-b".into()); + let cfg = TuiConfig::load(§ion).unwrap(); + assert_eq!( + cfg.keymap.resolve(Mode::Insert, "ctrl-b ctrl-k"), + Some(Action::SendLiteralCtrlK), + ); + // The old leader's chord is gone along with its Insert escape. + assert_eq!(cfg.keymap.resolve(Mode::Insert, "ctrl-a ctrl-k"), None); + } + #[test] fn user_normal_override_replaces_default() { let mut section = TuiSection::default(); diff --git a/crates/maestro-tui/src/input.rs b/crates/maestro-tui/src/input.rs index 760ffdc..a167101 100644 --- a/crates/maestro-tui/src/input.rs +++ b/crates/maestro-tui/src/input.rs @@ -387,6 +387,9 @@ pub struct ChordState { held: Vec, last_press: Option, timeout: Duration, + /// A key bumped out of an abandoned chord (see [`Self::feed`]), waiting + /// to be replayed as a fresh keystroke by the caller. + replay: Option, } impl ChordState { @@ -395,6 +398,7 @@ impl ChordState { held: Vec::new(), last_press: None, timeout, + replay: None, } } @@ -404,6 +408,13 @@ impl ChordState { /// binding; wait for the next key. /// * `Resolved(s)` — `s` is the canonical chord (or single key) to look /// up in the keymap. The held buffer is cleared. + /// + /// A held prefix that is itself directly bound (e.g. a leader key bound + /// alone as well as chorded, like `ctrl-a` and `ctrl-a ctrl-k`) resolves + /// to that binding the moment the next key fails to extend it, rather + /// than swallowing the held key and losing its meaning — the new key + /// never belonged to the chord and is handed back via [`Self::take_replay`] + /// so the caller can process it as its own keystroke. pub fn feed(&mut self, key: &InputKey, known_bindings: &HashSet) -> ChordResult { if let Some(t) = self.last_press { if t.elapsed() > self.timeout { @@ -428,11 +439,31 @@ impl ChordState { if is_prefix { self.held.push(key_s); - ChordResult::Pending - } else { - self.held.clear(); - ChordResult::Resolved(candidate) + return ChordResult::Pending; } + + if known_bindings.contains(&candidate) { + self.held.clear(); + return ChordResult::Resolved(candidate); + } + + if !self.held.is_empty() { + let held_str = self.held.join(" "); + self.held.clear(); + if known_bindings.contains(&held_str) { + self.replay = Some(key.clone()); + return ChordResult::Resolved(held_str); + } + } + + ChordResult::Resolved(candidate) + } + + /// Take the key bumped out of an abandoned chord by the last [`Self::feed`] + /// call, if any. The caller feeds it through again as a brand new + /// keystroke. + pub fn take_replay(&mut self) -> Option { + self.replay.take() } } @@ -733,6 +764,41 @@ mod tests { assert_eq!(r, ChordResult::Resolved("g x".into())); } + /// A key bound both alone and as a chord prefix (the leader: `ctrl-a` and + /// `ctrl-a ctrl-k`) must still fire its own binding when the next key + /// doesn't complete the chord — the held key's meaning isn't lost, and + /// the new key comes back via `take_replay` for the caller to process on + /// its own (#156). + #[test] + fn a_bound_prefix_resolves_itself_and_replays_the_breaking_key() { + let mut chord = ChordState::new(Duration::from_secs(10)); + let known: HashSet = ["ctrl-a".into(), "ctrl-a ctrl-k".into()] + .into_iter() + .collect(); + + let r = chord.feed(&InputKey::ctrl_char('a'), &known); + assert_eq!(r, ChordResult::Pending); + + let r = chord.feed(&InputKey::char('j'), &known); + assert_eq!(r, ChordResult::Resolved("ctrl-a".into())); + assert_eq!(chord.take_replay(), Some(InputKey::char('j'))); + } + + /// The full chord still resolves normally when the next key completes + /// it — no replay, no early fire of the shorter binding. + #[test] + fn a_completed_chord_off_a_bound_prefix_has_no_replay() { + let mut chord = ChordState::new(Duration::from_secs(10)); + let known: HashSet = ["ctrl-a".into(), "ctrl-a ctrl-k".into()] + .into_iter() + .collect(); + + chord.feed(&InputKey::ctrl_char('a'), &known); + let r = chord.feed(&InputKey::ctrl_char('k'), &known); + assert_eq!(r, ChordResult::Resolved("ctrl-a ctrl-k".into())); + assert_eq!(chord.take_replay(), None); + } + #[test] fn chord_state_lone_key_resolves_immediately() { let mut chord = ChordState::new(Duration::from_secs(10));