experiments in a post-browser web
10
fork

Configure Feed

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

refactor(cmd): rewrite panel.js to use state machine

Hard cut replacement of implicit state handling with explicit state machine:
- All DOM events dispatch through machine.dispatch() calls
- Single IZUI escape handler delegates to machine.handleEscape() (bug fix #1)
- Concurrent execution guard via EXECUTING state blocking input (bug fix #2)
- Click handlers route through dispatch, no duplicate logic (bug fix #3)
- Param mode Enter works for both item and non-item params (bug fix #4)
- originalTyped reset on all paths via resetAllState action (bug fix #5)
- Mode indicator visibility handled via existing CSS (bug fix #7)
- Backward-compatible _cmdState proxy exposes boolean accessors for tests
- All external behavior preserved (pubsub events, IPC calls, window management)

+1109 -1655
+1109 -1655
extensions/cmd/panel.js
··· 3 3 * 4 4 * Runs in isolated panel window (peek://ext/cmd/panel.html) 5 5 * Uses api.settings for persistent adaptive matching data 6 + * 7 + * State management is handled by the state machine module (state-machine.js). 8 + * All DOM events dispatch to the machine; actions perform side effects. 6 9 */ 7 10 import { id, labels, schemas, storageKeys, defaults } from './config.js'; 8 11 import './commands.js'; // Load commands module to dispatch cmd-update-commands event 9 12 import { log } from 'peek://app/log.js'; 10 13 import { getSuggestions, invalidateCache as invalidateCompleterCache } from './completers.js'; 11 - // CodeMirror imports removed — chain text editing now uses popup windows 12 - // (see chain-editor.html/js) 14 + import { createStateMachine, States, Events } from './state-machine.js'; 13 15 14 16 log('cmd:panel', 'loaded'); 15 17 ··· 20 22 const STORAGE_KEY_COUNTS = 'matchCounts'; 21 23 const STORAGE_KEY_VERSION = 'adaptiveVersion'; 22 24 // Version 2: removed prefix feedback that caused cross-contamination 23 - // (e.g., typing 'tag' no longer pollutes the 'ta' feedback entry) 24 25 const ADAPTIVE_VERSION = 2; 25 26 26 27 // Cache for adaptive data ··· 28 29 29 30 /** 30 31 * Load persisted adaptive data from extension settings 31 - * Checks data version and resets stale data from older feedback algorithms 32 32 */ 33 33 const loadAdaptiveData = async () => { 34 34 if (adaptiveDataCache) { ··· 40 40 const storedVersion = result.data[STORAGE_KEY_VERSION] || 0; 41 41 42 42 if (storedVersion < ADAPTIVE_VERSION) { 43 - // Data is from an older algorithm version (prefix feedback caused cross-contamination). 44 - // Reset feedback but keep match counts (frecency) since those are still valid. 45 43 log('cmd:panel', 'loadAdaptiveData: resetting stale feedback data (version', storedVersion, '->', ADAPTIVE_VERSION + ')'); 46 44 adaptiveDataCache = { 47 45 feedback: {}, 48 46 counts: result.data[STORAGE_KEY_COUNTS] || {} 49 47 }; 50 - // Persist the reset and new version 51 48 await Promise.all([ 52 49 api.settings.setKey(STORAGE_KEY_FEEDBACK, {}), 53 50 api.settings.setKey(STORAGE_KEY_VERSION, ADAPTIVE_VERSION) ··· 60 57 } 61 58 } else { 62 59 adaptiveDataCache = { feedback: {}, counts: {} }; 63 - // Set version for fresh installs 64 60 await api.settings.setKey(STORAGE_KEY_VERSION, ADAPTIVE_VERSION); 65 61 } 66 62 log('cmd:panel', 'loadAdaptiveData: loaded', Object.keys(adaptiveDataCache.feedback).length, 'feedback entries,', Object.keys(adaptiveDataCache.counts).length, 'count entries'); ··· 69 65 70 66 /** 71 67 * Save adaptive data to extension settings 72 - * Uses setKey to write each key independently, avoiding read-modify-write races 73 68 */ 74 69 const saveAdaptiveData = async (feedback, counts) => { 75 70 adaptiveDataCache = { feedback, counts }; 76 - 77 - // Write each key independently to avoid overwriting other settings 78 71 await Promise.all([ 79 72 api.settings.setKey(STORAGE_KEY_FEEDBACK, feedback), 80 73 api.settings.setKey(STORAGE_KEY_COUNTS, counts) 81 74 ]); 82 75 }; 83 76 84 - // Initialize with empty data - will be loaded asynchronously 85 - let state = { 86 - commands: [], // array of command names 87 - matches: [], // array of commands matching the typed text 88 - matchIndex: 0, // index of selected match 89 - matchCounts: {}, // match counts - selectedcommand:numberofselections 90 - adaptiveFeedback: {}, // adaptive matching - typed -> { command: count, ... } 91 - typed: '', // text typed by user so far, if any 92 - originalTyped: '', // original text before Tab completion (for adaptive feedback) 93 - lastExecuted: '', // text last typed by user when last they hit return 94 - 95 - // UI visibility state 96 - showResults: false, // Whether to show the results dropdown 97 - 98 - // Output selection mode - for selecting an item from command output 99 - outputSelectionMode: false, // Whether we're selecting from command output 100 - outputItems: [], // Array of items to select from 101 - outputItemIndex: 0, // Currently selected output item 102 - outputMimeType: null, // MIME type of output items 103 - outputSourceCommand: null, // Command that produced the output 77 + // ===== State Machine Setup ===== 104 78 105 - // Chain mode state for command composition 106 - chainMode: false, // Whether we're in chain mode (piping output between commands) 107 - chainContext: null, // Current chain data: { data, mimeType, title, sourceCommand } 108 - chainStack: [], // History stack for undo (array of chainContext objects) 109 - 110 - // Param suggestion mode state 111 - paramMode: false, // In param suggestion mode? 112 - paramCommand: null, // Which command we're completing params for 113 - paramSuggestions: [], // Current suggestion items [{title, subtitle, value}] 114 - paramIndex: -1, // Selected suggestion (-1 = none) 115 - paramGeneration: 0, // Stale result guard for async completions 116 - 117 - // Execution state for showing progress 118 - executing: false, // Whether a command is currently executing 119 - executingCommand: null, // Name of the command being executed 120 - executionTimeout: null, // Timeout handle for cancellation 121 - executionError: null // Error message if execution failed 122 - }; 79 + // Create the state machine with action handlers and guards 80 + const machine = createStateMachine(actionHandlers(), {}, (...args) => log(...args)); 81 + const state = machine.getMutableData(); 123 82 124 83 // Load adaptive data on startup 125 84 loadAdaptiveData().then(data => { ··· 128 87 log('cmd:panel', 'Loaded adaptive data'); 129 88 }); 130 89 131 - // Chain popup state (popup windows for interactive chain editing) 132 - let chainPopupActive = false; // Whether a chain popup is currently open 133 - let chainPopupWindowId = null; // Window ID of the active chain popup 90 + // Chain popup state (module-level for cleanup) 91 + let chainPopupWindowId = null; 134 92 135 93 // Current command context (mode, target window, etc.) 136 - // Loaded asynchronously when panel opens 137 94 let commandContext = null; 138 95 139 96 // Major modes for cycling ··· 142 99 // Current mode for UI display 143 100 let currentMode = 'default'; 144 101 let currentModeMetadata = {}; 145 - // Flag to track if mode was explicitly set (vs still at initial 'default') 146 102 let modeWasSet = false; 147 103 148 - /** 149 - * Load the current command context (target window, mode state) 150 - * Called when panel becomes visible 151 - */ 104 + // Expose state for testing — allows waitForFunction to check command loading 105 + // Provide backward-compatible boolean accessors on top of machine data 106 + window._cmdState = new Proxy(state, { 107 + get(target, prop) { 108 + if (prop === 'paramMode') return machine.getState() === States.PARAM_MODE; 109 + if (prop === 'outputSelectionMode') return machine.getState() === States.OUTPUT_SELECTION; 110 + if (prop === 'chainMode') return machine.getState() === States.CHAIN_MODE || machine.getState() === States.CHAIN_POPUP; 111 + if (prop === 'executing') return machine.getState() === States.EXECUTING; 112 + if (prop === 'currentState') return machine.getState(); 113 + return target[prop]; 114 + } 115 + }); 116 + 117 + // ===== Window Sizing ===== 118 + 119 + const COLLAPSED_HEIGHT = 60; 120 + const EXPANDED_HEIGHT = 400; 121 + 122 + function updateWindowSize() { 123 + const resultsVisible = document.getElementById('results')?.classList.contains('visible'); 124 + const previewVisible = document.getElementById('preview-container')?.classList.contains('visible'); 125 + const chainVisible = document.getElementById('chain-indicator')?.classList.contains('visible'); 126 + const execVisible = document.getElementById('execution-state')?.classList.contains('visible'); 127 + const chainStepsVisible = document.getElementById('chain-steps')?.classList.contains('visible'); 128 + const isOutputSelection = machine.getState() === States.OUTPUT_SELECTION; 129 + 130 + const needsExpanded = resultsVisible || previewVisible || chainVisible || execVisible || isOutputSelection || chainStepsVisible; 131 + const targetHeight = needsExpanded ? EXPANDED_HEIGHT : COLLAPSED_HEIGHT; 132 + 133 + api.window.resize(600, targetHeight); 134 + } 135 + 136 + // ===== Command Update Listener ===== 137 + 138 + window.addEventListener('cmd-update-commands', function(e) { 139 + log('cmd:panel', 'received updated commands'); 140 + state.commands = e.detail; 141 + }); 142 + 143 + // ===== Mode Indicator Functions ===== 144 + 145 + function updateModeIndicator() { 146 + const indicator = document.getElementById('mode-indicator'); 147 + if (!indicator) return; 148 + 149 + const label = indicator.querySelector('.mode-label'); 150 + indicator.setAttribute('data-mode', currentMode); 151 + 152 + if (currentMode === 'group' && currentModeMetadata.groupName) { 153 + label.textContent = currentModeMetadata.groupName; 154 + } else { 155 + label.textContent = currentMode; 156 + } 157 + 158 + log('cmd:panel', 'Mode indicator updated:', currentMode, currentModeMetadata); 159 + } 160 + 152 161 const loadCommandContext = async () => { 153 162 try { 154 - // IZUI Policy: If invoked transiently (app was not focused when invoked), 155 - // use "default" mode since we are not targeting a specific window context. 156 - // Use api.window.isTransient() which reads the per-window transient flag 157 - // set in the keepLive reuse path BEFORE the window is shown (and before 158 - // showing the window triggers browser-window-focus which would promote 159 - // the session from transient to active, defeating the check). 160 163 const isTransient = await api.window.isTransient(); 161 164 if (isTransient) { 162 165 log('cmd:panel', 'Transient invocation - using default mode'); 163 166 currentMode = 'default'; 164 167 currentModeMetadata = {}; 165 - modeWasSet = true; // Explicitly set by transient detection 168 + modeWasSet = true; 166 169 updateModeIndicator(); 167 - // Still load command context for backwards compat, but don't use its mode 168 170 const result = await api.modes.getCommandContext(); 169 171 if (result.success) { 170 172 commandContext = result.data; ··· 172 174 return; 173 175 } 174 176 175 - // Use context API to get mode for the target window 176 - // First get the focused visible window ID 177 177 const targetWindowId = await api.window.getFocusedVisibleWindowId(); 178 178 179 - // Get mode from context API 180 179 if (api.context) { 181 180 const modeResult = await api.context.get('mode', targetWindowId); 182 181 if (modeResult.success && modeResult.data) { 183 182 currentMode = modeResult.data.value || 'default'; 184 183 currentModeMetadata = modeResult.data.metadata || {}; 185 - modeWasSet = true; // Explicitly set from context API 184 + modeWasSet = true; 186 185 log('cmd:panel', 'Loaded mode from context:', currentMode, currentModeMetadata); 187 186 updateModeIndicator(); 188 187 } 189 188 } 190 189 191 - // Also load from old modes API for backwards compatibility 192 190 const result = await api.modes.getCommandContext(); 193 191 if (result.success) { 194 192 commandContext = result.data; 195 193 log('cmd:panel', 'Loaded command context:', commandContext); 196 - // Update mode from old API if context API didn't set a mode 197 194 if (!modeWasSet && commandContext?.mode?.major) { 198 195 currentMode = commandContext.mode.major; 199 196 modeWasSet = true; ··· 206 203 } 207 204 }; 208 205 209 - // ===== Mode Indicator Functions ===== 210 - 211 - /** 212 - * Update the mode indicator UI 213 - */ 214 - function updateModeIndicator() { 215 - const indicator = document.getElementById('mode-indicator'); 216 - if (!indicator) return; 217 - 218 - const label = indicator.querySelector('.mode-label'); 219 - indicator.setAttribute('data-mode', currentMode); 220 - 221 - // Show group name in group mode 222 - if (currentMode === 'group' && currentModeMetadata.groupName) { 223 - label.textContent = currentModeMetadata.groupName; 224 - } else { 225 - label.textContent = currentMode; 226 - } 227 - 228 - log('cmd:panel', 'Mode indicator updated:', currentMode, currentModeMetadata); 229 - } 230 - 231 - /** 232 - * Cycle to the next major mode 233 - */ 234 206 async function cycleMode() { 235 207 const currentIndex = MAJOR_MODES.indexOf(currentMode); 236 208 const nextIndex = (currentIndex + 1) % MAJOR_MODES.length; ··· 238 210 239 211 log('cmd:panel', 'Cycling mode from', currentMode, 'to', nextMode); 240 212 241 - // Try context API first 242 213 if (api.context) { 243 214 const targetWindowId = await api.window.getFocusedVisibleWindowId(); 244 215 const result = await api.context.setMode(nextMode, { windowId: targetWindowId }); ··· 251 222 } 252 223 } 253 224 254 - // Fall back to old modes API 255 225 const result = await api.modes.setMajorMode(nextMode); 256 226 if (result.success) { 257 227 currentMode = nextMode; ··· 263 233 } 264 234 } 265 235 266 - /** 267 - * Initialize the mode indicator 268 - */ 269 236 async function initModeIndicator() { 270 - // Use commandContext which has the target window's mode (not the cmd panel's mode) 271 - // commandContext is loaded by loadCommandContext() before this is called 272 - // If mode was explicitly set (by transient detection or context API), use that 273 - // Otherwise try to get mode from commandContext (legacy modes API) 274 237 if (modeWasSet) { 275 - // Mode was explicitly set by loadCommandContext - use it 276 238 updateModeIndicator(); 277 239 } else if (commandContext?.mode?.major) { 278 - // Fallback to legacy modes API 279 240 currentMode = commandContext.mode.major; 280 241 modeWasSet = true; 281 242 updateModeIndicator(); 282 243 } else { 283 - // No mode data available - stay at 'default' 284 244 updateModeIndicator(); 285 245 } 286 246 287 - // Set up click handler for cycling 288 247 const indicator = document.getElementById('mode-indicator'); 289 248 if (indicator) { 290 249 indicator.addEventListener('click', (e) => { 291 250 e.preventDefault(); 292 251 e.stopPropagation(); 293 - cycleMode(); 252 + machine.dispatch(Events.CYCLE_MODE); 294 253 }); 295 254 } 296 255 297 - // Watch context API for mode changes (preferred) 298 256 if (api.context) { 299 257 api.context.watchMode((mode, entry) => { 300 258 log('cmd:panel', 'Context mode changed:', mode, entry); ··· 304 262 }); 305 263 } 306 264 307 - // Also subscribe to old modes API for backwards compatibility 308 265 api.modes.onModeChange((modeState, windowId) => { 309 266 log('cmd:panel', 'Mode changed (modes API):', modeState, 'for window:', windowId); 310 - // Only update if context API hasn't set a value 311 267 if (!currentMode || currentMode === 'default') { 312 268 currentMode = modeState.major || 'default'; 313 269 updateModeIndicator(); ··· 317 273 log('cmd:panel', 'Mode indicator initialized with mode:', currentMode); 318 274 } 319 275 320 - /** 321 - * Check if a command is available in the current context 322 - * Based on mode requirements and scope 323 - */ 324 276 function isCommandAvailable(cmd) { 325 277 if (!cmd) return false; 326 - 327 - // Commands without mode restrictions are always available 328 - if (!cmd.modes || cmd.modes.length === 0) { 329 - return true; 330 - } 331 - 332 - // If no context loaded, show all commands 333 - if (!commandContext || !commandContext.mode) { 334 - return true; 335 - } 336 - 337 - // Check if current major mode is in the command's allowed modes 278 + if (!cmd.modes || cmd.modes.length === 0) return true; 279 + if (!commandContext || !commandContext.mode) return true; 338 280 const currentMajorMode = commandContext.mode.major; 339 281 return cmd.modes.includes(currentMajorMode); 340 282 } 341 283 342 - // ===== Window Drag ===== 343 - // Window dragging is handled by the preload's unified drag system. 344 - // Hold the mouse button on any non-interactive area for ~150ms to start dragging. 345 - // No custom drag handler needed here -- the preload covers all windows. 284 + // ===== Command Matching ===== 346 285 347 - // Window sizing constants 348 - const COLLAPSED_HEIGHT = 60; // Just the command bar 349 - const EXPANDED_HEIGHT = 400; // With results/preview 286 + function findMatchingCommands(text) { 287 + log('cmd:panel', 'findMatchingCommands', text, Object.keys(state.commands).length); 350 288 351 - /** 352 - * Resize the window based on content visibility 353 - */ 354 - function updateWindowSize() { 355 - const resultsVisible = document.getElementById('results')?.classList.contains('visible'); 356 - const previewVisible = document.getElementById('preview-container')?.classList.contains('visible'); 357 - const chainVisible = document.getElementById('chain-indicator')?.classList.contains('visible'); 358 - const execVisible = document.getElementById('execution-state')?.classList.contains('visible'); 359 - const chainStepsVisible = document.getElementById('chain-steps')?.classList.contains('visible'); 289 + let matches = []; 290 + if (!text) return matches; 360 291 361 - const needsExpanded = resultsVisible || previewVisible || chainVisible || execVisible || state.outputSelectionMode || chainStepsVisible; 362 - const targetHeight = needsExpanded ? EXPANDED_HEIGHT : COLLAPSED_HEIGHT; 292 + const commandPart = text.split(' ')[0]; 293 + const hasParameters = text.includes(' '); 294 + const lowerCommandPart = commandPart.toLowerCase(); 295 + const lowerText = text.toLowerCase(); 363 296 364 - api.window.resize(600, targetHeight); 365 - } 366 - 367 - window.addEventListener('cmd-update-commands', function(e) { 368 - log('cmd:panel', 'received updated commands'); 369 - state.commands = e.detail; 370 - }); 371 - 372 - // Expose state for testing — allows waitForFunction to check command loading 373 - window._cmdState = state; 374 - 375 - async function render() { 376 - // Get elements 377 - const commandInput = document.getElementById('command-input'); 378 - const commandText = document.getElementById('command-text'); 379 - const resultsContainer = document.getElementById('results'); 380 - 381 - // Set up input tracking 382 - commandInput.value = ''; 383 - commandInput.focus(); 384 - 385 - // Add event listeners to the input 386 - commandInput.addEventListener('input', () => { 387 - state.typed = commandInput.value; 388 - // Track original typed text for adaptive feedback (before Tab may overwrite it) 389 - state.originalTyped = commandInput.value; 390 - // Reset showResults when user types (they'll press down to see results) 391 - state.showResults = false; 392 - 393 - if (state.typed) { 394 - // In chain mode, filter to only commands that accept the current pipe type 395 - if (state.chainMode && state.chainContext) { 396 - const chainingCommands = findChainingCommands(state.chainContext.mimeType); 397 - const chainingNames = new Set(chainingCommands.map(cmd => cmd.name)); 398 - // Find matching commands, then intersect with chaining-compatible ones 399 - const allMatches = findMatchingCommands(state.typed); 400 - state.matches = allMatches.filter(name => chainingNames.has(name)); 401 - } else { 402 - // Always pass full text to findMatchingCommands so it can detect parameters 403 - state.matches = findMatchingCommands(state.typed); 404 - } 405 - state.matchIndex = 0; 406 - 407 - // Check if we should enter/update param mode 408 - // Find the best committed command: typed starts with command name + space 409 - const typedLower = state.typed.toLowerCase(); 410 - let paramCandidate = null; 411 - for (const matchName of state.matches) { 412 - const matchLower = matchName.toLowerCase(); 413 - if (typedLower.startsWith(matchLower + ' ')) { 414 - // Prefer longest command name (e.g., "list notes" over "list") 415 - if (!paramCandidate || matchName.length > paramCandidate.length) { 416 - paramCandidate = matchName; 417 - } 297 + // Check if typed text starts with a complete multi-word command name + space 298 + let committedCommand = null; 299 + if (hasParameters) { 300 + const trimmedLower = lowerText.trimEnd(); 301 + for (const name of Object.keys(state.commands)) { 302 + const lowerName = name.toLowerCase(); 303 + if (lowerName.includes(' ') && (trimmedLower === lowerName || lowerText.startsWith(lowerName + ' '))) { 304 + if (!committedCommand || name.length > committedCommand.length) { 305 + committedCommand = name; 418 306 } 419 307 } 420 - 421 - if (paramCandidate) { 422 - const cmd = state.commands[paramCandidate]; 423 - // Enter param mode for commands with params (provides autocomplete) 424 - if (cmd && cmd.params && cmd.params.length > 0) { 425 - enterParamMode(paramCandidate); 426 - } else { 427 - exitParamMode(); 428 - } 429 - } else { 430 - exitParamMode(); 431 - } 432 - } else { 433 - if (state.chainMode && state.chainContext) { 434 - // Show all chaining commands when input is cleared 435 - const chainingCommands = findChainingCommands(state.chainContext.mimeType); 436 - state.matches = chainingCommands.map(cmd => cmd.name); 437 - } else { 438 - state.matches = []; 439 - } 440 - state.matchIndex = 0; 441 - exitParamMode(); 442 308 } 443 - updateCommandUI(); 444 - updateResultsUI(); 445 - }); 309 + } 446 310 447 - commandInput.addEventListener('keydown', (e) => { 448 - if (['ArrowUp', 'ArrowDown', 'ArrowRight', 'Tab', 'Escape', 'Enter'].includes(e.key)) { 449 - // Only prevent default for ArrowRight in output selection mode 450 - if (e.key === 'ArrowRight' && !state.outputSelectionMode) { 451 - return; // Let normal cursor movement happen 452 - } 453 - e.preventDefault(); // Prevent default for special keys 454 - handleSpecialKey(e); 455 - } 456 - }); 311 + for (const name of Object.keys(state.commands)) { 312 + const cmd = state.commands[name]; 313 + if (!isCommandAvailable(cmd)) continue; 457 314 458 - // Keep focus on input (unless chain popup is active) 459 - window.addEventListener('blur', () => { 460 - if (!chainPopupActive) { 461 - setTimeout(() => commandInput.focus(), 10); 315 + if (committedCommand) { 316 + if (name === committedCommand) matches.push(name); 317 + continue; 462 318 } 463 - }); 464 319 465 - window.addEventListener('focus', () => { 466 - commandInput.focus(); 467 - }); 320 + const lowerName = name.toLowerCase(); 321 + const matchesCommand = lowerName.indexOf(lowerCommandPart) !== -1; 322 + const isCommandWithParams = hasParameters && lowerText.startsWith(lowerName + ' '); 468 323 469 - // Handle visibility changes 470 - document.addEventListener('visibilitychange', () => { 471 - if (!document.hidden) { 472 - updateCommandUI(); 324 + if (matchesCommand || isCommandWithParams) { 325 + matches.push(name); 473 326 } 474 - }); 327 + } 475 328 476 - // Initial focus 477 - setTimeout(() => { 478 - commandInput.focus(); 479 - }, 50); 329 + // Sort by combined ranking score 330 + matches.sort(function(a, b) { 331 + const aLower = a.toLowerCase(); 332 + const bLower = b.toLowerCase(); 480 333 481 - // Reset state when panel becomes visible (handles keepLive reuse) 482 - document.addEventListener('visibilitychange', async () => { 483 - if (!document.hidden) { 484 - // Reset execution state when panel is shown 485 - hideExecutionState(); 486 - // Exit output selection mode if panel was reused 487 - if (state.outputSelectionMode) { 488 - exitOutputSelectionMode(); 489 - } 490 - // Exit chain mode if panel was reused 491 - if (state.chainMode) { 492 - exitChainMode(); 493 - } 494 - // Exit param mode if panel was reused 495 - if (state.paramMode) { 496 - exitParamMode(); 497 - } 498 - // Invalidate completer cache so tags are fresh 499 - invalidateCompleterCache(); 500 - // Reset chain popup state 501 - chainPopupActive = false; 502 - // Reset showResults 503 - state.showResults = false; 504 - // Reset mode state for fresh context loading 505 - currentMode = 'default'; 506 - currentModeMetadata = {}; 507 - modeWasSet = false; 508 - // Load current command context (mode, target window) 509 - await loadCommandContext(); 510 - // Reload adaptive data from storage (may have been updated by other processes) 511 - adaptiveDataCache = null; 512 - loadAdaptiveData().then(data => { 513 - state.adaptiveFeedback = data.feedback; 514 - state.matchCounts = data.counts; 515 - log('cmd:panel', 'Reloaded adaptive data on panel show'); 516 - }); 334 + if (hasParameters) { 335 + const aExact = aLower === lowerCommandPart; 336 + const bExact = bLower === lowerCommandPart; 337 + if (aExact && !bExact) return -1; 338 + if (bExact && !aExact) return 1; 517 339 } 518 - }); 519 340 520 - // Load command context on initial render (must complete before mode indicator init) 521 - await loadCommandContext(); 341 + const aAdaptive = getAdaptiveScore(commandPart, a); 342 + const bAdaptive = getAdaptiveScore(commandPart, b); 343 + const aFrecency = getFrecencyScore(a); 344 + const bFrecency = getFrecencyScore(b); 522 345 523 - // Initialize mode indicator (uses commandContext loaded above) 524 - initModeIndicator(); 346 + const aScore = (aAdaptive * 3) + aFrecency; 347 + const bScore = (bAdaptive * 3) + bFrecency; 525 348 526 - // Chain cancel button handler 527 - const chainCancelBtn = document.getElementById('chain-cancel'); 528 - if (chainCancelBtn) { 529 - chainCancelBtn.addEventListener('click', () => { 530 - exitChainMode(); 531 - commandInput.value = ''; 532 - state.typed = ''; 533 - commandInput.focus(); 534 - updateCommandUI(); 535 - updateResultsUI(); 536 - }); 537 - } 349 + if (bScore !== aScore) return bScore - aScore; 538 350 539 - // Execution cancel button handler 540 - const execCancelBtn = document.querySelector('#execution-state .exec-cancel'); 541 - if (execCancelBtn) { 542 - execCancelBtn.addEventListener('click', () => { 543 - cancelExecution(); 544 - commandInput.focus(); 545 - }); 546 - } 547 - } 351 + const aPrefix = aLower.startsWith(lowerCommandPart); 352 + const bPrefix = bLower.startsWith(lowerCommandPart); 353 + if (aPrefix && !bPrefix) return -1; 354 + if (bPrefix && !aPrefix) return 1; 548 355 549 - render(); 356 + if (a.length !== b.length) return a.length - b.length; 357 + return a.localeCompare(b); 358 + }); 550 359 551 - // Register IZUI escape handler for the main process before-input-event flow. 552 - // The backend intercepts ESC on keyDown via before-input-event and calls e.preventDefault(), 553 - // so the DOM keydown event never reaches the page. This callback is the only way 554 - // for the panel to handle ESC with clear-text-first behavior. 555 - api.escape.onEscape(() => { 556 - const commandInput = document.getElementById('command-input'); 360 + return matches; 361 + } 557 362 558 - // Exit param mode first 559 - if (state.paramMode) { 560 - exitParamMode(); 561 - updateResultsUI(); 562 - updateCommandUI(); 563 - return { handled: true }; 564 - } 565 - 566 - // Exit output selection mode first 567 - if (state.outputSelectionMode) { 568 - exitOutputSelectionMode(); 569 - commandInput.value = ''; 570 - state.typed = ''; 571 - updateCommandUI(); 572 - updateResultsUI(); 573 - return { handled: true }; 574 - } 575 - 576 - // Exit chain mode next 577 - if (state.chainMode) { 578 - exitChainMode(); 579 - commandInput.value = ''; 580 - state.typed = ''; 581 - updateCommandUI(); 582 - updateResultsUI(); 583 - return { handled: true }; 584 - } 363 + // ===== Adaptive Feedback ===== 585 364 586 - // Hide results if visible 587 - if (state.showResults) { 588 - state.showResults = false; 589 - updateResultsUI(); 590 - return { handled: true }; 365 + function updateAdaptiveFeedback(typed, name) { 366 + log('cmd:panel', 'updateAdaptiveFeedback', typed, '->', name); 367 + if (!state.adaptiveFeedback[typed]) { 368 + state.adaptiveFeedback[typed] = {}; 591 369 } 592 - 593 - // Clear text first, then close (IZUI-style: escape content before container) 594 - if (commandInput.value) { 595 - commandInput.value = ''; 596 - state.typed = ''; 597 - state.matches = []; 598 - state.matchIndex = 0; 599 - updateCommandUI(); 600 - updateResultsUI(); 601 - return { handled: true }; 370 + if (!state.adaptiveFeedback[typed][name]) { 371 + state.adaptiveFeedback[typed][name] = 0; 602 372 } 373 + state.adaptiveFeedback[typed][name]++; 603 374 604 - // Input is empty, no modes active — let backend close the window 605 - return { handled: false }; 606 - }); 375 + const lowerName = name.toLowerCase(); 376 + const lowerTyped = typed.toLowerCase(); 377 + const words = lowerName.split(/[\s-]+/); 607 378 608 - /** 609 - * Handles special key presses (arrows, tab, enter, escape) 610 - */ 611 - function handleSpecialKey(e) { 612 - const commandInput = document.getElementById('command-input'); 613 - 614 - // Escape key - exit modes first, then close window 615 - if (e.key === 'Escape' && !hasModifier(e)) { 616 - // Exit param mode first 617 - if (state.paramMode) { 618 - exitParamMode(); 619 - updateResultsUI(); 620 - updateCommandUI(); 621 - return; 622 - } 623 - 624 - // Exit output selection mode first 625 - if (state.outputSelectionMode) { 626 - exitOutputSelectionMode(); 627 - commandInput.value = ''; 628 - state.typed = ''; 629 - updateCommandUI(); 630 - updateResultsUI(); 631 - return; 632 - } 633 - 634 - // Exit chain mode next 635 - if (state.chainMode) { 636 - exitChainMode(); 637 - commandInput.value = ''; 638 - state.typed = ''; 639 - updateCommandUI(); 640 - updateResultsUI(); 641 - return; 642 - } 643 - 644 - // Hide results if visible 645 - if (state.showResults) { 646 - state.showResults = false; 647 - updateResultsUI(); 648 - return; 649 - } 650 - 651 - 652 - // Clear text first, then close (IZUI-style: escape content before container) 653 - if (commandInput.value) { 654 - commandInput.value = ''; 655 - state.typed = ''; 656 - state.matches = []; 657 - state.matchIndex = 0; 658 - updateCommandUI(); 659 - updateResultsUI(); 660 - return; 661 - } 662 - shutdown(); 663 - return; 664 - } 665 - 666 - // Enter key in param mode with item-type param — accept selected suggestion 667 - if (e.key === 'Enter' && !hasModifier(e) && state.paramMode && state.paramSuggestions.length > 0) { 668 - const cmd = state.commands[state.paramCommand]; 669 - const paramDef = cmd && cmd.params && cmd.params[0]; 670 - if (paramDef && paramDef.type === 'item') { 671 - e.preventDefault(); 672 - const idx = state.paramIndex >= 0 ? state.paramIndex : 0; 673 - acceptParamSuggestion(idx); 674 - return; 379 + for (const word of words) { 380 + if (lowerTyped.startsWith(word) || word.startsWith(lowerTyped)) continue; 381 + for (let len = 2; len <= word.length; len++) { 382 + const sub = word.substring(0, len); 383 + if (!state.adaptiveFeedback[sub]) state.adaptiveFeedback[sub] = {}; 384 + if (!state.adaptiveFeedback[sub][name]) state.adaptiveFeedback[sub][name] = 0; 385 + state.adaptiveFeedback[sub][name] += 0.25; 675 386 } 676 387 } 677 388 678 - // Enter key - execute command (but not if in output selection mode - handled above) 679 - if (e.key === 'Enter' && !hasModifier(e) && !state.outputSelectionMode) { 680 - // Check if the typed text is a URL - if so, open it directly 681 - const trimmedText = commandInput.value.trim(); 682 - const urlResult = getValidURL(trimmedText); 683 - if (urlResult.valid) { 684 - log('cmd:panel', 'Detected URL, opening:', urlResult.url); 685 - 686 - // Record frecency for the URL itself (as a registered command from history). 687 - // Find the best matching URL command so frecency accumulates for it, 688 - // making it appear in future suggestions when the user starts typing it. 689 - const urlToOpen = urlResult.url; 690 - const matchingUrlCommand = findMatchingUrlCommand(urlToOpen); 691 - const frecencyTarget = matchingUrlCommand || urlToOpen; 692 - state.lastExecuted = frecencyTarget; 693 - updateMatchCount(frecencyTarget); 694 - updateAdaptiveFeedback(trimmedText, frecencyTarget); 695 - 696 - // Open URL directly using api.window.open 697 - api.window.open(urlResult.url, { 698 - role: 'content', 699 - width: 800, 700 - height: 600, 701 - trackingSource: 'cmd', 702 - trackingSourceId: 'url-direct' 703 - }).then(result => { 704 - log('cmd:panel', 'URL opened:', result); 705 - }).catch(error => { 706 - log.error('cmd:panel', 'Failed to open URL:', error); 707 - showExecutionError('open', error.message || 'Failed to open URL'); 708 - }); 709 - 710 - // Clear input and UI 711 - commandInput.value = ''; 712 - state.typed = ''; 713 - updateCommandUI(); 714 - updateResultsUI(); 389 + saveAdaptiveData(state.adaptiveFeedback, state.matchCounts); 390 + } 715 391 716 - // Close panel after opening URL 717 - setTimeout(shutdown, 100); 718 - return; 719 - } 720 - 721 - // Otherwise, execute the matched command — but only if the user has committed 722 - // to it (Tab-completed, typed the full name, or explicitly selected from results). 723 - // Ghost text (inline autocomplete) is visual-only; Enter operates on typed text. 724 - const name = state.matches[state.matchIndex]; 725 - const typedLower = trimmedText.toLowerCase(); 726 - const nameLower = name ? name.toLowerCase() : ''; 727 - const userCommittedToCommand = name && ( 728 - typedLower === nameLower || // Typed full command name 729 - typedLower.startsWith(nameLower + ' ') || // Command name + parameters 730 - state.showResults || // Explicitly browsing results list 731 - state.chainMode // In chain mode, commands are pre-filtered 732 - ); 733 - if (userCommittedToCommand && Object.keys(state.commands).indexOf(name) > -1) { 734 - // Preserve any parameters when executing 735 - const typedText = commandInput.value; 736 - 737 - // Store command name for history and adaptive feedback 738 - // Use originalTyped (pre-Tab text) for adaptive feedback so "ta" -> "open tags" is recorded 739 - const commandPart = (state.originalTyped || typedText).split(' ')[0]; 740 - state.lastExecuted = name; 741 - updateMatchCount(name); 742 - updateAdaptiveFeedback(commandPart, name); 392 + function getAdaptiveScore(typed, name) { 393 + const k = 3; 394 + const feedback = state.adaptiveFeedback[typed]; 395 + if (!feedback || !feedback[name]) return 0; 396 + return feedback[name] / (feedback[name] + k); 397 + } 743 398 744 - // Exit param mode before executing 745 - exitParamMode(); 399 + function getFrecencyScore(name) { 400 + const k = 10; 401 + const count = state.matchCounts[name] || 0; 402 + if (count === 0) return 0; 403 + return count / (count + k); 404 + } 746 405 747 - // Execute with full typed text 748 - execute(name, typedText); 406 + function updateMatchCount(name) { 407 + if (!state.matchCounts[name]) state.matchCounts[name] = 0; 408 + state.matchCounts[name]++; 409 + saveAdaptiveData(state.adaptiveFeedback, state.matchCounts); 410 + } 749 411 750 - // Clear input and UI 751 - commandInput.value = ''; 752 - state.typed = ''; 753 - updateCommandUI(); 754 - updateResultsUI(); 755 - } else if (trimmedText) { 756 - // No matching command and not a URL — open search view with the typed text 757 - log('cmd:panel', 'No command match, opening search for:', trimmedText); 412 + // ===== Param Mode Helpers ===== 758 413 759 - api.window.open(`peek://ext/search/home.html?q=${encodeURIComponent(trimmedText)}`, { 760 - role: 'workspace', 761 - key: 'search-home', 762 - width: 700, 763 - height: 600, 764 - trackingSource: 'cmd', 765 - trackingSourceId: 'search' 766 - }).catch(error => { 767 - log.error('cmd:panel', 'Failed to open search:', error); 768 - }); 414 + function getParamTokens() { 415 + const commandName = state.paramCommand; 416 + if (!commandName) return { partial: '', completedTokens: [] }; 769 417 770 - // Clear input and UI 771 - commandInput.value = ''; 772 - state.typed = ''; 773 - updateCommandUI(); 774 - updateResultsUI(); 418 + const typed = state.typed || ''; 419 + const lowerTyped = typed.toLowerCase(); 420 + const lowerName = commandName.toLowerCase(); 775 421 776 - // Close panel after opening search 777 - setTimeout(shutdown, 100); 778 - } else { 779 - log('cmd:panel', 'Empty input, ignoring Enter'); 780 - } 781 - return; 422 + let rest = ''; 423 + if (lowerTyped.startsWith(lowerName + ' ')) { 424 + rest = typed.slice(commandName.length + 1); 425 + } else if (lowerTyped.startsWith(lowerName)) { 426 + rest = typed.slice(commandName.length); 782 427 } 783 428 784 - // Arrow Up - navigate results or output items up 785 - if (e.key === 'ArrowUp') { 786 - if (state.paramMode && state.paramSuggestions.length > 0) { 787 - if (state.paramIndex > 0) { 788 - state.paramIndex--; 789 - } 790 - updateResultsUI(); 791 - updateCommandUI(); 792 - return; 793 - } 794 - if (state.outputSelectionMode && state.outputItemIndex > 0) { 795 - // Navigate output items 796 - state.outputItemIndex--; 797 - updateOutputSelectionUI(); 798 - return; 799 - } else if (state.showResults && state.matchIndex > 0) { 800 - // Navigate command results 801 - state.matchIndex--; 802 - updateCommandUI(); 803 - updateResultsUI(); 804 - return; 805 - } 806 - return; 429 + const tokens = rest.split(/\s+/); 430 + const endsWithSpace = rest.endsWith(' ') || rest === ''; 431 + 432 + if (endsWithSpace) { 433 + return { partial: '', completedTokens: tokens.filter(t => t.length > 0) }; 807 434 } 808 435 809 - // Arrow Down - show results or navigate down 810 - if (e.key === 'ArrowDown') { 811 - if (state.paramMode && state.paramSuggestions.length > 0) { 812 - if (state.paramIndex + 1 < state.paramSuggestions.length) { 813 - state.paramIndex++; 814 - } 815 - updateResultsUI(); 816 - updateCommandUI(); 817 - return; 818 - } 436 + const partial = tokens.pop() || ''; 437 + return { partial, completedTokens: tokens.filter(t => t.length > 0) }; 438 + } 819 439 820 - if (state.outputSelectionMode) { 821 - // Navigate output items 822 - if (state.outputItemIndex + 1 < state.outputItems.length) { 823 - state.outputItemIndex++; 824 - updateOutputSelectionUI(); 825 - } 826 - return; 827 - } 440 + async function doUpdateParamSuggestions() { 441 + if (!state.paramCommand) return; 828 442 829 - // Show results if not already visible 830 - if (!state.showResults && state.matches.length > 0) { 831 - state.showResults = true; 832 - updateResultsUI(); 833 - return; 834 - } 443 + const cmd = state.commands[state.paramCommand]; 444 + if (!cmd) return; 835 445 836 - // Navigate down in results 837 - if (state.showResults && state.matchIndex + 1 < state.matches.length) { 838 - state.matchIndex++; 839 - updateCommandUI(); 840 - updateResultsUI(); 841 - return; 842 - } 446 + const paramDef = (cmd.params && cmd.params[0]) || null; 447 + if (!paramDef) { 448 + state.paramSuggestions = []; 843 449 return; 844 450 } 845 451 846 - // Right Arrow or Enter in output selection mode - proceed to chaining 847 - if (state.outputSelectionMode && (e.key === 'ArrowRight' || e.key === 'Enter')) { 848 - selectOutputItem(); 849 - return; 850 - } 452 + const generation = ++state.paramGeneration; 453 + const { partial, completedTokens } = getParamTokens(); 851 454 852 - // Tab key - in param mode, fill suggestion text (do NOT execute) 853 - if (e.key === 'Tab' && state.paramMode && state.paramSuggestions.length > 0) { 854 - const idx = state.paramIndex >= 0 ? state.paramIndex : 0; 855 - fillParamSuggestion(idx); 856 - return; 455 + let searchPartial = partial; 456 + if (paramDef.type === 'item') { 457 + searchPartial = [...completedTokens, partial].filter(Boolean).join(' '); 857 458 } 858 459 859 - // Tab key - autocomplete or cycle through matches 860 - if (e.key === 'Tab' && state.matches.length > 0) { 861 - const currentMatch = state.matches[state.matchIndex]; 862 - const currentValue = commandInput.value.trim(); 863 - 864 - // Check if we already completed to a match (need to cycle to next) 865 - // This is true if current value starts with current match + space or equals current match 866 - const alreadyCompleted = currentValue === currentMatch || 867 - currentValue.startsWith(currentMatch + ' '); 868 - 869 - if (alreadyCompleted && state.matches.length > 1) { 870 - // Cycle forward (Tab) or backward (Shift-Tab) 871 - if (e.shiftKey) { 872 - state.matchIndex = (state.matchIndex - 1 + state.matches.length) % state.matches.length; 873 - } else { 874 - state.matchIndex = (state.matchIndex + 1) % state.matches.length; 875 - } 876 - } 877 - // else: first completion, stay on current matchIndex 878 - 879 - const match = state.matches[state.matchIndex]; 880 - 881 - // Set to the match with a trailing space 882 - state.typed = match + ' '; 883 - commandInput.value = state.typed; 460 + const prefix = paramDef.prefix || ''; 461 + const exclude = new Set( 462 + completedTokens.map(t => { 463 + const stripped = prefix && t.toLowerCase().startsWith(prefix.toLowerCase()) 464 + ? t.slice(prefix.length).toLowerCase() 465 + : t.toLowerCase(); 466 + return stripped; 467 + }) 468 + ); 884 469 885 - // Check if Tab-completed command has params → enter param mode 886 - // (provides autocomplete suggestions including for connector commands) 887 - const cmd = state.commands[match]; 888 - if (cmd && cmd.params && cmd.params.length > 0) { 889 - enterParamMode(match); 890 - } else { 891 - exitParamMode(); 892 - } 470 + const suggestions = await getSuggestions(paramDef, searchPartial, exclude); 893 471 894 - // Update UI but DON'T recalculate matches - keep cycling through current set 895 - updateCommandUI(); 896 - // Don't call updateResultsUI() here - it would recalculate matches 897 - // (unless in param mode, where we need to show suggestions) 898 - if (state.paramMode) { 899 - updateResultsUI(); 900 - } 472 + if (generation !== state.paramGeneration) return; 901 473 902 - // Place cursor at the end 903 - setTimeout(() => { 904 - commandInput.setSelectionRange(commandInput.value.length, commandInput.value.length); 905 - }, 0); 474 + state.paramSuggestions = suggestions; 475 + state.paramIndex = suggestions.length > 0 ? 0 : -1; 906 476 907 - return; 908 - } 477 + updateResultsUI(); 478 + updateCommandUI(); 909 479 } 910 480 911 - /** 912 - * Builds execution context from typed string and command name 913 - */ 481 + // ===== Execution Context Builder ===== 482 + 914 483 function buildExecutionContext(name, typed) { 915 - // Strip the command name from the beginning to get the rest 916 484 const trimmed = typed.trim(); 917 485 const nameLower = name.toLowerCase(); 918 486 const trimmedLower = trimmed.toLowerCase(); 919 487 let rest; 920 488 if (trimmedLower.startsWith(nameLower)) { 921 - // Typed full command name (or more) — slice it off 922 489 rest = trimmed.slice(name.length).trim(); 923 490 } else { 924 - // Typed partial text (e.g., "s" matched "save") — check if first word is prefix of command 925 491 const spaceIdx = trimmed.indexOf(' '); 926 492 const firstWord = spaceIdx > 0 ? trimmedLower.slice(0, spaceIdx) : trimmedLower; 927 493 if (nameLower.startsWith(firstWord)) { 928 - // First word is prefix of command name — rest is after the space (if any) 929 494 rest = spaceIdx > 0 ? trimmed.slice(spaceIdx + 1).trim() : ''; 930 495 } else { 931 496 rest = trimmed; ··· 934 499 const params = rest.length > 0 ? rest.split(/\s+/) : []; 935 500 const search = rest.length > 0 ? rest : null; 936 501 937 - const context = { 938 - typed, // Full typed string 939 - name, // Command name 940 - params, // Array of parameters 941 - search // Text after command name (for search-style commands) 942 - }; 502 + const context = { typed, name, params, search }; 943 503 944 - // Add chain data if in chain mode 945 - if (state.chainMode && state.chainContext) { 946 - context.input = state.chainContext.data; // Input data from previous command 947 - context.inputMimeType = state.chainContext.mimeType; // MIME type of input data 948 - context.inputTitle = state.chainContext.title; // Human-readable title 949 - context.inputSource = state.chainContext.sourceCommand; // Source command name 504 + if ((machine.getState() === States.CHAIN_MODE || machine.getState() === States.EXECUTING) && state.chainContext) { 505 + context.input = state.chainContext.data; 506 + context.inputMimeType = state.chainContext.mimeType; 507 + context.inputTitle = state.chainContext.title; 508 + context.inputSource = state.chainContext.sourceCommand; 950 509 } 951 510 952 511 return context; ··· 954 513 955 514 // ===== Chain Mode Functions ===== 956 515 957 - /** 958 - * Check if a MIME pattern matches an actual MIME type 959 - * Supports wildcards like star/star for any type, text/star for text subtypes 960 - */ 961 516 function mimeTypeMatches(pattern, actual) { 962 517 if (!pattern || !actual) return false; 963 518 if (pattern === '*/*' || pattern === actual) return true; 964 - 965 519 const [pType, pSub] = pattern.split('/'); 966 - const [aType, aSub] = actual.split('/'); 967 - 968 - // Wildcard subtype (e.g., "text/*" matches "text/plain") 520 + const [aType] = actual.split('/'); 969 521 return pSub === '*' && pType === aType; 970 522 } 971 523 972 - /** 973 - * Find commands that can accept the given MIME type as input 974 - */ 975 524 function findChainingCommands(mimeType) { 976 525 return Object.values(state.commands).filter(cmd => { 977 526 if (!cmd.accepts?.length) return false; ··· 979 528 }); 980 529 } 981 530 982 - /** 983 - * Enter chain mode with output from a command 984 - * @param {Object} output - { data, mimeType, title } 985 - * @param {string} sourceCommand - Name of the command that produced this output 986 - */ 987 - async function enterChainMode(output, sourceCommand) { 531 + async function doEnterChainMode(output, sourceCommand) { 988 532 log('cmd:panel', 'Entering chain mode with output:', output.mimeType, output.title); 989 533 990 534 hidePreview(); 991 535 992 - state.chainMode = true; 993 536 state.chainContext = { 994 537 data: output.data, 995 538 mimeType: output.mimeType, ··· 997 540 sourceCommand 998 541 }; 999 542 1000 - // Push to stack for potential undo 1001 543 state.chainStack.push({ ...state.chainContext }); 1002 544 1003 - // Find commands that can accept this output 1004 545 const chainingCommands = findChainingCommands(output.mimeType); 1005 546 log('cmd:panel', 'Found', chainingCommands.length, 'commands accepting', output.mimeType); 1006 547 1007 - // Clear input and update matches to show chaining commands 1008 548 state.typed = ''; 1009 549 state.matches = chainingCommands.map(cmd => cmd.name); 1010 550 state.matchIndex = 0; 1011 551 1012 - // Update UI 1013 552 updateChainUI(); 1014 553 updateChainStepsUI(); 1015 554 updateCommandUI(); 1016 555 updateResultsUI(); 1017 556 1018 - // For interactive popups (declared by command or MIME fallback) 1019 557 const popupUrl = output.interactive || getDefaultPopupForMime(output.mimeType); 1020 558 if (popupUrl && output.data) { 1021 559 await openChainPopup(popupUrl, output.data, output.mimeType, output.title); 1022 560 } else if (output.data) { 1023 - // Non-interactive output — just show preview 1024 561 showPreview(output.data, output.mimeType, output.title); 1025 562 } 1026 563 } 1027 564 1028 - /** 1029 - * Exit chain mode and reset state 1030 - */ 1031 - function exitChainMode() { 565 + function doExitChainMode() { 1032 566 log('cmd:panel', 'Exiting chain mode'); 1033 567 1034 - state.chainMode = false; 1035 568 state.chainContext = null; 1036 569 state.chainStack = []; 1037 570 state.matches = []; 1038 571 state.matchIndex = 0; 1039 - chainPopupActive = false; 572 + state.chainPopupActive = false; 1040 573 1041 - // Close any open popup window 1042 574 if (chainPopupWindowId) { 1043 575 api.window.close(chainPopupWindowId); 1044 576 chainPopupWindowId = null; 1045 577 } 1046 578 1047 - // Hide preview 1048 579 hidePreview(); 1049 - 1050 - // Update UI 1051 580 updateChainUI(); 1052 581 updateChainStepsUI(); 1053 582 updateCommandUI(); 1054 583 updateResultsUI(); 1055 584 } 1056 585 1057 - /** 1058 - * Go back one step in the chain (undo) 1059 - */ 1060 - function chainUndo() { 586 + function doChainUndo() { 1061 587 if (state.chainStack.length <= 1) { 1062 - // Can't undo past the first item, exit chain mode 1063 - exitChainMode(); 588 + doExitChainMode(); 1064 589 return; 1065 590 } 1066 591 1067 - // Pop current and restore previous 1068 592 state.chainStack.pop(); 1069 593 state.chainContext = { ...state.chainStack[state.chainStack.length - 1] }; 1070 594 1071 - // Update matches for the restored context 1072 595 const chainingCommands = findChainingCommands(state.chainContext.mimeType); 1073 596 state.matches = chainingCommands.map(cmd => cmd.name); 1074 597 state.matchIndex = 0; 1075 598 1076 - // Update UI 1077 599 updateChainUI(); 1078 600 updateChainStepsUI(); 1079 601 updateCommandUI(); 1080 602 updateResultsUI(); 1081 603 1082 - // Update preview for restored context 1083 604 if (state.chainContext.data) { 1084 605 const popupUrl = getDefaultPopupForMime(state.chainContext.mimeType); 1085 606 if (popupUrl) { ··· 1093 614 1094 615 // ===== Chain Popup Functions ===== 1095 616 1096 - /** 1097 - * Default popup URLs for MIME type prefixes. 1098 - * Commands can override these by declaring an `interactive` field in output. 1099 - */ 1100 617 const DEFAULT_POPUPS = { 1101 618 'text/': 'peek://ext/cmd/chain-editor.html', 1102 619 }; 1103 620 1104 - /** 1105 - * Get the default popup URL for a given MIME type (prefix matching). 1106 - * @param {string} mimeType 1107 - * @returns {string|null} Popup URL or null if no default 1108 - */ 1109 621 function getDefaultPopupForMime(mimeType) { 1110 622 if (!mimeType) return null; 1111 623 for (const [prefix, url] of Object.entries(DEFAULT_POPUPS)) { ··· 1114 626 return null; 1115 627 } 1116 628 1117 - /** 1118 - * Open a chain popup window for interactive editing of chain output. 1119 - * 1120 - * The popup receives content via pubsub and returns the edited result 1121 - * via pubsub when the user closes the popup. 1122 - * 1123 - * @param {string} popupUrl - The URL to open (e.g., peek://ext/cmd/chain-editor.html) 1124 - * @param {*} data - The content data to send to the popup 1125 - * @param {string} mimeType - MIME type of the data 1126 - * @param {string} title - Human-readable title 1127 - */ 1128 629 async function openChainPopup(popupUrl, data, mimeType, title) { 1129 630 const sessionId = Math.random().toString(36).slice(2) + Date.now().toString(36); 1130 - chainPopupActive = true; 631 + state.chainPopupActive = true; 1131 632 1132 633 log('cmd:panel', 'Opening chain popup:', popupUrl, 'sessionId:', sessionId); 1133 634 1134 - // Subscribe for result BEFORE opening popup 1135 635 let resultReceived = false; 1136 636 api.subscribe('chain-popup:result', (msg) => { 1137 637 if (msg.sessionId !== sessionId) return; 1138 638 if (resultReceived) return; 1139 639 resultReceived = true; 1140 - chainPopupActive = false; 640 + state.chainPopupActive = false; 1141 641 chainPopupWindowId = null; 1142 642 1143 643 log('cmd:panel', 'Received chain popup result, length:', (msg.data || '').length, 'done:', !!msg.done); 1144 644 1145 - // If the popup signals it's done (e.g. record created), close the panel 1146 - if (msg.done) { 1147 - shutdown(); 1148 - return; 1149 - } 1150 - 1151 - // Update chain context with returned data 1152 - state.chainContext.data = msg.data; 1153 - state.chainContext.mimeType = msg.mimeType || mimeType; 1154 - if (state.chainStack.length > 0) { 1155 - state.chainStack[state.chainStack.length - 1].data = msg.data; 1156 - } 1157 - 1158 - // Show commands accepting the new mimeType 1159 - const chainingCommands = findChainingCommands(state.chainContext.mimeType); 1160 - state.typed = ''; 1161 - state.matches = chainingCommands.map(cmd => cmd.name); 1162 - state.matchIndex = 0; 1163 - state.showResults = true; 1164 - 1165 - updateChainUI(); 1166 - updateChainStepsUI(); 1167 - updateCommandUI(); 1168 - updateResultsUI(); 1169 - updateWindowSize(); 1170 - 1171 - // Re-focus cmd panel BrowserWindow and input after popup closes 1172 - api.window.getWindowId().then(id => { 1173 - if (id) api.window.focus(id); 645 + machine.dispatch(Events.POPUP_RESULT, { 646 + done: msg.done, 647 + data: msg.data, 648 + mimeType: msg.mimeType || mimeType, 1174 649 }); 1175 - setTimeout(() => { 1176 - const commandInput = document.getElementById('command-input'); 1177 - if (commandInput) { 1178 - commandInput.focus(); 1179 - } 1180 - }, 100); 1181 650 }, api.scopes.GLOBAL); 1182 651 1183 - // Get cmd panel bounds for positioning 1184 652 const panelBounds = await api.window.getBounds(); 1185 - 1186 - // Open popup centered vertically over cmd 1187 653 const editorHeight = 400; 1188 654 const editorY = Math.round(panelBounds.y - editorHeight / 2 + panelBounds.height / 2); 1189 655 ··· 1202 668 resizable: true, 1203 669 }); 1204 670 1205 - // Track popup window ID for cleanup 1206 671 chainPopupWindowId = openResult?.id || null; 1207 672 1208 - // Delay content publish to let the popup's module script load and subscribe. 1209 - // loadURL resolves on did-finish-load, but ES modules execute after that. 673 + // Wait for popup's ES module to load and subscribe 674 + // TODO: Replace with proper ready signaling (bug fix #6) 1210 675 await new Promise(r => setTimeout(r, 300)); 1211 676 1212 - // Send content via pubsub 1213 677 api.publish('chain-popup:content', { 1214 678 sessionId, 1215 679 data, ··· 1220 684 1221 685 // ===== Chain Steps UI ===== 1222 686 1223 - /** 1224 - * Update the chain steps indicator showing the pipe history 1225 - */ 1226 687 function updateChainStepsUI() { 1227 688 const stepsEl = document.getElementById('chain-steps'); 1228 689 if (!stepsEl) return; 1229 690 1230 - if (!state.chainMode || state.chainStack.length === 0) { 691 + const isChainState = machine.getState() === States.CHAIN_MODE || machine.getState() === States.CHAIN_POPUP; 692 + if (!isChainState || state.chainStack.length === 0) { 1231 693 stepsEl.classList.remove('visible'); 1232 694 stepsEl.innerHTML = ''; 1233 695 return; ··· 1246 708 1247 709 const stepEl = document.createElement('span'); 1248 710 stepEl.className = 'chain-step'; 1249 - if (i === state.chainStack.length - 1) { 1250 - stepEl.classList.add('active'); 1251 - } 711 + if (i === state.chainStack.length - 1) stepEl.classList.add('active'); 1252 712 stepEl.textContent = step.sourceCommand || step.title || step.mimeType; 1253 713 stepsEl.appendChild(stepEl); 1254 714 }); ··· 1258 718 1259 719 // ===== Output Selection Mode Functions ===== 1260 720 1261 - /** 1262 - * Enter output selection mode to let user pick an item from array output 1263 - * @param {Array} items - Array of items to select from 1264 - * @param {string} mimeType - MIME type of the items 1265 - * @param {string} sourceCommand - Command that produced this output 1266 - */ 1267 - function enterOutputSelectionMode(items, mimeType, sourceCommand) { 721 + function doEnterOutputSelectionMode(items, mimeType, sourceCommand) { 1268 722 log('cmd:panel', 'Entering output selection mode with', items.length, 'items'); 1269 723 1270 - state.outputSelectionMode = true; 1271 724 state.outputItems = items; 1272 725 state.outputItemIndex = 0; 1273 726 state.outputMimeType = mimeType; 1274 727 state.outputSourceCommand = sourceCommand; 1275 728 1276 - // Clear command input 1277 729 state.typed = ''; 1278 730 state.matches = []; 1279 731 state.showResults = false; 1280 732 1281 - // Update UI to show selectable items 1282 733 updateOutputSelectionUI(); 1283 734 } 1284 735 1285 - /** 1286 - * Exit output selection mode 1287 - */ 1288 - function exitOutputSelectionMode() { 736 + function doExitOutputSelectionMode() { 1289 737 log('cmd:panel', 'Exiting output selection mode'); 1290 738 1291 - state.outputSelectionMode = false; 1292 739 state.outputItems = []; 1293 740 state.outputItemIndex = 0; 1294 741 state.outputMimeType = null; 1295 742 state.outputSourceCommand = null; 1296 743 1297 - // Hide preview and results 1298 744 hidePreview(); 1299 745 const resultsContainer = document.getElementById('results'); 1300 746 if (resultsContainer) { ··· 1303 749 } 1304 750 } 1305 751 1306 - /** 1307 - * Select the currently highlighted output item 1308 - * Routes to editor for 'item' mimeType, otherwise enters chain mode 1309 - */ 1310 - function selectOutputItem() { 1311 - if (!state.outputSelectionMode || state.outputItems.length === 0) return; 752 + function doSelectOutputItem() { 753 + if (state.outputItems.length === 0) return; 1312 754 1313 755 const selectedItem = state.outputItems[state.outputItemIndex]; 1314 756 log('cmd:panel', 'Selected output item:', state.outputItemIndex, selectedItem); 1315 757 1316 - // Capture state before exiting selection mode 1317 758 const mimeType = state.outputMimeType; 1318 759 const sourceCommand = state.outputSourceCommand; 1319 - exitOutputSelectionMode(); 760 + doExitOutputSelectionMode(); 1320 761 1321 - // Handle 'item' mimeType - route to editor instead of chain mode 1322 762 if (mimeType === 'item' && selectedItem.id) { 1323 763 log('cmd:panel', 'Opening editor for selected item:', selectedItem.id); 1324 764 api.publish('editor:open', { itemId: selectedItem.id }, api.scopes.GLOBAL); 765 + machine.setState(States.CLOSING); 1325 766 setTimeout(shutdown, 100); 1326 767 return; 1327 768 } 1328 769 1329 - // For other mimeTypes, enter chain mode with the selected item 1330 - enterChainMode({ 770 + // For other mimeTypes, enter chain mode 771 + machine.setState(States.CHAIN_MODE); 772 + doEnterChainMode({ 1331 773 data: selectedItem, 1332 774 mimeType: mimeType, 1333 775 title: getItemTitle(selectedItem) 1334 776 }, sourceCommand); 1335 777 } 1336 778 1337 - /** 1338 - * Get a human-readable title for an item 1339 - */ 1340 779 function getItemTitle(item) { 1341 780 if (typeof item === 'string') return item.slice(0, 50); 1342 781 if (typeof item === 'object' && item !== null) { 1343 - // Try common title fields 1344 782 return item.name || item.title || item.label || item.id || JSON.stringify(item).slice(0, 50); 1345 783 } 1346 784 return String(item).slice(0, 50); 1347 785 } 1348 786 1349 - /** 1350 - * Update UI for output selection mode 1351 - */ 1352 787 function updateOutputSelectionUI() { 1353 788 const resultsContainer = document.getElementById('results'); 1354 789 if (!resultsContainer) return; 1355 790 1356 791 resultsContainer.innerHTML = ''; 1357 792 1358 - if (!state.outputSelectionMode || state.outputItems.length === 0) { 793 + if (state.outputItems.length === 0) { 1359 794 resultsContainer.classList.remove('visible'); 1360 795 return; 1361 796 } 1362 797 1363 - // Show results container 1364 798 resultsContainer.classList.add('visible'); 1365 799 1366 - // Create items 1367 800 state.outputItems.slice(0, 50).forEach((item, index) => { 1368 801 const itemEl = document.createElement('div'); 1369 802 itemEl.className = 'command-item'; 1370 - if (index === state.outputItemIndex) { 1371 - itemEl.classList.add('selected'); 1372 - } 803 + if (index === state.outputItemIndex) itemEl.classList.add('selected'); 1373 804 1374 - // Render item content based on type 1375 805 const nameSpan = document.createElement('span'); 1376 806 nameSpan.className = 'cmd-name'; 1377 807 nameSpan.textContent = getItemTitle(item); 1378 808 itemEl.appendChild(nameSpan); 1379 809 1380 - // Add type indicator 1381 810 if (typeof item === 'object' && item !== null) { 1382 811 const descSpan = document.createElement('span'); 1383 812 descSpan.className = 'cmd-desc'; 1384 - const keys = Object.keys(item).slice(0, 3); 1385 - descSpan.textContent = keys.join(', '); 813 + descSpan.textContent = Object.keys(item).slice(0, 3).join(', '); 1386 814 itemEl.appendChild(descSpan); 1387 815 } 1388 816 1389 - // Click to select 1390 817 itemEl.addEventListener('click', () => { 1391 - state.outputItemIndex = index; 1392 - selectOutputItem(); 818 + machine.dispatch(Events.CLICK_OUTPUT, { index }); 1393 819 }); 1394 820 1395 821 resultsContainer.appendChild(itemEl); 1396 822 }); 1397 823 1398 - // Show preview of selected item 1399 824 if (state.outputItems[state.outputItemIndex]) { 1400 825 showPreview( 1401 826 state.outputItems[state.outputItemIndex], ··· 1405 830 } 1406 831 } 1407 832 1408 - /** 1409 - * Update chain indicator UI 1410 - */ 833 + // ===== Chain UI ===== 834 + 1411 835 function updateChainUI() { 1412 836 const chainIndicator = document.getElementById('chain-indicator'); 1413 837 if (!chainIndicator) return; 1414 838 1415 - if (state.chainMode && state.chainContext) { 839 + const isChainState = machine.getState() === States.CHAIN_MODE || machine.getState() === States.CHAIN_POPUP; 840 + if (isChainState && state.chainContext) { 1416 841 chainIndicator.classList.add('visible'); 1417 842 document.getElementById('chain-mime').textContent = state.chainContext.mimeType; 1418 843 document.getElementById('chain-title').textContent = state.chainContext.title || ''; ··· 1422 847 updateWindowSize(); 1423 848 } 1424 849 1425 - /** 1426 - * Show preview pane with data 1427 - */ 850 + // ===== Preview Functions ===== 851 + 1428 852 function showPreview(data, mimeType, title) { 1429 853 const previewContainer = document.getElementById('preview-container'); 1430 854 const previewContent = document.getElementById('preview-content'); 1431 855 const previewMime = document.getElementById('preview-mime'); 1432 856 const previewTitle = document.getElementById('preview-title'); 1433 857 1434 - if (!previewContainer || !previewContent) { 1435 - log('cmd:panel', 'Preview container not found'); 1436 - return; 1437 - } 858 + if (!previewContainer || !previewContent) return; 1438 859 1439 - // Render content based on MIME type 1440 860 const renderer = getRenderer(mimeType); 1441 861 previewContent.innerHTML = renderer(data); 1442 862 1443 - // Update header 1444 863 if (previewMime) previewMime.textContent = mimeType; 1445 864 if (previewTitle) previewTitle.textContent = title || ''; 1446 865 1447 - // Show container 1448 866 previewContainer.classList.add('visible'); 1449 867 updateWindowSize(); 1450 868 } 1451 869 1452 - /** 1453 - * Hide preview pane 1454 - */ 1455 870 function hidePreview() { 1456 871 const previewContainer = document.getElementById('preview-container'); 1457 - if (previewContainer) { 1458 - previewContainer.classList.remove('visible'); 1459 - } 872 + if (previewContainer) previewContainer.classList.remove('visible'); 1460 873 updateWindowSize(); 1461 874 } 1462 875 1463 876 // ===== MIME Type Renderers ===== 1464 877 1465 - /** 1466 - * Get renderer for MIME type 1467 - */ 1468 878 function getRenderer(mimeType) { 1469 879 const renderers = { 1470 880 'application/json': renderJson, ··· 1474 884 'text/html': renderHtml 1475 885 }; 1476 886 1477 - // Try exact match first 1478 - if (renderers[mimeType]) { 1479 - return renderers[mimeType]; 1480 - } 1481 - 1482 - // Try type-only match (e.g., "text/*") 887 + if (renderers[mimeType]) return renderers[mimeType]; 1483 888 const [type] = mimeType.split('/'); 1484 - if (type === 'text') { 1485 - return renderPlain; 1486 - } 1487 - 1488 - // Default fallback 889 + if (type === 'text') return renderPlain; 1489 890 return renderDefault; 1490 891 } 1491 892 1492 893 function renderJson(data) { 1493 894 try { 1494 895 const parsed = typeof data === 'string' ? JSON.parse(data) : data; 1495 - 1496 - // If it's an array, render as a nice list/table 1497 896 if (Array.isArray(parsed) && parsed.length > 0) { 1498 - // Check if items are objects (render as table) or primitives (render as list) 1499 897 if (typeof parsed[0] === 'object' && parsed[0] !== null) { 1500 898 return renderJsonTable(parsed); 1501 899 } else { 1502 - // Simple array of primitives 1503 900 const items = parsed.slice(0, 50).map((item, i) => 1504 901 `<div class="preview-list-item">${i + 1}. ${escapeHtml(String(item))}</div>` 1505 902 ).join(''); ··· 1507 904 return `<div class="preview-list">${items}${more}</div>`; 1508 905 } 1509 906 } 1510 - 1511 - // For objects or other types, show formatted JSON 1512 907 return `<pre class="preview-json">${escapeHtml(JSON.stringify(parsed, null, 2))}</pre>`; 1513 908 } catch (e) { 1514 909 return `<pre class="preview-error">Invalid JSON: ${escapeHtml(String(data))}</pre>`; ··· 1516 911 } 1517 912 1518 913 function renderJsonTable(data) { 1519 - // Get all unique keys from the objects 1520 914 const keys = new Set(); 1521 915 data.forEach(item => { 1522 - if (typeof item === 'object' && item !== null) { 1523 - Object.keys(item).forEach(k => keys.add(k)); 1524 - } 916 + if (typeof item === 'object' && item !== null) Object.keys(item).forEach(k => keys.add(k)); 1525 917 }); 1526 - const headers = Array.from(keys).slice(0, 6); // Limit columns 1527 - 1528 - // Build table 918 + const headers = Array.from(keys).slice(0, 6); 1529 919 const headerRow = headers.map(h => `<th>${escapeHtml(String(h))}</th>`).join(''); 1530 920 const rows = data.slice(0, 30).map(item => { 1531 921 const cells = headers.map(h => { ··· 1536 926 }).join(''); 1537 927 return `<tr>${cells}</tr>`; 1538 928 }).join(''); 1539 - 1540 929 const more = data.length > 30 ? `<div class="preview-more">Showing 30 of ${data.length} items</div>` : ''; 1541 - 1542 930 return `<table class="preview-table"><thead><tr>${headerRow}</tr></thead><tbody>${rows}</tbody></table>${more}`; 1543 931 } 1544 932 1545 933 function renderCsv(data) { 1546 - const lines = String(data).split('\n').slice(0, 20); // Limit to 20 lines 934 + const lines = String(data).split('\n').slice(0, 20); 1547 935 const rows = lines.map(line => { 1548 936 const cells = line.split(',').map(cell => `<td>${escapeHtml(cell.trim())}</td>`).join(''); 1549 937 return `<tr>${cells}</tr>`; ··· 1552 940 } 1553 941 1554 942 function renderMarkdown(data) { 1555 - // Simple markdown-to-HTML rendering for preview 1556 943 const text = String(data).slice(0, 4000); 1557 - const html = text 1558 - .split('\n') 1559 - .map(line => { 1560 - // Headings 1561 - if (line.startsWith('## ')) return `<h3>${escapeHtml(line.slice(3))}</h3>`; 1562 - if (line.startsWith('# ')) return `<h2>${escapeHtml(line.slice(2))}</h2>`; 1563 - // Horizontal rule 1564 - if (line.trim() === '---') return '<hr>'; 1565 - // Bold/italic inline 1566 - let processed = escapeHtml(line); 1567 - processed = processed.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>'); 1568 - processed = processed.replace(/\*(.+?)\*/g, '<em>$1</em>'); 1569 - // List items 1570 - if (line.startsWith('- ')) return `<li>${processed.slice(2)}</li>`; 1571 - // Empty line 1572 - if (line.trim() === '') return '<br>'; 1573 - // Default paragraph 1574 - return `<p>${processed}</p>`; 1575 - }) 1576 - .join('\n'); 944 + const html = text.split('\n').map(line => { 945 + if (line.startsWith('## ')) return `<h3>${escapeHtml(line.slice(3))}</h3>`; 946 + if (line.startsWith('# ')) return `<h2>${escapeHtml(line.slice(2))}</h2>`; 947 + if (line.trim() === '---') return '<hr>'; 948 + let processed = escapeHtml(line); 949 + processed = processed.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>'); 950 + processed = processed.replace(/\*(.+?)\*/g, '<em>$1</em>'); 951 + if (line.startsWith('- ')) return `<li>${processed.slice(2)}</li>`; 952 + if (line.trim() === '') return '<br>'; 953 + return `<p>${processed}</p>`; 954 + }).join('\n'); 1577 955 return `<div class="preview-markdown">${html}</div>`; 1578 956 } 1579 957 1580 958 function renderPlain(data) { 1581 - const text = String(data).slice(0, 2000); // Limit length 1582 - return `<pre class="preview-plain">${escapeHtml(text)}</pre>`; 959 + return `<pre class="preview-plain">${escapeHtml(String(data).slice(0, 2000))}</pre>`; 1583 960 } 1584 961 1585 962 function renderHtml(data) { 1586 - // Sanitize and show snippet of HTML 1587 - const text = String(data).slice(0, 2000); 1588 - return `<pre class="preview-html">${escapeHtml(text)}</pre>`; 963 + return `<pre class="preview-html">${escapeHtml(String(data).slice(0, 2000))}</pre>`; 1589 964 } 1590 965 1591 966 function renderDefault(data) { 1592 - const text = String(data).slice(0, 1000); 1593 - return `<pre class="preview-default">${escapeHtml(text)}</pre>`; 967 + return `<pre class="preview-default">${escapeHtml(String(data).slice(0, 1000))}</pre>`; 1594 968 } 1595 969 1596 970 function escapeHtml(str) { ··· 1602 976 .replace(/'/g, '&#039;'); 1603 977 } 1604 978 1605 - // ===== Execution State Functions ===== 979 + // ===== Execution State UI ===== 1606 980 1607 - const EXECUTION_TIMEOUT_MS = 30000; // 30 second timeout 981 + const EXECUTION_TIMEOUT_MS = 30000; 1608 982 1609 - /** 1610 - * Show execution state (spinner + command name) 1611 - */ 1612 983 function showExecutionState(commandName) { 1613 - state.executing = true; 1614 984 state.executingCommand = commandName; 1615 985 state.executionError = null; 1616 986 ··· 1628 998 updateWindowSize(); 1629 999 } 1630 1000 1631 - /** 1632 - * Hide execution state 1633 - */ 1634 1001 function hideExecutionState() { 1635 - state.executing = false; 1636 1002 state.executingCommand = null; 1637 1003 state.executionError = null; 1638 1004 ··· 1649 1015 updateWindowSize(); 1650 1016 } 1651 1017 1652 - /** 1653 - * Show execution error 1654 - */ 1655 1018 function showExecutionError(commandName, errorMsg) { 1656 - state.executing = false; 1657 1019 state.executionError = errorMsg; 1658 1020 1659 1021 if (state.executionTimeout) { ··· 1676 1038 } 1677 1039 updateWindowSize(); 1678 1040 1679 - // Auto-hide error after 5 seconds 1041 + // Auto-hide after 5 seconds 1680 1042 setTimeout(() => { 1681 1043 if (state.executionError === errorMsg) { 1682 - hideExecutionState(); 1044 + machine.dispatch(Events.ERROR_TIMEOUT); 1683 1045 } 1684 1046 }, 5000); 1685 1047 } 1686 1048 1687 - /** 1688 - * Cancel current execution 1689 - */ 1690 - function cancelExecution() { 1691 - log('cmd:panel', 'Cancelling execution'); 1692 - hideExecutionState(); 1693 - } 1049 + // ===== Execute Command ===== 1694 1050 1695 - /** 1696 - * Executes a command 1697 - */ 1698 - async function execute(name, typed, extra = {}) { 1051 + async function doExecute(name, typed, extra = {}) { 1699 1052 log('cmd:panel', 'execute() called with:', name, typed); 1700 1053 if (!state.commands[name]) { 1701 1054 log.error('cmd:panel', 'Command not found:', name); 1702 - showExecutionError(name, 'Command not found. Try again in a moment.'); 1055 + machine.dispatch(Events.COMMAND_ERROR, { name, error: 'Command not found. Try again in a moment.' }); 1703 1056 return; 1704 1057 } 1705 1058 ··· 1708 1061 if (extra.selectedItem) { 1709 1062 context.selectedItem = extra.selectedItem; 1710 1063 } 1711 - log('cmd:panel', 'execution context', context); 1712 1064 1713 - // Delay showing execution state - only show if command takes > 150ms 1714 - // This prevents flash for fast commands 1065 + // Delay showing execution state (only show if command takes > 150ms) 1715 1066 const SHOW_SPINNER_DELAY_MS = 150; 1716 1067 const showStateTimer = setTimeout(() => { 1717 1068 showExecutionState(name); 1718 1069 }, SHOW_SPINNER_DELAY_MS); 1719 1070 1720 - // Set up timeout 1721 1071 const timeoutPromise = new Promise((_, reject) => { 1722 1072 state.executionTimeout = setTimeout(() => { 1723 1073 reject(new Error(`Command timed out after ${EXECUTION_TIMEOUT_MS / 1000}s`)); ··· 1725 1075 }); 1726 1076 1727 1077 try { 1728 - // Execute command with timeout 1729 1078 const result = await Promise.race([ 1730 1079 state.commands[name].execute(context), 1731 1080 timeoutPromise 1732 1081 ]); 1733 1082 1734 - // Clear timers 1735 1083 clearTimeout(showStateTimer); 1736 1084 if (state.executionTimeout) { 1737 1085 clearTimeout(state.executionTimeout); 1738 1086 state.executionTimeout = null; 1739 1087 } 1740 1088 1741 - // Hide execution state (in case it was shown) 1742 - hideExecutionState(); 1089 + log('cmd:panel', 'command result:', result); 1743 1090 1744 - log('cmd:panel', 'command result:', result); 1091 + // Determine output routing and dispatch command_complete with routing info 1092 + const payload = { result: result || {}, name }; 1745 1093 1746 - // Check if command produced chainable output 1747 1094 if (result && result.output && result.output.data && result.output.mimeType) { 1748 1095 const outputData = result.output.data; 1749 1096 const outputMimeType = result.output.mimeType; 1750 1097 1751 - // Handle special mimeTypes that have automatic routing (not chaining) 1752 - // 'item' - open existing item in editor 1753 - // 'new-item' - open empty editor for new item 1754 - if (outputMimeType === 'item' || outputMimeType === 'new-item') { 1755 - // If output is an array of items, enter selection mode 1756 - if (Array.isArray(outputData) && outputData.length > 0) { 1757 - enterOutputSelectionMode(outputData, outputMimeType, name); 1758 - 1759 - const commandInput = document.getElementById('command-input'); 1760 - if (commandInput) { 1761 - commandInput.value = ''; 1762 - commandInput.focus(); 1763 - } 1764 - return; 1765 - } 1766 - 1767 - // Single item - route to editor 1768 - if (outputMimeType === 'new-item') { 1769 - // Open empty editor for new item 1770 - log('cmd:panel', 'Opening editor for new item:', outputData); 1771 - api.publish('editor:add', { type: outputData.type || 'text' }, api.scopes.GLOBAL); 1772 - } else if (outputData.id) { 1773 - // Open existing item in editor 1774 - log('cmd:panel', 'Opening editor for item:', outputData.id); 1775 - api.publish('editor:open', { itemId: outputData.id }, api.scopes.GLOBAL); 1776 - 1777 - // If this is a newly created item, also notify of the change 1778 - if (result.output.isNew) { 1779 - api.publish('editor:changed', { action: 'add', itemId: outputData.id }, api.scopes.GLOBAL); 1780 - } 1781 - } 1782 - 1783 - // Close panel after routing to editor 1784 - setTimeout(shutdown, 100); 1785 - return; 1786 - } 1787 - 1788 - // If output is an array, check if we should pipe the whole array 1789 - // or enter output selection mode for individual item picking 1790 - if (Array.isArray(outputData) && outputData.length > 0) { 1791 - // Check if there are downstream commands that accept this MIME type. 1792 - // If so, pipe the entire array into chain mode (e.g., list notes -> markdown). 1793 - // If no downstream commands, fall back to output selection mode. 1794 - const downstreamCommands = findChainingCommands(outputMimeType); 1795 - if (downstreamCommands.length > 0) { 1796 - // Pipe entire array to chain mode 1797 - await enterChainMode(result.output, name); 1798 - 1799 - const commandInput = document.getElementById('command-input'); 1800 - if (commandInput) { 1801 - commandInput.value = ''; 1802 - commandInput.focus(); 1803 - } 1804 - return; 1805 - } 1806 - 1807 - // No downstream commands - enter output selection mode to let user pick 1808 - enterOutputSelectionMode(outputData, outputMimeType, name); 1809 - 1810 - // Clear input for selection 1811 - const commandInput = document.getElementById('command-input'); 1812 - if (commandInput) { 1813 - commandInput.value = ''; 1814 - commandInput.focus(); 1815 - } 1816 - // Don't shutdown - stay open for selection 1817 - return; 1818 - } 1819 - 1820 - // Single item output - enter chain mode directly 1821 - await enterChainMode(result.output, name); 1822 - 1823 - // Clear input for next command 1824 - const commandInput = document.getElementById('command-input'); 1825 - if (commandInput) { 1826 - commandInput.value = ''; 1827 - commandInput.focus(); 1828 - } 1829 - // Don't shutdown - stay open for chaining 1830 - return; 1098 + // Check for downstream commands to set hasDownstream 1099 + const downstreamCommands = findChainingCommands(outputMimeType); 1100 + payload.hasDownstream = downstreamCommands.length > 0; 1831 1101 } 1832 1102 1833 - // No output or end of chain - close panel 1834 - if (state.chainMode) { 1835 - exitChainMode(); 1836 - } 1103 + machine.dispatch(Events.COMMAND_COMPLETE, payload); 1837 1104 1838 - // Check result action type 1839 - // 'prompt' - command shows a dialog/prompt, keep panel open for user interaction 1840 - if (result && result.action === 'prompt') { 1841 - return; 1842 - } 1843 - 1844 - setTimeout(shutdown, 100); 1845 1105 } catch (err) { 1846 - // Clear the show state timer on error too 1847 1106 clearTimeout(showStateTimer); 1848 - 1849 1107 log.error('cmd:panel', 'Command execution error:', err); 1850 - 1851 - // Show error state 1852 - showExecutionError(name, err.message || 'Unknown error'); 1853 - 1854 - // Don't close panel on error - let user see the error and try again 1108 + machine.dispatch(Events.COMMAND_ERROR, { name, error: err.message || 'Unknown error' }); 1855 1109 } 1856 1110 } 1857 1111 1858 - /** 1859 - * Closes the window 1860 - */ 1861 - async function shutdown() { 1862 - window.close(); 1863 - } 1864 - 1865 - /** 1866 - * Finds commands matching the typed text 1867 - * Filters based on: 1868 - * 1. Text matching (command name contains typed text) 1869 - * 2. Mode availability (command's mode requirements match current mode) 1870 - */ 1871 - function findMatchingCommands(text) { 1872 - log('cmd:panel', 'findMatchingCommands', text, Object.keys(state.commands).length); 1112 + // ===== URL Validation ===== 1873 1113 1874 - let matches = []; 1114 + function getValidURL(str) { 1115 + if (!str) return { valid: false }; 1875 1116 1876 - // No text, no matches 1877 - if (!text) { 1878 - return matches; 1879 - } 1117 + const hasValidProtocol = /^(https?|ftp|file|peek):\/\//.test(str); 1880 1118 1881 - // Get the command part (text before the first space) 1882 - const commandPart = text.split(' ')[0]; 1883 - const hasParameters = text.includes(' '); 1119 + if (!hasValidProtocol) { 1120 + const isDomainPattern = /^([a-zA-Z0-9]([a-zA-Z0-9\-]{0,61}[a-zA-Z0-9])?\.)+[a-zA-Z]{2,}(\/.*)?$/.test(str); 1121 + const isLocalhost = /^localhost(:\d+)?(\/.*)?$/.test(str); 1884 1122 1885 - const lowerCommandPart = commandPart.toLowerCase(); 1886 - const lowerText = text.toLowerCase(); 1887 - 1888 - log('cmd:panel', 'Command part:', commandPart, 'Has parameters:', hasParameters); 1889 - 1890 - // Check if the typed text already starts with a complete multi-word command name + space. 1891 - // If so, the user has committed to that command (e.g., "list notes #foo") and we should 1892 - // only return that exact command — not other commands that share the first word. 1893 - let committedCommand = null; 1894 - if (hasParameters) { 1895 - const trimmedLower = lowerText.trimEnd(); 1896 - for (const name of Object.keys(state.commands)) { 1897 - const lowerName = name.toLowerCase(); 1898 - if (lowerName.includes(' ') && (trimmedLower === lowerName || lowerText.startsWith(lowerName + ' '))) { 1899 - // Prefer longest matching command name (e.g., "list notes" over "list") 1900 - if (!committedCommand || name.length > committedCommand.length) { 1901 - committedCommand = name; 1902 - } 1123 + if (isDomainPattern || isLocalhost) { 1124 + const urlWithProtocol = 'https://' + str; 1125 + try { 1126 + new URL(urlWithProtocol); 1127 + return { valid: true, url: urlWithProtocol }; 1128 + } catch (e) { 1129 + return { valid: false }; 1903 1130 } 1904 1131 } 1132 + return { valid: false }; 1905 1133 } 1906 1134 1907 - // Iterate over all commands, searching for matches 1908 - for (const name of Object.keys(state.commands)) { 1909 - const cmd = state.commands[name]; 1910 - 1911 - // Check mode availability first 1912 - if (!isCommandAvailable(cmd)) { 1913 - continue; 1914 - } 1915 - 1916 - // If user committed to a specific multi-word command, only include that one 1917 - if (committedCommand) { 1918 - if (name === committedCommand) { 1919 - matches.push(name); 1920 - } 1921 - continue; 1922 - } 1923 - 1924 - // Match when: 1925 - // 1. typed string is anywhere in a command name 1926 - // 2. command name is at beginning of typed string (for commands with parameters) 1927 - log('cmd:panel', 'testing option...', name); 1928 - 1929 - const lowerName = name.toLowerCase(); 1930 - const matchesCommand = lowerName.indexOf(lowerCommandPart) !== -1; 1931 - const isCommandWithParams = hasParameters && lowerText.startsWith(lowerName + ' '); 1932 - 1933 - if (matchesCommand || isCommandWithParams) { 1934 - matches.push(name); 1935 - } 1135 + try { 1136 + new URL(str); 1137 + return { valid: true, url: str }; 1138 + } catch (e) { 1139 + return { valid: false }; 1936 1140 } 1937 - 1938 - // Sort by combined ranking score: 1939 - // 1. Exact match with parameters (highest priority) 1940 - // 2. Combined score: adaptive score (query-specific) + frecency (global usage) 1941 - // This ensures frequently-used commands rank high even when the specific 1942 - // query has no adaptive data (e.g., user types "ta" but usually types "tags") 1943 - // 3. Tiebreaker: prefix match preferred, then shorter name, then alphabetical 1944 - matches.sort(function(a, b) { 1945 - const aLower = a.toLowerCase(); 1946 - const bLower = b.toLowerCase(); 1947 - 1948 - // If we have parameters, prioritize exact command match 1949 - if (hasParameters) { 1950 - const aExact = aLower === lowerCommandPart; 1951 - const bExact = bLower === lowerCommandPart; 1952 - if (aExact && !bExact) return -1; 1953 - if (bExact && !aExact) return 1; 1954 - } 1955 - 1956 - // Combined ranking: adaptive score (0-1) weighted heavily + frecency score (0-1) 1957 - // Adaptive score is query-specific (e.g., "ta" -> "open tags") 1958 - // Frecency score is global (total selections of this command) 1959 - const aAdaptive = getAdaptiveScore(commandPart, a); 1960 - const bAdaptive = getAdaptiveScore(commandPart, b); 1961 - const aFrecency = getFrecencyScore(a); 1962 - const bFrecency = getFrecencyScore(b); 1963 - 1964 - // Weight adaptive 3x more than frecency when adaptive data exists, 1965 - // but frecency still contributes so heavily-used commands rank well 1966 - // even without adaptive data for this specific query 1967 - const aScore = (aAdaptive * 3) + aFrecency; 1968 - const bScore = (bAdaptive * 3) + bFrecency; 1969 - 1970 - log('cmd:panel', 'sort score', commandPart, a, 'adaptive:'+aAdaptive, 'frecency:'+aFrecency, 'total:'+aScore, 'vs', b, 'adaptive:'+bAdaptive, 'frecency:'+bFrecency, 'total:'+bScore); 1971 - 1972 - if (bScore !== aScore) { 1973 - return bScore - aScore; 1974 - } 1975 - 1976 - // Tiebreaker when scores are equal: 1977 - // 1. Prefer prefix matches (command starts with typed text) over substring matches 1978 - // This makes the inline autocomplete ghost text more natural (e.g., "ta" -> "tag" 1979 - // rather than "ta" -> "open tags" which shifts the cursor position) 1980 - const aPrefix = aLower.startsWith(lowerCommandPart); 1981 - const bPrefix = bLower.startsWith(lowerCommandPart); 1982 - if (aPrefix && !bPrefix) return -1; 1983 - if (bPrefix && !aPrefix) return 1; 1984 - 1985 - // 2. Prefer shorter command names (more specific match) 1986 - if (a.length !== b.length) { 1987 - return a.length - b.length; 1988 - } 1989 - 1990 - // 3. Alphabetical for full determinism 1991 - return a.localeCompare(b); 1992 - }); 1993 - 1994 - return matches; 1995 1141 } 1996 1142 1997 - /** 1998 - * Updates the adaptive feedback for a typed string -> command selection 1999 - * Uses asymptotic scoring: score = count / (count + k) 2000 - * This creates ever-strengthening reinforcement based on user decisions 2001 - * 2002 - * Records feedback for: 2003 - * 1. The exact typed string (full weight) 2004 - * 2. Substrings of the command name that could be search queries (quarter weight) 2005 - * - e.g., selecting "open tags" via "open" also creates entries for "ta", "tag", "tags" 2006 - * - This ensures multi-word commands rank well when searched by any word substring 2007 - * 2008 - * Note: Prefix feedback (typing "tag" adds to "ta") was removed in v2 because it 2009 - * caused cross-contamination between queries (e.g., "tag" command usage polluted 2010 - * the "ta" query, preventing "open tags" from ranking first). 2011 - */ 2012 - function updateAdaptiveFeedback(typed, name) { 2013 - // Initialize feedback for this typed string if needed 2014 - log('cmd:panel', 'updateAdaptiveFeedback', typed, '->', name, 'current feedback:', JSON.stringify(state.adaptiveFeedback[typed] || {})); 2015 - if (!state.adaptiveFeedback[typed]) { 2016 - state.adaptiveFeedback[typed] = {}; 2017 - } 1143 + function findMatchingUrlCommand(url) { 1144 + if (!url) return null; 1145 + const lowerUrl = url.toLowerCase(); 2018 1146 2019 - // Increment the count for this typed -> command pair 2020 - if (!state.adaptiveFeedback[typed][name]) { 2021 - state.adaptiveFeedback[typed][name] = 0; 2022 - } 2023 - state.adaptiveFeedback[typed][name]++; 2024 - 2025 - // Record feedback for substrings of the command name that could be typed 2026 - // as search queries. This covers the case where a user always types "open" 2027 - // to reach "open tags" but sometimes types "ta" instead - the command 2028 - // should rank high for both queries. 2029 - const lowerName = name.toLowerCase(); 2030 - const lowerTyped = typed.toLowerCase(); 2031 - const words = lowerName.split(/[\s-]+/); // Split on spaces and hyphens 1147 + if (state.commands[url]) return url; 2032 1148 2033 - for (const word of words) { 2034 - // Skip words that are the same as or prefix of the typed text (already covered by the direct feedback or redundant) 2035 - if (lowerTyped.startsWith(word) || word.startsWith(lowerTyped)) { 2036 - continue; 2037 - } 1149 + let bestMatch = null; 1150 + let bestLength = Infinity; 2038 1151 2039 - // Generate substrings of this word (min length 2 to avoid single chars) 2040 - for (let len = 2; len <= word.length; len++) { 2041 - const sub = word.substring(0, len); 2042 - if (!state.adaptiveFeedback[sub]) { 2043 - state.adaptiveFeedback[sub] = {}; 1152 + for (const name of Object.keys(state.commands)) { 1153 + const lowerName = name.toLowerCase(); 1154 + if (lowerName.startsWith(lowerUrl) || lowerUrl.startsWith(lowerName)) { 1155 + if (name.length < bestLength) { 1156 + bestMatch = name; 1157 + bestLength = name.length; 2044 1158 } 2045 - if (!state.adaptiveFeedback[sub][name]) { 2046 - state.adaptiveFeedback[sub][name] = 0; 2047 - } 2048 - // Quarter weight for command name substrings 2049 - state.adaptiveFeedback[sub][name] += 0.25; 2050 1159 } 2051 1160 } 2052 1161 2053 - // Persist to storage 2054 - saveAdaptiveData(state.adaptiveFeedback, state.matchCounts); 2055 - } 2056 - 2057 - /** 2058 - * Gets the adaptive score for a command given the typed string 2059 - * Uses asymptotic formula: score = count / (count + k) 2060 - * Returns 0-1 where higher is better 2061 - */ 2062 - function getAdaptiveScore(typed, name) { 2063 - const k = 3; // Tuning constant - higher = slower convergence 2064 - const feedback = state.adaptiveFeedback[typed]; 2065 - if (!feedback || !feedback[name]) { 2066 - return 0; 2067 - } 2068 - const count = feedback[name]; 2069 - return count / (count + k); 2070 - } 2071 - 2072 - /** 2073 - * Gets the frecency score for a command based on total selection count 2074 - * Uses the same asymptotic formula as adaptive scoring 2075 - * Returns 0-1 where higher is better 2076 - */ 2077 - function getFrecencyScore(name) { 2078 - const k = 10; // Higher k so frecency grows more slowly (needs more uses to saturate) 2079 - const count = state.matchCounts[name] || 0; 2080 - if (count === 0) return 0; 2081 - return count / (count + k); 1162 + return bestMatch; 2082 1163 } 2083 1164 2084 - /** 2085 - * Updates the match count for frequency sorting 2086 - */ 2087 - function updateMatchCount(name) { 2088 - if (!state.matchCounts[name]) { 2089 - state.matchCounts[name] = 0; 2090 - } 2091 - state.matchCounts[name]++; 2092 - 2093 - // Persist to storage 2094 - saveAdaptiveData(state.adaptiveFeedback, state.matchCounts); 1165 + function hasModifier(e) { 1166 + return e.altKey || e.ctrlKey || e.metaKey; 2095 1167 } 2096 1168 2097 - // ===== Param Mode Functions ===== 2098 - 2099 - /** 2100 - * Enter param suggestion mode for a committed command 2101 - * If already in param mode for the same command, just refresh suggestions. 2102 - * @param {string} commandName - The command the user has committed to 2103 - */ 2104 - function enterParamMode(commandName) { 2105 - const cmd = state.commands[commandName]; 2106 - if (!cmd) return; 2107 - 2108 - if (!state.paramMode || state.paramCommand !== commandName) { 2109 - log('cmd:panel', 'Entering param mode for:', commandName); 2110 - state.paramMode = true; 2111 - state.paramCommand = commandName; 2112 - state.paramSuggestions = []; 2113 - state.paramIndex = -1; 2114 - } 2115 - 2116 - updateParamSuggestions(); 2117 - } 2118 - 2119 - /** 2120 - * Exit param suggestion mode 2121 - */ 2122 - function exitParamMode() { 2123 - if (!state.paramMode) return; 2124 - log('cmd:panel', 'Exiting param mode'); 2125 - state.paramMode = false; 2126 - state.paramCommand = null; 2127 - state.paramSuggestions = []; 2128 - state.paramIndex = -1; 2129 - } 2130 - 2131 - /** 2132 - * Extract the current partial token and completed tokens from typed text 2133 - * @returns {{partial: string, completedTokens: string[]}} 2134 - */ 2135 - function getParamTokens() { 2136 - const commandName = state.paramCommand; 2137 - if (!commandName) return { partial: '', completedTokens: [] }; 2138 - 2139 - const typed = state.typed || ''; 2140 - const lowerTyped = typed.toLowerCase(); 2141 - const lowerName = commandName.toLowerCase(); 2142 - 2143 - // Get text after the command name 2144 - let rest = ''; 2145 - if (lowerTyped.startsWith(lowerName + ' ')) { 2146 - rest = typed.slice(commandName.length + 1); 2147 - } else if (lowerTyped.startsWith(lowerName)) { 2148 - rest = typed.slice(commandName.length); 2149 - } 2150 - 2151 - // Split into tokens - the last one is the current partial 2152 - const tokens = rest.split(/\s+/); 2153 - const endsWithSpace = rest.endsWith(' ') || rest === ''; 1169 + // ===== UI Update Functions ===== 2154 1170 2155 - if (endsWithSpace) { 2156 - // Trailing space means all tokens are completed, partial is empty 2157 - return { 2158 - partial: '', 2159 - completedTokens: tokens.filter(t => t.length > 0) 2160 - }; 2161 - } 2162 - 2163 - // Last token is partial (still being typed) 2164 - const partial = tokens.pop() || ''; 2165 - return { 2166 - partial, 2167 - completedTokens: tokens.filter(t => t.length > 0) 2168 - }; 2169 - } 2170 - 2171 - /** 2172 - * Update param suggestions based on current input 2173 - */ 2174 - async function updateParamSuggestions() { 2175 - if (!state.paramMode || !state.paramCommand) return; 2176 - 2177 - const cmd = state.commands[state.paramCommand]; 2178 - if (!cmd) return; 2179 - 2180 - const paramDef = (cmd.params && cmd.params[0]) || null; 2181 - if (!paramDef) { 2182 - state.paramSuggestions = []; 2183 - return; 2184 - } 2185 - 2186 - const generation = ++state.paramGeneration; 2187 - const { partial, completedTokens } = getParamTokens(); 2188 - 2189 - // For item-type params, pass the full rest-of-input as the search query 2190 - // (user is typing a search phrase, not discrete tokens) 2191 - let searchPartial = partial; 2192 - if (paramDef.type === 'item') { 2193 - searchPartial = [...completedTokens, partial].filter(Boolean).join(' '); 2194 - } 2195 - 2196 - // Build exclusion set from completed tokens 2197 - const prefix = paramDef.prefix || ''; 2198 - const exclude = new Set( 2199 - completedTokens.map(t => { 2200 - const stripped = prefix && t.toLowerCase().startsWith(prefix.toLowerCase()) 2201 - ? t.slice(prefix.length).toLowerCase() 2202 - : t.toLowerCase(); 2203 - return stripped; 2204 - }) 2205 - ); 2206 - 2207 - const suggestions = await getSuggestions(paramDef, searchPartial, exclude); 2208 - 2209 - // Discard stale results 2210 - if (generation !== state.paramGeneration) return; 2211 - 2212 - state.paramSuggestions = suggestions; 2213 - state.paramIndex = suggestions.length > 0 ? 0 : -1; 2214 - 2215 - updateResultsUI(); 2216 - updateCommandUI(); 2217 - } 2218 - 2219 - /** 2220 - * Fill a param suggestion into the input without executing (Tab behavior). 2221 - * For item-type params, fills the title (human-readable) into the input. 2222 - * For other params, fills the suggestion value. 2223 - * Tab should ONLY complete/fill text — never execute. 2224 - * @param {number} index - Index in paramSuggestions to fill 2225 - */ 2226 - function fillParamSuggestion(index) { 2227 - if (index < 0 || index >= state.paramSuggestions.length) return; 2228 - 2229 - const suggestion = state.paramSuggestions[index]; 2230 - const commandInput = document.getElementById('command-input'); 2231 - const commandName = state.paramCommand; 2232 - 2233 - // For item-type params, use title (human-readable); otherwise use value 2234 - const cmd = state.commands[state.paramCommand]; 2235 - const paramDef = cmd && cmd.params && cmd.params[0]; 2236 - const fillText = (paramDef && paramDef.type === 'item' && suggestion.title) 2237 - ? suggestion.title 2238 - : suggestion.value; 2239 - 2240 - // Get current text after command name 2241 - const typed = state.typed || ''; 2242 - const lowerTyped = typed.toLowerCase(); 2243 - const lowerName = commandName.toLowerCase(); 2244 - let beforeParams = commandName + ' '; 2245 - 2246 - // Get text after command name 2247 - let rest = ''; 2248 - if (lowerTyped.startsWith(lowerName + ' ')) { 2249 - rest = typed.slice(commandName.length + 1); 2250 - } 2251 - 2252 - // Split tokens, replace the last partial with suggestion text 2253 - const tokens = rest.split(/\s+/).filter(t => t.length > 0); 2254 - const endsWithSpace = rest.endsWith(' ') || rest === ''; 2255 - 2256 - if (!endsWithSpace && tokens.length > 0) { 2257 - // Replace the partial (last token) with suggestion text 2258 - tokens[tokens.length - 1] = fillText; 2259 - } else { 2260 - // No partial — append the suggestion 2261 - tokens.push(fillText); 2262 - } 2263 - 2264 - // Rebuild input: command + completed tokens + trailing space 2265 - const newValue = beforeParams + tokens.join(' ') + ' '; 2266 - commandInput.value = newValue; 2267 - state.typed = newValue; 2268 - 2269 - // Select this suggestion in the list for visual feedback 2270 - state.paramIndex = index; 2271 - 2272 - // Cursor to end 2273 - setTimeout(() => { 2274 - commandInput.setSelectionRange(newValue.length, newValue.length); 2275 - }, 0); 2276 - 2277 - // Refresh suggestions for the next token 2278 - updateParamSuggestions(); 2279 - } 2280 - 2281 - /** 2282 - * Accept a param suggestion at the given index — executes the command. 2283 - * Called by Enter key and click handlers. 2284 - * @param {number} index - Index in paramSuggestions to accept 2285 - */ 2286 - function acceptParamSuggestion(index) { 2287 - if (index < 0 || index >= state.paramSuggestions.length) return; 2288 - 2289 - const suggestion = state.paramSuggestions[index]; 2290 - 2291 - // Item-type param for connector commands (those with produces): 2292 - // Route through execute() with the selected item so the command can use it 2293 - // directly instead of re-searching by text (which could match the wrong item). 2294 - const cmd = state.commands[state.paramCommand]; 2295 - const paramDef = cmd && cmd.params && cmd.params[0]; 2296 - if (paramDef && paramDef.type === 'item' && suggestion._item) { 2297 - const commandName = state.paramCommand; 2298 - const typed = commandName + ' ' + (suggestion.title || suggestion.value || ''); 2299 - const selectedItem = suggestion._item; 2300 - exitParamMode(); 2301 - execute(commandName, typed, { selectedItem }); 2302 - return; 2303 - } 2304 - 2305 - const commandInput = document.getElementById('command-input'); 2306 - const commandName = state.paramCommand; 2307 - 2308 - // Get current text after command name 2309 - const typed = state.typed || ''; 2310 - const lowerTyped = typed.toLowerCase(); 2311 - const lowerName = commandName.toLowerCase(); 2312 - let beforeParams = commandName + ' '; 2313 - 2314 - // Get text after command name 2315 - let rest = ''; 2316 - if (lowerTyped.startsWith(lowerName + ' ')) { 2317 - rest = typed.slice(commandName.length + 1); 2318 - } 2319 - 2320 - // Split tokens, replace the last partial with suggestion value 2321 - const tokens = rest.split(/\s+/).filter(t => t.length > 0); 2322 - const endsWithSpace = rest.endsWith(' ') || rest === ''; 2323 - 2324 - if (!endsWithSpace && tokens.length > 0) { 2325 - // Replace the partial (last token) with suggestion value 2326 - tokens[tokens.length - 1] = suggestion.value; 2327 - } else { 2328 - // No partial — append the suggestion 2329 - tokens.push(suggestion.value); 2330 - } 2331 - 2332 - // Rebuild input: command + completed tokens + trailing space 2333 - const newValue = beforeParams + tokens.join(' ') + ' '; 2334 - commandInput.value = newValue; 2335 - state.typed = newValue; 2336 - 2337 - // Cursor to end 2338 - setTimeout(() => { 2339 - commandInput.setSelectionRange(newValue.length, newValue.length); 2340 - }, 0); 2341 - 2342 - // Refresh suggestions for the next token 2343 - updateParamSuggestions(); 2344 - } 2345 - 2346 - /** 2347 - * Updates the command text UI with proper highlighting 2348 - * Shows typed text in white, suggestion completion in grey 2349 - */ 2350 1171 function updateCommandUI() { 2351 1172 const commandText = document.getElementById('command-text'); 2352 1173 commandText.innerHTML = ''; 2353 1174 2354 - // If no typed text, show nothing 2355 - if (!state.typed) { 2356 - return; 2357 - } 1175 + if (!state.typed) return; 2358 1176 2359 - // If no matches, just show the typed text 2360 1177 if (state.matches.length === 0) { 2361 1178 const typedSpan = document.createElement('span'); 2362 1179 typedSpan.className = 'typed'; ··· 2374 1191 return; 2375 1192 } 2376 1193 2377 - // Check if we have parameters (text after the command) 2378 - // For multi-word commands like "open groups", we need to check if typed text 2379 - // matches the full command name before splitting on spaces 2380 - let typedCommand, typedParams; 2381 - // Use originalTyped (what user actually typed) for highlight boundary, 2382 - // so Tab-cycled suggestions show the original input bold and completion dim 2383 1194 const userTyped = state.originalTyped || state.typed; 2384 1195 const trimmedUserTyped = userTyped.trim(); 2385 1196 2386 - // Check if typed text matches full command or command + params 1197 + let typedCommand, typedParams; 1198 + 2387 1199 if (state.typed.toLowerCase().startsWith(selectedMatch.toLowerCase() + ' ')) { 2388 - // Command with parameters - highlight original typed command portion, show params 2389 - // Use originalTyped for highlight length but state.typed for actual param text 2390 1200 typedCommand = trimmedUserTyped.toLowerCase().startsWith(selectedMatch.toLowerCase()) 2391 1201 ? trimmedUserTyped.substring(0, Math.min(trimmedUserTyped.length, selectedMatch.length)) 2392 1202 : trimmedUserTyped; 2393 1203 typedParams = state.typed.substring(selectedMatch.length); 2394 1204 } else if (selectedMatch.toLowerCase().startsWith(trimmedUserTyped.toLowerCase())) { 2395 - // Typed text is a prefix of the command (e.g., "ta" → "tags", or "list n" → "list notes") 2396 - // Bold the user-typed portion, dim the rest 2397 1205 typedCommand = trimmedUserTyped; 2398 1206 typedParams = ''; 2399 1207 } else if (trimmedUserTyped.toLowerCase() === selectedMatch.toLowerCase()) { 2400 - // Exact match - typed text IS the command 2401 1208 typedCommand = trimmedUserTyped; 2402 1209 typedParams = state.typed.substring(trimmedUserTyped.length); 2403 1210 } else { 2404 - // Partial match in middle or no clear match - use first space as split point 2405 1211 const hasParameters = userTyped.includes(' '); 2406 1212 typedCommand = hasParameters ? userTyped.substring(0, userTyped.indexOf(' ')) : userTyped; 2407 1213 typedParams = hasParameters ? state.typed.substring(state.typed.indexOf(' ')) : ''; 2408 1214 } 2409 1215 2410 - // Find where the typed text matches in the command 2411 1216 const lowerMatch = selectedMatch.toLowerCase(); 2412 1217 const lowerTyped = typedCommand.toLowerCase(); 2413 - const matchIndex = lowerMatch.indexOf(lowerTyped); 1218 + const matchIdx = lowerMatch.indexOf(lowerTyped); 2414 1219 2415 - if (matchIndex === 0) { 2416 - // Typed text is at the start - show typed in white, rest in grey 1220 + if (matchIdx === 0) { 2417 1221 const typedSpan = document.createElement('span'); 2418 1222 typedSpan.className = 'typed'; 2419 1223 typedSpan.textContent = typedCommand; 2420 1224 commandText.appendChild(typedSpan); 2421 1225 2422 - // Show the rest of the command suggestion in grey 2423 1226 if (selectedMatch.length > typedCommand.length) { 2424 1227 const restSpan = document.createElement('span'); 2425 1228 restSpan.textContent = selectedMatch.substring(typedCommand.length); 2426 1229 commandText.appendChild(restSpan); 2427 1230 } 2428 1231 2429 - // Add parameters in white 2430 1232 if (typedParams) { 2431 1233 const paramsSpan = document.createElement('span'); 2432 1234 paramsSpan.className = 'typed'; 2433 1235 paramsSpan.textContent = typedParams; 2434 1236 commandText.appendChild(paramsSpan); 2435 1237 } 2436 - } else if (matchIndex > 0) { 2437 - // Typed text matches in the middle - show full command with typed part highlighted 1238 + } else if (matchIdx > 0) { 2438 1239 const beforeSpan = document.createElement('span'); 2439 - beforeSpan.textContent = selectedMatch.substring(0, matchIndex); 1240 + beforeSpan.textContent = selectedMatch.substring(0, matchIdx); 2440 1241 commandText.appendChild(beforeSpan); 2441 1242 2442 1243 const typedSpan = document.createElement('span'); 2443 1244 typedSpan.className = 'typed'; 2444 - typedSpan.textContent = selectedMatch.substring(matchIndex, matchIndex + typedCommand.length); 1245 + typedSpan.textContent = selectedMatch.substring(matchIdx, matchIdx + typedCommand.length); 2445 1246 commandText.appendChild(typedSpan); 2446 1247 2447 1248 const afterSpan = document.createElement('span'); 2448 - afterSpan.textContent = selectedMatch.substring(matchIndex + typedCommand.length); 1249 + afterSpan.textContent = selectedMatch.substring(matchIdx + typedCommand.length); 2449 1250 commandText.appendChild(afterSpan); 2450 1251 2451 - // Add parameters in white 2452 1252 if (typedParams) { 2453 1253 const paramsSpan = document.createElement('span'); 2454 1254 paramsSpan.className = 'typed'; ··· 2456 1256 commandText.appendChild(paramsSpan); 2457 1257 } 2458 1258 } else { 2459 - // No match position found, just show typed text 2460 1259 const typedSpan = document.createElement('span'); 2461 1260 typedSpan.className = 'typed'; 2462 1261 typedSpan.textContent = state.typed; 2463 1262 commandText.appendChild(typedSpan); 2464 1263 } 2465 1264 2466 - // In param mode, ghost-complete the current partial token from top suggestion 2467 - if (state.paramMode && state.paramSuggestions.length > 0) { 1265 + // Param mode ghost completion 1266 + const isParamState = machine.getState() === States.PARAM_MODE; 1267 + if (isParamState && state.paramSuggestions.length > 0) { 2468 1268 const idx = state.paramIndex >= 0 ? state.paramIndex : 0; 2469 1269 const suggestion = state.paramSuggestions[idx]; 2470 1270 if (suggestion) { 2471 1271 const { partial } = getParamTokens(); 2472 - // For item-type params, show title (human-readable) instead of value (ID) 2473 1272 const cmd = state.commands[state.paramCommand]; 2474 1273 const paramDef = cmd && cmd.params && cmd.params[0]; 2475 1274 const ghostSource = (paramDef && paramDef.type === 'item') ? suggestion.title : suggestion.value; 2476 - // Only show ghost if suggestion starts with the partial 2477 1275 if (partial && ghostSource.toLowerCase().startsWith(partial.toLowerCase())) { 2478 1276 const ghostText = ghostSource.substring(partial.length); 2479 1277 if (ghostText) { ··· 2482 1280 commandText.appendChild(ghostSpan); 2483 1281 } 2484 1282 } else if (!partial) { 2485 - // No partial typed — show full suggestion as ghost 2486 1283 const ghostSpan = document.createElement('span'); 2487 1284 ghostSpan.textContent = ghostSource; 2488 1285 commandText.appendChild(ghostSpan); ··· 2491 1288 } 2492 1289 } 2493 1290 2494 - /** 2495 - * Updates the results list UI 2496 - */ 2497 1291 function updateResultsUI() { 2498 1292 const resultsContainer = document.getElementById('results'); 2499 1293 resultsContainer.innerHTML = ''; 2500 1294 2501 - // Don't show if in output selection mode (that has its own UI) 2502 - if (state.outputSelectionMode) { 2503 - return; 2504 - } 1295 + const isOutputSelection = machine.getState() === States.OUTPUT_SELECTION; 1296 + if (isOutputSelection) return; 2505 1297 2506 - // Param mode: show parameter suggestions instead of command matches 2507 - if (state.paramMode && state.paramSuggestions.length > 0) { 1298 + // Param mode: show parameter suggestions 1299 + const isParamState = machine.getState() === States.PARAM_MODE; 1300 + if (isParamState && state.paramSuggestions.length > 0) { 2508 1301 resultsContainer.classList.add('visible'); 2509 1302 2510 1303 state.paramSuggestions.forEach((suggestion, index) => { 2511 1304 const item = document.createElement('div'); 2512 1305 item.className = 'command-item'; 2513 - if (index === state.paramIndex) { 2514 - item.classList.add('selected'); 2515 - } 1306 + if (index === state.paramIndex) item.classList.add('selected'); 2516 1307 2517 1308 const nameSpan = document.createElement('span'); 2518 1309 nameSpan.className = 'cmd-name'; ··· 2527 1318 } 2528 1319 2529 1320 item.addEventListener('click', () => { 2530 - acceptParamSuggestion(index); 1321 + machine.dispatch(Events.CLICK_PARAM, { index }); 2531 1322 }); 2532 1323 2533 1324 resultsContainer.appendChild(item); ··· 2537 1328 return; 2538 1329 } 2539 1330 2540 - // In param mode with no suggestions, hide results (no "no results" noise) 2541 - if (state.paramMode) { 1331 + if (isParamState) { 2542 1332 resultsContainer.classList.remove('visible'); 2543 1333 updateWindowSize(); 2544 1334 return; 2545 1335 } 2546 1336 2547 - // Hide results if no matches or not in showResults mode 2548 - // Exception: always show in chain mode since user needs to see available commands 2549 - if (state.matches.length === 0 || (!state.showResults && !state.chainMode)) { 1337 + const isChainState = machine.getState() === States.CHAIN_MODE; 1338 + if (state.matches.length === 0 || (!state.showResults && !isChainState)) { 2550 1339 resultsContainer.classList.remove('visible'); 2551 1340 updateWindowSize(); 2552 1341 return; 2553 1342 } 2554 1343 2555 - // Show results container 2556 1344 resultsContainer.classList.add('visible'); 2557 1345 2558 - // Create and append result items 2559 1346 state.matches.forEach((match, index) => { 2560 1347 const item = document.createElement('div'); 2561 1348 item.className = 'command-item'; 2562 - if (index === state.matchIndex) { 2563 - item.classList.add('selected'); 2564 - } 1349 + if (index === state.matchIndex) item.classList.add('selected'); 2565 1350 2566 - // Get command metadata for description and badges 2567 1351 const cmd = state.commands[match]; 2568 1352 2569 - // Build item content with name, description, and badges 2570 1353 const nameSpan = document.createElement('span'); 2571 1354 nameSpan.className = 'cmd-name'; 2572 1355 nameSpan.textContent = match; 2573 1356 item.appendChild(nameSpan); 2574 1357 2575 - // Add description if available 2576 1358 if (cmd && cmd.description) { 2577 1359 const descSpan = document.createElement('span'); 2578 1360 descSpan.className = 'cmd-desc'; ··· 2580 1362 item.appendChild(descSpan); 2581 1363 } 2582 1364 2583 - // Add badges for scope, modes, and chaining capabilities 2584 1365 if (cmd) { 2585 1366 const badgesSpan = document.createElement('span'); 2586 1367 badgesSpan.className = 'cmd-badges'; 2587 1368 2588 - // Show scope badge (window/page commands are more specific) 2589 1369 if (cmd.scope && cmd.scope !== 'global') { 2590 1370 const scopeBadge = document.createElement('span'); 2591 1371 scopeBadge.className = 'cmd-badge cmd-badge-scope'; 2592 - scopeBadge.textContent = cmd.scope === 'window' ? '⊞' : '⧉'; // window or page icon 1372 + scopeBadge.textContent = cmd.scope === 'window' ? '\u229E' : '\u29C9'; 2593 1373 scopeBadge.title = cmd.scope === 'window' ? 'Window command' : 'Page command'; 2594 1374 badgesSpan.appendChild(scopeBadge); 2595 1375 } 2596 1376 2597 - // Show what the command accepts (in chain mode) 2598 - if (state.chainMode && cmd.accepts && cmd.accepts.length > 0) { 1377 + if (isChainState && cmd.accepts && cmd.accepts.length > 0) { 2599 1378 const acceptBadge = document.createElement('span'); 2600 1379 acceptBadge.className = 'cmd-badge'; 2601 - acceptBadge.textContent = '← ' + cmd.accepts[0]; 1380 + acceptBadge.textContent = '\u2190 ' + cmd.accepts[0]; 2602 1381 badgesSpan.appendChild(acceptBadge); 2603 1382 } 2604 1383 2605 - // Show what the command produces 2606 1384 if (cmd.produces && cmd.produces.length > 0) { 2607 1385 const produceBadge = document.createElement('span'); 2608 1386 produceBadge.className = 'cmd-badge'; 2609 - produceBadge.textContent = '→ ' + cmd.produces[0]; 1387 + produceBadge.textContent = '\u2192 ' + cmd.produces[0]; 2610 1388 badgesSpan.appendChild(produceBadge); 2611 1389 } 2612 1390 ··· 2615 1393 } 2616 1394 } 2617 1395 2618 - // Add click handler 1396 + // Route clicks through state machine dispatch (bug fix #3) 2619 1397 item.addEventListener('click', () => { 2620 - state.matchIndex = index; 2621 - execute(match, state.typed); 2622 - state.lastExecuted = match; 2623 - updateMatchCount(match); 2624 - updateAdaptiveFeedback((state.originalTyped || state.typed).split(' ')[0], match); 2625 - document.getElementById('command-input').value = ''; 2626 - state.typed = ''; 2627 - updateCommandUI(); 2628 - updateResultsUI(); 1398 + machine.dispatch(Events.CLICK_RESULT, { index, commandName: match }); 2629 1399 }); 2630 1400 2631 1401 resultsContainer.appendChild(item); 2632 1402 }); 2633 1403 2634 - // Update window size based on visibility 2635 1404 updateWindowSize(); 2636 1405 } 2637 1406 2638 - /** 2639 - * Checks if an event has modifier keys 2640 - */ 2641 - function hasModifier(e) { 2642 - return e.altKey || e.ctrlKey || e.metaKey; 1407 + // ===== Shutdown ===== 1408 + 1409 + async function shutdown() { 1410 + window.close(); 2643 1411 } 2644 1412 2645 - /** 2646 - * Checks if a key is a modifier key 2647 - */ 2648 - function isModifier(e) { 2649 - return ['Alt', 'Control', 'Shift', 'Meta'].indexOf(e.key) !== -1; 1413 + // ===== Action Handlers ===== 1414 + // These are called by the state machine's action runner. 1415 + // They receive (payload, data) and perform DOM/IPC side effects. 1416 + 1417 + function actionHandlers() { 1418 + const commandInput = () => document.getElementById('command-input'); 1419 + 1420 + return { 1421 + setTyped(payload) { 1422 + state.typed = payload.value; 1423 + state.originalTyped = payload.value; 1424 + }, 1425 + 1426 + clearInput() { 1427 + const input = commandInput(); 1428 + if (input) { 1429 + input.value = ''; 1430 + } 1431 + state.typed = ''; 1432 + state.originalTyped = ''; 1433 + state.matches = []; 1434 + state.matchIndex = 0; 1435 + }, 1436 + 1437 + clearMatches() { 1438 + state.matches = []; 1439 + state.matchIndex = 0; 1440 + }, 1441 + 1442 + computeMatches(payload) { 1443 + const typed = payload?.value || state.typed; 1444 + if (typed) { 1445 + const isChainState = machine.getState() === States.CHAIN_MODE; 1446 + if (isChainState && state.chainContext) { 1447 + const chainingCommands = findChainingCommands(state.chainContext.mimeType); 1448 + const chainingNames = new Set(chainingCommands.map(cmd => cmd.name)); 1449 + const allMatches = findMatchingCommands(typed); 1450 + state.matches = allMatches.filter(name => chainingNames.has(name)); 1451 + } else { 1452 + state.matches = findMatchingCommands(typed); 1453 + } 1454 + state.matchIndex = 0; 1455 + } else { 1456 + state.matches = []; 1457 + state.matchIndex = 0; 1458 + } 1459 + }, 1460 + 1461 + computeChainMatches() { 1462 + if (state.typed && state.chainContext) { 1463 + const chainingCommands = findChainingCommands(state.chainContext.mimeType); 1464 + const chainingNames = new Set(chainingCommands.map(cmd => cmd.name)); 1465 + const allMatches = findMatchingCommands(state.typed); 1466 + state.matches = allMatches.filter(name => chainingNames.has(name)); 1467 + } else if (state.chainContext) { 1468 + const chainingCommands = findChainingCommands(state.chainContext.mimeType); 1469 + state.matches = chainingCommands.map(cmd => cmd.name); 1470 + } else { 1471 + state.matches = []; 1472 + } 1473 + state.matchIndex = 0; 1474 + }, 1475 + 1476 + clearTypedKeepChain() { 1477 + state.typed = ''; 1478 + const input = commandInput(); 1479 + if (input) input.value = ''; 1480 + }, 1481 + 1482 + checkParamEntry(payload) { 1483 + // Check if typed text auto-enters param mode 1484 + const typed = payload?.value || state.typed; 1485 + if (!typed) return; 1486 + const typedLower = typed.toLowerCase(); 1487 + let paramCandidate = null; 1488 + for (const matchName of state.matches) { 1489 + const matchLower = matchName.toLowerCase(); 1490 + if (typedLower.startsWith(matchLower + ' ')) { 1491 + if (!paramCandidate || matchName.length > paramCandidate.length) { 1492 + paramCandidate = matchName; 1493 + } 1494 + } 1495 + } 1496 + if (paramCandidate) { 1497 + const cmd = state.commands[paramCandidate]; 1498 + if (cmd && cmd.params && cmd.params.length > 0) { 1499 + // Signal to the transition that param mode should be entered 1500 + // This is handled by the INPUT transition's guard 1501 + payload.enterParamMode = true; 1502 + payload.paramCommandName = paramCandidate; 1503 + } 1504 + } 1505 + }, 1506 + 1507 + showResults() { 1508 + state.showResults = true; 1509 + }, 1510 + 1511 + hideResults() { 1512 + state.showResults = false; 1513 + }, 1514 + 1515 + renderResults() { 1516 + updateResultsUI(); 1517 + }, 1518 + 1519 + updateGhostText() { 1520 + updateCommandUI(); 1521 + }, 1522 + 1523 + updateResults() { 1524 + updateResultsUI(); 1525 + }, 1526 + 1527 + navigateDown() { 1528 + state.matchIndex++; 1529 + }, 1530 + 1531 + navigateUp() { 1532 + state.matchIndex--; 1533 + }, 1534 + 1535 + navigateParamDown() { 1536 + state.paramIndex++; 1537 + }, 1538 + 1539 + navigateParamUp() { 1540 + state.paramIndex--; 1541 + }, 1542 + 1543 + navigateOutputDown() { 1544 + state.outputItemIndex++; 1545 + }, 1546 + 1547 + navigateOutputUp() { 1548 + state.outputItemIndex--; 1549 + }, 1550 + 1551 + updateOutputUI() { 1552 + updateOutputSelectionUI(); 1553 + }, 1554 + 1555 + tabCompleteCommand() { 1556 + const input = commandInput(); 1557 + const currentMatch = state.matches[state.matchIndex]; 1558 + const currentValue = (input ? input.value : state.typed).trim(); 1559 + 1560 + const alreadyCompleted = currentValue === currentMatch || 1561 + currentValue.startsWith(currentMatch + ' '); 1562 + 1563 + if (alreadyCompleted && state.matches.length > 1) { 1564 + state.matchIndex = (state.matchIndex + 1) % state.matches.length; 1565 + } 1566 + 1567 + const match = state.matches[state.matchIndex]; 1568 + state.typed = match + ' '; 1569 + if (input) { 1570 + input.value = state.typed; 1571 + setTimeout(() => input.setSelectionRange(input.value.length, input.value.length), 0); 1572 + } 1573 + }, 1574 + 1575 + shiftTabCycleCommand() { 1576 + const input = commandInput(); 1577 + const currentMatch = state.matches[state.matchIndex]; 1578 + const currentValue = (input ? input.value : state.typed).trim(); 1579 + 1580 + const alreadyCompleted = currentValue === currentMatch || 1581 + currentValue.startsWith(currentMatch + ' '); 1582 + 1583 + if (alreadyCompleted && state.matches.length > 1) { 1584 + state.matchIndex = (state.matchIndex - 1 + state.matches.length) % state.matches.length; 1585 + } 1586 + 1587 + const match = state.matches[state.matchIndex]; 1588 + state.typed = match + ' '; 1589 + if (input) { 1590 + input.value = state.typed; 1591 + setTimeout(() => input.setSelectionRange(input.value.length, input.value.length), 0); 1592 + } 1593 + }, 1594 + 1595 + tabCycleInResults() { 1596 + const input = commandInput(); 1597 + const currentMatch = state.matches[state.matchIndex]; 1598 + const currentValue = (input ? input.value : state.typed).trim(); 1599 + 1600 + const alreadyCompleted = currentValue === currentMatch || 1601 + currentValue.startsWith(currentMatch + ' '); 1602 + 1603 + if (alreadyCompleted && state.matches.length > 1) { 1604 + state.matchIndex = (state.matchIndex + 1) % state.matches.length; 1605 + } 1606 + 1607 + const match = state.matches[state.matchIndex]; 1608 + state.typed = match + ' '; 1609 + if (input) { 1610 + input.value = state.typed; 1611 + setTimeout(() => input.setSelectionRange(input.value.length, input.value.length), 0); 1612 + } 1613 + }, 1614 + 1615 + enterParamMode(payload) { 1616 + const cmdName = payload?.paramCommandName || state.matches[state.matchIndex]; 1617 + const cmd = cmdName && state.commands[cmdName]; 1618 + if (!cmd) return; 1619 + 1620 + if (state.paramCommand !== cmdName) { 1621 + log('cmd:panel', 'Entering param mode for:', cmdName); 1622 + state.paramCommand = cmdName; 1623 + state.paramSuggestions = []; 1624 + state.paramIndex = -1; 1625 + } 1626 + 1627 + doUpdateParamSuggestions(); 1628 + }, 1629 + 1630 + exitParamMode() { 1631 + if (state.paramCommand) { 1632 + log('cmd:panel', 'Exiting param mode'); 1633 + } 1634 + state.paramCommand = null; 1635 + state.paramSuggestions = []; 1636 + state.paramIndex = -1; 1637 + }, 1638 + 1639 + updateParamSuggestions() { 1640 + doUpdateParamSuggestions(); 1641 + }, 1642 + 1643 + fillParamText() { 1644 + const idx = state.paramIndex >= 0 ? state.paramIndex : 0; 1645 + if (idx < 0 || idx >= state.paramSuggestions.length) return; 1646 + 1647 + const suggestion = state.paramSuggestions[idx]; 1648 + const input = commandInput(); 1649 + const commandName = state.paramCommand; 1650 + 1651 + const cmd = state.commands[state.paramCommand]; 1652 + const paramDef = cmd && cmd.params && cmd.params[0]; 1653 + const fillText = (paramDef && paramDef.type === 'item' && suggestion.title) 1654 + ? suggestion.title 1655 + : suggestion.value; 1656 + 1657 + const typed = state.typed || ''; 1658 + const lowerTyped = typed.toLowerCase(); 1659 + const lowerName = commandName.toLowerCase(); 1660 + let beforeParams = commandName + ' '; 1661 + 1662 + let rest = ''; 1663 + if (lowerTyped.startsWith(lowerName + ' ')) { 1664 + rest = typed.slice(commandName.length + 1); 1665 + } 1666 + 1667 + const tokens = rest.split(/\s+/).filter(t => t.length > 0); 1668 + const endsWithSpace = rest.endsWith(' ') || rest === ''; 1669 + 1670 + if (!endsWithSpace && tokens.length > 0) { 1671 + tokens[tokens.length - 1] = fillText; 1672 + } else { 1673 + tokens.push(fillText); 1674 + } 1675 + 1676 + const newValue = beforeParams + tokens.join(' ') + ' '; 1677 + if (input) { 1678 + input.value = newValue; 1679 + setTimeout(() => input.setSelectionRange(newValue.length, newValue.length), 0); 1680 + } 1681 + state.typed = newValue; 1682 + state.paramIndex = idx; 1683 + 1684 + doUpdateParamSuggestions(); 1685 + }, 1686 + 1687 + acceptParamItem(payload) { 1688 + const idx = payload?.index !== undefined ? payload.index : (state.paramIndex >= 0 ? state.paramIndex : 0); 1689 + if (idx < 0 || idx >= state.paramSuggestions.length) return; 1690 + 1691 + const suggestion = state.paramSuggestions[idx]; 1692 + const cmd = state.commands[state.paramCommand]; 1693 + const paramDef = cmd && cmd.params && cmd.params[0]; 1694 + 1695 + if (paramDef && paramDef.type === 'item' && suggestion._item) { 1696 + const commandName = state.paramCommand; 1697 + const typed = commandName + ' ' + (suggestion.title || suggestion.value || ''); 1698 + const selectedItem = suggestion._item; 1699 + 1700 + // Record frecency 1701 + const commandPart = (state.originalTyped || typed).split(' ')[0]; 1702 + updateMatchCount(commandName); 1703 + updateAdaptiveFeedback(commandPart, commandName); 1704 + state.lastExecuted = commandName; 1705 + 1706 + doExecute(commandName, typed, { selectedItem }); 1707 + } else { 1708 + // Fallback: execute with typed text 1709 + const commandName = state.paramCommand; 1710 + const typed = state.typed; 1711 + const commandPart = (state.originalTyped || typed).split(' ')[0]; 1712 + updateMatchCount(commandName); 1713 + updateAdaptiveFeedback(commandPart, commandName); 1714 + state.lastExecuted = commandName; 1715 + 1716 + doExecute(commandName, typed); 1717 + } 1718 + 1719 + const input = commandInput(); 1720 + if (input) { input.value = ''; } 1721 + state.typed = ''; 1722 + }, 1723 + 1724 + setParamIndexFromClick(payload) { 1725 + if (payload?.index !== undefined) { 1726 + state.paramIndex = payload.index; 1727 + } 1728 + }, 1729 + 1730 + recordFrecency(payload) { 1731 + const name = payload?.commandName || state.matches[state.matchIndex]; 1732 + if (!name) return; 1733 + const typed = state.originalTyped || state.typed || ''; 1734 + const commandPart = typed.split(' ')[0]; 1735 + state.lastExecuted = name; 1736 + updateMatchCount(name); 1737 + updateAdaptiveFeedback(commandPart, name); 1738 + }, 1739 + 1740 + recordFrecencyFromResults() { 1741 + const name = state.matches[state.matchIndex]; 1742 + if (!name) return; 1743 + const typed = state.originalTyped || state.typed || ''; 1744 + const commandPart = typed.split(' ')[0]; 1745 + state.lastExecuted = name; 1746 + updateMatchCount(name); 1747 + updateAdaptiveFeedback(commandPart, name); 1748 + }, 1749 + 1750 + setMatchIndexFromClick(payload) { 1751 + if (payload?.index !== undefined) { 1752 + state.matchIndex = payload.index; 1753 + } 1754 + }, 1755 + 1756 + executeCommand(payload) { 1757 + const name = payload?.commandName || state.matches[state.matchIndex]; 1758 + const typed = payload?.typed || state.typed; 1759 + if (!name) return; 1760 + doExecute(name, typed); 1761 + }, 1762 + 1763 + executeCommandWithParams() { 1764 + const name = state.paramCommand || state.matches[state.matchIndex]; 1765 + const typed = state.typed; 1766 + if (!name) return; 1767 + doExecute(name, typed); 1768 + const input = commandInput(); 1769 + if (input) { input.value = ''; } 1770 + state.typed = ''; 1771 + }, 1772 + 1773 + executeChainCommand() { 1774 + const name = state.matches[state.matchIndex]; 1775 + const typed = state.typed; 1776 + if (!name) return; 1777 + doExecute(name, typed); 1778 + }, 1779 + 1780 + openURL(payload) { 1781 + const trimmedText = (payload?.value || state.typed || '').trim(); 1782 + const urlResult = getValidURL(trimmedText); 1783 + if (!urlResult.valid) return; 1784 + 1785 + log('cmd:panel', 'Opening URL:', urlResult.url); 1786 + 1787 + const matchingUrlCommand = findMatchingUrlCommand(urlResult.url); 1788 + const frecencyTarget = matchingUrlCommand || urlResult.url; 1789 + state.lastExecuted = frecencyTarget; 1790 + updateMatchCount(frecencyTarget); 1791 + updateAdaptiveFeedback(trimmedText, frecencyTarget); 1792 + 1793 + api.window.open(urlResult.url, { 1794 + role: 'content', 1795 + width: 800, 1796 + height: 600, 1797 + trackingSource: 'cmd', 1798 + trackingSourceId: 'url-direct' 1799 + }).catch(error => { 1800 + log.error('cmd:panel', 'Failed to open URL:', error); 1801 + }); 1802 + }, 1803 + 1804 + openSearch(payload) { 1805 + const trimmedText = (payload?.value || state.typed || '').trim(); 1806 + if (!trimmedText) return; 1807 + 1808 + log('cmd:panel', 'Opening search for:', trimmedText); 1809 + api.window.open(`peek://ext/search/home.html?q=${encodeURIComponent(trimmedText)}`, { 1810 + role: 'workspace', 1811 + key: 'search-home', 1812 + width: 700, 1813 + height: 600, 1814 + trackingSource: 'cmd', 1815 + trackingSourceId: 'search' 1816 + }).catch(error => { 1817 + log.error('cmd:panel', 'Failed to open search:', error); 1818 + }); 1819 + }, 1820 + 1821 + shutdown() { 1822 + setTimeout(shutdown, 100); 1823 + }, 1824 + 1825 + shutdownDelayed() { 1826 + setTimeout(shutdown, 100); 1827 + }, 1828 + 1829 + showSpinner(payload) { 1830 + showExecutionState(payload?.name || state.executingCommand); 1831 + }, 1832 + 1833 + hideSpinner() { 1834 + hideExecutionState(); 1835 + }, 1836 + 1837 + showError(payload) { 1838 + const name = payload?.name || state.executingCommand || 'command'; 1839 + const msg = payload?.error || 'Unknown error'; 1840 + showExecutionError(name, msg); 1841 + }, 1842 + 1843 + hideError() { 1844 + hideExecutionState(); 1845 + }, 1846 + 1847 + cancelExecution() { 1848 + log('cmd:panel', 'Cancelling execution'); 1849 + hideExecutionState(); 1850 + }, 1851 + 1852 + enterOutputSelection(payload) { 1853 + const result = payload?.result; 1854 + if (!result || !result.output) return; 1855 + doEnterOutputSelectionMode(result.output.data, result.output.mimeType, payload.name); 1856 + const input = commandInput(); 1857 + if (input) { input.value = ''; input.focus(); } 1858 + }, 1859 + 1860 + exitOutputSelection() { 1861 + doExitOutputSelectionMode(); 1862 + }, 1863 + 1864 + selectOutputItem() { 1865 + doSelectOutputItem(); 1866 + }, 1867 + 1868 + setOutputIndexFromClick(payload) { 1869 + if (payload?.index !== undefined) { 1870 + state.outputItemIndex = payload.index; 1871 + } 1872 + }, 1873 + 1874 + enterChainMode(payload) { 1875 + const result = payload?.result; 1876 + if (!result || !result.output) return; 1877 + machine.setState(States.CHAIN_MODE); 1878 + doEnterChainMode(result.output, payload.name); 1879 + const input = commandInput(); 1880 + if (input) { input.value = ''; input.focus(); } 1881 + }, 1882 + 1883 + exitChainMode() { 1884 + doExitChainMode(); 1885 + }, 1886 + 1887 + exitChainModeIfActive() { 1888 + if (state.chainContext) { 1889 + doExitChainMode(); 1890 + } 1891 + }, 1892 + 1893 + chainUndo() { 1894 + doChainUndo(); 1895 + }, 1896 + 1897 + publishEditorOpen(payload) { 1898 + const result = payload?.result; 1899 + if (!result || !result.output) return; 1900 + const outputData = result.output.data; 1901 + const outputMimeType = result.output.mimeType; 1902 + 1903 + if (outputMimeType === 'new-item') { 1904 + api.publish('editor:add', { type: outputData.type || 'text' }, api.scopes.GLOBAL); 1905 + } else if (outputData.id) { 1906 + api.publish('editor:open', { itemId: outputData.id }, api.scopes.GLOBAL); 1907 + if (result.output.isNew) { 1908 + api.publish('editor:changed', { action: 'add', itemId: outputData.id }, api.scopes.GLOBAL); 1909 + } 1910 + } 1911 + }, 1912 + 1913 + updateChainFromPopup(payload) { 1914 + if (state.chainContext) { 1915 + state.chainContext.data = payload.data; 1916 + state.chainContext.mimeType = payload.mimeType || state.chainContext.mimeType; 1917 + if (state.chainStack.length > 0) { 1918 + state.chainStack[state.chainStack.length - 1].data = payload.data; 1919 + } 1920 + } 1921 + 1922 + state.typed = ''; 1923 + state.showResults = true; 1924 + 1925 + updateChainUI(); 1926 + updateChainStepsUI(); 1927 + }, 1928 + 1929 + closeChainPopup() { 1930 + if (chainPopupWindowId) { 1931 + api.window.close(chainPopupWindowId); 1932 + chainPopupWindowId = null; 1933 + } 1934 + state.chainPopupActive = false; 1935 + }, 1936 + 1937 + refocusPanel() { 1938 + api.window.getWindowId().then(id => { 1939 + if (id) api.window.focus(id); 1940 + }); 1941 + setTimeout(() => { 1942 + const input = commandInput(); 1943 + if (input) input.focus(); 1944 + }, 100); 1945 + }, 1946 + 1947 + cycleMode() { 1948 + cycleMode(); 1949 + }, 1950 + 1951 + resetAllState() { 1952 + machine.reset(); 1953 + 1954 + hideExecutionState(); 1955 + doExitOutputSelectionMode(); 1956 + doExitChainMode(); 1957 + 1958 + state.paramCommand = null; 1959 + state.paramSuggestions = []; 1960 + state.paramIndex = -1; 1961 + 1962 + invalidateCompleterCache(); 1963 + 1964 + state.chainPopupActive = false; 1965 + state.showResults = false; 1966 + 1967 + currentMode = 'default'; 1968 + currentModeMetadata = {}; 1969 + modeWasSet = false; 1970 + 1971 + loadCommandContext(); 1972 + adaptiveDataCache = null; 1973 + loadAdaptiveData().then(d => { 1974 + state.adaptiveFeedback = d.feedback; 1975 + state.matchCounts = d.counts; 1976 + log('cmd:panel', 'Reloaded adaptive data on panel show'); 1977 + }); 1978 + 1979 + const input = commandInput(); 1980 + if (input) { 1981 + input.value = ''; 1982 + input.focus(); 1983 + } 1984 + 1985 + updateCommandUI(); 1986 + updateResultsUI(); 1987 + }, 1988 + }; 2650 1989 } 2651 1990 2652 - /** 2653 - * Find the best matching URL command registered from history. 2654 - * When a user types a URL and presses Enter, we need to record frecency 2655 - * against the registered command name (from history.js) so it appears 2656 - * in future suggestions. Matches exact URL or the closest registered URL 2657 - * that starts with the typed URL. 2658 - * @param {string} url - The URL to find a matching command for 2659 - * @returns {string|null} - The registered command name, or null if no match 2660 - */ 2661 - function findMatchingUrlCommand(url) { 2662 - if (!url) return null; 2663 - const lowerUrl = url.toLowerCase(); 1991 + // ===== DOM Event Wiring ===== 2664 1992 2665 - // Try exact match first 2666 - if (state.commands[url]) return url; 1993 + async function render() { 1994 + const commandInput = document.getElementById('command-input'); 1995 + const commandText = document.getElementById('command-text'); 1996 + 1997 + commandInput.value = ''; 1998 + commandInput.focus(); 1999 + 2000 + // Input event -> dispatch to state machine 2001 + commandInput.addEventListener('input', () => { 2002 + const value = commandInput.value; 2003 + const trimmedText = value.trim(); 2004 + 2005 + // Pre-compute whether param mode should be entered 2006 + // (the action handler will check, but the guard needs the signal) 2007 + let enterParamMode = false; 2008 + let paramCommandName = null; 2009 + 2010 + if (trimmedText) { 2011 + // Compute matches first to check for param candidates 2012 + const matches = findMatchingCommands(value); 2013 + const typedLower = value.toLowerCase(); 2667 2014 2668 - // Try to find a registered command whose name starts with the typed URL 2669 - // (e.g., typed "http://localhost" matches command "http://localhost:3000") 2670 - // or whose URL the typed text is a prefix of 2671 - let bestMatch = null; 2672 - let bestLength = Infinity; 2015 + for (const matchName of matches) { 2016 + const matchLower = matchName.toLowerCase(); 2017 + if (typedLower.startsWith(matchLower + ' ')) { 2018 + if (!paramCommandName || matchName.length > paramCommandName.length) { 2019 + paramCommandName = matchName; 2020 + } 2021 + } 2022 + } 2673 2023 2674 - for (const name of Object.keys(state.commands)) { 2675 - const lowerName = name.toLowerCase(); 2676 - // Check if the command name is a URL that matches what was typed 2677 - if (lowerName.startsWith(lowerUrl) || lowerUrl.startsWith(lowerName)) { 2678 - // Prefer the shortest match (most specific) 2679 - if (name.length < bestLength) { 2680 - bestMatch = name; 2681 - bestLength = name.length; 2024 + if (paramCommandName) { 2025 + const cmd = state.commands[paramCommandName]; 2026 + if (cmd && cmd.params && cmd.params.length > 0) { 2027 + enterParamMode = true; 2028 + } 2682 2029 } 2683 2030 } 2684 - } 2685 2031 2686 - return bestMatch; 2687 - } 2032 + // For param mode, check if input still matches the param command 2033 + let paramStillMatches = false; 2034 + if (machine.getState() === States.PARAM_MODE && state.paramCommand) { 2035 + const lowerVal = value.toLowerCase(); 2036 + const lowerCmd = state.paramCommand.toLowerCase(); 2037 + paramStillMatches = lowerVal.startsWith(lowerCmd + ' ') || lowerVal.startsWith(lowerCmd); 2038 + } 2688 2039 2689 - /** 2690 - * Checks if a string is a valid URL (with or without protocol) 2691 - * @param {string} str - The string to check 2692 - * @returns {Object} - Object with valid flag and normalized URL 2693 - */ 2694 - function getValidURL(str) { 2695 - if (!str) return { valid: false }; 2040 + machine.dispatch(Events.INPUT, { 2041 + value, 2042 + enterParamMode, 2043 + paramCommandName, 2044 + paramStillMatches, 2045 + }); 2046 + }); 2696 2047 2697 - // Check if it starts with a valid protocol (including peek:// for internal pages) 2698 - const hasValidProtocol = /^(https?|ftp|file|peek):\/\//.test(str); 2048 + // Keydown event -> dispatch special keys through state machine 2049 + commandInput.addEventListener('keydown', (e) => { 2050 + if (e.key === 'Escape' && !hasModifier(e)) { 2051 + // Escape is handled by IZUI onEscape handler — don't double-handle 2052 + // But if the keydown reaches us (not intercepted by before-input-event), 2053 + // handle it here 2054 + e.preventDefault(); 2055 + machine.dispatch(Events.ESCAPE); 2056 + return; 2057 + } 2699 2058 2700 - if (!hasValidProtocol) { 2701 - // Check if it looks like a domain (e.g., "example.com" or "localhost") 2702 - const isDomainPattern = /^([a-zA-Z0-9]([a-zA-Z0-9\-]{0,61}[a-zA-Z0-9])?\.)+[a-zA-Z]{2,}(\/.*)?$/.test(str); 2703 - const isLocalhost = /^localhost(:\d+)?(\/.*)?$/.test(str); 2059 + if (e.key === 'Enter' && !hasModifier(e)) { 2060 + e.preventDefault(); 2061 + const trimmedText = commandInput.value.trim(); 2062 + const urlResult = getValidURL(trimmedText); 2063 + const name = state.matches[state.matchIndex]; 2064 + const nameLower = name ? name.toLowerCase() : ''; 2065 + const typedLower = trimmedText.toLowerCase(); 2066 + 2067 + const committed = name && ( 2068 + typedLower === nameLower || 2069 + typedLower.startsWith(nameLower + ' ') || 2070 + state.showResults || 2071 + machine.getState() === States.CHAIN_MODE 2072 + ); 2073 + 2074 + machine.dispatch(Events.ENTER, { 2075 + value: commandInput.value, 2076 + isURL: urlResult.valid, 2077 + url: urlResult.url, 2078 + committed, 2079 + commandName: name, 2080 + typed: commandInput.value, 2081 + }); 2082 + return; 2083 + } 2084 + 2085 + if (e.key === 'ArrowDown') { 2086 + e.preventDefault(); 2087 + machine.dispatch(Events.ARROW_DOWN); 2088 + return; 2089 + } 2704 2090 2705 - if (isDomainPattern || isLocalhost) { 2706 - const urlWithProtocol = 'https://' + str; 2707 - try { 2708 - new URL(urlWithProtocol); 2709 - return { valid: true, url: urlWithProtocol }; 2710 - } catch (e) { 2711 - return { valid: false }; 2091 + if (e.key === 'ArrowUp') { 2092 + e.preventDefault(); 2093 + machine.dispatch(Events.ARROW_UP); 2094 + return; 2095 + } 2096 + 2097 + if (e.key === 'ArrowRight') { 2098 + if (machine.getState() === States.OUTPUT_SELECTION) { 2099 + e.preventDefault(); 2100 + machine.dispatch(Events.ARROW_RIGHT); 2712 2101 } 2102 + return; 2713 2103 } 2714 - return { valid: false }; 2104 + 2105 + if (e.key === 'Tab') { 2106 + e.preventDefault(); 2107 + if (e.shiftKey) { 2108 + machine.dispatch(Events.SHIFT_TAB); 2109 + } else { 2110 + // For Tab, check if the selected command has params 2111 + const name = state.matches[state.matchIndex]; 2112 + const cmd = name && state.commands[name]; 2113 + const hasParams = cmd && cmd.params && cmd.params.length > 0; 2114 + machine.dispatch(Events.TAB, { commandName: name, hasParams }); 2115 + } 2116 + return; 2117 + } 2118 + }); 2119 + 2120 + // Keep focus on input (unless chain popup is active) 2121 + window.addEventListener('blur', () => { 2122 + if (!state.chainPopupActive) { 2123 + setTimeout(() => commandInput.focus(), 10); 2124 + } 2125 + }); 2126 + 2127 + window.addEventListener('focus', () => { 2128 + commandInput.focus(); 2129 + }); 2130 + 2131 + // Handle visibility changes — single handler for panel show/hide 2132 + document.addEventListener('visibilitychange', () => { 2133 + if (!document.hidden) { 2134 + // Panel is now visible — dispatch visibility:shown 2135 + // If we're in CLOSING state, this resets everything 2136 + if (machine.getState() === States.CLOSING) { 2137 + machine.dispatch(Events.VISIBILITY_SHOWN); 2138 + } else { 2139 + // Force reset on re-show regardless (keepLive reuse) 2140 + machine.setState(States.CLOSING); 2141 + machine.dispatch(Events.VISIBILITY_SHOWN); 2142 + } 2143 + } 2144 + }); 2145 + 2146 + // Initial focus 2147 + setTimeout(() => commandInput.focus(), 50); 2148 + 2149 + // Load command context on initial render 2150 + await loadCommandContext(); 2151 + 2152 + // Initialize mode indicator 2153 + initModeIndicator(); 2154 + 2155 + // Chain cancel button handler — dispatch through state machine 2156 + const chainCancelBtn = document.getElementById('chain-cancel'); 2157 + if (chainCancelBtn) { 2158 + chainCancelBtn.addEventListener('click', () => { 2159 + machine.dispatch(Events.CLICK_CHAIN_CANCEL); 2160 + }); 2715 2161 } 2716 2162 2717 - try { 2718 - new URL(str); 2719 - return { valid: true, url: str }; 2720 - } catch (e) { 2721 - return { valid: false }; 2163 + // Execution cancel button handler — dispatch through state machine 2164 + const execCancelBtn = document.querySelector('#execution-state .exec-cancel'); 2165 + if (execCancelBtn) { 2166 + execCancelBtn.addEventListener('click', () => { 2167 + machine.dispatch(Events.CLICK_CANCEL); 2168 + }); 2722 2169 } 2723 2170 } 2171 + 2172 + render(); 2173 + 2174 + // Register IZUI escape handler — single escape through dispatch (bug fix #1) 2175 + api.escape.onEscape(() => { 2176 + return machine.handleEscape(); 2177 + });