diff --git a/src/web/public/i18n.js b/src/web/public/i18n.js index d921df66..3ce4698f 100644 --- a/src/web/public/i18n.js +++ b/src/web/public/i18n.js @@ -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.': '编程智能体会保留,不会被卸载。', diff --git a/src/web/public/local-model-ui.js b/src/web/public/local-model-ui.js index dc210177..6c22c0da 100644 --- a/src/web/public/local-model-ui.js +++ b/src/web/public/local-model-ui.js @@ -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 " | + * | | 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 " 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 ": 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 "), 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), }) ); } diff --git a/src/web/public/styles.css b/src/web/public/styles.css index 2c952ce2..633a71ea 100644 --- a/src/web/public/styles.css +++ b/src/web/public/styles.css @@ -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; } diff --git a/test/local-model-ui.test.ts b/test/local-model-ui.test.ts index e0d50bd6..7496423e 100644 --- a/test/local-model-ui.test.ts +++ b/test/local-model-ui.test.ts @@ -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; + startLocalModelSession(cli?: string): Promise; loadAppSettingsFromServer?: () => Promise; loadCliListForSettings?: () => Promise; selectCustomModelEntry?: (cli: string, endpointId: string) => Promise; @@ -389,6 +390,7 @@ function install(over: Partial = {}): 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 = {}) => + 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('.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('.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('.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! network 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! network 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('.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: '', installCommand: '' } + : row + ); + await opened(page, withAgents(rows)); + pick(page, ''); + expect(page.body.querySelector('.lm-agents img, .lm-agents script')).toBeNull(); + expect(texts(page.body, '.lm-command')).toEqual(['']); + expect(buttons(page.body)).toContain('Install '); + }); + + 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'] });