From c6a5995ac66db3e0a7346cf819b221564d657b0f Mon Sep 17 00:00:00 2001 From: Aamer Akhter Date: Thu, 22 Oct 2026 17:22:04 -0400 Subject: [PATCH 1/4] feat(palette): choose the agent when starting a session from Cmd+K The palette's "New session" row now shows the agent it will start and lets Left/Right (or the row's arrows) switch it. The agents come from the injected CLI catalog, filtered to enabled and installed entries, so no CLI id list lives in the palette. The pick is a one-shot launch through run() with a temporary _runMode swap: the Run button's mode is left unchanged. --- src/web/public/panels-ui.js | 116 +++++++++++++++- src/web/public/styles.css | 31 +++++ test/command-palette-ui.test.ts | 160 ++++++++++++++++++++++ test/frontend-cli-no-id-branching.test.ts | 3 + 4 files changed, 305 insertions(+), 5 deletions(-) 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