experiments in a post-browser web
10
fork

Configure Feed

Select the types of activity you want to include in your feed.

docs: cmd bar state machine design

+538
+538
docs/cmd-state-machine.md
··· 1 + # Cmd Bar State Machine Design 2 + 3 + ## 1. Current State Analysis 4 + 5 + ### 1.1 Recent Changes (Last Week) 6 + 7 + Four commits touched cmd bar code in the past week, each addressing regressions or behavioral inconsistencies: 8 + 9 + **`oyvpktvp` — fix(cmd): restore param mode for connector commands, route item selection through execute** 10 + - Problem: Param mode (`edit` command's autocomplete) was bypassing `execute()` and directly publishing `editor:open`, which broke chaining, output handling, and lazy extension loading. 11 + - Fix: `acceptParamSuggestion()` now routes item-type params through `execute()`. Added comments clarifying that param mode should enter for any command with `params`, including connector commands. 12 + - Regression risk: The routing change means `execute()` must handle `selectedItem` in context, which was not part of the original contract. 13 + 14 + **`ourylmsr` — fix(cmd): editor:open lazy-load interceptor + cmd palette blink on hotkey** 15 + - Problem: When cmd panel fires `editor:open` before the editor extension is loaded, the event is lost. Also, the cmd panel would briefly flash visible then hide when triggered by hotkey while app was not focused (because `did-resign-active` was hiding all `alwaysOnTop` windows, including focusable ones like the cmd panel). 16 + - Fix: Added `registerLazyEventInterceptors()` in main.ts to catch `editor:open`/`editor:add` and load the editor extension first. Fixed `did-resign-active` to skip focusable `alwaysOnTop` windows. 17 + - Note: This fix is in main.ts, not panel.js, but it papers over a sequencing issue that a state machine should address (executing a command before its target extension is loaded). 18 + 19 + **`llsxksrz` — fix(cmd): param mode Tab fills text, Enter executes with correct item identity** 20 + - Problem: Tab in param mode was executing the command (calling `acceptParamSuggestion`), and Enter was passing item identity by search text (which could match the wrong item). 21 + - Fix: Split into `fillParamSuggestion()` (Tab, text-only) and `acceptParamSuggestion()` (Enter, executes). Enter now passes `selectedItem` directly through `execute()`. The `edit` command checks `ctx.selectedItem` first. 22 + - New tests added for Tab-fills and Enter-executes semantics. 23 + 24 + **`nlxwwmyv` — fix(cmd): restore two-tone inline suggestion styling (bold typed, dim completion)** 25 + - Problem: After Tab-cycling through suggestions, the inline ghost text was using `state.typed` (which now contains the completed command name) for the highlight boundary, making everything bold. 26 + - Fix: Introduced `state.originalTyped` tracking and used it in `updateCommandUI()` so the bold/dim split reflects what the user actually typed, not what Tab inserted. 27 + 28 + ### 1.2 Architecture Overview 29 + 30 + The cmd bar system spans three execution contexts: 31 + 32 + 1. **Background process** (`background.js`): Owns the command registry. Handles registration/unregistration from other extensions. Opens the panel window. 33 + 2. **Panel window** (`panel.js` + `commands.js`): The interactive UI. Maintains local command copies via pubsub proxy. Handles all user interaction. 34 + 3. **Commands module** (`commands.js`): Bridges background registry to panel. Creates proxy commands that execute via pubsub round-trip. 35 + 36 + The panel is a `keepLive` window -- it is created once and reused by hiding/showing. On visibility change, all modes are reset. 37 + 38 + ### 1.3 Current State Fields (from `panel.js`) 39 + 40 + ```javascript 41 + state = { 42 + // Command matching 43 + commands: {}, // Object map of command name -> command object 44 + matches: [], // Commands matching current typed text 45 + matchIndex: 0, // Selected match index 46 + matchCounts: {}, // Frecency counts (persisted) 47 + adaptiveFeedback: {}, // Adaptive matching data (persisted) 48 + typed: '', // Current input text 49 + originalTyped: '', // Text before Tab completion 50 + lastExecuted: '', // Last executed command name 51 + 52 + // UI visibility 53 + showResults: false, // Whether results dropdown is visible 54 + 55 + // Output selection mode 56 + outputSelectionMode: false, 57 + outputItems: [], 58 + outputItemIndex: 0, 59 + outputMimeType: null, 60 + outputSourceCommand: null, 61 + 62 + // Chain mode 63 + chainMode: false, 64 + chainContext: null, // { data, mimeType, title, sourceCommand } 65 + chainStack: [], 66 + 67 + // Param mode 68 + paramMode: false, 69 + paramCommand: null, 70 + paramSuggestions: [], 71 + paramIndex: -1, 72 + paramGeneration: 0, // Stale async guard 73 + 74 + // Execution 75 + executing: false, 76 + executingCommand: null, 77 + executionTimeout: null, 78 + executionError: null, 79 + } 80 + ``` 81 + 82 + ### 1.4 Current Bugs and Inconsistencies 83 + 84 + 1. **Escape handler duplication**: Both `api.escape.onEscape()` (IZUI flow, lines 555-606) and `handleSpecialKey()` Escape branch (lines 615-663) implement identical escape-layering logic. If the IZUI escape interception changes, they can drift apart. 85 + 86 + 2. **No guard against concurrent execution**: `execute()` can be called while a previous command is still running (the `executing` flag is checked nowhere before starting a new execution). 87 + 88 + 3. **Click handler bypasses state machine**: The click handler on result items (line 2619-2628) calls `execute()` directly and manually updates frecency, duplicating logic from the Enter key path. 89 + 90 + 4. **`acceptParamSuggestion` for non-item params does not execute**: For non-item-type params (tags, enums), `acceptParamSuggestion()` just fills text and refreshes suggestions -- same as `fillParamSuggestion()`. Enter in param mode only routes through `acceptParamSuggestion` for item-type params (line 670 guard). For tag params, Enter falls through to the normal command execution path, which may or may not work correctly depending on whether the typed text matches the command name. 91 + 92 + 5. **`originalTyped` not reset on all paths**: `originalTyped` is set in the input handler but not reset when the panel is re-shown via visibility change. It is also not cleared when entering chain mode or output selection mode. 93 + 94 + 6. **Chain popup timing fragility**: `openChainPopup` uses a hardcoded 300ms delay before publishing content to the popup (line 1210), relying on the popup's ES module loading within that window. 95 + 96 + 7. **Mode indicator hidden for 'default'**: The CSS rule `display: none` on `[data-mode="default"]` means the mode indicator is invisible most of the time. Mode cycling requires clicking an invisible element first (unless in a non-default mode). 97 + 98 + --- 99 + 100 + ## 2. State Machine Specification 101 + 102 + ### 2.1 States 103 + 104 + The cmd bar can be in exactly one of these states at any time: 105 + 106 + | State | Description | Entry Condition | 107 + |-------|-------------|-----------------| 108 + | **IDLE** | Panel visible, input empty, no results shown | Panel shown (visibility change), or all content cleared | 109 + | **TYPING** | User is typing, matches being computed, ghost text visible | Any character input when in IDLE or TYPING | 110 + | **RESULTS_OPEN** | Results dropdown visible, user can navigate with arrows | ArrowDown when matches exist, or chain mode active with matches | 111 + | **PARAM_MODE** | Command committed, showing parameter suggestions | Tab-complete or typed `commandName + space` for a command with params | 112 + | **EXECUTING** | A command is running (async), spinner may be visible | Enter/click triggers execute() | 113 + | **OUTPUT_SELECTION** | Showing array output items for user selection | Command returned array output with item/new-item mimeType or no downstream commands | 114 + | **CHAIN_MODE** | Piping output between commands, chain indicator visible | Command returned chainable output with downstream consumers | 115 + | **CHAIN_POPUP** | Chain popup window open for interactive editing | Chain mode entered with text/* output | 116 + | **ERROR** | Execution failed, error message displayed | Command threw or timed out | 117 + | **CLOSING** | Panel is shutting down | shutdown() called | 118 + 119 + ### 2.2 State Transition Diagram 120 + 121 + ``` 122 + +---------+ 123 + panel shown | IDLE | panel hidden 124 + +------------>| |<-----------+ 125 + | +----+----+ | 126 + | | | 127 + | typing | ArrowDown | 128 + | chars | (matches>0) | 129 + | v | 130 + | +--------+ | 131 + | | TYPING |<------+ | 132 + | +---+----+ | | 133 + | | | | 134 + | ArrowDown | input | | 135 + | (matches>0) | changed | | 136 + | v | | 137 + | +-----------+ | | 138 + | | RESULTS |-------+ | 139 + | | _OPEN | typing | 140 + | +-----+-----+ | 141 + | | | 142 + | Tab/typed | Enter/click | 143 + | cmd+space | | 144 + | (has params) | | 145 + | v v | 146 + | +-----------+ +-----------+ | 147 + | | PARAM | | EXECUTING | | 148 + | | _MODE | +-----+-----+ | 149 + | +-----+-----+ | | 150 + | | +----+----+----+ | 151 + | Enter | | | | | | 152 + | (item)| no | array chain | error 153 + | | output| output output| | 154 + | v v v v v v | 155 + | +-----------+ +------+ +------+ +-+-----+ 156 + | | EXECUTING | |OUTPUT| |CHAIN | | ERROR | 157 + | +-----------+ |SELECT| |_MODE | +-------+ 158 + | +------+ +--+---+ 159 + | | 160 + | text/*|output 161 + | v 162 + | +----------+ 163 + | |CHAIN | 164 + | |_POPUP | 165 + | +----------+ 166 + | 167 + +--- Escape (layered: paramMode > outputSelect > chain > results > text > close) 168 + ``` 169 + 170 + ### 2.3 Transitions 171 + 172 + Each transition is defined as: `(CurrentState, Event) -> (NextState, Actions, Guards)` 173 + 174 + #### From IDLE 175 + 176 + | Event | Guard | Next State | Actions | 177 + |-------|-------|------------|---------| 178 + | `input` (non-empty) | -- | TYPING | Set `typed`, compute matches, update ghost text UI | 179 + | `ArrowDown` | matches.length > 0 | RESULTS_OPEN | Set `showResults=true`, render results list | 180 + | `Escape` | -- | CLOSING | Call `shutdown()` | 181 + | `Enter` | input empty | IDLE | No-op (log "empty input") | 182 + | `visibility:hidden` | -- | CLOSING | Window hidden by system | 183 + 184 + #### From TYPING 185 + 186 + | Event | Guard | Next State | Actions | 187 + |-------|-------|------------|---------| 188 + | `input` (changed) | text non-empty | TYPING | Recompute matches, check param mode entry, update UI | 189 + | `input` (cleared) | text empty | IDLE | Clear matches, exit param mode if active, update UI | 190 + | `ArrowDown` | matches.length > 0 | RESULTS_OPEN | Set `showResults=true`, render results | 191 + | `Tab` | matches.length > 0, cmd has params | PARAM_MODE | Complete command name + space, enter param mode | 192 + | `Tab` | matches.length > 0, no params | TYPING | Complete command name + space, cycle if already completed | 193 + | `Enter` | typed is URL | CLOSING | Open URL, record frecency, shutdown | 194 + | `Enter` | user committed to command | EXECUTING | Build context, call `execute()` | 195 + | `Enter` | no match, text non-empty | CLOSING | Open search view, shutdown | 196 + | `Escape` | text non-empty | IDLE | Clear input and matches | 197 + | `Escape` | text empty | CLOSING | Call `shutdown()` | 198 + | `input` (typed matches `cmdName + space`, cmd has params) | -- | PARAM_MODE | Enter param mode while typing continues | 199 + 200 + #### From RESULTS_OPEN 201 + 202 + | Event | Guard | Next State | Actions | 203 + |-------|-------|------------|---------| 204 + | `input` (changed) | -- | TYPING | Recompute matches, `showResults=false` | 205 + | `ArrowDown` | matchIndex < matches.length - 1 | RESULTS_OPEN | Increment matchIndex, update selection | 206 + | `ArrowUp` | matchIndex > 0 | RESULTS_OPEN | Decrement matchIndex, update selection | 207 + | `Tab` | cmd has params | PARAM_MODE | Complete selected command, enter param mode | 208 + | `Tab` | no params | RESULTS_OPEN | Cycle through matches | 209 + | `Enter` | user committed | EXECUTING | Execute selected command | 210 + | `click` (result item) | -- | EXECUTING | Set matchIndex, execute command | 211 + | `Escape` | -- | TYPING | Hide results (`showResults=false`) | 212 + 213 + #### From PARAM_MODE 214 + 215 + | Event | Guard | Next State | Actions | 216 + |-------|-------|------------|---------| 217 + | `input` (changed) | text still starts with `cmdName + space` | PARAM_MODE | Update param suggestions async | 218 + | `input` (changed) | text no longer matches command | TYPING | Exit param mode, recompute matches | 219 + | `input` (cleared) | -- | IDLE | Exit param mode, clear everything | 220 + | `ArrowDown` | paramSuggestions.length > 0 | PARAM_MODE | Increment paramIndex | 221 + | `ArrowUp` | paramIndex > 0 | PARAM_MODE | Decrement paramIndex | 222 + | `Tab` | paramSuggestions.length > 0 | PARAM_MODE | Fill suggestion text into input (do NOT execute) | 223 + | `Enter` | item-type param, suggestions exist | EXECUTING | Accept param suggestion, route through `execute()` with `selectedItem` | 224 + | `Enter` | non-item param | EXECUTING | Execute command with current typed text as params | 225 + | `Escape` | -- | TYPING | Exit param mode, keep typed text | 226 + | `click` (suggestion) | -- | EXECUTING | Accept param suggestion (item-type) or fill + execute | 227 + 228 + #### From EXECUTING 229 + 230 + | Event | Guard | Next State | Actions | 231 + |-------|-------|------------|---------| 232 + | `command_complete` | no output | CLOSING | Exit chain mode if active, shutdown after 100ms | 233 + | `command_complete` | output mimeType = item/new-item, single | CLOSING | Publish `editor:open`/`editor:add`, shutdown | 234 + | `command_complete` | output mimeType = item/new-item, array | OUTPUT_SELECTION | Enter output selection mode | 235 + | `command_complete` | output with downstream commands, array | CHAIN_MODE | Enter chain mode with full array | 236 + | `command_complete` | output with downstream commands, single | CHAIN_MODE | Enter chain mode | 237 + | `command_complete` | output, no downstream, array | OUTPUT_SELECTION | Enter output selection mode | 238 + | `command_complete` | result.action = 'prompt' | IDLE | Keep panel open for user interaction | 239 + | `command_error` | -- | ERROR | Show error message, clear timers | 240 + | `command_timeout` | 30s elapsed | ERROR | Show timeout error | 241 + | `cancel_click` | -- | IDLE | Hide execution state | 242 + | `Escape` | -- | IDLE | Cancel execution (Note: currently not handled during execution) | 243 + 244 + #### From OUTPUT_SELECTION 245 + 246 + | Event | Guard | Next State | Actions | 247 + |-------|-------|------------|---------| 248 + | `ArrowDown` | outputItemIndex < outputItems.length - 1 | OUTPUT_SELECTION | Navigate down, update preview | 249 + | `ArrowUp` | outputItemIndex > 0 | OUTPUT_SELECTION | Navigate up, update preview | 250 + | `Enter` / `ArrowRight` | mimeType = item, item has id | CLOSING | Open editor, shutdown | 251 + | `Enter` / `ArrowRight` | other mimeType | CHAIN_MODE | Enter chain mode with selected item | 252 + | `click` (item) | -- | (same as Enter) | Select and proceed | 253 + | `Escape` | -- | IDLE | Exit output selection mode, clear input | 254 + 255 + #### From CHAIN_MODE 256 + 257 + | Event | Guard | Next State | Actions | 258 + |-------|-------|------------|---------| 259 + | `input` (typing) | -- | CHAIN_MODE | Filter matches to chain-compatible commands | 260 + | `ArrowDown` | -- | CHAIN_MODE | Navigate chain command list | 261 + | `ArrowUp` | -- | CHAIN_MODE | Navigate chain command list | 262 + | `Tab` | -- | CHAIN_MODE | Complete chain command name | 263 + | `Enter` | command selected | EXECUTING | Execute chain command with chain context | 264 + | `Escape` | chainStack.length > 1 | CHAIN_MODE | Undo one step (pop stack) | 265 + | `Escape` | chainStack.length <= 1 | IDLE | Exit chain mode entirely | 266 + | `chain_cancel_click` | -- | IDLE | Exit chain mode, clear input | 267 + | `popup_result` | popup signals done | CLOSING | Shutdown | 268 + | `popup_result` | popup returns data | CHAIN_MODE | Update chain context, show commands | 269 + 270 + #### From CHAIN_POPUP 271 + 272 + | Event | Guard | Next State | Actions | 273 + |-------|-------|------------|---------| 274 + | `popup:result` | msg.done = true | CLOSING | Shutdown | 275 + | `popup:result` | msg.done = false | CHAIN_MODE | Update chain context, refocus panel | 276 + | `Escape` | -- | CHAIN_MODE | Close popup, return to chain commands | 277 + 278 + #### From ERROR 279 + 280 + | Event | Guard | Next State | Actions | 281 + |-------|-------|------------|---------| 282 + | `timeout` (5s auto-hide) | -- | IDLE | Hide error state | 283 + | `input` | -- | TYPING | Hide error, start typing | 284 + | `Escape` | -- | IDLE | Hide error state | 285 + 286 + #### From CLOSING 287 + 288 + Terminal state. Panel hides and resets on next visibility change -> IDLE. 289 + 290 + ### 2.4 Guards 291 + 292 + Guards are boolean conditions that determine which transition fires when multiple transitions share the same (State, Event) pair: 293 + 294 + | Guard | Condition | 295 + |-------|-----------| 296 + | `hasMatches` | `state.matches.length > 0` | 297 + | `hasParamSuggestions` | `state.paramSuggestions.length > 0` | 298 + | `commandHasParams` | `cmd.params && cmd.params.length > 0` | 299 + | `isItemTypeParam` | `paramDef.type === 'item'` | 300 + | `isURL` | `getValidURL(typed).valid` | 301 + | `userCommittedToCommand` | Typed equals command name, or starts with `cmdName + space`, or `showResults` is true, or in chain mode | 302 + | `hasChainableOutput` | `result.output && result.output.data && result.output.mimeType` | 303 + | `hasDownstreamCommands` | `findChainingCommands(mimeType).length > 0` | 304 + | `isArrayOutput` | `Array.isArray(outputData) && outputData.length > 0` | 305 + | `isEditorMimeType` | `mimeType === 'item' || mimeType === 'new-item'` | 306 + | `isNotExecuting` | `!state.executing` (currently unenforced) | 307 + | `inputEmpty` | `!commandInput.value` | 308 + | `inputNonEmpty` | `commandInput.value.trim().length > 0` | 309 + 310 + ### 2.5 Actions 311 + 312 + Side effects that occur during transitions: 313 + 314 + | Action | Description | 315 + |--------|-------------| 316 + | `computeMatches(typed)` | Run `findMatchingCommands()` with adaptive/frecency sorting | 317 + | `updateGhostText()` | Render the two-tone command suggestion overlay | 318 + | `renderResults()` | Build and show the results dropdown | 319 + | `hideResults()` | Remove results dropdown | 320 + | `enterParamMode(cmdName)` | Set param state, begin async suggestion fetching | 321 + | `exitParamMode()` | Clear param state | 322 + | `fillParamText(index)` | Insert suggestion text into input without executing | 323 + | `acceptParam(index)` | Execute command with selected param item | 324 + | `executeCommand(name, typed, extra)` | Build context, run command, handle output | 325 + | `recordFrecency(name, typed)` | Update adaptive feedback and match counts | 326 + | `showSpinner(name)` | Show execution progress (delayed 150ms) | 327 + | `hideSpinner()` | Hide execution progress | 328 + | `showError(name, msg)` | Show error state with auto-hide timer | 329 + | `enterOutputSelection(items, mime, source)` | Switch to output picking UI | 330 + | `exitOutputSelection()` | Clear output selection state | 331 + | `enterChainMode(output, source)` | Set chain context, filter to compatible commands | 332 + | `exitChainMode()` | Clear chain state, close popups | 333 + | `openChainPopup(url, data, mime)` | Open interactive editor popup | 334 + | `resizeWindow()` | Adjust window height based on visible content | 335 + | `openURL(url)` | Open URL in new content window | 336 + | `openSearch(query)` | Open search view with typed text | 337 + | `publishEditorOpen(itemId)` | Publish `editor:open` event | 338 + | `shutdown()` | Close/hide the panel window | 339 + | `resetAllState()` | Reset all state fields to defaults (on panel re-show) | 340 + 341 + ### 2.6 Invariants 342 + 343 + The state machine must maintain these invariants at all times: 344 + 345 + 1. **Mutual exclusivity of major modes**: Exactly one of `{paramMode, outputSelectionMode, chainMode}` can be true, or all are false. They cannot overlap. 346 + 2. **paramCommand requires paramMode**: If `paramMode` is false, `paramCommand` must be null. 347 + 3. **chainContext requires chainMode**: If `chainMode` is false, `chainContext` must be null and `chainStack` must be empty. 348 + 4. **executing blocks new execution**: While `executing` is true, no new `execute()` call should be started. 349 + 5. **matchIndex in bounds**: `matchIndex` must be `>= 0` and `< matches.length` (or 0 when matches is empty). 350 + 6. **paramIndex in bounds**: `paramIndex` must be `>= -1` and `< paramSuggestions.length`. 351 + 7. **outputItemIndex in bounds**: When in output selection mode, `outputItemIndex >= 0` and `< outputItems.length`. 352 + 8. **Escape layering order**: Escape always exits the innermost active mode first: paramMode -> outputSelection -> chainMode -> showResults -> clearText -> close. 353 + 354 + --- 355 + 356 + ## 3. Test Coverage Plan 357 + 358 + ### 3.1 State Entry/Exit Tests 359 + 360 + Each test verifies a single transition fires correctly: 361 + 362 + **IDLE transitions:** 363 + - [ ] IDLE + character input -> TYPING (typed set, matches computed) 364 + - [ ] IDLE + ArrowDown with matches -> RESULTS_OPEN 365 + - [ ] IDLE + ArrowDown with no matches -> IDLE (no-op) 366 + - [ ] IDLE + Escape -> CLOSING 367 + - [ ] IDLE + Enter with empty input -> IDLE (no-op) 368 + 369 + **TYPING transitions:** 370 + - [ ] TYPING + input changed -> TYPING (matches recomputed) 371 + - [ ] TYPING + input cleared -> IDLE 372 + - [ ] TYPING + ArrowDown -> RESULTS_OPEN 373 + - [ ] TYPING + Tab (has params) -> PARAM_MODE 374 + - [ ] TYPING + Tab (no params) -> TYPING (name completed) 375 + - [ ] TYPING + Tab cycling (already completed) -> TYPING (next match) 376 + - [ ] TYPING + Shift+Tab -> TYPING (previous match) 377 + - [ ] TYPING + Enter with URL -> CLOSING (URL opened) 378 + - [ ] TYPING + Enter with committed command -> EXECUTING 379 + - [ ] TYPING + Enter with no match -> CLOSING (search opened) 380 + - [ ] TYPING + Escape with text -> IDLE (text cleared) 381 + - [ ] TYPING + Escape with empty text -> CLOSING 382 + - [ ] TYPING + typed `cmdName + space` (cmd has params) -> PARAM_MODE (auto-enter) 383 + 384 + **RESULTS_OPEN transitions:** 385 + - [ ] RESULTS_OPEN + input changed -> TYPING (results hidden) 386 + - [ ] RESULTS_OPEN + ArrowDown -> RESULTS_OPEN (index incremented) 387 + - [ ] RESULTS_OPEN + ArrowDown at end -> RESULTS_OPEN (no-op) 388 + - [ ] RESULTS_OPEN + ArrowUp -> RESULTS_OPEN (index decremented) 389 + - [ ] RESULTS_OPEN + ArrowUp at 0 -> RESULTS_OPEN (no-op) 390 + - [ ] RESULTS_OPEN + Tab -> RESULTS_OPEN or PARAM_MODE 391 + - [ ] RESULTS_OPEN + Enter -> EXECUTING 392 + - [ ] RESULTS_OPEN + click item -> EXECUTING 393 + - [ ] RESULTS_OPEN + Escape -> TYPING (results hidden) 394 + 395 + **PARAM_MODE transitions:** 396 + - [ ] PARAM_MODE + input (still matches cmd) -> PARAM_MODE (suggestions updated) 397 + - [ ] PARAM_MODE + input (no longer matches cmd) -> TYPING (param mode exited) 398 + - [ ] PARAM_MODE + input cleared -> IDLE 399 + - [ ] PARAM_MODE + ArrowDown -> PARAM_MODE (paramIndex incremented) 400 + - [ ] PARAM_MODE + ArrowUp -> PARAM_MODE (paramIndex decremented) 401 + - [ ] PARAM_MODE + Tab -> PARAM_MODE (text filled, not executed) 402 + - [ ] PARAM_MODE + Enter (item-type) -> EXECUTING (with selectedItem) 403 + - [ ] PARAM_MODE + Enter (tag-type) -> EXECUTING (with typed params) 404 + - [ ] PARAM_MODE + Escape -> TYPING 405 + - [ ] PARAM_MODE + click suggestion (item-type) -> EXECUTING 406 + 407 + **EXECUTING transitions:** 408 + - [ ] EXECUTING + complete (no output) -> CLOSING 409 + - [ ] EXECUTING + complete (item output, single) -> CLOSING (editor opened) 410 + - [ ] EXECUTING + complete (item output, array) -> OUTPUT_SELECTION 411 + - [ ] EXECUTING + complete (chainable output, single) -> CHAIN_MODE 412 + - [ ] EXECUTING + complete (chainable output, array, has downstream) -> CHAIN_MODE 413 + - [ ] EXECUTING + complete (output, array, no downstream) -> OUTPUT_SELECTION 414 + - [ ] EXECUTING + complete (action=prompt) -> IDLE 415 + - [ ] EXECUTING + error -> ERROR 416 + - [ ] EXECUTING + timeout -> ERROR 417 + - [ ] EXECUTING + cancel click -> IDLE 418 + 419 + **OUTPUT_SELECTION transitions:** 420 + - [ ] OUTPUT_SELECTION + ArrowDown -> OUTPUT_SELECTION (navigate) 421 + - [ ] OUTPUT_SELECTION + ArrowUp -> OUTPUT_SELECTION (navigate) 422 + - [ ] OUTPUT_SELECTION + Enter (item type) -> CLOSING (editor) 423 + - [ ] OUTPUT_SELECTION + Enter (other type) -> CHAIN_MODE 424 + - [ ] OUTPUT_SELECTION + ArrowRight -> same as Enter 425 + - [ ] OUTPUT_SELECTION + click item -> same as Enter 426 + - [ ] OUTPUT_SELECTION + Escape -> IDLE 427 + 428 + **CHAIN_MODE transitions:** 429 + - [ ] CHAIN_MODE + input -> CHAIN_MODE (filtered matches) 430 + - [ ] CHAIN_MODE + Enter (command selected) -> EXECUTING 431 + - [ ] CHAIN_MODE + Escape (stack > 1) -> CHAIN_MODE (undo) 432 + - [ ] CHAIN_MODE + Escape (stack <= 1) -> IDLE 433 + - [ ] CHAIN_MODE + cancel click -> IDLE 434 + - [ ] CHAIN_MODE + popup result (done) -> CLOSING 435 + - [ ] CHAIN_MODE + popup result (data) -> CHAIN_MODE (updated context) 436 + 437 + **ERROR transitions:** 438 + - [ ] ERROR + 5s timeout -> IDLE 439 + - [ ] ERROR + input -> TYPING 440 + - [ ] ERROR + Escape -> IDLE 441 + 442 + ### 3.2 Edge Case Tests 443 + 444 + - [ ] **Rapid typing during async param suggestions**: Type fast, verify only the latest generation's results are applied (stale guard via `paramGeneration`) 445 + - [ ] **Escape during execution**: Currently unhandled -- verify graceful behavior 446 + - [ ] **Double Enter**: Press Enter twice quickly -- verify second execute is blocked by `executing` guard 447 + - [ ] **Tab then immediate Enter**: Tab fills param, Enter executes -- verify correct item identity 448 + - [ ] **Panel re-show resets all state**: Hide panel, re-show -- verify all modes cleared 449 + - [ ] **Chain mode Escape layering**: In chain mode with param mode active, Escape exits param first, then chain 450 + - [ ] **Click during param mode**: Click a result item while in param mode -- verify correct execution path 451 + - [ ] **URL typed in chain mode**: Type a URL while in chain mode -- verify it doesn't bypass chain 452 + - [ ] **Empty input Enter in chain mode**: Verify no crash or unexpected behavior 453 + - [ ] **Command not found during execution**: Verify error state shown 454 + - [ ] **Panel visibility change during execution**: Verify execution state cleaned up 455 + - [ ] **originalTyped preserved across Tab cycles**: Tab through 3 matches, verify ghost text always shows original input bold 456 + - [ ] **Param mode auto-entry from typing**: Type "edit " (with space) -- verify param mode entered without Tab 457 + - [ ] **Param mode auto-exit from backspace**: In param mode, backspace to remove space -- verify param mode exited 458 + 459 + ### 3.3 Integration Tests (Full Sequences) 460 + 461 + - [ ] **Basic command**: Open panel -> type "tags" -> ArrowDown -> Enter -> verify command executed -> panel closed 462 + - [ ] **Tab completion -> param mode -> select item**: Open -> type "ed" -> Tab (completes "edit ") -> wait for suggestions -> ArrowDown -> Enter -> verify editor opened with correct item 463 + - [ ] **Chain mode flow**: Open -> type "list notes" -> Enter -> (output selection if array) -> select item -> chain mode -> type next command -> Enter -> verify chaining worked 464 + - [ ] **URL opening**: Open -> type "github.com" -> Enter -> verify URL opened -> panel closed 465 + - [ ] **Search fallback**: Open -> type "xyzzy" (no match) -> Enter -> verify search view opened 466 + - [ ] **Escape layering full sequence**: Open -> type "edit " (param mode) -> Escape (exits param) -> Escape (clears text) -> Escape (closes panel) 467 + - [ ] **Mode cycling**: Open -> click mode indicator -> verify mode cycles -> verify commands filtered by mode 468 + - [ ] **Error recovery**: Open -> execute failing command -> verify error shown -> type new command -> verify error hidden -> execute succeeds 469 + 470 + --- 471 + 472 + ## 4. Implementation Notes 473 + 474 + ### 4.1 Refactoring Strategy 475 + 476 + The current `panel.js` implements the state machine implicitly through scattered `if/else` checks in event handlers. To make it explicit: 477 + 478 + 1. **Define a `currentState` variable** that holds the current state name (string enum). 479 + 2. **Create a transition table** as a data structure mapping `(state, event) -> {guard, nextState, actions}`. 480 + 3. **Centralize event dispatch** through a single `dispatch(event, payload)` function that: 481 + - Looks up valid transitions for `(currentState, event)` 482 + - Evaluates guards 483 + - Runs actions 484 + - Updates `currentState` 485 + - Logs the transition for debugging 486 + 4. **Wire DOM events to dispatch calls** rather than directly manipulating state. 487 + 488 + ### 4.2 Suggested State Object Simplification 489 + 490 + Instead of flat boolean flags, group state into mode-specific sub-objects: 491 + 492 + ```javascript 493 + // Current (flat, error-prone) 494 + state.paramMode = true; 495 + state.paramCommand = 'edit'; 496 + state.outputSelectionMode = false; // Must be kept in sync 497 + 498 + // Proposed (discriminated union) 499 + state.mode = { 500 + type: 'param', // 'idle' | 'typing' | 'results' | 'param' | 'executing' | 'output' | 'chain' | 'error' 501 + command: 'edit', // Only present in 'param' mode 502 + suggestions: [...], // Only present in 'param' mode 503 + selectedIndex: 0, // Only present in 'param' mode 504 + }; 505 + ``` 506 + 507 + This makes invalid states unrepresentable -- you cannot have `paramMode=true` and `outputSelectionMode=true` simultaneously. 508 + 509 + ### 4.3 Key Principles 510 + 511 + 1. **Commands are data, not control flow**: The state machine should never contain command-specific logic. Commands provide metadata (params, accepts, produces), and the state machine uses that metadata to choose transitions. 512 + 513 + 2. **Actions are pure side effects**: Each action function should do exactly one thing (update DOM, send IPC, etc.) and should be independently testable. 514 + 515 + 3. **Escape is always layered**: The escape handler should be a simple lookup: "what is the innermost active layer?" -> exit it. No duplicate implementations. 516 + 517 + 4. **Execution is a state, not an action**: While a command is executing, the state machine is in EXECUTING state. No other transitions (except cancel/error) should be valid. 518 + 519 + 5. **`originalTyped` should be first-class**: Instead of patching it in post-hoc, the state machine should distinguish between "user typed text" and "machine-completed text" as separate state fields from the start. 520 + 521 + ### 4.4 Migration Path 522 + 523 + 1. **Phase 1**: Extract the state enum and transition table as documentation (this document). 524 + 2. **Phase 2**: Add the `currentState` variable and `dispatch()` function alongside existing code. Both run in parallel; dispatch logs but doesn't act. 525 + 3. **Phase 3**: Migrate one state at a time, starting with IDLE and TYPING. Verify tests pass after each migration. 526 + 4. **Phase 4**: Remove old `if/else` chains once all states are migrated. 527 + 5. **Phase 5**: Add the `executing` guard to prevent concurrent execution. 528 + 529 + ### 4.5 Testing Infrastructure 530 + 531 + Tests should use the existing pattern from `smoke.spec.ts`: 532 + - Open cmd panel via `app.window.open()` 533 + - Access internal state via `window._cmdState` 534 + - Simulate input via `cmdWindow.fill()` and `cmdWindow.keyboard.press()` 535 + - Wait for state changes via `cmdWindow.waitForFunction()` 536 + - Verify side effects via pubsub event listeners on `bgWindow` 537 + 538 + For unit tests of the state machine itself (no DOM/IPC), extract the transition table and dispatch logic into a separate module that can be tested with `yarn test:unit`.