// @vitest-environment happy-dom 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 { barGroups, barRows, closeCommandBar, commandBarCandidates, dedupeCommands, directoryBarCandidates, directoryCandidate, isCommandBarOpen, openCommandBar, orderSessionsForBar, parseBarQuery, sessionBarCandidates, sessionActionCandidates, sessionCandidate, SPAWN_DIRECTORY_EVENT, } from "./commandBar.js"; import { COMMAND_SPECS, type Command } from "./commands.js"; import type { Session } from "./types.js"; function session(id: string, over: Partial = {}): Session { return { id, cwd: "/home/me/dev/api", command: "bash", env: {}, metadata: { name: id }, status: "Running", created_at: "2026-01-01T00:00:00Z", ...over, }; } afterEach(() => { closeCommandBar(); document.body.replaceChildren(); }); function press(key: string, init: KeyboardEventInit = {}): void { const input = document.querySelector(".cmdbar-input") as HTMLInputElement; input.dispatchEvent(new KeyboardEvent("keydown", { key, bubbles: true, ...init })); } function type(value: string): void { const input = document.querySelector(".cmdbar-input") as HTMLInputElement; input.value = value; input.dispatchEvent(new Event("input", { bubbles: true })); } function rowNames(): string[] { return [...document.querySelectorAll(".cmdbar-row-name")].map((r) => r.textContent ?? ""); } function activeName(): string | undefined { return document.querySelector(".cmdbar-row.is-active .cmdbar-row-name")?.textContent ?? undefined; } describe("empty-query ordering", () => { it("sorts most recently focused first", () => { const older = session("older", { last_focused_at: "2026-01-01T10:00:00Z" }); const newer = session("newer", { last_focused_at: "2026-01-01T11:00:00Z" }); expect(orderSessionsForBar([older, newer]).map((s) => s.id)).toEqual(["newer", "older"]); }); it("sorts a never-focused session after every focused one, newest created first", () => { const focused = session("focused", { last_focused_at: "2020-01-01T00:00:00Z", created_at: "2020-01-01T00:00:00Z", }); const oldFresh = session("old-fresh", { created_at: "2026-01-01T00:00:00Z" }); const newFresh = session("new-fresh", { created_at: "2026-06-01T00:00:00Z" }); expect(orderSessionsForBar([oldFresh, newFresh, focused]).map((s) => s.id)).toEqual([ "focused", "new-fresh", "old-fresh", ]); }); it("sorts dead sessions after live ones regardless of recency", () => { const deadRecent = session("dead", { status: "Dead", last_focused_at: "2026-06-01T00:00:00Z", }); const liveStale = session("live", { last_focused_at: "2020-01-01T00:00:00Z" }); const liveNever = session("never"); expect(orderSessionsForBar([deadRecent, liveNever, liveStale]).map((s) => s.id)).toEqual([ "live", "never", "dead", ]); }); it("treats a null last_focused_at as never focused", () => { const nulled = session("nulled", { last_focused_at: null }); const stamped = session("stamped", { last_focused_at: "2020-01-01T00:00:00Z" }); expect(orderSessionsForBar([nulled, stamped]).map((s) => s.id)).toEqual(["stamped", "nulled"]); }); }); describe("session candidates", () => { it("carries the candidate table's fields, with the directory abbreviated", () => { const s = session("api", { metadata: { name: "api" }, command: "claude --resume", current_cwd: "/home/me/dev/api", }); expect(sessionCandidate(s, "/home/me")).toEqual({ kind: "session", session: s, fields: { name: "api", directory: "~/dev/api", command: "claude --resume", tool: "claude", }, }); }); it("leaves the tool out when the program has no mark, and groups a worktree by its repo", () => { const s = session("edit", { metadata: {}, command: "hx src/", worktree: { path: "/home/me/dev/api/wt/x", base_repo: "/home/me/dev/api" }, }); const candidate = sessionCandidate(s, "/home/me"); expect(candidate.fields.tool).toBeUndefined(); expect(candidate.fields.directory).toBe("~/dev/api"); // No `metadata.name`: the sidebar's fallback is the command line. expect(candidate.fields.name).toBe("hx src/"); }); it("builds candidates in the empty-query order", () => { const a = session("a", { last_focused_at: "2026-01-01T10:00:00Z" }); const b = session("b", { last_focused_at: "2026-01-01T11:00:00Z" }); expect(sessionBarCandidates([a, b]).map((c) => c.session.id)).toEqual(["b", "a"]); }); }); describe("rows and grouping", () => { it("an empty query keeps every session in input order", () => { const candidates = sessionBarCandidates([session("a"), session("b")]); expect(barRows("", candidates).map((r) => r.candidate.session.id)).toEqual(["a", "b"]); }); it("groups by kind without reordering the ranking", () => { const candidates = sessionBarCandidates([ session("worker", { metadata: { name: "worker" }, current_cwd: "/home/me/dev/api" }), session("api", { metadata: { name: "api" }, current_cwd: "/home/me/dev/web" }), ]); const rows = barRows("api", candidates); const groups = barGroups(rows); expect(groups).toHaveLength(1); expect(groups[0].title).toBe("Sessions"); expect(groups[0].results.map((r) => r.candidate.session.id)).toEqual( rows.map((r) => r.candidate.session.id), ); // F6: the name beats the directory, and the directory match highlights // inside the directory. expect(groups[0].results[0].candidate.session.id).toBe("api"); expect(groups[0].results[1].field).toBe("directory"); }); }); describe("the overlay", () => { const sessions = [ session("api", { metadata: { name: "api" }, current_cwd: "/home/me/dev/api", last_focused_at: "2026-01-01T11:00:00Z", }), session("worker", { metadata: { name: "worker" }, current_cwd: "/home/me/dev/worker", last_focused_at: "2026-01-01T10:00:00Z", }), session("notes", { metadata: { name: "notes" }, current_cwd: "/home/me/notes" }), ]; // Sessions only: the mixed list's command half is the sigil describe's // business, and the registry is whatever another test file happened to bind. function open(over: Parameters[0] | null = null) { return openCommandBar( over ?? { sessions, homeDir: "/home/me", commands: [], onOpenSession: () => {} }, ); } it("is a registry command, so the help overlay lists it", () => { const spec = COMMAND_SPECS.find((s) => s.id === "bar.open"); expect(spec?.keys).toEqual(["ctrl-k"]); expect(spec?.scope).toBe("global"); }); it("lists sessions by recency under a kind header on an empty query", () => { open(); expect([...document.querySelectorAll(".cmdbar-group-title")].map((h) => h.textContent)).toEqual([ "Sessions", ]); expect(rowNames()).toEqual(["api", "worker", "notes"]); expect(activeName()).toBe("api"); }); it("opening twice leaves one overlay", () => { open(); open(); expect(document.querySelectorAll(".cmdbar-overlay")).toHaveLength(1); expect(isCommandBarOpen()).toBe(true); }); it("ranks a typed query through the matcher and highlights the matched range", () => { open(); type("wor"); expect(rowNames()).toEqual(["worker"]); const marks = [...document.querySelectorAll(".cmdbar-row-name mark")].map((m) => m.textContent); expect(marks).toEqual(["wor"]); }); it("highlights inside the directory when that is the field that matched", () => { open(); type("notes"); const row = document.querySelector(".cmdbar-row") as HTMLElement; expect(row.dataset.sessionId).toBe("notes"); type("dev/wor"); expect(rowNames()).toEqual(["worker"]); expect( [...document.querySelectorAll(".cmdbar-row-meta mark")].map((m) => m.textContent).join(""), ).toBe("dev/wor"); }); it("renders a no-results state rather than an empty list", () => { open(); type("zzz"); expect(rowNames()).toEqual([]); expect(document.querySelector(".cmdbar-empty")?.textContent).toBe("No matches"); }); it("moves the highlight with the arrows and ctrl-p/ctrl-n, without wrapping", () => { open(); press("ArrowUp"); expect(activeName()).toBe("api"); press("ArrowDown"); expect(activeName()).toBe("worker"); press("n", { ctrlKey: true }); expect(activeName()).toBe("notes"); press("ArrowDown"); expect(activeName()).toBe("notes"); press("p", { ctrlKey: true }); expect(activeName()).toBe("worker"); }); it("puts the highlight back on the best row whenever the query changes", () => { open(); press("ArrowDown"); expect(activeName()).toBe("worker"); type("o"); expect(activeName()).toBe(document.querySelector(".cmdbar-row-name")?.textContent); }); it("opens the highlighted session through the shared open path on Enter", () => { const seen: string[] = []; const listener = (e: Event) => seen.push((e as CustomEvent).detail); document.addEventListener(SESSION_SELECT_EVENT, listener); openCommandBar({ sessions, homeDir: "/home/me", commands: [] }); press("ArrowDown"); press("Enter"); document.removeEventListener(SESSION_SELECT_EVENT, listener); expect(seen).toEqual(["worker"]); expect(isCommandBarOpen()).toBe(false); }); it("Enter on an empty result list does nothing", () => { const onOpenSession = vi.fn(); open({ sessions, homeDir: "/home/me", commands: [], onOpenSession }); type("zzz"); press("Enter"); expect(onOpenSession).not.toHaveBeenCalled(); expect(isCommandBarOpen()).toBe(true); }); it("closes on Escape and on a press outside the panel", () => { open(); press("Escape"); expect(isCommandBarOpen()).toBe(false); open(); (document.querySelector(".cmdbar-overlay") as HTMLElement).dispatchEvent( new MouseEvent("mousedown", { bubbles: true }), ); expect(isCommandBarOpen()).toBe(false); }); it("stays open for a press inside the panel", () => { open(); (document.querySelector(".cmdbar-panel") as HTMLElement).dispatchEvent( new MouseEvent("mousedown", { bubbles: true }), ); expect(isCommandBarOpen()).toBe(true); }); it("returns the keyboard to whatever held it when it opened", () => { const before = document.createElement("button"); document.body.appendChild(before); before.focus(); open(); expect(document.activeElement).toBe(document.querySelector(".cmdbar-input")); press("Escape"); expect(document.activeElement).toBe(before); }); it("clicking a row opens that session", () => { const onOpenSession = vi.fn(); open({ sessions, homeDir: "/home/me", commands: [], onOpenSession }); (document.querySelectorAll(".cmdbar-row")[1] as HTMLElement).click(); expect(onOpenSession).toHaveBeenCalledWith("worker"); expect(isCommandBarOpen()).toBe(false); }); }); // ---- the sigils (#150) ---- function command(id: string, label: string, keys: string[], run = () => {}): Command { return { id, label, group: "Test", keys, scope: "global", run }; } const flush = () => new Promise((resolve) => setTimeout(resolve, 0)); function rowKeys(): string[][] { return [...document.querySelectorAll(".cmdbar-row-keys")].map((el) => [...el.querySelectorAll("kbd")].map((k) => k.textContent ?? ""), ); } describe("parsing the query line", () => { it("reads a sigil only in first position", () => { expect(parseBarQuery(">kill")).toEqual({ sigil: ">", query: "kill" }); expect(parseBarQuery("~/dev")).toEqual({ sigil: "~", query: "/dev" }); expect(parseBarQuery("")).toEqual({ sigil: null, query: "" }); expect(parseBarQuery("api")).toEqual({ sigil: null, query: "api" }); }); it("leaves a sigil character elsewhere in the query literal", () => { expect(parseBarQuery("api>x")).toEqual({ sigil: null, query: "api>x" }); // The case that matters: the second `~` of a directory query reaches the // matcher. expect(parseBarQuery("~~/dev")).toEqual({ sigil: "~", query: "~/dev" }); }); it("returns to the mixed list when the sigil is backspaced away", () => { expect(parseBarQuery(">").sigil).toBe(">"); expect(parseBarQuery("").sigil).toBeNull(); }); }); describe("command candidates", () => { it("dedupes by action, not by chord", () => { const run = () => {}; const kill = command("a.kill", "Kill the session", ["x"], run); const alias = command("b.kill", "Kill the session", ["ctrl-x"], run); const other = command("c.rename", "Rename the session", ["r"]); expect(dedupeCommands([kill, alias, other]).map((c) => c.id)).toEqual(["a.kill", "c.rename"]); }); it("matches on the label only, and keeps the chord for display", () => { const candidates = commandBarCandidates([command("a", "Open the spawn form", ["ctrl-k"])]); expect(candidates[0].fields).toEqual({ label: "Open the spawn form" }); expect(candidates[0].command.keys).toEqual(["ctrl-k"]); }); }); describe("directory candidates", () => { it("abbreviates the path it shows and keeps the one it spawns in", () => { const c = directoryCandidate("/home/me/dev/api", "/home/me"); expect(c).toEqual({ kind: "directory", path: "/home/me/dev/api", fields: { path: "~/dev/api" } }); }); it("expands a typed `~` and dedupes by the directory spawned in", () => { const paths = ["~/dev/api", "/home/me/dev/api", "/tmp/scratch"]; expect(directoryBarCandidates(paths, "/home/me").map((c) => c.path)).toEqual([ "/home/me/dev/api", "/tmp/scratch", ]); }); }); describe("the sigils in the overlay", () => { const sessions = [session("api", { metadata: { name: "api" }, current_cwd: "/home/me/dev/api" })]; const ran: string[] = []; const commands = [ command("spawn.open", "Open the spawn form", ["n"], () => ran.push("spawn.open")), command("list.kill", "Kill the highlighted session", ["x"], () => ran.push("list.kill")), ]; afterEach(() => { ran.length = 0; }); function open(over: Partial[0]> = {}) { return openCommandBar({ sessions, homeDir: "/home/me", commands, onOpenSession: () => {}, loadDirectories: async () => [], listDirEntries: async () => [], onSpawnDirectory: () => {}, ...over, }); } it("lists every command in registry order under `>`, with its chord and no header", () => { open(); type(">"); expect(rowNames()).toEqual(["Open the spawn form", "Kill the highlighted session"]); expect(rowKeys()).toEqual([["n"], ["x"]]); expect(document.querySelectorAll(".cmdbar-group-title")).toHaveLength(0); }); it("matches a command on its label and never on its chord", () => { open(); type(">kill"); expect(rowNames()).toEqual(["Kill the highlighted session"]); // `n` and `x` are chords, not label text: querying one finds nothing that // the label does not also justify. type(">ctrlx"); expect(rowNames()).toEqual([]); }); it("runs the highlighted command on Enter", () => { open(); type(">kill"); press("Enter"); expect(ran).toEqual(["list.kill"]); expect(isCommandBarOpen()).toBe(false); }); it("keeps the list mixed with no sigil, and returns to it when the sigil goes", () => { open(); type("s"); expect([...document.querySelectorAll(".cmdbar-group-title")].map((h) => h.textContent)).toEqual([ "Sessions", "Commands", ]); type(">spawn"); expect(rowNames()).toEqual(["Open the spawn form"]); // Backspacing the sigil away is ordinary editing. type("spawn"); expect(rowNames()).toContain("Open the spawn form"); type(""); expect(rowNames()).toEqual(["api"]); }); it("lists recent dirs then projects under `~`", async () => { open({ loadDirectories: async () => ["/home/me/dev/api", "/srv/www"] }); type("~"); await flush(); expect(rowNames()).toEqual(["~/dev/api", "/srv/www"]); expect(document.querySelectorAll(".cmdbar-group-title")).toHaveLength(0); }); it("fills the list in from /api/dirs as a path is typed", async () => { const asked: string[] = []; open({ loadDirectories: async () => ["/home/me/notes"], listDirEntries: async (dir) => { asked.push(dir); return ["maestro", "api"]; }, }); // The second `~` is a literal query character and reaches the matcher. type("~~/dev/mae"); await flush(); expect(asked).toEqual(["~/dev/"]); expect(rowNames()).toEqual(["~/dev/maestro"]); }); it("spawns in the highlighted directory on Enter", async () => { const spawned: string[] = []; open({ loadDirectories: async () => ["~/dev/api"], onSpawnDirectory: (path) => spawned.push(path), }); type("~api"); await flush(); press("Enter"); expect(spawned).toEqual(["/home/me/dev/api"]); expect(isCommandBarOpen()).toBe(false); }); it("raises the spawn event when the caller supplies no handler", async () => { const seen: string[] = []; const listener = (e: Event) => seen.push((e as CustomEvent).detail); document.addEventListener(SPAWN_DIRECTORY_EVENT, listener); openCommandBar({ sessions, homeDir: "/home/me", commands, onOpenSession: () => {}, loadDirectories: async () => ["/home/me/dev/api"], listDirEntries: async () => [], }); type("~"); await flush(); press("Enter"); document.removeEventListener(SPAWN_DIRECTORY_EVENT, listener); expect(seen).toEqual(["/home/me/dev/api"]); }); }); describe("the focus round-trip over a pane (#152)", () => { function terminalTextarea(): HTMLTextAreaElement { const ta = document.createElement("textarea"); ta.className = "xterm-helper-textarea"; document.body.appendChild(ta); return ta; } it("hands the keyboard back to the terminal that had it", () => { const ta = terminalTextarea(); ta.focus(); openCommandBar({ sessions: [], commands: [] }); expect(document.activeElement).toBe(document.querySelector(".cmdbar-input")); press("Escape"); expect(document.activeElement).toBe(ta); }); it("reports no focus change to the daemon, opening or closing", () => { const fetchMock = vi.fn(() => Promise.resolve(new Response(null, { status: 204 }))); vi.stubGlobal("fetch", fetchMock); try { const ta = terminalTextarea(); ta.focus(); openCommandBar({ sessions: [], commands: [] }); press("Escape"); const focusCalls = fetchMock.mock.calls.filter(([url]: unknown[]) => String(url).includes("/focus"), ); expect(focusCalls).toEqual([]); } finally { vi.unstubAllGlobals(); } }); }); describe("the session action page (#151)", () => { const sessions = [ session("api", { metadata: { name: "api" }, current_cwd: "/home/me/dev/api", last_focused_at: "2026-01-01T11:00:00Z", }), session("worker", { metadata: { name: "worker" }, current_cwd: "/home/me/dev/worker", last_focused_at: "2026-01-01T10:00:00Z", }), ]; function open(over: Partial[0]> = {}) { return openCommandBar({ sessions, homeDir: "/home/me", commands: [], onOpenSession: () => {}, ...over, }); } function headerText(): string | undefined { return document.querySelector(".cmdbar-page-header")?.textContent ?? undefined; } it("lists kill, rename and spawn similar for the highlighted session", () => { open(); press("Tab"); expect(rowNames()).toEqual(["Kill session", "Rename session", "Spawn similar"]); expect(activeName()).toBe("Kill session"); }); it("names the session being acted on", () => { open(); press("ArrowDown"); press("Tab"); expect(headerText()).toContain("worker"); }); it("drops the kind headers, since the page is one kind", () => { open(); press("Tab"); expect(document.querySelectorAll(".cmdbar-group-title")).toHaveLength(0); }); it("stays fuzzy-searchable over the three actions", () => { open(); press("Tab"); type("ren"); expect(rowNames()).toEqual(["Rename session"]); type("spwn"); expect(rowNames()).toEqual(["Spawn similar"]); type("zzz"); expect(document.querySelector(".cmdbar-empty")?.textContent).toBe("No matches"); }); it("does nothing on a command row", () => { openCommandBar({ sessions, homeDir: "/home/me", commands: [{ ...COMMAND_SPECS[0], run: () => {} } as Command], onOpenSession: () => {}, }); type(">"); const before = rowNames(); press("Tab"); expect(rowNames()).toEqual(before); expect(headerText()).toBeUndefined(); }); it("does nothing on a directory row", async () => { open({ loadDirectories: () => Promise.resolve(["/home/me/dev/api"]) }); type("~"); await Promise.resolve(); press("Tab"); expect(rowNames()).toEqual(["~/dev/api"]); expect(headerText()).toBeUndefined(); }); it("returns to the result list on Escape, with the previous query intact", () => { open(); type("wor"); press("Tab"); expect(headerText()).toContain("worker"); press("Escape"); expect(isCommandBarOpen()).toBe(true); expect(headerText()).toBeUndefined(); expect((document.querySelector(".cmdbar-input") as HTMLInputElement).value).toBe("wor"); expect(rowNames()).toEqual(["worker"]); }); it("returns to the result list on backspace at an empty query", () => { open(); type("wor"); press("Tab"); type(""); press("Backspace"); expect(headerText()).toBeUndefined(); expect((document.querySelector(".cmdbar-input") as HTMLInputElement).value).toBe("wor"); expect(rowNames()).toEqual(["worker"]); }); it("keeps backspace ordinary while the page's query has text", () => { open(); press("Tab"); type("ren"); press("Backspace"); expect(headerText()).toContain("api"); expect(rowNames()).toEqual(["Rename session"]); }); it("closes the bar when the page's Escape has already been used", () => { open(); press("Tab"); press("Escape"); press("Escape"); expect(isCommandBarOpen()).toBe(false); }); it("raises kill, rename and spawn similar with the session, closing the bar", () => { const seen: string[] = []; for (const [hook, key] of [ ["onKillSession", "Kill session"], ["onRenameSession", "Rename session"], ["onSpawnSimilar", "Spawn similar"], ] as const) { open({ [hook]: (s: Session) => seen.push(`${hook}:${s.id}`) }); type("wor"); press("Tab"); type(key); press("Enter"); expect(isCommandBarOpen()).toBe(false); } expect(seen).toEqual([ "onKillSession:worker", "onRenameSession:worker", "onSpawnSimilar:worker", ]); }); it("hands each action to the flow it already has, by default", () => { const seen: string[] = []; const listeners: Array<[string, EventListener]> = [ [KILL_SESSION_EVENT, (e) => seen.push(`kill:${(e as CustomEvent).detail.id}`)], [RENAME_SESSION_EVENT, (e) => seen.push(`rename:${(e as CustomEvent).detail.id}`)], [SPAWN_SIMILAR_EVENT, (e) => seen.push(`similar:${(e as CustomEvent).detail.id}`)], ]; for (const [name, fn] of listeners) document.addEventListener(name, fn); try { for (const action of ["Kill session", "Rename session", "Spawn similar"]) { open(); press("Tab"); type(action); press("Enter"); } } finally { for (const [name, fn] of listeners) document.removeEventListener(name, fn); } expect(seen).toEqual(["kill:api", "rename:api", "similar:api"]); }); it("offers no action beyond the three", () => { expect(sessionActionCandidates().map((c) => c.action.id)).toEqual([ "kill", "rename", "spawn-similar", ]); }); });