feat(ui): Ctrl-K command palette #101

Manually merged
lz merged 5 commits from feat/command-palette into main 2026-09-04 17:24:58 +02:00
Owner

Issue #96, design D7. Fourth and last PR in the chain — stacked on #100, merge after it.

A filter over the workspaces store, an overlay, and a keydown listener.

Index = every workspace, every session, and each of the five DockViews for each of them, plus New workspace…. Enter opens the highlighted target's terminal; Tab narrows to that target's views; Escape backs out of the narrowing before it closes the palette, so leaving costs the same keystrokes as entering.

An empty query is not "everything": it shows the two groups worth showing unprompted — Needs you (workspaces stuck in pulling/booting or errored, and previews awaiting approval) and Recent. Once the operator types, the list is flat and ranked — grouping there put a pending-port row above the workspace you just named, and Enter then opened the wrong thing.

Two deliberate deviations from the spec

  • The spec says "no API". Needs-you's pending previews are in no client store — previews are fetched per session — so this adds one read-only route, GET /api/previews/pending, behind the same operator gate as everything else. Without it the group can only report half of what it promises.
  • The spec has Tab step into "the D3 menu, rendered inline". Half of that menu (new-session, Container logs) is wired to component-local state a palette cannot reach, so a faithful version would ship rows that do nothing — and each new onPick switch is a new place for exactly that. Tab narrows to the target's views instead: the drill-down a palette is for, with nothing dead in it.

Also here, because the palette is what makes them reachable

  • The rail's pinned footer (spec §D6). At 52px there was no way to search or create a workspace without expanding the sidebar first.
  • A header Ctrl-K chip — the whole affordance on a phone. Hidden off the main page, where the palette is not mounted and it would do nothing.
  • NewWorkerModal moves from Workers up to MainSplit: three surfaces raise it now and only one is the list. Focus returns to whichever raised it.

The binding

isPaletteChord is a pure predicate with its own test, because the interesting part is what it must not match and that is only checkable in a test. Measured in a real browser:

chord claimed? why
Ctrl-K / Ctrl-P yes, preventDefault both are browser chords in a tab (Firefox search bar / Chrome omnibox; Print)
Ctrl-Shift-K no Firefox's Web Console — caught in the browser pass, the uppercase match was swallowing it
Ctrl-K with caps lock yes caps lock reports 'K' with shiftKey: false
Cmd-K no no Mac accelerators anywhere, per the design

Verification

Gates: 4875 files / 0 errors 0 warnings, 113 files / 1075 tests, lint clean.

Browser (seeded instance, playwright-cli): open by chord / chip / rail footer; the Needs-you group against seeded stuck workspaces and pending previews; ranking; Tab drill and Shift-Tab; Enter opening a panel and recording a recent; the modal from all three entry points with focus return; and the mobile layout at 390px.

New unit tests: palette-index.test.ts (index construction, the Needs-you predicate, shell-preview routing to the workspace target, token filtering and ranking), palette-chord.test.ts, palette-recents.test.ts.


Rebased after the spec audit

Rebased onto the updated #100. One conflict resolution worth naming: both branches added a VIEW_LABEL record to panel-target.ts, which is a compile error together. The palette's copy stayed — it documents why the ellipsis on Settings… belongs at the call site rather than in the name.

Verified after the rebase that the palette and the new menu work together: Ctrl-K still opens, the rail's pinned footer (Search / +) coexists with the rail's new context menu, and the fleet-wide workspaces.portCount() feeds the shared menuContextFor rather than the per-card fetch it replaced.

Rebased again after #100's review pass. Gates at this tip: 4889 files / 0 errors 0 warnings, 117 files / 1133 tests, lint clean.

