diff --git a/src/web/public/panels-ui.js b/src/web/public/panels-ui.js index dedaea57..9561d4a3 100644 --- a/src/web/public/panels-ui.js +++ b/src/web/public/panels-ui.js @@ -424,6 +424,7 @@ Object.assign(CodemanApp.prototype, { if (!modal || !search) return; this.commandPaletteActiveIndex = 0; + this._resetCommandPaletteAgent(); search.value = ''; modal.classList.add('active'); @@ -465,6 +466,11 @@ Object.assign(CodemanApp.prototype, { }); search?.addEventListener('keydown', async (e) => { + if ((e.key === 'ArrowLeft' || e.key === 'ArrowRight') && this._commandPaletteArrowCyclesAgent(e)) { + e.preventDefault(); + this._cycleCommandPaletteAgent(e.key === 'ArrowRight' ? 1 : -1); + return; + } if (e.key === 'ArrowDown') { e.preventDefault(); this.moveCommandPaletteSelection(1); @@ -494,7 +500,22 @@ Object.assign(CodemanApp.prototype, { } }); + // Clicking an agent arrow must leave focus in the search box, or the + // Left/Right/Enter keys stop reaching the palette after the first click. + list?.addEventListener?.('mousedown', (e) => { + if (e.target?.closest?.('[data-palette-agent-cycle]')) e.preventDefault?.(); + }); + list?.addEventListener?.('click', (e) => { + // The agent arrows sit inside the new-session row: they change the agent + // and must not also activate (launch) the row they are drawn in. + const cycle = e.target?.closest?.('[data-palette-agent-cycle]'); + if (cycle) { + e.preventDefault?.(); + e.stopPropagation?.(); + this._cycleCommandPaletteAgent(cycle.dataset.paletteAgentCycle === 'prev' ? -1 : 1); + return; + } const row = e.target?.closest?.('[data-command-index]'); if (!row) return; this.commandPaletteActiveIndex = Number(row.dataset.commandIndex) || 0; @@ -535,18 +556,75 @@ Object.assign(CodemanApp.prototype, { }, _buildCommandPaletteNewSessionItem(query = '') { - const mode = this.runMode || this._runMode || 'claude'; + const agent = this._commandPaletteAgent(); + const mode = agent?.id || this.runMode || this._runMode || 'claude'; const labels = { claude: 'Claude', opencode: 'OpenCode', codex: 'Codex', gemini: 'Gemini', antigravity: 'Antigravity', pi: 'Pi', grok: 'Grok', deepseek: 'DeepSeek', omp: 'OMP', copilot: 'Copilot' }; const caseName = this._findCommandPaletteCaseMatch(query) || document.getElementById('quickStartCase')?.value || 'testcase'; return { id: 'new-session', type: 'new-session', caseName, + mode, title: 'New session', - subtitle: `Run ${labels[mode] || mode} in ${caseName}`, + subtitle: `Run ${agent?.label || labels[mode] || mode} in ${caseName}`, }; }, + /** + * The agents the new-session row can start: the server-injected CLI catalog's + * enabled entries that are installed here, in catalog order, the same list the + * welcome screen's Run buttons offer (renderWelcomeCliActions). The ids are + * DATA from the registry, never a list kept here. Empty on a page rendered + * without the catalog, which leaves the row on the Run button's mode. + */ + _commandPaletteAgentChoices() { + const catalog = typeof window !== 'undefined' && Array.isArray(window.__codemanCliCatalog) ? window.__codemanCliCatalog : []; + return catalog + .filter((cli) => cli && typeof cli.id === 'string' && cli.enabled && (this.isCliAvailable?.(cli.id) ?? true)) + .map((cli) => ({ id: cli.id, label: (typeof cli.label === 'string' && cli.label.trim()) || cli.id })); + }, + + /** The agent the new-session row will start, or null when the catalog offers none. */ + _commandPaletteAgent() { + const choices = this._commandPaletteAgentChoices(); + if (!choices.length) return null; + return choices.find((c) => c.id === this._commandPaletteAgentId) || choices[0]; + }, + + /** Each time the palette opens, the row starts on the Run button's mode (or the first offered agent). */ + _resetCommandPaletteAgent() { + const choices = this._commandPaletteAgentChoices(); + const current = this.runMode || this._runMode; + this._commandPaletteAgentId = (choices.find((c) => c.id === current) || choices[0])?.id || null; + }, + + _cycleCommandPaletteAgent(delta) { + const choices = this._commandPaletteAgentChoices(); + if (choices.length < 2) return; + const at = Math.max(0, choices.findIndex((c) => c.id === this._commandPaletteAgent()?.id)); + this._commandPaletteAgentId = choices[(((at + delta) % choices.length) + choices.length) % choices.length].id; + this.renderCommandPalette(); + }, + + /** + * Left/Right change the agent only while the new-session row is highlighted, + * and only when the key would not move the caret anyway (Left at the start of + * the query, Right at its end, no selection), so editing a typed case name + * keeps working. + */ + _commandPaletteArrowCyclesAgent(e) { + const active = (this.commandPaletteItems || [])[this.commandPaletteActiveIndex || 0]; + if (active?.type !== 'new-session') return false; + if (this._commandPaletteAgentChoices().length < 2) return false; + if (e.altKey || e.ctrlKey || e.metaKey || e.shiftKey) return false; + const input = e.target; + const value = typeof input?.value === 'string' ? input.value : ''; + const start = Number.isFinite(input?.selectionStart) ? input.selectionStart : value.length; + const end = Number.isFinite(input?.selectionEnd) ? input.selectionEnd : start; + if (start !== end) return false; + return e.key === 'ArrowRight' ? end >= value.length : start <= 0; + }, + _findCommandPaletteCaseMatch(query = '') { const needle = query.trim().toLowerCase(); if (!needle || !Array.isArray(this.cases)) return null; @@ -600,19 +678,38 @@ Object.assign(CodemanApp.prototype, { const active = index === this.commandPaletteActiveIndex ? ' active' : ''; const icon = item.type === 'new-session' ? '+' : item.type === 'browse-sessions' ? '≡' : '›'; const browse = item.type === 'browse-sessions' ? ' command-palette-item--browse' : ''; + const agent = item.type === 'new-session' ? this._renderCommandPaletteAgentPicker(item.mode) : ''; return ` - `; }) .join(''); }, + /** + * The new-session row's agent switch: arrows either side of the agent's logo + * and name. Spans, not buttons: the row itself is a