diff --git a/web/src/commandBar.test.ts b/web/src/commandBar.test.ts new file mode 100644 index 0000000..bc1135d --- /dev/null +++ b/web/src/commandBar.test.ts @@ -0,0 +1,309 @@ +// @vitest-environment happy-dom +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { SESSION_SELECT_EVENT } from "./catalog.js"; +import { + barGroups, + barRows, + closeCommandBar, + isCommandBarOpen, + openCommandBar, + orderSessionsForBar, + sessionBarCandidates, + sessionCandidate, +} from "./commandBar.js"; +import { COMMAND_SPECS } 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" }), + ]; + + function open(over: Parameters[0] | null = null) { + return openCommandBar(over ?? { sessions, homeDir: "/home/me", 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" }); + 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", 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", onOpenSession }); + (document.querySelectorAll(".cmdbar-row")[1] as HTMLElement).click(); + expect(onOpenSession).toHaveBeenCalledWith("worker"); + expect(isCommandBarOpen()).toBe(false); + }); +}); diff --git a/web/src/commandBar.ts b/web/src/commandBar.ts new file mode 100644 index 0000000..8ec33e7 --- /dev/null +++ b/web/src/commandBar.ts @@ -0,0 +1,381 @@ +// 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. + +import { SESSION_SELECT_EVENT } from "./catalog.js"; +import { + matchAndRank, + type CandidateKind, + type MatchRange, + type MatchResult, + type SessionCandidate, +} from "./matcher.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`. */ +export type SessionBarCandidate = SessionCandidate & { session: Session }; + +export type BarCandidate = SessionBarCandidate; +export type BarResult = MatchResult; + +/** One kind's slice of the ranked list, in rank order. */ +export interface BarGroup { + kind: CandidateKind; + title: string; + results: BarResult[]; +} + +const KIND_TITLES: Record = { + session: "Sessions", + command: "Commands", + directory: "Directories", +}; + +// ---- 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)); +} + +// ---- 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[] = []; + 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 BarCandidate[]): BarResult[] { + 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 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. */ +function metaText(result: BarResult): string | 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 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"); + + 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, + ); + row.appendChild(name); + + 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; +} + +// ---- 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; +} + +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 candidates: BarCandidate[] = sessionBarCandidates(opts.sessions, opts.homeDir); + + 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"); + input.setAttribute("autocomplete", "off"); + input.spellcheck = false; + input.placeholder = "Go to a session…"; + + 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; + + 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"); + } + + function render(): void { + rows = barRows(input.value, candidates); + // 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)) { + 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(); + } + + function activate(index: number): void { + const result = rows[index]; + if (!result) return; + const sessionId = result.candidate.session.id; + close(); + openSession(sessionId); + } + + function onKeydown(e: KeyboardEvent): void { + switch (e.key) { + case "Escape": + e.preventDefault(); + close(); + 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; +} diff --git a/web/src/commands.ts b/web/src/commands.ts index e378da2..72a409d 100644 --- a/web/src/commands.ts +++ b/web/src/commands.ts @@ -49,6 +49,13 @@ export const COMMAND_SPECS: readonly CommandSpec[] = [ keys: ["N"], scope: "global", }, + { + id: "bar.open", + label: "Open the command bar", + group: "Sessions", + keys: ["ctrl-k"], + scope: "global", + }, { id: "focus.toggle", label: "Move the keyboard between the session list and the pane", diff --git a/web/src/main.ts b/web/src/main.ts index c75de4a..ec3b788 100644 --- a/web/src/main.ts +++ b/web/src/main.ts @@ -7,7 +7,8 @@ import { ConnectionPool } from "./connections.js"; import { installDrawer, installDrawerSwipe, requestToggleDrawer } from "./drawer.js"; import { installHudKeybind } from "./debug.js"; import { installHelpKeybind, isHelpOpen } from "./help.js"; -import { CommandSet, DEFAULT_LEADER, defineCommands } from "./commands.js"; +import { CommandSet, DEFAULT_LEADER, defineCommands, isTypingTarget } from "./commands.js"; +import { isCommandBarOpen, openCommandBar } from "./commandBar.js"; import { DaemonLink } from "./daemonLink.js"; import { CatalogStore } from "./events.js"; import { installRestartOverlay } from "./restartOverlay.js"; @@ -80,6 +81,15 @@ async function bootApp(): Promise { const store = new CatalogStore(); +// The daemon's home dir, for the command bar's `~/…` directory abbreviation. +// Absolute paths are a fine fallback until it lands. +let homeDir: string | undefined; +void getDaemonInfo() + .then((info) => (homeDir = info.home_dir)) + .catch(() => { + /* absolute paths are a fine fallback */ + }); + // The daemon's identity and liveness: names the release in the sidebar's status // dot, and blocks the UI behind the restart overlay while the daemon is away // (docs/RESTART.md). The events socket is the trigger — it dies first. @@ -399,12 +409,38 @@ const focusKeys = new CommandSet( { leader: DEFAULT_LEADER }, ); -window.addEventListener("keydown", (e) => { - if (focusKeys.handle(e)) return; - // The help overlay owns the keyboard while it is up. - if (isHelpOpen()) return; - spawnKeys.handle(e); -}); +// Ctrl+K opens the command bar (docs/COMMAND-BAR.md). It is a registry command +// like any other, so the `?` overlay lists it without a second list. +const barKeys = new CommandSet( + defineCommands({ + "bar.open": () => { + // Opening while a session is previewed discards the preview first, like + // any other real change (docs/PREVIEW.md): the bar is not previewed into. + schedulePreview.cancel(); + mv?.previewSession(null); + openCommandBar({ sessions: [...store.snapshot().sessions.values()], homeDir }); + }, + }), +); + +// Capture phase: an attached xterm swallows every keystroke it is given, so the +// bar's binding has to be seen ahead of the terminal (docs/COMMAND-BAR.md). +window.addEventListener( + "keydown", + (e) => { + // The bar owns the keyboard while it is up; its own input handles the keys. + if (isCommandBarOpen()) return; + if (focusKeys.handle(e)) return; + // The help overlay owns the keyboard while it is up. + if (isHelpOpen()) return; + // Opening the bar over a focused terminal is #152; until then the pane's + // hidden textarea keeps its Ctrl+K, like any other typing target. + if (!isTypingTarget(e.target) && barKeys.handle(e)) return; + spawnKeys.handle(e); + }, + true, +); + // Tab in the session list hands the keyboard to the pane; Escape in the pane // hands it back — the pair the TUI gets from `tab` and `esc`. diff --git a/web/src/styles.css b/web/src/styles.css index c4e3242..78e092f 100644 --- a/web/src/styles.css +++ b/web/src/styles.css @@ -1425,6 +1425,128 @@ input.leaf-rename:focus { color: var(--muted); } +/* ---------- command bar (Ctrl+K) — docs/COMMAND-BAR.md ---------- */ +/* Same family of surface as the spawn modal and the help overlay: the overlay + tokens below are theirs, not a third set. */ +.cmdbar-overlay { + position: fixed; + inset: 0; + z-index: var(--z-modal); + display: grid; + grid-template-rows: 12vh auto 1fr; + justify-items: center; + padding: var(--sp-4); + background: oklch(0 0 0 / 0.5); + backdrop-filter: blur(4px); +} + +.cmdbar-panel { + grid-row: 2; + display: flex; + flex-direction: column; + width: min(560px, 100%); + max-height: min(60vh, 520px); + overflow: hidden; + background: var(--surface); + border: 1px solid var(--border-strong); + border-radius: var(--r-3); + box-shadow: var(--shadow-2); +} + +.cmdbar-input { + appearance: none; + background: none; + border: 0; + border-bottom: 1px solid var(--border); + border-radius: 0; + color: var(--ink); + font-family: var(--font-mono); + font-size: var(--fs-15); + padding: var(--sp-3) var(--sp-4); +} + +.cmdbar-input:focus-visible { + outline: none; +} + +.cmdbar-results { + overflow-y: auto; + padding: var(--sp-2) 0; +} + +.cmdbar-group-title { + padding: var(--sp-2) var(--sp-4) var(--sp-1); + font-size: var(--fs-11); + font-weight: 600; + letter-spacing: 0.08em; + text-transform: uppercase; + color: var(--faint); +} + +.cmdbar-row { + display: flex; + align-items: baseline; + gap: var(--sp-3); + padding: var(--sp-1) var(--sp-4); + cursor: pointer; +} + +.cmdbar-row.is-active { + background: var(--surface-3); +} + +.cmdbar-row-name { + font-size: var(--fs-13); + color: var(--ink); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +/* A dead session still lists — it just is not where you just were. */ +.cmdbar-row.is-dead .cmdbar-row-name { + color: var(--muted); +} + +.cmdbar-row-meta { + flex: 1 1 auto; + min-width: 0; + font-size: var(--fs-11); + color: var(--faint); + text-align: right; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.cmdbar-hit { + background: none; + color: var(--accent); + font-weight: 600; +} + +.cmdbar-empty { + padding: var(--sp-3) var(--sp-4); + font-size: var(--fs-13); + color: var(--muted); +} + +/* Phone: the bottom-sheet treatment the spawn modal already uses. */ +@media (max-width: 600px) { + .cmdbar-overlay { + grid-template-rows: 1fr auto; + padding: 0; + } + + .cmdbar-panel { + grid-row: 2; + width: 100%; + max-height: 70vh; + border-radius: var(--r-3) var(--r-3) 0 0; + border-bottom: 0; + } +} + /* Line 2 — dot + duration in the state's colour, then muted fragments. The state word itself is never spelled out here; the colour carries it. */ .card-line2 {