Issue #96, design **D7**. Fourth and last PR in the chain — stacked on #100, merge after it. A filter over the `workspaces` store, an overlay, and a keydown listener. **Index** = every workspace, every session, and each of the five `DockView`s for each of them, plus `New workspace…`. `Enter` opens the highlighted target's terminal; `Tab` narrows to that target's views; `Escape` backs out of the narrowing before it closes the palette, so leaving costs the same keystrokes as entering. An empty query is not "everything": it shows the two groups worth showing unprompted — **Needs you** (workspaces stuck in `pulling`/`booting` or errored, and previews awaiting approval) and **Recent**. Once the operator types, the list is **flat and ranked** — grouping there put a pending-port row above the workspace you just named, and `Enter` then opened the wrong thing. ### Two deliberate deviations from the spec - **The spec says "no API".** Needs-you's pending previews are in no client store — previews are fetched per session — so this adds one read-only route, `GET /api/previews/pending`, behind the same operator gate as everything else. Without it the group can only report half of what it promises. - **The spec has `Tab` step into "the D3 menu, rendered inline".** Half of that menu (`new-session`, `Container logs`) is wired to component-local state a palette cannot reach, so a faithful version would ship rows that do nothing — and each new `onPick` switch is a new place for exactly that. `Tab` narrows to the target's views instead: the drill-down a palette is for, with nothing dead in it. ### Also here, because the palette is what makes them reachable - **The rail's pinned footer** (spec §D6). At 52px there was no way to search or create a workspace without expanding the sidebar first. - **A header `Ctrl-K` chip** — the whole affordance on a phone. Hidden off the main page, where the palette is not mounted and it would do nothing. - **`NewWorkerModal` moves from `Workers` up to `MainSplit`**: three surfaces raise it now and only one is the list. Focus returns to whichever raised it. ### The binding `isPaletteChord` is a pure predicate with its own test, because the interesting part is what it must *not* match and that is only checkable in a test. Measured in a real browser: | chord | claimed? | why | |---|---|---| | `Ctrl-K` / `Ctrl-P` | yes, `preventDefault` | both are browser chords in a tab (Firefox search bar / Chrome omnibox; Print) | | `Ctrl-Shift-K` | **no** | Firefox's Web Console — caught in the browser pass, the uppercase match was swallowing it | | `Ctrl-K` with caps lock | yes | caps lock reports `'K'` with `shiftKey: false` | | `Cmd-K` | no | no Mac accelerators anywhere, per the design | ### Verification Gates: **4875 files / 0 errors 0 warnings**, **113 files / 1075 tests**, lint clean. Browser (seeded instance, `playwright-cli`): open by chord / chip / rail footer; the Needs-you group against seeded stuck workspaces and pending previews; ranking; `Tab` drill and `Shift-Tab`; `Enter` opening a panel and recording a recent; the modal from all three entry points with focus return; and the mobile layout at 390px. New unit tests: `palette-index.test.ts` (index construction, the Needs-you predicate, shell-preview routing to the workspace target, token filtering and ranking), `palette-chord.test.ts`, `palette-recents.test.ts`. --- ### Rebased after the spec audit Rebased onto the updated #100. One conflict resolution worth naming: both branches added a `VIEW_LABEL` record to `panel-target.ts`, which is a compile error together. The palette's copy stayed — it documents why the ellipsis on `Settings…` belongs at the call site rather than in the name. Verified after the rebase that the palette and the new menu work together: `Ctrl-K` still opens, the rail's pinned footer (Search / +) coexists with the rail's new context menu, and the fleet-wide `workspaces.portCount()` feeds the shared `menuContextFor` rather than the per-card fetch it replaced. Rebased again after #100's review pass. Gates at this tip: 4889 files / 0 errors 0 warnings, 117 files / 1133 tests, lint clean.
feat(ui): Ctrl-K command palette
All checks were successful
ci / nexus (pull_request) Successful in 9m37s
ci / images (pull_request) Successful in 12m39s
0bfde7d2de
D7. A filter over the workspaces store, an overlay, and a keydown listener.

Index = every workspace, every session, and each of the five DockViews for each
of them, plus "New workspace…". Enter opens the highlighted target's terminal;
Tab narrows to that target's views; Escape backs out of the narrowing before it
closes the palette, so leaving costs the same keystrokes as entering.

An empty query is not "everything": it shows the two groups worth showing
unprompted — **Needs you** (workspaces stuck in pulling/booting or errored, and
previews awaiting approval) and **Recent**. Once the operator types, the list is
FLAT and ranked: grouping there would put a pending-port row above the workspace
you just named, and Enter would open the wrong thing.

Two deviations from the spec, both deliberate:

- The spec says "no API". Needs-you's pending previews are not in any client
  store — previews are fetched per session — so this adds one read-only route,
  GET /api/previews/pending, behind the same operator gate as everything else.
  Without it the group could only report half of what it promises.
- The spec has Tab step into "the D3 menu, rendered inline". Half of that menu
  (new-session, container logs) is wired to component-local state that a
  palette cannot reach, so a faithful version would ship rows that do nothing —
  and each new onPick switch is a new place for exactly that. Tab narrows to
  the target's views instead, which is the drill-down a palette is for and
  offers nothing dead.

Also here because the palette is what makes them reachable:

- The rail's pinned footer. At 52px there was no way to search or to create a
  workspace without expanding the sidebar first.
- A header Ctrl-K chip, which is the whole affordance on a phone. Hidden off
  the main page, where the palette is not mounted and it would do nothing.
