diff --git a/src/local-model/recommend.ts b/src/local-model/recommend.ts index 752a32df..54a747f4 100644 --- a/src/local-model/recommend.ts +++ b/src/local-model/recommend.ts @@ -313,14 +313,16 @@ function buildCandidate( } /** - * The one tag of a row: the first kind that applies, in the order tested, best (the pick), - * tight, slow, small. `undefined` when none does. + * The one tag of a row: the first kind that applies, in the order tested, the pick (which + * says so when it only just fits), tight, slow, small. `undefined` when none does. */ function tagFor(candidate: Candidate, isPick: boolean): CandidateTag | undefined { const kind: CandidateTagKind | undefined = candidate.tested ? 'tested' : isPick - ? 'best' + ? candidate.fit === 'tight' + ? 'best-tight' + : 'best' : candidate.fit === 'tight' ? 'tight' : candidate.speed === 'slow' @@ -442,12 +444,8 @@ export function recommend( const hasRoom = (model: LocalModelEntry): boolean => free === undefined || free - model.bytes >= DISK_HEADROOM_BYTES; if (free !== undefined && !accepted.some((entry) => hasRoom(entry.model))) { const smallest = Math.min(...accepted.map((entry) => entry.model.bytes)); - return none('disk', budget, { - download: formatGb(smallest), - reserve: formatGb(DISK_HEADROOM_BYTES), - free: hw.freeDiskGb ?? formatGb(free), - missing: formatGb(smallest + DISK_HEADROOM_BYTES - free), - }); + // One number, the one that says what to do: what must be freed for the smallest model. + return none('disk', budget, { missing: formatGb(smallest + DISK_HEADROOM_BYTES - free) }); } // Everything that fits is ranked, the models without room on the disk included: the best of diff --git a/src/types/local-model.ts b/src/types/local-model.ts index 4e146941..5baa6903 100644 --- a/src/types/local-model.ts +++ b/src/types/local-model.ts @@ -351,7 +351,7 @@ export const LOCAL_MODEL_LABELS = { headlineLimited: 'This Mac can run a small model. It handles small edits and questions. It does not replace a cloud model.', /** A note under an `ok` or `limited` pick whose `tested` is false. */ - noteUntested: 'Not yet tested by us on a Mac like this.', + noteUntested: 'Codeman has not run this on a Mac like this yet.', /** A note under an `ok` pick that is itself a small model: larger ones fit this Mac, but rank lower or have no room on the disk. */ noteSmallPick: 'The best fit is a small model. It handles small edits and questions. It does not replace a cloud model.', @@ -363,18 +363,17 @@ export const LOCAL_MODEL_LABELS = { speedSlow: 'Slow', speedUnknown: 'Speed unknown', noneNotMacos: 'Codeman recommends a local model only on a Mac with Apple silicon.', - noneIntel: 'This Mac has an Intel processor. Local coding models need Apple silicon.', + noneIntel: 'Local coding models need Apple silicon, and this Mac has an Intel processor.', /** Reason `memory`: nothing fits at any context. */ - noneMemory: - 'This Mac has {memory} GB of memory. That is too little to run a model that can drive a coding agent, so Codeman recommends none here.', - /** Reason `memory`: a model fits, but only below the context a harness needs. Says both numbers, and that they are an estimate. */ + noneMemory: 'With {memory} GB of memory, this Mac cannot run a model that can drive a coding agent.', + /** Reason `memory`: a model fits, but only below the context a harness needs. Says both numbers, and ("about") that they are estimates. */ noneMemoryContext: - 'Codeman estimates that this Mac can give a model about {budget} GB of memory. The smallest model a coding agent can work with needs about {need} GB. That leaves no safe margin, so Codeman recommends none here.', + 'This Mac can give a model about {budget} GB of memory, and the smallest model a coding agent can work with needs about {need} GB.', /** Reason `memory`: the memory size could not be read, so no size is stated. */ noneMemoryUnknown: 'Codeman could not read how much memory this Mac has, so it recommends no model here.', - noneDisk: - 'Not enough free disk space. The smallest model that fits this Mac is a {download} GB download, and {reserve} GB must stay free after it. {free} GB is free now. Free up about {missing} GB.', - noneMacos: 'This Mac runs macOS {version}. Local models need macOS {minimum} or newer.', + /** Reason `disk`. One number: what to do. The other sizes travel in `vars` for a client that wants them. */ + noneDisk: 'Free up about {missing} GB of disk space to install the smallest model that fits this Mac.', + noneMacos: 'Local models need macOS {minimum} or newer, and this Mac runs macOS {version}.', noneNoHarness: 'No coding agent that Codeman can connect to a local model is available on this Mac.', } as const; @@ -401,14 +400,17 @@ export interface LocalModelMessage { /** * The one tag a model row of the chooser carries, by what it says. * - `tested`: `Candidate.tested` is true. Replaces every other tag. - * - `best`: the pick. The one tag drawn in the accent colour. + * - `best-tight`: the pick, when its `fit` is `tight`. The words must not say less than the + * memory bar beside them, which is nearly full for such a row. + * - `best`: the pick. * - `tight`: `Candidate.fit` is `tight`. * - `slow`: `Candidate.speed` is `slow`. * - `small`: the model has fewer than 12 billion parameters. - * A row gets the FIRST kind of this list that applies and no tag when none does, so a pick - * that is tight says "Best fit" (the memory bar already shows how full it is). + * A row gets the FIRST kind of this list that applies and no tag when none does. `tested`, + * `best` and `best-tight` are the tags of the row the window opens on, drawn in the accent + * colour. */ -export type CandidateTagKind = 'tested' | 'best' | 'tight' | 'slow' | 'small'; +export type CandidateTagKind = 'tested' | 'best-tight' | 'best' | 'tight' | 'slow' | 'small'; /** * The text of each tag, in English (the i18n source language), like `LOCAL_MODEL_LABELS`. A @@ -416,6 +418,7 @@ export type CandidateTagKind = 'tested' | 'best' | 'tight' | 'slow' | 'small'; */ export const LOCAL_MODEL_TAG_LABELS = { tested: 'Tested on a Mac like this', + 'best-tight': 'Best fit, tight', best: 'Best fit', tight: 'Tight', slow: 'Slow', diff --git a/src/web/public/i18n.js b/src/web/public/i18n.js index 47f33f7d..ac8622f7 100644 --- a/src/web/public/i18n.js +++ b/src/web/public/i18n.js @@ -1435,7 +1435,8 @@ estimated: '估算', 'The selected model needs {share}% of the memory this Mac can give a model': '所选模型需要此 Mac 可分配给模型的内存的 {share}%', - 'The model at {context} tokens of context': '模型在 {context} 个 Token 上下文下的占用', + 'The model at its peak, with all {context} tokens of context in use': + '模型的峰值占用({context} 个 Token 的上下文全部用满时)', 'What stays free': '剩余可用', 'Models that fit this Mac': '此 Mac 装得下的模型', Speed: '速度', @@ -1444,20 +1445,20 @@ '{speed} tokens/s': '{speed} Token/秒', 'about {speed} tokens/s': '约 {speed} Token/秒', 'Runs with': '搭配', - 'Not on this Mac yet. It is installed for you.': '此 Mac 上尚未安装,将为你一并安装。', + 'Not on this Mac yet. Codeman installs it for you.': '此 Mac 上尚未安装,{name} 会为你一并安装。', 'The first answer can take minutes.': '第一次回答可能需要几分钟。', 'No account and no API key. The model runs only on this Mac.': '无需账号,也无需 API 密钥。模型只在此 Mac 上运行。', 'Install on this Mac': '安装到此 Mac', - // The confirm screen: what is downloaded, where it goes. - 'Runs with {cli}, at {context} tokens of context.': '搭配 {cli},上下文为 {context} 个 Token。', - 'What is downloaded': '将下载的内容', - 'Ollama {version}, the program that runs the model': 'Ollama {version},用于运行模型的程序', + // The confirm screen: the download as a table, where it goes. + 'What will be downloaded': '将下载的内容', + 'At {context} tokens of context, with {cli}': '上下文 {context} 个 Token,搭配 {cli}', + 'Ollama {version}': 'Ollama {version}', + 'The program that runs the model': '用于运行模型的程序', Total: '合计', - '{cli} is not on this Mac yet. Its own installer is run with this command:': - '此 Mac 上尚未安装 {cli}。将用以下命令运行它自带的安装程序:', - '{cli} is not on this Mac yet. It is installed with its own installer.': - '此 Mac 上尚未安装 {cli}。将用它自带的安装程序进行安装。', - 'The model and the program that runs it go into:': '模型和运行它的程序将存放在:', + 'Not on this Mac yet. Its own installer fetches it with this command:': + '此 Mac 上尚未安装。它自带的安装程序会用以下命令获取它:', + 'Not on this Mac yet. Its own installer fetches it.': '此 Mac 上尚未安装。它自带的安装程序会获取它。', + 'Goes into': '存放位置:', 'No administrator password.': '无需管理员密码。', 'Download and install': '下载并安装', // Progress, and the same picture paused. @@ -1474,9 +1475,14 @@ 'Loading and checking': '加载并检查', 'Installing {cli}': '安装 {cli}', Connecting: '正在连接', + 'Download of the model': '模型下载进度', + '0 of {total} GB': '已下载 0 GB,共 {total} GB', 'You can close this window. The download continues while Codeman is running.': '你可以关闭此窗口。只要 {name} 在运行,下载就会继续。', - 'Nothing is downloaded until you continue.': '在你继续之前不会下载任何内容。', + 'You can close this window. The install continues while Codeman is running.': + '你可以关闭此窗口。只要 {name} 在运行,安装就会继续。', + 'The download waits until you continue.': '下载会等你点击继续后再进行。', + 'The install waits until you continue.': '安装会等你点击继续后再进行。', Continue: '继续', 'Choose another model': '选择其他模型', // After an error: the one action that helps. @@ -1488,11 +1494,11 @@ '{model} is ready on this Mac.': '{model} 已在此 Mac 上准备就绪。', '{model} is installed on this Mac.': '{model} 已安装在此 Mac 上。', 'Runs with {cli}.': '搭配 {cli} 运行。', - 'Context {context} tokens. Takes {memory} GB of memory when loaded, all on the GPU.': - '上下文 {context} 个 Token。加载后占用 {memory} GB 内存,全部位于 GPU 上。', - 'Context {context} tokens. Takes {memory} GB of memory when loaded.': - '上下文 {context} 个 Token。加载后占用 {memory} GB 内存。', 'Context {context} tokens.': '上下文 {context} 个 Token。', + 'Takes {memory} GB of memory now, and up to about {peak} GB at its peak, with all of the context in use.': + '目前占用 {memory} GB 内存;上下文全部用满时,峰值约为 {peak} GB。', + 'Takes {memory} GB of memory now.': '目前占用 {memory} GB 内存。', + 'All of it is on the GPU.': '全部位于 GPU 上。', 'Start a session': '开始会话', 'Remove the local model from this Mac?': '要从此 Mac 移除本地模型吗?', 'Also delete the downloaded model': '同时删除已下载的模型', @@ -1503,7 +1509,7 @@ 'Recommended for this Mac': '为此 Mac 推荐', 'This Mac can run a small model. It handles small edits and questions. It does not replace a cloud model.': '此 Mac 可以运行小型模型。它能处理小幅修改和问答,但不能替代云端模型。', - 'Not yet tested by us on a Mac like this.': '我们尚未在类似的 Mac 上测试过。', + 'Codeman has not run this on a Mac like this yet.': '{name} 尚未在同类 Mac 上运行过它。', 'The best fit is a small model. It handles small edits and questions. It does not replace a cloud model.': '最适合的是一个小型模型。它能处理小幅修改和问答,但不能替代云端模型。', '{model} fits the memory of this Mac too, but its download needs about {missing} GB more free disk space.': @@ -1514,24 +1520,25 @@ 'Speed unknown': '速度未知', 'Codeman recommends a local model only on a Mac with Apple silicon.': '{name} 仅在搭载 Apple 芯片的 Mac 上推荐本地模型。', - 'This Mac has an Intel processor. Local coding models need Apple silicon.': - '此 Mac 使用 Intel 处理器。本地编程模型需要 Apple 芯片。', - 'This Mac has {memory} GB of memory. That is too little to run a model that can drive a coding agent, so Codeman recommends none here.': - '此 Mac 有 {memory} GB 内存,不足以运行能够驱动编程智能体的模型,因此 {name} 在此不推荐任何模型。', - 'Codeman estimates that this Mac can give a model about {budget} GB of memory. The smallest model a coding agent can work with needs about {need} GB. That leaves no safe margin, so Codeman recommends none here.': - '据 {name} 估算,此 Mac 可分配给模型的内存约为 {budget} GB。编程智能体能够使用的最小模型约需 {need} GB。这样没有留出安全余量,因此 {name} 在此不推荐任何模型。', + 'Local coding models need Apple silicon, and this Mac has an Intel processor.': + '本地编程模型需要 Apple 芯片,而此 Mac 使用的是 Intel 处理器。', + 'With {memory} GB of memory, this Mac cannot run a model that can drive a coding agent.': + '此 Mac 只有 {memory} GB 内存,无法运行能够驱动编程智能体的模型。', + 'This Mac can give a model about {budget} GB of memory, and the smallest model a coding agent can work with needs about {need} GB.': + '此 Mac 可分配给模型的内存约为 {budget} GB,而编程智能体能够使用的最小模型约需 {need} GB。', 'Codeman could not read how much memory this Mac has, so it recommends no model here.': '{name} 无法读取此 Mac 的内存大小,因此在此不推荐任何模型。', - 'Not enough free disk space. The smallest model that fits this Mac is a {download} GB download, and {reserve} GB must stay free after it. {free} GB is free now. Free up about {missing} GB.': - '可用磁盘空间不足。适合此 Mac 的最小模型需要下载 {download} GB,下载后还必须保留 {reserve} GB 的可用空间。当前可用空间为 {free} GB。请腾出约 {missing} GB。', - 'This Mac runs macOS {version}. Local models need macOS {minimum} or newer.': - '此 Mac 运行的是 macOS {version}。本地模型需要 macOS {minimum} 或更高版本。', + 'Free up about {missing} GB of disk space to install the smallest model that fits this Mac.': + '请腾出约 {missing} GB 磁盘空间,以便安装适合此 Mac 的最小模型。', + 'Local models need macOS {minimum} or newer, and this Mac runs macOS {version}.': + '本地模型需要 macOS {minimum} 或更高版本,而此 Mac 运行的是 macOS {version}。', 'No coding agent that Codeman can connect to a local model is available on this Mac.': '此 Mac 上没有 {name} 可以连接到本地模型的编程智能体。', // The tag of a model row (LOCAL_MODEL_TAG_LABELS in src/types/local-model.ts). Tight and Slow are // the entries above. 'Tested on a Mac like this': '已在同类 Mac 上测试', 'Best fit': '最适合', + 'Best fit, tight': '最适合,内存偏紧', 'Small model': '小型模型', // The sentences of the install (LOCAL_MODEL_INSTALL_LABELS in src/types/local-model-install.ts): // why a job stopped, why a request was refused, and the one sentence about the developer tools. @@ -1569,6 +1576,17 @@ 'Sessions are still using this model: {sessions}. Close them first.': '仍有会话在使用此模型:{sessions}。请先关闭它们。', 'That is not possible in the current state.': '当前状态下无法执行此操作。', + 'This model at this context is not offered for this Mac right now.': + '目前不为此 Mac 提供这一上下文大小下的该模型。', + 'This coding agent is not offered for this model.': '该模型不提供这个编程智能体。', + '{cli} is not installed and cannot be installed from here.': '{cli} 尚未安装,也无法从这里安装。', + 'Only {model} is installed on this Mac. Remove it and install another model first.': + '此 Mac 上只安装了 {model}。请先移除它,再安装其他模型。', + // What the endpoint routes answer for the record of the model on this Mac (shown as a toast). + 'This endpoint belongs to the model Codeman runs on this Mac. Change or remove it in the local model window.': + '此端点属于 {name} 在此 Mac 上运行的模型。请在本地模型窗口中更改或移除它。', + 'This endpoint id is reserved for the model Codeman runs on this Mac. Pick another id.': + '此端点标识已保留给 {name} 在此 Mac 上运行的模型。请换一个标识。', 'Coding agents use git and Python. This Mac has no developer tools yet, so macOS will offer to install them the first time an agent needs one.': '编程智能体会用到 git 和 Python。此 Mac 尚未安装开发者工具,因此在智能体首次需要时,macOS 会提示安装。', }); diff --git a/src/web/public/index.html b/src/web/public/index.html index abd35e38..c3c502c0 100644 --- a/src/web/public/index.html +++ b/src/web/public/index.html @@ -3693,6 +3693,9 @@ + +
diff --git a/src/web/public/local-model-ui.js b/src/web/public/local-model-ui.js index 2609ddf2..19964c5f 100644 --- a/src/web/public/local-model-ui.js +++ b/src/web/public/local-model-ui.js @@ -26,15 +26,28 @@ * |-------------|-------------------------------------------------------------------| * | `choose` | idle: the memory bar, the models that fit as a radio group, the | * | | coding agent, "Install on this Mac" | - * | `confirm` | after that button: what is downloaded with sizes, where it goes | + * | `confirm` | after that button: the download as a table with its total, where | + * | | it goes | * | `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, Start a session, 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 - * what the selected model needs (`budgetShare`). Selecting another row moves the fill. + * what the selected model needs at its PEAK (`budgetShare`). Selecting another row moves the + * fill. The legend under it and the line of an installed model use the same words for that + * peak, so the two figures a person meets (what the model takes now, what it can take) are + * never read as a contradiction. + * + * The window is ONE frame for the whole flow: the header and the foot (`#localModelWindowFoot`, + * the buttons) stay where they are and only the body between them changes and scrolls, so a + * button never moves away from under the pointer and is never cut off. + * + * The rows of the chooser are the server's: its pick first, then the other models by download + * size, largest first, which is a column the reader can see. The server's own order of the + * alternatives is its ranking (what it would offer next), not a reading order. * * ## Polling * @@ -53,11 +66,13 @@ * - Names and labels come from the payload: this file holds no list of models or coding * agents and never compares a CLI id with a literal. The id is passed on as data * (`cli` of the install request, the session start). - * - Sizes are printed exactly as sent (`needGb`, `budgetGb`, `downloadGb`, `installGb`, - * `completedGb`, `totalGb`: decimal GB as text). Bytes are never turned into a size here. - * The installed memory alone is `memClassGb`, the size the Mac is sold as. The two byte + * - Sizes are printed exactly as sent (`downloadGb`, `installGb`, `completedGb`, `totalGb`, + * `memoryGb`: decimal GB as text). Bytes are never turned into a size here. The one figure + * that is rounded is the memory estimate beside the bar and in the installed line: it says + * "about", so it is printed in whole GB from the server's own text (`needGb`, `budgetGb`). + * The installed memory alone is `memClassGb`, the size the Mac is sold as. The byte * figures this file does read are the consent total (sent back unchanged as - * `acknowledgedBytes`) and the two ends of a bar. + * `acknowledgedBytes`), the two ends of a bar and the download size the rows are ordered by. * - The consent is bound to the size shown: the install request carries the server's own total * for the row (`Candidate.installBytes`), and a 409 `size-changed` shows the server's new * total and asks again. @@ -85,6 +100,9 @@ const LOCAL_MODEL_MAX_AGE_MS = 60000; /** How often the open window asks where the install stands. */ const LOCAL_MODEL_POLL_MS = 1000; +/** 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']; + /** i18n.js's translator with the `{placeholder}` slots filled, or the English source filled the same way. */ function localModelT(source, vars) { const values = vars && typeof vars === 'object' ? vars : {}; @@ -163,12 +181,25 @@ function localModelInstall(status) { return { state: 'idle', running: false, developerTools: 'unknown', actions: [] }; } -/** The rows of the chooser: the pick first, then the other models that fit. */ +/** + * The rows of the chooser: the server's pick first, then the other models that fit by download + * size, largest first. The download is a column of the list, so the order can be read off the + * screen; the server sends the alternatives in its own ranking, which no column shows. + */ function localModelCandidates(status) { const recommendation = (status && status.recommendation) || {}; if (recommendation.verdict === 'none' || !recommendation.pick) return []; - const rest = Array.isArray(recommendation.alternatives) ? recommendation.alternatives : []; - return [recommendation.pick, ...rest].filter((row) => row && typeof row.modelId === 'string'); + const rest = (Array.isArray(recommendation.alternatives) ? recommendation.alternatives : []).filter( + (row) => row && typeof row.modelId === 'string' + ); + const bytes = (row) => (Number.isFinite(row.downloadBytes) ? row.downloadBytes : 0); + // A stable sort: rows of one size keep the server's order. + const bySize = rest + .map((row, index) => ({ row, index })) + .sort((a, b) => bytes(b.row) - bytes(a.row) || a.index - b.index); + return [recommendation.pick, ...bySize.map((entry) => entry.row)].filter( + (row) => row && typeof row.modelId === 'string' + ); } /** @@ -202,6 +233,29 @@ function localModelGb(value) { return `${value} GB`; } +/** + * A memory estimate in whole GB, from the server's decimal GB text ('36.0' is '36'). Only for + * a figure that says "about": an estimate printed to the decimal reads as a measurement. + * Text that is no number is returned as it is. + */ +function localModelWholeGb(value) { + const number = Number(value); + return typeof value === 'string' && value !== '' && Number.isFinite(number) + ? String(Math.round(number)) + : String(value ?? ''); +} + +/** + * A sentence group split after its first sentence: `[first, rest]`. The first is what a + * headline carries; the rest is read in the body size. Works on the translated text, so both + * the Latin full stop (followed by a space) and the ideographic one end a sentence. + */ +function localModelSentences(text) { + const value = String(text ?? '').trim(); + const match = /^(.+?(?:[.!?](?=\s)|[。!?]))\s*(.+)$/su.exec(value); + return match ? [match[1], match[2]] : [value, '']; +} + /** "about 68 tokens/s", the measured figure without "about", or that there is none. */ function localModelSpeedText(candidate) { const speed = localModelSpeed(candidate.tokensPerSecond); @@ -270,11 +324,20 @@ function localModelBar({ share, role, label, kind, from }) { return bar; } -/** One line of the confirm screen's download list: what, and how large. */ -function localModelSizeRow(label, gb, { strong = false } = {}) { +/** + * One row of the confirm screen's table: what is fetched, how large it is, and under the name + * a second line that says what it is (`note`) and, for a coding agent, the literal command of + * its installer (`command`). A row without a size (the coding agent: its installer decides) + * has none. + */ +function localModelSizeRow(label, { gb, note, command, strong = false } = {}) { const row = localModelEl('div', strong ? 'lm-size-row lm-size-total' : 'lm-size-row'); - row.appendChild(localModelEl('span', 'lm-size-what', label)); - row.appendChild(localModelEl('span', 'lm-size-gb', localModelGb(gb))); + const what = localModelEl('div', 'lm-size-what'); + what.appendChild(localModelEl('span', 'lm-size-name', label)); + if (note) what.appendChild(localModelEl('span', 'lm-size-note', note)); + if (command) what.appendChild(localModelEl('code', 'lm-command', command)); + row.appendChild(what); + if (gb) row.appendChild(localModelEl('span', 'lm-size-gb', localModelGb(gb))); return row; } @@ -343,12 +406,26 @@ Object.assign(CodemanApp.prototype, { this.renderLocalModel(); return; } + this._noteLocalModelInstall(data.install); this._localModelStatus = data; this._localModelState = 'ready'; this._localModelLoadedAt = Date.now(); this.renderLocalModel(); }, + /** + * Called with the `install` object that is about to replace the one on screen. An install + * that was running and is installed now finished in front of the person: the window then + * shows the screen that ends it (see `justInstalled`). Any other state ends that moment. + */ + _noteLocalModelInstall(next) { + const ui = this._localModelWindowState(); + const before = localModelInstall(this._localModelStatus).state; + const after = next && typeof next.state === 'string' ? next.state : 'idle'; + if (after !== 'installed') ui.justInstalled = false; + else if (before === 'installing' && ui.open) ui.justInstalled = true; + }, + _localModelRefused() { return this._localModelState === 'refused' || localModelNotAdmin(); }, @@ -373,6 +450,11 @@ Object.assign(CodemanApp.prototype, { /** The Remove question is open, and whether the downloaded model goes too. */ confirmRemove: false, removeModel: true, + /** + * The install finished while this window was open: the screen that ends it shows + * "Start a session" alone. Stop and Remove belong to the window opened later. + */ + justInstalled: false, }; } return this._localModelUi; @@ -417,7 +499,15 @@ Object.assign(CodemanApp.prototype, { if (!modal || this._localModelRefused()) return; const ui = this._localModelWindowState(); if (ui.open) return; - Object.assign(ui, { open: true, view: 'auto', busy: false, notice: null, expected: null, confirmRemove: false }); + Object.assign(ui, { + open: true, + view: 'auto', + busy: false, + notice: null, + expected: null, + confirmRemove: false, + justInstalled: false, + }); this._forgetLocalModelPaint(); modal.classList.add('active'); // A trap of its own: the window can open on top of App Settings, whose trap must stay @@ -503,11 +593,17 @@ Object.assign(CodemanApp.prototype, { const data = json && typeof json === 'object' && json.data && typeof json.data === 'object' ? json.data : null; let result; if (res?.ok && json?.success === true && data && typeof data.state === 'string') { + this._noteLocalModelInstall(data); if (this._localModelStatus) this._localModelStatus = { ...this._localModelStatus, install: data }; result = { ok: true, view: data }; } else if (res?.status === 409 && data) { ui.notice = { message: data.message, sessions: data.sessions }; result = { ok: false, conflict: data }; + } else if (res && data && data.message && typeof data.message.text === 'string') { + // A refusal the server names (a 400, a full disk) carries its sentence with the slots + // apart, like a 409, so it is printed in the window's own language. + ui.notice = { message: data.message }; + result = { ok: false }; } else { ui.notice = res ? { refused: true, detail: typeof json?.error === 'string' ? json.error : '' } @@ -732,9 +828,12 @@ Object.assign(CodemanApp.prototype, { const meter = body.querySelector('.lm-meter'); if (frame === this._localModelFrameKey && (screen === 'progress' || screen === 'paused') && meter) { - meter.replaceWith(this._localModelDownloadMeter(screen, last)); - this._settleLocalModelBars(body); - return; + const moved = this._localModelDownloadMeter(screen, last); + if (moved) { + meter.replaceWith(moved); + this._settleLocalModelBars(body); + return; + } } this._localModelFrameKey = frame; @@ -742,9 +841,17 @@ Object.assign(CodemanApp.prototype, { const active = document.activeElement; const inside = !!(modal && active && modal.contains(active)); const focusKey = inside && active.dataset ? active.dataset.lmKey : undefined; - const scrolled = body.scrollTop; + const scrolled = body.dataset.screen === screen ? body.scrollTop : 0; + const parts = this._buildLocalModelScreen(screen, last); + // The buttons (or the Remove question) are the last part a screen builds. They go into + // the foot of the frame, outside the part that scrolls, so they stay in one place. + const foot = document.getElementById('localModelWindowFoot'); + const tail = parts[parts.length - 1]; + const actions = + foot && tail && tail.classList && (tail.classList.contains('lm-foot') || tail.classList.contains('lm-remove')); body.dataset.screen = screen; - body.replaceChildren(...this._buildLocalModelScreen(screen, last)); + body.replaceChildren(...(actions ? parts.slice(0, -1) : parts)); + if (foot) foot.replaceChildren(...(actions ? [tail] : [])); body.scrollTop = scrolled; this._settleLocalModelBars(body); if (inside || active === document.body) this._focusLocalModelWindow(focusKey); @@ -776,7 +883,8 @@ Object.assign(CodemanApp.prototype, { const modal = document.getElementById('localModelModal'); const body = document.getElementById('localModelWindowBody'); if (!modal || !body) return; - const keyed = [...body.querySelectorAll('[data-lm-key]')].filter((el) => !el.disabled); + // The body and the foot: a screen's controls live in both. + const keyed = [...modal.querySelectorAll('[data-lm-key]')].filter((el) => !el.disabled); const target = (key && keyed.find((el) => el.dataset.lmKey === key && el.getAttribute('tabindex') !== '-1')) || keyed.find((el) => el.dataset.lmKey === 'model' && el.getAttribute('aria-checked') === 'true') || @@ -836,27 +944,23 @@ Object.assign(CodemanApp.prototype, { ); }, - /** One model row: the radio of the chooser, or the same line as plain text on the confirm screen. */ - _localModelRow(candidate, { checked, interactive, tabStop }) { + /** One model row of the chooser: a radio with the name, the tag, the speed and the download. */ + _localModelRow(candidate, { checked, tabStop }) { const row = localModelEl('div', 'lm-model'); - if (interactive) { - row.setAttribute('role', 'radio'); - row.setAttribute('aria-checked', checked ? 'true' : 'false'); - row.setAttribute('tabindex', tabStop ? '0' : '-1'); - row.dataset.lmKey = 'model'; - row.dataset.modelId = candidate.modelId; - row.appendChild(localModelEl('span', 'lm-radio')); - } else { - row.classList.add('lm-model-static'); - } + row.setAttribute('role', 'radio'); + row.setAttribute('aria-checked', checked ? 'true' : 'false'); + row.setAttribute('tabindex', tabStop ? '0' : '-1'); + row.dataset.lmKey = 'model'; + row.dataset.modelId = candidate.modelId; + row.appendChild(localModelEl('span', 'lm-radio')); if (checked) row.classList.add('lm-model-on'); row.appendChild(localModelEl('span', 'lm-model-name', String(candidate.modelLabel || candidate.modelId))); const tagSlot = localModelEl('span', 'lm-model-tag'); const label = candidate.tag && typeof candidate.tag.label === 'string' ? candidate.tag.label : ''; if (label) { const tag = localModelEl('span', 'lm-tag', localModelT(label)); - // The pick, and a row run on a Mac like this one, take the accent. Every other tag is quiet. - if (candidate.tag.kind === 'best' || candidate.tag.kind === 'tested') tag.classList.add('lm-tag-accent'); + // The tags of the row the window opens on take the accent. Every other tag is quiet. + if (LOCAL_MODEL_ACCENT_TAGS.includes(candidate.tag.kind)) tag.classList.add('lm-tag-accent'); tagSlot.appendChild(tag); } row.appendChild(tagSlot); @@ -878,7 +982,8 @@ Object.assign(CodemanApp.prototype, { // The memory bar: what this Mac can give a model, filled to what the selected one needs. const meter = localModelEl('div', 'lm-meter'); const head = localModelEl('div', 'lm-meter-head'); - const sizes = { need: candidate.needGb, budget: recommendation.budgetGb }; + // An estimate, so whole GB: "about 36 of 38", never an estimate to the decimal. + const sizes = { need: localModelWholeGb(candidate.needGb), budget: localModelWholeGb(recommendation.budgetGb) }; head.appendChild(localModelEl('span', 'lm-figure', localModelT('About {need} of {budget} GB of memory', sizes))); // A formula's figure says so, once. A wired limit and the runtime's own figure are facts. if (recommendation.budgetSource === 'estimated') { @@ -897,7 +1002,14 @@ Object.assign(CodemanApp.prototype, { ); const legend = localModelEl('div', 'lm-legend'); const context = localModelCount(candidate.context); - legend.appendChild(localModelEl('span', '', localModelT('The model at {context} tokens of context', { context }))); + // The fill is the model at its PEAK. The installed line uses the same words for it. + legend.appendChild( + localModelEl( + 'span', + '', + localModelT('The model at its peak, with all {context} tokens of context in use', { context }) + ) + ); legend.appendChild(localModelEl('span', '', localModelT('What stays free'))); meter.appendChild(legend); parts.push(meter); @@ -925,7 +1037,7 @@ Object.assign(CodemanApp.prototype, { group.setAttribute('aria-labelledby', 'localModelListLabel'); for (const row of candidates) { const checked = row === candidate; - const el = this._localModelRow(row, { checked, interactive: true, tabStop: checked }); + const el = this._localModelRow(row, { checked, tabStop: checked }); el.addEventListener('click', () => this.selectLocalModel(row.modelId)); group.appendChild(el); } @@ -962,7 +1074,7 @@ Object.assign(CodemanApp.prototype, { }); runs.appendChild(select); const hints = []; - if (harness.installed === false) hints.push(localModelT('Not on this Mac yet. It is installed for you.')); + if (harness.installed === false) hints.push(localModelT('Not on this Mac yet. Codeman installs it for you.')); if (harness.slowFirstTurn === true) hints.push(localModelT('The first answer can take minutes.')); if (hints.length) runs.appendChild(localModelEl('span', 'lm-runs-hint', hints.join(' '))); parts.push(runs); @@ -1014,6 +1126,13 @@ Object.assign(CodemanApp.prototype, { this.selectLocalModel(candidates[next].modelId); }, + /** + * The confirm screen: the download as a table. One row per thing that is fetched (the chosen + * model, the program that runs it while it is missing, the coding agent while it is + * missing), each with what it is in a second line, and the total under them in the strong + * weight. The table IS the screen; around it only where it goes and, on a Mac without the + * developer tools, the one sentence about them. + */ _buildLocalModelConfirm() { const ui = this._localModelWindowState(); const status = this._localModelStatus; @@ -1023,51 +1142,48 @@ Object.assign(CodemanApp.prototype, { const hw = this._localModelHardwareEl(); if (hw) parts.push(hw); - // The list collapses to the chosen row. - const chosen = localModelEl('div', 'lm-list'); - const one = localModelEl('div', 'lm-models'); - one.appendChild(this._localModelRow(candidate, { checked: true, interactive: false })); - chosen.appendChild(one); - parts.push(chosen); const cli = String(harness.cliLabel || harness.cli); - const runs = localModelT('Runs with {cli}, at {context} tokens of context.', { + const sizes = localModelEl('div', 'lm-sizes'); + sizes.appendChild(localModelEl('h4', 'lm-heading', localModelT('What will be downloaded'))); + + // The list collapses to the chosen row: it is the first row of the table. + const about = localModelT('At {context} tokens of context, with {cli}', { cli, context: localModelCount(candidate.context), }); - parts.push(localModelEl('p', 'lm-aside', runs)); - - // What is downloaded, with sizes: the model, the runtime while it is missing, the total. - const sizes = localModelEl('div', 'lm-sizes'); - sizes.appendChild(localModelEl('h4', 'lm-heading', localModelT('What is downloaded'))); - sizes.appendChild(localModelSizeRow(String(candidate.modelLabel || candidate.modelId), candidate.downloadGb)); + sizes.appendChild( + localModelSizeRow(String(candidate.modelLabel || candidate.modelId), { gb: candidate.downloadGb, note: about }) + ); const runtime = install.runtime || {}; if (runtime.installed !== true && Number(runtime.downloadBytes) > 0 && runtime.downloadGb) { - const what = localModelT('Ollama {version}, the program that runs the model', { version: runtime.version }); - sizes.appendChild(localModelSizeRow(what, runtime.downloadGb)); + sizes.appendChild( + localModelSizeRow(localModelT('Ollama {version}', { version: runtime.version }), { + gb: runtime.downloadGb, + note: localModelT('The program that runs the model'), + }) + ); + } + // The coding agent, when it is missing: its own installer fetches it, with this literal command. + if (harness.installed === false) { + const note = harness.installCommand + ? localModelT('Not on this Mac yet. Its own installer fetches it with this command:') + : localModelT('Not on this Mac yet. Its own installer fetches it.'); + sizes.appendChild( + localModelSizeRow(cli, { note, command: harness.installCommand ? String(harness.installCommand) : '' }) + ); } const expected = ui.expected && ui.expected.modelId === candidate.modelId ? ui.expected : null; const total = expected ? expected.gb : candidate.installGb; - if (total) sizes.appendChild(localModelSizeRow(localModelT('Total'), total, { strong: true })); + if (total) sizes.appendChild(localModelSizeRow(localModelT('Total'), { gb: total, strong: true })); parts.push(sizes); - // The coding agent, when it is missing: the literal command its installer runs. - if (harness.installed === false) { - const agent = localModelEl('div', 'lm-agent'); - const lead = harness.installCommand - ? localModelT('{cli} is not on this Mac yet. Its own installer is run with this command:', { cli }) - : localModelT('{cli} is not on this Mac yet. It is installed with its own installer.', { cli }); - agent.appendChild(localModelEl('p', 'lm-aside', lead)); - if (harness.installCommand) agent.appendChild(localModelEl('code', 'lm-command', String(harness.installCommand))); - parts.push(agent); - } - - const where = localModelEl('div', 'lm-where'); if (install.folder) { - where.appendChild(localModelEl('p', 'lm-aside', localModelT('The model and the program that runs it go into:'))); - where.appendChild(localModelEl('code', 'lm-command', String(install.folder))); + const where = localModelEl('p', 'lm-where'); + where.appendChild(localModelEl('span', '', localModelT('Goes into'))); + where.appendChild(document.createTextNode(' ')); + where.appendChild(localModelEl('code', 'lm-path', String(install.folder))); + parts.push(where); } - where.appendChild(localModelEl('p', 'lm-aside', localModelT('No administrator password.'))); - parts.push(where); const tools = this._localModelDeveloperToolsEl(); if (tools) parts.push(tools); @@ -1075,6 +1191,7 @@ Object.assign(CodemanApp.prototype, { if (notice) parts.push(notice); const foot = localModelEl('div', 'lm-foot'); + foot.appendChild(localModelEl('p', 'lm-foot-note', localModelT('No administrator password.'))); const buttons = localModelEl('div', 'lm-buttons'); buttons.appendChild( localModelButton(localModelT('Back'), { key: 'back', disabled: ui.busy, onClick: () => this.chooseLocalModel() }) @@ -1160,22 +1277,24 @@ Object.assign(CodemanApp.prototype, { }, /** - * The download bar and its figure: "6.2 of 23.1 GB", then the speed and the time left. A step - * that downloads nothing names itself instead, with the bar full once the model is in. + * The download bar and its figure: "6.2 of 23.1 GB", then the speed and the time left. The + * bar means ONE thing, the model download: while the runtime is fetched (seconds) it stands + * at the start with the size that is coming, it fills while the model arrives, and once the + * model is in it is gone (`null`): the step list carries the rest. */ _localModelDownloadMeter(screen, last) { const install = localModelInstall(this._localModelStatus); + if (install.step !== 'runtime' && install.step !== 'model') return null; const paused = screen === 'paused'; - const progress = install.progress; + const progress = install.step === 'model' ? install.progress : null; const meter = localModelEl('div', 'lm-meter'); const head = localModelEl('div', 'lm-meter-head'); - const steps = Array.isArray(install.steps) ? install.steps : []; - const now = localModelStepLabel(install.step, String(install.cliLabel || '')); - let share = steps.indexOf(install.step) > steps.indexOf('model') ? 1 : 0; + const label = localModelT('Download of the model'); + let share = 0; if (progress && Number(progress.totalBytes) > 0) { share = Number(progress.completedBytes) / Number(progress.totalBytes); const sizes = { done: progress.completedGb, total: progress.totalGb }; - head.appendChild(localModelEl('span', 'lm-figure', localModelT('{done} of {total} GB', sizes))); + head.appendChild(localModelEl('span', 'lm-figure lm-figure-count', localModelT('{done} of {total} GB', sizes))); const left = localModelTimeLeft(progress.secondsLeft); let rate = ''; if (!paused && progress.mbPerSecond && left) { @@ -1185,10 +1304,15 @@ Object.assign(CodemanApp.prototype, { } if (rate) head.appendChild(localModelEl('span', 'lm-figure-aside', rate)); } else { - head.appendChild(localModelEl('span', 'lm-figure', now)); + // Nothing of the model has arrived yet: the size that is coming, from the row it was chosen on. + const row = localModelCandidates(this._localModelStatus).find( + (candidate) => install.model && candidate.modelId === install.model.id + ); + const text = row && row.downloadGb ? localModelT('0 of {total} GB', { total: row.downloadGb }) : label; + head.appendChild(localModelEl('span', 'lm-figure lm-figure-count', text)); } meter.appendChild(head); - meter.appendChild(localModelBar({ share, role: 'progressbar', kind: 'download', from: last.download, label: now })); + meter.appendChild(localModelBar({ share, role: 'progressbar', kind: 'download', from: last.download, label })); return meter; }, @@ -1204,8 +1328,9 @@ Object.assign(CodemanApp.prototype, { : localModelT('Installing {model}', { model }); parts.push(localModelEl('p', 'lm-title', title)); - // The download bar takes the place of the memory bar. - parts.push(this._localModelDownloadMeter(screen, last)); + // The download bar takes the place of the memory bar, for as long as something downloads. + const meter = this._localModelDownloadMeter(screen, last); + if (meter) parts.push(meter); const list = this._localModelStepsEl(install, { stopped: paused }); if (list) parts.push(list); @@ -1215,9 +1340,18 @@ Object.assign(CodemanApp.prototype, { return parts; } - const note = paused - ? localModelT('Nothing is downloaded until you continue.') - : localModelT('You can close this window. The download continues while Codeman is running.'); + // What waits or goes on is the download while there is one, the install after it. + const downloading = !!meter; + let note; + if (paused) { + note = downloading + ? localModelT('The download waits until you continue.') + : localModelT('The install waits until you continue.'); + } else { + note = downloading + ? localModelT('You can close this window. The download continues while Codeman is running.') + : localModelT('You can close this window. The install continues while Codeman is running.'); + } parts.push(localModelEl('p', 'lm-aside', note)); const notice = this._localModelNoticeEl(); if (notice) parts.push(notice); @@ -1269,6 +1403,7 @@ Object.assign(CodemanApp.prototype, { /** * The ONE action that helps after an error, as a button, or null when the server does not * offer it right now. `error.next` names it; `install.actions` says whether it is possible. + * It is the main button of the screen, except a removal, which never takes the accent. */ _localModelNextButton(install, error) { const ui = this._localModelWindowState(); @@ -1289,7 +1424,12 @@ Object.assign(CodemanApp.prototype, { return primary(localModelT('Start'), () => void this.setLocalModelRuntime('start')); } if (error.next === 'remove' && can('remove')) { - return primary(localModelT('Remove'), () => void this.removeLocalModel()); + // The one action that helps, and still a removal: quiet here, red only in its question. + return localModelButton(localModelT('Remove'), { + key: 'primary', + disabled: ui.busy, + onClick: () => void this.removeLocalModel(), + }); } if (error.next === 'next-model' && can('install')) { const candidates = localModelCandidates(this._localModelStatus); @@ -1303,10 +1443,15 @@ Object.assign(CodemanApp.prototype, { return null; }, - /** An error as the window shows it: the server's sentence, and its raw detail folded away. */ + /** + * An error as the window shows it: the first sentence of the server's text as the headline, + * what follows it in the body size, and the raw detail folded away. + */ _localModelErrorEl(error) { const box = localModelEl('div', 'lm-error'); - box.appendChild(localModelEl('p', 'lm-title', localModelMessage(error.message))); + const [headline, rest] = localModelSentences(localModelMessage(error.message)); + box.appendChild(localModelEl('p', 'lm-title', headline)); + if (rest) box.appendChild(localModelEl('p', 'lm-lead', rest)); if (typeof error.detail === 'string' && error.detail) { const details = localModelEl('details', 'lm-details'); details.appendChild(localModelEl('summary', '', localModelT('Details'))); @@ -1351,44 +1496,70 @@ Object.assign(CodemanApp.prototype, { return parts; }, + /** + * The line of facts under an installed model: the context, what the model takes now (what + * the runtime reported when the install checked it) and, in the words of the chooser's + * legend, what it can take at its peak (the row's own estimate, so the two figures a person + * meets are told apart instead of read as a contradiction). + */ + _localModelFactsLine(install) { + const loaded = install.loaded || {}; + const context = localModelCount(loaded.context ?? install.context); + const row = localModelCandidates(this._localModelStatus).find( + (candidate) => + install.model && + candidate.modelId === install.model.id && + candidate.context === (loaded.context ?? install.context) + ); + const facts = { context, memory: loaded.memoryGb, peak: row ? localModelWholeGb(row.needGb) : '' }; + const sentences = []; + if (context) sentences.push(localModelT('Context {context} tokens.', facts)); + if (loaded.memoryGb && facts.peak) { + sentences.push( + localModelT( + 'Takes {memory} GB of memory now, and up to about {peak} GB at its peak, with all of the context in use.', + facts + ) + ); + } else if (loaded.memoryGb) { + sentences.push(localModelT('Takes {memory} GB of memory now.', facts)); + } + if (loaded.memoryGb && loaded.onGpu === true) sentences.push(localModelT('All of it is on the GPU.')); + return sentences.join(' '); + }, + _buildLocalModelInstalled() { const ui = this._localModelWindowState(); const install = localModelInstall(this._localModelStatus); const model = install.model ? String(install.model.label || install.model.id || '') : ''; + const error = install.error; const parts = []; - const title = install.running - ? localModelT('{model} is ready on this Mac.', { model }) - : localModelT('{model} is installed on this Mac.', { model }); - parts.push(localModelEl('p', 'lm-title', title)); - // The measured facts, in one line: what the runtime reported when the install checked it. - const loaded = install.loaded || {}; - const facts = { context: localModelCount(loaded.context ?? install.context), memory: loaded.memoryGb }; - let line = ''; - if (loaded.memoryGb && loaded.onGpu === true) { - line = localModelT('Context {context} tokens. Takes {memory} GB of memory when loaded, all on the GPU.', facts); - } else if (loaded.memoryGb) { - line = localModelT('Context {context} tokens. Takes {memory} GB of memory when loaded.', facts); - } else if (facts.context) { - line = localModelT('Context {context} tokens.', facts); + // Something blocks a session: that sentence is the headline, not "is ready". Its one + // action is the main button below. + if (error) { + parts.push(this._localModelErrorEl(error)); + } else { + const title = install.running + ? localModelT('{model} is ready on this Mac.', { model }) + : localModelT('{model} is installed on this Mac.', { model }); + parts.push(localModelEl('p', 'lm-title', title)); } + const state = localModelEl('div', 'lm-state'); - const dot = localModelEl('span', install.running ? 'lm-dot lm-dot-on' : 'lm-dot'); + // Green says "a session can be started". A model that is blocked is not that. + const dot = localModelEl('span', install.running && !error ? 'lm-dot lm-dot-on' : 'lm-dot'); dot.setAttribute('aria-hidden', 'true'); state.appendChild(dot); - state.appendChild( - localModelEl('span', 'lm-state-text', install.running ? localModelT('Running') : localModelT('Stopped')) - ); + const running = install.running ? localModelT('Running') : localModelT('Stopped'); + 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) })); + const line = this._localModelFactsLine(install); if (line) about.push(line); if (about.length) parts.push(localModelEl('p', 'lm-aside', about.join(' '))); - // Something blocks a session: the server's sentence. Its one action is the main button below. - const error = install.error; - if (error) parts.push(this._localModelErrorEl(error)); - if (ui.confirmRemove) { parts.push(this._localModelRemoveEl()); return parts; @@ -1400,9 +1571,12 @@ Object.assign(CodemanApp.prototype, { const can = (action) => install.actions.includes(action); const next = error ? this._localModelNextButton(install, error) : null; + // 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; const foot = localModelEl('div', 'lm-foot'); const buttons = localModelEl('div', 'lm-buttons'); - if (can('remove') && error?.next !== 'remove') { + if (!ending && can('remove') && error?.next !== 'remove') { buttons.appendChild( localModelButton(localModelT('Remove'), { key: 'remove', @@ -1411,7 +1585,7 @@ Object.assign(CodemanApp.prototype, { }) ); } - if (can('stop')) { + if (!ending && can('stop')) { buttons.appendChild( localModelButton(localModelT('Stop'), { key: 'stop', diff --git a/src/web/public/mobile.css b/src/web/public/mobile.css index cfdff19c..fc8fdd63 100644 --- a/src/web/public/mobile.css +++ b/src/web/public/mobile.css @@ -1870,14 +1870,26 @@ html.mobile-init .file-browser-panel { } .local-model-window > .lm-body { - flex: 1; - padding: 1rem 1rem calc(1rem + var(--safe-area-bottom)); + padding: 1rem; } /* The window fills the screen, so its actions sit at the bottom, under the thumb. */ - .lm-body > .lm-foot, - .lm-body > .lm-remove { - margin-top: auto; + .local-model-window > .lm-foot-slot { + padding: 0 1rem calc(1rem + var(--safe-area-bottom)); + } + + /* The short screens (progress, paused, a failure, installed, nothing fits) + hold a few lines. On a screen this tall they sit right above the buttons, + where the eye and the thumb already are, not at the top with half a + screen of nothing between the two. */ + .local-model-window > .lm-body:is( + [data-screen='progress'], + [data-screen='paused'], + [data-screen='failed'], + [data-screen='installed'], + [data-screen='none'] + ) { + justify-content: flex-end; } /* Ralph wizard responsive */ @@ -4332,14 +4344,6 @@ html[data-session-list="sidebar"] .session-sidebar .session-tab .tab-close { grid-column: 3; } - .lm-model-static .lm-model-name { - grid-column: 1 / span 2; - } - - .lm-model-static .lm-model-speed { - grid-column: 1 / span 2; - } - .lm-foot { flex-direction: column; align-items: stretch; diff --git a/src/web/public/styles.css b/src/web/public/styles.css index 24494294..42f7d2d4 100644 --- a/src/web/public/styles.css +++ b/src/web/public/styles.css @@ -22315,11 +22315,16 @@ html[data-tile-anim="soft"] .tile-grid-ghosts.tile-grid-ghosts--release:not(.til row carries the accent on its reading edge and a faint fill. Theme tokens only, never a black or white alpha (those vanish on the light - skins). The bar's track is `--control-border-hover`: the fill of the app's - inputs (`--bg-input`) is the window's own surface on the dark skins, so the - part of the bar that stays free could not be seen. Nothing here is keyed on - a CLI or a model id. The only motion is the bar's width, and it stops under - prefers-reduced-motion. + skins). The bar's track is `--text-muted`: the part of the bar that stays + FREE is half of what the bar says, so it must be seen against the window + (about 3:1 or better on every skin; the fill of the app's inputs, + `--bg-input`, is the window's own surface on the dark skins, and a border + token is 1.5:1). Nothing here is keyed on a CLI or a model id. The only + motion is the bar's width, and it stops under prefers-reduced-motion. + + The window is ONE frame for the whole flow: a fixed height on desktop, the + header on top, the buttons in a foot of their own (`.lm-foot-slot`) and the + body scrolling between them. A click never moves the next button away. ═══════════════════════════════════════════════════════════════ */ /* The welcome button: one more chip, on its own line under the launchers. */ @@ -22337,13 +22342,16 @@ html[data-tile-anim="soft"] .tile-grid-ghosts.tile-grid-ghosts--release:not(.til flex-shrink: 0; } -/* The window: App Settings' chrome (`.modal`, `.modal-header`, `.modal-close`) - at a width of its own, the body scrolling under a fixed header. */ +/* The window: App Settings' chrome (`.modal`, `.modal-header`, the boxed + close button) at a width of its own. One frame for every screen: the height + is fixed, so the header, the close button and the main button stay where + they are from the chooser to "Start a session". */ .modal-content.local-model-window { display: flex; flex-direction: column; width: min(640px, 94vw); max-width: min(640px, 94vw); + height: min(600px, 88vh); max-height: 88vh; } @@ -22351,8 +22359,36 @@ html[data-tile-anim="soft"] .tile-grid-ghosts.tile-grid-ghosts--release:not(.til flex-shrink: 0; } +/* App Settings' close button: a 30px box, not the bare glyph of a plain modal. */ +.local-model-window > .modal-header .modal-close { + display: inline-flex; + align-items: center; + justify-content: center; + width: 30px; + height: 30px; + padding: 0; + border-radius: 9px; + font-size: 1.2rem; + color: var(--text-dim); + background: transparent; + transition: + background 0.15s ease, + color 0.15s ease, + transform 0.1s ease; +} + +.local-model-window > .modal-header .modal-close:hover { + background: var(--bg-hover); + color: var(--text); +} + +.local-model-window > .modal-header .modal-close:active { + transform: scale(0.94); +} + .local-model-window > .lm-body { display: flex; + flex: 1; flex-direction: column; gap: 1.05rem; min-height: 0; @@ -22361,6 +22397,37 @@ html[data-tile-anim="soft"] .tile-grid-ghosts.tile-grid-ghosts--release:not(.til text-align: start; } +/* The short screens (progress, paused, a failure, installed, nothing fits) + hold a few lines in a frame sized for the chooser: they sit in the middle of + it, a status in its own room, not a heading with a void under it. */ +.local-model-window + > .lm-body:is( + [data-screen='progress'], + [data-screen='paused'], + [data-screen='failed'], + [data-screen='installed'], + [data-screen='none'] + ) { + justify-content: center; + padding-bottom: 2.2rem; +} + +/* The foot of the frame: the buttons of the screen (or the Remove question), + outside the part that scrolls. */ +.local-model-window > .lm-foot-slot { + flex-shrink: 0; + padding: 0 1.25rem 1.15rem; + text-align: start; +} + +.local-model-window > .lm-foot-slot:empty { + display: none; +} + +.lm-foot-slot p { + margin: 0; +} + .lm-body p, .lm-body h4 { margin: 0; @@ -22437,6 +22504,11 @@ html[data-tile-anim="soft"] .tile-grid-ghosts.tile-grid-ghosts--release:not(.til font-weight: 680; letter-spacing: -0.012em; color: var(--text); +} + +/* Only the figure that ticks (the download) holds its digits in fixed cells. + In a sentence fixed cells leave a hole around every 1. */ +.lm-figure-count { font-variant-numeric: tabular-nums; } @@ -22449,7 +22521,7 @@ html[data-tile-anim="soft"] .tile-grid-ghosts.tile-grid-ghosts--release:not(.til .lm-bar { height: 10px; border-radius: 999px; - background: var(--control-border-hover); + background: var(--text-muted); overflow: hidden; } @@ -22464,9 +22536,9 @@ html[data-tile-anim="soft"] .tile-grid-ghosts.tile-grid-ghosts--release:not(.til display: flex; justify-content: space-between; gap: 1rem; - font-size: 0.68rem; + font-size: 0.7rem; line-height: 1.4; - color: var(--text-muted); + color: var(--text-dim); } .lm-legend > :last-child { @@ -22507,8 +22579,8 @@ html[data-tile-anim="soft"] .tile-grid-ghosts.tile-grid-ghosts--release:not(.til .lm-caption { padding-bottom: 0.3rem; - font-size: 0.68rem; - color: var(--text-muted); + font-size: 0.7rem; + color: var(--text-dim); } .lm-caption-label { @@ -22551,15 +22623,6 @@ html[data-tile-anim="soft"] .tile-grid-ghosts.tile-grid-ghosts--release:not(.til background: rgba(var(--accent-rgb), 0.075); } -/* The chosen row of the confirm screen: the same line, nothing to press. */ -.lm-model-static { - cursor: default; -} - -.lm-model-static .lm-model-name { - grid-column: 1 / span 2; -} - .lm-radio { box-sizing: border-box; width: 14px; @@ -22674,60 +22737,79 @@ html[data-tile-anim="soft"] .tile-grid-ghosts.tile-grid-ghosts--release:not(.til font-size: 0.74rem; } -/* ── the confirm screen ───────────────────────────────────────────── */ +/* ── the confirm screen: the download as a table ──────────────────── */ +/* One row per thing that is fetched: its name in the text colour with what it + is underneath, its size at the right edge, and the total under a rule in the + strong weight. The table is the screen; nothing else on it competes. */ .lm-sizes { display: flex; flex-direction: column; } .lm-sizes .lm-heading { - margin-bottom: 0.3rem; + margin-bottom: 0.35rem; + font-size: 0.84rem; + font-weight: 650; + letter-spacing: -0.005em; + color: var(--text); } .lm-size-row { display: flex; align-items: baseline; justify-content: space-between; - gap: 1rem; - padding: 0.28rem 0; - font-size: 0.8rem; - color: var(--text-dim); + gap: 1.2rem; + padding: 0.6rem 0; + border-top: 1px solid var(--border); } .lm-size-what { + display: flex; + flex-direction: column; + gap: 0.18rem; min-width: 0; +} + +.lm-size-name { + font-size: 0.86rem; + font-weight: 600; + color: var(--text); + overflow-wrap: anywhere; +} + +.lm-size-note { + font-size: 0.74rem; + line-height: 1.45; + color: var(--text-dim); overflow-wrap: anywhere; } .lm-size-gb { flex-shrink: 0; + font-size: 0.86rem; + font-weight: 560; color: var(--text); font-variant-numeric: tabular-nums; white-space: nowrap; } .lm-size-total { - margin-top: 0.25rem; - padding-top: 0.55rem; - border-top: 1px solid var(--border); - font-weight: 650; - color: var(--text); + padding-top: 0.75rem; + border-top-color: var(--text-muted); } -.lm-agent, -.lm-where { - display: flex; - flex-direction: column; - gap: 0.35rem; +.lm-size-total .lm-size-name, +.lm-size-total .lm-size-gb { + font-size: 1.06rem; + font-weight: 680; + letter-spacing: -0.012em; } -/* A literal command and a folder: text to read and to copy, so it is set in - the mono face and selects whole. */ +/* A literal command: text to read and to copy, so it is set in the mono face + and selects whole. It sits in its row, under the sentence that names it. */ .lm-command { display: block; - padding: 0.42rem 0.6rem; - border-radius: 6px; - background: var(--bg-input); + margin-top: 0.15rem; color: var(--text); font-family: var(--font-mono); font-size: 0.72rem; @@ -22736,6 +22818,21 @@ html[data-tile-anim="soft"] .tile-grid-ghosts.tile-grid-ghosts--release:not(.til user-select: all; } +/* Where it goes: one quiet line under the table. */ +.lm-where { + font-size: 0.74rem; + line-height: 1.5; + color: var(--text-dim); + overflow-wrap: anywhere; +} + +.lm-path { + color: var(--text); + font-family: var(--font-mono); + font-size: 0.72rem; + user-select: all; +} + /* ── progress: the steps ARE a sequence, so they are a list ───────── */ .lm-steps { display: flex; @@ -22853,7 +22950,7 @@ html[data-tile-anim="soft"] .tile-grid-ghosts.tile-grid-ghosts--release:not(.til } .lm-details { - margin-top: 0.55rem; + margin-top: 0.2rem; font-size: 0.74rem; color: var(--text-dim); } @@ -22882,6 +22979,13 @@ html[data-tile-anim="soft"] .tile-grid-ghosts.tile-grid-ghosts--release:not(.til overflow-wrap: anywhere; } +/* An error: its first sentence as the headline, the rest in the body size. */ +.lm-error { + display: flex; + flex-direction: column; + gap: 0.4rem; +} + .lm-remove { display: flex; flex-direction: column; @@ -22922,9 +23026,9 @@ html[data-tile-anim="soft"] .tile-grid-ghosts.tile-grid-ghosts--release:not(.til .lm-foot-note { min-width: 0; - font-size: 0.72rem; + font-size: 0.74rem; line-height: 1.45; - color: var(--text-muted); + color: var(--text-dim); } .lm-buttons { diff --git a/test/local-model-recommend.test.ts b/test/local-model-recommend.test.ts index 4a6ee5f8..71726247 100644 --- a/test/local-model-recommend.test.ts +++ b/test/local-model-recommend.test.ts @@ -604,10 +604,11 @@ describe('the MacBook Pro (Mac17,13: Apple M5 Pro, 48 GB)', () => { tested: false, // 13.6 x 307 / 100. tokensPerSecond: 41.8, - tag: { kind: 'best', label: 'Best fit' }, + // It only just fits, and the tag says so. + tag: { kind: 'best-tight', label: 'Best fit, tight' }, }); expect(result.headline).toEqual({ text: 'Best fit for this Mac' }); - expect(result.notes).toEqual([{ text: 'Not yet tested by us on a Mac like this.' }]); + expect(result.notes).toEqual([{ text: 'Codeman has not run this on a Mac like this yet.' }]); expect(stringsIn(result).join(' ')).not.toMatch(/recommended/i); }); @@ -651,7 +652,7 @@ describe('the Mac mini (Mac16,10: Apple M4, 16 GB)', () => { verdict: 'none', reason: 'memory', headline: { - text: 'This Mac has {memory} GB of memory. That is too little to run a model that can drive a coding agent, so Codeman recommends none here.', + text: 'With {memory} GB of memory, this Mac cannot run a model that can drive a coding agent.', vars: { memory: '16' }, }, notes: [], @@ -818,7 +819,7 @@ describe('verdict none', () => { it('answers so for an Intel Mac, whose sysctl has no arm64 flag', () => { const hw = without(mac(32, intelChip, { arch: 'x64' }), 'arm64', 'translated', 'wiredLimitMb'); const result = recommend(hw, STOCK, ALL_INSTALLED); - expectNone(result, 'intel', 'This Mac has an Intel processor. Local coding models need Apple silicon.'); + expectNone(result, 'intel', 'Local coding models need Apple silicon, and this Mac has an Intel processor.'); expect(result.budgetBytes).toBe(0); expect(result.budgetGb).toBe('0.0'); }); @@ -868,7 +869,7 @@ describe('verdict none', () => { STOCK, ALL_INSTALLED ); - expectNone(result, 'macos', 'This Mac runs macOS {version}. Local models need macOS {minimum} or newer.'); + expectNone(result, 'macos', 'Local models need macOS {minimum} or newer, and this Mac runs macOS {version}.'); expect(result.headline.vars).toEqual({ version: '13.6.7', minimum: '14' }); // The budget is still reported: the Mac itself is able. expect(result.budgetBytes).toBe(35 * GIB); @@ -898,7 +899,7 @@ describe('verdict none', () => { expectNone( result, 'memory', - 'This Mac has {memory} GB of memory. That is too little to run a model that can drive a coding agent, so Codeman recommends none here.' + 'With {memory} GB of memory, this Mac cannot run a model that can drive a coding agent.' ); expect(result.headline.text).toBe(LOCAL_MODEL_LABELS.noneMemory); // The size the Mac is sold as (what About This Mac shows), not 8 x 2^30 bytes as 8.6 decimal GB. @@ -931,7 +932,10 @@ describe('verdict none', () => { // The need is the smallest one at a context a usable harness works at (the 9B at 65536), // not the 9B at 32768, which fits and which only the gated harness could use. expect(result.headline.vars).toEqual({ budget: '16.1', need: '17.0' }); - expect(result.headline.text).toMatch(/^Codeman estimates/); + // Both figures are said as estimates ("about"), in one sentence. + expect(result.headline.text).toBe( + 'This Mac can give a model about {budget} GB of memory, and the smallest model a coding agent can work with needs about {need} GB.' + ); expect(result.headline.text).not.toContain('{memory}'); }); @@ -1045,24 +1049,24 @@ describe('verdict none', () => { // 12 GiB free: even the smallest download (6.55 GB) leaves 5.9 GiB. const hw = mac(48, 'Apple M4 Max', { freeDiskBytes: 12 * GIB, freeDiskGb: formatGb(12 * GIB) }); const result = recommend(hw, STOCK, ALL_INSTALLED); + // One sentence with the one number that says what to do. expectNone( result, 'disk', - 'Not enough free disk space. The smallest model that fits this Mac is a {download} GB download, and {reserve} GB must stay free after it. {free} GB is free now. Free up about {missing} GB.' + 'Free up about {missing} GB of disk space to install the smallest model that fits this Mac.' ); // download + 10 GiB - free = 6,550,825,373 + 10,737,418,240 - 12,884,901,888 bytes. - expect(result.headline.vars).toEqual({ download: '6.6', reserve: '10.7', free: '12.9', missing: '4.4' }); + expect(result.headline.vars).toEqual({ missing: '4.4' }); expect(result.budgetBytes).toBe(35 * GIB); }); - it('gives the reader every number of the sum: download + reserve - free = missing', () => { - // 12 GB free is more than the 6.6 GB download, so without the reserve "not enough" reads false. + it('says what to free, counting the reserve: download + 10 GiB - free', () => { + // 12 GB free is more than the 6.6 GB download: what is missing is the reserve on top of it. const hw = mac(32, 'Apple M4', { freeDiskBytes: 12 * GB, freeDiskGb: formatGb(12 * GB) }); const vars = recommend(hw, STOCK, ALL_INSTALLED).headline.vars as Record; - expect(vars).toEqual({ download: '6.6', reserve: '10.7', free: '12.0', missing: '5.3' }); - expect(vars.reserve).toBe(formatGb(DISK_HEADROOM_BYTES)); - // Within the rounding of four one-decimal figures. - expect(Number(vars.download) + Number(vars.reserve) - Number(vars.free)).toBeCloseTo(Number(vars.missing), 0); + // 6,550,825,373 + 10,737,418,240 - 12,000,000,000 bytes. + expect(vars).toEqual({ missing: '5.3' }); + expect(Number(vars.missing)).toBeCloseTo((6_550_825_373 + DISK_HEADROOM_BYTES - 12 * GB) / GB, 1); }); it('names the smallest model that fits the MEMORY, not the smallest of the catalogue', () => { @@ -1074,7 +1078,8 @@ describe('verdict none', () => { const hw = mac(48, 'Apple M4 Max', { freeDiskBytes: 12 * GIB, freeDiskGb: formatGb(12 * GIB) }); const result = recommend(hw, STOCK, facts); expect(result.reason).toBe('disk'); - expect(result.headline.vars?.download).toBe('13.8'); + // 13,793,441,427 + 10 GiB - 12 GiB: the 20B's download, not the 9B's. + expect(result.headline.vars).toEqual({ missing: '11.6' }); }); it('drops only the models that do not leave 10 GiB, picks among the rest, and says what was left out', () => { @@ -1095,7 +1100,7 @@ describe('verdict none', () => { text: '{model} fits the memory of this Mac too, but its download needs about {missing} GB more free disk space.', vars: { model: 'Qwen3.8 27B', missing: '7.0' }, }, - { text: 'Not yet tested by us on a Mac like this.' }, + { text: 'Codeman has not run this on a Mac like this yet.' }, ]); }); @@ -1165,9 +1170,10 @@ describe('verdict none', () => { expect(recommend(hw, STOCK, ALL_INSTALLED).verdict).toBe('ok'); }); - it('formats the free size itself when the hardware carries only the bytes', () => { + it('needs only the bytes of the free disk, not its text', () => { const hw = without(mac(48, 'Apple M4 Max', { freeDiskBytes: 3 * GB }), 'freeDiskGb'); - expect(recommend(hw, STOCK, ALL_INSTALLED).headline.vars?.free).toBe('3.0'); + // 6,550,825,373 + 10 GiB - 3 GB. + expect(recommend(hw, STOCK, ALL_INSTALLED).headline.vars).toEqual({ missing: '14.3' }); }); it('comes after the memory and the harness', () => { @@ -1191,7 +1197,7 @@ describe('verdict limited', () => { expect(result.headline).toEqual({ text: 'This Mac can run a small model. It handles small edits and questions. It does not replace a cloud model.', }); - expect(result.notes).toEqual([{ text: 'Not yet tested by us on a Mac like this.' }]); + expect(result.notes).toEqual([{ text: 'Codeman has not run this on a Mac like this yet.' }]); }); it('holds when the pick is a tight fit too (32 GB with a fast chip)', () => { @@ -1231,7 +1237,7 @@ describe('verdict limited', () => { { text: 'The best fit is a small model. It handles small edits and questions. It does not replace a cloud model.', }, - { text: 'Not yet tested by us on a Mac like this.' }, + { text: 'Codeman has not run this on a Mac like this yet.' }, ]); }); @@ -1665,7 +1671,7 @@ describe('the tested flag and the word "Recommended"', () => { const result = recommend(hw, catalog, ALL_INSTALLED); expect(result.pick).toMatchObject({ modelId: 'qwen3.8:27b', cli: 'opencode', tested: false }); expect(result.headline).toEqual({ text: 'Best fit for this Mac' }); - expect(result.notes).toEqual([{ text: 'Not yet tested by us on a Mac like this.' }]); + expect(result.notes).toEqual([{ text: 'Codeman has not run this on a Mac like this yet.' }]); } }); @@ -1727,9 +1733,12 @@ describe('what each row of the chooser carries', () => { expect(result.alternatives[3]).not.toHaveProperty('tag'); }); - it('says "Best fit" on a pick that is tight: the memory bar shows how full it is', () => { + it('says "Best fit, tight" on a pick that only just fits: the words must not say less than the bar', () => { const result = recommend(mac(48, 'Apple M4 Pro'), STOCK, ALL_INSTALLED); - expect(result.pick).toMatchObject({ fit: 'tight', tag: { kind: 'best', label: 'Best fit' } }); + expect(result.pick).toMatchObject({ fit: 'tight', tag: { kind: 'best-tight', label: 'Best fit, tight' } }); + // A pick with room says "Best fit" and nothing more. + const roomy = recommend(mac(64, 'Apple M4 Max'), STOCK, ALL_INSTALLED); + expect(roomy.pick).toMatchObject({ fit: 'good', tag: { kind: 'best', label: 'Best fit' } }); }); it('says "Tested on a Mac like this" in place of every other tag', () => { @@ -1785,8 +1794,9 @@ describe('what each row of the chooser carries', () => { for (const hw of SWEEP) { const result = recommend(hw, STOCK, ALL_INSTALLED); if (!result.pick) continue; - expect(result.pick.tag?.kind).toBe('best'); - expect(result.alternatives.some((c) => c.tag?.kind === 'best')).toBe(false); + // The pick's tag is the pick's alone: "Best fit", with ", tight" when it only just fits. + expect(result.pick.tag?.kind).toBe(result.pick.fit === 'tight' ? 'best-tight' : 'best'); + expect(result.alternatives.some((c) => c.tag?.kind === 'best' || c.tag?.kind === 'best-tight')).toBe(false); } }); }); diff --git a/test/local-model-ui.test.ts b/test/local-model-ui.test.ts index a13ea890..b577c4c4 100644 --- a/test/local-model-ui.test.ts +++ b/test/local-model-ui.test.ts @@ -185,6 +185,9 @@ function mount(options: { i18n?: boolean; visible?: boolean } = {}) { group: document.getElementById('localModelGroup')!, modal: document.getElementById('localModelModal')!, body: document.getElementById('localModelWindowBody')!, + /** The body and the foot together: where a screen's controls are. */ + frame: document.querySelector('#localModelModal .local-model-window')!, + foot: document.getElementById('localModelWindowFoot')!, }; } @@ -195,13 +198,18 @@ function texts(root: ParentNode, selector: string): string[] { return [...root.querySelectorAll(selector)].map((el) => el.textContent ?? ''); } +/** The frame the buttons live in: the window's body AND its foot (the buttons are in the foot). */ +function frameOf(body: Element): Element { + return body.closest('.local-model-window') ?? body; +} + /** The window's buttons, by their label. */ function buttons(body: Element): string[] { - return texts(body, '.lm-buttons button'); + return texts(frameOf(body), '.lm-buttons button'); } function press(body: Element, label: string) { - const found = [...body.querySelectorAll('.lm-buttons button')].find( + const found = [...frameOf(body).querySelectorAll('.lm-buttons button')].find( (el) => el.textContent === label ); expect(found, `no button "${label}" among ${JSON.stringify(buttons(body))}`).toBeTruthy(); @@ -806,7 +814,7 @@ describe('the App Settings row', () => { await app.loadLocalModel(); expect(texts(page.row, '.set-row-label')).toEqual(['No model installed']); expect(texts(page.row, '.set-row-desc')).toEqual([ - 'This Mac has 8 GB of memory. That is too little to run a model that can drive a coding agent, so Codeman recommends none here.', + 'With 8 GB of memory, this Mac cannot run a model that can drive a coding agent.', ]); expect(page.rowButton.textContent).toBe('Open'); app.openLocalModelWindow(); @@ -823,8 +831,12 @@ describe('the chooser', () => { const { body } = page; expect(body.dataset.screen).toBe('choose'); expect(texts(body, '.lm-hw')).toEqual(['Apple M5 Pro, 48 GB memory, 897 GB free']); - expect(texts(body, '.lm-figure')).toEqual(['About 36.0 of 37.6 GB of memory']); - expect(texts(body, '.lm-legend span')).toEqual(['The model at 131,072 tokens of context', 'What stays free']); + // An estimate, in whole GB: "about 36 of 38", never "36.0 of 37.6". + expect(texts(body, '.lm-figure')).toEqual(['About 36 of 38 GB of memory']); + expect(texts(body, '.lm-legend span')).toEqual([ + 'The model at its peak, with all 131,072 tokens of context in use', + 'What stays free', + ]); const group = body.querySelector('[role="radiogroup"]')!; const label = page.document.getElementById(group.getAttribute('aria-labelledby')!)!; @@ -839,6 +851,14 @@ describe('the chooser', () => { 'Qwen3.5 9B', ]); expect(rows.map((row) => row.getAttribute('aria-checked'))).toEqual(['true', 'false', 'false', 'false', 'false']); + // The order can be read off the Download column: the pick, then the others largest first. + expect(rows.map((row) => row.querySelector('.lm-model-size')!.textContent)).toEqual([ + '23.1 GB', + '17.7 GB', + '13.8 GB', + '10.4 GB', + '6.6 GB', + ]); // One tab stop: the selected row. expect(rows.map((row) => row.getAttribute('tabindex'))).toEqual(['0', '-1', '-1', '-1', '-1']); // One line a row: name, one tag, speed, download size. @@ -859,7 +879,9 @@ describe('the chooser', () => { ]); expect(texts(group, '.lm-model-size')).toEqual(['23.1 GB', '17.7 GB', '13.8 GB', '10.4 GB', '6.6 GB']); expect(texts(body, '.lm-caption span')).toEqual(['Recommended for this Mac', 'Speed', 'Download']); - expect(texts(body, '.lm-foot-note')).toEqual(['No account and no API key. The model runs only on this Mac.']); + expect(texts(frameOf(body), '.lm-foot-note')).toEqual([ + 'No account and no API key. The model runs only on this Mac.', + ]); expect(buttons(body)).toEqual(['Install on this Mac']); }); @@ -896,7 +918,7 @@ describe('the chooser', () => { for (const budgetSource of ['measured', 'wired-limit'] as const) { serve(status({ budgetBytes: 40157944217, budgetGb: '40.2', budgetSource })); await app.loadLocalModel({ force: true }); - expect(texts(page.body, '.lm-figure')).toEqual(['About 36.0 of 40.2 GB of memory']); + expect(texts(page.body, '.lm-figure')).toEqual(['About 36 of 40 GB of memory']); expect(texts(page.body, '.lm-figure-aside')).toEqual([]); expect(page.body.textContent).not.toContain('estimated'); } @@ -921,7 +943,7 @@ describe('the chooser', () => { expect(texts(page.body, '.lm-caption-label')).toEqual(['Models that fit this Mac']); expect(texts(page.body, '.lm-notes .lm-aside')).toEqual([ 'Qwen3.6 35B-A3B fits the memory of this Mac too, but its download needs about 0.4 GB more free disk space.', - 'Not yet tested by us on a Mac like this.', + 'Codeman has not run this on a Mac like this yet.', ]); }); @@ -934,7 +956,7 @@ describe('the chooser', () => { expect(body.querySelector('.lm-runs-label')!.getAttribute('for')).toBe(select().id); expect([...select().options].map((option) => option.textContent)).toEqual(['OpenCode', 'Claude Code']); expect(select().selectedOptions[0].textContent).toBe('OpenCode'); - expect(texts(body, '.lm-runs-hint')).toEqual(['Not on this Mac yet. It is installed for you.']); + expect(texts(body, '.lm-runs-hint')).toEqual(['Not on this Mac yet. Codeman installs it for you.']); // No value in the page is a CLI id: the choice is a position in the server's list. expect([...select().options].map((option) => option.value)).toEqual(['0', '1']); @@ -1051,9 +1073,9 @@ describe('the memory bar', () => { ); const expected: Array<[number, string, string, string]> = [ - [1, '81', 'About 30.6 of 37.6 GB of memory', 'The model at 131,072 tokens of context'], - [4, '45', 'About 17.0 of 37.6 GB of memory', 'The model at 65,536 tokens of context'], - [0, '96', 'About 36.0 of 37.6 GB of memory', 'The model at 131,072 tokens of context'], + [1, '81', 'About 31 of 38 GB of memory', 'The model at its peak, with all 131,072 tokens of context in use'], + [4, '45', 'About 17 of 38 GB of memory', 'The model at its peak, with all 65,536 tokens of context in use'], + [0, '96', 'About 36 of 38 GB of memory', 'The model at its peak, with all 131,072 tokens of context in use'], ]; for (const [index, percent, figure, legend] of expected) { page.body.querySelectorAll('[role="radio"]')[index].click(); @@ -1091,20 +1113,23 @@ describe('the confirm screen', () => { expect(body.dataset.screen).toBe('confirm'); // Nothing was sent: this is the question. expect(body.querySelectorAll('[role="radio"]')).toHaveLength(0); - expect(texts(body, '.lm-model .lm-model-name')).toEqual(['Qwen3.6 35B-A3B']); - expect(texts(body, '.lm-size-row').map((text) => text.replace(/\s+/g, ' '))).toEqual([ - 'Qwen3.6 35B-A3B23.1 GB', - 'Ollama 0.40.15, the program that runs the model0.2 GB', - 'Total23.3 GB', + // The table is the screen: the chosen row first, the runtime, the missing coding agent + // with its installer's literal command, and the total. + expect(texts(body, '.lm-heading')).toEqual(['What will be downloaded']); + expect(texts(body, '.lm-size-name')).toEqual(['Qwen3.6 35B-A3B', 'Ollama 0.40.15', 'OpenCode', 'Total']); + expect(texts(body, '.lm-size-note')).toEqual([ + 'At 131,072 tokens of context, with OpenCode', + 'The program that runs the model', + 'Not on this Mac yet. Its own installer fetches it with this command:', ]); + expect(texts(body, '.lm-size-gb')).toEqual(['23.1 GB', '0.2 GB', '23.3 GB']); expect(body.querySelector('.lm-size-total .lm-size-gb')!.textContent).toBe('23.3 GB'); - // The coding agent is missing: the literal command its installer runs, and the folder. - expect(texts(body, '.lm-command')).toEqual(['curl -fsSL https://opencode.ai/install | bash', FOLDER]); - const asides = texts(body, '.lm-aside'); - expect(asides).toContain('Runs with OpenCode, at 131,072 tokens of context.'); - expect(asides).toContain('OpenCode is not on this Mac yet. Its own installer is run with this command:'); - expect(asides).toContain('No administrator password.'); - expect(asides).toContain(LOCAL_MODEL_INSTALL_LABELS.developerToolsMissing); + expect(texts(body, '.lm-command')).toEqual(['curl -fsSL https://opencode.ai/install | bash']); + // Where it goes, in one line; the developer tools sentence as the one aside. + expect(texts(body, '.lm-path')).toEqual([FOLDER]); + expect(texts(body, '.lm-aside')).toEqual([LOCAL_MODEL_INSTALL_LABELS.developerToolsMissing]); + // The promise sits beside the buttons. + expect(texts(frameOf(body), '.lm-foot-note')).toEqual(['No administrator password.']); expect(buttons(body)).toEqual(['Back', 'Download and install']); }); @@ -1113,8 +1138,9 @@ describe('the confirm screen', () => { const pick = candidate({ installBytes: 23105798956, installGb: '23.1', harnesses: [CLAUDE] }); await opened(page, status({ pick, alternatives: [] }, { runtime: RUNTIME_THERE, developerTools: 'present' })); press(page.body, 'Install on this Mac'); - expect(texts(page.body, '.lm-size-what')).toEqual(['Qwen3.6 35B-A3B', 'Total']); - expect(texts(page.body, '.lm-command')).toEqual([FOLDER]); + expect(texts(page.body, '.lm-size-name')).toEqual(['Qwen3.6 35B-A3B', 'Total']); + expect(texts(page.body, '.lm-command')).toEqual([]); + expect(texts(page.body, '.lm-path')).toEqual([FOLDER]); expect(page.body.textContent).not.toContain('developer tools'); expect(page.body.textContent).not.toContain('installer'); }); @@ -1131,7 +1157,7 @@ describe('the confirm screen', () => { await opened(page, IDLE); page.body.querySelectorAll('[role="radio"]')[1].click(); press(page.body, 'Install on this Mac'); - expect(texts(page.body, '.lm-model .lm-model-name')).toEqual(['Qwen3.8 27B']); + expect(texts(page.body, '.lm-size-name')[0]).toBe('Qwen3.8 27B'); press(page.body, 'Back'); expect(page.body.dataset.screen).toBe('choose'); expect(page.body.querySelector('[aria-checked="true"] .lm-model-name')!.textContent).toBe('Qwen3.8 27B'); @@ -1187,10 +1213,8 @@ describe('the consent', () => { const { app } = await opened(page, status({ pick: bare, alternatives: [] }), () => ok(INSTALLING.install)); press(page.body, 'Install on this Mac'); // No total the server formatted, so none is printed: bytes are never turned into a size here. - expect(texts(page.body, '.lm-size-what')).toEqual([ - 'Qwen3.6 35B-A3B', - 'Ollama 0.40.15, the program that runs the model', - ]); + expect(texts(page.body, '.lm-size-name')).toEqual(['Qwen3.6 35B-A3B', 'Ollama 0.40.15', 'OpenCode']); + expect(page.body.querySelector('.lm-size-total')).toBeNull(); press(page.body, 'Download and install'); await settle(); expect((verbs(app)[0][2] as { acknowledgedBytes: number }).acknowledgedBytes).toBe(23105798956 + RUNTIME_BYTES); @@ -1316,11 +1340,11 @@ describe('the consent', () => { press(page.body, 'Install on this Mac'); press(page.body, 'Download and install'); // While the request is in flight every button waits. - expect([...page.body.querySelectorAll('.lm-buttons button')].map((el) => el.disabled)).toEqual([ + expect([...page.frame.querySelectorAll('.lm-buttons button')].map((el) => el.disabled)).toEqual([ true, true, ]); - page.body.querySelectorAll('.lm-buttons button')[1].click(); + page.frame.querySelectorAll('.lm-buttons button')[1].click(); answer(ok(INSTALLING.install)); await settle(); expect(verbs(app)).toHaveLength(1); @@ -1404,14 +1428,19 @@ describe('the progress screen', () => { expect(texts(page.body, '.lm-figure-aside')).toEqual([aside]); }); - it('names the step when nothing is being downloaded, with the bar full once the model is in', async () => { + it('the bar is the model download alone: at the start while the runtime is fetched, gone once the model is in', async () => { const page = mount(); const { app, serve } = await opened( page, status({}, { ...JOB, state: 'installing', step: 'runtime', actions: ['cancel'], runtime: RUNTIME_MISSING }) ); - expect(texts(page.body, '.lm-figure')).toEqual(['Getting the runtime']); + // Nothing of the model has arrived: the size that is coming, from the row it was chosen on. + expect(texts(page.body, '.lm-figure')).toEqual(['0 of 23.1 GB']); expect(page.body.querySelector('.lm-bar')!.getAttribute('aria-valuenow')).toBe('0'); + expect(texts(page.body, '.lm-aside')).toEqual([ + 'You can close this window. The download continues while Codeman is running.', + ]); + // The steps after the download have no bar and no second headline: the list says where it stands. for (const [step, label] of [ ['verify', 'Loading and checking'], ['agent', 'Installing OpenCode'], @@ -1419,9 +1448,13 @@ describe('the progress screen', () => { ] as const) { serve(status({}, { ...JOB, state: 'installing', step, actions: ['cancel'] })); await app.loadLocalModel({ force: true }); - expect(texts(page.body, '.lm-figure')).toEqual([label]); - expect(page.body.querySelector('.lm-bar')!.getAttribute('aria-valuenow')).toBe('100'); - expect(texts(page.body, '.lm-figure-aside')).toEqual([]); + expect(page.body.querySelector('.lm-meter')).toBeNull(); + expect(texts(page.body, '.lm-figure')).toEqual([]); + expect(texts(page.body, '.lm-title')).toEqual(['Installing Qwen3.6 35B-A3B']); + expect(texts(page.body, '.lm-step-now')).toEqual([label]); + expect(texts(page.body, '.lm-aside')).toEqual([ + 'You can close this window. The install continues while Codeman is running.', + ]); } }); @@ -1437,7 +1470,7 @@ describe('the progress screen', () => { it('moves only the bar when a tick moved only the download: the button is not swapped under a click', async () => { const page = mount(); const { app, serve } = await opened(page, INSTALLING); - const cancel = page.body.querySelector('.lm-buttons button'); + const cancel = page.frame.querySelector('.lm-buttons button'); const steps = page.body.querySelector('ol.lm-steps'); serve( status( @@ -1454,7 +1487,7 @@ describe('the progress screen', () => { expect(page.body.querySelector('.lm-bar')!.getAttribute('aria-valuenow')).toBe('50'); expect(page.body.querySelector('.lm-bar-fill')!.style.width).toBe('50%'); expect(page.body.querySelectorAll('.lm-meter')).toHaveLength(1); - expect(page.body.querySelector('.lm-buttons button')).toBe(cancel); + expect(page.frame.querySelector('.lm-buttons button')).toBe(cancel); expect(page.body.querySelector('ol.lm-steps')).toBe(steps); // A new step is a new picture. serve(status({}, { ...JOB, state: 'installing', step: 'verify', actions: ['cancel'] })); @@ -1475,7 +1508,7 @@ describe('a paused install', () => { // No speed and no time left on a download that is not running. expect(texts(body, '.lm-figure-aside')).toEqual([]); expect(texts(body, '.lm-step-stopped')).toEqual(['Downloading the model']); - expect(texts(body, '.lm-aside')).toEqual(['Nothing is downloaded until you continue.']); + expect(texts(body, '.lm-aside')).toEqual(['The download waits until you continue.']); expect(buttons(body)).toEqual(['Remove', 'Choose another model', 'Continue']); expect(verbs(app)).toEqual([]); press(body, 'Continue'); @@ -1533,10 +1566,15 @@ describe('a failure', () => { const page = mount(); const vars = { model: 'M', port: '11435', actual: '4096', wanted: '131072', cli: 'OpenCode', id: 'local-ollama' }; await opened(page, failed(code, vars)); - const title = texts(page.body, '.lm-title')[0]; - expect(title.length, code).toBeGreaterThan(20); - expect(title, code).not.toMatch(/[{}]/); - expect(title, code).not.toMatch(/sorry|apolog/i); + // The headline is the FIRST sentence; what follows is read in the body size. + const title = texts(page.body, '.lm-error .lm-title')[0]; + const rest = texts(page.body, '.lm-error .lm-lead').join(' '); + const whole = [title, rest].filter(Boolean).join(' '); + expect(whole.length, code).toBeGreaterThan(20); + expect(title, code).toMatch(/[.!?]$/); + expect(title.replace(/[^.!?]/g, '').length, code).toBeLessThanOrEqual(2); + expect(whole, code).not.toMatch(/[{}]/); + expect(whole, code).not.toMatch(/sorry|apolog/i); page.dom.window.close(); } }); @@ -1610,13 +1648,15 @@ describe('an installed model', () => { expect(texts(body, '.lm-title')).toEqual(['Qwen3.6 35B-A3B is ready on this Mac.']); expect(texts(body, '.lm-state-text')).toEqual(['Running']); expect(body.querySelector('.lm-dot')!.classList.contains('lm-dot-on')).toBe(true); + // What it takes now, and what it can take, in the words of the chooser's legend: the + // row's own estimate, so the two figures are never read as a contradiction. expect(texts(body, '.lm-aside')).toEqual([ - 'Runs with OpenCode. Context 131,072 tokens. Takes 26.3 GB of memory when loaded, all on the GPU.', + 'Runs with OpenCode. Context 131,072 tokens. Takes 26.3 GB of memory now, and up to about 36 GB at its peak, with all of the context in use. All of it is on the GPU.', ]); expect(buttons(body)).toEqual(['Remove', 'Stop', 'Start a session']); // One main action. - expect(body.querySelectorAll('.lm-buttons .wt-btn-primary')).toHaveLength(1); - expect(body.querySelector('.lm-buttons .wt-btn-primary')!.textContent).toBe('Start a session'); + expect(frameOf(body).querySelectorAll('.lm-buttons .wt-btn-primary')).toHaveLength(1); + expect(frameOf(body).querySelector('.lm-buttons .wt-btn-primary')!.textContent).toBe('Start a session'); }); it('says the one honest sentence about git and Python only on a Mac without developer tools', async () => { @@ -1640,7 +1680,7 @@ describe('an installed model', () => { expect(texts(page.body, '.lm-state-text')).toEqual(['Stopped']); expect(page.body.querySelector('.lm-dot')!.classList.contains('lm-dot-on')).toBe(false); expect(buttons(page.body)).toEqual(['Remove', 'Start']); - expect(page.body.querySelector('.lm-buttons .wt-btn-primary')!.textContent).toBe('Start'); + expect(page.frame.querySelector('.lm-buttons .wt-btn-primary')!.textContent).toBe('Start'); press(page.body, 'Start'); await settle(); expect(verbs(app)).toEqual([ @@ -1684,7 +1724,7 @@ describe('an installed model', () => { const { app } = await opened(page, answer, () => ok(INSTALLED.install)); expect(texts(page.body, '.lm-error .lm-title')).toEqual([sentence]); expect(buttons(page.body)).toEqual(labels); - expect(page.body.querySelectorAll('.lm-buttons .wt-btn-primary')).toHaveLength(1); + expect(page.frame.querySelectorAll('.lm-buttons .wt-btn-primary')).toHaveLength(1); press(page.body, labels[1]); await settle(); expect(verbs(app)).toEqual([['POST', '/api/local-model/runtime', body]]); @@ -1705,13 +1745,19 @@ describe('an installed model', () => { } ); await opened(page, answer); - expect(texts(page.body, '.lm-error .lm-title')).toEqual([ - 'The model on this Mac changed since it was installed. Remove it and install it again.', - ]); - // No session can start, so that button is not there. + // The sentence is the headline (not "is ready"), its second sentence in the body size. + expect(texts(page.body, '.lm-title')).toEqual(['The model on this Mac changed since it was installed.']); + expect(texts(page.body, '.lm-error .lm-lead')).toEqual(['Remove it and install it again.']); + // The dot is not green: a session cannot be started. The state line names the model. + expect(page.body.querySelector('.lm-dot')!.classList.contains('lm-dot-on')).toBe(false); + expect(texts(page.body, '.lm-state-text')).toEqual(['Qwen3.6 35B-A3B, Running']); + // No session can start, so that button is not there. Remove never takes the accent. expect(buttons(page.body)).toEqual(['Stop', 'Remove']); + expect(page.frame.querySelectorAll('.lm-buttons .wt-btn-primary')).toHaveLength(0); press(page.body, 'Remove'); - expect(texts(page.body, '.lm-remove .lm-lead')).toEqual(['Remove the local model from this Mac?']); + expect(texts(page.frame, '.lm-remove .lm-lead')).toEqual(['Remove the local model from this Mac?']); + // In its question the removal is red. + expect(page.frame.querySelector('.lm-remove .wt-btn-danger')!.textContent).toBe('Remove'); }); it('starts a session on the saved connection with the agent of the install, after refreshing the page', async () => { @@ -1754,7 +1800,7 @@ describe('Remove', () => { press(page.body, 'Remove'); // The question, in place of the buttons. Nothing was sent. expect(verbs(app)).toEqual([]); - const question = page.body.querySelector('.lm-remove')!; + const question = page.frame.querySelector('.lm-remove')!; expect(texts(question, '.lm-lead')).toEqual(['Remove the local model from this Mac?']); expect(texts(question, '.lm-check span')).toEqual(['Also delete the downloaded model']); expect(texts(question, '.lm-aside')).toEqual(['The coding agent stays installed.']); @@ -1770,10 +1816,10 @@ describe('Remove', () => { const { app } = await opened(page, INSTALLED, () => ok(install())); press(page.body, 'Remove'); press(page.body, 'Cancel'); - expect(page.body.querySelector('.lm-remove')).toBeNull(); + expect(page.frame.querySelector('.lm-remove')).toBeNull(); expect(buttons(page.body)).toEqual(['Remove', 'Stop', 'Start a session']); press(page.body, 'Remove'); - const box = page.body.querySelector('.lm-remove input[type="checkbox"]')!; + const box = page.frame.querySelector('.lm-remove input[type="checkbox"]')!; box.checked = false; box.dispatchEvent(new page.dom.window.Event('change')); press(page.body, 'Remove'); @@ -1806,7 +1852,7 @@ describe('Remove', () => { press(page.body, 'Remove'); await settle(); expect(page.body.dataset.screen).toBe('installed'); - expect(texts(page.body, '.lm-notice-text')).toEqual([ + expect(texts(page.frame, '.lm-notice-text')).toEqual([ 'Sessions are still using this model: w1-app, w2-site. Close them first.', ]); // Still the question: the person can close the sessions and press Remove again. @@ -1818,7 +1864,7 @@ describe('Remove', () => { const page = mount(); const { app } = await opened(page, answer, () => ok(install())); press(page.body, 'Remove'); - expect(texts(page.body, '.lm-remove .lm-lead')).toEqual(['Remove the local model from this Mac?']); + expect(texts(page.frame, '.lm-remove .lm-lead')).toEqual(['Remove the local model from this Mac?']); press(page.body, 'Remove'); await settle(); expect(verbs(app)).toEqual([['DELETE', '/api/local-model', { removeModel: true }]]); @@ -1835,19 +1881,19 @@ describe('no recommendation', () => { 'intel', none('intel', undefined, { arch: 'x64', arm64: false, chip: 'Intel(R) Core(TM) i7-9750H', memClassGb: 16 }), 'Intel(R) Core(TM) i7-9750H, 16 GB memory, 897 GB free', - 'This Mac has an Intel processor. Local coding models need Apple silicon.', + 'Local coding models need Apple silicon, and this Mac has an Intel processor.', ], [ 'memory', none('memory', { memory: '8' }, { memGb: '8.6', memClassGb: 8, chip: 'Apple M2', freeDiskGb: '41.3' }), 'Apple M2, 8 GB memory, 41.3 GB free', - 'This Mac has 8 GB of memory. That is too little to run a model that can drive a coding agent, so Codeman recommends none here.', + 'With 8 GB of memory, this Mac cannot run a model that can drive a coding agent.', ], [ 'disk', - none('disk', { download: '6.6', reserve: '10.7', free: '12.0', missing: '5.3' }, { freeDiskGb: '12.0' }), + none('disk', { missing: '5.3' }, { freeDiskGb: '12.0' }), 'Apple M5 Pro, 48 GB memory, 12.0 GB free', - 'Not enough free disk space. The smallest model that fits this Mac is a 6.6 GB download, and 10.7 GB must stay free after it. 12.0 GB is free now. Free up about 5.3 GB.', + 'Free up about 5.3 GB of disk space to install the smallest model that fits this Mac.', ], ] as Array<[string, LocalModelStatus, string, string]>)( 'opens with the hardware line, the reason and no button except Close: %s', @@ -1944,14 +1990,20 @@ describe('the poll', () => { it('follows the install from the download to the end without a click', async () => { const page = mount(); - const { serve } = await opened(page, INSTALLING); + const { app, serve } = await opened(page, INSTALLING); expect(page.body.dataset.screen).toBe('progress'); serve(INSTALLED); await page.tick(); expect(page.body.dataset.screen).toBe('installed'); - expect(buttons(page.body)).toEqual(['Remove', 'Stop', 'Start a session']); + // The screen that ends an install has one button. Stop and Remove are for the window + // opened later. + expect(buttons(page.body)).toEqual(['Start a session']); // The focus goes to the main action of the new screen, inside the dialog. expect(page.document.activeElement?.textContent).toBe('Start a session'); + // Opened again, the same model has its Stop and Remove. + app.closeLocalModelWindow(); + app.openLocalModelWindow(); + expect(buttons(page.body)).toEqual(['Remove', 'Stop', 'Start a session']); }); }); @@ -1990,8 +2042,9 @@ describe('what the server sends is shown as text', () => { expect(texts(page.body, '.lm-notes .lm-aside')[0]).toContain(HOSTILE); press(page.body, 'Install on this Mac'); clean(page); - expect(texts(page.body, '.lm-command')).toEqual([`run ${HOSTILE}`, `/tmp/${HOSTILE}`]); - expect(page.body.textContent).toContain(`Agent ${HOSTILE} is not on this Mac yet.`); + expect(texts(page.body, '.lm-command')).toEqual([`run ${HOSTILE}`]); + expect(texts(page.body, '.lm-path')).toEqual([`/tmp/${HOSTILE}`]); + expect(texts(page.body, '.lm-size-name')).toContain(`Agent ${HOSTILE}`); // The row and the button on the welcome screen too. expect(page.row.textContent).toContain(HOSTILE); clean(page); @@ -2042,7 +2095,7 @@ describe('what the server sends is shown as text', () => { press(page.body, 'Remove'); press(page.body, 'Remove'); await settle(); - expect(texts(page.body, '.lm-notice-text')[0]).toContain(HOSTILE); + expect(texts(page.frame, '.lm-notice-text')[0]).toContain(HOSTILE); expect(page.row.textContent).toContain(HOSTILE); clean(page); }); @@ -2068,7 +2121,7 @@ describe('in another language', () => { expect(buttons(page.body)).toEqual(['安装到此 Mac']); expect(page.button.textContent!.trim()).toBe('运行本地模型'); expect(texts(page.row, '.set-row-label')).toEqual(['尚未安装模型']); - expect(texts(page.body, '.lm-figure')).toEqual(['约需 36.0 GB 内存,共 37.6 GB 可用']); + expect(texts(page.body, '.lm-figure')).toEqual(['约需 36 GB 内存,共 38 GB 可用']); // Names are someone else's text and stay as sent; the server's tag is translated. expect(texts(page.body, '.lm-model-name')[0]).toBe('Qwen3.6 35B-A3B'); expect(texts(page.body, '.lm-tag')[0]).toBe('已在同类 Mac 上测试'); @@ -2167,3 +2220,99 @@ describe('the module keeps to its rules', () => { expect(verbs(app)).toEqual([['POST', '/codeman/api/local-model/cancel', undefined]]); }); }); + +// ---------- After the review: the order of the rows, the words of a tight pick, a refusal's sentence, a start that failed ---------- + +describe('the rows of the chooser are ordered by a column the reader can see', () => { + it('the pick first, then the other models by download size, largest first, whatever order the server sent', async () => { + const page = mount(); + // The server's own order of the alternatives is its ranking, which no column shows. + await opened(page, status({ alternatives: [ALT_SMALL_Q8, ALT_SMALL, ALT_UNKNOWN, ALT_SLOW] })); + const rows = [...page.body.querySelectorAll('[role="radio"]')]; + expect(rows.map((row) => row.querySelector('.lm-model-name')!.textContent)).toEqual([ + 'Qwen3.6 35B-A3B', + 'Qwen3.8 27B', + 'GPT-OSS 20B', + 'Qwen3.5 9B (Q8)', + 'Qwen3.5 9B', + ]); + expect(rows.map((row) => row.querySelector('.lm-model-size')!.textContent)).toEqual([ + '23.1 GB', + '17.7 GB', + '13.8 GB', + '10.4 GB', + '6.6 GB', + ]); + // The arrow keys walk the drawn order. + const group = page.body.querySelector('[role="radiogroup"]')!; + group.dispatchEvent(new page.dom.window.KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true })); + expect(page.body.querySelector('[aria-checked="true"] .lm-model-name')!.textContent).toBe('Qwen3.8 27B'); + }); + + it('a pick that only just fits says so in its tag, in the accent like every tag of the pick', async () => { + const page = mount(); + const tightPick = candidate({ + tested: false, + tag: { kind: 'best-tight', label: LOCAL_MODEL_TAG_LABELS['best-tight'] }, + }); + await opened(page, status({ pick: tightPick, alternatives: [ALT_SMALL] })); + const tag = page.body.querySelector('[aria-checked="true"] .lm-tag')!; + expect(tag.textContent).toBe('Best fit, tight'); + expect(tag.classList.contains('lm-tag-accent')).toBe(true); + expect(page.body.querySelector('.lm-bar')!.getAttribute('aria-valuenow')).toBe('96'); + }); +}); + +describe('a refusal the server names', () => { + it('is printed from its sentence and slots, not from the English error text', async () => { + const page = mount({ i18n: true }); + const { app } = await opened(page, IDLE, () => ({ + ok: false, + status: 400, + json: async () => ({ + success: false, + error: 'OpenCode is not installed and cannot be installed from here.', + errorCode: 'INVALID_INPUT', + data: { message: { text: LOCAL_MODEL_INSTALL_LABELS.refusedAgentNotInstallable, vars: { cli: 'OpenCode' } } }, + }), + })); + page.win.CodemanI18n.configure({ language: 'zh-CN' }); + await settle(); + press(page.body, '安装到此 Mac'); + press(page.body, '下载并安装'); + await settle(); + expect(texts(page.body, '.lm-notice-text')).toEqual(['OpenCode 尚未安装,也无法从这里安装。']); + expect(page.body.textContent).not.toContain('cannot be installed from here'); + app.closeLocalModelWindow(); + }); +}); + +describe('an installed model whose runtime did not start', () => { + it('says what the runtime said as the headline, keeps the dot grey, and Start tries again', async () => { + const page = mount(); + const answer = status( + {}, + { + ...INSTALLED.install, + running: false, + actions: ['start', 'remove'], + error: { + code: 'runtime-start-failed', + message: { text: LOCAL_MODEL_ERROR_LABELS['runtime-start-failed'] }, + // The server adapts the next step to the state: Start, not "try again". + next: 'start', + detail: 'spawn ENOENT', + }, + } + ); + const { app } = await opened(page, answer, () => ok(INSTALLED.install)); + expect(texts(page.body, '.lm-title')).toEqual(['The runtime did not start on this Mac.']); + expect(page.body.querySelector('.lm-dot')!.classList.contains('lm-dot-on')).toBe(false); + expect(page.body.querySelector('.lm-detail-text')!.textContent).toBe('spawn ENOENT'); + expect(buttons(page.body)).toEqual(['Remove', 'Start']); + expect(page.frame.querySelector('.lm-buttons .wt-btn-primary')!.textContent).toBe('Start'); + press(page.body, 'Start'); + await settle(); + expect(verbs(app)).toEqual([['POST', '/api/local-model/runtime', { action: 'start' }]]); + }); +});