// The `ctrl-k` command bar (docs/COMMAND-BAR.md): a query line over a result // list, opened from the sidebar, closed with Escape. // // Three kinds live here: sessions and commands, ranked together by default, // and directories, which need the `~` sigil. `tab` on a session drills into // its action page (#151), which is the same overlay over three actions. import { listDir, projects as fetchProjects, recentDirs } from "./api.js"; import { KILL_SESSION_EVENT, RENAME_SESSION_EVENT, SESSION_SELECT_EVENT, SPAWN_SIMILAR_EVENT, } from "./catalog.js"; import { keyCaps, publishedCommands, type Command } from "./commands.js"; import { matchAndRank, type CandidateKind, type CommandCandidate, type DirectoryCandidate, type MatchRange, type MatchResult, type SessionCandidate, } from "./utils/matcher.js"; import { splitPath } from "./paths.js"; import { splitCommand } from "./toolIcon.js"; import { abbreviateCwd, sessionName, sessionTreePath, type Session } from "./types.js"; /** A matcher candidate that remembers 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; /** Raised when Enter lands on a directory row. The bar picks the directory; the * shell owns the spawn, which is the same fast-forward path `N` takes. */ export const SPAWN_DIRECTORY_EVENT = "cmdbar-spawn-directory"; /** One kind's slice of the ranked list, in rank order. */ export interface BarGroup { 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; } /** 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]; } // ---- 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); } // ---- rendering ---- /** Text with its matched ranges wrapped in ``. Ranges come from the * matcher as `[start, end)` character pairs over the field that won. */ export function renderHighlighted(text: string, ranges: readonly MatchRange[]): DocumentFragment { const frag = document.createDocumentFragment(); let cursor = 0; for (const [start, end] of ranges) { if (start > cursor) frag.append(text.slice(cursor, start)); const mark = document.createElement("mark"); mark.className = "cmdbar-hit"; mark.textContent = text.slice(start, end); frag.append(mark); cursor = end; } if (cursor < text.length) frag.append(text.slice(cursor)); return frag; } /** The name line of a row: the session's name, the command's label, or the * directory's path. */ function rowName(candidate: BarCandidate): string { switch (candidate.kind) { case "session": return candidate.fields.name; case "command": return candidate.fields.label; case "directory": return candidate.fields.path; } } /** The secondary line under a session row: the field the match was found in * when that was not the name, so a session found through its directory says * so. Only sessions have one — a command shows keycaps instead, and a * directory's path is already the name. */ function metaText(result: BarResult): string | undefined { if (result.candidate.kind !== "session") return undefined; const fields = result.candidate.fields; switch (result.field) { case "directory": return fields.directory; case "command": return fields.command; case "tool": return fields.tool; default: return fields.directory; } } function renderRow(result: BarResult, index: number): HTMLElement { const candidate = result.candidate; const row = document.createElement("div"); row.className = "cmdbar-row"; row.setAttribute("role", "option"); row.id = `cmdbar-row-${index}`; row.dataset.index = String(index); row.dataset.kind = candidate.kind; if (candidate.kind === "session") { row.dataset.sessionId = candidate.session.id; if (candidate.session.status === "Dead") row.classList.add("is-dead"); } else if (isActionCandidate(candidate)) { row.dataset.actionId = candidate.action.id; } else if (candidate.kind === "command") { row.dataset.commandId = candidate.command.id; } else { row.dataset.path = candidate.path; } const name = document.createElement("span"); name.className = "cmdbar-row-name"; const nameText = rowName(candidate); const nameMatched = result.field === "name" || result.field === "label" || result.field === "path"; name.append(nameMatched ? renderHighlighted(nameText, result.ranges) : nameText); row.appendChild(name); // The chord is teaching material, not a match target: it is rendered from // the live registry entry and never fed to the matcher. if (candidate.kind === "command" && !isActionCandidate(candidate)) { const keys = document.createElement("span"); keys.className = "cmdbar-row-keys"; for (const cap of keyCaps(candidate.command.keys[0])) { const kbd = document.createElement("kbd"); kbd.textContent = cap; keys.appendChild(kbd); } row.appendChild(keys); return row; } const meta = metaText(result); if (meta) { const el = document.createElement("span"); el.className = "cmdbar-row-meta"; const highlighted = result.field !== undefined && result.field !== "name"; el.append(highlighted ? renderHighlighted(meta, result.ranges) : meta); row.appendChild(el); } return row; } const PLACEHOLDERS: Record<"mixed" | BarSigil, string> = { mixed: "Go to a session…", ">": "Run a command…", "~": "Spawn in a directory…", }; // ---- the overlay ---- export interface CommandBarOptions { /** Sessions to build candidates from, as the store has them right now. */ sessions: readonly Session[]; /** The daemon's home dir, for the directory field's `~/…` abbreviation. */ homeDir?: string; /** Open the highlighted session. Defaults to the shared open path — the * sidebar's click event — which is what the contract requires; tests pass * their own. */ onOpenSession?: (sessionId: string) => void; /** The command registry to list under `>`. Defaults to every bound command, * in registry order. */ commands?: readonly Command[]; /** The directory list behind `~`, fetched once on first use. */ loadDirectories?: () => Promise; /** One directory's subdirectories, for filling the list in as a path is * typed. Defaults to `GET /api/dirs`. */ listDirEntries?: (dir: string) => Promise; /** Spawn a session in the highlighted directory. Defaults to raising * `SPAWN_DIRECTORY_EVENT`, which the shell turns into a fast-forward * spawn. */ onSpawnDirectory?: (path: string) => void; /** The action page's three actions (#151). Each defaults to raising the * event the flow that already implements it listens on — the sidebar's * confirm-and-blockers kill, its inline rename prompt, the spawn form * seeded from the session. The bar picks the action and nothing more. */ onKillSession?: (session: Session) => void; onRenameSession?: (session: Session) => void; onSpawnSimilar?: (session: Session) => void; } interface OpenBar { el: HTMLElement; dispose: () => void; } let current: OpenBar | null = null; export function isCommandBarOpen(): boolean { return current !== null; } export function closeCommandBar(): void { current?.dispose(); } /** Mount the bar. Opening while one is up replaces it, so a stray second call * can never stack two overlays. */ export function openCommandBar(opts: CommandBarOptions): HTMLElement { closeCommandBar(); // Closing returns the keyboard to whatever held it when the bar opened: a // round trip through the bar never silently moves focus. const previouslyFocused = document.activeElement as HTMLElement | null; const openSession = opts.onOpenSession ?? ((id: string) => document.dispatchEvent(new CustomEvent(SESSION_SELECT_EVENT, { detail: id }))); const spawnDirectory = opts.onSpawnDirectory ?? ((path: string) => document.dispatchEvent(new CustomEvent(SPAWN_DIRECTORY_EVENT, { detail: path }))); const sessionEvent = (name: string) => (session: Session): void => { document.dispatchEvent(new CustomEvent(name, { detail: session })); }; const killSession = opts.onKillSession ?? sessionEvent(KILL_SESSION_EVENT); const renameSession = opts.onRenameSession ?? sessionEvent(RENAME_SESSION_EVENT); const spawnSimilar = opts.onSpawnSimilar ?? sessionEvent(SPAWN_SIMILAR_EVENT); const loadDirectories = opts.loadDirectories ?? loadBarDirectories; const listEntries = opts.listDirEntries ?? ((dir: string) => listDir(dir).then((listing) => listing.entries)); const sessionCandidates: SessionBarCandidate[] = sessionBarCandidates( opts.sessions, opts.homeDir, ); const commandCandidates: CommandBarCandidate[] = commandBarCandidates( opts.commands ?? publishedCommands(), ); const el = document.createElement("div"); el.className = "cmdbar-overlay"; el.setAttribute("role", "dialog"); el.setAttribute("aria-modal", "true"); el.setAttribute("aria-label", "Command bar"); const panel = document.createElement("div"); panel.className = "cmdbar-panel"; const input = document.createElement("input"); input.type = "text"; input.className = "cmdbar-input"; input.setAttribute("role", "combobox"); input.setAttribute("aria-expanded", "true"); input.setAttribute("aria-controls", "cmdbar-results"); input.setAttribute("aria-label", "Search sessions, commands and directories"); input.setAttribute("autocomplete", "off"); input.spellcheck = false; input.placeholder = PLACEHOLDERS.mixed; const results = document.createElement("div"); results.className = "cmdbar-results"; results.id = "cmdbar-results"; results.setAttribute("role", "listbox"); panel.append(input, results); el.appendChild(panel); document.body.appendChild(el); let rows: BarResult[] = []; let highlighted = 0; // The action page (#151). `page` is the session being acted on, `stashed` // the result-list query it was drilled into from — backing out restores it, // so the round trip costs the operator nothing. let page: Session | null = null; let stashed = ""; let header: HTMLElement | null = null; // `~`'s candidates: the recents-and-projects list, fetched once, plus // whatever `/api/dirs` has filled in for the directory being typed. let directories: DirectoryBarCandidate[] = []; let directoriesRequested = false; let probed: DirectoryBarCandidate[] = []; let probedDir: string | null = null; const listings = new Map(); function alive(): boolean { return current?.el === el; } /** Fetch the recents-and-projects list the first time `~` is used. */ function ensureDirectories(): void { if (directoriesRequested) return; directoriesRequested = true; loadDirectories() .then((paths) => { directories = directoryBarCandidates(paths, opts.homeDir); if (alive()) render(); }) .catch(() => { /* no directories is a shorter list, not an error state */ }); } /** Fill the list in from `GET /api/dirs` once the query looks like a path: * the directory it names is listed and its children join the candidates, so * `~` reaches a directory that was never spawned in. */ function probeDirectories(query: string): void { const { dir } = splitPath(query); if (dir === "") { probed = []; probedDir = null; return; } if (dir === probedDir) return; probedDir = dir; const cached = listings.get(dir); if (cached) { probed = directoryBarCandidates( cached.map((entry) => `${dir}${entry}`), opts.homeDir, ); return; } probed = []; listEntries(dir) .then((entries) => { listings.set(dir, entries); if (!alive() || probedDir !== dir) return; probed = directoryBarCandidates( entries.map((entry) => `${dir}${entry}`), opts.homeDir, ); render(); }) .catch(() => { /* an unreadable directory contributes nothing */ }); } /** What the matcher sees for the query line as it stands. With no sigil the * list is mixed — sessions and commands together — except on an empty query, * where the bar is a session switcher. */ function candidatesFor(parsed: BarQuery): BarCandidate[] { if (page) return sessionActionCandidates(); switch (parsed.sigil) { case ">": return [...commandCandidates]; case "~": { ensureDirectories(); probeDirectories(parsed.query); const seen = new Set(); return [...directories, ...probed].filter((c) => seen.has(c.path) ? false : (seen.add(c.path), true), ); } default: return parsed.query === "" ? [...sessionCandidates] : [...sessionCandidates, ...commandCandidates]; } } function paintHighlight(): void { for (const row of results.querySelectorAll(".cmdbar-row")) { const active = Number(row.dataset.index) === highlighted; row.classList.toggle("is-active", active); row.setAttribute("aria-selected", active ? "true" : "false"); if (active) { row.scrollIntoView({ block: "nearest" }); input.setAttribute("aria-activedescendant", row.id); } } if (rows.length === 0) input.removeAttribute("aria-activedescendant"); } /** The line naming the session being acted on, so the target is never * ambiguous. Present only while the action page is up. */ function paintHeader(): void { header?.remove(); header = null; if (!page) return; header = document.createElement("div"); header.className = "cmdbar-page-header"; header.setAttribute("role", "presentation"); header.textContent = `Actions for "${sessionName(page)}"`; panel.insertBefore(header, results); } function render(): void { // On the action page the query line is over the three actions, so there is // no sigil to read: `>` and `~` are ordinary characters there. const parsed = page ? { sigil: null, query: input.value } : parseBarQuery(input.value); input.placeholder = page ? `Act on ${sessionName(page)}…` : PLACEHOLDERS[parsed.sigil ?? "mixed"]; paintHeader(); rows = barRows(parsed.query, candidatesFor(parsed)); // The highlight starts on the globally best row every time the list // changes: an old index would point at a different result. highlighted = 0; results.replaceChildren(); if (rows.length === 0) { const empty = document.createElement("div"); empty.className = "cmdbar-empty"; empty.textContent = "No matches"; results.appendChild(empty); paintHighlight(); return; } let index = 0; for (const group of barGroups(rows)) { // While a sigil is in force there is one kind, so the header is // dropped — and the action page is one kind too. if (parsed.sigil === null && !page) { const header = document.createElement("div"); header.className = "cmdbar-group-title"; header.setAttribute("role", "presentation"); header.textContent = group.title; results.appendChild(header); } for (const result of group.results) { const at = index; const row = renderRow(result, at); row.addEventListener("click", () => activate(at)); results.appendChild(row); index += 1; } } paintHighlight(); } /** The highlight does not wrap: it stops at the first and last row. */ function move(delta: number): void { if (rows.length === 0) return; highlighted = Math.min(rows.length - 1, Math.max(0, highlighted + delta)); paintHighlight(); } /** `tab`: drill into the highlighted session's action page. No effect on any * other kind, and none on the page itself, which has no session rows. */ function drillIn(): void { const candidate = rows[highlighted]?.candidate; if (page || candidate?.kind !== "session") return; page = candidate.session; stashed = input.value; input.value = ""; render(); } /** Escape, or backspace at an empty query: back to the result list with the * query the drill-in was made from. */ function leavePage(): void { page = null; input.value = stashed; stashed = ""; render(); } function runAction(action: SessionAction, session: Session): void { close(); switch (action.id) { case "kill": killSession(session); return; case "rename": renameSession(session); return; case "spawn-similar": spawnSimilar(session); return; } } function activate(index: number): void { const result = rows[index]; if (!result) return; const candidate = result.candidate; if (isActionCandidate(candidate)) { // The page cannot be up without a session to act on. if (page) runAction(candidate.action, page); return; } close(); switch (candidate.kind) { case "session": openSession(candidate.session.id); return; case "command": candidate.command.run(); return; case "directory": spawnDirectory(candidate.path); return; } } function onKeydown(e: KeyboardEvent): void { switch (e.key) { case "Escape": e.preventDefault(); // On an action page Escape is one level up, not the way out. if (page) leavePage(); else close(); return; case "Tab": e.preventDefault(); drillIn(); return; case "Backspace": // Ordinary editing, except at an empty query on the action page. if (page && input.value === "") { e.preventDefault(); leavePage(); } return; case "Enter": e.preventDefault(); activate(highlighted); return; case "ArrowDown": e.preventDefault(); move(1); return; case "ArrowUp": e.preventDefault(); move(-1); return; case "n": if (e.ctrlKey) { e.preventDefault(); move(1); } return; case "p": if (e.ctrlKey) { e.preventDefault(); move(-1); } return; } } function close(): void { if (current?.el !== el) return; current = null; el.remove(); // Restoring focus is what makes the bar a view over the app rather than a // place in it; a detached node cannot take it. if (previouslyFocused?.isConnected) previouslyFocused.focus(); } input.addEventListener("keydown", onKeydown); input.addEventListener("input", render); // A press on the scrim closes; one inside the panel is left alone. el.addEventListener("mousedown", (ev) => { if (!panel.contains(ev.target as Node)) close(); }); current = { el, dispose: close }; render(); input.focus(); return el; }