- NewWorkerModal moves from Workers up to MainSplit: three surfaces raise it
  now and only one of them is the list. Focus returns to whichever raised it.

isPaletteChord is a pure predicate with its own test, because the interesting
part is what it must NOT match and that is only checkable in a test. Measured in
a real browser: Ctrl-K and Ctrl-P are preventDefault'd (both are browser chords
in a tab), Ctrl-Shift-K is left to the devtools console, and a caps-locked 'K'
with no Shift still opens the palette.

Verified in a browser: open by chord/chip/rail, the Needs-you group against
seeded stuck workspaces and pending previews, ranking, Tab-drill and Shift-Tab,
Enter opening a panel and recording a recent, the modal from all three entry
points with focus return, and the mobile layout at 390px.
refactor(ui): apply /simplify + review to the palette
All checks were successful
ci / nexus (pull_request) Successful in 7m42s
ci / images (pull_request) Successful in 11m51s
6359f4b532
Three defects, one of them the kind only a reactivity trace finds:

- The index was rebuilt on every 10s poll with the palette CLOSED. A $derived
  is lazy, but the $effect that clamped activeIndex READ it, and effects are
  eager — so each new `workspaces.workers` array reference pulled buildIndex →
  filterRows → groupRows and threw the result away. Measured at a 6x4 fleet:
  ~181 row objects and ~750 string allocations every 10s, unread. `index` is now
  gated on `palette.open`, so a closed palette does not even subscribe to the
  roster, and the effect depends on `query`/`drill` instead of the result set.
- Focus restore after "New workspace…" was a silent no-op. Raising the modal
  FROM the palette destroys the input that had focus, so the captured invoker
  was already detached. Guarded with isConnected, and nulled after use so it
  stops pinning a button the next poll's re-render has replaced.
- The palette printed the raw phase enum at the operator — "pulling" where
  every other surface says "Pulling image…". It now uses provisionLabel.

Needs-you was also incoherent: it listed workspaces that were merely slow while
omitting DEGRADED ones, which boot without secrets, look healthy and cannot
push. The rail already flags those. `workerNeedsYou` becomes `needsYouReason`,
returning the reason so the row can show it and so the precedence is explicit —
a real failure outranks a degraded boot, which outranks being slow. It is
deliberately NOT the rail's predicate: the rail's pip answers "needs attention
NOW" and rightly excludes the transient phases. Two questions, two answers; only
the names were ever similar, and now neither is.

Accessibility: the palette was a search overlay whose arrow keys moved a purely
visual highlight. Now a proper combobox — role + aria-expanded +
aria-activedescendant on the input, listbox on the results, option +
aria-selected per row. Verified in a browser that activedescendant tracks the
highlight across ArrowDown.

Reuse:

- `workspaceScope` / `sessionScope` were hand-written in FOUR modules, including
  the /workspace/.nexus/worktrees/<name> path formula, which has to agree with
  what createSession actually made on disk. One definition in main-page/scope.ts.
- `VIEW_LABEL` joins the view set it belongs to in dock/panel-target.ts; the
  menu builder now reads its labels from there rather than spelling out the same
  five names a second time.

Also corrected a comment that claimed the pending-previews query is bounded by
pending rows: that is true of the result, not the scan — approved rows persist,
so it reads the whole table. Still the right call at this scale, but the reader
should not be told otherwise.
refactor(ui): second /simplify pass on the palette
All checks were successful
ci / nexus (pull_request) Successful in 8m58s
ci / images (pull_request) Successful in 8m14s
ec2e91b3a8
Closes a latent bug the review found NEXT DOOR to this PR: `viewItems` in
target-menu.ts was a hand-written four-element array — the one per-view list
with no compile-time guard. Dock's panel map and SessionTab's icon map are both
`Record<DockView, …>`, so a sixth view fails to compile there; added to
viewItems it compiled clean and was simply absent from every ⋯ menu, on every
surface, with nothing to catch it. Now derived from VIEWS.

`needsYouReason` stops enumerating phases. `provision_phase` is nulled on the
first successful tmux probe, so non-null already means "still coming up, or
failed" — which also covers queued/creating/starting, dropped by the old list
while its own comment argued for including them. Nothing left to drift.

`PaletteRow` carried each row's target three times: as `action.scope`, as a
`scope` field always identical to it, and as a `colourKey` that was `targetKey`
of the same thing. One `rowKey(row)` accessor replaces both fields. Drilling is
keyed on that too, which deletes the one-caller `sameScope`, a non-null
assertion and a guard.

