Merge pull request #685: feat(palette): choose the agent when starting a session from Cmd+K

This commit is contained in:
Codeman maintainer committed 2026-10-24 15:23:11 +02:00
commit 395d8293a7
8 files changed
+569 -36

No files matched your search

+2
View File
@@ -94,6 +94,8 @@
'Move to Ungrouped': '移到未分组',
'Move to new group': '移到新分组',
'New session': '新建会话',
// The Cmd+K palette's new-session agent switch (panels-ui.js, its title tooltip).
'Agent for the new session (Left/Right to change)': '新会话使用的智能体(按左/右方向键切换)',
'A session is already starting.': '已有会话正在启动。',
'Follow parent again': '重新跟随父会话',
'Parent closed; placed on its own': '父会话已关闭;单独放置',
+149 -6
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,110 @@ Object.assign(CodemanApp.prototype, {
},
_buildCommandPaletteNewSessionItem(query = '') {
const mode = this.runMode || this._runMode || 'claude';
const caseName = this._commandPaletteCaseName(query);
const agent = this._commandPaletteAgent(caseName);
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 case a new session from the palette starts in: the case the query names, else the Run button's case. */
_commandPaletteCaseName(query = '') {
return this._findCommandPaletteCaseMatch(query) || document.getElementById('quickStartCase')?.value || 'testcase';
},
/** The case of the new-session row on screen (the palette's last render), for the agent switch. */
_commandPaletteRowCase() {
const row = (this.commandPaletteItems || []).find((item) => item?.type === 'new-session');
return row ? row.caseName : this._commandPaletteCaseName(document.getElementById('commandPaletteSearch')?.value || '');
},
/**
* The agents the new-session row can start in `caseName`: the server-injected
* CLI catalog's enabled entries that the toolbar Run menu offers for that case,
* in catalog order. An agent entry goes through the Run menu's own rule,
* _runModeAvailableForCase (session-ui.js): the host's CLIs for a local or
* remote case, the container's for a Docker case. A non-agent entry (Shell) is
* never gated, as in the Run menu. The vm test harness may load this file
* without session-ui.js, and then the host's isCliAvailable() decides. The ids
* are DATA from the registry, never a list kept here. Empty on a page rendered
* without the catalog; that and a single entry both leave the row on the Run
* button's mode (see _commandPaletteAgent).
*/
_commandPaletteAgentChoices(caseName = this._commandPaletteRowCase()) {
const catalog = typeof window !== 'undefined' && Array.isArray(window.__codemanCliCatalog) ? window.__codemanCliCatalog : [];
const offered = (id) =>
typeof this._runModeAvailableForCase === 'function'
? this._runModeAvailableForCase(id, caseName)
: (this.isCliAvailable?.(id) ?? true);
return catalog
.filter((cli) => cli && typeof cli.id === 'string' && cli.enabled && (cli.kind !== 'agent' || offered(cli.id)))
.map((cli) => ({ id: cli.id, label: (typeof cli.label === 'string' && cli.label.trim()) || cli.id }));
},
/**
* The agent the new-session row will start in `caseName`: the one picked with
* the switch since the palette opened, else the Run button's mode, else the
* first offered. Null when fewer than two are offered: with one entry there is
* nothing to pick, so the row keeps the Run button's mode as it did before the
* switch existed, and a Run mode the case cannot start fails the way the Run
* button would, instead of the palette quietly starting the one entry offered
* (often Shell).
*/
_commandPaletteAgent(caseName = this._commandPaletteRowCase()) {
const choices = this._commandPaletteAgentChoices(caseName);
if (choices.length < 2) return null;
const runMode = this.runMode || this._runMode;
return (
choices.find((c) => c.id === this._commandPaletteAgentId) || choices.find((c) => c.id === runMode) || choices[0]
);
},
/**
* Each time the palette opens it forgets the last pick, so the row starts on
* the Run button's mode (see _commandPaletteAgent). That default is resolved
* per render, not here: the query can move the row to another case, which may
* offer the Run button's mode when the first case did not.
*/
_resetCommandPaletteAgent() {
this._commandPaletteAgentId = null;
},
_cycleCommandPaletteAgent(delta) {
const caseName = this._commandPaletteRowCase();
const choices = this._commandPaletteAgentChoices(caseName);
if (choices.length < 2) return;
const at = Math.max(0, choices.findIndex((c) => c.id === this._commandPaletteAgent(caseName)?.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(active.caseName).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 +713,40 @@ 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, item.caseName) : '';
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>. The switch is
* aria-hidden (no nested buttons; the row's subtitle already names the agent),
* and its arrows work by click through data-palette-agent-cycle. Nothing is
* drawn when there is nothing to choose between.
*/
_renderCommandPaletteAgentPicker(mode, caseName) {
const choices = this._commandPaletteAgentChoices(caseName);
if (choices.length < 2) return '';
const agent = choices.find((c) => c.id === mode) || choices[0];
return `
<span class="command-palette-agent" aria-hidden="true" title="Agent for the new session (Left/Right to change)">
<span class="command-palette-agent-arrow" data-palette-agent-cycle="prev">&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" data-palette-agent-cycle="next">&rsaquo;</span>
</span>`;
},
moveCommandPaletteSelection(delta) {
const items = this.commandPaletteItems || this.buildCommandPaletteItems(document.getElementById('commandPaletteSearch')?.value || '');
if (!items.length) return;
@@ -655,7 +789,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;
}
}
},
+41 -23
View File
@@ -782,38 +782,18 @@ Object.assign(CodemanApp.prototype, {
* run modes like the rest, and neither `agy` nor `pi` is likely to be installed.
*/
_refreshRunModeAvailability(menu) {
// A DOCKER case runs its agents INSIDE the container, so host CLI
// availability answers the wrong question: the host may have no claude at
// all while the container ships one, and gating on the host hides a mode
// that would have worked. Adoption records what the container really has
// (`availableModes`); an owned container runs our base image, which ships
// every CLI, so an absent list means "do not gate" rather than "nothing".
// Same source every run* path reads the selected case from.
const caseName = document.getElementById('quickStartCase')?.value;
const activeCase = caseName ? (this.cases || []).find((c) => c.name === caseName) : null;
const isDocker = activeCase?.location === 'docker';
// Prefer a LIVE probe over the value stored at attach time: a container's
// CLIs can be installed or removed long after the case was linked, and a
// case linked before that field existed has none at all.
const containerModes = isDocker
? this._dockerCaseModes?.[caseName] || activeCase.docker?.availableModes || null
: null;
if (isDocker && !this._dockerCaseModes?.[caseName]) void this._probeDockerCaseModes(activeCase, menu);
// An unreachable container hides every agent mode and explains why, instead
// of silently offering modes that cannot start.
//
// ⚠️ ADOPTED cases only. For an OWNED case a missing container is the normal
// state before the first session — the launch chain creates and starts it — so
// reporting it as a fault hid every agent mode on a freshly linked Docker case
// behind "start it yourself first", for a container Codeman was about to create.
// An unreachable (adopted) container hides every agent mode and explains why,
// instead of silently offering modes that cannot start; see _runModeAvailableForCase.
const probeError = isDocker ? this._dockerCaseProbeError?.[caseName] : null;
for (const option of menu.querySelectorAll('.run-mode-option[data-mode]')) {
const mode = option.dataset.mode;
if (!mode || mode === 'shell') continue;
let available;
if (isDocker) available = probeError ? false : containerModes ? containerModes.includes(mode) : true;
else available = this.isCliAvailable(mode);
option.style.display = available ? 'flex' : 'none';
option.style.display = this._runModeAvailableForCase(mode, caseName) ? 'flex' : 'none';
}
this._renderRunModeNotice(menu, probeError);
// DeepSeek is the one mode whose availability has two halves: `dsh` can be
@@ -832,6 +812,44 @@ Object.assign(CodemanApp.prototype, {
if (dsWeb) dsWeb.style.display = avail.deepseekBinary ? 'flex' : 'none';
},
/**
* Does the case named `caseName` offer agent run mode `mode`? The ONE rule
* behind the Run menu's entries (_refreshRunModeAvailability) and the Cmd+K
* new-session row's agents (_commandPaletteAgentChoices, panels-ui.js), so the
* two never disagree on what a case can start. Shell needs no CLI and is
* never gated; both callers skip it before asking.
*
* Reads only what is already known and never starts a container probe: the
* Run menu starts one (_probeDockerCaseModes), and its answer lands in
* _dockerCaseModes, which this reads. Until then (a probe still in flight)
* the stored availableModes decide, else nothing is gated.
*/
_runModeAvailableForCase(mode, caseName) {
const activeCase = caseName ? (this.cases || []).find((c) => c.name === caseName) : null;
if (activeCase?.location === 'docker') {
// A DOCKER case runs its agents INSIDE the container, so host CLI
// availability answers the wrong question: the host may have no claude at
// all while the container ships one, and gating on the host hides a mode
// that would have worked. Adoption records what the container really has
// (`availableModes`); an owned container runs our base image, which ships
// every CLI, so an absent list means "do not gate" rather than "nothing".
//
// An unreachable container offers no agent mode. ⚠️ Only an ADOPTED case
// ever records that error: for an OWNED case a missing container is the
// normal state before the first session (the launch chain creates and
// starts it), so reporting it as a fault hid every agent mode on a freshly
// linked Docker case behind "start it yourself first", for a container
// Codeman was about to create.
if (this._dockerCaseProbeError?.[caseName]) return false;
// Prefer a LIVE probe over the value stored at attach time: a container's
// CLIs can be installed or removed long after the case was linked, and a
// case linked before that field existed has none at all.
const containerModes = this._dockerCaseModes?.[caseName] || activeCase.docker?.availableModes || null;
return containerModes ? containerModes.includes(mode) : true;
}
return this.isCliAvailable(mode);
},
/** Render every enabled agent entry from the server's registry projection. */
renderRegistryRunOptions() {
const container = document.getElementById('runModeCliOptions');
+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;