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

+330 -1
View File
@@ -3,7 +3,7 @@ import { resolve } from 'node:path';
import vm from 'node:vm';
import { describe, expect, it, vi } from 'vitest';
function loadPaletteHarness(overrides: Record<string, any> = {}) {
function loadPaletteHarness(overrides: Record<string, any> = {}, { sessionUi = false } = {}) {
const elements: Record<string, any> = {};
const listeners: Record<string, (event: any) => void> = {};
const CodemanApp = function CodemanApp(this: any) {};
@@ -70,6 +70,12 @@ function loadPaletteHarness(overrides: Record<string, any> = {}) {
const panelsUi = readFileSync(resolve(import.meta.dirname, '../src/web/public/panels-ui.js'), 'utf8');
vm.runInContext(panelsUi, context, { filename: 'panels-ui.js' });
// session-ui.js loads after panels-ui.js in index.html. Opt-in: it also brings
// the real selectQuickStartCase, which the case-pick tests above stub around.
if (sessionUi) {
const sessionUiSrc = readFileSync(resolve(import.meta.dirname, '../src/web/public/session-ui.js'), 'utf8');
vm.runInContext(sessionUiSrc, context, { filename: 'session-ui.js' });
}
const app = new (CodemanApp as any)();
app.sessions = new Map([
@@ -341,6 +347,329 @@ describe('Command-K session palette', () => {
});
});
const PALETTE_CATALOG = [
{ id: 'claude', label: 'Claude Code', kind: 'agent', enabled: true },
{ id: 'shell', label: 'Shell', kind: 'shell', enabled: true },
{ id: 'codex', label: 'Codex', kind: 'agent', enabled: true },
{ id: 'opencode', label: 'OpenCode', kind: 'agent', enabled: true },
{ id: 'gemini', label: 'Gemini', kind: 'agent', enabled: false },
];
/** A palette whose agent list comes from the injected CLI catalog, codex not installed. */
function loadAgentPalette(catalog: any[] = PALETTE_CATALOG, options: { sessionUi?: boolean } = {}) {
const available: Record<string, boolean> = { claude: true, shell: true, codex: false, opencode: true, gemini: true };
const harness = loadPaletteHarness(
{ window: { __codemanCliCatalog: catalog, __codemanCliAvailable: available } },
options
);
// Mirrors settings-ui.js isCliAvailable(): unknown reads as available.
harness.app.isCliAvailable = (id: string) => available[id] !== false;
harness.app._runMode = 'claude';
return harness;
}
function arrow(key: 'ArrowLeft' | 'ArrowRight', caret: number, value = '') {
return {
key,
preventDefault: vi.fn(),
stopPropagation: vi.fn(),
target: { value, selectionStart: caret, selectionEnd: caret },
};
}
describe('Command-K new-session agent picker', () => {
it('offers the enabled, installed CLIs from the registry catalog, in catalog order', () => {
const { app } = loadAgentPalette();
expect(app._commandPaletteAgentChoices().map((c: any) => c.id)).toEqual(['claude', 'shell', 'opencode']);
});
it('starts on the Run button mode and labels the row from the catalog', () => {
const { app, elements } = loadAgentPalette();
app._runMode = 'opencode';
app.openCommandPalette();
const item = app.buildCommandPaletteItems('flux').find((i: any) => i.type === 'new-session');
expect(item).toMatchObject({ mode: 'opencode', subtitle: 'Run OpenCode in flux-player' });
expect(elements.commandPaletteList.innerHTML).toContain('data-palette-agent-cycle="next"');
expect(elements.commandPaletteList.innerHTML).toContain('run-mode-dot opencode');
});
it('keeps the switch out of the row button accessible name and gives its tooltip a zh-CN entry', () => {
const { app, elements } = loadAgentPalette();
app.openCommandPalette();
const html = elements.commandPaletteList.innerHTML;
const title = 'Agent for the new session (Left/Right to change)';
// No nested buttons inside the row's <button>: the subtitle names the agent.
expect(html).toContain(`class="command-palette-agent" aria-hidden="true" title="${title}"`);
expect(html).not.toContain('role="button"');
expect(html).not.toContain('aria-label=');
const i18n = readFileSync(resolve(import.meta.dirname, '../src/web/public/i18n.js'), 'utf8');
expect(i18n.split(`'${title}':`).length - 1).toBe(1);
});
it('falls back to the first offered agent when the Run button mode is not offered', () => {
const { app } = loadAgentPalette();
app._runMode = 'codex'; // not installed
app.openCommandPalette();
expect(app.buildCommandPaletteItems('').find((i: any) => i.type === 'new-session').mode).toBe('claude');
});
it('cycles the agent with Right/Left on the new-session row, wrapping', async () => {
const { app, listeners, elements } = loadAgentPalette();
app.openCommandPalette();
elements.commandPaletteSearch.value = 'nomatch';
app.renderCommandPalette();
expect(app.commandPaletteItems[app.commandPaletteActiveIndex].type).toBe('new-session');
const right = arrow('ArrowRight', 7, 'nomatch');
await listeners['search:keydown'](right);
expect(right.preventDefault).toHaveBeenCalled();
expect(app.commandPaletteItems[app.commandPaletteActiveIndex]).toMatchObject({ mode: 'shell' });
expect(elements.commandPaletteList.innerHTML).toContain('Run Shell in');
await listeners['search:keydown'](arrow('ArrowLeft', 0, 'nomatch'));
await listeners['search:keydown'](arrow('ArrowLeft', 0, 'nomatch'));
expect(app.commandPaletteItems[app.commandPaletteActiveIndex]).toMatchObject({ mode: 'opencode' });
});
it('leaves Left/Right to the caret when they would move it', async () => {
const { app, listeners, elements } = loadAgentPalette();
app.openCommandPalette();
elements.commandPaletteSearch.value = 'nomatch';
app.renderCommandPalette();
const left = arrow('ArrowLeft', 7, 'nomatch');
const right = arrow('ArrowRight', 3, 'nomatch');
await listeners['search:keydown'](left);
await listeners['search:keydown'](right);
expect(left.preventDefault).not.toHaveBeenCalled();
expect(right.preventDefault).not.toHaveBeenCalled();
expect(app.commandPaletteItems[app.commandPaletteActiveIndex].mode).toBe('claude');
});
it('does not cycle while a session row is highlighted', async () => {
const { app, listeners } = loadAgentPalette();
app.openCommandPalette();
expect(app.commandPaletteItems[0].type).toBe('session');
const right = arrow('ArrowRight', 0);
await listeners['search:keydown'](right);
expect(right.preventDefault).not.toHaveBeenCalled();
expect(app.buildCommandPaletteItems('').find((i: any) => i.type === 'new-session').mode).toBe('claude');
});
it('cycles from the row arrows without launching', async () => {
const { app, listeners } = loadAgentPalette();
app.openCommandPalette();
const cycle = { dataset: { paletteAgentCycle: 'next' } };
const event = {
preventDefault: vi.fn(),
stopPropagation: vi.fn(),
target: { closest: (sel: string) => (sel === '[data-palette-agent-cycle]' ? cycle : null) },
};
listeners['list:click'](event);
expect(app.run).not.toHaveBeenCalled();
expect(app.buildCommandPaletteItems('').find((i: any) => i.type === 'new-session').mode).toBe('shell');
});
it('launches the chosen agent once, without changing the Run button mode', async () => {
const { app } = loadAgentPalette();
app.setRunMode = vi.fn();
let modeDuringRun: string | undefined;
app.run = vi.fn(async () => {
modeDuringRun = app._runMode;
});
app.openCommandPalette();
app._cycleCommandPaletteAgent(2); // claude -> shell -> opencode
app.commandPaletteItems = app.buildCommandPaletteItems('nomatch');
app.commandPaletteActiveIndex = 0;
await app.activateCommandPaletteItem();
expect(app.run).toHaveBeenCalledTimes(1);
expect(modeDuringRun).toBe('opencode');
expect(app._runMode).toBe('claude');
expect(app.setRunMode).not.toHaveBeenCalled();
});
it('restores the Run button mode even when the launch throws', async () => {
const { app } = loadAgentPalette();
app.run = vi.fn(async () => {
throw new Error('boom');
});
app.openCommandPalette();
app._cycleCommandPaletteAgent(1);
app.commandPaletteItems = app.buildCommandPaletteItems('nomatch');
app.commandPaletteActiveIndex = 0;
await expect(app.activateCommandPaletteItem()).rejects.toThrow('boom');
expect(app._runMode).toBe('claude');
});
it('draws no picker when only one agent is offered', () => {
const { app, elements } = loadAgentPalette([{ id: 'claude', label: 'Claude Code', kind: 'agent', enabled: true }]);
app.openCommandPalette();
expect(elements.commandPaletteList.innerHTML).not.toContain('data-palette-agent-cycle');
});
it('keeps the Run button mode when a single other agent is offered', async () => {
// Only Shell is offered while the Run button names claude: with nothing to
// pick between, the row keeps the Run button's mode, as before the picker
// existed, so run() answers a missing claude with its install hint instead
// of the palette silently starting Shell.
const { app, elements } = loadAgentPalette(PALETTE_CATALOG.slice(0, 2));
app.isCliAvailable = (id: string) => id !== 'claude';
let modeDuringRun: string | undefined;
app.run = vi.fn(async () => {
modeDuringRun = app._runMode;
});
app.openCommandPalette();
elements.commandPaletteSearch.value = 'nomatch';
app.renderCommandPalette();
const index = app.commandPaletteItems.findIndex((i: any) => i.type === 'new-session');
expect(app.commandPaletteItems[index]).toMatchObject({ mode: 'claude', subtitle: 'Run Claude in plex-previews' });
expect(elements.commandPaletteList.innerHTML).not.toContain('data-palette-agent-cycle');
await app.activateCommandPaletteItem(index);
expect(modeDuringRun).toBe('claude');
expect(app._runMode).toBe('claude');
});
});
/** The toolbar Run menu's entries, as renderRegistryRunOptions() and index.html draw them. */
function fakeRunMenu(modes: string[]) {
const options = modes.map((mode) => ({ dataset: { mode }, style: { display: 'PRISTINE' } }));
return {
options,
querySelectorAll: (selector: string) => (selector === '.run-mode-option[data-mode]' ? options : []),
querySelector: () => null,
prepend: vi.fn(),
};
}
const shownByRunMenu = (menu: ReturnType<typeof fakeRunMenu>) =>
menu.options.filter((option) => option.style.display === 'flex').map((option) => option.dataset.mode);
/**
* A palette with session-ui.js loaded, so the agent list goes through the Run
* menu's real availability rule. Host flags as in loadAgentPalette (no codex
* here); the active case is a Docker case with the given `docker` record.
*/
function loadDockerPalette(docker: Record<string, any>) {
const harness = loadAgentPalette(PALETTE_CATALOG, { sessionUi: true });
harness.app.cases = [
{ name: 'plex-previews' },
{ name: 'far', location: 'remote' },
{ name: 'dock', location: 'docker', docker },
];
harness.elements.quickStartCase.value = 'dock';
harness.app.selectQuickStartCase = vi.fn();
// The Run menu's live container probe is a docker exec round trip; here it
// never lands, which is also what a probe still in flight looks like.
harness.app._probeDockerCaseModes = vi.fn();
return harness;
}
const ADOPTED = { owned: false, hostId: 'local', container: 'dev-box', availableModes: ['shell', 'claude', 'codex'] };
describe('Command-K new-session agents follow the Run menu for the row case', () => {
it('offers a Docker case its container agents and starts on a container-only Run mode', async () => {
// codex is missing on the host but ships in the container, and the Run
// button is on it (picked from the Run menu, which gates on the container).
const { app, elements } = loadDockerPalette(ADOPTED);
app._runMode = 'codex';
let modeDuringRun: string | undefined;
app.run = vi.fn(async () => {
modeDuringRun = app._runMode;
});
app.openCommandPalette();
elements.commandPaletteSearch.value = 'nomatch';
app.renderCommandPalette();
const index = app.commandPaletteItems.findIndex((i: any) => i.type === 'new-session');
expect(app._commandPaletteAgentChoices('dock').map((c: any) => c.id)).toEqual(['claude', 'shell', 'codex']);
expect(app.commandPaletteItems[index]).toMatchObject({
caseName: 'dock',
mode: 'codex',
subtitle: 'Run Codex in dock',
});
expect(elements.commandPaletteList.innerHTML).toContain('run-mode-dot codex');
// The same agents the Run menu shows for this case.
const menu = fakeRunMenu(['claude', 'codex', 'opencode', 'shell']);
app._refreshRunModeAvailability(menu);
expect(shownByRunMenu(menu)).toEqual(['claude', 'codex']);
await app.activateCommandPaletteItem(index);
expect(modeDuringRun).toBe('codex');
expect(app._runMode).toBe('codex');
});
it('offers every enabled agent for an owned container nothing has probed yet, and then follows the probe', () => {
// An owned container runs the base image, which ships every CLI, and before
// its first session there is nothing to probe: like the Run menu, an unknown
// list does not gate, so a probe still in flight cannot empty the row.
const { app } = loadDockerPalette({ owned: true, hostId: 'local', container: 'codeman-dock' });
app._runMode = 'codex';
app.openCommandPalette();
expect(app._commandPaletteAgentChoices('dock').map((c: any) => c.id)).toEqual([
'claude',
'shell',
'codex',
'opencode',
]);
expect(app.commandPaletteItems.find((i: any) => i.type === 'new-session').mode).toBe('codex');
// The palette reads what the Run menu learned; it never starts a docker exec itself.
expect(app._probeDockerCaseModes).not.toHaveBeenCalled();
const menu = fakeRunMenu(['claude', 'codex', 'opencode', 'shell']);
app._refreshRunModeAvailability(menu);
expect(shownByRunMenu(menu)).toEqual(['claude', 'codex', 'opencode']);
// Once the live probe has landed, both follow it.
app._dockerCaseModes = { dock: ['shell', 'opencode'] };
expect(app._commandPaletteAgentChoices('dock').map((c: any) => c.id)).toEqual(['shell', 'opencode']);
app._refreshRunModeAvailability(menu);
expect(shownByRunMenu(menu)).toEqual(['opencode']);
});
it('keeps the host rule for local and remote cases, and follows the case the query names', () => {
const { app, elements } = loadDockerPalette(ADOPTED);
elements.quickStartCase.value = 'plex-previews';
app._runMode = 'codex'; // missing on the host
const row = () => app.commandPaletteItems.find((i: any) => i.type === 'new-session');
app.openCommandPalette();
expect(app._commandPaletteAgentChoices('plex-previews').map((c: any) => c.id)).toEqual([
'claude',
'shell',
'opencode',
]);
expect(app._commandPaletteAgentChoices('far').map((c: any) => c.id)).toEqual(['claude', 'shell', 'opencode']);
expect(row()).toMatchObject({ caseName: 'plex-previews', mode: 'claude' });
// Typing the Docker case's name moves the row there, where the container
// has the Run button's codex, so the row starts on it.
elements.commandPaletteSearch.value = 'dock';
app.renderCommandPalette();
expect(row()).toMatchObject({ caseName: 'dock', mode: 'codex' });
});
});
describe('Session Manager unified list', () => {
it('maps UnifiedSessionItem fields to the history-record shape and routes clicks by liveness', async () => {
const { app, elements } = loadPaletteHarness({
+12 -5
View File
@@ -299,8 +299,8 @@ describe('adopted container: run modes come from the CONTAINER, not the host', (
* exactly what happened twice: a comment added above the assertion pushed the
* asserted line past the cutoff and CI failed on a test that was still true.
*/
const refreshFn = (src) => {
const start = src.indexOf('_refreshRunModeAvailability(menu) {');
const methodFn = (src, signature) => {
const start = src.indexOf(signature);
expect(start).toBeGreaterThan(-1);
const open = src.indexOf('{', start);
let depth = 0;
@@ -308,23 +308,30 @@ describe('adopted container: run modes come from the CONTAINER, not the host', (
if (src[i] === '{') depth++;
else if (src[i] === '}' && --depth === 0) return src.slice(start, i + 1);
}
throw new Error('unbalanced braces in _refreshRunModeAvailability');
throw new Error(`unbalanced braces in ${signature}`);
};
// The per-mode decision lives in ONE helper, shared by the Run menu and the
// Cmd+K new-session row (panels-ui.js), so the two cannot drift apart.
const ruleFn = (src) => methodFn(src, '_runModeAvailableForCase(mode, caseName) {');
it('gates a docker case on availableModes instead of host CLI probes', () => {
// The sandbox host had codex but no claude while the adopted container had
// claude and no codex; gating on the host hid the only mode that worked.
const fn = refreshFn(ui);
const fn = ruleFn(ui);
expect(fn).toContain("location === 'docker'");
expect(fn).toContain('availableModes');
// Non-docker cases must keep the original host probe (#201).
expect(fn).toContain('this.isCliAvailable(mode)');
// And the Run menu asks that helper rather than keeping its own copy.
expect(methodFn(ui, '_refreshRunModeAvailability(menu) {')).toContain(
'this._runModeAvailableForCase(mode, caseName)'
);
});
it('leaves an owned container ungated when nothing was probed', () => {
// Our base image ships every CLI, so an absent list means "unknown", and
// treating unknown as "nothing available" would empty the menu.
expect(refreshFn(ui)).toMatch(/containerModes \?[^:]*:\s*true/);
expect(ruleFn(ui)).toMatch(/containerModes \?[^:]*:\s*true/);
});
});
@@ -33,6 +33,9 @@ const SCANNED_FILES = [
'terminal-split.js',
'tile-grid.js',
'terminal-tile.js',
// The command palette's new-session agent picker reads its agents from the
// injected catalog; panels-ui.js carries no id branch, so keep it that way.
'panels-ui.js',
];
/**