experiments in a post-browser web
10
fork

Configure Feed

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

feat(cmd): add state machine module with transition table and dispatch

Pure state machine for the cmd panel with:
- State enum: IDLE, TYPING, RESULTS_OPEN, PARAM_MODE, EXECUTING, etc.
- Event enum for all user interactions and lifecycle events
- Transition table as data structure mapping (state, event) -> transitions
- Guard system for conditional transitions
- Action runner for side effects
- Invariant enforcement (bounds checking, mutual exclusivity)
- IZUI escape handler integration
- Concurrent execution guard (EXECUTING blocks new input)
- Testable without DOM/IPC dependencies

+855
+855
extensions/cmd/state-machine.js
··· 1 + /** 2 + * Cmd Panel State Machine 3 + * 4 + * Pure state machine module for the command panel. 5 + * No DOM or IPC dependencies — all side effects are handled by action callbacks. 6 + * 7 + * States: IDLE, TYPING, RESULTS_OPEN, PARAM_MODE, EXECUTING, OUTPUT_SELECTION, 8 + * CHAIN_MODE, CHAIN_POPUP, ERROR, CLOSING 9 + * 10 + * Usage: 11 + * import { createStateMachine, States, Events } from './state-machine.js'; 12 + * const machine = createStateMachine(actionHandlers); 13 + * machine.dispatch(Events.INPUT, { value: 'tags' }); 14 + */ 15 + 16 + // ===== State Enum ===== 17 + export const States = Object.freeze({ 18 + IDLE: 'IDLE', 19 + TYPING: 'TYPING', 20 + RESULTS_OPEN: 'RESULTS_OPEN', 21 + PARAM_MODE: 'PARAM_MODE', 22 + EXECUTING: 'EXECUTING', 23 + OUTPUT_SELECTION: 'OUTPUT_SELECTION', 24 + CHAIN_MODE: 'CHAIN_MODE', 25 + CHAIN_POPUP: 'CHAIN_POPUP', 26 + ERROR: 'ERROR', 27 + CLOSING: 'CLOSING', 28 + }); 29 + 30 + // ===== Event Enum ===== 31 + export const Events = Object.freeze({ 32 + // Input events 33 + INPUT: 'input', // User typed or cleared text 34 + // Key events 35 + ARROW_DOWN: 'ArrowDown', 36 + ARROW_UP: 'ArrowUp', 37 + ARROW_RIGHT: 'ArrowRight', 38 + TAB: 'Tab', 39 + SHIFT_TAB: 'ShiftTab', 40 + ENTER: 'Enter', 41 + ESCAPE: 'Escape', 42 + // Click events 43 + CLICK_RESULT: 'click_result', // Clicked a command result item 44 + CLICK_PARAM: 'click_param', // Clicked a param suggestion 45 + CLICK_OUTPUT: 'click_output', // Clicked an output selection item 46 + CLICK_CANCEL: 'cancel_click', // Clicked cancel on execution 47 + CLICK_CHAIN_CANCEL: 'chain_cancel_click', 48 + // Lifecycle events 49 + VISIBILITY_SHOWN: 'visibility:shown', 50 + VISIBILITY_HIDDEN: 'visibility:hidden', 51 + // Execution result events 52 + COMMAND_COMPLETE: 'command_complete', 53 + COMMAND_ERROR: 'command_error', 54 + COMMAND_TIMEOUT: 'command_timeout', 55 + // Chain popup events 56 + POPUP_RESULT: 'popup_result', 57 + // Error auto-hide 58 + ERROR_TIMEOUT: 'error_timeout', 59 + // Mode cycling 60 + CYCLE_MODE: 'cycle_mode', 61 + }); 62 + 63 + /** 64 + * Creates a new state machine instance. 65 + * 66 + * @param {Object} actions - Object of action handler functions. 67 + * Each action receives (payload, state) and can perform side effects. 68 + * Actions should NOT mutate state directly — the machine handles state transitions. 69 + * @param {Object} guards - Object of guard functions. 70 + * Each guard receives (payload, state) and returns a boolean. 71 + * @param {Function} [logger] - Optional logging function (tag, ...args) 72 + * @returns {Object} State machine interface 73 + */ 74 + export function createStateMachine(actions = {}, guards = {}, logger = null) { 75 + // ===== Internal State ===== 76 + let currentState = States.IDLE; 77 + 78 + // Discriminated-union mode state. The `type` field always matches `currentState`. 79 + // Additional mode-specific fields live alongside `type`. 80 + let modeState = { type: States.IDLE }; 81 + 82 + // Shared data that persists across state transitions 83 + let data = { 84 + // Command matching 85 + commands: {}, // command name -> command object 86 + matches: [], // command names matching typed text 87 + matchIndex: 0, 88 + matchCounts: {}, // frecency counts 89 + adaptiveFeedback: {}, // adaptive matching data 90 + typed: '', // current input text 91 + originalTyped: '', // text before Tab completion 92 + lastExecuted: '', // last executed command name 93 + 94 + // UI flags 95 + showResults: false, 96 + 97 + // Output selection 98 + outputItems: [], 99 + outputItemIndex: 0, 100 + outputMimeType: null, 101 + outputSourceCommand: null, 102 + 103 + // Chain mode 104 + chainContext: null, // { data, mimeType, title, sourceCommand } 105 + chainStack: [], 106 + 107 + // Param mode 108 + paramCommand: null, 109 + paramSuggestions: [], 110 + paramIndex: -1, 111 + paramGeneration: 0, 112 + 113 + // Execution 114 + executingCommand: null, 115 + executionTimeout: null, 116 + executionError: null, 117 + 118 + // Chain popup 119 + chainPopupActive: false, 120 + chainPopupWindowId: null, 121 + }; 122 + 123 + // ===== Guard Helpers ===== 124 + // Default guards that use `data` directly. Can be overridden via the `guards` parameter. 125 + 126 + const defaultGuards = { 127 + hasMatches: () => data.matches.length > 0, 128 + hasParamSuggestions: () => data.paramSuggestions.length > 0, 129 + commandHasParams: (payload) => { 130 + const cmdName = payload?.commandName || data.matches[data.matchIndex]; 131 + const cmd = cmdName && data.commands[cmdName]; 132 + return cmd && cmd.params && cmd.params.length > 0; 133 + }, 134 + isItemTypeParam: () => { 135 + const cmd = data.paramCommand && data.commands[data.paramCommand]; 136 + const paramDef = cmd && cmd.params && cmd.params[0]; 137 + return paramDef && paramDef.type === 'item'; 138 + }, 139 + isURL: (payload) => { 140 + return payload?.isURL === true; 141 + }, 142 + userCommittedToCommand: (payload) => { 143 + return payload?.committed === true; 144 + }, 145 + hasChainableOutput: (payload) => { 146 + const result = payload?.result; 147 + return result && result.output && result.output.data && result.output.mimeType; 148 + }, 149 + hasDownstreamCommands: (payload) => { 150 + return payload?.hasDownstream === true; 151 + }, 152 + isArrayOutput: (payload) => { 153 + const d = payload?.result?.output?.data; 154 + return Array.isArray(d) && d.length > 0; 155 + }, 156 + isEditorMimeType: (payload) => { 157 + const mt = payload?.result?.output?.mimeType; 158 + return mt === 'item' || mt === 'new-item'; 159 + }, 160 + isNotExecuting: () => currentState !== States.EXECUTING, 161 + inputEmpty: (payload) => !payload?.value || payload.value.trim().length === 0, 162 + inputNonEmpty: (payload) => payload?.value && payload.value.trim().length > 0, 163 + textNonEmpty: () => data.typed && data.typed.trim().length > 0, 164 + textEmpty: () => !data.typed || data.typed.trim().length === 0, 165 + resultsVisible: () => data.showResults, 166 + inParamMode: () => currentState === States.PARAM_MODE, 167 + inChainMode: () => currentState === States.CHAIN_MODE, 168 + inOutputSelection: () => currentState === States.OUTPUT_SELECTION, 169 + chainStackDeep: () => data.chainStack.length > 1, 170 + canNavigateDown: () => data.matchIndex + 1 < data.matches.length, 171 + canNavigateUp: () => data.matchIndex > 0, 172 + canNavigateParamDown: () => data.paramIndex + 1 < data.paramSuggestions.length, 173 + canNavigateParamUp: () => data.paramIndex > 0, 174 + canNavigateOutputDown: () => data.outputItemIndex + 1 < data.outputItems.length, 175 + canNavigateOutputUp: () => data.outputItemIndex > 0, 176 + isPromptAction: (payload) => payload?.result?.action === 'prompt', 177 + popupDone: (payload) => payload?.done === true, 178 + paramInputStillMatches: (payload) => { 179 + return payload?.paramStillMatches === true; 180 + }, 181 + }; 182 + 183 + function guard(name, payload) { 184 + const fn = guards[name] || defaultGuards[name]; 185 + if (!fn) { 186 + log('warn', `Guard "${name}" not found, returning false`); 187 + return false; 188 + } 189 + return fn(payload, data); 190 + } 191 + 192 + // ===== Logging ===== 193 + function log(...args) { 194 + if (logger) logger('cmd:sm', ...args); 195 + } 196 + 197 + // ===== Action Runner ===== 198 + function runAction(name, payload) { 199 + const fn = actions[name]; 200 + if (fn) { 201 + try { 202 + fn(payload, data); 203 + } catch (err) { 204 + log('error', `Action "${name}" threw:`, err); 205 + } 206 + } 207 + } 208 + 209 + // ===== Transition Table ===== 210 + // Each entry: { guard?: string|function, target: State, actions?: string[] } 211 + // First matching transition wins (guards evaluated in order). 212 + 213 + const transitions = { 214 + // ===== IDLE ===== 215 + [States.IDLE]: { 216 + [Events.INPUT]: [ 217 + { 218 + guard: 'inputNonEmpty', 219 + target: States.TYPING, 220 + actions: ['setTyped', 'computeMatches', 'checkParamEntry', 'updateGhostText', 'updateResults'], 221 + }, 222 + // Empty input stays IDLE (no-op) 223 + ], 224 + [Events.ARROW_DOWN]: [ 225 + { guard: 'hasMatches', target: States.RESULTS_OPEN, actions: ['showResults', 'renderResults'] }, 226 + ], 227 + [Events.ESCAPE]: [ 228 + { target: States.CLOSING, actions: ['shutdown'] }, 229 + ], 230 + [Events.ENTER]: [ 231 + // Empty input — no-op 232 + ], 233 + [Events.VISIBILITY_HIDDEN]: [ 234 + { target: States.CLOSING, actions: ['shutdown'] }, 235 + ], 236 + [Events.CYCLE_MODE]: [ 237 + { target: States.IDLE, actions: ['cycleMode'] }, 238 + ], 239 + [Events.ERROR_TIMEOUT]: [ 240 + // Can receive late error timeout — ignore 241 + ], 242 + }, 243 + 244 + // ===== TYPING ===== 245 + [States.TYPING]: { 246 + [Events.INPUT]: [ 247 + { 248 + guard: 'inputEmpty', 249 + target: States.IDLE, 250 + actions: ['clearInput', 'exitParamMode', 'updateGhostText', 'updateResults'], 251 + }, 252 + { 253 + // Check if typing enters param mode 254 + guard: (p) => p?.enterParamMode === true, 255 + target: States.PARAM_MODE, 256 + actions: ['setTyped', 'computeMatches', 'enterParamMode', 'updateGhostText', 'updateResults'], 257 + }, 258 + { 259 + guard: 'inputNonEmpty', 260 + target: States.TYPING, 261 + actions: ['setTyped', 'computeMatches', 'updateGhostText', 'updateResults'], 262 + }, 263 + ], 264 + [Events.ARROW_DOWN]: [ 265 + { guard: 'hasMatches', target: States.RESULTS_OPEN, actions: ['showResults', 'renderResults'] }, 266 + ], 267 + [Events.TAB]: [ 268 + { 269 + guard: (p) => guard('hasMatches', p) && guard('commandHasParams', p), 270 + target: States.PARAM_MODE, 271 + actions: ['tabCompleteCommand', 'enterParamMode', 'updateGhostText', 'updateResults'], 272 + }, 273 + { 274 + guard: 'hasMatches', 275 + target: States.TYPING, 276 + actions: ['tabCompleteCommand', 'updateGhostText', 'updateResults'], 277 + }, 278 + ], 279 + [Events.SHIFT_TAB]: [ 280 + { 281 + guard: 'hasMatches', 282 + target: States.TYPING, 283 + actions: ['shiftTabCycleCommand', 'updateGhostText', 'updateResults'], 284 + }, 285 + ], 286 + [Events.ENTER]: [ 287 + { 288 + guard: 'isURL', 289 + target: States.CLOSING, 290 + actions: ['openURL', 'recordFrecency', 'clearInput', 'shutdown'], 291 + }, 292 + { 293 + guard: 'userCommittedToCommand', 294 + target: States.EXECUTING, 295 + actions: ['recordFrecency', 'exitParamMode', 'executeCommand', 'clearInput', 'updateGhostText', 'updateResults'], 296 + }, 297 + { 298 + guard: 'inputNonEmpty', 299 + target: States.CLOSING, 300 + actions: ['openSearch', 'clearInput', 'shutdown'], 301 + }, 302 + // Empty input — no-op 303 + ], 304 + [Events.ESCAPE]: [ 305 + { guard: 'textNonEmpty', target: States.IDLE, actions: ['clearInput', 'clearMatches', 'updateGhostText', 'updateResults'] }, 306 + { target: States.CLOSING, actions: ['shutdown'] }, 307 + ], 308 + [Events.VISIBILITY_HIDDEN]: [ 309 + { target: States.CLOSING, actions: ['shutdown'] }, 310 + ], 311 + [Events.CYCLE_MODE]: [ 312 + { target: States.TYPING, actions: ['cycleMode'] }, 313 + ], 314 + }, 315 + 316 + // ===== RESULTS_OPEN ===== 317 + [States.RESULTS_OPEN]: { 318 + [Events.INPUT]: [ 319 + { 320 + guard: 'inputEmpty', 321 + target: States.IDLE, 322 + actions: ['clearInput', 'hideResults', 'updateGhostText', 'updateResults'], 323 + }, 324 + { 325 + guard: (p) => p?.enterParamMode === true, 326 + target: States.PARAM_MODE, 327 + actions: ['setTyped', 'hideResults', 'computeMatches', 'enterParamMode', 'updateGhostText', 'updateResults'], 328 + }, 329 + { 330 + target: States.TYPING, 331 + actions: ['setTyped', 'hideResults', 'computeMatches', 'updateGhostText', 'updateResults'], 332 + }, 333 + ], 334 + [Events.ARROW_DOWN]: [ 335 + { 336 + guard: 'canNavigateDown', 337 + target: States.RESULTS_OPEN, 338 + actions: ['navigateDown', 'updateGhostText', 'renderResults'], 339 + }, 340 + ], 341 + [Events.ARROW_UP]: [ 342 + { 343 + guard: 'canNavigateUp', 344 + target: States.RESULTS_OPEN, 345 + actions: ['navigateUp', 'updateGhostText', 'renderResults'], 346 + }, 347 + ], 348 + [Events.TAB]: [ 349 + { 350 + guard: (p) => guard('hasMatches', p) && guard('commandHasParams', p), 351 + target: States.PARAM_MODE, 352 + actions: ['tabCompleteCommand', 'enterParamMode', 'updateGhostText', 'updateResults'], 353 + }, 354 + { 355 + guard: 'hasMatches', 356 + target: States.RESULTS_OPEN, 357 + actions: ['tabCycleInResults', 'updateGhostText', 'renderResults'], 358 + }, 359 + ], 360 + [Events.ENTER]: [ 361 + { 362 + guard: 'isURL', 363 + target: States.CLOSING, 364 + actions: ['openURL', 'recordFrecency', 'clearInput', 'shutdown'], 365 + }, 366 + { 367 + // User committed — execute selected command 368 + target: States.EXECUTING, 369 + actions: ['recordFrecencyFromResults', 'exitParamMode', 'executeCommand', 'clearInput', 'hideResults', 'updateGhostText', 'updateResults'], 370 + }, 371 + ], 372 + [Events.CLICK_RESULT]: [ 373 + { 374 + target: States.EXECUTING, 375 + actions: ['setMatchIndexFromClick', 'recordFrecencyFromResults', 'exitParamMode', 'executeCommand', 'clearInput', 'hideResults', 'updateGhostText', 'updateResults'], 376 + }, 377 + ], 378 + [Events.ESCAPE]: [ 379 + { target: States.TYPING, actions: ['hideResults', 'updateResults'] }, 380 + ], 381 + [Events.VISIBILITY_HIDDEN]: [ 382 + { target: States.CLOSING, actions: ['shutdown'] }, 383 + ], 384 + }, 385 + 386 + // ===== PARAM_MODE ===== 387 + [States.PARAM_MODE]: { 388 + [Events.INPUT]: [ 389 + { 390 + guard: 'inputEmpty', 391 + target: States.IDLE, 392 + actions: ['clearInput', 'exitParamMode', 'updateGhostText', 'updateResults'], 393 + }, 394 + { 395 + guard: 'paramInputStillMatches', 396 + target: States.PARAM_MODE, 397 + actions: ['setTyped', 'updateParamSuggestions', 'updateGhostText', 'updateResults'], 398 + }, 399 + { 400 + // Input no longer matches the param command — exit param mode 401 + target: States.TYPING, 402 + actions: ['setTyped', 'exitParamMode', 'computeMatches', 'checkParamEntry', 'updateGhostText', 'updateResults'], 403 + }, 404 + ], 405 + [Events.ARROW_DOWN]: [ 406 + { 407 + guard: 'canNavigateParamDown', 408 + target: States.PARAM_MODE, 409 + actions: ['navigateParamDown', 'updateGhostText', 'updateResults'], 410 + }, 411 + ], 412 + [Events.ARROW_UP]: [ 413 + { 414 + guard: 'canNavigateParamUp', 415 + target: States.PARAM_MODE, 416 + actions: ['navigateParamUp', 'updateGhostText', 'updateResults'], 417 + }, 418 + ], 419 + [Events.TAB]: [ 420 + { 421 + guard: 'hasParamSuggestions', 422 + target: States.PARAM_MODE, 423 + actions: ['fillParamText', 'updateGhostText', 'updateResults'], 424 + }, 425 + ], 426 + [Events.ENTER]: [ 427 + { 428 + guard: (p) => guard('isItemTypeParam', p) && guard('hasParamSuggestions', p), 429 + target: States.EXECUTING, 430 + actions: ['acceptParamItem', 'exitParamMode'], 431 + }, 432 + { 433 + // Non-item param or no suggestions — execute with typed text 434 + target: States.EXECUTING, 435 + actions: ['recordFrecency', 'executeCommandWithParams', 'clearInput', 'exitParamMode', 'updateGhostText', 'updateResults'], 436 + }, 437 + ], 438 + [Events.CLICK_PARAM]: [ 439 + { 440 + guard: 'isItemTypeParam', 441 + target: States.EXECUTING, 442 + actions: ['setParamIndexFromClick', 'acceptParamItem', 'exitParamMode'], 443 + }, 444 + { 445 + target: States.EXECUTING, 446 + actions: ['setParamIndexFromClick', 'recordFrecency', 'executeCommandWithParams', 'clearInput', 'exitParamMode', 'updateGhostText', 'updateResults'], 447 + }, 448 + ], 449 + [Events.ESCAPE]: [ 450 + { target: States.TYPING, actions: ['exitParamMode', 'updateResults', 'updateGhostText'] }, 451 + ], 452 + [Events.VISIBILITY_HIDDEN]: [ 453 + { target: States.CLOSING, actions: ['exitParamMode', 'shutdown'] }, 454 + ], 455 + }, 456 + 457 + // ===== EXECUTING ===== 458 + [States.EXECUTING]: { 459 + [Events.COMMAND_COMPLETE]: [ 460 + { 461 + guard: 'isPromptAction', 462 + target: States.IDLE, 463 + actions: ['hideSpinner'], 464 + }, 465 + { 466 + guard: (p) => guard('isEditorMimeType', p) && guard('isArrayOutput', p), 467 + target: States.OUTPUT_SELECTION, 468 + actions: ['hideSpinner', 'enterOutputSelection'], 469 + }, 470 + { 471 + guard: (p) => guard('isEditorMimeType', p), 472 + target: States.CLOSING, 473 + actions: ['hideSpinner', 'publishEditorOpen', 'shutdown'], 474 + }, 475 + { 476 + guard: (p) => guard('isArrayOutput', p) && guard('hasDownstreamCommands', p), 477 + target: States.CHAIN_MODE, 478 + actions: ['hideSpinner', 'enterChainMode'], 479 + }, 480 + { 481 + guard: (p) => guard('isArrayOutput', p), 482 + target: States.OUTPUT_SELECTION, 483 + actions: ['hideSpinner', 'enterOutputSelection'], 484 + }, 485 + { 486 + guard: 'hasChainableOutput', 487 + target: States.CHAIN_MODE, 488 + actions: ['hideSpinner', 'enterChainMode'], 489 + }, 490 + { 491 + // No output — close 492 + target: States.CLOSING, 493 + actions: ['hideSpinner', 'exitChainModeIfActive', 'shutdownDelayed'], 494 + }, 495 + ], 496 + [Events.COMMAND_ERROR]: [ 497 + { target: States.ERROR, actions: ['hideSpinner', 'showError'] }, 498 + ], 499 + [Events.COMMAND_TIMEOUT]: [ 500 + { target: States.ERROR, actions: ['hideSpinner', 'showError'] }, 501 + ], 502 + [Events.CLICK_CANCEL]: [ 503 + { target: States.IDLE, actions: ['cancelExecution'] }, 504 + ], 505 + [Events.ESCAPE]: [ 506 + { target: States.IDLE, actions: ['cancelExecution'] }, 507 + ], 508 + // Block all other input while executing 509 + [Events.INPUT]: [], 510 + [Events.ENTER]: [], 511 + [Events.TAB]: [], 512 + [Events.ARROW_DOWN]: [], 513 + [Events.ARROW_UP]: [], 514 + [Events.VISIBILITY_HIDDEN]: [ 515 + { target: States.CLOSING, actions: ['cancelExecution', 'shutdown'] }, 516 + ], 517 + }, 518 + 519 + // ===== OUTPUT_SELECTION ===== 520 + [States.OUTPUT_SELECTION]: { 521 + [Events.ARROW_DOWN]: [ 522 + { 523 + guard: 'canNavigateOutputDown', 524 + target: States.OUTPUT_SELECTION, 525 + actions: ['navigateOutputDown', 'updateOutputUI'], 526 + }, 527 + ], 528 + [Events.ARROW_UP]: [ 529 + { 530 + guard: 'canNavigateOutputUp', 531 + target: States.OUTPUT_SELECTION, 532 + actions: ['navigateOutputUp', 'updateOutputUI'], 533 + }, 534 + ], 535 + [Events.ENTER]: [ 536 + { target: null, actions: ['selectOutputItem'] }, // target determined by action 537 + ], 538 + [Events.ARROW_RIGHT]: [ 539 + { target: null, actions: ['selectOutputItem'] }, // target determined by action 540 + ], 541 + [Events.CLICK_OUTPUT]: [ 542 + { target: null, actions: ['setOutputIndexFromClick', 'selectOutputItem'] }, 543 + ], 544 + [Events.ESCAPE]: [ 545 + { target: States.IDLE, actions: ['exitOutputSelection', 'clearInput', 'updateGhostText', 'updateResults'] }, 546 + ], 547 + [Events.VISIBILITY_HIDDEN]: [ 548 + { target: States.CLOSING, actions: ['exitOutputSelection', 'shutdown'] }, 549 + ], 550 + }, 551 + 552 + // ===== CHAIN_MODE ===== 553 + [States.CHAIN_MODE]: { 554 + [Events.INPUT]: [ 555 + { 556 + guard: 'inputEmpty', 557 + target: States.CHAIN_MODE, 558 + actions: ['clearTypedKeepChain', 'computeChainMatches', 'updateGhostText', 'updateResults'], 559 + }, 560 + { 561 + target: States.CHAIN_MODE, 562 + actions: ['setTyped', 'computeChainMatches', 'updateGhostText', 'updateResults'], 563 + }, 564 + ], 565 + [Events.ARROW_DOWN]: [ 566 + { 567 + guard: 'canNavigateDown', 568 + target: States.CHAIN_MODE, 569 + actions: ['navigateDown', 'updateGhostText', 'renderResults'], 570 + }, 571 + ], 572 + [Events.ARROW_UP]: [ 573 + { 574 + guard: 'canNavigateUp', 575 + target: States.CHAIN_MODE, 576 + actions: ['navigateUp', 'updateGhostText', 'renderResults'], 577 + }, 578 + ], 579 + [Events.TAB]: [ 580 + { 581 + guard: 'hasMatches', 582 + target: States.CHAIN_MODE, 583 + actions: ['tabCompleteCommand', 'updateGhostText', 'updateResults'], 584 + }, 585 + ], 586 + [Events.ENTER]: [ 587 + { 588 + guard: 'hasMatches', 589 + target: States.EXECUTING, 590 + actions: ['recordFrecencyFromResults', 'executeChainCommand', 'clearInput', 'updateGhostText', 'updateResults'], 591 + }, 592 + ], 593 + [Events.ESCAPE]: [ 594 + { guard: 'chainStackDeep', target: States.CHAIN_MODE, actions: ['chainUndo'] }, 595 + { target: States.IDLE, actions: ['exitChainMode', 'clearInput', 'updateGhostText', 'updateResults'] }, 596 + ], 597 + [Events.CLICK_CHAIN_CANCEL]: [ 598 + { target: States.IDLE, actions: ['exitChainMode', 'clearInput', 'updateGhostText', 'updateResults'] }, 599 + ], 600 + [Events.CLICK_RESULT]: [ 601 + { 602 + target: States.EXECUTING, 603 + actions: ['setMatchIndexFromClick', 'recordFrecencyFromResults', 'executeChainCommand', 'clearInput', 'updateGhostText', 'updateResults'], 604 + }, 605 + ], 606 + [Events.POPUP_RESULT]: [ 607 + { guard: 'popupDone', target: States.CLOSING, actions: ['shutdown'] }, 608 + { target: States.CHAIN_MODE, actions: ['updateChainFromPopup', 'computeChainMatches', 'showResults', 'updateGhostText', 'updateResults', 'refocusPanel'] }, 609 + ], 610 + [Events.VISIBILITY_HIDDEN]: [ 611 + { target: States.CLOSING, actions: ['exitChainMode', 'shutdown'] }, 612 + ], 613 + }, 614 + 615 + // ===== CHAIN_POPUP ===== 616 + [States.CHAIN_POPUP]: { 617 + [Events.POPUP_RESULT]: [ 618 + { guard: 'popupDone', target: States.CLOSING, actions: ['shutdown'] }, 619 + { target: States.CHAIN_MODE, actions: ['updateChainFromPopup', 'computeChainMatches', 'showResults', 'updateGhostText', 'updateResults', 'refocusPanel'] }, 620 + ], 621 + [Events.ESCAPE]: [ 622 + { target: States.CHAIN_MODE, actions: ['closeChainPopup'] }, 623 + ], 624 + [Events.VISIBILITY_HIDDEN]: [ 625 + { target: States.CLOSING, actions: ['exitChainMode', 'shutdown'] }, 626 + ], 627 + }, 628 + 629 + // ===== ERROR ===== 630 + [States.ERROR]: { 631 + [Events.ERROR_TIMEOUT]: [ 632 + { target: States.IDLE, actions: ['hideError'] }, 633 + ], 634 + [Events.INPUT]: [ 635 + { 636 + guard: 'inputNonEmpty', 637 + target: States.TYPING, 638 + actions: ['hideError', 'setTyped', 'computeMatches', 'updateGhostText', 'updateResults'], 639 + }, 640 + { target: States.IDLE, actions: ['hideError'] }, 641 + ], 642 + [Events.ESCAPE]: [ 643 + { target: States.IDLE, actions: ['hideError'] }, 644 + ], 645 + [Events.VISIBILITY_HIDDEN]: [ 646 + { target: States.CLOSING, actions: ['hideError', 'shutdown'] }, 647 + ], 648 + }, 649 + 650 + // ===== CLOSING ===== 651 + [States.CLOSING]: { 652 + // Terminal state — panel hides and resets on next visibility change. 653 + [Events.VISIBILITY_SHOWN]: [ 654 + { target: States.IDLE, actions: ['resetAllState'] }, 655 + ], 656 + // Absorb everything else 657 + }, 658 + }; 659 + 660 + // ===== Dispatch ===== 661 + 662 + /** 663 + * Dispatch an event to the state machine. 664 + * Evaluates guards in order and fires the first matching transition. 665 + * 666 + * @param {string} event - Event name from Events enum 667 + * @param {Object} [payload] - Optional event payload 668 + * @returns {{ handled: boolean, prevState: string, nextState: string|null }} 669 + */ 670 + function dispatch(event, payload = {}) { 671 + const stateTransitions = transitions[currentState]; 672 + if (!stateTransitions) { 673 + log('warn', `No transitions defined for state "${currentState}"`); 674 + return { handled: false, prevState: currentState, nextState: null }; 675 + } 676 + 677 + const eventTransitions = stateTransitions[event]; 678 + if (!eventTransitions || eventTransitions.length === 0) { 679 + log('debug', `No transition for (${currentState}, ${event})`); 680 + return { handled: false, prevState: currentState, nextState: null }; 681 + } 682 + 683 + const prevState = currentState; 684 + 685 + for (const transition of eventTransitions) { 686 + // Evaluate guard 687 + if (transition.guard) { 688 + let guardResult; 689 + if (typeof transition.guard === 'function') { 690 + guardResult = transition.guard(payload); 691 + } else { 692 + guardResult = guard(transition.guard, payload); 693 + } 694 + if (!guardResult) continue; 695 + } 696 + 697 + // Transition matched — execute 698 + const target = transition.target; 699 + 700 + // Run actions 701 + if (transition.actions) { 702 + for (const actionName of transition.actions) { 703 + runAction(actionName, payload); 704 + } 705 + } 706 + 707 + // Update state (target can be null for actions that determine state themselves) 708 + if (target !== null && target !== undefined) { 709 + currentState = target; 710 + } 711 + 712 + // Enforce invariants 713 + enforceInvariants(); 714 + 715 + log('transition', `${prevState} + ${event} → ${currentState}`, payload?.value !== undefined ? `(input: "${payload.value}")` : ''); 716 + 717 + return { handled: true, prevState, nextState: currentState }; 718 + } 719 + 720 + // No guard matched 721 + log('debug', `No guard matched for (${currentState}, ${event})`); 722 + return { handled: false, prevState: currentState, nextState: null }; 723 + } 724 + 725 + // ===== Invariant Enforcement ===== 726 + 727 + function enforceInvariants() { 728 + // Bound matchIndex 729 + if (data.matches.length === 0) { 730 + data.matchIndex = 0; 731 + } else if (data.matchIndex >= data.matches.length) { 732 + data.matchIndex = data.matches.length - 1; 733 + } else if (data.matchIndex < 0) { 734 + data.matchIndex = 0; 735 + } 736 + 737 + // Bound paramIndex 738 + if (data.paramSuggestions.length === 0) { 739 + data.paramIndex = -1; 740 + } else if (data.paramIndex >= data.paramSuggestions.length) { 741 + data.paramIndex = data.paramSuggestions.length - 1; 742 + } 743 + 744 + // Bound outputItemIndex 745 + if (data.outputItems.length === 0) { 746 + data.outputItemIndex = 0; 747 + } else if (data.outputItemIndex >= data.outputItems.length) { 748 + data.outputItemIndex = data.outputItems.length - 1; 749 + } else if (data.outputItemIndex < 0) { 750 + data.outputItemIndex = 0; 751 + } 752 + 753 + // Mutual exclusivity — if we're not in the right state, clear mode data 754 + if (currentState !== States.PARAM_MODE) { 755 + data.paramCommand = null; 756 + data.paramSuggestions = []; 757 + data.paramIndex = -1; 758 + } 759 + 760 + if (currentState !== States.OUTPUT_SELECTION) { 761 + data.outputItems = []; 762 + data.outputItemIndex = 0; 763 + data.outputMimeType = null; 764 + data.outputSourceCommand = null; 765 + } 766 + 767 + if (currentState !== States.CHAIN_MODE && currentState !== States.CHAIN_POPUP && currentState !== States.EXECUTING) { 768 + // Don't clear chain state during EXECUTING since we might return to chain mode 769 + } 770 + } 771 + 772 + // ===== Public Interface ===== 773 + 774 + return { 775 + /** 776 + * Dispatch an event to the state machine 777 + */ 778 + dispatch, 779 + 780 + /** 781 + * Get the current state name 782 + */ 783 + getState() { 784 + return currentState; 785 + }, 786 + 787 + /** 788 + * Get a read-only snapshot of the data 789 + */ 790 + getData() { 791 + return data; 792 + }, 793 + 794 + /** 795 + * Get mutable reference to data (for action handlers to modify) 796 + */ 797 + getMutableData() { 798 + return data; 799 + }, 800 + 801 + /** 802 + * Force-set state (for testing or reset on visibility change) 803 + */ 804 + setState(newState) { 805 + currentState = newState; 806 + }, 807 + 808 + /** 809 + * Reset the machine to initial state with fresh data 810 + */ 811 + reset() { 812 + currentState = States.IDLE; 813 + data.typed = ''; 814 + data.originalTyped = ''; 815 + data.matches = []; 816 + data.matchIndex = 0; 817 + data.showResults = false; 818 + data.outputItems = []; 819 + data.outputItemIndex = 0; 820 + data.outputMimeType = null; 821 + data.outputSourceCommand = null; 822 + data.chainContext = null; 823 + data.chainStack = []; 824 + data.paramCommand = null; 825 + data.paramSuggestions = []; 826 + data.paramIndex = -1; 827 + data.executingCommand = null; 828 + data.executionTimeout = null; 829 + data.executionError = null; 830 + data.chainPopupActive = false; 831 + data.chainPopupWindowId = null; 832 + data.lastExecuted = ''; 833 + }, 834 + 835 + /** 836 + * Check if the machine is in a specific state 837 + */ 838 + is(stateName) { 839 + return currentState === stateName; 840 + }, 841 + 842 + /** 843 + * Returns the IZUI escape handler result. 844 + * Delegates to dispatch(ESCAPE) and returns { handled } for the IZUI flow. 845 + */ 846 + handleEscape() { 847 + const result = dispatch(Events.ESCAPE); 848 + // If we transitioned to CLOSING, the backend should close the window 849 + if (currentState === States.CLOSING) { 850 + return { handled: false }; 851 + } 852 + return { handled: result.handled }; 853 + }, 854 + }; 855 + }