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.
This commit is contained in:
Aamer Akhter committed 2026-10-22 17:23:03 -04:00
1 parent 002de32a08
commit c6a5995ac6
4 files changed
+305 -5

No files matched your search

+111 -5
View File
@@ -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 `
<button class="command-palette-item${active}${browse}" type="button" data-command-index="${index}">
<button class="command-palette-item${active}${browse}${agent ? ' command-palette-item--agent' : ''}" type="button" data-command-index="${index}">
<span class="command-palette-icon" aria-hidden="true">${icon}</span>
<span class="command-palette-text">
<span class="command-palette-title">${escapeHtml(item.title)}</span>
<span class="command-palette-subtitle">${escapeHtml(item.subtitle || '')}</span>
</span>
</span>${agent}
</button>
`;
})
.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 <button>. Nothing is drawn
* when there is nothing to choose between.
*/
_renderCommandPaletteAgentPicker(mode) {
const choices = this._commandPaletteAgentChoices();
if (choices.length < 2) return '';
const agent = choices.find((c) => c.id === mode) || choices[0];
return `
<span class="command-palette-agent" title="Agent for the new session (Left/Right to change)">
<span class="command-palette-agent-arrow" role="button" data-palette-agent-cycle="prev" aria-label="Previous agent">&lsaquo;</span>
<span class="run-mode-dot ${escapeHtml(agent.id)}" aria-hidden="true"></span>
<span class="command-palette-agent-label">${escapeHtml(agent.label)}</span>
<span class="command-palette-agent-arrow" role="button" data-palette-agent-cycle="next" aria-label="Next agent">&rsaquo;</span>
</span>`;
},
moveCommandPaletteSelection(delta) {
const items = this.commandPaletteItems || this.buildCommandPaletteItems(document.getElementById('commandPaletteSearch')?.value || '');
if (!items.length) return;
@@ -655,7 +752,16 @@ Object.assign(CodemanApp.prototype, {
caseSelect.value = item.caseName;
}
}
await this.run();
// A one-shot launch of the picked agent: the same temporary _runMode swap
// the custom-model entries and worktree launches use, so run() keeps its
// launch lock and per-mode paths and the Run button's mode is untouched.
const previousRunMode = this._runMode;
if (item.mode) this._runMode = item.mode;
try {
await this.run();
} finally {
this._runMode = previousRunMode;
}
}
},
+31
View File
@@ -8970,6 +8970,37 @@ kbd {
font-size: 0.72rem;
}
/* New-session row: the agent it will start, switchable with Left/Right or the arrows */
.command-palette-item--agent {
grid-template-columns: 26px minmax(0, 1fr) auto;
}
.command-palette-agent {
display: inline-flex;
align-items: center;
gap: 0.35rem;
padding: 0.15rem 0.2rem;
font-size: 0.78rem;
color: var(--text);
background: rgba(255, 255, 255, 0.06);
border-radius: 6px;
white-space: nowrap;
}
.command-palette-agent-arrow {
display: inline-flex;
align-items: center;
justify-content: center;
width: 20px;
height: 20px;
color: var(--text-muted);
border-radius: 4px;
font-size: 1rem;
line-height: 1;
}
.command-palette-agent-arrow:hover {
color: var(--text);
background: rgba(255, 255, 255, 0.1);
}
/* COD-192: "Browse all sessions…" escape hatch — visually secondary */
.command-palette-item--browse {
margin-top: 4px;