mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-11-14 18:39:33 +01:00
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:
1 parent
d22bfaaaaf
commit
0442b4cdbf
4 files changed
+688
-14
No files matched your search
@@ -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.': '编程智能体会保留,不会被卸载。',
|
||||
|
||||
@@ -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),
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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
@@ -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'] });
|
||||
|
||||
|
||||
Reference in new issue
Block a user