···11+# Cmd / Panel / Chaining / Pubsub Architecture
22+33+**Purpose:** one-page map of the cmd chaining flow so we can target the right
44+break instead of chasing symptom-level test failures.
55+66+## Components and ownership
77+88+| File | Role |
99+|------|------|
1010+| `app/cmd/index.html` + `cmd-glue.ts` | **Cmd resident renderer.** Hidden, persistent, trustedBuiltin. Owns cmd system bootstrap. URL: `peek://cmd/index.html`. |
1111+| `app/cmd/background.js` | **Registry provider.** Runs inside the cmd resident. Subscribes to `cmd:register`, `cmd:register-batch`, `noun:register-batch`, `cmd:query-commands`. Holds the canonical command list. Responds to queries with `cmd:query-commands-response`. Loads user settings. |
1212+| `app/cmd/panel.html` + `panel.js` | **On-demand palette UI.** Opened by the resident via `api.window.open('peek://ext/cmd/panel.html', ...)`. Has its own local `state.commands` map that it maintains from pubsub. trustedBuiltin (as of Cat 1 routing). |
1313+| `app/cmd/commands.js` | Runs **in the panel renderer**. Populates a local command cache by: (a) loading built-in commands (from `./commands/index.js`), (b) subscribing to `cmd:register`/`cmd:register-batch`/`cmd:query-commands-response`, (c) publishing `cmd:query-commands` on load. Dispatches DOM event `cmd-update-commands` to panel.js. |
1414+| `app/cmd/state-machine.js` | Pure 10-state FSM. IDLE → TYPING → RESULTS_OPEN → PARAM_MODE → EXECUTING → {OUTPUT_SELECTION, CHAIN_MODE, CHAIN_POPUP} → CLOSING. No DOM / IPC deps. |
1515+| Feature tiles (`features/*/background.js`) | Register their commands via `api.commands.register({name, accepts, produces, ...})` which in tile-preload publishes a `cmd:register` pubsub event with metadata. |
1616+1717+## Pubsub topics (scope: GLOBAL unless noted)
1818+1919+| Topic | Publisher | Subscriber | Payload |
2020+|-------|-----------|------------|---------|
2121+| `cmd:register` | tile-preload's `api.commands.register` | `background.js` + panel's `commands.js` | `{name, description, accepts, produces, params, source, scope, modes}` |
2222+| `cmd:register-batch` | rarely used now; pre-v2 preload batched | both | `{commands: [...]}` |
2323+| `cmd:query-commands` | panel's `commands.js` on load | `background.js` | `{}` |
2424+| `cmd:query-commands-response` | `background.js` | panel's `commands.js` | `{commands: [...]}` |
2525+| `cmd:execute:{name}` | panel proxy command | feature tile's subscribe | context `{...ctx, expectResult, resultTopic}` |
2626+| `cmd:execute:{name}:result` | feature tile after executing | panel proxy command | `{success, data, mimeType, title, ...}` |
2727+| `noun:{cap}:{name}` / `noun:result:{name}:{ts}` | noun proxy | noun handler | analogous |
2828+2929+Non-chain: `cmd:settings-update`, `cmd:settings-changed`, `cmd:unregister`,
3030+`cmd:register-batch-direct-handlers`.
3131+3232+## End-to-end flow for "list urls → csv"
3333+3434+1. User opens panel (global shortcut).
3535+2. `panel.js` + `commands.js` load. `commands.js` publishes `cmd:query-commands`. Also subscribes to `cmd:register` / `cmd:register-batch` / `cmd:query-commands-response`.
3636+3. Resident's `background.js` responds with `cmd:query-commands-response` containing all registered commands (list, new, open, csv, save, tagset, etc.). Panel populates `state.commands`.
3737+4. Features that registered AFTER resident started have their `cmd:register` events flowing through to both the resident (for persistence) and the panel (live updates). Scope is GLOBAL.
3838+5. User types `list urls`. Noun system resolves to a proxy command; panel executes via `noun:list:urls` pubsub. Feature returns `{data: [...urls...], mimeType: 'application/json', title: 'URLs'}`.
3939+6. `state-machine` COMMAND_COMPLETE guard `isArrayOutput && hasDownstreamCommands` decides **OUTPUT_SELECTION vs CHAIN_MODE**. ← *this is where the current flow is ambiguous/fragile*.
4040+7. If OUTPUT_SELECTION: arrow-navigable list of rows; Enter/Tab on a row triggers `ITEM_SELECTED` event → transitions to CHAIN_MODE with the selected item as `chainContext.data`.
4141+8. CHAIN_MODE: `findChainingCommands(output.mimeType)` filters `state.commands` by `accepts` matching the output's mimeType. Shows those as suggestions.
4242+9. User picks `csv`. csv's execute receives `ctx.input = selected row`, returns `{data: csvString, mimeType: 'text/csv'}`. Flow loops: can chain further if something accepts `text/csv`.
4343+10. ESC in CHAIN_MODE → pops chain stack, returns to OUTPUT_SELECTION (or EXITS chain_mode entirely if stack empty). ESC in OUTPUT_SELECTION → CLOSING.
4444+4545+## The fragile bits
4646+4747+1. **OUTPUT_SELECTION vs CHAIN_MODE routing is guard-order-dependent.** In `state-machine.js:486-510` the transitions are checked top-down; the first matching guard wins. Current order:
4848+ - editor mimeType + array → OUTPUT_SELECTION (editor case)
4949+ - editor mimeType → CLOSING (publishEditorOpen)
5050+ - array + hasDownstreamCommands → CHAIN_MODE
5151+ - array → OUTPUT_SELECTION
5252+ - chainable → CHAIN_MODE
5353+5454+ So **if `hasDownstreamCommands` returns true, we skip OUTPUT_SELECTION entirely for non-editor array outputs**. Tests 3026/3203 expect OUTPUT_SELECTION first, THEN chain on selection. These two expectations are in tension — tests check the "select a row first, then chain" ergonomic, but the state machine's decision to enter CHAIN_MODE direct-from-EXECUTING skips that step.
5555+5656+2. **`findChainingCommands` lives in the panel's local view.** If `state.commands` is missing entries (e.g., feature tile's `cmd:register` didn't reach the panel), `hasDownstreamCommands` is incorrectly false and we enter OUTPUT_SELECTION instead of CHAIN_MODE. Equally, incorrect `accepts` metadata on a command → filter returns nothing.
5757+5858+3. **Pubsub reach.** The panel is a separate BrowserWindow with its own tile token (`cmd-ui` / entry `panel`). It receives pubsub through `extensionBroadcaster`'s iteration over trustedBuiltin-registered windows. If panel isn't registered (via `registerTrustedBuiltinWindow`), it gets no broadcasts and `state.commands` never populates from live events.
5959+6060+4. **mimeType wildcard semantics duplicated.** panel.js:595 `mimeTypeMatches` + the same logic implicit in `hasDownstreamCommands`. Any divergence causes confusing results.
6161+6262+5. **Chain stack is an ad-hoc linear array.** Pops on ESC. No branching support, no invariants. If a chain command produces output that re-enters selection, the stack model loses correspondence.
6363+6464+## Observable test failures (all describe a single architectural gap)
6565+6666+| Test | Asserts | What the architecture can't do reliably |
6767+|------|---------|----------------------------------------|
6868+| 3026 `selecting output item enters chain mode with filtered commands` | OUTPUT_SELECTION → select row → CHAIN_MODE with csv/save filtered | Needs OUTPUT_SELECTION first, then ITEM_SELECTED → CHAIN_MODE |
6969+| 3085 `csv command converts JSON to CSV format` | Full list urls → csv → text/csv output | Needs the chain to reach csv's execute with selected input |
7070+| 3149 `escape exits chain mode before closing panel` | ESC in CHAIN_MODE → OUTPUT_SELECTION (not CLOSING) | Chain-aware ESC layering |
7171+| 3203 `arrow navigation in output selection mode` | Arrow keys move selection cursor | Plain OUTPUT_SELECTION UI |
7272+| 3333 `list notes chains with csv` | Same flow as 3026 for `list notes` | Noun-based source command |
7373+7474+Test 2970 (`list urls command produces array output and enters output selection mode`) passes today because `hasDownstreamCommands` returns false (csv et al not reaching panel's `state.commands`), which routes to OUTPUT_SELECTION. Fixing #2 (pubsub reach) WOULD break 2970 if we keep the current guard order: `hasDownstreamCommands` would be true → CHAIN_MODE (skipping OUTPUT_SELECTION). Tests 2970 and 3026 are in direct tension with the current guard logic.
7575+7676+## Redesign sketch
7777+7878+Keep the pieces, fix the flow:
7979+8080+**Principle:** array output ALWAYS enters OUTPUT_SELECTION. CHAIN_MODE is reached from OUTPUT_SELECTION via explicit item selection (Enter/Tab), not directly from EXECUTING. This matches tests 2970, 3026, 3203 simultaneously and gives users a consistent "see the rows first" ergonomic.
8181+8282+State machine changes:
8383+- Drop the `array + hasDownstreamCommands → CHAIN_MODE` transition (guard-order line 503).
8484+- Keep `array → OUTPUT_SELECTION`.
8585+- In OUTPUT_SELECTION, Enter/Tab on a row:
8686+ - if chain commands exist for the output mimeType → CHAIN_MODE
8787+ - else → default action (open item / close panel)
8888+- Non-array single chainable output (editor string, url, etc.) can still go direct CHAIN_MODE.
8989+9090+Pubsub changes:
9191+- Verify the panel window is `registerTrustedBuiltinWindow`-ed so `extensionBroadcaster` sends pubsub to it. Today the panel's token is minted in `ipc.ts` window-open but we may not be calling `registerTrustedBuiltinWindow`.
9292+- Consolidate mimeType match into one shared util (`app/cmd/mime-match.js`), used by both `hasDownstreamCommands` and `findChainingCommands`.
9393+9494+Scope discipline:
9595+- `state.commands` is the single source of truth in the panel. Bugs in its population show up here.
9696+- Registry owner is the resident. Panel caches. If the panel ever shows stale data, log and requery — no silent drift.