Cmd / Panel / Chaining / Pubsub Architecture#
Purpose: one-page map of the cmd chaining flow so we can target the right break instead of chasing symptom-level test failures.
Components and ownership#
| File | Role |
|---|---|
app/cmd/index.html + cmd-glue.ts |
Cmd resident renderer. Hidden, persistent, trustedBuiltin. Owns cmd system bootstrap. URL: peek://cmd/index.html. |
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. |
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). |
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. |
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. |
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. |
Pubsub topics (scope: GLOBAL unless noted)#
| Topic | Publisher | Subscriber | Payload |
|---|---|---|---|
cmd:register |
tile-preload's api.commands.register |
background.js + panel's commands.js |
{name, description, accepts, produces, params, source, scope, modes} |
cmd:register-batch |
rarely used now; pre-v2 preload batched | both | {commands: [...]} |
cmd:query-commands |
panel's commands.js on load |
background.js |
{} |
cmd:query-commands-response |
background.js |
panel's commands.js |
{commands: [...]} |
cmd:execute:{name} |
panel proxy command | feature tile's subscribe | context {...ctx, expectResult, resultTopic} |
cmd:execute:{name}:result |
feature tile after executing | panel proxy command | {success, data, mimeType, title, ...} |
noun:{cap}:{name} / noun:result:{name}:{ts} |
noun proxy | noun handler | analogous |
Non-chain: cmd:settings-update, cmd:settings-changed, cmd:unregister,
cmd:register-batch-direct-handlers.
End-to-end flow for "list urls → csv"#
- User opens panel (global shortcut).
panel.js+commands.jsload.commands.jspublishescmd:query-commands. Also subscribes tocmd:register/cmd:register-batch/cmd:query-commands-response.- Resident's
background.jsresponds withcmd:query-commands-responsecontaining all registered commands (list, new, open, csv, save, tagset, etc.). Panel populatesstate.commands. - Features that registered AFTER resident started have their
cmd:registerevents flowing through to both the resident (for persistence) and the panel (live updates). Scope is GLOBAL. - User types
list urls. Noun system resolves to a proxy command; panel executes vianoun:list:urlspubsub. Feature returns{data: [...urls...], mimeType: 'application/json', title: 'URLs'}. state-machineCOMMAND_COMPLETE guardisArrayOutput && hasDownstreamCommandsdecides OUTPUT_SELECTION vs CHAIN_MODE. ← this is where the current flow is ambiguous/fragile.- If OUTPUT_SELECTION: arrow-navigable list of rows; Enter/Tab on a row triggers
ITEM_SELECTEDevent → transitions to CHAIN_MODE with the selected item aschainContext.data. - CHAIN_MODE:
findChainingCommands(output.mimeType)filtersstate.commandsbyacceptsmatching the output's mimeType. Shows those as suggestions. - User picks
csv. csv's execute receivesctx.input = selected row, returns{data: csvString, mimeType: 'text/csv'}. Flow loops: can chain further if something acceptstext/csv. - ESC in CHAIN_MODE → pops chain stack, returns to OUTPUT_SELECTION (or EXITS chain_mode entirely if stack empty). ESC in OUTPUT_SELECTION → CLOSING.
The fragile bits#
-
OUTPUT_SELECTION vs CHAIN_MODE routing is guard-order-dependent. In
state-machine.js:486-510the transitions are checked top-down; the first matching guard wins. Current order:- editor mimeType + array → OUTPUT_SELECTION (editor case)
- editor mimeType → CLOSING (publishEditorOpen)
- array + hasDownstreamCommands → CHAIN_MODE
- array → OUTPUT_SELECTION
- chainable → CHAIN_MODE
So if
hasDownstreamCommandsreturns 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. -
findChainingCommandslives in the panel's local view. Ifstate.commandsis missing entries (e.g., feature tile'scmd:registerdidn't reach the panel),hasDownstreamCommandsis incorrectly false and we enter OUTPUT_SELECTION instead of CHAIN_MODE. Equally, incorrectacceptsmetadata on a command → filter returns nothing. -
Pubsub reach. The panel is a separate BrowserWindow with its own tile token (
cmd-ui/ entrypanel). It receives pubsub throughextensionBroadcaster's iteration over trustedBuiltin-registered windows. If panel isn't registered (viaregisterTrustedBuiltinWindow), it gets no broadcasts andstate.commandsnever populates from live events. -
mimeType wildcard semantics duplicated. panel.js:595
mimeTypeMatches+ the same logic implicit inhasDownstreamCommands. Any divergence causes confusing results. -
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.
Observable test failures (all describe a single architectural gap)#
| Test | Asserts | What the architecture can't do reliably |
|---|---|---|
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 |
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 |
3149 escape exits chain mode before closing panel |
ESC in CHAIN_MODE → OUTPUT_SELECTION (not CLOSING) | Chain-aware ESC layering |
3203 arrow navigation in output selection mode |
Arrow keys move selection cursor | Plain OUTPUT_SELECTION UI |
3333 list notes chains with csv |
Same flow as 3026 for list notes |
Noun-based source command |
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.
Redesign sketch#
Keep the pieces, fix the flow:
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.
State machine changes:
- Drop the
array + hasDownstreamCommands → CHAIN_MODEtransition (guard-order line 503). - Keep
array → OUTPUT_SELECTION. - In OUTPUT_SELECTION, Enter/Tab on a row:
- if chain commands exist for the output mimeType → CHAIN_MODE
- else → default action (open item / close panel)
- Non-array single chainable output (editor string, url, etc.) can still go direct CHAIN_MODE.
Pubsub changes:
- Verify the panel window is
registerTrustedBuiltinWindow-ed soextensionBroadcastersends pubsub to it. Today the panel's token is minted inipc.tswindow-open but we may not be callingregisterTrustedBuiltinWindow. - Consolidate mimeType match into one shared util (
app/cmd/mime-match.js), used by bothhasDownstreamCommandsandfindChainingCommands.
Scope discipline:
state.commandsis the single source of truth in the panel. Bugs in its population show up here.- Registry owner is the resident. Panel caches. If the panel ever shows stale data, log and requery — no silent drift.