diff --git a/README.md b/README.md index ceaad3590..8df79784f 100644 --- a/README.md +++ b/README.md @@ -31,7 +31,7 @@ Open http://127.0.0.1:7681 (or the address you chose above). ### Terminals -- Create, switch, and kill terminals — every terminal renders as a draggable tile on the canvas, with the left-edge **dock** as the canonical at-a-glance navigator (rail / cards / mega levels) +- Create, switch, and kill terminals — every terminal renders as a draggable tile on the canvas, with the left-edge **dock** as the canonical at-a-glance navigator (rail / cards levels) and the **command palette** as the canonical search surface - Split terminals — Ctrl+` splits a bottom pane per terminal; Ctrl+Shift+` adds tabs, Ctrl+PageDown / Ctrl+PageUp cycles - Font zoom (Cmd/Ctrl +/-), persisted per terminal across sessions - WebGL rendering with canvas fallback, clickable URLs, Unicode 11, inline images (sixel, iTerm2, kitty) @@ -54,12 +54,13 @@ The desktop workspace is mode-less — every terminal renders as a draggable, re - **Infinite pan & zoom** — two-finger scroll / trackpad to pan, pinch or Ctrl+scroll to zoom. Hold Shift to force pan even with the cursor over a terminal tile (hand-tool style). No boundaries — the canvas extends freely in every direction via CSS `transform: translate() scale()` (Figma/Excalidraw model) - **Snap-to-grid** — tiles snap to a 24px grid on drag and resize for tidy layouts - **Maximize a tile** — double-click any tile's title bar (or click the maximize button) to fill the viewport; the maximized posture persists across reload via localStorage so you land back where you left off -- **Dock — three-level navigator** — the left-edge dock is the canonical live-terminal surface, with three progressive levels of detail (#903): +- **Dock — two-level navigator** — the left-edge dock is the canonical live-terminal surface, with two progressive levels of detail (#903): - **Rail** — a narrow strip of repo-colored swatches, one per terminal. Ambient peripheral signal; click any swatch to jump to that terminal. - **Cards** (default) — recency-sorted rows: awaiting terminals get full cards with a tail of xterm buffer + a reply input wired straight to the PTY, working terminals get compact pills, idle terminals get faded one-liners, parked terminals get the tiniest dim row. - - **Mega** — search + repo facets + agent-state columns (`Idle`, `Awaiting you`, `Working`, `No agent`). The `Idle` column leads and sub-groups by age (`4–12h`, `12–24h`, `24–48h`, `48h+`), the same threshold ladder the minimap window picker uses, so picking `12h` on the minimap and scanning the mega's `12–24h` sub-row are the same triage view in two surfaces. Open with the dock's chevron-up affordance or Cmd+Shift+K (search auto-focused). - Ordering is **agent recency** (most recent agent semantic-key transition first). Recency is persisted, so a restored session lands where it left off. Click any row or mega card to focus and center its tile. In maximized-tile mode the dock renders as an opaque flush-left sidebar and the maximized terminal reflows next to it (#904), so an agent on another terminal needing attention is still visible. + Workspace search lives in the unified **command palette** (#912): the dock's search-icon button (and Cmd+Shift+K) opens the palette pre-drilled into the "Search workspaces" group. The group renders its own body inside the palette — a repo-facet sidebar plus agent-state columns (`Idle`, `Awaiting you`, `Working`, `No agent`), with the `Idle` column sub-grouped by age (`4–12h`, `12–24h`, `24–48h`, `48h+`) the same way the minimap window picker shows it. The palette input drives an AND-token query across 20+ metadata fields; the body filters the visible cards live. + + Ordering is **agent recency** (most recent agent semantic-key transition first). Recency is persisted, so a restored session lands where it left off. Click any dock row or palette workspace card to focus and center its tile. In maximized-tile mode the dock renders as an opaque flush-left sidebar and the maximized terminal reflows next to it (#904), so an agent on another terminal needing attention is still visible. - **Dock row border encodes state** — each row's left rail doubles as identity (repo color) and live status: a conic-gradient sweep while the agent is `thinking`/`tool_use`, a breathing pulse while `waiting`, and a flat fade when the terminal is idle/parked - **Auto-park stale terminals** — when a terminal's last observed agent transition is more than 4 hours old, the dock routes it into the parked bucket (regardless of its prior agent state), the row fades and drops its agent-state border, and the canvas tile fades too — so a wall of "awaiting" tiles you parked yesterday doesn't drown out the one that genuinely needs you now. Parked terminals also stop counting toward the awaiting bucket and are dropped from the OS/PWA dock badge. Any fresh agent transition unparks automatically — pure derivation off `lastActivityAt`, no persisted state - **Minimap heatmap & activity window** — the canvas minimap dots any tile whose agent is currently `waiting` (alert color) or `thinking`/`tool_use` (accent color), suppressed once the tile falls outside the active window, so you can scan a 20-tile workspace for "who needs me" or "who is making progress" without opening the switcher. A compact pill in the zoom bar shows the current window (`All` by default) and opens a five-option picker — _All / Active in last 4h / 12h / 24h / 48h_; tiles outside the window collapse to small ghost markers so visual weight shifts onto what's still in play. The choice lives in localStorage (per-device) @@ -267,7 +268,7 @@ flowchart TB **Metadata** (dashed lines) — shell activity triggers a provider DAG: CWD changes (OSC 7) → git provider (.git/HEAD watcher) → GitHub provider (`gh pr view` polling). Agent detection uses a single generic orchestrator ([`meta/agent.ts`](packages/server/src/meta/agent.ts)) driven by per-agent `AgentProvider` instances from each integration package. Today three instances are registered: `claudeCodeProvider` (from `kolu-claude-code`) wakes on title events (OSC 2) and its own `fs.watch` on `~/.claude/sessions/`; `codexProvider` (from `kolu-codex`) queries the highest-numbered `~/.codex/state_.sqlite` for thread metadata and tails the matched rollout JSONL for state transitions; `opencodeProvider` (from `kolu-opencode`) queries OpenCode's SQLite database directly and watches its WAL file for live state updates. Adding a new agent CLI is one new `AgentProvider` and one line in `startProviders` — no server-side adapter file. All providers feed a single metadata channel streamed to the client as a subscription[^providers]. Separately, kolu's preexec hook emits an `OSC 633;E` command mark before each user command; the pty handler republishes the raw payload on a `commandRun` channel, and [`meta/agent-command.ts`](packages/server/src/meta/agent-command.ts) subscribes to match the first token against a known-agents allowlist and fan out to both (a) a bounded recent-agents MRU for the agent-aware command palette and (b) a per-terminal stash keyed by terminal id, so codex/opencode session detection still matches when the agent is an interpreter shim (e.g. npm-installed `codex`, whose kernel-level process name is `node`). No `/proc` lookups or argv scraping. -**User actions** — command palette, workspace switcher, and tile chrome dispatch plain oRPC client calls ([`useTerminalCrud`](packages/client/src/terminal/useTerminalCrud.ts), [`useWorktreeOps`](packages/client/src/terminal/useWorktreeOps.ts)). The server's live subscriptions push updated state to the client automatically. [`useTerminalMetadata`](packages/client/src/terminal/useTerminalMetadata.ts) uses SolidJS's `mapArray` to create per-terminal subscriptions that automatically tear down when terminals are removed[^client-state]. +**User actions** — the unified command palette (Cmd+K for commands; Cmd+Shift+K or the dock's search-icon button to drill into "Search workspaces") and tile chrome dispatch plain oRPC client calls ([`useTerminalCrud`](packages/client/src/terminal/useTerminalCrud.ts), [`useWorktreeOps`](packages/client/src/terminal/useWorktreeOps.ts)). The server's live subscriptions push updated state to the client automatically. [`useTerminalMetadata`](packages/client/src/terminal/useTerminalMetadata.ts) uses SolidJS's `mapArray` to create per-terminal subscriptions that automatically tear down when terminals are removed[^client-state]. [^lazy-attach]: ~4 KB serialized snapshot instead of replaying the full scrollback buffer. diff --git a/packages/client/src/App.tsx b/packages/client/src/App.tsx index 9fb4f7095..60984b222 100644 --- a/packages/client/src/App.tsx +++ b/packages/client/src/App.tsx @@ -80,10 +80,10 @@ const App: Component = () => { const rightPanel = useRightPanel(); const { colorScheme } = useColorScheme(); - // Workspace-switcher feeds — desktop and mobile share the same - // accessors. The mega-level model lives in `buildDockModel` (consumed - // inside `Dock`); row order is shared via `rankDockRows` so the - // `Cmd+1..9` shortcut targets the same row the dock paints. + // Workspace search feeds — the live-terminal source list and recency + // accessor consumed by the unified command palette's "Search + // workspaces" group. `rankDockRows` shares row order with the dock + // so the `Cmd+1..9` shortcut targets the same row the dock paints. const workspaceEntries = createMemo(() => buildWorkspaceEntries( store.terminalIds(), @@ -120,11 +120,6 @@ const App: Component = () => { // Shortcuts help overlay state const [shortcutsHelpOpen, setShortcutsHelpOpen] = createSignal(false); - // Impulse signal — Mod+Shift+K bumps it; the dock listens and - // opens its mega level (search + repo facets + columns). Replaces the - // chrome-bar workspace switcher's open-request wiring. - const [dockMegaOpenRequest, setDockMegaOpenRequest] = createSignal(0); - // About dialog state const [aboutOpen, setAboutOpen] = createSignal(false); @@ -200,7 +195,7 @@ const App: Component = () => { void crud.handleCreateSubTerminal(parentId, cwd), openNewTerminalMenu: () => openPaletteGroup("New terminal"), openWorkspaceSwitcher: () => { - if (!isMobile()) setDockMegaOpenRequest((n) => n + 1); + if (!isMobile()) openPaletteGroup("Search workspaces"); }, setPaletteOpen, setShortcutsHelpOpen, @@ -280,10 +275,22 @@ const App: Component = () => { if (id) closeTerminal(id); }, handleCloseAll: () => void crud.handleCloseAll(), + handleTriggerServerError: () => + void client.terminal.resize({ + id: "00000000-0000-0000-0000-000000000000", + cols: 1, + rows: 1, + }), + handleClearLocalStorage: () => { + localStorage.clear(); + location.reload(); + }, simulateAlert: alerts.simulateAlert, isMobile, canvasCenterActive: handleCanvasCenterActive, canvasAutoArrange: arrange.handleCanvasAutoArrange, + workspaceEntries, + recencyOf, }); // Reset state on close and return focus to terminal @@ -535,9 +542,9 @@ const App: Component = () => { onAutoArrange={arrange.handleCanvasAutoArrange} onSelect={store.setActiveSilently} onClose={(id) => closeTerminal(id)} - workspaceEntries={workspaceEntries()} - getRecency={recencyOf} - openMegaRequest={dockMegaOpenRequest()} + onOpenWorkspaceSearch={() => + openPaletteGroup("Search workspaces") + } onCreate={() => openPaletteGroup("New terminal")} renderTileTitle={(id) => ( diff --git a/packages/client/src/CommandPalette.tsx b/packages/client/src/CommandPalette.tsx index 782726c36..2ade642df 100644 --- a/packages/client/src/CommandPalette.tsx +++ b/packages/client/src/CommandPalette.tsx @@ -62,6 +62,22 @@ export interface PaletteGroup extends PaletteBase { children: PaletteItem[] | (() => PaletteItem[]); } +/** A drill-in group that renders a custom body component instead of a + * filtered list. Body groups are leaves — they cannot host nested + * groups, and the engine never resolves children for them. The palette + * still owns the search input (the body reads `query` as a prop) and + * the breadcrumb / bottom action bar; the body decides how to paint + * its rows. Use this for grids that don't fit a single column of + * items (e.g. agent-state columns + facet sidebar). */ +export interface PaletteBodyGroup extends PaletteBase { + kind: "body-group"; + body: Component<{ query: string; closePalette: () => void }>; + /** Hint string shown in the bottom action bar when drilled in — + * describes what clicking inside the body does (e.g. "Pick a + * workspace to switch"). */ + bodyHint?: string; +} + /** A group whose drill-in switches the input from a filter to a free-text * value field — pre-filled with `prefill()` and auto-selected on focus. * Children are passive label rows: their own `onSelect` (if any) is @@ -102,21 +118,42 @@ export interface PaletteHint { text: string; } -/** Top-level commands — action, group, or value-input. Labels are not - * permitted at the top level; they appear only as `PaletteValueInput` - * children. */ -export type PaletteCommand = PaletteAction | PaletteGroup | PaletteValueInput; +/** Top-level commands — action, group, body-group, or value-input. + * Labels are not permitted at the top level; they appear only as + * `PaletteValueInput` children. */ +export type PaletteCommand = + | PaletteAction + | PaletteGroup + | PaletteBodyGroup + | PaletteValueInput; /** Anything renderable at a palette level. */ export type PaletteItem = PaletteCommand | PaletteLabel | PaletteHint; -function isGroup(item: PaletteItem): item is PaletteGroup | PaletteValueInput { - return item.kind === "group" || item.kind === "value"; +/** Any drillable kind — group with children, body group, or value input. */ +type DrillableKind = PaletteGroup | PaletteValueInput | PaletteBodyGroup; + +/** Discriminated UI mode driven by the deepest path segment. Filter + * mode: input narrows the children list. Value mode: input is a + * free-text field; children render as passive labels. Body mode: + * the body component renders its own custom JSX in place of the + * list (the input still drives a query the body reads). Exported so + * child components (e.g. ActionBar) reference the same union the + * engine dispatches on — a future arm forces both ends to update. */ +export type PaletteMode = + | { kind: "filter" } + | { kind: "value"; leaf: PaletteValueInput } + | { kind: "body"; leaf: PaletteBodyGroup }; + +function isDrillable(item: PaletteItem): item is DrillableKind { + return ( + item.kind === "group" || item.kind === "value" || item.kind === "body-group" + ); } -/** Resolve children, handling both static arrays and accessors. - * `PaletteValueChild` is a subset of `PaletteItem`, so a value-group's - * children fit the wider return type. */ +/** Resolve children, handling both static arrays and accessors. Body + * groups have no children, so they are excluded from the input type — + * callers narrow first. */ function resolveChildren(cmd: PaletteGroup | PaletteValueInput): PaletteItem[] { return typeof cmd.children === "function" ? cmd.children() : cmd.children; } @@ -143,9 +180,7 @@ const CommandPalette: Component<{ const [selectedIndex, setSelectedIndex] = createSignal(0); // Ignore mouseEnter until a real mouse move after opening (prevents cursor-under-palette hijack). const [mouseActive, setMouseActive] = createSignal(false); - const [path, setPath] = createSignal<(PaletteGroup | PaletteValueInput)[]>( - [], - ); + const [path, setPath] = createSignal([]); /** Items at the current navigation level (may include hints). * @@ -166,11 +201,14 @@ const CommandPalette: Component<{ const p = path(); const last = p.at(-1); if (last === undefined) return props.commands(); + // Body groups are leaves — the body owns rendering, no children. + if (last.kind === "body-group") return []; let level: PaletteItem[] = props.commands(); for (const segment of p) { const match = level.find( (item): item is PaletteGroup | PaletteValueInput => - isGroup(item) && item.name === segment.name, + (item.kind === "group" || item.kind === "value") && + item.name === segment.name, ); if (!match) return resolveChildren(last); level = resolveChildren(match); @@ -192,20 +230,21 @@ const CommandPalette: Component<{ return { interactive, hints }; }); - /** Discriminated UI mode driven by the deepest path segment. - * Filter mode: input narrows the children list. Value mode: input is - * a free-text field; children render as passive labels. The five - * behavior swaps (filter bypass, validation, placeholder, - * selection-reset suppression, submit dispatch) all switch on this. */ - type Mode = { kind: "filter" } | { kind: "value"; leaf: PaletteValueInput }; - - const mode = createMemo(() => { + const mode = createMemo(() => { const last = path().at(-1); - return last?.kind === "value" - ? { kind: "value", leaf: last } - : { kind: "filter" }; + if (last?.kind === "value") return { kind: "value", leaf: last }; + if (last?.kind === "body-group") return { kind: "body", leaf: last }; + return { kind: "filter" }; }); + /** Narrow `mode()` to the body leaf for the `` render branch. + * Plain function — the only consumer is the JSX below, so a memo + * would just add a signal node for one read site. */ + function bodyLeaf(): PaletteBodyGroup | undefined { + const m = mode(); + return m.kind === "body" ? m.leaf : undefined; + } + /** Validation error for the current value-input query. `null` outside * value mode or when the value passes. */ const valueError = createMemo(() => { @@ -222,16 +261,25 @@ const CommandPalette: Component<{ return "Type a command..."; } - /** Interactive rows at the current level (filter is bypassed in value - * mode). Filter mode produces `PaletteCommand[]`; value mode produces - * `PaletteLabel[]` — the union covers both without dynamic typing. */ + /** Interactive rows at the current level (filter is bypassed in + * value and body modes). Filter mode produces `PaletteCommand[]`; + * value mode produces `PaletteLabel[]`; body mode skips the list + * entirely. The union covers all three without dynamic typing. + * + * Substring semantics (case-insensitive) against the row's `name` + * or `description`. Substring was chosen over AND-token because the + * palette also hosts close-name action pairs like "Toggle terminal + * split" vs "Split terminal" — token permutation matches both and + * clicks the wrong one. Workspace search inside the column body + * runs its own AND-token filter on the 20-field corpus + * (`buildDockModel`), which is the right semantics there. */ const filtered = createMemo((): (PaletteCommand | PaletteLabel)[] => { const items = partitioned().interactive; - if (mode().kind === "value") return items; + if (mode().kind !== "filter") return items; const q = query().toLowerCase(); + if (!q) return items; return items.filter( (cmd) => - !q || cmd.name.toLowerCase().includes(q) || cmd.description?.toLowerCase().includes(q), ); @@ -245,7 +293,7 @@ const CommandPalette: Component<{ partitioned().interactive.some((cmd) => cmd.icon), ); - function drillInto(cmd: PaletteGroup | PaletteValueInput) { + function drillInto(cmd: DrillableKind) { setPath((p) => [...p, cmd]); if (cmd.kind === "value") { setQuery(cmd.prefill()); @@ -296,7 +344,12 @@ const CommandPalette: Component<{ // .exhaustive() forces a compile error if a future kind is added // without an arm here. match(cmd) - .with({ kind: "group" }, { kind: "value" }, (group) => drillInto(group)) + .with( + { kind: "group" }, + { kind: "value" }, + { kind: "body-group" }, + (group) => drillInto(group), + ) .with({ kind: "action" }, (action) => { // Close first so the highlight effect stops tracking filtered(), // preventing onSelect's state changes from re-triggering a preview. @@ -309,6 +362,12 @@ const CommandPalette: Component<{ function handleKeyDown(e: KeyboardEvent) { if (!props.open) return; + // Body mode (custom group renderer): the body owns its own + // selection/activation. The engine still handles Backspace for + // drilling out (so the input being empty still pops the path) + // and lets Escape fall through to Corvu Dialog. Arrow/Tab/Enter + // pass to the body's own listener. + if (mode().kind === "body" && e.key !== "Backspace") return; const items = filtered(); const isCtrl = e.ctrlKey && !e.metaKey && !e.altKey && !e.shiftKey; const key = (isCtrl && CTRL_KEY_MAP[e.key]) || e.key; @@ -354,49 +413,41 @@ const CommandPalette: Component<{ // Capture phase: intercept before terminal's keydown handler makeEventListener(window, "keydown", handleKeyDown, { capture: true }); - // Open: reset transient state. Close: fire onCancel for the drilled-in - // path unless the close was selection-initiated. - createEffect( - on( - () => props.open, - (isOpen) => { - if (isOpen) { - setQuery(""); - setSelectedIndex(0); - setAmbientTip(randomAmbientTip()); - setMouseActive(false); - setClosingForSelection(false); - // forceMount keeps the dialog in the DOM, so Corvu's initialFocusEl - // only fires on first mount. Re-focus explicitly on every open. - requestAnimationFrame(() => - requestAnimationFrame(() => inputRef.focus()), - ); - } else { - if (!closingForSelection()) { - for (const g of path()) g.onCancel?.(); - } - setClosingForSelection(false); - } - }, - ), - ); - - // Track initialGroup reactively: a caller changing the prop (or opening - // with a new value) re-targets the drilled level. Closing clears the path. - // Routes through `drillInto` rather than `setPath` directly so the - // value-input branch (prefill + auto-select) fires when initialGroup - // names a value-input leaf. + // Open/close lifecycle — one effect so the read of `path()` for + // `onCancel` propagation is ordered explicitly before the path + // reset. Splitting open-vs-initialGroup into two `on()` effects + // raced when both depended on `props.open` (the path-reset effect + // could fire first, clearing the segments the close branch was + // about to walk for cancellation). createEffect( on([() => props.open, () => props.initialGroup], ([isOpen, initial]) => { - setPath([]); - if (!isOpen || !initial) return; - const group = props - .commands() - .find( - (c): c is PaletteGroup | PaletteValueInput => - isGroup(c) && c.name === initial, + if (isOpen) { + setQuery(""); + setSelectedIndex(0); + setAmbientTip(randomAmbientTip()); + setMouseActive(false); + setClosingForSelection(false); + setPath([]); + if (initial) { + const group = props + .commands() + .find( + (c): c is DrillableKind => isDrillable(c) && c.name === initial, + ); + if (group) drillInto(group); + } + // forceMount keeps the dialog in the DOM, so Corvu's initialFocusEl + // only fires on first mount. Re-focus explicitly on every open. + requestAnimationFrame(() => + requestAnimationFrame(() => inputRef.focus()), ); - if (group) drillInto(group); + } else { + if (!closingForSelection()) { + for (const g of path()) g.onCancel?.(); + } + setClosingForSelection(false); + setPath([]); + } }), ); @@ -444,25 +495,31 @@ const CommandPalette: Component<{ onOpenChange={props.onOpenChange} transparentOverlay={props.transparentOverlay} initialFocusEl={inputRef} + size="lg" > - {/* Breadcrumb — visible when drilled into a group */} + {/* Breadcrumb — visible when drilled into a group. Renders as + Raycast-style chips: "Commands › Theme" feels like a path you + can click any segment of to pop back. */} 0}> -