The global listener already ran in the capture phase, but deferred to isTypingTarget, so xterm's hidden textarea kept Ctrl-K. The bar now treats that textarea as not a typing target, while real form fields — the spawn form's inputs — keep their own Ctrl-K. Leader then ctrl-k (ctrl-a ctrl-k) sends the literal 0x0B to the focused pane through the leader chord the command set already resolves, so readline's kill-to-end-of-line stays reachable. The pool grows a sendToFocused for it, over a new AttachConnection.sendInput. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
545 lines
19 KiB
TypeScript
545 lines
19 KiB
TypeScript
// @vitest-environment happy-dom
|
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
|
|
import { SESSION_SELECT_EVENT } from "./catalog.js";
|
|
import {
|
|
barGroups,
|
|
barRows,
|
|
closeCommandBar,
|
|
commandBarCandidates,
|
|
dedupeCommands,
|
|
directoryBarCandidates,
|
|
directoryCandidate,
|
|
isCommandBarOpen,
|
|
openCommandBar,
|
|
orderSessionsForBar,
|
|
parseBarQuery,
|
|
sessionBarCandidates,
|
|
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> = {}): 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<typeof openCommandBar>[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<string>).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<Parameters<typeof openCommandBar>[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<string>).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();
|
|
}
|
|
});
|
|
});
|