fix(palette): offer a Docker case's container agents in Cmd+K, as the Run menu does

The new-session row's agents now go through _runModeAvailableForCase (session-ui.js), the rule extracted from _refreshRunModeAvailability, for the case the row launches in: a Docker case offers what its container has (live probe, else availableModes, else ungated), so a container-only Run mode is offered and is the row's default again.
The default is resolved per render (pick, else the Run button's mode, else the first offered), so a query that moves the row to another case starts on the Run mode when that case offers it.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Codeman maintainer committed 2026-10-24 15:10:16 +02:00
1 parent fac47f067d
commit f6e21a7c50
4 files changed
+244 -61

No files matched your search

+52 -27
View File
@@ -556,10 +556,10 @@ Object.assign(CodemanApp.prototype, {
},
_buildCommandPaletteNewSessionItem(query = '') {
const agent = this._commandPaletteAgent();
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',
@@ -570,48 +570,73 @@ Object.assign(CodemanApp.prototype, {
};
},
/** 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: 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
* 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() {
_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 && (this.isCliAvailable?.(cli.id) ?? true))
.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, or null when fewer than two are
* offered. With one entry there is no picker to change it, so the row keeps
* the Run button's mode: for a Docker case the Run menu offers the
* container's CLIs while this list follows the host's, and the only host
* entry (often Shell) must not replace the agent the Run button shows.
* 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() {
const choices = this._commandPaletteAgentChoices();
_commandPaletteAgent(caseName = this._commandPaletteRowCase()) {
const choices = this._commandPaletteAgentChoices(caseName);
if (choices.length < 2) return null;
return choices.find((c) => c.id === this._commandPaletteAgentId) || choices[0];
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, the row starts on the Run button's mode (or,
* with two or more offered and that mode not among them, the first offered agent).
* 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() {
const choices = this._commandPaletteAgentChoices();
const current = this.runMode || this._runMode;
this._commandPaletteAgentId = (choices.find((c) => c.id === current) || choices[0])?.id || null;
this._commandPaletteAgentId = null;
},
_cycleCommandPaletteAgent(delta) {
const choices = this._commandPaletteAgentChoices();
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()?.id));
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();
},
@@ -625,7 +650,7 @@ Object.assign(CodemanApp.prototype, {
_commandPaletteArrowCyclesAgent(e) {
const active = (this.commandPaletteItems || [])[this.commandPaletteActiveIndex || 0];
if (active?.type !== 'new-session') return false;
if (this._commandPaletteAgentChoices().length < 2) 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 : '';
@@ -688,7 +713,7 @@ 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) : '';
const agent = item.type === 'new-session' ? this._renderCommandPaletteAgentPicker(item.mode, item.caseName) : '';
return `
<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>
@@ -709,8 +734,8 @@ Object.assign(CodemanApp.prototype, {
* and its arrows work by click through data-palette-agent-cycle. Nothing is
* drawn when there is nothing to choose between.
*/
_renderCommandPaletteAgentPicker(mode) {
const choices = this._commandPaletteAgentChoices();
_renderCommandPaletteAgentPicker(mode, caseName) {
const choices = this._commandPaletteAgentChoices(caseName);
if (choices.length < 2) return '';
const agent = choices.find((c) => c.id === mode) || choices[0];
return `
+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');