From 9fe9fe4628002a1e3beb0e35f7bb097b763ff1d9 Mon Sep 17 00:00:00 2001 From: Miguel Palhas Date: Thu, 3 Sep 2026 16:56:41 +0100 Subject: [PATCH] feat(maestro): add the web command bar's > and ~ sigils `>` narrows the bar to commands, matched on the label only with the chord shown beside each row and deduped by action. `~` narrows to directories over recent dirs and the project list, filled in from /api/dirs as a path is typed; Enter fast-forward-spawns there, falling back to the pre-filled spawn form when a default cannot resolve. Without a sigil the list stays mixed. Co-Authored-By: Claude Opus 5 --- web/src/commandBar.test.ts | 210 +++++++++++++++++++++- web/src/commandBar.ts | 346 +++++++++++++++++++++++++++++++++---- web/src/main.ts | 62 ++++++- web/src/styles.css | 19 ++ 4 files changed, 594 insertions(+), 43 deletions(-) diff --git a/web/src/commandBar.test.ts b/web/src/commandBar.test.ts index bc1135d..bb2cc6a 100644 --- a/web/src/commandBar.test.ts +++ b/web/src/commandBar.test.ts @@ -6,13 +6,19 @@ import { barGroups, barRows, closeCommandBar, + commandBarCandidates, + dedupeCommands, + directoryBarCandidates, + directoryCandidate, isCommandBarOpen, openCommandBar, orderSessionsForBar, + parseBarQuery, sessionBarCandidates, sessionCandidate, + SPAWN_DIRECTORY_EVENT, } from "./commandBar.js"; -import { COMMAND_SPECS } from "./commands.js"; +import { COMMAND_SPECS, type Command } from "./commands.js"; import type { Session } from "./types.js"; function session(id: string, over: Partial = {}): Session { @@ -173,8 +179,12 @@ describe("the overlay", () => { 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", onOpenSession: () => {} }); + return openCommandBar( + over ?? { sessions, homeDir: "/home/me", commands: [], onOpenSession: () => {} }, + ); } it("is a registry command, so the help overlay lists it", () => { @@ -252,7 +262,7 @@ describe("the overlay", () => { const seen: string[] = []; const listener = (e: Event) => seen.push((e as CustomEvent).detail); document.addEventListener(SESSION_SELECT_EVENT, listener); - openCommandBar({ sessions, homeDir: "/home/me" }); + openCommandBar({ sessions, homeDir: "/home/me", commands: [] }); press("ArrowDown"); press("Enter"); document.removeEventListener(SESSION_SELECT_EVENT, listener); @@ -262,7 +272,7 @@ describe("the overlay", () => { it("Enter on an empty result list does nothing", () => { const onOpenSession = vi.fn(); - open({ sessions, homeDir: "/home/me", onOpenSession }); + open({ sessions, homeDir: "/home/me", commands: [], onOpenSession }); type("zzz"); press("Enter"); expect(onOpenSession).not.toHaveBeenCalled(); @@ -301,9 +311,199 @@ describe("the overlay", () => { it("clicking a row opens that session", () => { const onOpenSession = vi.fn(); - open({ sessions, homeDir: "/home/me", onOpenSession }); + 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"]); + }); +}); diff --git a/web/src/commandBar.ts b/web/src/commandBar.ts index 8ec33e7..a0ad6d5 100644 --- a/web/src/commandBar.ts +++ b/web/src/commandBar.ts @@ -1,33 +1,47 @@ // The `ctrl-k` command bar (docs/COMMAND-BAR.md): a query line over a result // list, opened from the sidebar, closed with Escape. // -// Sessions are the only kind here (#149); the sigils, the action page and the -// terminal-focus binding land in #150-#152. +// Three kinds live here: sessions and commands, ranked together by default, +// and directories, which need the `~` sigil. The action page and the +// terminal-focus binding land in #151-#152. +import { listDir, projects as fetchProjects, recentDirs } from "./api.js"; import { SESSION_SELECT_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 "./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 which session 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`. */ +/** 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 }; -export type BarCandidate = SessionBarCandidate; +export type BarCandidate = SessionBarCandidate | CommandBarCandidate | 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 { +export interface BarGroup { kind: CandidateKind; title: string; - results: BarResult[]; + results: MatchResult[]; } const KIND_TITLES: Record = { @@ -36,6 +50,29 @@ const KIND_TITLES: Record = { 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 }; +} + // ---- candidates ---- function focusedAt(s: Session): number | null { @@ -99,14 +136,92 @@ export function sessionBarCandidates( 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 BarResult[]): BarGroup[] { - const groups: BarGroup[] = []; +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); @@ -122,7 +237,10 @@ export function barGroups(results: readonly BarResult[]): BarGroup[] { /** 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 BarCandidate[]): BarResult[] { +export function barRows( + query: string, + candidates: readonly C[], +): MatchResult[] { return matchAndRank(query, candidates); } @@ -145,9 +263,25 @@ export function renderHighlighted(text: string, ranges: readonly MatchRange[]): return frag; } -/** The secondary line under a row's name: the field the match was found in when - * that was not the name, so a session found through its directory says so. */ +/** 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": @@ -162,22 +296,44 @@ function metaText(result: BarResult): string | undefined { } 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.sessionId = result.candidate.session.id; - if (result.candidate.session.status === "Dead") row.classList.add("is-dead"); + 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 (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 = result.candidate.fields.name; - name.append( - result.field === "name" ? renderHighlighted(nameText, result.ranges) : nameText, - ); + 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") { + 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"); @@ -189,6 +345,12 @@ function renderRow(result: BarResult, index: number): HTMLElement { 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 { @@ -200,6 +362,18 @@ export interface CommandBarOptions { * 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; } interface OpenBar { @@ -228,8 +402,21 @@ export function openCommandBar(opts: CommandBarOptions): HTMLElement { 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 loadDirectories = opts.loadDirectories ?? loadBarDirectories; + const listEntries = + opts.listDirEntries ?? ((dir: string) => listDir(dir).then((listing) => listing.entries)); - const candidates: BarCandidate[] = sessionBarCandidates(opts.sessions, opts.homeDir); + const sessionCandidates: SessionBarCandidate[] = sessionBarCandidates( + opts.sessions, + opts.homeDir, + ); + const commandCandidates: CommandBarCandidate[] = commandBarCandidates( + opts.commands ?? publishedCommands(), + ); const el = document.createElement("div"); el.className = "cmdbar-overlay"; @@ -246,10 +433,10 @@ export function openCommandBar(opts: CommandBarOptions): HTMLElement { input.setAttribute("role", "combobox"); input.setAttribute("aria-expanded", "true"); input.setAttribute("aria-controls", "cmdbar-results"); - input.setAttribute("aria-label", "Search sessions"); + input.setAttribute("aria-label", "Search sessions, commands and directories"); input.setAttribute("autocomplete", "off"); input.spellcheck = false; - input.placeholder = "Go to a session…"; + input.placeholder = PLACEHOLDERS.mixed; const results = document.createElement("div"); results.className = "cmdbar-results"; @@ -263,6 +450,90 @@ export function openCommandBar(opts: CommandBarOptions): HTMLElement { let rows: BarResult[] = []; let highlighted = 0; + // `~`'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[] { + 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; @@ -277,7 +548,9 @@ export function openCommandBar(opts: CommandBarOptions): HTMLElement { } function render(): void { - rows = barRows(input.value, candidates); + const parsed = parseBarQuery(input.value); + input.placeholder = PLACEHOLDERS[parsed.sigil ?? "mixed"]; + 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; @@ -294,11 +567,14 @@ export function openCommandBar(opts: CommandBarOptions): HTMLElement { let index = 0; for (const group of barGroups(rows)) { - const header = document.createElement("div"); - header.className = "cmdbar-group-title"; - header.setAttribute("role", "presentation"); - header.textContent = group.title; - results.appendChild(header); + // While a sigil is in force there is one kind, so the header is dropped. + if (parsed.sigil === null) { + 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); @@ -320,9 +596,19 @@ export function openCommandBar(opts: CommandBarOptions): HTMLElement { function activate(index: number): void { const result = rows[index]; if (!result) return; - const sessionId = result.candidate.session.id; + const candidate = result.candidate; close(); - openSession(sessionId); + 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 { diff --git a/web/src/main.ts b/web/src/main.ts index ec3b788..fa1c46a 100644 --- a/web/src/main.ts +++ b/web/src/main.ts @@ -8,7 +8,7 @@ import { installDrawer, installDrawerSwipe, requestToggleDrawer } from "./drawer import { installHudKeybind } from "./debug.js"; import { installHelpKeybind, isHelpOpen } from "./help.js"; import { CommandSet, DEFAULT_LEADER, defineCommands, isTypingTarget } from "./commands.js"; -import { isCommandBarOpen, openCommandBar } from "./commandBar.js"; +import { isCommandBarOpen, openCommandBar, SPAWN_DIRECTORY_EVENT } from "./commandBar.js"; import { DaemonLink } from "./daemonLink.js"; import { CatalogStore } from "./events.js"; import { installRestartOverlay } from "./restartOverlay.js"; @@ -17,6 +17,7 @@ import { createSession, fetchNames, getDaemonInfo, + gitRoot, projects as fetchProjects, } from "./api.js"; import { @@ -337,6 +338,16 @@ function mountSpawned(session: Session, placement?: { newTab?: boolean }): void // a new branch named after that name. Anything unresolvable (no projects // known yet, no command, no generated name) opens the form pre-filled with // what did resolve instead. +async function spawnPresets(): Promise { + try { + const info = await getDaemonInfo(); + if (info.presets?.length) return info.presets; + } catch { + /* keep fallbacks */ + } + return FALLBACK_PRESETS; +} + async function requestFastForward(): Promise { const [discovered, name] = await Promise.all([ fetchProjects().catch(() => [] as Project[]), @@ -344,13 +355,7 @@ async function requestFastForward(): Promise { .then((batch) => batch.names[0]) .catch(() => undefined), ]); - let presets: SpawnPreset[] = FALLBACK_PRESETS; - try { - const info = await getDaemonInfo(); - if (info.presets?.length) presets = info.presets; - } catch { - /* keep fallbacks */ - } + const presets = await spawnPresets(); const resolved = resolveFastForward(discovered, presets, name, focusedProjectCwd()); const req = buildFastForwardRequest(resolved); if (req) { @@ -372,6 +377,47 @@ async function requestFastForward(): Promise { }); } +// Enter on a `~` row in the command bar (docs/COMMAND-BAR.md): the same +// fast-forward spawn, aimed at the chosen directory instead of the most recent +// project. A directory the project scan never saw stands in as its own project; +// one outside any repo spawns without a worktree, since there is no repo to cut +// a branch from. Anything still unresolved opens the pre-filled form. +async function requestSpawnInDirectory(path: string): Promise { + const [discovered, name] = await Promise.all([ + fetchProjects().catch(() => [] as Project[]), + fetchNames(1) + .then((batch) => batch.names[0]) + .catch(() => undefined), + ]); + const presets = await spawnPresets(); + const known = discovered.find((p) => p.path === path); + const project: Project = known ?? { path, name: path.split("/").filter(Boolean).pop() ?? path }; + const resolved = resolveFastForward([project], presets, name, path); + const req = buildFastForwardRequest(resolved); + if (req) { + const inRepo = + known !== undefined || + (await gitRoot(path) + .then((r) => !!r.root) + .catch(() => false)); + if (!inRepo) delete req.worktree; + try { + mountSpawned(await createSession(req)); + return; + } catch { + // A failed POST degrades to the pre-filled form, never a dead row. + } + } + openSpawnModal({ + seed: fastForwardSeed(resolved), + onSuccess: (session) => mountSpawned(session), + }); +} + +document.addEventListener(SPAWN_DIRECTORY_EVENT, (ev) => { + void requestSpawnInDirectory((ev as CustomEvent).detail); +}); + // The global half of the command registry (#148). Two sets, because they // differ in one thing: the help overlay owns the keyboard while it is up, so // the plain keys go deaf behind it while the focus chord does not — an diff --git a/web/src/styles.css b/web/src/styles.css index 71ab291..4b293b4 100644 --- a/web/src/styles.css +++ b/web/src/styles.css @@ -1517,6 +1517,25 @@ input.leaf-rename:focus { text-overflow: ellipsis; } +/* A command row's chord, shown so the bar teaches the keymap. */ +.cmdbar-row-keys { + flex: 1 1 auto; + display: flex; + justify-content: flex-end; + gap: var(--sp-1); + min-width: 0; +} + +.cmdbar-row-keys kbd { + font-family: var(--font-mono); + font-size: var(--fs-11); + color: var(--muted); + background: var(--surface-2); + border: 1px solid var(--border); + border-radius: var(--r-1); + padding: 0 var(--sp-1); +} + .cmdbar-hit { background: none; color: var(--accent);