Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
116 changes: 111 additions & 5 deletions src/web/public/panels-ui.js
Original file line number Diff line number Diff line change
Expand Up @@ -424,6 +424,7 @@ Object.assign(CodemanApp.prototype, {
if (!modal || !search) return;

this.commandPaletteActiveIndex = 0;
this._resetCommandPaletteAgent();
search.value = '';
modal.classList.add('active');

Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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;
}
}
},

Expand Down
31 changes: 31 additions & 0 deletions src/web/public/styles.css
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
160 changes: 160 additions & 0 deletions test/command-palette-ui.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -341,6 +341,166 @@ 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) {
const available: Record<string, boolean> = { claude: true, shell: true, codex: false, opencode: true, gemini: true };
const harness = loadPaletteHarness({ window: { __codemanCliCatalog: catalog, __codemanCliAvailable: available } });
// 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('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');
});
});

describe('Session Manager unified list', () => {
it('maps UnifiedSessionItem fields to the history-record shape and routes clicks by liveness', async () => {
const { app, elements } = loadPaletteHarness({
Expand Down
3 changes: 3 additions & 0 deletions test/frontend-cli-no-id-branching.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
];

/**
Expand Down
Loading