`GET /api/previews/pending` returned `{previews}` — the only list GET in the
codebase that wraps; all eight others return a bare array. Unwrapped, and the
one-off generic in the client goes with it. It also had no test, the only new
server surface in this PR without one; now covered for both the fleet-wide
pending set and the empty case.

The header no longer tests `pathname === '/'` to decide whether to show its
Ctrl-K chip. AGENTS.md calls out this exact file for deriving visibility rather
than hardcoding a route — a literal there once hid Settings from the operator
who needed it. The palette now reports its own mount, so the chip follows what
exists rather than where it is mounted. Falls out of that: the Ctrl-K chord is
claimed from the browser ONLY on a page that has a palette, verified.

Smaller: `groupRows` already returns the shape the identity `.map()` rebuilt;
`query.trim()` was recomputed five times beside a `searching` that was used
once; the reset effect no longer lists `drill`, which drillInto/drillOut already
handle synchronously (an effect would paint one frame on the old row first);
GROUP_ORDER is module-private; a dead class attribute and a stray blank line.

Reviewed and left alone, with reasons: the fleet-wide read and its absent
try/catch (single operator by design, and its GET siblings do the same);
PaletteAction's two-member union (a third member is a compile error at the
destructure); building the index per open rather than caching.
refactor: the three cleanups the palette review deferred
All checks were successful
ci / nexus (pull_request) Successful in 7m46s
ci / images (pull_request) Successful in 13m11s
f6d4619931
One request per poll instead of one per session
-----------------------------------------------
Sessions.svelte fetched the whole preview list for EVERY session on EVERY tick
and read `.length` off each response, to render a chip. The palette then
fetched the pending set again on each open. Both now read one fleet-wide list
the workspaces store loads alongside the roster.

`GET /api/previews/pending` becomes `GET /api/previews`, unfiltered: two callers
want different slices of the same tens-of-rows table, and neither slice is worth
a round trip of its own. The palette still calls `refreshPreviews()` on open, so
a port an agent registered three seconds ago is not poll-stale — that is what
the Needs-you group exists to catch.

Measured in a browser over one poll cycle: per-session preview calls 0 (was up
to one per session per mounted card), fleet-wide calls 1.

A store for the new-workspace modal
-----------------------------------
It was `$state` in MainSplit threaded as an `onNewWorkspace` prop through
Workers, WorkspaceRail and CommandPalette — which left the rail's two adjacent
footer buttons doing the same job by two different mechanisms, one store and one
prop. Prop-drilling both was never available: Header sits above MainSplit in the
layout, so the palette had to be a store. Now both are, and three Props entries
and three threading sites go with it.

The invoker capture moves onto the store with the flag, rather than being split
between an opener in MainSplit and a restore in a closure — one owner of "who
opened this and where focus goes back to". The isConnected guard survives: the
palette destroys the focused element on its way out.

$lib/storage.ts
---------------
The guarded localStorage accessors were written three times (dock/layout.ts,
main-page/sidebar-state.ts, palette/palette-recents.ts). This is a move, not an
abstraction: no key handling, no serialisation, no type parameter, and every
module keeps its own injectable-for-vitest signature. Both guards are documented
in one place now — `typeof localStorage` for SSR and the node test environment,
try/catch for a browser that has the global and still throws (Safari private
mode, blocked site data).

It also clears layout.ts's header comment, which pointed at a sibling module
(`right-pane-tab.ts`) deleted some time ago.

New tests: the store's preview loading (one call per refresh, stale list kept on
failure, pending vs total, refreshPreviews without a roster read), and
listAllPreviews returning both statuses.
lz force-pushed feat/command-palette from f6d4619931
All checks were successful
ci / nexus (pull_request) Successful in 7m46s
ci / images (pull_request) Successful in 13m11s
to fc2872ba4e
All checks were successful
ci / nexus (pull_request) Successful in 7m41s
ci / images (pull_request) Successful in 8m20s
2026-09-03 01:10:56 +02:00
Compare
lz force-pushed feat/command-palette from fc2872ba4e
All checks were successful
ci / nexus (pull_request) Successful in 7m41s
ci / images (pull_request) Successful in 8m20s
to d164870f48
All checks were successful
ci / nexus (pull_request) Successful in 8m13s
ci / images (pull_request) Successful in 8m44s
pr-image-cleanup / delete-pr-images (pull_request) Successful in 9s
2026-09-03 14:58:04 +02:00
Compare
lz changed target branch from feat/target-menu to main 2026-09-04 17:24:39 +02:00
lz manually merged commit 830567fdfb into main 2026-09-04 17:24:58 +02:00
Sign in to join this conversation.
No reviewers
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
lz/agent-nexus!101
No description provided.