diff --git a/web/src/components/commandBar/ActionPage.tsx b/web/src/components/commandBar/ActionPage.tsx new file mode 100644 index 0000000..a4675cc --- /dev/null +++ b/web/src/components/commandBar/ActionPage.tsx @@ -0,0 +1,34 @@ +// A session's action page (docs/COMMAND-BAR.md, "Drill-in"): the same overlay +// over kill, rename and spawn similar, still fuzzy searchable. The page names +// the session it acts on, so the target is never ambiguous. + +import { sessionName, type Session } from "../../types.js"; +import { ResultList } from "./ResultList.js"; +import type { BarGroup } from "./model.js"; + +export interface ActionPageProps { + session: Session; + /** The three actions, ranked by the query line — one kind, so no headers. */ + groups: BarGroup[]; + highlighted: number; + onActivate(index: number): void; +} + +export function ActionPage({ session, groups, highlighted, onActivate }: ActionPageProps) { + return ( + <> +
+ {`Actions for "${sessionName(session)}"`} +
+ + + ); +} diff --git a/web/src/components/commandBar/CommandBar.test.tsx b/web/src/components/commandBar/CommandBar.test.tsx new file mode 100644 index 0000000..bb666a4 --- /dev/null +++ b/web/src/components/commandBar/CommandBar.test.tsx @@ -0,0 +1,407 @@ +// @vitest-environment happy-dom +import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { + KILL_SESSION_EVENT, + RENAME_SESSION_EVENT, + SESSION_SELECT_EVENT, + SPAWN_SIMILAR_EVENT, +} from "../../catalog.js"; +import { COMMAND_SPECS, type Command } from "../../commands.js"; +import type { Session } from "../../types.js"; +import { CommandBar, SPAWN_DIRECTORY_EVENT, type CommandBarProps } from "./CommandBar.js"; + +vi.mock("../../api.js", async (importOriginal) => ({ + ...(await importOriginal>()), + listDir: vi.fn(async () => ({ path: "", entries: [] })), + projects: vi.fn(async () => []), + recentDirs: vi.fn(async () => ({ dirs: [] })), +})); + +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, + }; +} + +function command(over: Partial = {}): Command { + return { + id: "cmd", + label: "A command", + group: "Test", + keys: ["a"], + scope: "global", + run: () => {}, + ...over, + }; +} + +const sessions = [ + session("api", { current_cwd: "/home/me/dev/api", last_focused_at: "2026-01-01T11:00:00Z" }), + session("worker", { + current_cwd: "/home/me/dev/worker", + last_focused_at: "2026-01-01T10:00:00Z", + }), + session("notes", { current_cwd: "/home/me/notes" }), +]; + +function open(over: Partial = {}) { + return render( + {}} + onClose={() => {}} + {...over} + />, + ); +} + +function input(): HTMLInputElement { + return screen.getByRole("combobox") as HTMLInputElement; +} + +function type(value: string): void { + fireEvent.change(input(), { target: { value } }); +} + +function press(key: string, init: Record = {}): void { + fireEvent.keyDown(input(), { key, ...init }); +} + +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; +} + +afterEach(() => { + cleanup(); + vi.clearAllMocks(); +}); + +describe("the overlay", () => { + 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("ranks a typed query through the matcher and highlights the matched range", () => { + open(); + type("wor"); + expect(rowNames()).toEqual(["worker"]); + expect([...document.querySelectorAll(".cmdbar-row-name mark")].map((m) => m.textContent)).toEqual( + ["wor"], + ); + }); + + it("highlights inside the directory when that is the field that matched", () => { + open(); + 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(screen.getByText("No matches")).toBeDefined(); + }); + + 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(rowNames()[0]); + }); + + it("opens the highlighted session through the caller's open path on Enter", () => { + const onOpenSession = vi.fn(); + const onClose = vi.fn(); + open({ onOpenSession, onClose }); + press("ArrowDown"); + press("Enter"); + expect(onOpenSession).toHaveBeenCalledWith("worker"); + expect(onClose).toHaveBeenCalled(); + }); + + it("raises the shared select event when the caller supplies no handler", () => { + const listener = vi.fn(); + document.addEventListener(SESSION_SELECT_EVENT, listener); + open({ onOpenSession: undefined }); + press("Enter"); + document.removeEventListener(SESSION_SELECT_EVENT, listener); + expect((listener.mock.calls[0][0] as CustomEvent).detail).toBe("api"); + }); + + it("Enter on an empty result list does nothing", () => { + const onOpenSession = vi.fn(); + const onClose = vi.fn(); + open({ onOpenSession, onClose }); + type("zzz"); + press("Enter"); + expect(onOpenSession).not.toHaveBeenCalled(); + expect(onClose).not.toHaveBeenCalled(); + }); + + it("closes on Escape and on a press outside the panel", () => { + const onClose = vi.fn(); + open({ onClose }); + press("Escape"); + expect(onClose).toHaveBeenCalledTimes(1); + fireEvent.mouseDown(document.querySelector(".cmdbar-overlay") as HTMLElement); + expect(onClose).toHaveBeenCalledTimes(2); + }); + + it("stays open for a press inside the panel", () => { + const onClose = vi.fn(); + open({ onClose }); + fireEvent.mouseDown(document.querySelector(".cmdbar-panel") as HTMLElement); + expect(onClose).not.toHaveBeenCalled(); + }); + + it("returns the keyboard to whatever held it when it opened", () => { + const before = document.createElement("input"); + document.body.appendChild(before); + before.focus(); + const view = open(); + expect(document.activeElement).toBe(input()); + view.unmount(); + expect(document.activeElement).toBe(before); + before.remove(); + }); + + it("clicking a row opens that session", () => { + const onOpenSession = vi.fn(); + open({ onOpenSession }); + fireEvent.click(document.querySelectorAll(".cmdbar-row")[1]); + expect(onOpenSession).toHaveBeenCalledWith("worker"); + }); +}); + +describe("the sigils", () => { + const commands = [ + command({ id: "help.toggle", label: "Show help", keys: ["?"] }), + command({ id: "spawn.open", label: "Open the spawn form", keys: ["n"], run: vi.fn() }), + ]; + + it("lists every command in registry order under `>`, with its chord and no header", () => { + open({ commands }); + type(">"); + expect(rowNames()).toEqual(["Show help", "Open the spawn form"]); + expect(document.querySelectorAll(".cmdbar-group-title")).toHaveLength(0); + expect([...document.querySelectorAll(".cmdbar-row-keys kbd")].map((k) => k.textContent)).toEqual( + ["?", "n"], + ); + }); + + it("matches a command on its label and never on its chord", () => { + open({ commands }); + type(">help"); + expect(rowNames()).toEqual(["Show help"]); + type(">?"); + expect(rowNames()).toEqual([]); + }); + + it("runs the highlighted command on Enter", () => { + const run = vi.fn(); + open({ commands: [command({ label: "Run me", run })] }); + type(">run"); + press("Enter"); + expect(run).toHaveBeenCalled(); + }); + + it("keeps the list mixed with no sigil, and returns to it when the sigil goes", () => { + open({ commands }); + type(">show"); + expect(rowNames()).toEqual(["Show help"]); + type(""); + expect(rowNames()).toEqual(["api", "worker", "notes"]); + }); + + it("lists recent dirs then projects under `~`", async () => { + open({ loadDirectories: async () => ["/home/me/dev/api", "/srv/x"] }); + type("~"); + await waitFor(() => expect(rowNames()).toEqual(["~/dev/api", "/srv/x"])); + expect(document.querySelectorAll(".cmdbar-group-title")).toHaveLength(0); + }); + + it("fills the list in from /api/dirs as a path is typed", async () => { + const listDirEntries = vi.fn(async () => ["maestro", "api"]); + open({ loadDirectories: async () => ["/home/me/notes"], listDirEntries }); + // The second `~` is a literal query character and reaches the matcher. + type("~~/dev/mae"); + await waitFor(() => expect(rowNames()).toEqual(["~/dev/maestro"])); + expect(listDirEntries).toHaveBeenCalledWith("~/dev/"); + }); + + it("spawns in the highlighted directory on Enter", async () => { + const onSpawnDirectory = vi.fn(); + open({ loadDirectories: async () => ["/srv/x"], onSpawnDirectory }); + type("~"); + await waitFor(() => expect(rowNames()).toEqual(["/srv/x"])); + press("Enter"); + expect(onSpawnDirectory).toHaveBeenCalledWith("/srv/x"); + }); + + it("raises the spawn event when the caller supplies no handler", async () => { + const listener = vi.fn(); + document.addEventListener(SPAWN_DIRECTORY_EVENT, listener); + open({ loadDirectories: async () => ["/srv/x"] }); + type("~"); + await waitFor(() => expect(rowNames()).toEqual(["/srv/x"])); + press("Enter"); + document.removeEventListener(SPAWN_DIRECTORY_EVENT, listener); + expect((listener.mock.calls[0][0] as CustomEvent).detail).toBe("/srv/x"); + }); +}); + +describe("the session action page", () => { + function drill(over: Partial = {}) { + const view = open(over); + press("Tab"); + return view; + } + + it("lists kill, rename and spawn similar for the highlighted session", () => { + drill(); + expect(rowNames()).toEqual(["Kill session", "Rename session", "Spawn similar"]); + }); + + it("names the session being acted on", () => { + drill(); + expect(screen.getByText('Actions for "api"')).toBeDefined(); + }); + + it("drops the kind headers, since the page is one kind", () => { + drill(); + expect(document.querySelectorAll(".cmdbar-group-title")).toHaveLength(0); + }); + + it("stays fuzzy-searchable over the three actions", () => { + drill(); + type("ren"); + expect(rowNames()).toEqual(["Rename session"]); + }); + + it("does nothing on a command row", () => { + open({ commands: [command({ label: "Show help" })] }); + type(">show"); + press("Tab"); + expect(rowNames()).toEqual(["Show help"]); + }); + + it("does nothing on a directory row", async () => { + open({ loadDirectories: async () => ["/srv/x"] }); + type("~"); + await waitFor(() => expect(rowNames()).toEqual(["/srv/x"])); + press("Tab"); + expect(rowNames()).toEqual(["/srv/x"]); + }); + + it("returns to the result list on Escape, with the previous query intact", () => { + const onClose = vi.fn(); + open({ onClose }); + type("wor"); + press("Tab"); + expect(rowNames()).toEqual(["Kill session", "Rename session", "Spawn similar"]); + press("Escape"); + expect(onClose).not.toHaveBeenCalled(); + expect(input().value).toBe("wor"); + expect(rowNames()).toEqual(["worker"]); + }); + + it("returns to the result list on backspace at an empty query", () => { + drill(); + fireEvent.keyDown(input(), { key: "Backspace" }); + expect(rowNames()).toEqual(["api", "worker", "notes"]); + }); + + it("keeps backspace ordinary while the page's query has text", () => { + drill(); + type("ren"); + fireEvent.keyDown(input(), { key: "Backspace" }); + expect(rowNames()).toEqual(["Rename session"]); + }); + + it("closes the bar when the page's Escape has already been used", () => { + const onClose = vi.fn(); + drill({ onClose }); + press("Escape"); + press("Escape"); + expect(onClose).toHaveBeenCalledTimes(1); + }); + + it("raises kill, rename and spawn similar with the session, closing the bar", () => { + for (const [query, key] of [ + ["kill", "onKillSession"], + ["rename", "onRenameSession"], + ["spawn", "onSpawnSimilar"], + ] as const) { + const handler = vi.fn(); + const onClose = vi.fn(); + drill({ [key]: handler, onClose }); + type(query); + press("Enter"); + expect(handler).toHaveBeenCalledWith(expect.objectContaining({ id: "api" })); + expect(onClose).toHaveBeenCalled(); + cleanup(); + } + }); + + it("hands each action to the flow it already has, by default", () => { + for (const [query, event] of [ + ["kill", KILL_SESSION_EVENT], + ["rename", RENAME_SESSION_EVENT], + ["spawn", SPAWN_SIMILAR_EVENT], + ] as const) { + const listener = vi.fn(); + document.addEventListener(event, listener); + drill(); + type(query); + press("Enter"); + document.removeEventListener(event, listener); + expect((listener.mock.calls[0][0] as CustomEvent).detail.id).toBe("api"); + cleanup(); + } + }); +}); diff --git a/web/src/components/commandBar/CommandBar.tsx b/web/src/components/commandBar/CommandBar.tsx new file mode 100644 index 0000000..9f9cfb1 --- /dev/null +++ b/web/src/components/commandBar/CommandBar.tsx @@ -0,0 +1,379 @@ +// The `ctrl-k` command bar (docs/COMMAND-BAR.md): a query line over a result +// list. Sessions and commands rank together by default; `>` narrows to +// commands, `~` to directories. `tab` on a session drills into its action page. +// +// The bar picks an action and hands off — it never reimplements one. Each +// handler defaults to the event the flow that already implements it listens +// on, so the React bar and the legacy shell raise the same flows. + +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { createPortal } from "react-dom"; + +import { + KILL_SESSION_EVENT, + RENAME_SESSION_EVENT, + SESSION_SELECT_EVENT, + SPAWN_SIMILAR_EVENT, +} from "../../catalog.js"; +import { publishedCommands, type Command } from "../../commands.js"; +import { sessionName, type Session } from "../../types.js"; +import { ActionPage } from "./ActionPage.js"; +import { ResultList } from "./ResultList.js"; +import { + barGroups, + barRows, + commandBarCandidates, + dirEntryPaths, + directoryBarCandidates, + isActionCandidate, + listDirEntries as defaultListDirEntries, + loadBarDirectories, + parseBarQuery, + probeDir, + sessionActionCandidates, + sessionBarCandidates, + PLACEHOLDERS, + type BarCandidate, + type BarResult, + type DirectoryBarCandidate, + type SessionAction, +} from "./model.js"; + +/** Raised when Enter lands on a directory row and no handler was given. 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"; + +export interface CommandBarProps { + /** 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; + /** 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; + onOpenSession?(sessionId: string): void; + onSpawnDirectory?(path: string): void; + onKillSession?(session: Session): void; + onRenameSession?(session: Session): void; + onSpawnSimilar?(session: Session): void; + /** Closing returns the keyboard to whatever held it when the bar opened. */ + onClose(): void; +} + +function raise(name: string, detail: unknown): void { + document.dispatchEvent(new CustomEvent(name, { detail })); +} + +export function CommandBar(props: CommandBarProps) { + const { + sessions, + homeDir, + commands, + loadDirectories = loadBarDirectories, + listDirEntries = defaultListDirEntries, + onClose, + } = props; + + const inputRef = useRef(null); + const panelRef = useRef(null); + + const [raw, setRaw] = useState(""); + const [highlighted, setHighlighted] = useState(0); + // The action page: `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. + const [page, setPage] = useState(null); + const [stashed, setStashed] = useState(""); + + // `~`'s candidates: the recents-and-projects list, fetched once, plus + // whatever `/api/dirs` has filled in for the directory being typed. + const [directories, setDirectories] = useState([]); + const [probed, setProbed] = useState([]); + const requestedRef = useRef(false); + const listingsRef = useRef(new Map()); + + // 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 = useMemo(() => (page ? { sigil: null, query: raw } : parseBarQuery(raw)), [page, raw]); + + const sessionCandidates = useMemo( + () => sessionBarCandidates(sessions, homeDir), + [sessions, homeDir], + ); + const commandCandidates = useMemo( + () => commandBarCandidates(commands ?? publishedCommands()), + [commands], + ); + + // Closing returns the keyboard to whatever held it when the bar opened: a + // round trip through the bar never silently moves focus. + useEffect(() => { + const previouslyFocused = document.activeElement as HTMLElement | null; + inputRef.current?.focus(); + return () => { + if (previouslyFocused?.isConnected) previouslyFocused.focus(); + }; + }, []); + + // The recents-and-projects list, the first time `~` is used. No directories + // is a shorter list, not an error state. + useEffect(() => { + if (parsed.sigil !== "~" || requestedRef.current) return; + requestedRef.current = true; + let live = true; + loadDirectories() + .then((paths) => { + if (live) setDirectories(directoryBarCandidates(paths, homeDir)); + }) + .catch(() => {}); + return () => { + live = false; + }; + }, [parsed.sigil, loadDirectories, homeDir]); + + // 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. + const dir = parsed.sigil === "~" ? probeDir(parsed.query) : ""; + useEffect(() => { + if (dir === "") { + setProbed([]); + return; + } + const cached = listingsRef.current.get(dir); + if (cached) { + setProbed(directoryBarCandidates(dirEntryPaths(dir, cached), homeDir)); + return; + } + let live = true; + setProbed([]); + listDirEntries(dir) + .then((entries) => { + listingsRef.current.set(dir, entries); + if (live) setProbed(directoryBarCandidates(dirEntryPaths(dir, entries), homeDir)); + }) + .catch(() => { + /* an unreadable directory contributes nothing */ + }); + return () => { + live = false; + }; + }, [dir, listDirEntries, homeDir]); + + /** 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. */ + const candidates: BarCandidate[] = useMemo(() => { + if (page) return sessionActionCandidates(); + switch (parsed.sigil) { + case ">": + return [...commandCandidates]; + case "~": { + 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]; + } + }, [page, parsed.sigil, parsed.query, sessionCandidates, commandCandidates, directories, probed]); + + const groups = useMemo( + () => barGroups(barRows(parsed.query, candidates)), + [parsed.query, candidates], + ); + // Grouping is a rendering of the ranking, so the flattened groups are the + // rows in the order they appear — which is the order the highlight walks and + // Enter acts on. + const rows: BarResult[] = useMemo(() => groups.flatMap((g) => g.results), [groups]); + + // The highlight starts on the globally best row every time the list changes: + // an old index would point at a different result. + useEffect(() => { + setHighlighted(0); + }, [rows]); + + /** The highlight does not wrap: it stops at the first and last row. */ + const move = useCallback( + (delta: number) => { + setHighlighted((at) => Math.min(rows.length - 1, Math.max(0, at + delta))); + }, + [rows.length], + ); + + const runAction = useCallback( + (action: SessionAction, session: Session) => { + onClose(); + switch (action.id) { + case "kill": + (props.onKillSession ?? ((s: Session) => raise(KILL_SESSION_EVENT, s)))(session); + return; + case "rename": + (props.onRenameSession ?? ((s: Session) => raise(RENAME_SESSION_EVENT, s)))(session); + return; + case "spawn-similar": + (props.onSpawnSimilar ?? ((s: Session) => raise(SPAWN_SIMILAR_EVENT, s)))(session); + return; + } + }, + [onClose, props.onKillSession, props.onRenameSession, props.onSpawnSimilar], + ); + + const activate = useCallback( + (index: number) => { + 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; + } + onClose(); + switch (candidate.kind) { + case "session": + (props.onOpenSession ?? ((id: string) => raise(SESSION_SELECT_EVENT, id)))( + candidate.session.id, + ); + return; + case "command": + candidate.command.run(); + return; + case "directory": + (props.onSpawnDirectory ?? ((p: string) => raise(SPAWN_DIRECTORY_EVENT, p)))( + candidate.path, + ); + return; + } + }, + [rows, page, runAction, onClose, props.onOpenSession, props.onSpawnDirectory], + ); + + /** `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. */ + const drillIn = useCallback(() => { + const candidate = rows[highlighted]?.candidate; + if (page || candidate?.kind !== "session") return; + setPage(candidate.session); + setStashed(raw); + setRaw(""); + }, [rows, highlighted, page, raw]); + + /** Escape, or backspace at an empty query: back to the result list with the + * query the drill-in was made from. */ + const leavePage = useCallback(() => { + setPage(null); + setRaw(stashed); + setStashed(""); + }, [stashed]); + + const onKeyDown = (e: React.KeyboardEvent) => { + switch (e.key) { + case "Escape": + e.preventDefault(); + // On an action page Escape is one level up, not the way out. + if (page) leavePage(); + else onClose(); + return; + case "Tab": + e.preventDefault(); + drillIn(); + return; + case "Backspace": + // Ordinary editing, except at an empty query on the action page. + if (page && raw === "") { + e.preventDefault(); + leavePage(); + } + 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; + } + }; + + const activeRowId = rows.length > 0 ? `cmdbar-row-${highlighted}` : undefined; + + return createPortal( +
{ + // A press on the scrim closes; one inside the panel is left alone. + if (!panelRef.current?.contains(e.target as Node)) onClose(); + }} + > +
+ setRaw(e.currentTarget.value)} + onKeyDown={onKeyDown} + /> + {page ? ( + + ) : ( + + )} +
+
, + document.body, + ); +} diff --git a/web/src/components/commandBar/ResultList.tsx b/web/src/components/commandBar/ResultList.tsx new file mode 100644 index 0000000..ed3a64e --- /dev/null +++ b/web/src/components/commandBar/ResultList.tsx @@ -0,0 +1,171 @@ +// The bar's result list: kind headers over rank-ordered rows +// (docs/COMMAND-BAR.md, "Structure"). Headers are labels, never rows — the +// highlight walks the rows alone, in the order they are rendered. + +import { useEffect, useRef } from "react"; + +import { keyCaps } from "../../commands.js"; +import { + highlightSegments, + isActionCandidate, + metaText, + rowName, + type BarGroup, + type BarResult, +} from "./model.js"; + +export interface ResultListProps { + groups: BarGroup[]; + /** Dropped while a sigil is in force or on the action page: one kind needs + * no header. */ + showHeaders: boolean; + /** Index into the flattened rows, in the order they render. */ + highlighted: number; + onActivate(index: number): void; +} + +function Highlighted({ text, ranges }: { text: string; ranges: BarResult["ranges"] }) { + return ( + <> + {highlightSegments(text, ranges).map((segment, i) => + segment.hit ? ( + + {segment.text} + + ) : ( + {segment.text} + ), + )} + + ); +} + +function Row({ + result, + index, + active, + onActivate, +}: { + result: BarResult; + index: number; + active: boolean; + onActivate(index: number): void; +}) { + const ref = useRef(null); + const candidate = result.candidate; + + useEffect(() => { + if (active) ref.current?.scrollIntoView({ block: "nearest" }); + }, [active]); + + const name = rowName(candidate); + const nameMatched = + result.field === "name" || result.field === "label" || result.field === "path"; + const meta = metaText(result); + const dead = candidate.kind === "session" && candidate.session.status === "Dead"; + const chord = + candidate.kind === "command" && !isActionCandidate(candidate) + ? candidate.command.keys[0] + : undefined; + + return ( +
onActivate(index)} + > + + {nameMatched ? : name} + + {chord !== undefined ? ( + + {keyCaps(chord).map((cap, i) => ( + + {cap} + + ))} + + ) : meta ? ( + + {result.field !== undefined && result.field !== "name" ? ( + + ) : ( + meta + )} + + ) : null} +
+ ); +} + +export function ResultList({ groups, showHeaders, highlighted, onActivate }: ResultListProps) { + if (groups.length === 0) { + return ( +
+
No matches
+
+ ); + } + + let index = 0; + return ( +
+ {groups.map((group) => ( +
+ {showHeaders ? ( +
+ {group.title} +
+ ) : null} + {group.results.map((result) => { + const at = index++; + return ( + + ); + })} +
+ ))} +
+ ); +} diff --git a/web/src/components/commandBar/index.ts b/web/src/components/commandBar/index.ts new file mode 100644 index 0000000..5d53bb6 --- /dev/null +++ b/web/src/components/commandBar/index.ts @@ -0,0 +1,5 @@ +export { CommandBar, SPAWN_DIRECTORY_EVENT, type CommandBarProps } from "./CommandBar.js"; +export { ResultList, type ResultListProps } from "./ResultList.js"; +export { ActionPage, type ActionPageProps } from "./ActionPage.js"; +export { useCommandBar, type CommandBarControls, type UseCommandBarOptions } from "./useCommandBar.js"; +export * from "./model.js"; diff --git a/web/src/components/commandBar/model.test.ts b/web/src/components/commandBar/model.test.ts new file mode 100644 index 0000000..cbd63ac --- /dev/null +++ b/web/src/components/commandBar/model.test.ts @@ -0,0 +1,197 @@ +import { describe, expect, it } from "vitest"; + +import type { Command } from "../../commands.js"; +import type { Session } from "../../types.js"; +import { + barGroups, + barRows, + commandBarCandidates, + dedupeCommands, + directoryBarCandidates, + directoryCandidate, + highlightSegments, + orderSessionsForBar, + parseBarQuery, + sessionActionCandidates, + sessionBarCandidates, + sessionCandidate, +} from "./model.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, + }; +} + +function command(over: Partial = {}): Command { + return { + id: "a", + label: "A command", + group: "Test", + keys: ["a"], + scope: "global", + run: () => {}, + ...over, + }; +} + +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: "2026-01-01T09:00:00Z" }); + const old = session("old", { created_at: "2026-01-01T00:00:00Z" }); + const fresh = session("fresh", { created_at: "2026-01-02T00:00:00Z" }); + expect(orderSessionsForBar([old, fresh, focused]).map((s) => s.id)).toEqual([ + "focused", + "fresh", + "old", + ]); + }); + + it("sorts dead sessions after live ones regardless of recency", () => { + const dead = session("dead", { status: "Dead", last_focused_at: "2026-01-01T12:00:00Z" }); + const live = session("live", { last_focused_at: "2026-01-01T10:00:00Z" }); + expect(orderSessionsForBar([dead, live]).map((s) => s.id)).toEqual(["live", "dead"]); + }); + + it("treats an empty last_focused_at as never focused", () => { + const blank = session("blank", { last_focused_at: "" }); + const focused = session("focused", { last_focused_at: "2026-01-01T10:00:00Z" }); + expect(orderSessionsForBar([blank, focused]).map((s) => s.id)).toEqual(["focused", "blank"]); + }); +}); + +describe("session candidates", () => { + it("carries the candidate table's fields, with the directory abbreviated", () => { + const s = session("api", { + metadata: { name: "api" }, + current_cwd: "/home/me/dev/api", + command: "claude --resume", + }); + expect(sessionCandidate(s, "/home/me").fields).toEqual({ + 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 candidate in input order", () => { + const candidates = sessionBarCandidates([session("a"), session("b")]); + expect(barRows("", candidates).map((r) => r.score)).toEqual([0, 0]); + }); + + it("groups by kind without reordering the ranking", () => { + const rows = barRows("kill", [ + ...sessionBarCandidates([session("killer-app", { metadata: { name: "killer-app" } })]), + ...commandBarCandidates([command({ label: "Kill selected session" })]), + ]); + const groups = barGroups(rows); + expect(groups.map((g) => g.title)).toEqual(["Sessions", "Commands"]); + expect(groups.flatMap((g) => g.results)).toEqual(rows); + }); +}); + +describe("parsing the query line", () => { + it("reads a sigil only in first position", () => { + expect(parseBarQuery(">help")).toEqual({ sigil: ">", query: "help" }); + expect(parseBarQuery("~dev")).toEqual({ sigil: "~", query: "dev" }); + }); + + it("leaves a sigil character elsewhere in the query literal", () => { + expect(parseBarQuery("~dev/~scratch")).toEqual({ sigil: "~", query: "dev/~scratch" }); + expect(parseBarQuery("a>b")).toEqual({ sigil: null, query: "a>b" }); + }); + + it("returns to the mixed list when the sigil is backspaced away", () => { + expect(parseBarQuery("")).toEqual({ sigil: null, query: "" }); + }); +}); + +describe("command candidates", () => { + it("dedupes by action, not by chord", () => { + const run = () => {}; + const cmds = [ + command({ id: "one", label: "Do it", keys: ["j"], run }), + command({ id: "two", label: "Do it", keys: ["down"], run }), + ]; + expect(commandBarCandidates(cmds)).toHaveLength(1); + expect(dedupeCommands(cmds)).toHaveLength(1); + }); + + it("matches on the label only, never on the chord", () => { + const candidates = commandBarCandidates([command({ label: "Show help", keys: ["ctrl-q"] })]); + expect(barRows("ctrlq", candidates)).toEqual([]); + expect(barRows("show", candidates)).toHaveLength(1); + }); +}); + +describe("directory candidates", () => { + it("abbreviates the path it shows and keeps the one it spawns in", () => { + const candidate = directoryCandidate("/home/me/dev/api", "/home/me"); + expect(candidate.fields.path).toBe("~/dev/api"); + expect(candidate.path).toBe("/home/me/dev/api"); + }); + + it("expands a typed `~` and dedupes by the directory spawned in", () => { + const candidates = directoryBarCandidates( + ["~/dev/api", "/home/me/dev/api", "", "/srv/x"], + "/home/me", + ); + expect(candidates.map((c) => c.path)).toEqual(["/home/me/dev/api", "/srv/x"]); + }); +}); + +describe("the action page's candidates", () => { + it("offers no action beyond the three", () => { + expect(sessionActionCandidates().map((c) => c.action.id)).toEqual([ + "kill", + "rename", + "spawn-similar", + ]); + }); +}); + +describe("highlight segments", () => { + it("splits the text on the matcher's ranges", () => { + expect(highlightSegments("web-search", [[0, 1], [4, 5]])).toEqual([ + { text: "w", hit: true }, + { text: "eb-", hit: false }, + { text: "s", hit: true }, + { text: "earch", hit: false }, + ]); + }); +}); diff --git a/web/src/components/commandBar/model.ts b/web/src/components/commandBar/model.ts new file mode 100644 index 0000000..04f4e26 --- /dev/null +++ b/web/src/components/commandBar/model.ts @@ -0,0 +1,366 @@ +// The command bar's pure model (docs/COMMAND-BAR.md), lifted from +// ../../commandBar.ts for the React port (#247): what a candidate is, the +// order an empty query lists them in, how a query line splits into a sigil and +// a query, and how a ranked list groups for rendering. +// +// Nothing here touches the DOM or the network apart from `loadBarDirectories`, +// which is the one place the `~` list comes from. + +import { listDir, projects as fetchProjects, recentDirs } from "../../api.js"; +import type { Command } from "../../commands.js"; +import { splitPath } from "../../paths.js"; +import { splitCommand } from "../../toolIcon.js"; +import { abbreviateCwd, sessionName, sessionTreePath, type Session } from "../../types.js"; +import { + matchAndRank, + type CandidateKind, + type CommandCandidate, + type DirectoryCandidate, + type MatchRange, + type MatchResult, + type SessionCandidate, +} from "../../utils/matcher.js"; + +/** 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 }; + +/** 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; + +/** One kind's slice of the ranked list, in rank order. */ +export interface BarGroup { + kind: CandidateKind; + title: string; + results: MatchResult[]; +} + +const KIND_TITLES: Record = { + session: "Sessions", + command: "Commands", + 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; +} + +export const PLACEHOLDERS: Record<"mixed" | BarSigil, string> = { + mixed: "Go to a session…", + ">": "Run a command…", + "~": "Spawn in a directory…", +}; + +/** 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 }; +} + +// ---- 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 { + 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)); +} + +/** 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]; +} + +/** One directory's children as candidate paths, for filling the `~` list in as + * a path is typed. The directory being listed comes from the query itself. */ +export function dirEntryPaths(dir: string, entries: readonly string[]): string[] { + return entries.map((entry) => `${dir}${entry}`); +} + +/** The directory a `~` query names, or `""` when it names none yet. */ +export function probeDir(query: string): string { + return splitPath(query).dir; +} + +/** `GET /api/dirs`, in the shape the bar wants it. */ +export function listDirEntries(dir: string): Promise { + return listDir(dir).then((listing) => listing.entries); +} + +// ---- 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 MatchResult[], +): 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 C[], +): MatchResult[] { + return matchAndRank(query, candidates); +} + +// ---- row text ---- + +/** The name line of a row: the session's name, the command's label, or the + * directory's path. */ +export 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. */ +export function metaText(result: BarResult): string | undefined { + if (result.candidate.kind !== "session") return 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; + } +} + +/** One run of a row's text, flagged when the matcher matched inside it. The + * React rendering of `renderHighlighted`: ranges come from the matcher as + * `[start, end)` character pairs over the field that won. */ +export interface HighlightSegment { + text: string; + hit: boolean; +} + +export function highlightSegments( + text: string, + ranges: readonly MatchRange[], +): HighlightSegment[] { + const out: HighlightSegment[] = []; + let cursor = 0; + for (const [start, end] of ranges) { + if (start > cursor) out.push({ text: text.slice(cursor, start), hit: false }); + out.push({ text: text.slice(start, end), hit: true }); + cursor = end; + } + if (cursor < text.length) out.push({ text: text.slice(cursor), hit: false }); + return out; +} + +export { sessionName }; +export type { CandidateKind, MatchRange, MatchResult }; diff --git a/web/src/components/commandBar/useCommandBar.test.tsx b/web/src/components/commandBar/useCommandBar.test.tsx new file mode 100644 index 0000000..1f7ca11 --- /dev/null +++ b/web/src/components/commandBar/useCommandBar.test.tsx @@ -0,0 +1,76 @@ +// @vitest-environment happy-dom +import { act, cleanup, render } from "@testing-library/react"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { OPEN_COMMAND_BAR_EVENT } from "../../catalog.js"; +import { useCommandBar, type UseCommandBarOptions } from "./useCommandBar.js"; + +let controls: ReturnType; + +function Probe(opts: UseCommandBarOptions) { + controls = useCommandBar(opts); + return
{controls.open ? "open" : "closed"}
; +} + +function ctrlK(target: EventTarget = window): void { + act(() => { + target.dispatchEvent( + new KeyboardEvent("keydown", { key: "k", ctrlKey: true, bubbles: true, cancelable: true }), + ); + }); +} + +afterEach(cleanup); + +describe("useCommandBar", () => { + it("opens on ctrl-k, from the capture phase so an attached xterm never sees it", () => { + const view = render(); + ctrlK(); + expect(view.container.textContent).toBe("open"); + }); + + it("stays deaf while the bar is already up — its own input owns the keys", () => { + const onOpen = vi.fn(); + render(); + ctrlK(); + ctrlK(); + expect(onOpen).toHaveBeenCalledTimes(1); + }); + + it("leaves a form field's own Ctrl-K alone", () => { + const field = document.createElement("input"); + document.body.appendChild(field); + const view = render(); + ctrlK(field); + expect(view.container.textContent).toBe("closed"); + field.remove(); + }); + + it("opens from the sidebar's search field, the touch entry point", () => { + const view = render(); + act(() => { + document.dispatchEvent(new CustomEvent(OPEN_COMMAND_BAR_EVENT)); + }); + expect(view.container.textContent).toBe("open"); + }); + + it("runs the caller's hook before opening, so a preview is discarded first", () => { + const onOpen = vi.fn(); + render(); + ctrlK(); + expect(onOpen).toHaveBeenCalledTimes(1); + }); + + it("closes on demand", () => { + const view = render(); + ctrlK(); + act(() => controls.closeBar()); + expect(view.container.textContent).toBe("closed"); + }); + + it("binds nothing while disabled — another overlay owns the keyboard", () => { + const view = render(); + ctrlK(); + expect(view.container.textContent).toBe("closed"); + }); +}); diff --git a/web/src/components/commandBar/useCommandBar.ts b/web/src/components/commandBar/useCommandBar.ts new file mode 100644 index 0000000..5deb772 --- /dev/null +++ b/web/src/components/commandBar/useCommandBar.ts @@ -0,0 +1,65 @@ +// Opening and closing the bar (docs/COMMAND-BAR.md, "Opening"). `ctrl-k` is a +// registry command like any other, so the `?` overlay lists it without a +// second list; the touch entry point — the sidebar's search field — raises +// `OPEN_COMMAND_BAR_EVENT` and lands on the same open. + +import { useCallback, useEffect, useState } from "react"; + +import { OPEN_COMMAND_BAR_EVENT } from "../../catalog.js"; +import { CommandSet, defineCommands, isBarTypingTarget } from "../../commands.js"; + +export interface UseCommandBarOptions { + /** Run before the bar opens — the shell discards a preview here, since the + * bar is not previewed into (docs/PREVIEW.md). */ + onOpen?(): void; + /** Off while another overlay owns the keyboard (the restart overlay, the + * help dialog). Defaults to on. */ + enabled?: boolean; +} + +export interface CommandBarControls { + open: boolean; + openBar(): void; + closeBar(): void; +} + +export function useCommandBar(opts: UseCommandBarOptions = {}): CommandBarControls { + const { onOpen, enabled = true } = opts; + const [open, setOpen] = useState(false); + + const openBar = useCallback(() => { + onOpen?.(); + setOpen(true); + }, [onOpen]); + + const closeBar = useCallback(() => setOpen(false), []); + + useEffect(() => { + if (!enabled) return; + const keys = new CommandSet(defineCommands({ "bar.open": openBar })); + + // Capture phase: an attached xterm swallows every keystroke it is given, so + // the bar's binding has to be seen ahead of the terminal. + const onKeydown = (e: KeyboardEvent) => { + // The bar owns the keyboard while it is up; its own input handles the keys. + if (open) 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. A real form field — the spawn + // form's inputs — keeps its own Ctrl+K. + if (isBarTypingTarget(e.target)) return; + keys.handle(e); + }; + const onTouchEntry = () => { + if (!open) openBar(); + }; + + window.addEventListener("keydown", onKeydown, true); + document.addEventListener(OPEN_COMMAND_BAR_EVENT, onTouchEntry); + return () => { + window.removeEventListener("keydown", onKeydown, true); + document.removeEventListener(OPEN_COMMAND_BAR_EVENT, onTouchEntry); + }; + }, [enabled, open, openBar]); + + return { open, openBar, closeBar }; +} diff --git a/web/src/matcher.test.ts b/web/src/matcher.test.ts deleted file mode 100644 index 094c089..0000000 --- a/web/src/matcher.test.ts +++ /dev/null @@ -1,201 +0,0 @@ -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 deleted file mode 100644 index 00adb5c..0000000 --- a/web/src/matcher.ts +++ /dev/null @@ -1,224 +0,0 @@ -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 })); -}