// 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 "../../uiEvents.js"; import { publishedCommands, type Command } from "../../commands.js"; import { sessionName, type Session } from "../../types.js"; import { ActionPage } from "./ActionPage.js"; import { ResultList } from "./ResultList.js"; import { barGroups, barRows, commandBarCandidates, dirEntryPaths, directoryBarCandidates, isActionCandidate, listDirEntries as defaultListDirEntries, loadBarDirectories, parseBarQuery, probeDir, sessionActionCandidates, sessionBarCandidates, PLACEHOLDERS, type BarCandidate, type BarResult, type DirectoryBarCandidate, type SessionAction, } from "./model.js"; /** Raised when Enter lands on a directory row and no handler was given. The * bar picks the directory; the shell owns the spawn, which is the same * fast-forward path `N` takes. */ export const SPAWN_DIRECTORY_EVENT = "cmdbar-spawn-directory"; export interface CommandBarProps { /** Sessions to build candidates from, as the store has them right now. */ sessions: readonly Session[]; /** The daemon's home dir, for the directory field's `~/…` abbreviation. */ homeDir?: string; /** The command registry to list under `>`. Defaults to every bound command, * in registry order. */ commands?: readonly Command[]; /** The directory list behind `~`, fetched once on first use. */ loadDirectories?: () => Promise; /** One directory's subdirectories, for filling the list in as a path is * typed. Defaults to `GET /api/dirs`. */ listDirEntries?: (dir: string) => Promise; onOpenSession?(sessionId: string): void; onSpawnDirectory?(path: string): void; onKillSession?(session: Session): void; onRenameSession?(session: Session): void; onSpawnSimilar?(session: Session): void; /** Closing returns the keyboard to whatever held it when the bar opened. */ onClose(): void; } function raise(name: string, detail: unknown): void { document.dispatchEvent(new CustomEvent(name, { detail })); } export function CommandBar(props: CommandBarProps) { const { sessions, homeDir, commands, loadDirectories = loadBarDirectories, listDirEntries = defaultListDirEntries, onClose, } = props; const inputRef = useRef(null); const panelRef = useRef(null); const [raw, setRaw] = useState(""); const [highlighted, setHighlighted] = useState(0); // The action page: `page` is the session being acted on, `stashed` the // result-list query it was drilled into from — backing out restores it, so // the round trip costs the operator nothing. const [page, setPage] = useState(null); const [stashed, setStashed] = useState(""); // `~`'s candidates: the recents-and-projects list, fetched once, plus // whatever `/api/dirs` has filled in for the directory being typed. const [directories, setDirectories] = useState([]); const [probed, setProbed] = useState([]); const requestedRef = useRef(false); const listingsRef = useRef(new Map()); // On the action page the query line is over the three actions, so there is // no sigil to read: `>` and `~` are ordinary characters there. const parsed = useMemo(() => (page ? { sigil: null, query: raw } : parseBarQuery(raw)), [page, raw]); const sessionCandidates = useMemo( () => sessionBarCandidates(sessions, homeDir), [sessions, homeDir], ); const commandCandidates = useMemo( () => commandBarCandidates(commands ?? publishedCommands()), [commands], ); // Closing returns the keyboard to whatever held it when the bar opened: a // round trip through the bar never silently moves focus. useEffect(() => { const previouslyFocused = document.activeElement as HTMLElement | null; inputRef.current?.focus(); return () => { if (previouslyFocused?.isConnected) previouslyFocused.focus(); }; }, []); // The recents-and-projects list, the first time `~` is used. No directories // is a shorter list, not an error state. useEffect(() => { if (parsed.sigil !== "~" || requestedRef.current) return; requestedRef.current = true; let live = true; loadDirectories() .then((paths) => { if (live) setDirectories(directoryBarCandidates(paths, homeDir)); }) .catch(() => {}); return () => { live = false; }; }, [parsed.sigil, loadDirectories, homeDir]); // Fill the list in from `GET /api/dirs` once the query looks like a path: the // directory it names is listed and its children join the candidates, so `~` // reaches a directory that was never spawned in. const dir = parsed.sigil === "~" ? probeDir(parsed.query) : ""; useEffect(() => { if (dir === "") { setProbed([]); return; } const cached = listingsRef.current.get(dir); if (cached) { setProbed(directoryBarCandidates(dirEntryPaths(dir, cached), homeDir)); return; } let live = true; setProbed([]); listDirEntries(dir) .then((entries) => { listingsRef.current.set(dir, entries); if (live) setProbed(directoryBarCandidates(dirEntryPaths(dir, entries), homeDir)); }) .catch(() => { /* an unreadable directory contributes nothing */ }); return () => { live = false; }; }, [dir, listDirEntries, homeDir]); /** What the matcher sees for the query line as it stands. With no sigil the * list is mixed — sessions and commands together — except on an empty query, * where the bar is a session switcher. */ const candidates: BarCandidate[] = useMemo(() => { if (page) return sessionActionCandidates(); switch (parsed.sigil) { case ">": return [...commandCandidates]; case "~": { const seen = new Set(); return [...directories, ...probed].filter((c) => seen.has(c.path) ? false : (seen.add(c.path), true), ); } default: return parsed.query === "" ? [...sessionCandidates] : [...sessionCandidates, ...commandCandidates]; } }, [page, parsed.sigil, parsed.query, sessionCandidates, commandCandidates, directories, probed]); const groups = useMemo( () => barGroups(barRows(parsed.query, candidates)), [parsed.query, candidates], ); // Grouping is a rendering of the ranking, so the flattened groups are the // rows in the order they appear — which is the order the highlight walks and // Enter acts on. const rows: BarResult[] = useMemo(() => groups.flatMap((g) => g.results), [groups]); // The highlight starts on the globally best row every time the list changes: // an old index would point at a different result. useEffect(() => { setHighlighted(0); }, [rows]); /** The highlight does not wrap: it stops at the first and last row. */ const move = useCallback( (delta: number) => { setHighlighted((at) => Math.min(rows.length - 1, Math.max(0, at + delta))); }, [rows.length], ); const runAction = useCallback( (action: SessionAction, session: Session) => { onClose(); switch (action.id) { case "kill": (props.onKillSession ?? ((s: Session) => raise(KILL_SESSION_EVENT, s)))(session); return; case "rename": (props.onRenameSession ?? ((s: Session) => raise(RENAME_SESSION_EVENT, s)))(session); return; case "spawn-similar": (props.onSpawnSimilar ?? ((s: Session) => raise(SPAWN_SIMILAR_EVENT, s)))(session); return; } }, [onClose, props.onKillSession, props.onRenameSession, props.onSpawnSimilar], ); const activate = useCallback( (index: number) => { const result = rows[index]; if (!result) return; const candidate = result.candidate; if (isActionCandidate(candidate)) { // The page cannot be up without a session to act on. if (page) runAction(candidate.action, page); return; } onClose(); switch (candidate.kind) { case "session": (props.onOpenSession ?? ((id: string) => raise(SESSION_SELECT_EVENT, id)))( candidate.session.id, ); return; case "command": candidate.command.run(); return; case "directory": (props.onSpawnDirectory ?? ((p: string) => raise(SPAWN_DIRECTORY_EVENT, p)))( candidate.path, ); return; } }, [rows, page, runAction, onClose, props.onOpenSession, props.onSpawnDirectory], ); /** `tab`: drill into the highlighted session's action page. No effect on any * other kind, and none on the page itself, which has no session rows. */ const drillIn = useCallback(() => { const candidate = rows[highlighted]?.candidate; if (page || candidate?.kind !== "session") return; setPage(candidate.session); setStashed(raw); setRaw(""); }, [rows, highlighted, page, raw]); /** Escape, or backspace at an empty query: back to the result list with the * query the drill-in was made from. */ const leavePage = useCallback(() => { setPage(null); setRaw(stashed); setStashed(""); }, [stashed]); const onKeyDown = (e: React.KeyboardEvent) => { switch (e.key) { case "Escape": e.preventDefault(); // On an action page Escape is one level up, not the way out. if (page) leavePage(); else onClose(); return; case "Tab": e.preventDefault(); drillIn(); return; case "Backspace": // Ordinary editing, except at an empty query on the action page. if (page && raw === "") { e.preventDefault(); leavePage(); } return; case "Enter": e.preventDefault(); activate(highlighted); return; case "ArrowDown": e.preventDefault(); move(1); return; case "ArrowUp": e.preventDefault(); move(-1); return; case "n": if (e.ctrlKey) { e.preventDefault(); move(1); } return; case "p": if (e.ctrlKey) { e.preventDefault(); move(-1); } return; } }; const activeRowId = rows.length > 0 ? `cmdbar-row-${highlighted}` : undefined; return createPortal(
{ // A press on the scrim closes; one inside the panel is left alone. if (!panelRef.current?.contains(e.target as Node)) onClose(); }} >
setRaw(e.currentTarget.value)} onKeyDown={onKeyDown} /> {page ? ( ) : ( )}
, document.body, ); }