feat(local-model): pick the coding agent in the window, and install one that is missing

The installed screen gets one more block, "Start a session with": a chip
per coding agent the model in use can be run with, as a radio group, the
server's default selected. A chip that is not on the Mac says so.

The main button follows the selected chip:
- an agent that can be started: "Start a session", with that agent;
- an agent that is not on the Mac: its literal install command above the
  button, and "Install <agent>". The click is the consent; the server
  runs its own stock command, and the window shows "Installing" on the
  chip while the model stays ready and another agent can be started;
- an agent that needs a setup of its own (DeepSeek Harness without a
  terminal profile): one line that says where, nothing to press.

A failed install shows the server's sentence under the chip with the
installer's last words folded away; the model's own headline is
untouched. On a stopped model Start stays the one main button. The
choice is remembered on the device. A server that lists no agents gets
the screen it had.

Rendered in Chromium at 1440, 390 and 320 px on a dark and a light
skin: the chips wrap, nothing leaves the window.

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
This commit is contained in:
Codeman maintainer committed 2026-11-11 10:00:03 +01:00
1 parent d22bfaaaaf
commit 0442b4cdbf
4 files changed
+688 -14

No files matched your search

+12
View File
@@ -1540,6 +1540,18 @@
'Takes {memory} GB of memory now.': '目前占用 {memory} GB 内存。',
'All of it is on the GPU.': '全部位于 GPU 上。',
'Start a session': '开始会话',
// The coding agents of an installed model: one chip each, and the line under the selected one.
'Start a session with': '开始会话时使用',
Installing: '正在安装',
'Not installed': '未安装',
'Needs setup': '需要设置',
'Install {cli}': '安装 {cli}',
'Installing {cli}. You can start a session with another agent meanwhile.':
'正在安装 {cli}。在此期间,你可以用其他智能体开始会话。',
'{cli} is not on this Mac yet. Codeman installs it with this command:':
'此 Mac 上尚未安装 {cli}。{name} 会用下面这条命令安装它:',
'{cli} needs a setup of its own. Set it up from the Run menu, then start a session with it here.':
'{cli} 需要单独设置。请先在"运行"菜单中完成设置,然后就可以在这里用它开始会话。',
'Remove the local model from this Mac?': '要从此 Mac 移除本地模型吗?',
'Also delete the downloaded model': '同时删除已下载的模型',
'The coding agent stays installed.': '编程智能体会保留,不会被卸载。',
+235 -12
View File
@@ -13,6 +13,7 @@
* POST /api/local-model/runtime { action: 'start' | 'stop', newPort? }
* DELETE /api/local-model { removeModel }
* DELETE /api/local-model/models { modelId }
* POST /api/local-model/agent { cli }
*
* ## Three surfaces, one window
*
@@ -35,9 +36,11 @@
* | `progress` | installing: the download bar, the steps, Cancel |
* | `paused` | paused (Cancel or a server restart): Continue or Remove |
* | `failed` | failed: the server's sentence and the ONE action that helps |
* | `installed` | installed: running or stopped, the models on this Mac with their |
* | | size on disk (Use, Delete), Add a model, Start a session, |
* | | Stop/Start, Remove (right after an install: Start a session alone) |
* | `installed` | installed: running or stopped, the coding agents a session can be |
* | | started with, the models on this Mac with their size on disk |
* | | (Use, Delete), Add a model, Start a session (or "Install <agent>" |
* | | for one that is not on this Mac), Stop/Start, Remove (right after |
* | | an install: Start a session alone) |
* | `none` | idle and nothing fits: the hardware line, the reason, Close |
*
* The memory bar is the one strong element: the memory this Mac can give a model, filled to
@@ -61,6 +64,14 @@
* models (`install.store`) and "Add a model". A row has a button only for an action the server
* lists for THAT row (`row.actions`).
*
* The coding agent is chosen per session (`install.harnesses`): on the installed screen every
* agent the model can be run with is a chip of one radio group, the server's default first.
* The main button follows the selected chip: "Start a session" for one that can be started,
* "Install <agent>" for one that is not on this Mac (its literal install command is on screen
* above the button, so the click is the consent), and nothing to press for one that needs a
* setup of its own. An install of an agent runs beside the model: the state stays `installed`,
* and another agent can be started meanwhile. The choice is remembered on this device.
*
* ## Polling
*
* The window asks the GET once a second WHILE IT IS OPEN, and never otherwise (there is no SSE
@@ -115,6 +126,9 @@ const LOCAL_MODEL_POLL_MS = 1000;
/** What `confirmDelete` holds while the question is about everything an earlier Remove kept. No model id has a space. */
const LOCAL_MODEL_STORE_KEY = 'the store';
/** Where this device remembers the coding agent last chosen for a session (a CLI id, as data). */
const LOCAL_MODEL_AGENT_STORAGE_KEY = 'codeman-local-model-agent';
/** The tag kinds of the server's pick (`CandidateTagKind`): the ones drawn in the accent colour. */
const LOCAL_MODEL_ACCENT_TAGS = ['tested', 'best', 'best-tight'];
@@ -240,6 +254,39 @@ function localModelLibraryTag(row) {
return { text: localModelT('Not checked'), kind: 'unverified' };
}
/** The rows of `install.harnesses`: the coding agents the model in use can be run with. A payload without the list has none. */
function localModelAgents(install) {
return (Array.isArray(install.harnesses) ? install.harnesses : []).filter(
(row) => row && typeof row.cli === 'string' && row.cli
);
}
/** What an agent chip says beside the name, and the tag class that colours it. Nothing for one that can be started. */
function localModelAgentTag(row) {
if (row.status === 'installing') return { text: localModelT('Installing'), kind: 'tight' };
if (row.status === 'installable') return { text: localModelT('Not installed'), kind: 'unverified' };
if (row.status === 'setup-needed') return { text: localModelT('Needs setup'), kind: 'unverified' };
return null;
}
/** The coding agent this device chose last, or '' (storage can be empty or refused: the default agent stands then). */
function localModelStoredAgent() {
try {
const value = window.localStorage.getItem(LOCAL_MODEL_AGENT_STORAGE_KEY);
return typeof value === 'string' ? value : '';
} catch {
return '';
}
}
function localModelStoreAgent(cli) {
try {
window.localStorage.setItem(LOCAL_MODEL_AGENT_STORAGE_KEY, String(cli));
} catch {
/* a convenience only */
}
}
/**
* The coding agents a row can run with. A payload without the list means the one agent of the
* row's flat fields.
@@ -545,6 +592,11 @@ Object.assign(CodemanApp.prototype, {
modelId: null,
/** The selected coding agent (a CLI id, as data), or null for the row's first. */
cli: null,
/**
* The coding agent chosen for a session on the installed model (a CLI id, as data), or
* null: what this device chose last, else the server's default.
*/
agent: null,
/** A verb is in flight: every button waits. */
busy: false,
/** What the last verb answered when it was refused. */
@@ -580,6 +632,19 @@ Object.assign(CodemanApp.prototype, {
return { candidates, candidate, harnesses, harness };
},
/**
* The coding agents of the installed model and the one a session would start with: the one
* chosen in this window, else the one this device chose last, else the server's default
* (its first row). `row` is null when the server lists none.
*/
_localModelAgent() {
const ui = this._localModelWindowState();
const rows = localModelAgents(localModelInstall(this._localModelStatus));
const wanted = ui.agent || localModelStoredAgent();
const row = rows.find((entry) => entry.cli === wanted) || rows.find((entry) => entry.default === true) || rows[0];
return { rows, row: row || null };
},
/** Which screen the window shows, see the table in @fileoverview. */
_localModelScreen() {
const ui = this._localModelWindowState();
@@ -845,6 +910,25 @@ Object.assign(CodemanApp.prototype, {
if (asked) this._focusLocalModelWindow(asked === LOCAL_MODEL_STORE_KEY ? 'store-delete' : `delete:${asked}`);
},
/** Select a coding agent for the next session. Remembered on this device; nothing is sent. */
selectLocalModelAgent(cli) {
const ui = this._localModelWindowState();
if (ui.busy || typeof cli !== 'string' || !cli) return;
ui.agent = cli;
ui.notice = null;
localModelStoreAgent(cli);
this.renderLocalModel();
},
/**
* "Install <agent>": the consent click for the command shown above the button. The server
* runs its own stock command for that agent, never text from here, and answers at once: the
* install goes on beside the model and the next polls say how it stands.
*/
installLocalModelAgent(cli) {
return this._localModelVerb('POST', '/api/local-model/agent', { cli });
},
cancelLocalModelInstall() {
return this._localModelVerb('POST', '/api/local-model/cancel');
},
@@ -873,20 +957,22 @@ Object.assign(CodemanApp.prototype, {
},
/**
* "Start a session": a session on the saved connection with the coding agent of the install.
* "Start a session": a session on the saved connection with a coding agent: `cli` (the
* chip that is selected), or without one the server's default agent (`install.cli`).
* The page's own copies are stale right after an install (the settings flag was switched on
* by the server and the agent may have been installed a moment ago), so both are read again
* before the existing launcher runs. Both values are the payload's; no id is compared.
*/
async startLocalModelSession() {
async startLocalModelSession(cli) {
const install = localModelInstall(this._localModelStatus);
if (typeof install.cli !== 'string' || typeof install.endpointId !== 'string') return;
const agent = typeof cli === 'string' && cli ? cli : install.cli;
if (typeof agent !== 'string' || typeof install.endpointId !== 'string') return;
const settingsOpen = document.getElementById('appSettingsModal')?.classList.contains('active');
this.closeLocalModelWindow();
if (settingsOpen) this.closeAppSettings?.();
await this.loadAppSettingsFromServer?.();
await this.loadCliListForSettings?.();
await this.selectCustomModelEntry?.(install.cli, install.endpointId);
await this.selectCustomModelEntry?.(agent, install.endpointId);
},
// ═══════════════════════════════════════════════════════════════
@@ -1495,6 +1581,107 @@ Object.assign(CodemanApp.prototype, {
return box;
},
/**
* The coding agents a session on the installed model can be started with
* (`install.harnesses`): one chip per agent, a radio group, and under it one line about the
* selected one. For an agent that is not on this Mac that line is the literal install command
* (the main button then reads "Install <agent>"), and after a failed install the server's
* sentence with the installer's last words folded away. `null` when the server lists none.
*/
_localModelAgentsEl() {
const ui = this._localModelWindowState();
const { rows, row: chosen } = this._localModelAgent();
if (!rows.length || !chosen) return null;
const section = localModelEl('div', 'lm-agents');
const label = localModelEl('p', 'lm-agents-label', localModelT('Start a session with'));
label.id = 'localModelAgentsLabel';
section.appendChild(label);
const group = localModelEl('div', 'lm-agent-list');
group.setAttribute('role', 'radiogroup');
group.setAttribute('aria-labelledby', 'localModelAgentsLabel');
for (const row of rows) {
const on = row === chosen;
const chip = localModelEl('button', on ? 'lm-agent lm-agent-on' : 'lm-agent');
chip.type = 'button';
chip.setAttribute('role', 'radio');
chip.setAttribute('aria-checked', on ? 'true' : 'false');
// One tab stop for the group: the selected chip.
chip.tabIndex = on ? 0 : -1;
chip.dataset.lmKey = `agent:${row.cli}`;
chip.disabled = ui.busy;
const radio = localModelEl('span', 'lm-radio');
radio.setAttribute('aria-hidden', 'true');
chip.appendChild(radio);
chip.appendChild(localModelEl('span', 'lm-agent-name', String(row.cliLabel || row.cli)));
const tag = localModelAgentTag(row);
if (tag) chip.appendChild(localModelEl('span', `lm-tag lm-tag-${tag.kind}`, tag.text));
chip.addEventListener('click', () => this.selectLocalModelAgent(row.cli));
group.appendChild(chip);
}
group.addEventListener('keydown', (event) => this._onLocalModelAgentKey(event));
section.appendChild(group);
const name = String(chosen.cliLabel || chosen.cli);
const about = localModelEl('div', 'lm-agent-about');
if (chosen.status === 'installable' || chosen.status === 'installing') {
const lead =
chosen.status === 'installing'
? localModelT('Installing {cli}. You can start a session with another agent meanwhile.', { cli: name })
: localModelT('{cli} is not on this Mac yet. Codeman installs it with this command:', { cli: name });
about.appendChild(localModelEl('p', 'lm-aside', lead));
if (chosen.installCommand) about.appendChild(localModelEl('code', 'lm-command', String(chosen.installCommand)));
} else if (chosen.status === 'setup-needed') {
about.appendChild(
localModelEl(
'p',
'lm-aside',
localModelT(
'{cli} needs a setup of its own. Set it up from the Run menu, then start a session with it here.',
{
cli: name,
}
)
)
);
} else if (chosen.slowFirstTurn === true) {
about.appendChild(localModelEl('p', 'lm-aside', localModelT('The first answer can take minutes.')));
}
// The last install of this agent failed: the server's sentence, and what the installer said.
const error = chosen.status === 'installable' ? chosen.error : null;
if (error && error.message) {
const failed = localModelEl('div', 'lm-notice');
failed.setAttribute('role', 'alert');
failed.appendChild(localModelEl('p', 'lm-notice-text', localModelMessage(error.message)));
if (typeof error.detail === 'string' && error.detail) {
const details = localModelEl('details', 'lm-details');
details.appendChild(localModelEl('summary', '', localModelT('Details')));
details.appendChild(localModelEl('pre', 'lm-detail-text', error.detail));
failed.appendChild(details);
}
about.appendChild(failed);
}
if (about.childElementCount) section.appendChild(about);
return section;
},
/** Arrow keys, Home and End move the selection of the agent chips, and the focus with it. */
_onLocalModelAgentKey(event) {
const { rows, row } = this._localModelAgent();
if (!rows.length || !row) return;
const index = rows.indexOf(row);
let next = -1;
if (event.key === 'ArrowDown' || event.key === 'ArrowRight') next = (index + 1) % rows.length;
else if (event.key === 'ArrowUp' || event.key === 'ArrowLeft') next = (index - 1 + rows.length) % rows.length;
else if (event.key === 'Home') next = 0;
else if (event.key === 'End') next = rows.length - 1;
if (next < 0 || next === index) return;
event.preventDefault();
this.selectLocalModelAgent(rows[next].cli);
this._focusLocalModelWindow(`agent:${rows[next].cli}`);
},
/** Arrow keys, Home and End move the selection of the radio group, and the focus with it. */
_onLocalModelListKey(event) {
const selection = this._localModelSelection();
@@ -1962,7 +2149,9 @@ Object.assign(CodemanApp.prototype, {
state.appendChild(localModelEl('span', 'lm-state-text', error ? `${model}, ${running}` : running));
parts.push(state);
const about = [];
if (install.cliLabel) about.push(localModelT('Runs with {cli}.', { cli: String(install.cliLabel) }));
// The agents have their own block below when the server lists them; without the list, the one of the install.
const agent = this._localModelAgent().row;
if (!agent && install.cliLabel) about.push(localModelT('Runs with {cli}.', { cli: String(install.cliLabel) }));
const line = this._localModelFactsLine(install);
if (line) about.push(line);
if (about.length) parts.push(localModelEl('p', 'lm-aside', about.join(' ')));
@@ -1980,6 +2169,11 @@ Object.assign(CodemanApp.prototype, {
// The screen that ends an install has one button. Stop and Remove are for the window
// opened later (or for this one once anything else was said in it).
const ending = ui.justInstalled === true && can('start-session') && !error && !notice;
// Which coding agent the next session runs with. Not on the screen that ends an install
// (that one says one thing: the session starts with the agent that was just chosen), and
// not while something blocks every session.
const agents = ending || error ? null : this._localModelAgentsEl();
if (agents) parts.push(agents);
// Everything that is on this Mac, what it takes on disk, and the way to another model.
// Not on the screen that ends an install: that one says one thing.
const library = ending ? null : this._localModelLibraryEl(install);
@@ -2006,24 +2200,53 @@ Object.assign(CodemanApp.prototype, {
);
}
// Start is the main action of a stopped model, unless the error's own button says so already.
const startIsMain =
can('start') &&
!(error && (error.next === 'start' || error.next === 'other-port')) &&
!can('start-session') &&
!next;
if (can('start') && !(error && (error.next === 'start' || error.next === 'other-port'))) {
buttons.appendChild(
localModelButton(localModelT('Start'), {
key: can('start-session') || next ? 'start' : 'primary',
primary: !can('start-session') && !next,
key: startIsMain ? 'primary' : 'start',
primary: startIsMain,
disabled: ui.busy,
onClick: () => void this.setLocalModelRuntime('start'),
})
);
}
if (next) buttons.appendChild(next);
if (can('start-session')) {
// The main button follows the selected coding agent, by what the server offers for it.
const offers = (action) => !!agents && Array.isArray(agent?.actions) && agent.actions.includes(action);
if (agents && agent && agent.status !== 'ready') {
const name = String(agent.cliLabel || agent.cli);
if (offers('install')) {
buttons.appendChild(
localModelButton(localModelT('Install {cli}', { cli: name }), {
// A stopped model's main action is Start: installing an agent is the quiet one then.
key: startIsMain ? 'agent-install' : 'primary',
primary: !next && !startIsMain,
disabled: ui.busy,
onClick: () => void this.installLocalModelAgent(agent.cli),
})
);
} else if (agent.status === 'installing') {
buttons.appendChild(
localModelButton(localModelT('Installing {cli}', { cli: name }), {
key: startIsMain ? 'agent-install' : 'primary',
primary: !next && !startIsMain,
disabled: true,
})
);
}
// An agent that needs a setup of its own has nothing to press: the line above says where.
} else if (agents ? offers('start-session') : can('start-session')) {
buttons.appendChild(
localModelButton(localModelT('Start a session'), {
key: 'primary',
primary: !next,
disabled: ui.busy,
onClick: () => void this.startLocalModelSession(),
onClick: () => void this.startLocalModelSession(agents && agent ? agent.cli : undefined),
})
);
}
+88 -1
View File
@@ -23250,6 +23250,88 @@ html[data-skin='rose-pine-dawn'] {
accent-color: var(--lm-brand);
}
/* ── the coding agents of an installed model ─────────────────────── */
/* One chip per agent a session can be started with, as a radio group: the
selected one in the look of the selected model row, a tag on the ones that
are not on this Mac. Under them, one line about the selected one. */
.lm-agents {
display: flex;
flex-direction: column;
gap: 0.45rem;
}
.lm-agents-label {
font-size: 0.7rem;
color: var(--text-dim);
}
.lm-agent-list {
display: flex;
flex-wrap: wrap;
gap: 0.4rem;
}
.lm-agent {
display: inline-flex;
align-items: center;
gap: 0.45rem;
min-height: 2.1rem;
padding: 0 0.7rem 0 0.6rem;
border: 1px solid var(--border);
border-radius: 9px;
background: transparent;
color: var(--text);
font: inherit;
font-size: 0.8rem;
cursor: pointer;
transition:
background var(--transition-smooth),
border-color var(--transition-smooth);
}
.lm-agent:hover:not(:disabled) {
background: var(--control-bg);
}
.lm-agent:focus-visible {
outline: 2px solid var(--lm-brand);
outline-offset: 1px;
}
.lm-agent:disabled {
cursor: default;
}
.lm-agent-on,
.lm-agent-on:hover:not(:disabled) {
border-color: color-mix(in srgb, var(--lm-brand) 55%, var(--border));
background: linear-gradient(
90deg,
color-mix(in srgb, var(--lm-brand) 17%, transparent),
color-mix(in srgb, var(--lm-brand) 5%, transparent)
);
}
.lm-agent .lm-radio {
flex: none;
}
.lm-agent-on .lm-radio {
border-color: var(--lm-brand);
background: radial-gradient(circle, var(--lm-brand) 0 3px, transparent 3.5px);
}
.lm-agent-name {
font-weight: 600;
white-space: nowrap;
}
.lm-agent-about {
display: flex;
flex-direction: column;
gap: 0.3rem;
}
/* ── the models on this Mac ───────────────────────────────────────── */
/* Name with its context, state, size on disk, and what can be done with the
row. One grid like the chooser's, so the sizes line up down the list. The
@@ -23484,7 +23566,8 @@ html[data-skin='rose-pine-dawn'] {
@media (prefers-reduced-motion: reduce) {
.lm-bar-fill,
.lm-model {
.lm-model,
.lm-agent {
transition: none;
}
}
@@ -23495,6 +23578,10 @@ html[data-skin='rose-pine-dawn'] {
min-height: 2.9rem;
}
.lm-agent {
min-height: 2.6rem;
}
.local-model-window .wt-btn.lm-btn.lm-btn-row {
min-height: 36px;
}
+353 -1
View File
@@ -51,6 +51,7 @@ import type {
HardwareFacts,
LocalModelConflict,
LocalModelErrorCode,
LocalModelHarnessRow,
LocalModelInstallView,
LocalModelLibraryRow,
LocalModelMessage,
@@ -77,7 +78,7 @@ interface App {
openLocalModelWindow(): void;
closeLocalModelWindow(): void;
isLocalModelWindowOpen(): boolean;
startLocalModelSession(): Promise<void>;
startLocalModelSession(cli?: string): Promise<void>;
loadAppSettingsFromServer?: () => Promise<void>;
loadCliListForSettings?: () => Promise<void>;
selectCustomModelEntry?: (cli: string, endpointId: string) => Promise<void>;
@@ -389,6 +390,7 @@ function install(over: Partial<LocalModelInstallView> = {}): LocalModelInstallVi
runtime: RUNTIME_MISSING,
folder: FOLDER,
library: [],
harnesses: [],
actions: ['install'],
...over,
};
@@ -2827,6 +2829,356 @@ describe('the models on this Mac', () => {
});
});
// ---------- The coding agents of an installed model ----------
/** The agents the server lists for the installed model: two that can be started, one to install, one that needs a setup. */
const AGENTS: LocalModelHarnessRow[] = [
{ cli: 'opencode', cliLabel: 'OpenCode', default: true, status: 'ready', readyMark: '┃', actions: ['start-session'] },
{
cli: 'claude',
cliLabel: 'Claude Code',
default: false,
status: 'ready',
slowFirstTurn: true,
actions: ['start-session'],
},
{
cli: 'pi',
cliLabel: 'Pi',
default: false,
status: 'installable',
installCommand: 'npm install -g --ignore-scripts @earendil-works/pi-coding-agent',
actions: ['install'],
},
{ cli: 'deepseek', cliLabel: 'DeepSeek', default: false, status: 'setup-needed', readyMark: '❯', actions: [] },
];
const withAgents = (rows: LocalModelHarnessRow[], over: Partial<LocalModelInstallView> = {}) =>
status(
{},
{ ...INSTALLED.install, harnesses: rows, actions: [...INSTALLED.install.actions, 'install-agent'], ...over }
);
const WITH_AGENTS = withAgents(AGENTS);
describe('the coding agents of an installed model', () => {
const chips = (page: Mounted) => texts(page.body, '.lm-agent').map((text) => text.trim());
const chosen = (page: Mounted) =>
page.body.querySelector('.lm-agent[aria-checked="true"] .lm-agent-name')?.textContent ?? null;
const pick = (page: Mounted, label: string) => {
const chip = [...page.body.querySelectorAll<HTMLButtonElement>('.lm-agent')].find(
(el) => el.querySelector('.lm-agent-name')!.textContent === label
);
expect(chip, `no agent "${label}"`).toBeTruthy();
chip!.click();
};
const launcher = (app: App) => {
app.loadAppSettingsFromServer = vi.fn(async () => undefined);
app.loadCliListForSettings = vi.fn(async () => undefined);
app.selectCustomModelEntry = vi.fn(async () => undefined);
return app.selectCustomModelEntry;
};
it('is one radio group: a chip per agent in the server order, the default selected, a tag on what is not there', async () => {
const page = mount();
await opened(page, WITH_AGENTS);
const group = page.body.querySelector('.lm-agent-list')!;
expect(group.getAttribute('role')).toBe('radiogroup');
expect(page.document.getElementById(group.getAttribute('aria-labelledby')!)!.textContent).toBe(
'Start a session with'
);
expect(texts(page.body, '.lm-agent-name')).toEqual(['OpenCode', 'Claude Code', 'Pi', 'DeepSeek']);
expect(chips(page)).toEqual(['OpenCode', 'Claude Code', 'PiNot installed', 'DeepSeekNeeds setup']);
expect(chosen(page)).toBe('OpenCode');
// One tab stop for the group.
expect([...page.body.querySelectorAll<HTMLElement>('.lm-agent')].map((el) => el.tabIndex)).toEqual([0, -1, -1, -1]);
// The chips say which agent, so the facts line no longer does.
expect(page.body.textContent).not.toContain('Runs with');
expect(buttons(page.body)).toEqual(['Remove', 'Stop', 'Start a session']);
expect(frameOf(page.body).querySelectorAll('.lm-buttons .wt-btn-primary')).toHaveLength(1);
});
it('starts the session with the selected agent, and this device remembers the choice', async () => {
const page = mount();
const { app } = await opened(page, WITH_AGENTS);
const launch = launcher(app);
pick(page, 'Claude Code');
expect(chosen(page)).toBe('Claude Code');
// What the catalogue says about its first answer.
expect(texts(page.body, '.lm-agent-about .lm-aside')).toEqual(['The first answer can take minutes.']);
expect(verbs(app)).toEqual([]);
press(page.body, 'Start a session');
await settle();
expect(launch).toHaveBeenCalledWith('claude', 'local-ollama');
// The window opened again later: the last choice stands, not the default.
expect(page.dom.window.localStorage.getItem('codeman-local-model-agent')).toBe('claude');
const again = page.newApp(ok(WITH_AGENTS));
await again.loadLocalModel();
again.openLocalModelWindow();
await settle();
expect(chosen(page)).toBe('Claude Code');
});
it('a remembered agent the server no longer lists falls back to the default', async () => {
const page = mount();
page.dom.window.localStorage.setItem('codeman-local-model-agent', 'gone');
const { app } = await opened(page, WITH_AGENTS);
expect(chosen(page)).toBe('OpenCode');
const launch = launcher(app);
press(page.body, 'Start a session');
await settle();
expect(launch).toHaveBeenCalledWith('opencode', 'local-ollama');
});
it('an agent that is not on this Mac: its literal command, and the main button installs it', async () => {
const page = mount();
const installing = AGENTS.map((row) =>
row.cli === 'pi' ? { ...row, status: 'installing' as const, actions: [] } : row
);
const { app, serve } = await opened(page, WITH_AGENTS, () =>
ok(withAgents(installing, { actions: INSTALLED.install.actions }).install)
);
pick(page, 'Pi');
expect(texts(page.body, '.lm-agent-about .lm-aside')).toEqual([
'Pi is not on this Mac yet. Codeman installs it with this command:',
]);
// The command the click consents to, as text, exactly as the server sent it.
expect(texts(page.body, '.lm-agent-about .lm-command')).toEqual([
'npm install -g --ignore-scripts @earendil-works/pi-coding-agent',
]);
expect(buttons(page.body)).toEqual(['Remove', 'Stop', 'Install Pi']);
expect(frameOf(page.body).querySelector('.lm-buttons .wt-btn-primary')!.textContent).toBe('Install Pi');
press(page.body, 'Install Pi');
await settle();
// The id goes out as data; the command is the server's own and is never sent.
expect(verbs(app)).toEqual([['POST', '/api/local-model/agent', { cli: 'pi' }]]);
expect(chips(page)).toContain('PiInstalling');
expect(texts(page.body, '.lm-agent-about .lm-aside')).toEqual([
'Installing Pi. You can start a session with another agent meanwhile.',
]);
const main = frameOf(page.body).querySelector<HTMLButtonElement>('.lm-buttons .wt-btn-primary')!;
expect([main.textContent, main.disabled]).toEqual(['Installing Pi', true]);
// The model is as it was: still installed, still running.
expect(page.body.dataset.screen).toBe('installed');
expect(texts(page.body, '.lm-state-text')).toEqual(['Running']);
// The installer landed: the next poll says so, and the same chip now starts a session.
serve(
withAgents(
AGENTS.map((row) =>
row.cli === 'pi'
? {
cli: 'pi',
cliLabel: 'Pi',
default: false,
status: 'ready' as const,
actions: ['start-session' as const],
}
: row
)
)
);
await page.tick();
expect(chips(page)).toContain('Pi');
expect(chosen(page)).toBe('Pi');
expect(buttons(page.body)).toEqual(['Remove', 'Stop', 'Start a session']);
const launch = launcher(app);
press(page.body, 'Start a session');
await settle();
expect(launch).toHaveBeenCalledWith('pi', 'local-ollama');
});
it('while an agent installs, a session with another agent can be started', async () => {
const page = mount();
const rows = AGENTS.map((row) => (row.cli === 'pi' ? { ...row, status: 'installing' as const, actions: [] } : row));
const { app } = await opened(page, withAgents(rows, { actions: INSTALLED.install.actions }));
expect(chosen(page)).toBe('OpenCode');
const launch = launcher(app);
press(page.body, 'Start a session');
await settle();
expect(launch).toHaveBeenCalledWith('opencode', 'local-ollama');
});
it('a failed install: the server sentence on the chip that failed, the installer words folded away, and try again', async () => {
const page = mount();
const rows = AGENTS.map((row) =>
row.cli === 'pi'
? {
...row,
error: {
message: { text: LOCAL_MODEL_INSTALL_LABELS.errHarnessFailed, vars: { cli: 'Pi' } },
detail: 'npm ERR! <b>network</b> request failed',
},
}
: row
);
await opened(page, withAgents(rows));
// Not shown under another agent.
expect(page.body.querySelector('.lm-agent-about .lm-notice')).toBeNull();
pick(page, 'Pi');
expect(texts(page.body, '.lm-agent-about .lm-notice-text')).toEqual(['Pi could not be installed.']);
const detail = page.body.querySelector('.lm-agent-about .lm-detail-text')!;
// Someone else's text: shown as text, never as markup.
expect(detail.textContent).toBe('npm ERR! <b>network</b> request failed');
expect(detail.querySelector('b')).toBeNull();
expect(buttons(page.body)).toEqual(['Remove', 'Stop', 'Install Pi']);
// The model itself has no error: the headline still says ready.
expect(texts(page.body, '.lm-title')).toEqual(['Qwen3.6 35B-A3B is ready on this Mac.']);
});
it('an agent that needs a setup of its own says where, and has nothing to press', async () => {
const page = mount();
const { app } = await opened(page, WITH_AGENTS);
pick(page, 'DeepSeek');
expect(texts(page.body, '.lm-agent-about .lm-aside')).toEqual([
'DeepSeek needs a setup of its own. Set it up from the Run menu, then start a session with it here.',
]);
expect(buttons(page.body)).toEqual(['Remove', 'Stop']);
expect(verbs(app)).toEqual([]);
});
it('with no agent that can be started the main button installs the default one', async () => {
const page = mount();
const rows: LocalModelHarnessRow[] = [
{
cli: 'opencode',
cliLabel: 'OpenCode',
default: true,
status: 'installable',
installCommand: 'curl -fsSL https://opencode.ai/install | bash',
actions: ['install'],
},
];
await opened(page, withAgents(rows, { actions: ['stop', 'install', 'install-agent', 'remove'] }));
expect(buttons(page.body)).toEqual(['Remove', 'Stop', 'Install OpenCode']);
expect(texts(page.body, '.lm-command')).toEqual(['curl -fsSL https://opencode.ai/install | bash']);
});
it('a stopped model: Start stays the one main button, and an agent can still be installed', async () => {
const page = mount();
const rows = AGENTS.map((row) => (row.status === 'ready' ? { ...row, actions: [] } : row));
const { app } = await opened(
page,
withAgents(rows, { running: false, actions: ['start', 'install', 'install-agent', 'remove'] }),
() => ok(withAgents(rows, { running: false, actions: ['start', 'install', 'remove'] }).install)
);
expect(buttons(page.body)).toEqual(['Remove', 'Start']);
pick(page, 'Pi');
expect(buttons(page.body)).toEqual(['Remove', 'Start', 'Install Pi']);
const primary = frameOf(page.body).querySelectorAll('.lm-buttons .wt-btn-primary');
expect([...primary].map((el) => el.textContent)).toEqual(['Start']);
press(page.body, 'Install Pi');
await settle();
expect(verbs(app)).toEqual([['POST', '/api/local-model/agent', { cli: 'pi' }]]);
});
it('moves the selection with the arrow keys, Home and End, and the focus follows', async () => {
const page = mount();
await opened(page, WITH_AGENTS);
page.body.querySelector<HTMLElement>('.lm-agent[aria-checked="true"]')!.focus();
const key = (name: string) => {
const event = new page.dom.window.KeyboardEvent('keydown', { key: name, bubbles: true, cancelable: true });
page.document.activeElement!.dispatchEvent(event);
return event;
};
const walk: Array<[string, string]> = [
['ArrowRight', 'Claude Code'],
['ArrowDown', 'Pi'],
['ArrowLeft', 'Claude Code'],
['ArrowUp', 'OpenCode'],
// The ends wrap.
['ArrowLeft', 'DeepSeek'],
['ArrowRight', 'OpenCode'],
['End', 'DeepSeek'],
['Home', 'OpenCode'],
];
for (const [name, agent] of walk) {
const event = key(name);
expect(event.defaultPrevented, name).toBe(true);
expect(chosen(page), name).toBe(agent);
const active = page.document.activeElement!;
expect(active.classList.contains('lm-agent'), name).toBe(true);
expect(active.getAttribute('aria-checked'), name).toBe('true');
}
// Any other key is left alone.
expect(key('a').defaultPrevented).toBe(false);
});
it('is not on the screen that ends an install, and not while something blocks every session', async () => {
const page = mount();
const { app, serve } = await opened(page, INSTALLING);
serve(WITH_AGENTS);
await page.tick();
// The install just finished in this window: one thing to do.
expect(page.body.querySelector('.lm-agents')).toBeNull();
expect(buttons(page.body)).toEqual(['Start a session']);
const launch = launcher(app);
press(page.body, 'Start a session');
await settle();
// The agent that was chosen for the install: the server's default.
expect(launch).toHaveBeenCalledWith('opencode', 'local-ollama');
const blocked = mount();
await opened(
blocked,
withAgents(
AGENTS.map((row) => ({ ...row, actions: row.actions.filter((action) => action !== 'start-session') })),
{
actions: ['stop', 'install', 'remove'],
error: {
code: 'model-changed',
message: { text: LOCAL_MODEL_INSTALL_LABELS.errModelChanged },
next: 'remove',
},
}
)
);
expect(blocked.body.querySelector('.lm-agents')).toBeNull();
expect(buttons(blocked.body)).not.toContain('Start a session');
});
it('an agent name is text of someone else: shown as text', async () => {
const page = mount();
const rows = AGENTS.map((row) =>
row.cli === 'pi'
? { ...row, cliLabel: '<img src=x onerror=alert(1)>', installCommand: '<script>x</script>' }
: row
);
await opened(page, withAgents(rows));
pick(page, '<img src=x onerror=alert(1)>');
expect(page.body.querySelector('.lm-agents img, .lm-agents script')).toBeNull();
expect(texts(page.body, '.lm-command')).toEqual(['<script>x</script>']);
expect(buttons(page.body)).toContain('Install <img src=x onerror=alert(1)>');
});
it('in Chinese: the label, the tags, the line and the button', async () => {
const page = mount({ i18n: true });
page.win.CodemanI18n.start();
page.win.CodemanI18n.configure({ language: 'zh-CN' });
await opened(page, WITH_AGENTS);
expect(texts(page.body, '.lm-agents-label')).toEqual(['开始会话时使用']);
expect(texts(page.body, '.lm-agent .lm-tag')).toEqual(['未安装', '需要设置']);
pick(page, 'Pi');
expect(texts(page.body, '.lm-agent-about .lm-aside')).toEqual([
'此 Mac 上尚未安装 Pi。Codeman 会用下面这条命令安装它:',
]);
expect(buttons(page.body)).toContain('安装 Pi');
});
it('a server that lists no agents (an older one): the one agent of the install, as before', async () => {
const page = mount();
const { app } = await opened(page, INSTALLED);
expect(page.body.querySelector('.lm-agents')).toBeNull();
expect(page.body.textContent).toContain('Runs with OpenCode.');
const launch = launcher(app);
press(page.body, 'Start a session');
await settle();
expect(launch).toHaveBeenCalledWith('opencode', 'local-ollama');
});
});
describe('what an earlier Remove left on disk', () => {
const LEFT = status({}, { store: STORE_ONE, actions: ['install', 'remove'] });