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();
+ }}
+ >
+
+ );
+}
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