diff --git a/config/test-suites.ts b/config/test-suites.ts
index 49812b3a..ebbc8337 100644
--- a/config/test-suites.ts
+++ b/config/test-suites.ts
@@ -60,6 +60,7 @@ export const BROWSER_TEST_GLOBS = [
'test/fullscreen-tab-switch-capture.browser.test.ts',
'test/terminal-touch-swipe-axis.browser.test.ts',
'test/phone-press-feedback.browser.test.ts',
+ 'test/local-model-ui.browser.test.ts',
];
/**
diff --git a/src/web/CLAUDE.md b/src/web/CLAUDE.md
index 2f29a211..eb55b626 100644
--- a/src/web/CLAUDE.md
+++ b/src/web/CLAUDE.md
@@ -48,7 +48,7 @@ Frontend JS modules have `@fileoverview` with `@dependency`/`@loadorder` tags. L
**Opened outside Codeman** (`foreign-sessions.js`, both home screens): the tmux sessions a human started, listed from `GET /api/mux/foreign` (root CLAUDE.md, Adopted tmux sessions). ONE renderer, `renderForeignSessions(container)`, fills the welcome block and the phone overview's, and every row goes through `textContent`, since a session name and a command line come from someone else's process. ⚠️ It polls only while a home screen is up (`startForeignPolling`/`stopForeignPolling` from `showWelcome()`/`hideWelcome()`) and the tab is visible (`_foreignPollTick()` is the one gate, and `visibilitychange` catches up; the cache TTL sits under the poll interval, so one tab's every tick is a full socket sweep), and the poll is local only: the docker and ssh scan costs one connection per target, so it runs once on the toggle and again on Rescan, and its rows and notes are kept apart (`_foreignWideSessions`, `_foreignWideNotes`) or the next local poll would wipe them. ⚠️ A 403 is a non-admin in multi-user mode, an answer and not a failed poll: `_foreignRefused` stops the polling and shows no block. ⚠️ `closeSession()` re-reads the list after an adopted tab's DELETE, since the home screen's own load races it and the row would say `Go to tab` until the next poll. ⚠️ An adopted tab has no kill outcome to offer, so the close dialog hides the red button and always tears the wrapper down. ⚠️ `session.remote` and `session.docker` are undefined on an adopted session, so a check that hides a row for those two usually needs `session.adopt` as well. Tests: `test/foreign-sessions-ui.test.ts`, `test/foreign-sessions-i18n.test.ts` (harvests every string from the real module).
-**Model on this Mac** (`local-model-ui.js`; welcome card `#localModelCard` + App Settings > Models group `#localModelGroup`; server rules in the root CLAUDE.md, Local model recommendation): a READ-ONLY view of `GET /api/local-model`. ONE builder (`localModelBody`) paints both containers, so the card and the Settings row can never name a different pick; the card folds the alternatives, Settings lists them. ⚠️ Every dynamic string goes in through `textContent` (model, harness and chip names are someone else's text), both containers carry `data-i18n-skip`, and the module translates its own sentences at render time, so a language or display-name change must repaint from the module (the `` observer). ⚠️ Labels and sentences come from the payload (`{ text, vars }` through `localModelT`); the module holds no list of models or harnesses, never compares a CLI id, and never contains the word "Recommended" (that headline is the server's to send). ⚠️ `test/local-model-i18n.test.ts` harvests every `localModelT('...')` literal: a new string is a single-quoted literal with no apostrophe and gets a zh-CN entry, and the only non-literal calls are the two payload ones. ⚠️ Sizes are printed as sent (decimal GB text), bytes are never divided here; the installed memory alone is `memClassGb`, a number or nothing (an unknown size is left out, never `0 GB`). ⚠️ The card ships `hidden` and shows only once the server answered for a Mac; the card AND the Settings group stay hidden for `not-macos` (the server is usually Linux, and "this Mac" always means the machine the server runs on), for a refusal and while the first request is in flight; a failed first request shows one sentence in Settings only. ⚠️ Refused means `403` with `errorCode: FORBIDDEN` (a non-admin in multi-user mode: stop asking); the auth layer's `403 PASSWORD_CHANGE_REQUIRED` is a failed request that is asked again on `codeman:me`. ⚠️ Each fit and speed pill carries what it measures as `title` + `aria-label` ("Good" is the memory fit, not a rating), and the line saying Codeman cannot download or install yet sits right under the pick and its notes, in `--text-dim` like every other line. The dismissal is a per-device localStorage key named for this stage (`codeman:local-model-dismissed:readonly`), never a server setting: the stage that can set a model up takes a new key so its offer shows once more. At most one request a minute (the server caches its probe for 60 s), shared while in flight, and a failed refresh keeps the answer on screen. Tests: `test/local-model-ui.test.ts`, `test/local-model-i18n.test.ts`.
+**Run a model on this Mac** (`local-model-ui.js`; welcome button `#welcomeLocalModelBtn`, chooser window `#localModelModal`, App Settings > Models row `#localModelSettings`; server rules in the root CLAUDE.md, Local model): ONE window does everything (choose, confirm, progress, paused, failed, installed, no recommendation); the button and the Settings row only open it. Which screen shows follows `install.state` of `GET /api/local-model`; the page itself holds only the selection and whether the person is on the chooser or the confirm screen. ⚠️ The memory bar (`.lm-bar`, `role="meter"`) is the one strong element: filled to the selected row's `budgetShare`, a flat `--accent` fill on a `--control-border-hover` track (the inputs' `--bg-input` is the window's own surface on the dark skins), 10px, no gradient or glow, and its width transition is the only motion (off under `prefers-reduced-motion`); a repaint starts each bar at the last width of its kind, so keep `_settleLocalModelBars()` after every body write. ⚠️ The window polls the GET once a second WHILE OPEN and never otherwise (no SSE event): `closeLocalModelWindow()` clears the timer, a background tab asks nothing, an unchanged answer repaints nothing, and a tick that moved only the download replaces `.lm-meter` alone, so a button is never swapped out under a click. A read that was already under way when a verb answered is dropped (`_localModelVerbs`), or the old state flashes back. ⚠️ The consent is bound to the size shown: the install request carries the row's `installBytes` as `acknowledgedBytes`, and a 409 `size-changed` shows `expectedGb` and sends `expectedBytes` next; never compute a total here. ⚠️ A button exists only for an action in `install.actions`, and after an error exactly one helpful action (`error.next`) plus a quiet Remove. ⚠️ Every dynamic string goes in through `textContent` (model, coding agent and chip names, an error's detail, session names, the folder and the install command are someone else's text); the window body, the button and the row carry `data-i18n-skip` and the module translates its own sentences at render time, so a language or display-name change repaints from the module (the `` observer). ⚠️ Labels and sentences come from the payload (`{ text, vars }` through `localModelT`); the module holds no list of models or coding agents, compares no CLI id (the agent select's values are positions in the server's list, the id travels only as data into the install request and `selectCustomModelEntry`) and never contains the word "Recommended". ⚠️ `test/local-model-i18n.test.ts` harvests every `localModelT('...')` literal: a new string is a single-quoted literal with no apostrophe and gets a zh-CN entry, the only non-literal calls are the two payload ones, and a unit alone (`23.1 GB`) is not a translator call. ⚠️ Sizes are printed as sent (decimal GB text), bytes are never turned into a size; the installed memory alone is `memClassGb`. ⚠️ The model list is a real radio group (`role="radiogroup"`, roving `tabindex`, arrows, Home, End, the focus follows through the repaint's own focus restore, keyed by `data-lm-key`); the columns line up through `subgrid` with a fixed-track `@supports` fallback, and under 560px a row is two lines (mobile.css). ⚠️ The window has its OWN `FocusTrap` (`_localModelFocusTrap`, never `activeFocusTrap`: it opens on top of App Settings) and app.js claims its Escape before `closeAllPanels()`. ⚠️ Its buttons are the Git worktrees dialog's `.wt-btn` set, never `.btn-toolbar`: the skin block greys `.btn-toolbar.btn-primary` and the phone block pins every `.btn-toolbar` to 26px with `!important`. ⚠️ "Start a session" re-reads the settings and the CLI list first (`loadAppSettingsFromServer`, `loadCliListForSettings`: the server switched the endpoints flag on and may have installed the agent a moment ago), then calls the existing `selectCustomModelEntry(install.cli, install.endpointId)`. The button ships `hidden` and shows only once the server answered for a Mac ("Local model" once one is installed); button AND Settings group stay hidden for `not-macos`, for a refusal and while the first request is in flight; refused means `403` with `errorCode: FORBIDDEN` (the auth layer's `403 PASSWORD_CHANGE_REQUIRED` is a failed request, asked again on `codeman:me`). Remove asks first and deletes the downloaded model unless the box is cleared (nothing else in the UI can free that space later). Not on the phone overview. Tests: `test/local-model-ui.test.ts`, `test/local-model-i18n.test.ts`; the visual walk is a scratch Playwright script, not a test.
**Home-screen session order** (`CodemanSessionOrder` in constants.js, pure): BOTH home screens (phone overview, desktop rail) must order rows through this ONE comparator. Rank `needs` → `error` → `waiting` → `working` → `idle` → `done`. ⚠️ The tiebreak flips: states a session is still IN sort oldest-first, states it has STOPPED sort newest-first. ⚠️ The running group keys off `lastSubmitAt`, never `lastActivityAt` (a working pane repaints constantly). ⚠️ A 0 stamp means unknown and sorts last within its state. Final tiebreak is `orderIndex`, so the list never shuffles. The tab strip itself is NOT sorted by this. Test: `test/session-overview-order.test.ts`. → [architecture-invariants#home-screen-session-order](../../docs/architecture-invariants.md#home-screen-session-order)
diff --git a/src/web/public/app.js b/src/web/public/app.js
index 69b46af8..3257792e 100644
--- a/src/web/public/app.js
+++ b/src/web/public/app.js
@@ -1468,6 +1468,12 @@ class CodemanApp {
this.handleTabRailSearchKeydown(e);
return;
}
+ // The local model window can sit on top of App Settings (it opens from a
+ // row there): Escape closes the window alone and leaves Settings open.
+ if (this.isLocalModelWindowOpen?.()) {
+ this.closeLocalModelWindow();
+ return;
+ }
this.closeAllPanels();
this.closeHelp();
if (this.attachmentHistoryDrawerOpen) this.closeAttachmentHistory();
diff --git a/src/web/public/i18n.js b/src/web/public/i18n.js
index 87c18651..47f33f7d 100644
--- a/src/web/public/i18n.js
+++ b/src/web/public/i18n.js
@@ -1407,39 +1407,96 @@
'该 tmux 会话在此处打开时所在的位置。它是在 Codeman 之外启动的,关闭此标签页后仍会继续运行。',
'Git status is not available for a tmux session opened outside Codeman.':
'在 Codeman 之外打开的 tmux 会话无法使用 Git 状态。',
- // Model on this Mac (local-model-ui.js: the welcome card and its App Settings group). The first
- // block is the module's own text; sizes and names fill the {placeholder} slots.
+ // Run a model on this Mac (local-model-ui.js: the welcome button, the chooser window and its
+ // App Settings group). The first block is the page's and the module's own text; sizes and
+ // names fill the {placeholder} slots.
'Model on this Mac': '此 Mac 上的模型',
- 'What the Mac that Codeman runs on can run as a local coding model. Read-only: nothing is downloaded or installed from here.':
- '运行 {name} 的这台 Mac 可以运行哪种本地编程模型。只读:此处不会下载或安装任何内容。',
+ 'Run a coding model on the Mac that Codeman runs on. No account and no API key.':
+ '在运行 {name} 的这台 Mac 上运行编程模型。无需账号,也无需 API 密钥。',
+ 'Run a model on this Mac': '在此 Mac 上运行模型',
+ 'Run a local model': '运行本地模型',
+ 'Local model': '本地模型',
'The Mac that Codeman runs on, which is not always the device you are reading on.':
'指运行 {name} 的那台 Mac,不一定是你正在使用的这台设备。',
- Hide: '隐藏',
- 'Hide this card on this device. It stays in App Settings, under Models.':
- '在此设备上隐藏此卡片。它仍保留在“应用设置”的“模型”中。',
'{chip}, {memory} GB memory, {free} GB free': '{chip},内存 {memory} GB,可用磁盘空间 {free} GB',
'{chip}, {memory} GB memory': '{chip},内存 {memory} GB',
'{memory} GB memory, {free} GB free': '内存 {memory} GB,可用磁盘空间 {free} GB',
'{memory} GB memory': '内存 {memory} GB',
- '{model} ({id}) with {harness}': '{model}({id}),搭配 {harness}',
- 'Context {context} tokens': '上下文 {context} 个 Token',
- '{need} GB of memory': '占用内存 {need} GB',
- '{download} GB download': '下载 {download} GB',
- 'Needs about {need} GB of the {budget} GB this Mac can give a model.':
- '大约需要 {need} GB,此 Mac 可分配给模型的内存为 {budget} GB。',
- 'Needs about {need} GB of the {budget} GB this Mac can give a model (estimated).':
- '大约需要 {need} GB,此 Mac 可分配给模型的内存约为 {budget} GB(估算)。',
- 'About {speed} tokens per second (estimated).': '每秒约 {speed} 个 Token(估算)。',
- '{harness} is not installed on this Mac yet.': '此 Mac 上尚未安装 {harness}。',
- 'With {harness}, the first answer can take minutes.': '使用 {harness} 时,第一次回答可能需要几分钟。',
- 'about {speed} tokens per second': '每秒约 {speed} 个 Token',
- // What the fit and speed labels measure (their tooltip and accessible name).
- 'Memory fit: needs {share}% of what this Mac can give a model':
- '内存占用:需要此 Mac 可分配给模型的内存的 {share}%',
- 'No speed estimate for this model on this chip': '此芯片上没有该模型的速度估算',
- 'Other models that fit': '其他装得下的模型',
- 'Codeman cannot download or install this for you yet. That is the next step, and it is not built yet.':
- '{name} 目前还不能为你下载或安装它。这是下一步的功能,尚未实现。',
+ // The App Settings row: where the install stands, in a few words.
+ 'No model installed': '尚未安装模型',
+ 'Installing {model}': '正在安装 {model}',
+ 'The install of {model} is paused': '{model} 的安装已暂停',
+ 'The install of {model} stopped': '{model} 的安装已中止',
+ '{model} is running': '{model} 正在运行',
+ '{model} is installed and stopped': '{model} 已安装,当前已停止',
+ 'Checking this Mac': '正在检查此 Mac',
+ // The chooser: the memory bar, the list, the coding agent.
+ 'About {need} of {budget} GB of memory': '约需 {need} GB 内存,共 {budget} GB 可用',
+ 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 上下文下的占用',
+ 'What stays free': '剩余可用',
+ 'Models that fit this Mac': '此 Mac 装得下的模型',
+ Speed: '速度',
+ Download: '下载',
+ 'speed not known': '速度未知',
+ '{speed} tokens/s': '{speed} Token/秒',
+ 'about {speed} tokens/s': '约 {speed} Token/秒',
+ 'Runs with': '搭配',
+ 'Not on this Mac yet. It is installed for you.': '此 Mac 上尚未安装,将为你一并安装。',
+ '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},用于运行模型的程序',
+ 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:': '模型和运行它的程序将存放在:',
+ 'No administrator password.': '无需管理员密码。',
+ 'Download and install': '下载并安装',
+ // Progress, and the same picture paused.
+ '{done} of {total} GB': '已下载 {done} GB,共 {total} GB',
+ '{speed} MB per second, {left}': '每秒 {speed} MB,{left}',
+ '{speed} MB per second': '每秒 {speed} MB',
+ 'less than a minute left': '剩余不到 1 分钟',
+ 'about 1 minute left': '剩余约 1 分钟',
+ 'about {minutes} minutes left': '剩余约 {minutes} 分钟',
+ 'about 1 hour left': '剩余约 1 小时',
+ 'about {hours} hours left': '剩余约 {hours} 小时',
+ 'Getting the runtime': '获取运行时',
+ 'Downloading the model': '下载模型',
+ 'Loading and checking': '加载并检查',
+ 'Installing {cli}': '安装 {cli}',
+ Connecting: '正在连接',
+ 'You can close this window. The download continues while Codeman is running.':
+ '你可以关闭此窗口。只要 {name} 在运行,下载就会继续。',
+ 'Nothing is downloaded until you continue.': '在你继续之前不会下载任何内容。',
+ Continue: '继续',
+ 'Choose another model': '选择其他模型',
+ // After an error: the one action that helps.
+ 'Use another port': '改用其他端口',
+ 'Try {model}': '试试 {model}',
+ Start: '启动',
+ 'The server did not accept that.': '服务器未接受该请求。',
+ // Installed, and the Remove question.
+ '{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。',
+ 'Start a session': '开始会话',
+ 'Remove the local model from this Mac?': '要从此 Mac 移除本地模型吗?',
+ 'Also delete the downloaded model': '同时删除已下载的模型',
+ 'The coding agent stays installed.': '编程智能体会保留,不会被卸载。',
// The sentences and labels the SERVER picks (LOCAL_MODEL_LABELS in src/types/local-model.ts), keyed
// by their exact English text: changing a text there means changing its key here.
'Best fit for this Mac': '最适合此 Mac 的选择',
@@ -1471,6 +1528,49 @@
'此 Mac 运行的是 macOS {version}。本地模型需要 macOS {minimum} 或更高版本。',
'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': '最适合',
+ '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.
+ 'The download of the runtime stopped and could not be continued. Check the network connection, then try again.':
+ '运行时的下载已中断,且无法继续。请检查网络连接,然后重试。',
+ 'The runtime download was damaged and has been deleted. Try again.': '下载的运行时已损坏,已被删除。请重试。',
+ 'The runtime could not be unpacked on this Mac.': '无法在此 Mac 上解压运行时。',
+ 'The runtime did not start on this Mac.': '运行时未能在此 Mac 上启动。',
+ 'The runtime keeps stopping, so Codeman no longer restarts it.': '运行时反复停止,因此 {name} 不再重新启动它。',
+ 'The model on this Mac is not running. Start it first.': '此 Mac 上的模型没有在运行。请先启动它。',
+ 'Port {port} is in use by another program.': '端口 {port} 已被其他程序占用。',
+ 'The disk is full. Free up space, then try again.': '磁盘已满。请腾出空间,然后重试。',
+ 'The download keeps stopping. The network may be damaging data. Try again or use another network.':
+ '下载反复中断。网络可能正在损坏数据。请重试,或换一个网络。',
+ '{model} is no longer offered under this name. Update Codeman or pick another model.':
+ '{model} 已不再以此名称提供。请更新 {name},或选择其他模型。',
+ '{model} does not fit in the memory of this Mac after all.': '{model} 终究装不进此 Mac 的内存。',
+ '{model} loaded with a context of {actual} tokens instead of {wanted}.':
+ '{model} 加载时的上下文为 {actual} 个 Token,而不是 {wanted} 个。',
+ '{model} did not load on this Mac.': '{model} 未能在此 Mac 上加载。',
+ '{model} loaded but did not call a tool, so a coding agent cannot work with it.':
+ '{model} 已加载,但没有调用工具,因此编程智能体无法使用它。',
+ 'The check of {model} did not finish.': '对 {model} 的检查没有完成。',
+ '{cli} could not be installed.': '无法安装 {cli}。',
+ 'A saved model connection named {id} already exists and was not created here. Rename or delete it in App Settings, then try again.':
+ '已存在名为 {id} 的已保存模型连接,而它不是在这里创建的。请在“应用设置”中重命名或删除它,然后重试。',
+ 'Codeman could not save the connection to the model.': '{name} 无法保存与模型的连接。',
+ 'The model on this Mac changed since it was installed. Remove it and install it again.':
+ '此 Mac 上的模型在安装后发生了变化。请移除后重新安装。',
+ 'The install stopped on an unexpected error.': '安装因意外错误而中止。',
+ 'An install is already running.': '已有一个安装正在进行。',
+ 'A model is already installed on this Mac. Remove it first.': '此 Mac 上已安装了一个模型。请先移除它。',
+ 'The download is {size} GB now. Check the sizes and confirm again.':
+ '现在的下载大小为 {size} GB。请核对大小后再次确认。',
+ 'Sessions are still using this model: {sessions}. Close them first.':
+ '仍有会话在使用此模型:{sessions}。请先关闭它们。',
+ 'That is not possible in the current state.': '当前状态下无法执行此操作。',
+ '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 会提示安装。',
});
const ZH_CN_LOWER = new Map(Object.entries(ZH_CN).map(([key, value]) => [key.toLocaleLowerCase('en'), value]));
diff --git a/src/web/public/index.html b/src/web/public/index.html
index 8026b7ce..abd35e38 100644
--- a/src/web/public/index.html
+++ b/src/web/public/index.html
@@ -511,6 +511,17 @@
contents on every state change and owns its inline display. -->
+
+
-
-
@@ -2876,20 +2879,24 @@
-
+ for a non-admin in multi-user mode (the routes are admin-only). Not gated on
+ the endpoints toggle above: an install switches that on itself. -->
Model on this Mac
-
What the Mac that Codeman runs on can run as a local coding model. Read-only: nothing is downloaded or installed from here.
+
Run a coding model on the Mac that Codeman runs on. No account and no API key.
-
+
+
+
+
@@ -3671,6 +3678,24 @@
+
+
+
+
+
+
Run a model on this Mac
+
+
+
+
+
+
diff --git a/src/web/public/local-model-ui.js b/src/web/public/local-model-ui.js
index 92c02af0..2609ddf2 100644
--- a/src/web/public/local-model-ui.js
+++ b/src/web/public/local-model-ui.js
@@ -1,56 +1,67 @@
/**
- * @fileoverview The "model on this Mac" card of the welcome screen and its row in App Settings.
+ * @fileoverview "Run a model on this Mac": the welcome button, the chooser window and the row in
+ * App Settings > Models.
*
- * Reads `GET /api/local-model` (hardware of the machine the server runs on, the recommendation,
- * the catalogue date) and shows, read-only:
- * - the hardware line (chip, memory, free disk);
- * - the verdict: the headline the server sent, then the pick with its numbers (model, harness,
- * context, memory need against the budget, download size, speed) and the notes;
- * - one line saying that Codeman cannot download or install the model yet: that is the next
- * step and is not built. It sits right under the pick, where a reader looks for a button;
- * - the other models that fit, each with its fit and speed label. A label says what it
- * measures in its tooltip and accessible name ("Good" is the memory fit, not a rating of
- * the model), and an alternative with a speed estimate prints the figure.
+ * Reads `GET /api/local-model` (the hardware of the machine the server runs on, the
+ * recommendation, and `install`: where the install stands and what can be done about it) and
+ * drives the install verbs:
*
- * ## One body, two containers
+ * POST /api/local-model/install { modelId, cli, context, acknowledgedBytes }
+ * POST /api/local-model/cancel
+ * POST /api/local-model/resume { newPort? }
+ * POST /api/local-model/runtime { action: 'start' | 'stop', newPort? }
+ * DELETE /api/local-model { removeModel }
*
- * The welcome card (`#localModelCard`) and the App Settings row (`#localModelSettings`) are
- * painted from the same payload by the same builder (`localModelBody`), so the two can never
- * name a different pick. They differ only in chrome: the card has a title and a Hide button and
- * folds the alternatives; the row lists them open.
+ * ## Three surfaces, one window
*
- * ## When the card shows
+ * - Welcome screen: ONE button (`#welcomeLocalModelBtn`) under the launchers, in the chip
+ * family. "Run a local model", or "Local model" once one is installed. Ships `hidden` and
+ * shows only once the server has answered for a Mac.
+ * - App Settings > Models: one row (`#localModelSettings`), the state in a few words, and a
+ * button that opens the window.
+ * - The window (`#localModelModal`): everything happens here. Which screen it shows follows
+ * `install.state`, plus the one choice the page holds itself (chooser or confirm):
*
- * The card ships `hidden` and appears only once the server has answered for a Mac. It stays
- * hidden while the first request is in flight and after a failed one (nothing is known about the
- * machine then, and an error card would be the only thing this feature ever showed on a server
- * that is not a Mac), when the answer is `not-macos`, when the request is refused (a non-admin in
- * multi-user mode gets a `403 FORBIDDEN`, which is an answer: stop asking) and after the user hid
- * it on this device.
+ * | Screen | When |
+ * |-------------|-------------------------------------------------------------------|
+ * | `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 |
+ * | `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 |
+ * | `none` | idle and nothing fits: the hardware line, the reason, Close |
*
- * The Settings group ("Model on this Mac") follows the same knowledge: it is hidden for
- * `not-macos` (most servers are not a Mac, and a group about "this Mac" there is false), for a
- * refused request and while the first request is in flight (nothing says yet that the server is
- * a Mac). It shows the answer for a Mac, and one failure sentence when the first request
- * failed. It is the way back to the numbers after a Hide.
+ * 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.
*
- * "Refused" means the envelope's `errorCode` is `FORBIDDEN`. The auth layer has a second 403 for
- * every /api route (`PASSWORD_CHANGE_REQUIRED`, an admin who must change the password first):
- * that one is a failed request, asked again on the next show and on `codeman:me`.
+ * ## Polling
*
- * Rules:
- * - Read-only: no control here downloads, installs or writes anything but the per-device
- * dismissal (a localStorage key, never a server setting).
- * - Every dynamic string goes in through `textContent`. Model, harness and chip names are
- * someone else's text, so both containers carry `data-i18n-skip` and this file translates
- * its own sentences at render time. A language or display-name change therefore re-renders
- * (`` is written by every i18n `configure()`).
- * - Names and labels come from the payload: this file holds no list of models or harnesses and
- * never compares a CLI id with a literal.
- * - Sizes are printed exactly as sent (`needGb`, `downloadGb`, `budgetGb`, `freeDiskGb`:
- * decimal GB as text). Bytes are never divided here. The installed memory is the one size
- * that is not decimal GB: the hardware line prints `memClassGb`, the size the Mac is sold as
- * (48, not 51.5), which is what About This Mac shows.
+ * The window asks the GET once a second WHILE IT IS OPEN, and never otherwise (there is no SSE
+ * event). Closing it clears the timer. The welcome button and the Settings row use the same
+ * answer and ask at most once a minute by themselves. A poll that changes nothing repaints
+ * nothing, and a repaint puts the keyboard focus back where it was.
+ *
+ * ## Rules
+ *
+ * - Every dynamic string goes in through `textContent`. Model, coding agent and chip names, an
+ * error's raw detail and a session name are someone else's text, so the window body, the
+ * button and the row carry `data-i18n-skip` and this file translates its own sentences at
+ * render time. A language or display-name change therefore repaints (`` is
+ * written by every i18n `configure()`).
+ * - 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
+ * figures this file does read are the consent total (sent back unchanged as
+ * `acknowledgedBytes`) and the two ends of a bar.
+ * - 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.
+ * - A button exists only for an action the server lists in `install.actions`.
* - A sentence the server chose arrives as `{ text, vars }` and goes through
* `localModelT(text, vars)`; this file's own strings are literals in that same call, in
* `{placeholder}` form, so the i18n test can harvest them. Both kinds need a zh-CN entry in
@@ -60,21 +71,20 @@
* @mixin Extends CodemanApp.prototype via Object.assign
* @dependency app.js (CodemanApp)
* @dependency api-client.js (_api)
+ * @dependency keyboard-accessory.js (FocusTrap, optional)
+ * @dependency session-ui.js (selectCustomModelEntry, for "Start a session")
+ * @dependency settings-ui.js (loadAppSettingsFromServer, loadCliListForSettings, optional)
* @dependency constants.js (CodemanBase, optional)
* @dependency i18n.js (codemanT, optional)
* @loadorder 12.59 of 16, after edit-history-ui.js, before entrance-animations.js
*/
-/**
- * Per-device dismissal of the welcome card, named for THIS stage (the read-only card). The stage
- * that can set a model up takes a key of its own, so hiding a card that offers nothing yet does
- * not hide the offer later.
- */
-const LOCAL_MODEL_DISMISS_KEY = 'codeman:local-model-dismissed:readonly';
-
/** The server caches its hardware probe for a minute, so asking again sooner cannot change the answer. */
const LOCAL_MODEL_MAX_AGE_MS = 60000;
+/** How often the open window asks where the install stands. */
+const LOCAL_MODEL_POLL_MS = 1000;
+
/** 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 : {};
@@ -111,7 +121,7 @@ function localModelSpeed(value) {
return String(value >= 10 ? Math.round(value) : Math.round(value * 10) / 10);
}
-/** Whether the page already knows the route will refuse it (a non-admin in multi-user mode). */
+/** Whether the page already knows the routes will refuse it (a non-admin in multi-user mode). */
function localModelNotAdmin() {
const me = (typeof window !== 'undefined' && window.__codemanUser) || {};
return !!(me.multiUser && me.role !== 'admin');
@@ -133,145 +143,6 @@ function localModelHardwareLine(hw) {
return localModelT('{memory} GB memory', sizes);
}
-/** What the fit label measures: the share of the memory budget the model needs. */
-function localModelFitHint(candidate) {
- if (!Number.isFinite(candidate.budgetShare)) return '';
- const share = String(Math.round(candidate.budgetShare * 100));
- return localModelT('Memory fit: needs {share}% of what this Mac can give a model', { share });
-}
-
-/** What the speed label stands for: the estimate behind "Slow", or that there is none. */
-function localModelSpeedHint(candidate) {
- const speed = localModelSpeed(candidate.tokensPerSecond);
- if (speed) return localModelT('About {speed} tokens per second (estimated).', { speed });
- return localModelT('No speed estimate for this model on this chip');
-}
-
-/**
- * "Qwen3.8 27B (qwen3.8:27b) with OpenCode", then the fit and speed labels the server chose.
- * A bare "Good" next to a model name reads as a rating of the model, so each label carries what
- * it measures as its tooltip and its accessible name.
- */
-function localModelCandidateHead(candidate) {
- const head = localModelEl('div', 'local-model-name-row');
- const name = localModelT('{model} ({id}) with {harness}', {
- model: candidate.modelLabel || candidate.modelId,
- id: candidate.modelId,
- harness: candidate.cliLabel || candidate.cli,
- });
- head.appendChild(localModelEl('span', 'local-model-name', name));
- const pills = [
- [candidate.fitLabel, localModelFitHint],
- [candidate.speedLabel, localModelSpeedHint],
- ];
- for (const [label, hint] of pills) {
- if (typeof label !== 'string' || !label) continue;
- const pill = localModelEl('span', 'local-model-pill', localModelT(label));
- const meaning = hint(candidate);
- if (meaning) {
- pill.title = meaning;
- pill.setAttribute('role', 'note');
- pill.setAttribute('aria-label', `${pill.textContent}. ${meaning}`);
- }
- head.appendChild(pill);
- }
- return head;
-}
-
-/**
- * The dim line under a candidate's name: context, then for an alternative its memory need and,
- * when there is an estimate, its speed (so "Slow" comes with a number on a screen without
- * hover), the download, and for the pick the license.
- */
-function localModelMeta(candidate, { alternative = false } = {}) {
- const bits = [localModelT('Context {context} tokens', { context: localModelCount(candidate.context) })];
- if (alternative && candidate.needGb) bits.push(localModelT('{need} GB of memory', { need: candidate.needGb }));
- if (candidate.downloadGb) bits.push(localModelT('{download} GB download', { download: candidate.downloadGb }));
- const speed = alternative ? localModelSpeed(candidate.tokensPerSecond) : '';
- if (speed) bits.push(localModelT('about {speed} tokens per second', { speed }));
- if (!alternative && candidate.license) bits.push(String(candidate.license));
- return bits.join(' · ');
-}
-
-/** The pick with its numbers. */
-function localModelPick(pick, recommendation) {
- const box = localModelEl('div', 'local-model-pick');
- box.appendChild(localModelCandidateHead(pick));
- box.appendChild(localModelEl('div', 'local-model-line', localModelMeta(pick)));
- const sizes = { need: pick.needGb, budget: recommendation.budgetGb };
- // A wired limit is a setting of this Mac; anything else is the formula's estimate, and says so.
- const need =
- recommendation.budgetSource === 'wired-limit'
- ? localModelT('Needs about {need} GB of the {budget} GB this Mac can give a model.', sizes)
- : localModelT('Needs about {need} GB of the {budget} GB this Mac can give a model (estimated).', sizes);
- box.appendChild(localModelEl('div', 'local-model-line', need));
- const speed = localModelSpeed(pick.tokensPerSecond);
- if (speed) {
- const line = localModelT('About {speed} tokens per second (estimated).', { speed });
- box.appendChild(localModelEl('div', 'local-model-line', line));
- }
- const harness = pick.cliLabel || pick.cli;
- if (pick.cliInstalled === false) {
- const line = localModelT('{harness} is not installed on this Mac yet.', { harness });
- box.appendChild(localModelEl('div', 'local-model-line', line));
- }
- if (pick.slowFirstTurn === true) {
- const line = localModelT('With {harness}, the first answer can take minutes.', { harness });
- box.appendChild(localModelEl('div', 'local-model-line', line));
- }
- return box;
-}
-
-/**
- * The other models that fit. Folded behind a native disclosure on the welcome card, open in
- * Settings. No count beside the title: the server lists four at most, so the number of rows is
- * not the number of models that fit.
- */
-function localModelAlternatives(alternatives, fold) {
- const wrap = localModelEl(fold ? 'details' : 'div', 'local-model-alts');
- const title = localModelEl(fold ? 'summary' : 'div', 'local-model-alts-title', localModelT('Other models that fit'));
- wrap.appendChild(title);
- for (const candidate of alternatives) {
- const row = localModelEl('div', 'local-model-alt');
- row.appendChild(localModelCandidateHead(candidate));
- row.appendChild(localModelEl('div', 'local-model-line', localModelMeta(candidate, { alternative: true })));
- wrap.appendChild(row);
- }
- return wrap;
-}
-
-/**
- * Everything the server said, in reading order. The ONLY builder: the card and the Settings row
- * both call it, see @fileoverview.
- */
-function localModelBody(status, { fold }) {
- const hardware = status.hardware || {};
- const recommendation = status.recommendation || {};
- const body = localModelEl('div', 'local-model-body');
- // Not a Mac: one plain sentence, and no line calling another machine's parts "this Mac".
- const hardwareLine = recommendation.reason === 'not-macos' ? '' : localModelHardwareLine(hardware);
- if (hardwareLine) body.appendChild(localModelEl('div', 'local-model-hw', hardwareLine));
- const headline = localModelMessage(recommendation.headline);
- if (headline) body.appendChild(localModelEl('div', 'local-model-headline', headline));
- const pick = recommendation.verdict === 'none' ? null : recommendation.pick;
- if (pick) body.appendChild(localModelPick(pick, recommendation));
- for (const note of Array.isArray(recommendation.notes) ? recommendation.notes : []) {
- const text = localModelMessage(note);
- if (text) body.appendChild(localModelEl('div', 'local-model-note', text));
- }
- // Right under the pick and its notes, above the list: the pick names a download and maybe a
- // harness that is not installed, so this is where a reader looks for a button.
- if (pick) {
- const next = localModelT(
- 'Codeman cannot download or install this for you yet. That is the next step, and it is not built yet.'
- );
- body.appendChild(localModelEl('div', 'local-model-next', next));
- }
- const alternatives = pick && Array.isArray(recommendation.alternatives) ? recommendation.alternatives : [];
- if (alternatives.length) body.appendChild(localModelAlternatives(alternatives, fold));
- return body;
-}
-
/** The `data` of the GET, or null for anything that is not one. */
function localModelPayload(body) {
const data = body && body.success === true ? body.data : null;
@@ -280,17 +151,145 @@ function localModelPayload(body) {
return data;
}
+/**
+ * The `install` object of the payload. A server that does not send one yet is read as "nothing
+ * installed, and nothing can be done from here": the chooser shows without its button.
+ */
+function localModelInstall(status) {
+ const install = status && status.install && typeof status.install === 'object' ? status.install : null;
+ if (install && typeof install.state === 'string') {
+ return { ...install, actions: Array.isArray(install.actions) ? install.actions : [] };
+ }
+ return { state: 'idle', running: false, developerTools: 'unknown', actions: [] };
+}
+
+/** The rows of the chooser: the pick first, then the other models that fit. */
+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');
+}
+
+/**
+ * The coding agents a row can run with. A payload without the list means the one agent of the
+ * row's flat fields.
+ */
+function localModelHarnesses(candidate) {
+ if (Array.isArray(candidate.harnesses) && candidate.harnesses.length) return candidate.harnesses;
+ return [
+ {
+ cli: candidate.cli,
+ cliLabel: candidate.cliLabel,
+ installed: candidate.cliInstalled !== false,
+ slowFirstTurn: candidate.slowFirstTurn === true,
+ },
+ ];
+}
+
+/**
+ * The server's own total for a row: what a click on Install downloads, in bytes. A payload
+ * without it means the model plus the runtime archive while the runtime is not installed.
+ */
+function localModelInstallBytes(candidate, install) {
+ if (Number.isFinite(candidate.installBytes)) return candidate.installBytes;
+ const runtime = install.runtime && Number.isFinite(install.runtime.downloadBytes) ? install.runtime.downloadBytes : 0;
+ return (Number.isFinite(candidate.downloadBytes) ? candidate.downloadBytes : 0) + runtime;
+}
+
+/** A size as sent (decimal GB text) with its unit. The unit is the same in every language. */
+function localModelGb(value) {
+ return `${value} GB`;
+}
+
+/** "about 68 tokens/s", the measured figure without "about", or that there is none. */
+function localModelSpeedText(candidate) {
+ const speed = localModelSpeed(candidate.tokensPerSecond);
+ if (!speed) return localModelT('speed not known');
+ if (candidate.tested === true) return localModelT('{speed} tokens/s', { speed });
+ return localModelT('about {speed} tokens/s', { speed });
+}
+
+/** How long a download still takes, in the words of a person. Empty when the server sent no figure. */
+function localModelTimeLeft(seconds) {
+ if (!Number.isFinite(seconds) || seconds < 0) return '';
+ if (seconds < 60) return localModelT('less than a minute left');
+ if (seconds < 3600) {
+ const minutes = Math.max(1, Math.round(seconds / 60));
+ if (minutes === 1) return localModelT('about 1 minute left');
+ return localModelT('about {minutes} minutes left', { minutes: String(minutes) });
+ }
+ const hours = Math.max(1, Math.round(seconds / 3600));
+ if (hours === 1) return localModelT('about 1 hour left');
+ return localModelT('about {hours} hours left', { hours: String(hours) });
+}
+
+/** What a step of the install is called. The coding agent's name comes from the payload. */
+function localModelStepLabel(step, cliLabel) {
+ if (step === 'runtime') return localModelT('Getting the runtime');
+ if (step === 'model') return localModelT('Downloading the model');
+ if (step === 'verify') return localModelT('Loading and checking');
+ if (step === 'agent') return localModelT('Installing {cli}', { cli: cliLabel });
+ if (step === 'connect') return localModelT('Connecting');
+ return String(step);
+}
+
+/**
+ * A button of the window, in the button set of the Git worktrees dialog (`wt-btn`): its main
+ * action reads as one on every skin, and the phone toolbar's sizing does not reach it.
+ */
+function localModelButton(label, { key, primary = false, danger = false, disabled = false, onClick } = {}) {
+ const button = localModelEl('button', 'wt-btn lm-btn', label);
+ button.type = 'button';
+ if (primary) button.classList.add('wt-btn-primary');
+ if (danger) button.classList.add('wt-btn-danger');
+ if (key) button.dataset.lmKey = key;
+ if (disabled) button.disabled = true;
+ if (typeof onClick === 'function') button.addEventListener('click', onClick);
+ return button;
+}
+
+/**
+ * The bar: a track filled to `share` (0 to 1). `kind` names it for the width transition: a
+ * repaint starts the new bar at the width the last one of its kind had, so a selection or a
+ * poll moves the fill instead of redrawing it.
+ */
+function localModelBar({ share, role, label, kind, from }) {
+ const percent = Math.max(0, Math.min(100, Math.round((Number.isFinite(share) ? share : 0) * 100)));
+ const bar = localModelEl('div', 'lm-bar');
+ bar.setAttribute('role', role);
+ bar.setAttribute('aria-valuemin', '0');
+ bar.setAttribute('aria-valuemax', '100');
+ bar.setAttribute('aria-valuenow', String(percent));
+ if (label) bar.setAttribute('aria-label', label);
+ const fill = localModelEl('div', 'lm-bar-fill');
+ fill.dataset.kind = kind;
+ fill.dataset.to = String(percent);
+ fill.style.width = `${Number.isFinite(from) ? from : percent}%`;
+ bar.appendChild(fill);
+ return bar;
+}
+
+/** One line of the confirm screen's download list: what, and how large. */
+function localModelSizeRow(label, gb, { 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)));
+ return row;
+}
+
Object.assign(CodemanApp.prototype, {
// ═══════════════════════════════════════════════════════════════
// Data
// ═══════════════════════════════════════════════════════════════
/**
- * Ask the server what this Mac can run, at most once a minute, and paint both containers.
- * Called when the welcome screen shows and when App Settings opens. Idempotent: a request in
- * flight is shared, and a fresh answer is only re-rendered.
+ * Ask the server what this Mac can run and where the install stands, and paint every surface.
+ * Called when the welcome screen shows and when App Settings opens (at most one request a
+ * minute then), and with `force` by the open window's poll and after a verb. Idempotent: a
+ * request in flight is shared.
*/
- loadLocalModel() {
+ loadLocalModel({ force = false } = {}) {
this._wireLocalModel();
if (this._localModelRefused()) {
this.renderLocalModel();
@@ -298,7 +297,7 @@ Object.assign(CodemanApp.prototype, {
}
if (this._localModelInFlight) return this._localModelInFlight;
const fresh = this._localModelStatus && Date.now() - (this._localModelLoadedAt || 0) < LOCAL_MODEL_MAX_AGE_MS;
- if (fresh) {
+ if (fresh && !force) {
this.renderLocalModel();
return Promise.resolve();
}
@@ -312,6 +311,7 @@ Object.assign(CodemanApp.prototype, {
},
async _fetchLocalModel() {
+ const verbs = this._localModelVerbs || 0;
// `_api`, not `_apiJson`: a refusal (admin-only in multi-user mode) is an answer and must be
// told apart from a request that failed.
const res = await this._api(localModelUrl('/api/local-model'));
@@ -323,12 +323,16 @@ Object.assign(CodemanApp.prototype, {
body = null;
}
}
+ // A verb was answered while this request was on its way: what it carries is older than
+ // what is on screen, and the verb has asked again already.
+ if ((this._localModelVerbs || 0) !== verbs) return;
// Only the route's own refusal is final. The auth layer answers 403 too, with
// PASSWORD_CHANGE_REQUIRED, for an admin who must change the password first: that is a
// failed request, asked again once the password is changed.
if (res?.status === 403 && body?.errorCode === 'FORBIDDEN') {
this._localModelState = 'refused';
this._localModelStatus = null;
+ this.closeLocalModelWindow();
this.renderLocalModel();
return;
}
@@ -345,110 +349,1139 @@ Object.assign(CodemanApp.prototype, {
this.renderLocalModel();
},
- // ═══════════════════════════════════════════════════════════════
- // Dismissal (per device)
- // ═══════════════════════════════════════════════════════════════
-
- isLocalModelCardDismissed() {
- if (this._localModelDismissed === true) return true;
- try {
- return localStorage.getItem(LOCAL_MODEL_DISMISS_KEY) === '1';
- } catch {
- return false;
- }
+ _localModelRefused() {
+ return this._localModelState === 'refused' || localModelNotAdmin();
},
- /** Hide the welcome card on this device. The Settings row keeps showing the same answer. */
- dismissLocalModelCard() {
- // Remembered on the instance too: with storage blocked the card still stays away until a reload.
- this._localModelDismissed = true;
- try {
- localStorage.setItem(LOCAL_MODEL_DISMISS_KEY, '1');
- } catch {
- /* storage blocked: hidden for this page load only */
+ /** What the page itself holds about the window: open or not, and the choices made in it. */
+ _localModelWindowState() {
+ if (!this._localModelUi) {
+ this._localModelUi = {
+ open: false,
+ /** `auto`: the screen the install state dictates. `choose` and `confirm`: the person went there. */
+ view: 'auto',
+ /** The selected row's model id, or null for the server's pick. */
+ modelId: null,
+ /** The selected coding agent (a CLI id, as data), or null for the row's first. */
+ cli: null,
+ /** A verb is in flight: every button waits. */
+ busy: false,
+ /** What the last verb answered when it was refused. */
+ notice: null,
+ /** After a 409 `size-changed`: the server's own total for the row, to show and to send. */
+ expected: null,
+ /** The Remove question is open, and whether the downloaded model goes too. */
+ confirmRemove: false,
+ removeModel: true,
+ };
+ }
+ return this._localModelUi;
+ },
+
+ /** The selected row and coding agent, or null when no model fits. */
+ _localModelSelection() {
+ const ui = this._localModelWindowState();
+ const candidates = localModelCandidates(this._localModelStatus);
+ if (!candidates.length) return null;
+ const candidate = candidates.find((row) => row.modelId === ui.modelId) || candidates[0];
+ const harnesses = localModelHarnesses(candidate);
+ const harness = harnesses.find((entry) => entry.cli === ui.cli) || harnesses[0];
+ return { candidates, candidate, harnesses, harness };
+ },
+
+ /** Which screen the window shows, see the table in @fileoverview. */
+ _localModelScreen() {
+ const ui = this._localModelWindowState();
+ const install = localModelInstall(this._localModelStatus);
+ const selection = this._localModelSelection();
+ if (install.state === 'installing') return 'progress';
+ if (install.state === 'installed') return 'installed';
+ const canInstall = install.actions.includes('install') && !!selection;
+ if (canInstall && (ui.view === 'choose' || ui.view === 'confirm')) return ui.view;
+ if (install.state === 'paused') return 'paused';
+ if (install.state === 'failed') return 'failed';
+ return selection ? 'choose' : 'none';
+ },
+
+ // ═══════════════════════════════════════════════════════════════
+ // The window: open, close, poll
+ // ═══════════════════════════════════════════════════════════════
+
+ isLocalModelWindowOpen() {
+ return this._localModelWindowState().open === true;
+ },
+
+ /** Open the window (welcome button, Settings row) and start asking once a second. */
+ openLocalModelWindow() {
+ const modal = document.getElementById('localModelModal');
+ 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 });
+ this._forgetLocalModelPaint();
+ modal.classList.add('active');
+ // A trap of its own: the window can open on top of App Settings, whose trap must stay
+ // that modal's. Only the Tab cycle of FocusTrap is used, so the first focus is ours to place.
+ if (typeof FocusTrap === 'function') {
+ this._localModelFocusTrap = new FocusTrap(modal);
+ this._localModelFocusTrap.previouslyFocused = document.activeElement;
+ modal.addEventListener('keydown', this._localModelFocusTrap.boundHandleKeydown);
+ } else {
+ this._localModelReturnFocus = document.activeElement;
}
this.renderLocalModel();
+ this._focusLocalModelWindow(null);
+ void this.loadLocalModel({ force: true });
+ this._scheduleLocalModelPoll();
+ },
+
+ /**
+ * Close the window and stop asking. Called by the close button, the backdrop, Escape and
+ * "Start a session"; a no-op unless the window is open.
+ */
+ closeLocalModelWindow() {
+ const ui = this._localModelWindowState();
+ if (!ui.open) return;
+ ui.open = false;
+ if (this._localModelPollTimer) clearTimeout(this._localModelPollTimer);
+ this._localModelPollTimer = null;
+ document.getElementById('localModelModal')?.classList.remove('active');
+ if (this._localModelFocusTrap) {
+ this._localModelFocusTrap.deactivate();
+ this._localModelFocusTrap = null;
+ } else if (this._localModelReturnFocus && typeof this._localModelReturnFocus.focus === 'function') {
+ this._localModelReturnFocus.focus();
+ }
+ this._localModelReturnFocus = null;
+ // The button and the row may say something else now (installed, installing).
+ this.renderLocalModel();
+ },
+
+ /** One tick a second while the window is open; the timer is not re-armed once it is closed. */
+ _scheduleLocalModelPoll() {
+ if (this._localModelPollTimer) clearTimeout(this._localModelPollTimer);
+ this._localModelPollTimer = null;
+ if (!this.isLocalModelWindowOpen()) return;
+ this._localModelPollTimer = setTimeout(() => {
+ this._localModelPollTimer = null;
+ if (!this.isLocalModelWindowOpen()) return;
+ // A tab in the background keeps its place in the queue and asks nothing.
+ const asked = document.hidden === true ? Promise.resolve() : this.loadLocalModel({ force: true });
+ void Promise.resolve(asked).finally(() => this._scheduleLocalModelPoll());
+ }, LOCAL_MODEL_POLL_MS);
+ },
+
+ // ═══════════════════════════════════════════════════════════════
+ // Verbs
+ // ═══════════════════════════════════════════════════════════════
+
+ /**
+ * Send one install verb. On success the answer is the `install` object after the verb, which
+ * is painted at once; a 409 leaves the server's sentence on screen. Either way the whole
+ * status is read again, because a verb can change the recommendation too.
+ */
+ async _localModelVerb(method, path, body) {
+ const ui = this._localModelWindowState();
+ if (ui.busy) return { ok: false };
+ ui.busy = true;
+ ui.notice = null;
+ this.renderLocalModel();
+ let res = null;
+ let json = null;
+ try {
+ res = await this._api(localModelUrl(path), body === undefined ? { method } : { method, body });
+ if (res) {
+ try {
+ json = await res.json();
+ } catch {
+ json = null;
+ }
+ }
+ } finally {
+ ui.busy = false;
+ }
+ 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') {
+ 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 {
+ ui.notice = res
+ ? { refused: true, detail: typeof json?.error === 'string' ? json.error : '' }
+ : { unreachable: true };
+ result = { ok: false };
+ }
+ this._localModelVerbs = (this._localModelVerbs || 0) + 1;
+ this.renderLocalModel();
+ // After any read that was already under way (its answer is dropped, see _fetchLocalModel).
+ void Promise.resolve(this._localModelInFlight).then(() => this.loadLocalModel({ force: true }));
+ return result;
+ },
+
+ /** "Install on this Mac": the confirm screen with the sizes. Nothing is sent yet. */
+ reviewLocalModelInstall() {
+ const ui = this._localModelWindowState();
+ Object.assign(ui, { view: 'confirm', notice: null, expected: null });
+ this.renderLocalModel();
+ },
+
+ /** Back to the chooser (from the confirm screen, or to pick another model after a stop). */
+ chooseLocalModel(modelId) {
+ const ui = this._localModelWindowState();
+ Object.assign(ui, { view: 'choose', notice: null, expected: null, confirmRemove: false });
+ if (typeof modelId === 'string') {
+ ui.modelId = modelId;
+ ui.cli = null;
+ }
+ this.renderLocalModel();
+ },
+
+ /** Back from the chooser to what the install state says (a paused or stopped install). */
+ leaveLocalModelChooser() {
+ const ui = this._localModelWindowState();
+ Object.assign(ui, { view: 'auto', notice: null, expected: null });
+ this.renderLocalModel();
+ },
+
+ /** Select a row of the chooser. The coding agent stays when the new row offers it too. */
+ selectLocalModel(modelId) {
+ const ui = this._localModelWindowState();
+ if (ui.busy) return;
+ ui.modelId = modelId;
+ ui.expected = null;
+ this.renderLocalModel();
+ },
+
+ /**
+ * "Download and install": the consent click. Sends the row with the total that was on screen;
+ * a server whose own total differs answers 409, and the screen then shows that total.
+ */
+ async installLocalModel() {
+ const ui = this._localModelWindowState();
+ const selection = this._localModelSelection();
+ if (!selection) return;
+ const { candidate, harness } = selection;
+ const install = localModelInstall(this._localModelStatus);
+ const expected = ui.expected && ui.expected.modelId === candidate.modelId ? ui.expected : null;
+ const result = await this._localModelVerb('POST', '/api/local-model/install', {
+ modelId: candidate.modelId,
+ cli: harness.cli,
+ context: candidate.context,
+ acknowledgedBytes: expected ? expected.bytes : localModelInstallBytes(candidate, install),
+ });
+ if (result.ok) {
+ Object.assign(ui, { view: 'auto', expected: null });
+ } else if (result.conflict?.reason === 'size-changed' && Number.isFinite(result.conflict.expectedBytes)) {
+ ui.expected = {
+ modelId: candidate.modelId,
+ bytes: result.conflict.expectedBytes,
+ gb: typeof result.conflict.expectedGb === 'string' ? result.conflict.expectedGb : '',
+ };
+ } else if (result.conflict) {
+ // A job is running or a model is installed already: show that, with the server's sentence.
+ ui.view = 'auto';
+ }
+ this.renderLocalModel();
+ },
+
+ cancelLocalModelInstall() {
+ return this._localModelVerb('POST', '/api/local-model/cancel');
+ },
+
+ /** "Continue", "Try again", "Use another port" of a paused or stopped install. */
+ resumeLocalModelInstall({ newPort = false } = {}) {
+ return this._localModelVerb('POST', '/api/local-model/resume', newPort ? { newPort: true } : {});
+ },
+
+ /** "Start" and "Stop" of an installed model's runtime. */
+ setLocalModelRuntime(action, { newPort = false } = {}) {
+ return this._localModelVerb('POST', '/api/local-model/runtime', newPort ? { action, newPort: true } : { action });
+ },
+
+ /** "Remove": first the question, then (with `confirmed`) the delete. */
+ async removeLocalModel({ confirmed = false } = {}) {
+ const ui = this._localModelWindowState();
+ if (!confirmed) {
+ Object.assign(ui, { confirmRemove: true, notice: null });
+ this.renderLocalModel();
+ return;
+ }
+ const result = await this._localModelVerb('DELETE', '/api/local-model', { removeModel: ui.removeModel === true });
+ if (result.ok) Object.assign(ui, { confirmRemove: false, view: 'auto', modelId: null, cli: null });
+ this.renderLocalModel();
+ },
+
+ /**
+ * "Start a session": a session on the saved connection with the coding agent of the install.
+ * The page's own copies are stale right after an install (the settings flag was switched on
+ * by the server and the agent may have been installed a moment ago), so both are read again
+ * before the existing launcher runs. Both values are the payload's; no id is compared.
+ */
+ async startLocalModelSession() {
+ const install = localModelInstall(this._localModelStatus);
+ if (typeof install.cli !== 'string' || typeof install.endpointId !== 'string') return;
+ const settingsOpen = document.getElementById('appSettingsModal')?.classList.contains('active');
+ this.closeLocalModelWindow();
+ if (settingsOpen) this.closeAppSettings?.();
+ await this.loadAppSettingsFromServer?.();
+ await this.loadCliListForSettings?.();
+ await this.selectCustomModelEntry?.(install.cli, install.endpointId);
},
// ═══════════════════════════════════════════════════════════════
// Render
// ═══════════════════════════════════════════════════════════════
- /** Paint every mounted container (welcome card + App Settings row). */
+ /** Paint every surface: the welcome button, the App Settings row and, when open, the window. */
renderLocalModel() {
- const card = document.getElementById('localModelCard');
- if (card) this._renderLocalModelCard(card);
- const row = document.getElementById('localModelSettings');
- if (row) this._renderLocalModelSettings(row);
+ this._renderLocalModelButton();
+ this._renderLocalModelSettings();
+ this._renderLocalModelWindow();
},
- _localModelRefused() {
- return this._localModelState === 'refused' || localModelNotAdmin();
- },
-
- _renderLocalModelCard(card) {
- // The one piece of state the card holds itself: whether the alternatives were unfolded.
- const wasOpen = card.querySelector('details')?.open === true;
- card.textContent = '';
+ /** Whether the server answered for a Mac (the only case in which anything about "this Mac" shows). */
+ _localModelOnMac() {
const status = this._localModelStatus;
- const hidden =
- !status ||
- this._localModelRefused() ||
- status.recommendation.reason === 'not-macos' ||
- this.isLocalModelCardDismissed();
- card.hidden = hidden;
- if (hidden) return;
-
- const head = localModelEl('div', 'local-model-head');
- const title = localModelEl('h3', 'local-model-title', localModelT('Model on this Mac'));
- title.id = 'localModelCardTitle';
- // The page can be open on another laptop or a tablet: "this Mac" is the server's.
- title.title = localModelT('The Mac that Codeman runs on, which is not always the device you are reading on.');
- head.appendChild(title);
- const hide = localModelEl('button', 'local-model-hide', localModelT('Hide'));
- hide.type = 'button';
- hide.title = localModelT('Hide this card on this device. It stays in App Settings, under Models.');
- hide.addEventListener('click', () => this.dismissLocalModelCard());
- head.appendChild(hide);
- card.appendChild(head);
-
- const box = localModelEl('div', 'local-model-box');
- box.appendChild(localModelBody(status, { fold: true }));
- card.appendChild(box);
- const details = card.querySelector('details');
- if (details && wasOpen) details.open = true;
+ return !!status && !this._localModelRefused() && status.recommendation.reason !== 'not-macos';
},
- _renderLocalModelSettings(row) {
+ _renderLocalModelButton() {
+ const button = document.getElementById('welcomeLocalModelBtn');
+ if (!button) return;
+ const shown = this._localModelOnMac();
+ button.hidden = !shown;
+ if (!shown) return;
+ const installed = localModelInstall(this._localModelStatus).state === 'installed';
+ const label = installed ? localModelT('Local model') : localModelT('Run a local model');
+ const slot = document.getElementById('welcomeLocalModelLabel') || button;
+ slot.textContent = label;
+ // The page can be open on another laptop or a tablet: "this Mac" is the server's.
+ button.title = localModelT('The Mac that Codeman runs on, which is not always the device you are reading on.');
+ },
+
+ /** The state in a few words, for the Settings row. */
+ _localModelSummary() {
+ const status = this._localModelStatus;
+ const install = localModelInstall(status);
+ const model = install.model ? String(install.model.label || install.model.id || '') : '';
+ if (install.state === 'installing') return localModelT('Installing {model}', { model });
+ if (install.state === 'paused') return localModelT('The install of {model} is paused', { model });
+ if (install.state === 'failed') return localModelT('The install of {model} stopped', { model });
+ if (install.state === 'installed') {
+ if (install.running) return localModelT('{model} is running', { model });
+ return localModelT('{model} is installed and stopped', { model });
+ }
+ return localModelT('No model installed');
+ },
+
+ _renderLocalModelSettings() {
+ const row = document.getElementById('localModelSettings');
+ if (!row) return;
row.textContent = '';
const status = this._localModelStatus;
const failed = !status && this._localModelState === 'failed';
// The whole group goes for a non-admin, the way the other admin-only groups of this modal
// do, and for a server that is not a Mac: its title and hint are about "this Mac". Until
// the first answer nothing says the server is one, so the group waits for it.
- const shown = !this._localModelRefused() && (failed || (!!status && status.recommendation.reason !== 'not-macos'));
+ const shown = !this._localModelRefused() && (failed || this._localModelOnMac());
const group = document.getElementById('localModelGroup');
if (group) group.style.display = shown ? '' : 'none';
+ const open = document.getElementById('localModelSettingsOpen');
+ if (open) open.hidden = !shown || !status;
if (!shown) return;
- if (status) {
- row.appendChild(localModelBody(status, { fold: false }));
+ if (!status) {
+ row.appendChild(localModelEl('span', 'set-row-label', localModelT('Could not reach the server.')));
return;
}
- row.appendChild(localModelEl('div', 'local-model-headline', localModelT('Could not reach the server.')));
+ row.appendChild(localModelEl('span', 'set-row-label', this._localModelSummary()));
+ const install = localModelInstall(status);
+ const none = install.state === 'idle' && !localModelCandidates(status).length;
+ // Nothing fits: the reason, in the server's sentence. Otherwise what the Mac is.
+ const detail = none
+ ? localModelMessage(status.recommendation.headline)
+ : localModelHardwareLine(status.hardware || {});
+ if (detail) row.appendChild(localModelEl('span', 'set-row-desc', detail));
+ if (open) {
+ const choosing = install.state === 'idle' && !none;
+ open.textContent = choosing ? localModelT('Choose a model') : localModelT('Open');
+ }
+ },
+
+ /**
+ * Paint the window's body. Skipped when nothing it shows has changed (the poll asks every
+ * second). A tick that moved only the download replaces the bar and its figure alone, so a
+ * button is never swapped out under a click; any other change rebuilds the screen and puts
+ * the keyboard focus back on the control it was on.
+ */
+ _renderLocalModelWindow() {
+ const ui = this._localModelWindowState();
+ const body = document.getElementById('localModelWindowBody');
+ if (!body || !ui.open) return;
+ const status = this._localModelStatus;
+ const key = JSON.stringify([status, this._localModelState, ui]);
+ if (key === this._localModelWindowKey) return;
+ this._localModelWindowKey = key;
+ // The same picture without the download figures: equal means only the download moved.
+ const still = status && status.install ? { ...status, install: { ...status.install, progress: null } } : status;
+ const frame = JSON.stringify([still, this._localModelState, ui]);
+ const last = this._localModelBarLast || {};
+ const screen = this._localModelScreen();
+ 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;
+ }
+ this._localModelFrameKey = frame;
+
+ const modal = document.getElementById('localModelModal');
+ const active = document.activeElement;
+ const inside = !!(modal && active && modal.contains(active));
+ const focusKey = inside && active.dataset ? active.dataset.lmKey : undefined;
+ const scrolled = body.scrollTop;
+ body.dataset.screen = screen;
+ body.replaceChildren(...this._buildLocalModelScreen(screen, last));
+ body.scrollTop = scrolled;
+ this._settleLocalModelBars(body);
+ if (inside || active === document.body) this._focusLocalModelWindow(focusKey);
+ },
+
+ /**
+ * Forget what the window last painted, so the next render rebuilds the whole screen. Both
+ * keys go: with the frame key left, a progress screen would repaint its bar alone and keep
+ * every other word in the language it was painted in.
+ */
+ _forgetLocalModelPaint() {
+ this._localModelWindowKey = null;
+ this._localModelFrameKey = null;
+ },
+
+ /** Each bar was built at the width the last one of its kind had: move it to its own. */
+ _settleLocalModelBars(body) {
+ const painted = {};
+ for (const fill of body.querySelectorAll('.lm-bar-fill')) {
+ fill.getBoundingClientRect();
+ fill.style.width = `${fill.dataset.to}%`;
+ painted[fill.dataset.kind] = Number(fill.dataset.to);
+ }
+ this._localModelBarLast = painted;
+ },
+
+ /** Put the focus on the control named `key`, else on the screen's main action, else on Close. */
+ _focusLocalModelWindow(key) {
+ 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);
+ 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') ||
+ keyed.find((el) => el.dataset.lmKey === 'primary') ||
+ keyed.find((el) => el.getAttribute('tabindex') !== '-1') ||
+ modal.querySelector('.modal-close');
+ if (target && typeof target.focus === 'function') target.focus({ preventScroll: true });
+ },
+
+ _buildLocalModelScreen(screen, last) {
+ const status = this._localModelStatus;
+ if (!status) {
+ const waiting = this._localModelState === 'failed';
+ const text = waiting ? localModelT('Could not reach the server.') : localModelT('Checking this Mac');
+ return [localModelEl('p', 'lm-lead', text)];
+ }
+ if (screen === 'choose') return this._buildLocalModelChooser(last);
+ if (screen === 'confirm') return this._buildLocalModelConfirm();
+ if (screen === 'progress' || screen === 'paused') return this._buildLocalModelProgress(screen, last);
+ if (screen === 'failed') return this._buildLocalModelFailed();
+ if (screen === 'installed') return this._buildLocalModelInstalled();
+ return this._buildLocalModelNone();
+ },
+
+ /** The hardware line every idle screen opens with. */
+ _localModelHardwareEl() {
+ const text = localModelHardwareLine(this._localModelStatus.hardware || {});
+ return text ? localModelEl('p', 'lm-hw', text) : null;
+ },
+
+ /** What the last verb answered when it was refused, or nothing. */
+ _localModelNoticeEl() {
+ const notice = this._localModelWindowState().notice;
+ if (!notice) return null;
+ const box = localModelEl('div', 'lm-notice');
+ box.setAttribute('role', 'alert');
+ if (notice.message) {
+ box.appendChild(localModelEl('p', 'lm-notice-text', localModelMessage(notice.message)));
+ } else if (notice.unreachable) {
+ box.appendChild(localModelEl('p', 'lm-notice-text', localModelT('Could not reach the server.')));
+ } else {
+ box.appendChild(localModelEl('p', 'lm-notice-text', localModelT('The server did not accept that.')));
+ if (notice.detail) box.appendChild(localModelEl('p', 'lm-notice-detail', notice.detail));
+ }
+ return box;
+ },
+
+ /** The one honest sentence about git and Python, only on a Mac without the developer tools. */
+ _localModelDeveloperToolsEl() {
+ if (localModelInstall(this._localModelStatus).developerTools !== 'missing') return null;
+ return localModelEl(
+ 'p',
+ 'lm-aside',
+ localModelT(
+ '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.'
+ )
+ );
+ },
+
+ /** One model row: the radio of the chooser, or the same line as plain text on the confirm screen. */
+ _localModelRow(candidate, { checked, interactive, 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');
+ }
+ 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');
+ tagSlot.appendChild(tag);
+ }
+ row.appendChild(tagSlot);
+ row.appendChild(localModelEl('span', 'lm-model-speed', localModelSpeedText(candidate)));
+ row.appendChild(localModelEl('span', 'lm-model-size', localModelGb(candidate.downloadGb)));
+ return row;
+ },
+
+ _buildLocalModelChooser(last) {
+ const ui = this._localModelWindowState();
+ const status = this._localModelStatus;
+ const recommendation = status.recommendation;
+ const install = localModelInstall(status);
+ const { candidates, candidate, harnesses, harness } = this._localModelSelection();
+ const parts = [];
+ const hw = this._localModelHardwareEl();
+ if (hw) parts.push(hw);
+
+ // 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 };
+ 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') {
+ head.appendChild(localModelEl('span', 'lm-figure-aside', localModelT('estimated')));
+ }
+ meter.appendChild(head);
+ const share = String(Math.round((Number.isFinite(candidate.budgetShare) ? candidate.budgetShare : 0) * 100));
+ meter.appendChild(
+ localModelBar({
+ share: candidate.budgetShare,
+ role: 'meter',
+ kind: 'memory',
+ from: last.memory,
+ label: localModelT('The selected model needs {share}% of the memory this Mac can give a model', { share }),
+ })
+ );
+ const legend = localModelEl('div', 'lm-legend');
+ const context = localModelCount(candidate.context);
+ legend.appendChild(localModelEl('span', '', localModelT('The model at {context} tokens of context', { context })));
+ legend.appendChild(localModelEl('span', '', localModelT('What stays free')));
+ meter.appendChild(legend);
+ parts.push(meter);
+
+ // The server's headline: its word for a pick it has run on a Mac like this one labels the
+ // list, and the small-model caveat is a sentence above it. "Best fit for this Mac" would
+ // only repeat the pick's tag, so the list is then labelled by what it holds.
+ const pick = recommendation.pick || {};
+ const headline = localModelMessage(recommendation.headline);
+ if (recommendation.verdict === 'limited' && headline) parts.push(localModelEl('p', 'lm-lead', headline));
+ const tested = pick.tested === true && recommendation.verdict !== 'limited' && !!headline;
+
+ const list = localModelEl('div', 'lm-list');
+ const caption = localModelEl('div', 'lm-caption');
+ const captionLabel = tested
+ ? localModelEl('span', 'lm-caption-label lm-caption-strong', headline)
+ : localModelEl('span', 'lm-caption-label', localModelT('Models that fit this Mac'));
+ captionLabel.id = 'localModelListLabel';
+ caption.appendChild(captionLabel);
+ caption.appendChild(localModelEl('span', 'lm-caption-speed', localModelT('Speed')));
+ caption.appendChild(localModelEl('span', 'lm-caption-size', localModelT('Download')));
+ list.appendChild(caption);
+ const group = localModelEl('div', 'lm-models');
+ group.setAttribute('role', 'radiogroup');
+ group.setAttribute('aria-labelledby', 'localModelListLabel');
+ for (const row of candidates) {
+ const checked = row === candidate;
+ const el = this._localModelRow(row, { checked, interactive: true, tabStop: checked });
+ el.addEventListener('click', () => this.selectLocalModel(row.modelId));
+ group.appendChild(el);
+ }
+ group.addEventListener('keydown', (event) => this._onLocalModelListKey(event));
+ list.appendChild(group);
+ parts.push(list);
+
+ const notes = localModelEl('div', 'lm-notes');
+ for (const note of Array.isArray(recommendation.notes) ? recommendation.notes : []) {
+ const text = localModelMessage(note);
+ if (text) notes.appendChild(localModelEl('p', 'lm-aside', text));
+ }
+ if (notes.childElementCount) parts.push(notes);
+
+ // The coding agent, on its own line: only the ones the server offers for the selected row.
+ const runs = localModelEl('div', 'lm-runs');
+ const runsLabel = localModelEl('label', 'lm-runs-label', localModelT('Runs with'));
+ runsLabel.htmlFor = 'localModelCli';
+ runs.appendChild(runsLabel);
+ const select = localModelEl('select', 'lm-select');
+ select.id = 'localModelCli';
+ select.dataset.lmKey = 'cli';
+ harnesses.forEach((entry, index) => {
+ const option = localModelEl('option', '', String(entry.cliLabel || entry.cli));
+ option.value = String(index);
+ option.selected = entry === harness;
+ select.appendChild(option);
+ });
+ select.addEventListener('change', () => {
+ const chosen = harnesses[Number(select.value)];
+ if (!chosen) return;
+ ui.cli = chosen.cli;
+ this.renderLocalModel();
+ });
+ runs.appendChild(select);
+ const hints = [];
+ if (harness.installed === false) hints.push(localModelT('Not on this Mac yet. It is installed 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);
+
+ const notice = this._localModelNoticeEl();
+ if (notice) parts.push(notice);
+
+ const foot = localModelEl('div', 'lm-foot');
+ const promise = localModelT('No account and no API key. The model runs only on this Mac.');
+ foot.appendChild(localModelEl('p', 'lm-foot-note', promise));
+ const buttons = localModelEl('div', 'lm-buttons');
+ // Reached from a paused or stopped install: the way back to it.
+ if (install.state !== 'idle') {
+ buttons.appendChild(
+ localModelButton(localModelT('Back'), { key: 'back', onClick: () => this.leaveLocalModelChooser() })
+ );
+ }
+ if (install.actions.includes('install')) {
+ buttons.appendChild(
+ localModelButton(localModelT('Install on this Mac'), {
+ key: 'primary',
+ primary: true,
+ disabled: ui.busy,
+ onClick: () => this.reviewLocalModelInstall(),
+ })
+ );
+ }
+ if (buttons.childElementCount) foot.appendChild(buttons);
+ parts.push(foot);
+ return parts;
+ },
+
+ /** Arrow keys, Home and End move the selection of the radio group, and the focus with it. */
+ _onLocalModelListKey(event) {
+ const selection = this._localModelSelection();
+ if (!selection) return;
+ const { candidates, candidate } = selection;
+ const index = candidates.indexOf(candidate);
+ let next = -1;
+ if (event.key === 'ArrowDown' || event.key === 'ArrowRight') next = (index + 1) % candidates.length;
+ else if (event.key === 'ArrowUp' || event.key === 'ArrowLeft')
+ next = (index - 1 + candidates.length) % candidates.length;
+ else if (event.key === 'Home') next = 0;
+ else if (event.key === 'End') next = candidates.length - 1;
+ else if (event.key === ' ' || event.key === 'Enter') next = index;
+ if (next < 0) return;
+ event.preventDefault();
+ // The repaint puts the focus on the row that is selected now: the group's one tab stop.
+ this.selectLocalModel(candidates[next].modelId);
+ },
+
+ _buildLocalModelConfirm() {
+ const ui = this._localModelWindowState();
+ const status = this._localModelStatus;
+ const install = localModelInstall(status);
+ const { candidate, harness } = this._localModelSelection();
+ const parts = [];
+ 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.', {
+ 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));
+ 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));
+ }
+ 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 }));
+ 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)));
+ }
+ where.appendChild(localModelEl('p', 'lm-aside', localModelT('No administrator password.')));
+ parts.push(where);
+ const tools = this._localModelDeveloperToolsEl();
+ if (tools) parts.push(tools);
+
+ const notice = this._localModelNoticeEl();
+ if (notice) parts.push(notice);
+
+ const foot = localModelEl('div', 'lm-foot');
+ const buttons = localModelEl('div', 'lm-buttons');
+ buttons.appendChild(
+ localModelButton(localModelT('Back'), { key: 'back', disabled: ui.busy, onClick: () => this.chooseLocalModel() })
+ );
+ buttons.appendChild(
+ localModelButton(localModelT('Download and install'), {
+ key: 'primary',
+ primary: true,
+ disabled: ui.busy,
+ onClick: () => void this.installLocalModel(),
+ })
+ );
+ foot.appendChild(buttons);
+ parts.push(foot);
+ return parts;
+ },
+
+ /** The steps of the job as a short list: done, the current one, and what is still to come. */
+ _localModelStepsEl(install, { stopped }) {
+ const steps = Array.isArray(install.steps) ? install.steps : [];
+ if (!steps.length) return null;
+ const list = localModelEl('ol', 'lm-steps');
+ const at = steps.indexOf(install.step);
+ steps.forEach((step, index) => {
+ const item = localModelEl('li', 'lm-step');
+ if (index < at) item.classList.add('lm-step-done');
+ else if (index === at) {
+ item.classList.add(stopped ? 'lm-step-stopped' : 'lm-step-now');
+ item.setAttribute('aria-current', 'step');
+ }
+ item.appendChild(localModelEl('span', 'lm-step-mark'));
+ item.appendChild(
+ localModelEl('span', 'lm-step-label', localModelStepLabel(step, String(install.cliLabel || '')))
+ );
+ list.appendChild(item);
+ });
+ return list;
+ },
+
+ /** The Remove question, in place of a screen's buttons. */
+ _localModelRemoveEl() {
+ const ui = this._localModelWindowState();
+ const box = localModelEl('div', 'lm-remove');
+ box.appendChild(localModelEl('p', 'lm-lead', localModelT('Remove the local model from this Mac?')));
+ const label = localModelEl('label', 'lm-check');
+ const check = localModelEl('input', '');
+ check.type = 'checkbox';
+ check.checked = ui.removeModel === true;
+ check.dataset.lmKey = 'remove-model';
+ check.addEventListener('change', () => {
+ ui.removeModel = check.checked;
+ this.renderLocalModel();
+ });
+ label.appendChild(check);
+ label.appendChild(localModelEl('span', '', localModelT('Also delete the downloaded model')));
+ box.appendChild(label);
+ box.appendChild(localModelEl('p', 'lm-aside', localModelT('The coding agent stays installed.')));
+ const notice = this._localModelNoticeEl();
+ if (notice) box.appendChild(notice);
+ const buttons = localModelEl('div', 'lm-buttons');
+ buttons.appendChild(
+ localModelButton(localModelT('Cancel'), {
+ key: 'back',
+ disabled: ui.busy,
+ onClick: () => {
+ Object.assign(ui, { confirmRemove: false, notice: null });
+ this.renderLocalModel();
+ },
+ })
+ );
+ buttons.appendChild(
+ localModelButton(localModelT('Remove'), {
+ key: 'primary',
+ danger: true,
+ disabled: ui.busy,
+ onClick: () => void this.removeLocalModel({ confirmed: true }),
+ })
+ );
+ const foot = localModelEl('div', 'lm-foot');
+ foot.appendChild(buttons);
+ box.appendChild(foot);
+ return box;
+ },
+
+ /**
+ * 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.
+ */
+ _localModelDownloadMeter(screen, last) {
+ const install = localModelInstall(this._localModelStatus);
+ const paused = screen === 'paused';
+ const progress = install.progress;
+ 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;
+ 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)));
+ const left = localModelTimeLeft(progress.secondsLeft);
+ let rate = '';
+ if (!paused && progress.mbPerSecond && left) {
+ rate = localModelT('{speed} MB per second, {left}', { speed: progress.mbPerSecond, left });
+ } else if (!paused && progress.mbPerSecond) {
+ rate = localModelT('{speed} MB per second', { speed: progress.mbPerSecond });
+ }
+ if (rate) head.appendChild(localModelEl('span', 'lm-figure-aside', rate));
+ } else {
+ head.appendChild(localModelEl('span', 'lm-figure', now));
+ }
+ meter.appendChild(head);
+ meter.appendChild(localModelBar({ share, role: 'progressbar', kind: 'download', from: last.download, label: now }));
+ return meter;
+ },
+
+ /** Installing, and the same picture frozen: paused. */
+ _buildLocalModelProgress(screen, last) {
+ const ui = this._localModelWindowState();
+ const install = localModelInstall(this._localModelStatus);
+ const paused = screen === 'paused';
+ const model = install.model ? String(install.model.label || install.model.id || '') : '';
+ const parts = [];
+ const title = paused
+ ? localModelT('The install of {model} is paused', { model })
+ : 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));
+
+ const list = this._localModelStepsEl(install, { stopped: paused });
+ if (list) parts.push(list);
+
+ if (ui.confirmRemove) {
+ parts.push(this._localModelRemoveEl());
+ 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.');
+ parts.push(localModelEl('p', 'lm-aside', note));
+ const notice = this._localModelNoticeEl();
+ if (notice) parts.push(notice);
+
+ const foot = localModelEl('div', 'lm-foot');
+ const buttons = localModelEl('div', 'lm-buttons');
+ if (install.actions.includes('cancel')) {
+ buttons.appendChild(
+ localModelButton(localModelT('Cancel'), {
+ key: 'primary',
+ disabled: ui.busy,
+ onClick: () => void this.cancelLocalModelInstall(),
+ })
+ );
+ }
+ if (paused && install.actions.includes('remove')) {
+ buttons.appendChild(
+ localModelButton(localModelT('Remove'), {
+ key: 'remove',
+ disabled: ui.busy,
+ onClick: () => void this.removeLocalModel(),
+ })
+ );
+ }
+ if (paused && install.actions.includes('install') && this._localModelSelection()) {
+ buttons.appendChild(
+ localModelButton(localModelT('Choose another model'), {
+ key: 'choose',
+ disabled: ui.busy,
+ onClick: () => this.chooseLocalModel(),
+ })
+ );
+ }
+ if (paused && install.actions.includes('resume')) {
+ buttons.appendChild(
+ localModelButton(localModelT('Continue'), {
+ key: 'primary',
+ primary: true,
+ disabled: ui.busy,
+ onClick: () => void this.resumeLocalModelInstall(),
+ })
+ );
+ }
+ if (buttons.childElementCount) foot.appendChild(buttons);
+ parts.push(foot);
+ return parts;
+ },
+
+ /**
+ * 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.
+ */
+ _localModelNextButton(install, error) {
+ const ui = this._localModelWindowState();
+ const can = (action) => install.actions.includes(action);
+ const primary = (label, onClick) =>
+ localModelButton(label, { key: 'primary', primary: true, disabled: ui.busy, onClick });
+ const installed = install.state === 'installed';
+ if (error.next === 'retry' && can('resume')) {
+ return primary(localModelT('Try again'), () => void this.resumeLocalModelInstall());
+ }
+ if (error.next === 'other-port' && installed && can('start')) {
+ return primary(localModelT('Use another port'), () => void this.setLocalModelRuntime('start', { newPort: true }));
+ }
+ if (error.next === 'other-port' && can('resume')) {
+ return primary(localModelT('Use another port'), () => void this.resumeLocalModelInstall({ newPort: true }));
+ }
+ if (error.next === 'start' && can('start')) {
+ return primary(localModelT('Start'), () => void this.setLocalModelRuntime('start'));
+ }
+ if (error.next === 'remove' && can('remove')) {
+ return primary(localModelT('Remove'), () => void this.removeLocalModel());
+ }
+ if (error.next === 'next-model' && can('install')) {
+ const candidates = localModelCandidates(this._localModelStatus);
+ const next = error.nextModel && candidates.find((row) => row.modelId === error.nextModel.modelId);
+ if (next) {
+ const model = String(error.nextModel.modelLabel || next.modelLabel || next.modelId);
+ return primary(localModelT('Try {model}', { model }), () => this.chooseLocalModel(next.modelId));
+ }
+ if (candidates.length) return primary(localModelT('Choose another model'), () => this.chooseLocalModel());
+ }
+ return null;
+ },
+
+ /** An error as the window shows it: the server's sentence, and its raw detail folded away. */
+ _localModelErrorEl(error) {
+ const box = localModelEl('div', 'lm-error');
+ box.appendChild(localModelEl('p', 'lm-title', localModelMessage(error.message)));
+ if (typeof error.detail === 'string' && error.detail) {
+ const details = localModelEl('details', 'lm-details');
+ details.appendChild(localModelEl('summary', '', localModelT('Details')));
+ details.appendChild(localModelEl('pre', 'lm-detail-text', error.detail));
+ box.appendChild(details);
+ }
+ return box;
+ },
+
+ _buildLocalModelFailed() {
+ const ui = this._localModelWindowState();
+ const install = localModelInstall(this._localModelStatus);
+ const error = install.error || {};
+ const parts = [];
+ parts.push(this._localModelErrorEl(error));
+ const list = this._localModelStepsEl(install, { stopped: true });
+ if (list) parts.push(list);
+
+ if (ui.confirmRemove) {
+ parts.push(this._localModelRemoveEl());
+ return parts;
+ }
+ const notice = this._localModelNoticeEl();
+ if (notice) parts.push(notice);
+
+ const foot = localModelEl('div', 'lm-foot');
+ const buttons = localModelEl('div', 'lm-buttons');
+ const next = this._localModelNextButton(install, error);
+ // The way out, kept quiet beside the action that helps (unless that action is the removal).
+ if (install.actions.includes('remove') && error.next !== 'remove') {
+ buttons.appendChild(
+ localModelButton(localModelT('Remove'), {
+ key: 'remove',
+ disabled: ui.busy,
+ onClick: () => void this.removeLocalModel(),
+ })
+ );
+ }
+ if (next) buttons.appendChild(next);
+ if (buttons.childElementCount) foot.appendChild(buttons);
+ parts.push(foot);
+ return parts;
+ },
+
+ _buildLocalModelInstalled() {
+ const ui = this._localModelWindowState();
+ const install = localModelInstall(this._localModelStatus);
+ const model = install.model ? String(install.model.label || install.model.id || '') : '';
+ 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);
+ }
+ const state = localModelEl('div', 'lm-state');
+ const dot = localModelEl('span', install.running ? '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'))
+ );
+ parts.push(state);
+ const about = [];
+ if (install.cliLabel) about.push(localModelT('Runs with {cli}.', { cli: String(install.cliLabel) }));
+ 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;
+ }
+ const tools = this._localModelDeveloperToolsEl();
+ if (tools) parts.push(tools);
+ const notice = this._localModelNoticeEl();
+ if (notice) parts.push(notice);
+
+ const can = (action) => install.actions.includes(action);
+ const next = error ? this._localModelNextButton(install, error) : null;
+ const foot = localModelEl('div', 'lm-foot');
+ const buttons = localModelEl('div', 'lm-buttons');
+ if (can('remove') && error?.next !== 'remove') {
+ buttons.appendChild(
+ localModelButton(localModelT('Remove'), {
+ key: 'remove',
+ disabled: ui.busy,
+ onClick: () => void this.removeLocalModel(),
+ })
+ );
+ }
+ if (can('stop')) {
+ buttons.appendChild(
+ localModelButton(localModelT('Stop'), {
+ key: 'stop',
+ disabled: ui.busy,
+ onClick: () => void this.setLocalModelRuntime('stop'),
+ })
+ );
+ }
+ // Start is the main action of a stopped model, unless the error's own button says so already.
+ if (can('start') && !(error && (error.next === 'start' || error.next === 'other-port'))) {
+ buttons.appendChild(
+ localModelButton(localModelT('Start'), {
+ key: can('start-session') || next ? 'start' : 'primary',
+ primary: !can('start-session') && !next,
+ disabled: ui.busy,
+ onClick: () => void this.setLocalModelRuntime('start'),
+ })
+ );
+ }
+ if (next) buttons.appendChild(next);
+ if (can('start-session')) {
+ buttons.appendChild(
+ localModelButton(localModelT('Start a session'), {
+ key: 'primary',
+ primary: !next,
+ disabled: ui.busy,
+ onClick: () => void this.startLocalModelSession(),
+ })
+ );
+ }
+ if (buttons.childElementCount) foot.appendChild(buttons);
+ parts.push(foot);
+ return parts;
+ },
+
+ /** No model fits: the hardware line, the reason in the server's sentence, and Close. */
+ _buildLocalModelNone() {
+ const status = this._localModelStatus;
+ const parts = [];
+ const hw = status.recommendation.reason === 'not-macos' ? null : this._localModelHardwareEl();
+ if (hw) parts.push(hw);
+ const reason = localModelMessage(status.recommendation.headline);
+ if (reason) parts.push(localModelEl('p', 'lm-reason', reason));
+ const notice = this._localModelNoticeEl();
+ if (notice) parts.push(notice);
+ const foot = localModelEl('div', 'lm-foot');
+ const buttons = localModelEl('div', 'lm-buttons');
+ buttons.appendChild(
+ localModelButton(localModelT('Close'), { key: 'primary', onClick: () => this.closeLocalModelWindow() })
+ );
+ foot.appendChild(buttons);
+ parts.push(foot);
+ return parts;
},
/** Listeners that outlive a render, wired once. */
_wireLocalModel() {
if (this._localModelWired) return;
this._localModelWired = true;
- // Both containers are data-i18n-skip (see @fileoverview), so the DOM translator never
- // revisits them: a language or display-name change has to repaint them from here.
+ // Every surface is data-i18n-skip (see @fileoverview), so the DOM translator never revisits
+ // them: a language or display-name change has to repaint them from here.
if (typeof MutationObserver === 'function' && document.documentElement) {
- const observer = new MutationObserver(() => this.renderLocalModel());
+ const observer = new MutationObserver(() => {
+ this._forgetLocalModelPaint();
+ this.renderLocalModel();
+ });
observer.observe(document.documentElement, { attributes: true, attributeFilter: ['lang'] });
}
// The real role can arrive after the first render (admin-ui.js fetches /api/me): a non-admin
- // loses the card and the group, and an admin the page had taken for one is asked for at last.
+ // loses the button and the group, and an admin the page had taken for one is asked for at last.
document.addEventListener?.('codeman:me', () => void this.loadLocalModel());
},
});
diff --git a/src/web/public/mobile.css b/src/web/public/mobile.css
index a099bfa9..cfdff19c 100644
--- a/src/web/public/mobile.css
+++ b/src/web/public/mobile.css
@@ -1857,15 +1857,27 @@ html.mobile-init .file-browser-panel {
margin-top: 0.75rem;
}
- /* Model on this Mac (local-model-ui.js). Only reached with the phone overview
- switched off. The card has no fixed width and every line in it wraps, so
- the narrow column only takes some of the spacing back. */
- .local-model-card {
- margin-top: 0.6rem;
+ /* Run a model on this Mac (local-model-ui.js). The welcome button is only
+ reached with the phone overview switched off; it takes the chips' finger
+ size from `.welcome-chip` above. The window is full-screen like every
+ modal here: its own width rule in styles.css is more specific than the
+ full-screen `.modal-content` rule above, so the size is restated. */
+ .modal-content.local-model-window {
+ width: 100%;
+ max-width: 100%;
+ height: 100%;
+ max-height: 100%;
}
- .local-model-box {
- padding: 0.5rem 0.55rem;
+ .local-model-window > .lm-body {
+ flex: 1;
+ padding: 1rem 1rem calc(1rem + var(--safe-area-bottom));
+ }
+
+ /* 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;
}
/* Ralph wizard responsive */
@@ -3980,16 +3992,6 @@ html:is([data-skin="paper-gray"], [data-skin="solarized-light"], [data-skin="cat
max-width: none;
}
- /* Model on this Mac (local-model-ui.js): its row follows the label size above. */
- #localModelSettings .local-model-headline,
- #localModelSettings .local-model-name {
- font-size: 0.84rem;
- }
-
- #localModelSettings .local-model-alt .local-model-name {
- font-size: 0.78rem;
- }
-
/* Fields go full width under their label instead of fighting for the row */
:is(#appSettingsModal, #sessionOptionsModal, #createCaseModal) .set-row.has-field {
flex-direction: column;
@@ -4282,3 +4284,76 @@ html[data-session-list="sidebar"] .session-sidebar .session-tab .tab-close {
touch-action: pan-x pan-y;
}
}
+
+/* Run a model on this Mac (local-model-ui.js), under 560px: each model row
+ becomes two lines (name and tag, then speed and download size), the bar
+ stays full width and the buttons go full width under the foot's sentence. */
+@media (max-width: 559px) {
+ .lm-list {
+ grid-template-columns: auto minmax(0, 1fr) auto;
+ column-gap: 0.7rem;
+ }
+
+ .lm-caption-speed,
+ .lm-caption-size {
+ display: none;
+ }
+
+ .lm-model {
+ row-gap: 0.1rem;
+ padding-top: 0.5rem;
+ padding-bottom: 0.5rem;
+ }
+
+ .lm-radio {
+ grid-row: 1;
+ grid-column: 1;
+ }
+
+ .lm-model-name {
+ grid-row: 1;
+ grid-column: 2;
+ }
+
+ .lm-model-tag {
+ grid-row: 1;
+ grid-column: 3;
+ justify-content: flex-end;
+ }
+
+ .lm-model-speed {
+ grid-row: 2;
+ grid-column: 2;
+ text-align: start;
+ }
+
+ .lm-model-size {
+ grid-row: 2;
+ 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;
+ gap: 0.8rem;
+ }
+
+ /* The main action on top, the way back under it. */
+ .lm-buttons {
+ flex-direction: column-reverse;
+ margin-inline-start: 0;
+ }
+
+ .local-model-window .wt-btn.lm-btn {
+ min-height: 44px;
+ font-size: 0.88rem;
+ }
+}
diff --git a/src/web/public/settings-ui.js b/src/web/public/settings-ui.js
index dc988bb6..88646062 100644
--- a/src/web/public/settings-ui.js
+++ b/src/web/public/settings-ui.js
@@ -550,8 +550,9 @@ Object.assign(CodemanApp.prototype, {
// Assigning .checked above does not fire onchange, so the body's visibility
// (and its lazy load) needs an explicit sync on every open, not just a save.
this.applyCustomModelEndpointsVisibility();
- // Model on this Mac (local-model-ui.js): a read-only row, not a setting. Server state,
- // asked for at most once a minute; nothing here is loaded or saved by id.
+ // Model on this Mac (local-model-ui.js): a row that says where the local model stands and
+ // opens its window, not a setting. Server state, asked for at most once a minute; nothing
+ // here is loaded or saved by id.
this.loadLocalModel?.();
// CLI management (docs/cli-enable-disable-plan.md): synced, default OFF.
document.getElementById('appSettingsCliManagement').checked = settings.cliManagementEnabled === true;
diff --git a/src/web/public/styles.css b/src/web/public/styles.css
index ba9ac133..24494294 100644
--- a/src/web/public/styles.css
+++ b/src/web/public/styles.css
@@ -22304,192 +22304,687 @@ html[data-tile-anim="soft"] .tile-grid-ghosts.tile-grid-ghosts--release:not(.til
}
/* ═══════════════════════════════════════════════════════════════
- Model on this Mac (local-model-ui.js): what the server's Mac can run as
- a local coding model. One body (`.local-model-body`), drawn in two places:
- the welcome card and a row of App Settings > Models.
+ Run a model on this Mac (local-model-ui.js): the welcome button and the
+ chooser window (#localModelModal). The App Settings row is ordinary
+ `.set-row` markup and needs nothing here.
- Same vocabulary as the two blocks above it on the welcome screen: a dim
- heading row, then one bordered surface with a name over dim meta lines.
- Theme tokens only (`--control-bg`, never a white alpha: that fill is
- invisible on the light skins), and the fit and speed labels are neutral
- pills: the words carry the meaning, so no skin needs a colour of its own.
- Nothing here is keyed on a CLI or a model id, and nothing animates.
+ The subject is this Mac's memory, so the window has ONE strong element:
+ the bar (`.lm-bar`), the memory this Mac can give a model, filled to what
+ the selected model needs. Everything around it is quiet: hierarchy by size
+ and weight, no box inside the window, no border around a row. The selected
+ 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.
═══════════════════════════════════════════════════════════════ */
-.local-model-card {
- display: flex;
- flex-direction: column;
- gap: 0.4rem;
- width: 100%;
- margin-top: 0.75rem;
- text-align: left;
+/* The welcome button: one more chip, on its own line under the launchers. */
+.welcome-local-model {
+ margin-top: 1.1rem;
}
-/* A flex container, so `[hidden]` needs re-asserting or the module's only
- visibility lever does nothing (same trap as .foreign-sessions). */
-.local-model-card[hidden] {
+/* `.welcome-chip` is inline-flex, so `[hidden]` needs re-asserting or the
+ module's only visibility lever does nothing. */
+.welcome-local-model[hidden] {
display: none;
}
-.local-model-head {
+.welcome-local-model svg {
+ 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. */
+.modal-content.local-model-window {
display: flex;
- align-items: center;
+ flex-direction: column;
+ width: min(640px, 94vw);
+ max-width: min(640px, 94vw);
+ max-height: 88vh;
+}
+
+.local-model-window > .modal-header {
+ flex-shrink: 0;
+}
+
+.local-model-window > .lm-body {
+ display: flex;
+ flex-direction: column;
+ gap: 1.05rem;
+ min-height: 0;
+ padding: 1.15rem 1.25rem 1.2rem;
+ overflow-y: auto;
+ text-align: start;
+}
+
+.lm-body p,
+.lm-body h4 {
+ margin: 0;
+}
+
+/* ── type: four sizes, nothing in capitals ─────────────────────────── */
+.lm-hw {
+ font-size: 0.78rem;
+ line-height: 1.45;
+ color: var(--text-dim);
+ overflow-wrap: anywhere;
+}
+
+.lm-title {
+ font-size: 1rem;
+ font-weight: 650;
+ line-height: 1.4;
+ letter-spacing: -0.01em;
+ color: var(--text);
+ overflow-wrap: anywhere;
+}
+
+.lm-lead {
+ font-size: 0.84rem;
+ line-height: 1.5;
+ color: var(--text);
+ overflow-wrap: anywhere;
+}
+
+/* Why no model fits: one sentence, read as a sentence and not as a headline. */
+.lm-reason {
+ font-size: 0.92rem;
+ font-weight: 500;
+ line-height: 1.55;
+ color: var(--text);
+ overflow-wrap: anywhere;
+}
+
+.lm-notes {
+ display: flex;
+ flex-direction: column;
+ gap: 0.2rem;
+}
+
+.lm-aside {
+ font-size: 0.74rem;
+ line-height: 1.5;
+ color: var(--text-dim);
+ overflow-wrap: anywhere;
+}
+
+.lm-heading {
+ font-size: 0.74rem;
+ font-weight: 600;
+ color: var(--text-dim);
+}
+
+/* ── the bar ───────────────────────────────────────────────────────── */
+.lm-meter {
+ display: flex;
+ flex-direction: column;
gap: 0.5rem;
}
-.local-model-title {
- margin: 0;
- font-size: 0.85rem;
- color: var(--text-dim);
- font-weight: 500;
-}
-
-.local-model-hide {
- margin-left: auto;
- font: inherit;
- font-size: 0.68rem;
- color: var(--text-dim);
- background: transparent;
- border: 1px solid var(--border);
- border-radius: 999px;
- padding: 0.12rem 0.5rem;
- cursor: pointer;
-}
-
-.local-model-hide:hover {
- color: var(--text);
- background: var(--control-bg-hover);
-}
-
-.local-model-box {
- padding: 0.55rem 0.65rem;
- border: 1px solid var(--border);
- border-radius: 8px;
- background: var(--control-bg);
- min-width: 0;
-}
-
-.local-model-body {
- display: flex;
- flex-direction: column;
- gap: 0.3rem;
- min-width: 0;
-}
-
-/* The line that says nothing can be set up from here yet (.local-model-next) is
- the one a reader must not miss, so it reads like every other line: same size,
- same --text-dim. --text-muted on this surface was 3.9:1. */
-.local-model-hw,
-.local-model-line,
-.local-model-note,
-.local-model-next {
- font-size: 0.68rem;
- line-height: 1.45;
- color: var(--text-dim);
- overflow-wrap: anywhere;
-}
-
-.local-model-headline {
- font-size: 0.82rem;
- line-height: 1.45;
- color: var(--text);
- overflow-wrap: anywhere;
-}
-
-/* The pick is the one thing in the card set apart: a rule in the accent, on the
- reading side (logical, so it follows a right-to-left page). */
-.local-model-pick {
- display: flex;
- flex-direction: column;
- gap: 0.12rem;
- padding-inline-start: 0.55rem;
- border-inline-start: 2px solid var(--accent);
-}
-
-.local-model-name-row {
+.lm-meter-head {
display: flex;
flex-wrap: wrap;
- align-items: center;
- gap: 0.2rem 0.4rem;
- min-width: 0;
+ align-items: baseline;
+ gap: 0.15rem 0.6rem;
}
-.local-model-name {
- font-size: 0.82rem;
+.lm-figure {
+ font-size: 1.06rem;
+ font-weight: 680;
+ letter-spacing: -0.012em;
+ color: var(--text);
+ font-variant-numeric: tabular-nums;
+}
+
+.lm-figure-aside {
+ font-size: 0.74rem;
+ color: var(--text-dim);
+ font-variant-numeric: tabular-nums;
+}
+
+.lm-bar {
+ height: 10px;
+ border-radius: 999px;
+ background: var(--control-border-hover);
+ overflow: hidden;
+}
+
+.lm-bar-fill {
+ height: 100%;
+ border-radius: 999px;
+ background: var(--accent);
+ transition: width 0.32s cubic-bezier(0.4, 0, 0.2, 1);
+}
+
+.lm-legend {
+ display: flex;
+ justify-content: space-between;
+ gap: 1rem;
+ font-size: 0.68rem;
+ line-height: 1.4;
+ color: var(--text-muted);
+}
+
+.lm-legend > :last-child {
+ text-align: end;
+}
+
+/* ── the models: one grid, so the numbers line up down the list ──── */
+/* Radio, name, tag, speed, download. The tag sits next to the name and takes
+ the room that is left, which pushes the two numbers to the right edge. */
+.lm-list {
+ display: grid;
+ grid-template-columns: auto minmax(0, max-content) minmax(0, 1fr) auto auto;
+ column-gap: 0.9rem;
+ row-gap: 0.1rem;
+ /* A row's fill reaches into the window's padding, so what is IN a row lines
+ up with every other line of the window on both edges. */
+ margin-inline: calc(-0.7rem - 2px) -0.7rem;
+}
+
+.lm-caption,
+.lm-models,
+.lm-model {
+ display: grid;
+ grid-column: 1 / -1;
+ grid-template-columns: subgrid;
+}
+
+.lm-models {
+ row-gap: 0.1rem;
+}
+
+.lm-caption,
+.lm-model {
+ align-items: center;
+ padding: 0 0.7rem;
+ border-inline-start: 2px solid transparent;
+}
+
+.lm-caption {
+ padding-bottom: 0.3rem;
+ font-size: 0.68rem;
+ color: var(--text-muted);
+}
+
+.lm-caption-label {
+ grid-column: 1 / span 3;
+}
+
+/* The server's word for a pick it has run on a Mac like this one stands where
+ the list's label does, in the text colour. */
+.lm-caption-strong {
+ font-size: 0.8rem;
+ font-weight: 600;
+ color: var(--text);
+}
+
+.lm-caption-speed,
+.lm-caption-size {
+ text-align: end;
+}
+
+.lm-model {
+ min-height: 2.35rem;
+ border-radius: 7px;
+ cursor: pointer;
+ transition: background var(--transition-smooth);
+}
+
+.lm-model:hover {
+ background: var(--control-bg);
+}
+
+.lm-model:focus-visible {
+ outline: 2px solid var(--accent);
+ outline-offset: -2px;
+}
+
+/* Selected: the accent on the reading edge and a faint fill. Nothing else. */
+.lm-model-on,
+.lm-model-on:hover {
+ border-inline-start-color: var(--accent);
+ 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;
+ height: 14px;
+ border: 1.5px solid var(--text-muted);
+ border-radius: 50%;
+}
+
+.lm-model-on .lm-radio {
+ border-color: var(--accent);
+ background: radial-gradient(circle, var(--accent) 0 3px, transparent 3.5px);
+}
+
+.lm-model-name {
+ min-width: 0;
+ font-size: 0.86rem;
font-weight: 600;
color: var(--text);
overflow-wrap: anywhere;
- min-width: 0;
}
-/* Each pill carries what it measures as its tooltip (a title), hence the cursor. */
-.local-model-pill {
- flex: 0 0 auto;
- font-size: 0.62rem;
+.lm-model-tag {
+ display: flex;
+ justify-content: flex-start;
+}
+
+/* A tag is text with a hairline, not a coloured pill. The accent is for the
+ pick (and for a row that was run on a Mac like this one). */
+.lm-tag {
+ padding: 0.05rem 0.42rem;
+ border: 1px solid var(--border-light);
+ border-radius: 5px;
+ font-size: 0.66rem;
line-height: 1.5;
color: var(--text-dim);
- border: 1px solid var(--border);
- border-radius: 999px;
- padding: 0 0.42rem;
white-space: nowrap;
- cursor: help;
}
-.local-model-alts-title {
- font-size: 0.72rem;
+.lm-tag-accent {
+ border-color: rgba(var(--accent-rgb), 0.5);
+ color: var(--accent);
+}
+
+.lm-model-speed,
+.lm-model-size {
+ text-align: end;
+ white-space: nowrap;
+ font-variant-numeric: tabular-nums;
+}
+
+.lm-model-speed {
+ font-size: 0.76rem;
color: var(--text-dim);
}
-/* The welcome card folds the list behind a native disclosure (keyboard and
- screen-reader behaviour for free); the summary keeps `display: list-item`
- so its marker stays. */
-.local-model-alts > summary {
- cursor: pointer;
-}
-
-.local-model-alts > summary:hover {
+.lm-model-size {
+ font-size: 0.8rem;
+ font-weight: 560;
color: var(--text);
}
-.local-model-alt {
+/* A browser without subgrid: fixed tracks keep the columns in line. */
+@supports not (grid-template-columns: subgrid) {
+ .lm-list,
+ .lm-models {
+ display: block;
+ }
+
+ .lm-caption,
+ .lm-model {
+ grid-template-columns: 14px 10.5rem minmax(0, 1fr) 8.6rem 4.4rem;
+ column-gap: 0.9rem;
+ }
+
+ .lm-model {
+ margin-top: 0.1rem;
+ }
+}
+
+/* ── the coding agent ─────────────────────────────────────────────── */
+.lm-runs {
+ display: flex;
+ flex-wrap: wrap;
+ align-items: center;
+ gap: 0.4rem 0.6rem;
+ font-size: 0.78rem;
+ color: var(--text-dim);
+}
+
+.lm-runs-label {
+ color: var(--text);
+}
+
+.lm-select {
+ max-width: 100%;
+ padding: 5px 8px;
+ border: 1px solid var(--border);
+ border-radius: 7px;
+ background: var(--bg-input);
+ color: var(--text);
+ font: inherit;
+ font-size: 0.78rem;
+ outline: none;
+}
+
+.lm-select:focus-visible {
+ border-color: var(--accent);
+}
+
+.lm-runs-hint {
+ min-width: 0;
+ font-size: 0.74rem;
+}
+
+/* ── the confirm screen ───────────────────────────────────────────── */
+.lm-sizes {
display: flex;
flex-direction: column;
- gap: 0.1rem;
- margin-top: 0.3rem;
- padding-top: 0.3rem;
+}
+
+.lm-sizes .lm-heading {
+ margin-bottom: 0.3rem;
+}
+
+.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);
+}
+
+.lm-size-what {
+ min-width: 0;
+ overflow-wrap: anywhere;
+}
+
+.lm-size-gb {
+ flex-shrink: 0;
+ 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);
+}
+
+.lm-agent,
+.lm-where {
+ display: flex;
+ flex-direction: column;
+ gap: 0.35rem;
+}
+
+/* A literal command and a folder: text to read and to copy, so it is set in
+ the mono face and selects whole. */
+.lm-command {
+ display: block;
+ padding: 0.42rem 0.6rem;
+ border-radius: 6px;
+ background: var(--bg-input);
+ color: var(--text);
+ font-family: var(--font-mono);
+ font-size: 0.72rem;
+ line-height: 1.5;
+ overflow-wrap: anywhere;
+ user-select: all;
+}
+
+/* ── progress: the steps ARE a sequence, so they are a list ───────── */
+.lm-steps {
+ display: flex;
+ flex-direction: column;
+ gap: 0.5rem;
+ margin: 0;
+ padding: 0;
+ list-style: none;
+}
+
+.lm-step {
+ display: flex;
+ align-items: center;
+ gap: 0.65rem;
+ font-size: 0.8rem;
+ color: var(--text-muted);
+}
+
+.lm-step-mark {
+ position: relative;
+ box-sizing: border-box;
+ flex-shrink: 0;
+ width: 14px;
+ height: 14px;
+ border: 1.5px solid var(--border-light);
+ border-radius: 50%;
+}
+
+.lm-step-done {
+ color: var(--text-dim);
+}
+
+.lm-step-done .lm-step-mark {
+ border-color: var(--accent);
+ background: var(--accent);
+}
+
+/* The check of a finished step, drawn from two borders in the page colour. */
+.lm-step-done .lm-step-mark::after {
+ content: '';
+ position: absolute;
+ left: 3.5px;
+ top: 1px;
+ width: 3px;
+ height: 6px;
+ border: solid var(--accent-ink);
+ border-width: 0 1.5px 1.5px 0;
+ transform: rotate(45deg);
+}
+
+.lm-step-now,
+.lm-step-stopped {
+ font-weight: 600;
+ color: var(--text);
+}
+
+.lm-step-now .lm-step-mark {
+ border-color: var(--accent);
+ background: radial-gradient(circle, var(--accent) 0 3px, transparent 3.5px);
+}
+
+.lm-step-stopped .lm-step-mark {
+ border-color: var(--text-dim);
+ background: radial-gradient(circle, var(--text-dim) 0 3px, transparent 3.5px);
+}
+
+/* ── installed ────────────────────────────────────────────────────── */
+.lm-state {
+ display: flex;
+ flex-wrap: wrap;
+ align-items: center;
+ gap: 0.3rem 0.5rem;
+ font-size: 0.8rem;
+ color: var(--text-dim);
+}
+
+.lm-dot {
+ flex-shrink: 0;
+ width: 8px;
+ height: 8px;
+ border-radius: 50%;
+ background: var(--text-muted);
+}
+
+.lm-dot-on {
+ background: var(--green);
+}
+
+.lm-state-text {
+ font-weight: 600;
+ color: var(--text);
+}
+
+/* ── a refused verb, an error's raw detail, the Remove question ──── */
+.lm-notice {
+ display: flex;
+ flex-direction: column;
+ gap: 0.2rem;
+ padding-inline-start: 0.7rem;
+ border-inline-start: 2px solid var(--yellow);
+}
+
+.lm-notice-text {
+ font-size: 0.8rem;
+ line-height: 1.5;
+ color: var(--text);
+ overflow-wrap: anywhere;
+}
+
+.lm-notice-detail {
+ font-size: 0.72rem;
+ line-height: 1.45;
+ color: var(--text-dim);
+ overflow-wrap: anywhere;
+}
+
+.lm-details {
+ margin-top: 0.55rem;
+ font-size: 0.74rem;
+ color: var(--text-dim);
+}
+
+.lm-details > summary {
+ width: fit-content;
+ cursor: pointer;
+}
+
+.lm-details > summary:hover {
+ color: var(--text);
+}
+
+.lm-detail-text {
+ max-height: 9rem;
+ margin: 0.45rem 0 0;
+ padding: 0.5rem 0.6rem;
+ border-radius: 6px;
+ background: var(--bg-input);
+ color: var(--text-dim);
+ font-family: var(--font-mono);
+ font-size: 0.68rem;
+ line-height: 1.5;
+ white-space: pre-wrap;
+ overflow: auto;
+ overflow-wrap: anywhere;
+}
+
+.lm-remove {
+ display: flex;
+ flex-direction: column;
+ gap: 0.6rem;
+ padding-top: 0.95rem;
border-top: 1px solid var(--border);
}
-.local-model-alt .local-model-name {
- font-size: 0.76rem;
- font-weight: 500;
+.lm-check {
+ display: flex;
+ align-items: center;
+ gap: 0.5rem;
+ width: fit-content;
+ font-size: 0.8rem;
+ color: var(--text);
+ cursor: pointer;
}
-/* In App Settings the body sits inside an ordinary .set-row, so it takes that
- surface's type scale (label 0.76rem over a muted description) and the row
- supplies the border. */
-#localModelSettings {
- padding: 3px 0;
+.lm-check input {
+ margin: 0;
+ accent-color: var(--accent);
}
-#localModelSettings .local-model-headline,
-#localModelSettings .local-model-name {
- font-size: 0.76rem;
+/* ── the foot: one promise on the left, the action on the right ──── */
+.lm-foot {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 1rem;
+ padding-top: 0.95rem;
+ border-top: 1px solid var(--border);
}
-#localModelSettings .local-model-alt .local-model-name {
+.lm-remove .lm-foot {
+ padding-top: 0.2rem;
+ border-top: 0;
+}
+
+.lm-foot-note {
+ min-width: 0;
font-size: 0.72rem;
+ line-height: 1.45;
+ color: var(--text-muted);
}
-/* Touch: the two things in the card that take a press get a finger's height. */
+.lm-buttons {
+ display: flex;
+ flex-shrink: 0;
+ gap: 0.5rem;
+ margin-inline-start: auto;
+}
+
+/* The buttons are the Git worktrees dialog's set (`.wt-btn`): a main action
+ that reads as one on every skin (the skin block greys
+ `.btn-toolbar.btn-primary`) and that the phone toolbar's !important sizing
+ does not reach. Only the size is the window's own. */
+.local-model-window .wt-btn.lm-btn {
+ padding: 0.5rem 0.95rem;
+ font-weight: 600;
+ white-space: nowrap;
+}
+
+.local-model-window .wt-btn-primary.lm-btn {
+ font-weight: 700;
+}
+
+/* OG has no designed --accent-ink: the main action takes the deeper blue and
+ light text of that skin's own Run button, as `.welcome-primary` does. Keep
+ the two in step. */
+html[data-skin='og'] .local-model-window .wt-btn-primary.lm-btn {
+ border-color: rgba(96, 165, 250, 0.5);
+ background: linear-gradient(135deg, #1e4fb8 0%, #2563eb 100%);
+ color: #fff;
+}
+
+html[data-skin='og'] .local-model-window .wt-btn-primary.lm-btn:hover:not(:disabled) {
+ background: linear-gradient(135deg, #2454a8 0%, #2d6cf0 100%);
+}
+
+.local-model-window .wt-btn.lm-btn:focus-visible {
+ outline: 2px solid var(--accent);
+ outline-offset: 2px;
+}
+
+@media (prefers-reduced-motion: reduce) {
+ .lm-bar-fill,
+ .lm-model {
+ transition: none;
+ }
+}
+
+/* Touch: rows and the select get a finger's height. */
@media (pointer: coarse) {
- .local-model-hide {
- min-height: 32px;
- padding: 0.3rem 0.8rem;
+ .lm-model {
+ min-height: 2.9rem;
}
- .local-model-alts > summary {
- padding: 0.4rem 0;
+ .lm-select {
+ min-height: 36px;
+ }
+
+ .local-model-window .wt-btn.lm-btn {
+ min-height: 40px;
}
}
diff --git a/src/web/public/terminal-ui.js b/src/web/public/terminal-ui.js
index 949cbbc8..27ea257f 100644
--- a/src/web/public/terminal-ui.js
+++ b/src/web/public/terminal-ui.js
@@ -2964,8 +2964,9 @@ Object.assign(CodemanApp.prototype, {
// up (stopped in hideWelcome), see foreign-sessions.js.
this.wireForeignSessions?.();
this.startForeignPolling?.();
- // What this Mac can run as a local model (local-model-ui.js). One request,
- // at most once a minute, no timer: nothing to stop in hideWelcome.
+ // The "Run a local model" button (local-model-ui.js). One request, at most
+ // once a minute, no timer here: only its open window polls, and closing
+ // that window stops it, so there is nothing to stop in hideWelcome.
this.loadLocalModel?.();
}
// Home screen has no input target — hide the CJK textarea (activeSessionId
diff --git a/test/local-model-i18n.test.ts b/test/local-model-i18n.test.ts
index 691bff17..e71c4c54 100644
--- a/test/local-model-i18n.test.ts
+++ b/test/local-model-i18n.test.ts
@@ -1,18 +1,22 @@
/**
- * @fileoverview The "Model on this Mac" card and its App Settings group in Chinese (zh-CN).
+ * @fileoverview "Run a model on this Mac" in Chinese (zh-CN): the welcome button, the chooser
+ * window and the App Settings group.
*
* Three sources of text, all checked against the real dictionary in i18n.js:
* - the module's own strings, HARVESTED from the real local-model-ui.js (every literal it
* passes to `localModelT`), so a new string without an entry fails here instead of showing
* up English on one surface;
- * - every sentence and label the SERVER may send (`LOCAL_MODEL_LABELS`, imported from the
- * contract, so a label added there fails here until it has an entry);
- * - the static heading and hint of the Settings group, read from the real index.html and run
- * through the real DOM translator.
+ * - every sentence and label the SERVER may send: the recommendation (`LOCAL_MODEL_LABELS`),
+ * the tag of a model row (`LOCAL_MODEL_TAG_LABELS`) and the install
+ * (`LOCAL_MODEL_INSTALL_LABELS`), all imported from the contract, so a label added there
+ * fails here until it has an entry;
+ * - the static strings of the real index.html (the Settings group's heading and hint, the
+ * window's title), run through the real DOM translator.
*
- * Both containers carry data-i18n-skip (model, harness and chip names are someone else's
- * text), so nothing in them is reached by the DOM translator: these strings translate only
- * through the module's own call, which is why they are checked through `t()` here.
+ * The window's body, the welcome button and the Settings row carry data-i18n-skip (model,
+ * coding agent and chip names are someone else's text), so nothing in them is reached by the
+ * DOM translator: these strings translate only through the module's own call, which is why
+ * they are checked through `t()` here.
*
* Beyond "it is Chinese now", each entry must keep its `{placeholder}` slots: a translation
* that drops one prints a sentence without its number.
@@ -24,7 +28,13 @@ import { resolve } from 'node:path';
import vm from 'node:vm';
import { JSDOM } from 'jsdom';
import { afterAll, describe, expect, it } from 'vitest';
-import { LOCAL_MODEL_LABELS, LOCAL_MODEL_NONE_LABELS } from '../src/types.js';
+import {
+ LOCAL_MODEL_ERROR_LABELS,
+ LOCAL_MODEL_INSTALL_LABELS,
+ LOCAL_MODEL_LABELS,
+ LOCAL_MODEL_NONE_LABELS,
+ LOCAL_MODEL_TAG_LABELS,
+} from '../src/types.js';
const PUBLIC = resolve(import.meta.dirname, '../src/web/public');
const I18N = readFileSync(resolve(PUBLIC, 'i18n.js'), 'utf8');
@@ -39,11 +49,14 @@ type Api = {
/** Every literal the module hands to its translator call (Prettier may break after the paren). */
const FROM_MODULE = [...new Set([...MODULE.matchAll(/localModelT\(\s*'([^']+)'/g)].map((m) => m[1]))];
-/** Every text the server may send. */
-const FROM_SERVER: string[] = Object.values(LOCAL_MODEL_LABELS);
-/** Already in the dictionary for the outside-sessions block; reused here for a failed request. */
-const SHARED = ['Could not reach the server.'];
-const OWN = FROM_MODULE.filter((source) => !SHARED.includes(source));
+/** Every text the server may send: the recommendation, the tag of a row, the install. */
+const FROM_SERVER: string[] = [
+ ...new Set([
+ ...Object.values(LOCAL_MODEL_LABELS),
+ ...Object.values(LOCAL_MODEL_TAG_LABELS),
+ ...Object.values(LOCAL_MODEL_INSTALL_LABELS),
+ ]),
+];
/** A page with only the translator in it: enough for `t()`, and cheap to re-configure. */
const small = new JSDOM('', {
@@ -66,13 +79,15 @@ afterAll(() => {
page.window.close();
});
-/** The Settings group's static strings, as index.html ships them (English). */
+/** The static strings of the page, as index.html ships them (English): the Settings group and the window's title. */
const GROUP_MARKUP = INDEX.match(
/
([^<]+)<\/h3>/);
+const STATIC =
+ GROUP_MARKUP && TITLE_MARKUP ? [GROUP_MARKUP[1].trim(), GROUP_MARKUP[2].trim(), TITLE_MARKUP[1].trim()] : [];
-const ALL = [...new Set([...OWN, ...FROM_SERVER, ...STATIC])];
+const ALL = [...new Set([...FROM_MODULE, ...FROM_SERVER, ...STATIC])];
/** The `{slot}` names of a source text. */
const slots = (text: string) => [...text.matchAll(/\{([a-zA-Z]\w*)\}/g)].map((m) => m[1]);
@@ -80,25 +95,29 @@ const slots = (text: string) => [...text.matchAll(/\{([a-zA-Z]\w*)\}/g)].map((m)
/** U+2013, U+2014 and U+2015, built from their code points so this file holds none of them. */
const LONG_DASH = new RegExp(`[${String.fromCharCode(0x2013, 0x2014, 0x2015)}]`);
-/** What may stay Latin: the product, the platform and its vendors, the unit, and Token (the dictionary's own word). */
+/**
+ * What may stay Latin: the product, the platform and its vendors, the units, Token (the
+ * dictionary's own word), and the proper names of the install (the runtime, the two tools a
+ * coding agent calls, the GPU and an API key).
+ */
const leftover = (text: string) =>
- text.replace(/\b(Codeman|macOS|Mac|GB|Apple|Intel|Token)\b/g, '').match(/[A-Za-z]+/g) ?? [];
+ text.replace(/\b(Codeman|macOS|Mac|GB|MB|Apple|Intel|Token|Ollama|GPU|API|git|Python)\b/g, '').match(/[A-Za-z]+/g) ??
+ [];
describe('the local model strings in zh-CN', () => {
it('harvests the module (the check is not vacuous)', () => {
- expect(FROM_MODULE).toContain('Model on this Mac');
- expect(FROM_MODULE).toContain('{model} ({id}) with {harness}');
- expect(FROM_MODULE).toContain('Needs about {need} GB of the {budget} GB this Mac can give a model (estimated).');
- expect(FROM_MODULE).toContain(
- 'Codeman cannot download or install this for you yet. That is the next step, and it is not built yet.'
- );
- // What the fit and speed labels measure, and the speed of an alternative.
- expect(FROM_MODULE).toContain('Memory fit: needs {share}% of what this Mac can give a model');
- expect(FROM_MODULE).toContain('No speed estimate for this model on this chip');
- expect(FROM_MODULE).toContain('about {speed} tokens per second');
- expect(FROM_MODULE.length).toBeGreaterThanOrEqual(20);
+ expect(FROM_MODULE).toContain('Run a local model');
+ expect(FROM_MODULE).toContain('Local model');
+ expect(FROM_MODULE).toContain('About {need} of {budget} GB of memory');
+ expect(FROM_MODULE).toContain('Install on this Mac');
+ expect(FROM_MODULE).toContain('Download and install');
+ expect(FROM_MODULE).toContain('{speed} MB per second, {left}');
+ expect(FROM_MODULE).toContain('You can close this window. The download continues while Codeman is running.');
+ expect(FROM_MODULE).toContain('Start a session');
+ expect(FROM_MODULE).toContain('{model} is ready on this Mac.');
+ expect(FROM_MODULE.length).toBeGreaterThanOrEqual(70);
// The only other way the module calls its translator is with payload text (a server
- // sentence or label), so no string of its own can reach the page unharvested.
+ // sentence, or the tag of a row), so no string of its own can reach the page unharvested.
const code = MODULE.split('\n')
.filter((line) => !/^\s*(\/\/|\*|\/\*)/.test(line))
.join('\n');
@@ -108,14 +127,23 @@ describe('the local model strings in zh-CN', () => {
// One more: the function's own declaration.
expect(calls).toBe(literalCalls + payloadCalls + 1);
expect(payloadCalls).toBe(2);
+ // The stage that only read is gone, with its sentence.
+ expect(MODULE).not.toContain('cannot download or install');
});
- it('lists every server label, the none reasons included', () => {
+ it('lists every server label: the recommendation, the tags and the install', () => {
// A label added to the contract later fails below by its text, not here by a count.
- expect(FROM_SERVER.length).toBeGreaterThanOrEqual(18);
+ expect(FROM_SERVER.length).toBeGreaterThanOrEqual(18 + 3 + 26);
for (const text of Object.values(LOCAL_MODEL_NONE_LABELS)) expect(FROM_SERVER).toContain(text);
+ // Every error code has its sentence in the list.
+ for (const text of Object.values(LOCAL_MODEL_ERROR_LABELS)) expect(FROM_SERVER).toContain(text);
expect(FROM_SERVER).toContain('Best fit for this Mac');
expect(FROM_SERVER).toContain('Speed unknown');
+ expect(FROM_SERVER).toContain('Tested on a Mac like this');
+ expect(FROM_SERVER).toContain('Best fit');
+ expect(FROM_SERVER).toContain('Small model');
+ expect(FROM_SERVER).toContain(LOCAL_MODEL_INSTALL_LABELS.conflictSizeChanged);
+ expect(FROM_SERVER).toContain(LOCAL_MODEL_INSTALL_LABELS.conflictSessionsOpen);
// The two other sentences of the `memory` reason and the two notes are not in the
// reason map, so they are named here.
expect(FROM_SERVER).toContain(LOCAL_MODEL_LABELS.noneMemoryContext);
@@ -124,6 +152,12 @@ describe('the local model strings in zh-CN', () => {
expect(FROM_SERVER).toContain(LOCAL_MODEL_LABELS.noteDiskDropped);
});
+ it('prints the developer tools sentence from one source', () => {
+ // Not sent by the server: the window prints it when `developerTools` is `missing`. The
+ // module's literal is the contract's text, so one dictionary entry serves both.
+ expect(FROM_MODULE).toContain(LOCAL_MODEL_INSTALL_LABELS.developerToolsMissing);
+ });
+
it('never uses the translator slot {name} for a value of its own', () => {
for (const text of ALL) expect(slots(text), text).not.toContain('name');
});
@@ -166,9 +200,10 @@ describe('the local model strings in zh-CN', () => {
}
});
- it('translates the Settings group heading and hint in the page, and leaves both containers alone', () => {
- expect(STATIC).toHaveLength(2);
+ it('translates the static strings in the page, and leaves what the module writes alone', () => {
+ expect(STATIC).toHaveLength(3);
expect(STATIC[0]).toBe('Model on this Mac');
+ expect(STATIC[2]).toBe('Run a model on this Mac');
const translated = page.window.document.getElementById('localModelGroup')!;
const heading = translated.querySelector('.set-group-head h4')!.textContent!.trim();
const hint = translated.querySelector('.set-group-hint')!.textContent!.trim();
@@ -176,8 +211,16 @@ describe('the local model strings in zh-CN', () => {
expect(hint).not.toBe(STATIC[1]);
expect(hint).toBe(api.t(STATIC[1]));
expect(leftover(hint)).toEqual([]);
- // Written only by the module, through its own translator call.
- expect(INDEX).toMatch(/id="localModelCard"[^>]*data-i18n-skip/);
- expect(INDEX).toMatch(/id="localModelSettings"[^>]*data-i18n-skip/);
+ const title = page.window.document.getElementById('localModelModalTitle')!.textContent!.trim();
+ expect(title).toBe('在此 Mac 上运行模型');
+ // The dialog's close button is named in Chinese too.
+ const close = page.window.document.querySelector('#localModelModal .modal-close')!;
+ expect(close.getAttribute('aria-label')).toBe('关闭');
+ // Written only by the module, through its own translator call. The button keeps the
+ // English label it ships with until the module shows it.
+ for (const id of ['welcomeLocalModelBtn', 'localModelSettings', 'localModelSettingsOpen', 'localModelWindowBody']) {
+ expect(INDEX).toMatch(new RegExp(`id="${id}"[^>]*data-i18n-skip`));
+ }
+ expect(page.window.document.getElementById('welcomeLocalModelLabel')!.textContent).toBe('Run a local model');
});
});
diff --git a/test/local-model-ui.browser.test.ts b/test/local-model-ui.browser.test.ts
new file mode 100644
index 00000000..29380170
--- /dev/null
+++ b/test/local-model-ui.browser.test.ts
@@ -0,0 +1,349 @@
+/**
+ * @fileoverview Real-Chromium coverage for the "Run a model on this Mac" window
+ * (src/web/public/local-model-ui.js): what DOM emulation cannot answer, because it lays nothing
+ * out.
+ *
+ * - the numbers of the model list line up: every row's speed and download size end on the
+ * same edge, under their captions, and every tag starts on the same edge;
+ * - a row is one line on a desktop and two lines under 560px, where nothing runs off the
+ * side and the buttons take the full width;
+ * - the memory bar is 10px high and filled to the selected model's share of its track, and
+ * the fill follows the selection;
+ * - the selected row carries the accent on its reading edge;
+ * - the welcome button is really gone while it is `hidden`, and the window paints above App
+ * Settings.
+ *
+ * The REAL index.html is served with its scripts taken out and the two shipping stylesheets
+ * inlined (mobile.css under the media query its `` carries); the real module runs on a
+ * bare `CodemanApp` whose `_api` answers a fixture payload. The states themselves are pinned in
+ * JSDOM by test/local-model-ui.test.ts.
+ *
+ * Port: none (page.route on a fake origin, no server).
+ */
+
+import { readFileSync } from 'node:fs';
+import { resolve } from 'node:path';
+import { afterAll, afterEach, beforeAll, describe, expect, it } from 'vitest';
+import { chromium, type Browser, type Page } from 'playwright';
+
+const dir = resolve(import.meta.dirname, '../src/web/public');
+const read = (name: string) => readFileSync(resolve(dir, name), 'utf8');
+const ORIGIN = 'http://app.test/';
+
+/**
+ * index.html as shipped, minus every script, with styles.css and mobile.css inlined. The
+ * comments go first: some of them mention a script tag, which would otherwise open a match
+ * that swallows the markup up to the next real one.
+ */
+const MARKUP = read('index.html')
+ .replace(//g, '')
+ .replace(/').length - 1).toBe(1);
});
it('is what every other test here runs on', () => {
- const { card, row, group } = mount();
- expect(card.outerHTML).toBe(CARD_MARKUP);
+ const { button, group, modal } = mount();
+ expect(button.outerHTML).toBe(BUTTON_MARKUP);
expect(group.outerHTML).toBe(GROUP_MARKUP);
- expect(row.textContent).toBe('');
+ expect(modal.outerHTML).toBe(MODAL_MARKUP);
});
- it('is wired into the two places that show it', () => {
+ it('is wired into the places that show it, and Escape closes the window alone', () => {
const read = (file: string) => readFileSync(resolve(PUBLIC, file), 'utf8');
// The welcome screen (not the phone overview branch above it) and App Settings.
expect(read('terminal-ui.js').split('this.loadLocalModel?.();').length - 1).toBe(1);
expect(read('settings-ui.js').split('this.loadLocalModel?.();').length - 1).toBe(1);
+ // The window can sit on App Settings: its Escape is claimed before every panel is closed.
+ const app = read('app.js');
+ const claim = app.indexOf('if (this.isLocalModelWindowOpen?.()) {');
+ expect(claim).toBeGreaterThan(-1);
+ expect(app.slice(claim, claim + 120)).toContain('this.closeLocalModelWindow();\n return;');
+ expect(claim).toBeLessThan(app.indexOf('this.closeAllPanels();', claim));
});
});
describe('before there is an answer', () => {
- it('keeps the card and the Settings group hidden while the first request is in flight', async () => {
- const { newApp, card, row, group } = mount();
+ it('keeps the button and the Settings group hidden while the first request is in flight', async () => {
+ const { newApp, button, row, group } = mount();
let answer!: (response: ApiResponse) => void;
const app = newApp(() => new Promise((done) => (answer = done)));
const loading = app.loadLocalModel();
- expect(card.hidden).toBe(true);
- expect(card.textContent).toBe('');
- // Nothing says yet that the server is a Mac, so no group titled "Model on this Mac" and no
- // sentence about "this Mac" either: on most servers both would be false.
+ expect(button.hidden).toBe(true);
+ // Nothing says yet that the server is a Mac, so no group titled "Model on this Mac".
expect(group.style.display).toBe('none');
expect(row.textContent).toBe('');
- answer(ok(OK_STATUS));
+ answer(ok(IDLE));
await loading;
- expect(card.hidden).toBe(false);
+ expect(button.hidden).toBe(false);
expect(group.style.display).toBe('');
- expect(texts(row, '.local-model-headline')).toEqual(['Best fit for this Mac']);
- });
-
- it('keeps an answer on screen, group included, while it is refreshed', async () => {
- const { dom, newApp, row, group } = mount();
- let now = 1_000_000;
- (dom.window as unknown as { Date: DateConstructor }).Date.now = () => now;
- let answer!: (response: ApiResponse) => void;
- const answers: Array<() => Promise> = [
- async () => ok(OK_STATUS),
- () => new Promise((done) => (answer = done)),
- ];
- const app = newApp(() => (answers.shift() as () => Promise)());
- await app.loadLocalModel();
- now += 61_000;
- const refreshing = app.loadLocalModel();
- expect(group.style.display).toBe('');
- expect(texts(row, '.local-model-headline')).toEqual(['Best fit for this Mac']);
- answer(ok(OK_STATUS));
- await refreshing;
+ expect(texts(row, '.set-row-label')).toEqual(['No model installed']);
});
it.each([
@@ -390,630 +623,1547 @@ describe('before there is an answer', () => {
['the body is not JSON', { ok: true, status: 200, json: async () => Promise.reject(new Error('bad')) }],
['the data carries no recommendation', ok({ hardware: HARDWARE })],
] as Array<[string, ApiResponse]>)('shows a failed request only in Settings: %s', async (_name, response) => {
- const { newApp, card, row, group } = mount();
+ const { newApp, button, row, rowButton, group } = mount();
const app = newApp(response);
await app.loadLocalModel();
- // Nothing is known about the machine, so the welcome screen gets no card at all.
- expect(card.hidden).toBe(true);
+ // Nothing is known about the machine, so the welcome screen gets no button, and the row no way in.
+ expect(button.hidden).toBe(true);
expect(group.style.display).toBe('');
expect(row.textContent).toBe('Could not reach the server.');
+ expect(rowButton.hidden).toBe(true);
+ app.openLocalModelWindow();
+ expect(app.isLocalModelWindowOpen()).toBe(true);
});
- it('asks again the next time it is shown after a failure', async () => {
- const { newApp, card, row } = mount();
- const answers: ApiResponse[] = [null, ok(OK_STATUS)];
+ it('asks again the next time it is shown after a failure, and keeps an answer while it is refreshed', async () => {
+ const { win, newApp, button, row } = mount();
+ let now = 1_000_000;
+ (win as unknown as { Date: DateConstructor }).Date.now = () => now;
+ const answers: ApiResponse[] = [null, ok(IDLE), null];
const app = newApp(async () => answers.shift() ?? null);
await app.loadLocalModel();
expect(row.textContent).toBe('Could not reach the server.');
await app.loadLocalModel();
expect(app._api).toHaveBeenCalledTimes(2);
- expect(card.hidden).toBe(false);
- expect(texts(row, '.local-model-headline')).toEqual(['Best fit for this Mac']);
+ expect(button.hidden).toBe(false);
+ // Fresh for a minute: no request. Then a failed refresh keeps the answer on screen.
+ await app.loadLocalModel();
+ expect(app._api).toHaveBeenCalledTimes(2);
+ now += 61_000;
+ await app.loadLocalModel();
+ expect(app._api).toHaveBeenCalledTimes(3);
+ expect(button.hidden).toBe(false);
+ expect(texts(row, '.set-row-label')).toEqual(['No model installed']);
});
});
describe('a refused request (a non-admin in multi-user mode)', () => {
- it('shows neither the card nor the Settings group, and stops asking', async () => {
- const { newApp, card, row, group } = mount();
+ it('shows neither the button nor the Settings group, opens no window, and stops asking', async () => {
+ const { newApp, button, row, group, modal } = mount();
const app = newApp(refusal(403, 'FORBIDDEN'));
await app.loadLocalModel();
- expect(card.hidden).toBe(true);
+ expect(button.hidden).toBe(true);
expect(group.style.display).toBe('none');
expect(row.textContent).toBe('');
await app.loadLocalModel();
expect(app._api).toHaveBeenCalledTimes(1);
+ app.openLocalModelWindow();
+ expect(modal.classList.contains('active')).toBe(false);
+ });
+
+ it('does not ask at all when the page already knows the user is not an admin', async () => {
+ const { win, newApp, button, group } = mount();
+ win.__codemanUser = { multiUser: true, role: 'user' };
+ const app = newApp(ok(IDLE));
+ await app.loadLocalModel();
+ expect(app._api).not.toHaveBeenCalled();
+ expect(button.hidden).toBe(true);
+ expect(group.style.display).toBe('none');
});
it('does not take another 403 for a refusal: an admin who must change the password is asked again', async () => {
// The auth layer answers 403 PASSWORD_CHANGE_REQUIRED for every /api route until the
// password is changed, and the dialog that changes it closes without a reload.
- const { dom, document, newApp, card, row, group } = mount();
- const win = dom.window as unknown as { __codemanUser?: { multiUser: boolean; role: string } };
+ const { dom, win, document, newApp, button, row, group } = mount();
win.__codemanUser = { multiUser: true, role: 'admin' };
- const answers: ApiResponse[] = [refusal(403, 'PASSWORD_CHANGE_REQUIRED'), ok(OK_STATUS)];
+ const answers: ApiResponse[] = [refusal(403, 'PASSWORD_CHANGE_REQUIRED'), ok(IDLE)];
const app = newApp(async () => answers.shift() ?? null);
await app.loadLocalModel();
- expect(app._api).toHaveBeenCalledTimes(1);
- expect(card.hidden).toBe(true);
+ expect(button.hidden).toBe(true);
// A failed request, not a refusal: the group stays, with the failure sentence.
expect(group.style.display).toBe('');
expect(row.textContent).toBe('Could not reach the server.');
-
// The password was changed: admin-ui.js announces the user again.
document.dispatchEvent(new dom.window.CustomEvent('codeman:me'));
- await vi.waitFor(() => expect(card.hidden).toBe(false));
+ await vi.waitFor(() => expect(button.hidden).toBe(false));
expect(app._api).toHaveBeenCalledTimes(2);
- expect(group.style.display).toBe('');
- expect(texts(row, '.local-model-headline')).toEqual(['Best fit for this Mac']);
});
- it.each([
- ['a 403 without a body', { ok: false, status: 403, json: async () => Promise.reject(new Error('no body')) }],
- ['a 403 with another error code', refusal(403, 'SOMETHING_ELSE')],
- ] as Array<[string, ApiResponse]>)('keeps asking after %s', async (_name, response) => {
- const { newApp, group } = mount();
- const app = newApp(response);
- await app.loadLocalModel();
- await app.loadLocalModel();
- expect(app._api).toHaveBeenCalledTimes(2);
- expect(group.style.display).toBe('');
- });
-
- it('does not ask at all once the page knows the role, and asks when the role turns out to be admin', async () => {
- const { dom, document, newApp, card, group } = mount();
- const win = dom.window as unknown as { __codemanUser?: { multiUser: boolean; role: string } };
- win.__codemanUser = { multiUser: true, role: 'user' };
- const app = newApp(ok(OK_STATUS));
- await app.loadLocalModel();
- expect(app._api).not.toHaveBeenCalled();
- expect(group.style.display).toBe('none');
- expect(card.hidden).toBe(true);
-
- win.__codemanUser = { multiUser: true, role: 'admin' };
- document.dispatchEvent(new dom.window.CustomEvent('codeman:me'));
- await vi.waitFor(() => expect(card.hidden).toBe(false));
- expect(app._api).toHaveBeenCalledTimes(1);
- expect(group.style.display).toBe('');
+ it('closes an open window when the routes start refusing', async () => {
+ const page = mount();
+ const { app } = await opened(page, IDLE);
+ expect(page.modal.classList.contains('active')).toBe(true);
+ app._api.mockImplementation(async () => refusal(403, 'FORBIDDEN'));
+ await app.loadLocalModel({ force: true });
+ expect(page.modal.classList.contains('active')).toBe(false);
+ expect(page.button.hidden).toBe(true);
});
});
describe('a server that is not a Mac', () => {
- const LINUX = none('not-macos', undefined, {
- platform: 'linux',
- arch: 'x64',
- arm64: undefined,
- chip: 'AMD Ryzen 9 7950X',
- source: 'not-probed',
- });
-
- it('shows neither the card nor the Settings group: nothing there is about this server', async () => {
- const { newApp, card, row, group } = mount();
- await newApp(ok(LINUX)).loadLocalModel();
- expect(card.hidden).toBe(true);
- expect(card.textContent).toBe('');
- // A group titled "Model on this Mac", about "the Mac that Codeman runs on", on a Linux
- // server: hidden, the same lever as a refused request.
+ it('shows no button and no Settings group', async () => {
+ const { newApp, button, row, group, modal } = mount();
+ const app = newApp(ok(none('not-macos', undefined, { platform: 'linux', arch: 'x64', chip: 'AMD EPYC' })));
+ await app.loadLocalModel();
+ expect(button.hidden).toBe(true);
expect(group.style.display).toBe('none');
expect(row.textContent).toBe('');
- });
-
- it('never shows the group at any point of the load, and keeps it away on a refresh', async () => {
- const { dom, newApp, group, row } = mount();
- let now = 1_000_000;
- (dom.window as unknown as { Date: DateConstructor }).Date.now = () => now;
- const seen: string[] = [];
- const app = newApp(async () => {
- seen.push(group.style.display);
- return ok(LINUX);
- });
- const first = app.loadLocalModel();
- seen.push(group.style.display);
- await first;
- now += 61_000;
- const second = app.loadLocalModel();
- seen.push(group.style.display);
- await second;
- seen.push(group.style.display);
- expect(app._api).toHaveBeenCalledTimes(2);
- expect(seen).toEqual(['none', 'none', 'none', 'none', 'none']);
- expect(row.textContent).toBe('');
+ expect(modal.classList.contains('active')).toBe(false);
});
});
-describe('verdict none on a Mac', () => {
- it.each(NONE_CASES)('says why for $reason, with the numbers the server sent', async ({ answer, sentence }) => {
- const { newApp, card, row } = mount();
- await newApp(ok(answer)).loadLocalModel();
- expect(card.hidden).toBe(false);
- for (const root of [card, row]) {
- expect(texts(root, '.local-model-headline')).toEqual([sentence]);
- expect(texts(root, '.local-model-hw')).toHaveLength(1);
- expect(root.querySelector('.local-model-pick')).toBeNull();
- expect(root.querySelector('.local-model-alts')).toBeNull();
- // Nothing to set up, so no line about setting it up.
- expect(root.textContent).not.toContain(NEXT_STEP);
- expect(root.textContent).not.toContain('Recommended');
+describe('the welcome button', () => {
+ it('offers to run a local model until one is installed, then names it', async () => {
+ const { newApp, button, document } = mount();
+ let current = IDLE;
+ const app = newApp(() => ok(current));
+ await app.loadLocalModel();
+ expect(button.hidden).toBe(false);
+ expect(document.getElementById('welcomeLocalModelLabel')!.textContent).toBe('Run a local model');
+ // The icon of the markup stays: only the label is written.
+ expect(button.querySelector('svg')).not.toBeNull();
+ for (const busy of [INSTALLING, PAUSED, failed('load-failed', { model: 'Qwen3.6 35B-A3B' })]) {
+ current = busy;
+ await app.loadLocalModel({ force: true });
+ expect(button.textContent!.trim()).toBe('Run a local model');
}
+ current = INSTALLED;
+ await app.loadLocalModel({ force: true });
+ expect(button.textContent!.trim()).toBe('Local model');
+ current = STOPPED;
+ await app.loadLocalModel({ force: true });
+ expect(button.textContent!.trim()).toBe('Local model');
});
- it('covers every reason the contract knows', () => {
- const covered = [...NONE_CASES.map((c) => c.reason), 'not-macos'].sort();
- expect(covered).toEqual(Object.keys(LOCAL_MODEL_NONE_LABELS).sort());
+ it('shows on a Mac that can run no model too: the window says why', async () => {
+ const { newApp, button } = mount();
+ const app = newApp(ok(none('memory', { memory: '8' }, { memClassGb: 8, memGb: '8.6', chip: 'Apple M2' })));
+ await app.loadLocalModel();
+ expect(button.hidden).toBe(false);
+ expect(button.textContent!.trim()).toBe('Run a local model');
});
- it('says both numbers when the Mac misses by a hair (the 16 GB Mac mini)', async () => {
- const { newApp, card, row } = mount();
- const answer = noneWith(
- 'memory',
- LOCAL_MODEL_LABELS.noneMemoryContext,
- { budget: '10.4', need: '10.3' },
- { memGb: '17.2', memClassGb: 16, chip: 'Apple M4', freeDiskGb: '42.8' }
- );
- await newApp(ok(answer)).loadLocalModel();
- for (const root of [card, row]) {
- expect(texts(root, '.local-model-hw')).toEqual(['Apple M4, 16 GB memory, 42.8 GB free']);
- expect(texts(root, '.local-model-headline')).toEqual([
- 'Codeman estimates that this Mac can give a model about 10.4 GB of memory. The smallest model a coding agent can work with needs about 10.3 GB. That leaves no safe margin, so Codeman recommends none here.',
- ]);
- }
- });
-
- it('states no memory size when the server could not read one', async () => {
- const { newApp, card, row } = mount();
- const answer = noneWith('memory', LOCAL_MODEL_LABELS.noneMemoryUnknown, undefined, {
- memBytes: 0,
- memGb: '0.0',
- memClassGb: 0,
- });
- await newApp(ok(answer)).loadLocalModel();
- for (const root of [card, row]) {
- // The chip and nothing else: no "0 GB memory" in the hardware line, no size in the sentence.
- expect(texts(root, '.local-model-hw')).toEqual(['Apple M5 Pro']);
- expect(texts(root, '.local-model-headline')).toEqual([
- 'Codeman could not read how much memory this Mac has, so it recommends no model here.',
- ]);
- expect(root.textContent).not.toMatch(/\b0(\.0)? GB/);
- }
+ it('opens the window, and the window gives the focus back when it closes', async () => {
+ const page = mount();
+ const app = page.newApp(ok(IDLE));
+ await app.loadLocalModel();
+ page.button.focus();
+ app.openLocalModelWindow();
+ expect(page.modal.classList.contains('active')).toBe(true);
+ expect(app.isLocalModelWindowOpen()).toBe(true);
+ // The focus lands on the selected model, inside the dialog.
+ expect(page.document.activeElement?.getAttribute('role')).toBe('radio');
+ app.closeLocalModelWindow();
+ expect(page.modal.classList.contains('active')).toBe(false);
+ expect(page.document.activeElement).toBe(page.button);
+ // Escape and the backdrop call it whether or not the window is open.
+ app.closeLocalModelWindow();
+ expect(app.isLocalModelWindowOpen()).toBe(false);
});
});
-describe('verdict ok', () => {
- it('shows the hardware, the headline, the pick with its numbers, the note and the next step', async () => {
- const { newApp, card } = mount();
- await newApp(ok(OK_STATUS)).loadLocalModel();
- expect(card.hidden).toBe(false);
- expect(texts(card, '.local-model-title')).toEqual(['Model on this Mac']);
- // The page can be open on another device: the title says whose Mac is meant.
- expect((card.querySelector('.local-model-title') as HTMLElement).title).toBe(
- 'The Mac that Codeman runs on, which is not always the device you are reading on.'
- );
- // 48, the size this Mac is sold as and what About This Mac shows: not 51.5 (its bytes in decimal GB).
- expect(texts(card, '.local-model-hw')).toEqual(['Apple M5 Pro, 48 GB memory, 912 GB free']);
- expect(texts(card, '.local-model-headline')).toEqual(['Best fit for this Mac']);
- expect(texts(card, '.local-model-note')).toEqual(['Not yet tested by us on a Mac like this.']);
+describe('the App Settings row', () => {
+ it.each([
+ ['nothing installed', IDLE, 'No model installed', 'Apple M5 Pro, 48 GB memory, 897 GB free', 'Choose a model'],
+ ['an install running', INSTALLING, 'Installing Qwen3.6 35B-A3B', 'Apple M5 Pro, 48 GB memory, 897 GB free', 'Open'],
+ ['a paused install', PAUSED, 'The install of Qwen3.6 35B-A3B is paused', undefined, 'Open'],
+ [
+ 'a stopped install',
+ failed('load-failed', { model: 'Qwen3.6 35B-A3B' }),
+ 'The install of Qwen3.6 35B-A3B stopped',
+ undefined,
+ 'Open',
+ ],
+ ['a running model', INSTALLED, 'Qwen3.6 35B-A3B is running', undefined, 'Open'],
+ ['a stopped model', STOPPED, 'Qwen3.6 35B-A3B is installed and stopped', undefined, 'Open'],
+ ] as Array<[string, LocalModelStatus, string, string | undefined, string]>)(
+ 'says where things stand in a few words: %s',
+ async (_name, answer, label, detail, action) => {
+ const { newApp, row, rowButton, group } = mount();
+ const app = newApp(ok(answer));
+ await app.loadLocalModel();
+ expect(group.style.display).toBe('');
+ expect(texts(row, '.set-row-label')).toEqual([label]);
+ if (detail) expect(texts(row, '.set-row-desc')).toEqual([detail]);
+ expect(rowButton.hidden).toBe(false);
+ expect(rowButton.textContent).toBe(action);
+ }
+ );
- const pick = card.querySelector('.local-model-pick')!;
- expect(texts(pick, '.local-model-name')).toEqual(['Qwen3.8 27B (qwen3.8:27b) with OpenCode']);
- expect(texts(pick, '.local-model-pill')).toEqual(['Good', 'Speed unknown']);
- expect(texts(pick, '.local-model-line')).toEqual([
- 'Context 131,072 tokens · 17.7 GB download · Apache-2.0',
- 'Needs about 29.6 GB of the 37.6 GB this Mac can give a model (estimated).',
+ it('gives the reason when no model fits, and still opens the window', async () => {
+ const page = mount();
+ const app = page.newApp(ok(none('memory', { memory: '8' }, { memClassGb: 8, memGb: '8.6', chip: 'Apple M2' })));
+ 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.',
]);
- expect(texts(card, '.local-model-next')).toEqual([NEXT_STEP]);
+ expect(page.rowButton.textContent).toBe('Open');
+ app.openLocalModelWindow();
+ expect(page.body.dataset.screen).toBe('none');
+ });
+});
+
+// ---------- The chooser ----------
+
+describe('the chooser', () => {
+ it('opens with the hardware line, the memory bar and the five models, the pick selected', async () => {
+ const page = mount();
+ await opened(page, IDLE);
+ 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']);
+
+ const group = body.querySelector('[role="radiogroup"]')!;
+ const label = page.document.getElementById(group.getAttribute('aria-labelledby')!)!;
+ // The server's word for a pick it has run on a Mac like this one labels the list.
+ expect(label.textContent).toBe('Recommended for this Mac');
+ const rows = [...group.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.getAttribute('aria-checked'))).toEqual(['true', 'false', 'false', 'false', 'false']);
+ // 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.
+ expect(rows.map((row) => row.querySelector('.lm-model-tag')!.textContent)).toEqual([
+ 'Tested on a Mac like this',
+ 'Slow',
+ '',
+ 'Small model',
+ 'Small model',
+ ]);
+ // A measured speed is stated, an estimate says "about", and a missing one says so.
+ expect(texts(group, '.lm-model-speed')).toEqual([
+ '68 tokens/s',
+ 'about 9.1 tokens/s',
+ 'speed not known',
+ 'about 18 tokens/s',
+ 'about 29 tokens/s',
+ ]);
+ 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(buttons(body)).toEqual(['Install on this Mac']);
});
- it('never says "Recommended" for a pick nobody tested: that word is the server\'s to send', async () => {
- const { newApp, card, row } = mount();
- await newApp(ok(OK_STATUS)).loadLocalModel();
- expect(card.textContent).not.toMatch(/recommend/i);
- expect(row.textContent).not.toMatch(/recommend/i);
- // The module holds no such string of its own (comments aside).
+ it('gives the accent to the pick and to a tested row only', async () => {
+ const page = mount();
+ const best = candidate({ tested: false, tag: { kind: 'best', label: LOCAL_MODEL_TAG_LABELS.best } });
+ const tight = candidate({
+ modelId: 'gpt-oss:20b',
+ modelLabel: 'GPT-OSS 20B',
+ tested: false,
+ tag: { kind: 'tight', label: LOCAL_MODEL_TAG_LABELS.tight },
+ });
+ await opened(
+ page,
+ status({
+ headline: { text: LOCAL_MODEL_LABELS.headlineOk },
+ pick: best,
+ alternatives: [tight, ALT_SLOW, ALT_SMALL],
+ })
+ );
+ const tags = [...page.body.querySelectorAll('.lm-tag')];
+ expect(tags.map((tag) => tag.textContent)).toEqual(['Best fit', 'Tight', 'Slow', 'Small model']);
+ expect(tags.map((tag) => tag.classList.contains('lm-tag-accent'))).toEqual([true, false, false, false]);
+ // "Best fit for this Mac" would only repeat the tag: the list is labelled by what it holds.
+ expect(texts(page.body, '.lm-caption-label')).toEqual(['Models that fit this Mac']);
+ expect(page.body.textContent).not.toContain('Recommended');
+ expect(page.body.textContent).not.toContain('Best fit for this Mac');
+ });
+
+ it('says "estimated" once while the budget is a formula, and not once it is measured', async () => {
+ const page = mount();
+ const { app, serve } = await opened(page, IDLE);
+ expect(texts(page.body, '.lm-figure-aside')).toEqual(['estimated']);
+ 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-aside')).toEqual([]);
+ expect(page.body.textContent).not.toContain('estimated');
+ }
+ });
+
+ it('puts the small-model caveat above the list and the notes under it', async () => {
+ const page = mount();
+ await opened(
+ page,
+ status({
+ verdict: 'limited',
+ headline: { text: LOCAL_MODEL_LABELS.headlineLimited },
+ notes: [
+ { text: LOCAL_MODEL_LABELS.noteDiskDropped, vars: { model: 'Qwen3.6 35B-A3B', missing: '0.4' } },
+ { text: LOCAL_MODEL_LABELS.noteUntested },
+ ],
+ pick: ALT_SMALL,
+ alternatives: [],
+ })
+ );
+ expect(texts(page.body, '.lm-lead')).toEqual([LOCAL_MODEL_LABELS.headlineLimited]);
+ 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.',
+ ]);
+ });
+
+ it('offers only the coding agents the server lists for the selected model', async () => {
+ const page = mount();
+ await opened(page, IDLE);
+ const { body } = page;
+ const select = () => body.querySelector('select.lm-select')!;
+ expect(texts(body, '.lm-runs-label')).toEqual(['Runs with']);
+ 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.']);
+ // 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']);
+
+ select().value = '1';
+ select().dispatchEvent(new page.dom.window.Event('change'));
+ expect(select().selectedOptions[0].textContent).toBe('Claude Code');
+ expect(texts(body, '.lm-runs-hint')).toEqual(['The first answer can take minutes.']);
+
+ // The agent stays when the next row offers it too, and falls back to that row's first when not.
+ (body.querySelectorAll('[role="radio"]')[2] as HTMLElement).click();
+ expect(select().selectedOptions[0].textContent).toBe('Claude Code');
+ (body.querySelectorAll('[role="radio"]')[1] as HTMLElement).click();
+ expect([...select().options].map((option) => option.textContent)).toEqual(['OpenCode']);
+ });
+
+ it('reads a payload without the agent list as the one agent of the flat fields', async () => {
+ const page = mount();
+ const flat = { ...PICK, harnesses: undefined, cliInstalled: true } as unknown as Candidate;
+ await opened(page, status({ pick: flat, alternatives: [] }));
+ const select = page.body.querySelector('select.lm-select')!;
+ expect([...select.options].map((option) => option.textContent)).toEqual(['OpenCode']);
+ expect(texts(page.body, '.lm-runs-hint')).toEqual([]);
+ });
+
+ it('has a button only when the server accepts an install', async () => {
+ const page = mount();
+ const { app, serve } = await opened(page, status({}, { actions: [] }));
+ expect(page.body.dataset.screen).toBe('choose');
+ expect(buttons(page.body)).toEqual([]);
+ // A server that sends no `install` object yet: the chooser, and nothing to press.
+ const older = { ...IDLE } as Partial;
+ delete older.install;
+ serve(older as LocalModelStatus);
+ await app.loadLocalModel({ force: true });
+ expect(page.body.querySelectorAll('[role="radio"]')).toHaveLength(5);
+ expect(buttons(page.body)).toEqual([]);
+ });
+});
+
+describe('the radio group at the keyboard', () => {
+ const key = (page: Mounted, name: string) => {
+ const event = new page.dom.window.KeyboardEvent('keydown', { key: name, bubbles: true, cancelable: true });
+ page.document.activeElement!.dispatchEvent(event);
+ return event;
+ };
+ const checked = (page: Mounted) =>
+ page.body.querySelector('[role="radio"][aria-checked="true"] .lm-model-name')!.textContent;
+
+ it('moves the selection with the arrow keys, Home and End, and the focus follows', async () => {
+ const page = mount();
+ await opened(page, IDLE);
+ expect(checked(page)).toBe('Qwen3.6 35B-A3B');
+ const walk: Array<[string, string]> = [
+ ['ArrowDown', 'Qwen3.8 27B'],
+ ['ArrowRight', 'GPT-OSS 20B'],
+ ['ArrowUp', 'Qwen3.8 27B'],
+ ['ArrowLeft', 'Qwen3.6 35B-A3B'],
+ // The ends wrap.
+ ['ArrowUp', 'Qwen3.5 9B'],
+ ['ArrowDown', 'Qwen3.6 35B-A3B'],
+ ['End', 'Qwen3.5 9B'],
+ ['Home', 'Qwen3.6 35B-A3B'],
+ ];
+ for (const [name, model] of walk) {
+ const event = key(page, name);
+ expect(event.defaultPrevented, name).toBe(true);
+ expect(checked(page), name).toBe(model);
+ // The focus is on the selected row, which is the group's one tab stop.
+ const active = page.document.activeElement!;
+ expect(active.getAttribute('aria-checked'), name).toBe('true');
+ expect(active.getAttribute('tabindex'), name).toBe('0');
+ expect(page.body.querySelectorAll('[role="radio"][tabindex="0"]'), name).toHaveLength(1);
+ }
+ });
+
+ it('leaves every other key to the page', async () => {
+ const page = mount();
+ await opened(page, IDLE);
+ for (const name of ['Tab', 'a', 'Escape', 'PageDown']) {
+ expect(key(page, name).defaultPrevented, name).toBe(false);
+ expect(checked(page)).toBe('Qwen3.6 35B-A3B');
+ }
+ });
+
+ it('selects a row on a click, and keeps the selection across the poll', async () => {
+ const page = mount();
+ const { app } = await opened(page, IDLE);
+ page.body.querySelectorAll('[role="radio"]')[3].click();
+ expect(checked(page)).toBe('Qwen3.5 9B (Q8)');
+ const row = page.body.querySelector('[role="radio"][aria-checked="true"]');
+ await page.tick();
+ await app.loadLocalModel({ force: true });
+ expect(checked(page)).toBe('Qwen3.5 9B (Q8)');
+ // The same answer again repaints nothing: the very same element is still there.
+ expect(page.body.querySelector('[role="radio"][aria-checked="true"]')).toBe(row);
+ });
+});
+
+describe('the memory bar', () => {
+ const meter = (page: Mounted) => page.body.querySelector('.lm-bar')!;
+ const fill = (page: Mounted) => page.body.querySelector('.lm-bar-fill')!;
+
+ it('is filled to what the selected model needs, and follows the selection', async () => {
+ const page = mount();
+ await opened(page, IDLE);
+ expect(meter(page).getAttribute('role')).toBe('meter');
+ expect(meter(page).getAttribute('aria-valuemin')).toBe('0');
+ expect(meter(page).getAttribute('aria-valuemax')).toBe('100');
+ // The pick is a tight fit: the bar is nearly full.
+ expect(meter(page).getAttribute('aria-valuenow')).toBe('96');
+ expect(fill(page).style.width).toBe('96%');
+ expect(meter(page).getAttribute('aria-label')).toBe(
+ 'The selected model needs 96% of the memory this Mac can give a model'
+ );
+
+ 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'],
+ ];
+ for (const [index, percent, figure, legend] of expected) {
+ page.body.querySelectorAll('[role="radio"]')[index].click();
+ expect(meter(page).getAttribute('aria-valuenow')).toBe(percent);
+ expect(fill(page).style.width).toBe(`${percent}%`);
+ expect(texts(page.body, '.lm-figure')).toEqual([figure]);
+ expect(texts(page.body, '.lm-legend span')[0]).toBe(legend);
+ }
+ });
+
+ it('never runs past its track', async () => {
+ const page = mount();
+ await opened(page, status({ pick: candidate({ budgetShare: 1.2 }), alternatives: [] }));
+ expect(meter(page).getAttribute('aria-valuenow')).toBe('100');
+ expect(fill(page).style.width).toBe('100%');
+ });
+
+ it('is one flat fill on a track, and stands still when motion is not wanted', () => {
+ const bar = STYLES.match(/\.lm-bar-fill \{[^}]*\}/)![0];
+ expect(bar).toContain('background: var(--accent);');
+ expect(bar).not.toMatch(/gradient|box-shadow|animation/);
+ expect(STYLES).toMatch(/\.lm-bar \{[^}]*height: 10px;[^}]*border-radius: 999px;/);
+ expect(STYLES).toMatch(/@media \(prefers-reduced-motion: reduce\) \{\s*\.lm-bar-fill,[^}]*transition: none;/);
+ });
+});
+
+// ---------- Confirm and the consent ----------
+
+describe('the confirm screen', () => {
+ it('collapses the list to the chosen row and says what is downloaded, and where it goes', async () => {
+ const page = mount();
+ await opened(page, status({}, { developerTools: 'missing' }));
+ press(page.body, 'Install on this Mac');
+ const { body } = page;
+ 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',
+ ]);
+ 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(buttons(body)).toEqual(['Back', 'Download and install']);
+ });
+
+ it('leaves out what does not apply: an installed runtime, an installed agent, a Mac with developer tools', async () => {
+ const page = mount();
+ 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(page.body.textContent).not.toContain('developer tools');
+ expect(page.body.textContent).not.toContain('installer');
+ });
+
+ it.each(['unknown', 'present'] as const)('says nothing about developer tools when they are %s', async (tools) => {
+ const page = mount();
+ await opened(page, status({}, { developerTools: tools }));
+ press(page.body, 'Install on this Mac');
+ expect(page.body.textContent).not.toContain('git');
+ });
+
+ it('goes back to the chooser with the selection kept', async () => {
+ const page = mount();
+ 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']);
+ 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');
+ });
+});
+
+describe('the consent', () => {
+ it('sends the row that was on screen with the total that was on screen', async () => {
+ const page = mount();
+ const { app } = await opened(page, IDLE, () => ok(INSTALLING.install));
+ press(page.body, 'Install on this Mac');
+ expect(verbs(app)).toEqual([]);
+ press(page.body, 'Download and install');
+ await settle();
+ expect(verbs(app)).toEqual([
+ [
+ 'POST',
+ '/api/local-model/install',
+ {
+ modelId: 'qwen3.6:35b-a3b',
+ cli: 'opencode',
+ context: 131072,
+ acknowledgedBytes: 23105798956 + RUNTIME_BYTES,
+ },
+ ],
+ ]);
+ // The answer is the install after the verb: the window shows the download at once.
+ expect(page.body.dataset.screen).toBe('progress');
+ });
+
+ it('sends the other row and the other agent when those were chosen', async () => {
+ const page = mount();
+ const { app } = await opened(page, IDLE, () => ok(INSTALLING.install));
+ page.body.querySelectorAll('[role="radio"]')[3].click();
+ const select = page.body.querySelector('select.lm-select')!;
+ select.value = '1';
+ select.dispatchEvent(new page.dom.window.Event('change'));
+ press(page.body, 'Install on this Mac');
+ expect(texts(page.body, '.lm-size-total .lm-size-gb')).toEqual(['10.6 GB']);
+ press(page.body, 'Download and install');
+ await settle();
+ expect(verbs(app)[0][2]).toEqual({
+ modelId: 'qwen3.5:9b-q8_0',
+ cli: 'claude',
+ context: 65536,
+ acknowledgedBytes: 10449217949 + RUNTIME_BYTES,
+ });
+ });
+
+ it('reads a row without its own total as the model plus the runtime download', async () => {
+ const page = mount();
+ const bare = { ...PICK, installBytes: undefined, installGb: undefined } as unknown as Candidate;
+ 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',
+ ]);
+ press(page.body, 'Download and install');
+ await settle();
+ expect((verbs(app)[0][2] as { acknowledgedBytes: number }).acknowledgedBytes).toBe(23105798956 + RUNTIME_BYTES);
+ });
+
+ it('shows the new total after a 409 size-changed, and sends that one next', async () => {
+ const page = mount();
+ const answers: ApiResponse[] = [
+ conflict({
+ reason: 'size-changed',
+ message: { text: LOCAL_MODEL_INSTALL_LABELS.conflictSizeChanged, vars: { size: '23.9' } },
+ expectedBytes: 23900000000,
+ expectedGb: '23.9',
+ }),
+ ok(INSTALLING.install),
+ ];
+ const { app } = await opened(page, IDLE, () => answers.shift() ?? null);
+ press(page.body, 'Install on this Mac');
+ press(page.body, 'Download and install');
+ await settle();
+ // Still the question, now with the server's sentence and its total.
+ expect(page.body.dataset.screen).toBe('confirm');
+ const notice = page.body.querySelector('.lm-notice')!;
+ expect(notice.getAttribute('role')).toBe('alert');
+ expect(notice.textContent).toBe('The download is 23.9 GB now. Check the sizes and confirm again.');
+ expect(texts(page.body, '.lm-size-total .lm-size-gb')).toEqual(['23.9 GB']);
+ press(page.body, 'Download and install');
+ await settle();
+ expect(verbs(app).map((verb) => (verb[2] as { acknowledgedBytes: number }).acknowledgedBytes)).toEqual([
+ 23105798956 + RUNTIME_BYTES,
+ 23900000000,
+ ]);
+ expect(page.body.dataset.screen).toBe('progress');
+ });
+
+ it('forgets a refused total when the person goes back and picks again', async () => {
+ const page = mount();
+ const { app } = await opened(page, IDLE, () =>
+ conflict({
+ reason: 'size-changed',
+ message: { text: LOCAL_MODEL_INSTALL_LABELS.conflictSizeChanged, vars: { size: '23.9' } },
+ expectedBytes: 23900000000,
+ expectedGb: '23.9',
+ })
+ );
+ press(page.body, 'Install on this Mac');
+ press(page.body, 'Download and install');
+ await settle();
+ press(page.body, 'Back');
+ expect(page.body.querySelector('.lm-notice')).toBeNull();
+ page.body.querySelectorAll('[role="radio"]')[4].click();
+ press(page.body, 'Install on this Mac');
+ expect(texts(page.body, '.lm-size-total .lm-size-gb')).toEqual(['6.7 GB']);
+ press(page.body, 'Download and install');
+ await settle();
+ expect((verbs(app)[1][2] as { acknowledgedBytes: number }).acknowledgedBytes).toBe(6550825373 + RUNTIME_BYTES);
+ });
+
+ it('follows the server when a job is already running', async () => {
+ const page = mount();
+ const { serve } = await opened(page, IDLE, () =>
+ conflict({ reason: 'job-running', message: { text: LOCAL_MODEL_INSTALL_LABELS.conflictJobRunning } })
+ );
+ press(page.body, 'Install on this Mac');
+ serve(INSTALLING);
+ press(page.body, 'Download and install');
+ await settle();
+ await settle();
+ expect(page.body.dataset.screen).toBe('progress');
+ expect(texts(page.body, '.lm-notice-text')).toEqual(['An install is already running.']);
+ });
+
+ it('says so when the request did not reach the server, and when the server did not take it', async () => {
+ const page = mount();
+ const answers: ApiResponse[] = [
+ null,
+ {
+ ok: false,
+ status: 400,
+ json: async () => ({ success: false, error: 'not a model offered', errorCode: 'INVALID_INPUT' }),
+ },
+ ];
+ await opened(page, IDLE, () => answers.shift() ?? null);
+ press(page.body, 'Install on this Mac');
+ press(page.body, 'Download and install');
+ await settle();
+ expect(page.body.dataset.screen).toBe('confirm');
+ expect(texts(page.body, '.lm-notice-text')).toEqual(['Could not reach the server.']);
+ press(page.body, 'Download and install');
+ await settle();
+ expect(texts(page.body, '.lm-notice-text')).toEqual(['The server did not accept that.']);
+ // The server's own words, as text.
+ expect(texts(page.body, '.lm-notice-detail')).toEqual(['not a model offered']);
+ expect(page.body.querySelector('.lm-notice b')).toBeNull();
+ });
+
+ it('does not let a read that was already on its way undo what a verb answered', async () => {
+ const page = mount();
+ let stale!: (response: ApiResponse) => void;
+ const { app } = await opened(page, IDLE, () => ok(INSTALLING.install));
+ press(page.body, 'Install on this Mac');
+ // A poll is in flight with the old answer when the install is sent.
+ app._api.mockImplementationOnce(() => new Promise((done) => (stale = done)));
+ await page.tick();
+ press(page.body, 'Download and install');
+ await settle();
+ expect(page.body.dataset.screen).toBe('progress');
+ // Every later read hangs, so what the old answer does to the screen can be seen.
+ const before = gets(app);
+ app._api.mockImplementation(() => new Promise(() => {}));
+ stale(ok(IDLE));
+ await settle();
+ await settle();
+ // The older answer is dropped, and a new read was sent after it.
+ expect(page.body.dataset.screen).toBe('progress');
+ expect(gets(app)).toBe(before + 1);
+ });
+
+ it('sends one request for two quick clicks', async () => {
+ const page = mount();
+ let answer!: (response: ApiResponse) => void;
+ const { app } = await opened(page, IDLE, () => new Promise((done) => (answer = done)));
+ 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([
+ true,
+ true,
+ ]);
+ page.body.querySelectorAll('.lm-buttons button')[1].click();
+ answer(ok(INSTALLING.install));
+ await settle();
+ expect(verbs(app)).toHaveLength(1);
+ });
+});
+
+// ---------- Progress ----------
+
+describe('the progress screen', () => {
+ it('replaces the memory bar with the download bar and lists the steps, the current one marked', async () => {
+ const page = mount();
+ await opened(page, INSTALLING);
+ const { body } = page;
+ expect(body.dataset.screen).toBe('progress');
+ expect(texts(body, '.lm-title')).toEqual(['Installing Qwen3.6 35B-A3B']);
+ expect(texts(body, '.lm-figure')).toEqual(['6.2 of 23.1 GB']);
+ expect(texts(body, '.lm-figure-aside')).toEqual(['58 MB per second, about 5 minutes left']);
+ const bar = body.querySelector('.lm-bar')!;
+ expect(bar.getAttribute('role')).toBe('progressbar');
+ expect(bar.getAttribute('aria-valuenow')).toBe('27');
+ expect(body.querySelector('.lm-bar-fill')!.style.width).toBe('27%');
+ expect(body.querySelector('[role="meter"]')).toBeNull();
+
+ const steps = [...body.querySelectorAll('ol.lm-steps > li')];
+ expect(steps.map((step) => step.textContent)).toEqual([
+ 'Getting the runtime',
+ 'Downloading the model',
+ 'Loading and checking',
+ 'Installing OpenCode',
+ 'Connecting',
+ ]);
+ expect(steps.map((step) => step.className.replace('lm-step', '').trim())).toEqual([
+ 'lm-step-done',
+ 'lm-step-now',
+ '',
+ '',
+ '',
+ ]);
+ expect(steps.map((step) => step.getAttribute('aria-current'))).toEqual([null, 'step', null, null, null]);
+ expect(texts(body, '.lm-aside')).toEqual([
+ 'You can close this window. The download continues while Codeman is running.',
+ ]);
+ expect(buttons(body)).toEqual(['Cancel']);
+ });
+
+ it('leaves the agent step out of a job whose coding agent is installed', async () => {
+ const page = mount();
+ await opened(
+ page,
+ status(
+ {},
+ {
+ ...JOB,
+ state: 'installing',
+ step: 'runtime',
+ steps: ['runtime', 'model', 'verify', 'connect'],
+ actions: ['cancel'],
+ }
+ )
+ );
+ expect(texts(page.body, 'ol.lm-steps > li')).toEqual([
+ 'Getting the runtime',
+ 'Downloading the model',
+ 'Loading and checking',
+ 'Connecting',
+ ]);
+ });
+
+ it.each([
+ [undefined, '58 MB per second'],
+ [0, '58 MB per second, less than a minute left'],
+ [59, '58 MB per second, less than a minute left'],
+ [60, '58 MB per second, about 1 minute left'],
+ [149, '58 MB per second, about 2 minutes left'],
+ [3599, '58 MB per second, about 60 minutes left'],
+ [3600, '58 MB per second, about 1 hour left'],
+ [9000, '58 MB per second, about 3 hours left'],
+ ] as Array<[number | undefined, string]>)('words the time left: %s seconds', async (secondsLeft, aside) => {
+ const page = mount();
+ await opened(page, status({}, { ...INSTALLING.install, progress: { ...PROGRESS, secondsLeft } }));
+ 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 () => {
+ 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']);
+ expect(page.body.querySelector('.lm-bar')!.getAttribute('aria-valuenow')).toBe('0');
+ for (const [step, label] of [
+ ['verify', 'Loading and checking'],
+ ['agent', 'Installing OpenCode'],
+ ['connect', 'Connecting'],
+ ] 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([]);
+ }
+ });
+
+ it('cancels with one request', async () => {
+ const page = mount();
+ const { app } = await opened(page, INSTALLING, () => ok(PAUSED.install));
+ press(page.body, 'Cancel');
+ await settle();
+ expect(verbs(app)).toEqual([['POST', '/api/local-model/cancel', undefined]]);
+ expect(page.body.dataset.screen).toBe('paused');
+ });
+
+ 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 steps = page.body.querySelector('ol.lm-steps');
+ serve(
+ status(
+ {},
+ {
+ ...INSTALLING.install,
+ progress: { ...PROGRESS, completedBytes: 11552899478, completedGb: '11.6', secondsLeft: 199 },
+ }
+ )
+ );
+ await app.loadLocalModel({ force: true });
+ expect(texts(page.body, '.lm-figure')).toEqual(['11.6 of 23.1 GB']);
+ expect(texts(page.body, '.lm-figure-aside')).toEqual(['58 MB per second, about 3 minutes left']);
+ 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.body.querySelector('ol.lm-steps')).toBe(steps);
+ // A new step is a new picture.
+ serve(status({}, { ...JOB, state: 'installing', step: 'verify', actions: ['cancel'] }));
+ await app.loadLocalModel({ force: true });
+ expect(page.body.querySelector('ol.lm-steps')).not.toBe(steps);
+ expect(texts(page.body, '.lm-step-now')).toEqual(['Loading and checking']);
+ });
+});
+
+describe('a paused install', () => {
+ it('shows where it stopped and continues only on a click', async () => {
+ const page = mount();
+ const { app } = await opened(page, PAUSED, () => ok(INSTALLING.install));
+ const { body } = page;
+ expect(body.dataset.screen).toBe('paused');
+ expect(texts(body, '.lm-title')).toEqual(['The install of Qwen3.6 35B-A3B is paused']);
+ expect(texts(body, '.lm-figure')).toEqual(['6.2 of 23.1 GB']);
+ // 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(buttons(body)).toEqual(['Remove', 'Choose another model', 'Continue']);
+ expect(verbs(app)).toEqual([]);
+ press(body, 'Continue');
+ await settle();
+ expect(verbs(app)).toEqual([['POST', '/api/local-model/resume', {}]]);
+ expect(body.dataset.screen).toBe('progress');
+ });
+
+ it('lets the person pick another model, and come back', async () => {
+ const page = mount();
+ const { app } = await opened(page, PAUSED, () => ok(INSTALLING.install));
+ press(page.body, 'Choose another model');
+ expect(page.body.dataset.screen).toBe('choose');
+ expect(buttons(page.body)).toEqual(['Back', 'Install on this Mac']);
+ press(page.body, 'Back');
+ expect(page.body.dataset.screen).toBe('paused');
+ press(page.body, 'Choose another model');
+ page.body.querySelectorAll('[role="radio"]')[4].click();
+ press(page.body, 'Install on this Mac');
+ press(page.body, 'Download and install');
+ await settle();
+ // A new install replaces the paused job.
+ expect(verbs(app)[0].slice(0, 2)).toEqual(['POST', '/api/local-model/install']);
+ expect((verbs(app)[0][2] as { modelId: string }).modelId).toBe('qwen3.5:9b');
+ });
+});
+
+// ---------- Failures ----------
+
+describe('a failure', () => {
+ it("says what happened in the server's sentence, and offers the one action that helps", async () => {
+ const page = mount();
+ const { app } = await opened(
+ page,
+ failed('load-failed', { model: 'Qwen3.6 35B-A3B' }, { detail: 'runner exited with status 2' }),
+ () => ok(INSTALLING.install)
+ );
+ const { body } = page;
+ expect(body.dataset.screen).toBe('failed');
+ expect(texts(body, '.lm-title')).toEqual(['Qwen3.6 35B-A3B did not load on this Mac.']);
+ // The raw detail is folded away, and is text.
+ const details = body.querySelector('details.lm-details')!;
+ expect((details as HTMLDetailsElement).open).toBe(false);
+ expect(details.querySelector('summary')!.textContent).toBe('Details');
+ expect(details.querySelector('pre')!.textContent).toBe('runner exited with status 2');
+ expect(texts(body, '.lm-step-stopped')).toEqual(['Loading and checking']);
+ expect(buttons(body)).toEqual(['Remove', 'Try again']);
+ press(body, 'Try again');
+ await settle();
+ expect(verbs(app)).toEqual([['POST', '/api/local-model/resume', {}]]);
+ });
+
+ it('never shows a bare word in place of the sentence', async () => {
+ for (const code of Object.keys(LOCAL_MODEL_ERROR_LABELS) as LocalModelErrorCode[]) {
+ 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);
+ page.dom.window.close();
+ }
+ });
+
+ it('answers a port that is taken with another port', async () => {
+ const page = mount();
+ const { app } = await opened(page, failed('port-in-use', { port: '11435' }), () => ok(INSTALLING.install));
+ expect(texts(page.body, '.lm-title')).toEqual(['Port 11435 is in use by another program.']);
+ expect(buttons(page.body)).toEqual(['Remove', 'Use another port']);
+ press(page.body, 'Use another port');
+ await settle();
+ expect(verbs(app)).toEqual([['POST', '/api/local-model/resume', { newPort: true }]]);
+ });
+
+ it('offers the next model by name, and opens the chooser on it', async () => {
+ const page = mount();
+ const { app } = await opened(
+ page,
+ failed(
+ 'no-tool-call',
+ { model: 'Qwen3.6 35B-A3B' },
+ { nextModel: { modelId: 'qwen3.8:27b', modelLabel: 'Qwen3.8 27B' } }
+ )
+ );
+ expect(texts(page.body, '.lm-title')).toEqual([
+ 'Qwen3.6 35B-A3B loaded but did not call a tool, so a coding agent cannot work with it.',
+ ]);
+ expect(buttons(page.body)).toEqual(['Remove', 'Try Qwen3.8 27B']);
+ press(page.body, 'Try Qwen3.8 27B');
+ expect(page.body.dataset.screen).toBe('choose');
+ expect(page.body.querySelector('[aria-checked="true"] .lm-model-name')!.textContent).toBe('Qwen3.8 27B');
+ // The person still confirms its sizes: nothing was sent.
+ expect(verbs(app)).toEqual([]);
+ });
+
+ it('offers the chooser when the server names no next model, and nothing when none is left', async () => {
+ const page = mount();
+ const { app, serve } = await opened(page, failed('does-not-fit', { model: 'Qwen3.6 35B-A3B' }));
+ expect(buttons(page.body)).toEqual(['Remove', 'Choose another model']);
+ // A model the list no longer holds is not offered by name.
+ serve(
+ failed('does-not-fit', { model: 'Qwen3.6 35B-A3B' }, { nextModel: { modelId: 'gone:1b', modelLabel: 'Gone 1B' } })
+ );
+ await app.loadLocalModel({ force: true });
+ expect(buttons(page.body)).toEqual(['Remove', 'Choose another model']);
+ const empty = failed('does-not-fit', { model: 'Qwen3.6 35B-A3B' });
+ empty.recommendation = none('memory', { memory: '48' }).recommendation;
+ serve(empty);
+ await app.loadLocalModel({ force: true });
+ expect(buttons(page.body)).toEqual(['Remove']);
+ });
+
+ it('shows a button only for an action the server accepts right now', async () => {
+ const page = mount();
+ const answer = failed('load-failed', { model: 'Qwen3.6 35B-A3B' });
+ answer.install.actions = [];
+ await opened(page, answer);
+ expect(texts(page.body, '.lm-title')).toEqual(['Qwen3.6 35B-A3B did not load on this Mac.']);
+ expect(buttons(page.body)).toEqual([]);
+ });
+});
+
+// ---------- Installed ----------
+
+describe('an installed model', () => {
+ it('says it is ready, with the measured facts in one line', async () => {
+ const page = mount();
+ await opened(page, INSTALLED);
+ const { body } = page;
+ expect(body.dataset.screen).toBe('installed');
+ 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);
+ 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.',
+ ]);
+ 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');
+ });
+
+ it('says the one honest sentence about git and Python only on a Mac without developer tools', async () => {
+ const page = mount();
+ const { app, serve } = await opened(page, INSTALLED);
+ expect(page.body.textContent).not.toContain('developer tools');
+ serve(status({}, { ...INSTALLED.install, developerTools: 'missing' }));
+ await app.loadLocalModel({ force: true });
+ expect(texts(page.body, '.lm-aside')).toContain(
+ '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.'
+ );
+ });
+
+ it('stops and starts the runtime, and Start is the main action of a stopped model', async () => {
+ const page = mount();
+ const answers = [ok(STOPPED.install), ok(INSTALLED.install)];
+ const { app } = await opened(page, INSTALLED, () => answers.shift() ?? null);
+ press(page.body, 'Stop');
+ await settle();
+ expect(texts(page.body, '.lm-title')).toEqual(['Qwen3.6 35B-A3B is installed on this Mac.']);
+ 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');
+ press(page.body, 'Start');
+ await settle();
+ expect(verbs(app)).toEqual([
+ ['POST', '/api/local-model/runtime', { action: 'stop' }],
+ ['POST', '/api/local-model/runtime', { action: 'start' }],
+ ]);
+ expect(buttons(page.body)).toEqual(['Remove', 'Stop', 'Start a session']);
+ });
+
+ it.each([
+ [
+ 'runtime-stopped' as const,
+ {},
+ ['start', 'remove'] as const,
+ 'The runtime keeps stopping, so Codeman no longer restarts it.',
+ ['Remove', 'Start'],
+ { action: 'start' },
+ ],
+ [
+ 'port-in-use' as const,
+ { port: '11435' },
+ ['start', 'remove'] as const,
+ 'Port 11435 is in use by another program.',
+ ['Remove', 'Use another port'],
+ { action: 'start', newPort: true },
+ ],
+ ])('shows what blocks a session and its one action: %s', async (code, vars, actions, sentence, labels, body) => {
+ const page = mount();
+ const answer = status(
+ {},
+ {
+ ...STOPPED.install,
+ actions: [...actions],
+ error: {
+ code,
+ message: { text: LOCAL_MODEL_ERROR_LABELS[code], ...(Object.keys(vars).length ? { vars } : {}) },
+ next: LOCAL_MODEL_ERROR_NEXT[code],
+ },
+ }
+ );
+ 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);
+ press(page.body, labels[1]);
+ await settle();
+ expect(verbs(app)).toEqual([['POST', '/api/local-model/runtime', body]]);
+ });
+
+ it('offers only the removal when the model changed since it was installed', async () => {
+ const page = mount();
+ const answer = status(
+ {},
+ {
+ ...INSTALLED.install,
+ actions: ['stop', 'remove'],
+ error: {
+ code: 'model-changed',
+ message: { text: LOCAL_MODEL_ERROR_LABELS['model-changed'] },
+ next: LOCAL_MODEL_ERROR_NEXT['model-changed'],
+ },
+ }
+ );
+ 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.
+ expect(buttons(page.body)).toEqual(['Stop', 'Remove']);
+ press(page.body, 'Remove');
+ expect(texts(page.body, '.lm-remove .lm-lead')).toEqual(['Remove the local model from this Mac?']);
+ });
+
+ it('starts a session on the saved connection with the agent of the install, after refreshing the page', async () => {
+ const page = mount();
+ const { app } = await opened(page, INSTALLED);
+ const order: string[] = [];
+ app.loadAppSettingsFromServer = vi.fn(async () => void order.push('settings'));
+ app.loadCliListForSettings = vi.fn(async () => void order.push('clis'));
+ app.selectCustomModelEntry = vi.fn(async () => void order.push('launch'));
+ app.closeAppSettings = vi.fn();
+ press(page.body, 'Start a session');
+ await settle();
+ // The settings flag and the agent's availability are read again before the launcher runs.
+ expect(order).toEqual(['settings', 'clis', 'launch']);
+ // Both values are the payload's.
+ expect(app.selectCustomModelEntry).toHaveBeenCalledWith('opencode', 'local-ollama');
+ expect(app.isLocalModelWindowOpen()).toBe(false);
+ expect(page.modal.classList.contains('active')).toBe(false);
+ // App Settings was not open: it is left alone.
+ expect(app.closeAppSettings).not.toHaveBeenCalled();
+ });
+
+ it('closes App Settings too when the window was opened from there', async () => {
+ const page = mount();
+ const { app } = await opened(page, INSTALLED);
+ page.document.getElementById('appSettingsModal')!.classList.add('active');
+ app.selectCustomModelEntry = vi.fn(async () => undefined);
+ app.closeAppSettings = vi.fn();
+ press(page.body, 'Start a session');
+ await settle();
+ expect(app.closeAppSettings).toHaveBeenCalledTimes(1);
+ expect(app.selectCustomModelEntry).toHaveBeenCalledWith('opencode', 'local-ollama');
+ });
+});
+
+describe('Remove', () => {
+ it('asks first, and deletes the downloaded model too unless the person keeps it', async () => {
+ const page = mount();
+ const { app } = await opened(page, INSTALLED, () => ok(install()));
+ 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')!;
+ 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.']);
+ expect(question.querySelector('input[type="checkbox"]')!.checked).toBe(true);
+ expect(buttons(page.body)).toEqual(['Cancel', 'Remove']);
+ press(page.body, 'Remove');
+ await settle();
+ expect(verbs(app)).toEqual([['DELETE', '/api/local-model', { removeModel: true }]]);
+ });
+
+ it('keeps the model when the box is cleared, and Cancel sends nothing', async () => {
+ const page = mount();
+ const { app } = await opened(page, INSTALLED, () => ok(install()));
+ press(page.body, 'Remove');
+ press(page.body, 'Cancel');
+ expect(page.body.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"]')!;
+ box.checked = false;
+ box.dispatchEvent(new page.dom.window.Event('change'));
+ press(page.body, 'Remove');
+ await settle();
+ expect(verbs(app)).toEqual([['DELETE', '/api/local-model', { removeModel: false }]]);
+ });
+
+ it('goes back to the chooser once the model is gone', async () => {
+ const page = mount();
+ const { serve } = await opened(page, INSTALLED, () => ok(install()));
+ serve(IDLE);
+ press(page.body, 'Remove');
+ press(page.body, 'Remove');
+ await settle();
+ await settle();
+ expect(page.body.dataset.screen).toBe('choose');
+ expect(page.button.textContent!.trim()).toBe('Run a local model');
+ });
+
+ it("names the sessions that still use the model, in the server's sentence", async () => {
+ const page = mount();
+ await opened(page, INSTALLED, () =>
+ conflict({
+ reason: 'sessions-open',
+ message: { text: LOCAL_MODEL_INSTALL_LABELS.conflictSessionsOpen, vars: { sessions: 'w1-app, w2-site' } },
+ sessions: ['w1-app', 'w2-site'],
+ })
+ );
+ press(page.body, 'Remove');
+ press(page.body, 'Remove');
+ await settle();
+ expect(page.body.dataset.screen).toBe('installed');
+ expect(texts(page.body, '.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.
+ expect(buttons(page.body)).toEqual(['Cancel', 'Remove']);
+ });
+
+ it('removes a paused or stopped install the same way', async () => {
+ for (const answer of [PAUSED, failed('load-failed', { model: 'Qwen3.6 35B-A3B' })]) {
+ 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?']);
+ press(page.body, 'Remove');
+ await settle();
+ expect(verbs(app)).toEqual([['DELETE', '/api/local-model', { removeModel: true }]]);
+ page.dom.window.close();
+ }
+ });
+});
+
+// ---------- No recommendation ----------
+
+describe('no recommendation', () => {
+ it.each([
+ [
+ '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.',
+ ],
+ [
+ '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.',
+ ],
+ [
+ 'disk',
+ none('disk', { download: '6.6', reserve: '10.7', free: '12.0', 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.',
+ ],
+ ] as Array<[string, LocalModelStatus, string, string]>)(
+ 'opens with the hardware line, the reason and no button except Close: %s',
+ async (_reason, answer, hardware, sentence) => {
+ const page = mount();
+ const { app } = await opened(page, answer);
+ expect(page.body.dataset.screen).toBe('none');
+ expect(texts(page.body, '.lm-hw')).toEqual([hardware]);
+ expect(texts(page.body, '.lm-reason')).toEqual([sentence]);
+ expect(page.body.querySelector('[role="radiogroup"]')).toBeNull();
+ expect(page.body.querySelector('.lm-bar')).toBeNull();
+ expect(buttons(page.body)).toEqual(['Close']);
+ press(page.body, 'Close');
+ expect(app.isLocalModelWindowOpen()).toBe(false);
+ }
+ );
+
+ it('leaves an unknown memory size out of the hardware line', async () => {
+ const page = mount();
+ const answer = none('memory', undefined, { memBytes: 0, memGb: '0.0', memClassGb: 0 });
+ answer.recommendation.headline = { text: LOCAL_MODEL_LABELS.noneMemoryUnknown };
+ await opened(page, answer);
+ expect(texts(page.body, '.lm-hw')).toEqual(['Apple M5 Pro']);
+ expect(page.body.textContent).not.toMatch(/\b0(\.0)? GB/);
+ });
+});
+
+// ---------- The poll ----------
+
+describe('the poll', () => {
+ it('asks once a second while the window is open, and stops when it closes', async () => {
+ const page = mount();
+ const app = page.newApp(() => ok(INSTALLING));
+ await app.loadLocalModel();
+ expect(gets(app)).toBe(1);
+ // Closed: no timer at all, however long the welcome screen stays up.
+ expect(page.timers).toHaveLength(0);
+
+ app.openLocalModelWindow();
+ await settle();
+ // Opening asks at once (the minute-old answer is not good enough for a download).
+ expect(gets(app)).toBe(2);
+ expect(page.timers.map((timer) => timer.ms)).toEqual([1000]);
+ for (let second = 1; second <= 3; second++) {
+ await page.tick();
+ expect(gets(app)).toBe(2 + second);
+ expect(page.timers.map((timer) => timer.ms)).toEqual([1000]);
+ }
+
+ app.closeLocalModelWindow();
+ expect(page.timers).toHaveLength(0);
+ await page.tick();
+ await page.tick();
+ expect(gets(app)).toBe(5);
+
+ // Reopened: it asks again.
+ app.openLocalModelWindow();
+ await settle();
+ await page.tick();
+ expect(gets(app)).toBe(7);
+ app.closeLocalModelWindow();
+ });
+
+ it('does not re-arm a tick that was already running when the window closed', async () => {
+ const page = mount();
+ let answer: ((response: ApiResponse) => void) | null = null;
+ const app = page.newApp(() => ok(IDLE));
+ await app.loadLocalModel();
+ app.openLocalModelWindow();
+ await settle();
+ app._api.mockImplementation(() => new Promise((done) => (answer = done)));
+ const due = page.timers.splice(0);
+ for (const timer of due) timer.fn();
+ app.closeLocalModelWindow();
+ answer!(ok(IDLE));
+ await settle();
+ expect(page.timers).toHaveLength(0);
+ });
+
+ it('keeps its place and asks nothing while the tab is in the background', async () => {
+ const page = mount({ visible: false });
+ expect(page.document.hidden).toBe(true);
+ const app = page.newApp(() => ok(INSTALLING));
+ await app.loadLocalModel();
+ app.openLocalModelWindow();
+ await settle();
+ const before = gets(app);
+ await page.tick();
+ await page.tick();
+ expect(gets(app)).toBe(before);
+ expect(page.timers).toHaveLength(1);
+ app.closeLocalModelWindow();
+ });
+
+ it('follows the install from the download to the end without a click', async () => {
+ const page = mount();
+ const { 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 focus goes to the main action of the new screen, inside the dialog.
+ expect(page.document.activeElement?.textContent).toBe('Start a session');
+ });
+});
+
+// ---------- Text only ----------
+
+describe('what the server sends is shown as text', () => {
+ const HOSTILE = '';
+ const clean = (page: Mounted) => {
+ expect(page.document.querySelectorAll('img, script, iframe')).toHaveLength(0);
+ expect((page.win as unknown as { __pwned?: number }).__pwned).toBeUndefined();
+ };
+
+ it('in the chooser and on the confirm screen', async () => {
+ const page = mount();
+ const hostile = candidate({
+ modelId: 'evil:1b',
+ modelLabel: `Model ${HOSTILE}`,
+ harnesses: [{ cli: 'x', cliLabel: `Agent ${HOSTILE}`, installed: false, installCommand: `run ${HOSTILE}` }],
+ });
+ await opened(
+ page,
+ status(
+ {
+ pick: hostile,
+ alternatives: [],
+ notes: [{ text: LOCAL_MODEL_LABELS.noteDiskDropped, vars: { model: HOSTILE, missing: '1' } }],
+ },
+ { folder: `/tmp/${HOSTILE}` },
+ { chip: `Chip ${HOSTILE}` }
+ )
+ );
+ clean(page);
+ expect(texts(page.body, '.lm-hw')[0]).toContain(HOSTILE);
+ expect(texts(page.body, '.lm-model-name')).toEqual([`Model ${HOSTILE}`]);
+ expect(page.body.querySelector('option')!.textContent).toBe(`Agent ${HOSTILE}`);
+ 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.`);
+ // The row and the button on the welcome screen too.
+ expect(page.row.textContent).toContain(HOSTILE);
+ clean(page);
+ });
+
+ it('in a job, an error and its detail, a refusal and the installed screen', async () => {
+ const page = mount();
+ const job = { ...JOB, model: { id: 'evil:1b', label: `Model ${HOSTILE}` }, cliLabel: `Agent ${HOSTILE}` };
+ const { app, serve } = await opened(
+ page,
+ status({}, { ...job, state: 'installing', step: 'agent', actions: ['cancel'] }),
+ () =>
+ conflict({
+ reason: 'sessions-open',
+ message: { text: LOCAL_MODEL_INSTALL_LABELS.conflictSessionsOpen, vars: { sessions: HOSTILE } },
+ sessions: [HOSTILE],
+ })
+ );
+ expect(texts(page.body, '.lm-title')).toEqual([`Installing Model ${HOSTILE}`]);
+ expect(texts(page.body, '.lm-step-now')).toEqual([`Installing Agent ${HOSTILE}`]);
+ clean(page);
+
+ serve(
+ status(
+ {},
+ {
+ ...job,
+ state: 'failed',
+ step: 'agent',
+ actions: ['resume', 'remove'],
+ error: {
+ code: 'harness-failed',
+ message: { text: LOCAL_MODEL_ERROR_LABELS['harness-failed'], vars: { cli: HOSTILE } },
+ next: 'retry',
+ detail: `installer said ${HOSTILE}`,
+ },
+ }
+ )
+ );
+ await app.loadLocalModel({ force: true });
+ expect(texts(page.body, '.lm-title')).toEqual([`${HOSTILE} could not be installed.`]);
+ expect(page.body.querySelector('.lm-detail-text')!.textContent).toBe(`installer said ${HOSTILE}`);
+ clean(page);
+
+ serve(status({}, { ...INSTALLED.install, ...job }));
+ await app.loadLocalModel({ force: true });
+ expect(texts(page.body, '.lm-title')).toEqual([`Model ${HOSTILE} is ready on this Mac.`]);
+ press(page.body, 'Remove');
+ press(page.body, 'Remove');
+ await settle();
+ expect(texts(page.body, '.lm-notice-text')[0]).toContain(HOSTILE);
+ expect(page.row.textContent).toContain(HOSTILE);
+ clean(page);
+ });
+
+ it('because the module writes nothing as markup', () => {
const code = MODULE.split('\n')
.filter((line) => !/^\s*(\/\/|\*|\/\*)/.test(line))
.join('\n');
- expect(code).not.toMatch(/Recommended/);
- });
-
- it('shows the tested headline only because the server sent it', async () => {
- const { newApp, card } = mount();
- const tested = status({ headline: { text: LOCAL_MODEL_LABELS.headlineTested }, pick: candidate({ tested: true }) });
- await newApp(ok(tested)).loadLocalModel();
- expect(texts(card, '.local-model-headline')).toEqual(['Recommended for this Mac']);
- });
-
- it('lists the other models that fit with Good, Tight, Slow or Speed unknown', async () => {
- const { newApp, card } = mount();
- await newApp(ok(OK_STATUS)).loadLocalModel();
- const alts = card.querySelector('.local-model-alts')!;
- // No count beside the title: the server lists four at most, so a number here would be
- // the number of rows, not of models that fit.
- expect(texts(alts, '.local-model-alts-title')).toEqual(['Other models that fit']);
- expect(alts.querySelector('.local-model-count')).toBeNull();
- const rows = [...alts.querySelectorAll('.local-model-alt')];
- expect(rows.map((row) => texts(row, '.local-model-name')[0])).toEqual([
- 'Qwen3.6 35B-A3B (qwen3.6:35b-a3b) with OpenCode',
- 'Qwen3.8 27B Q8 (qwen3.8:27b-q8_0) with OpenCode',
- ]);
- // A candidate whose speed is fine carries a fit label only.
- expect(rows.map((row) => texts(row, '.local-model-pill'))).toEqual([['Good'], ['Tight', 'Slow']]);
- // An alternative with an estimate prints it: "Slow" alone does not say how slow.
- expect(rows.map((row) => texts(row, '.local-model-line')[0])).toEqual([
- 'Context 131,072 tokens · 26.4 GB of memory · 22.6 GB download · about 42 tokens per second',
- 'Context 65,536 tokens · 36.4 GB of memory · 30.0 GB download · about 5.2 tokens per second',
- ]);
- });
-
- it('says what each label measures, as its tooltip and its accessible name', async () => {
- const { newApp, card, row } = mount();
- await newApp(ok(OK_STATUS)).loadLocalModel();
- for (const root of [card, row]) {
- const pills = [...root.querySelectorAll('.local-model-pill')] as HTMLElement[];
- expect(pills.map((pill) => [pill.textContent, pill.title])).toEqual([
- // The pick: 0.79 of the budget, a chip in no chips row.
- ['Good', 'Memory fit: needs 79% of what this Mac can give a model'],
- ['Speed unknown', 'No speed estimate for this model on this chip'],
- ['Good', 'Memory fit: needs 70% of what this Mac can give a model'],
- ['Tight', 'Memory fit: needs 97% of what this Mac can give a model'],
- ['Slow', 'About 5.2 tokens per second (estimated).'],
- ]);
- for (const pill of pills) {
- expect(pill.getAttribute('role')).toBe('note');
- expect(pill.getAttribute('aria-label')).toBe(`${pill.textContent}. ${pill.title}`);
- }
- }
- });
-
- it('leaves a fit label without a share bare instead of inventing a number', async () => {
- const { newApp, card } = mount();
- const bare = candidate({ budgetShare: undefined as unknown as number, speedLabel: undefined, speed: 'ok' });
- await newApp(ok(status({ pick: bare }))).loadLocalModel();
- const pill = card.querySelector('.local-model-pill') as HTMLElement;
- expect(pill.textContent).toBe('Good');
- expect(pill.title).toBe('');
- expect(pill.hasAttribute('aria-label')).toBe(false);
- });
-
- it('puts the line about the missing setup right under the pick and its notes, above the list', async () => {
- const { newApp, card, row } = mount();
- const withNotes = status({
- notes: [
- { text: LOCAL_MODEL_LABELS.noteSmallPick },
- { text: LOCAL_MODEL_LABELS.noteDiskDropped, vars: { model: 'Qwen3.6 35B-A3B', missing: '0.4' } },
- { text: LOCAL_MODEL_LABELS.noteUntested },
- ],
- pick: candidate(),
- alternatives: [ALT_FAST, ALT_SLOW],
- });
- await newApp(ok(withNotes)).loadLocalModel();
- for (const root of [card, row]) {
- const body = root.querySelector('.local-model-body')!;
- expect([...body.children].map((el) => el.className)).toEqual([
- 'local-model-hw',
- 'local-model-headline',
- 'local-model-pick',
- 'local-model-note',
- 'local-model-note',
- 'local-model-note',
- 'local-model-next',
- 'local-model-alts',
- ]);
- // A note with slots is filled like a headline.
- expect(texts(root, '.local-model-note')).toEqual([
- 'The best fit is a small model. It handles small edits and questions. It does not replace a cloud model.',
- '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.',
- ]);
- expect(texts(root, '.local-model-next')).toEqual([NEXT_STEP]);
- }
- });
-
- it('prints every size exactly as sent, in one unit', async () => {
- const { newApp, card, row } = mount();
- await newApp(ok(OK_STATUS)).loadLocalModel();
- for (const root of [card, row]) {
- const text = root.textContent ?? '';
- expect(text).not.toMatch(/GiB|MiB|\bMB\b|bytes/);
- // Every number in front of the unit is one the payload carries: as text for the computed
- // sizes, and `memClassGb` (48) for the installed memory.
- const printed = [...text.matchAll(/([\d.,]+) GB/g)].map((m) => m[1]);
- const sent = ['48', '912', '37.6', '29.6', '17.7', '26.4', '22.6', '36.4', '30.0'];
- expect(printed.length).toBeGreaterThanOrEqual(sent.length);
- expect(printed.filter((value) => !sent.includes(value))).toEqual([]);
- // The decimal figure of the installed memory (`memGb`) is on neither surface.
- expect(text).not.toContain('51.5');
- }
- });
-
- it('calls a budget that is the wired limit of this Mac a fact, not an estimate', async () => {
- const { newApp, card } = mount();
- const wired = status({ pick: candidate(), budgetSource: 'wired-limit', budgetGb: '40.0' });
- await newApp(ok(wired)).loadLocalModel();
- expect(texts(card, '.local-model-pick .local-model-line')[1]).toBe(
- 'Needs about 29.6 GB of the 40.0 GB this Mac can give a model.'
- );
- });
-
- it('adds the speed, a missing harness and a slow first answer only when the payload says so', async () => {
- const { newApp, card } = mount();
- const pick = candidate({
- speed: 'ok',
- speedLabel: undefined,
- tokensPerSecond: 12.3,
- cliInstalled: false,
- slowFirstTurn: true,
- cliLabel: 'Some Harness',
- });
- await newApp(ok(status({ pick }))).loadLocalModel();
- expect(texts(card, '.local-model-pick .local-model-pill')).toEqual(['Good']);
- expect(texts(card, '.local-model-pick .local-model-line').slice(2)).toEqual([
- 'About 12 tokens per second (estimated).',
- 'Some Harness is not installed on this Mac yet.',
- 'With Some Harness, the first answer can take minutes.',
- ]);
- });
-
- it('describes the hardware with whatever parts the probe got', async () => {
- const { newApp, card } = mount();
- const cases: Array<[Partial, string]> = [
- [{ freeDiskBytes: undefined, freeDiskGb: undefined }, 'Apple M5 Pro, 48 GB memory'],
- [{ chip: undefined }, 'Mac17,13, 48 GB memory, 912 GB free'],
- [{ chip: undefined, model: undefined }, '48 GB memory, 912 GB free'],
- [{ chip: undefined, model: undefined, freeDiskBytes: undefined, freeDiskGb: undefined }, '48 GB memory'],
- // The sizes Macs are sold in, as sold.
- [{ memGb: '17.2', memClassGb: 16 }, 'Apple M5 Pro, 16 GB memory, 912 GB free'],
- [{ memGb: '34.4', memClassGb: 32 }, 'Apple M5 Pro, 32 GB memory, 912 GB free'],
- [{ memGb: '137', memClassGb: 128 }, 'Apple M5 Pro, 128 GB memory, 912 GB free'],
- // An unknown size is left out, whatever else the payload carries for it.
- [{ memGb: '0.0', memClassGb: 0 }, 'Apple M5 Pro'],
- [{ memClassGb: undefined as unknown as number }, 'Apple M5 Pro'],
- [{ memClassGb: '48' as unknown as number }, 'Apple M5 Pro'],
- ];
- for (const [hardware, line] of cases) {
- await newApp(ok(status({ pick: candidate() }, hardware))).loadLocalModel();
- expect(texts(card, '.local-model-hw')).toEqual([line]);
- }
+ expect(code).not.toMatch(/innerHTML|outerHTML|insertAdjacentHTML|document\.write/);
});
});
-describe('verdict limited', () => {
- it('says what a small model is good for, shows the pick, and never recommends', async () => {
- const { newApp, card, row } = mount();
- await newApp(ok(LIMITED_STATUS)).loadLocalModel();
- expect(card.hidden).toBe(false);
- for (const root of [card, row]) {
- expect(texts(root, '.local-model-headline')).toEqual([
- 'This Mac can run a small model. It handles small edits and questions. It does not replace a cloud model.',
- ]);
- expect(texts(root, '.local-model-hw')).toEqual(['Apple M4, 18 GB memory, 912 GB free']);
- expect(texts(root, '.local-model-name')).toEqual(['Qwen3.5 9B (qwen3.5:9b) with OpenCode']);
- expect(texts(root, '.local-model-pill')).toEqual(['Tight']);
- expect(texts(root, '.local-model-pick .local-model-line')).toEqual([
- 'Context 65,536 tokens · 6.6 GB download · Apache-2.0',
- 'Needs about 10.9 GB of the 12.5 GB this Mac can give a model (estimated).',
- 'About 11 tokens per second (estimated).',
- ]);
- expect(texts(root, '.local-model-note')).toEqual(['Not yet tested by us on a Mac like this.']);
- expect(texts(root, '.local-model-next')).toEqual([NEXT_STEP]);
- expect(root.textContent).not.toMatch(/recommend/i);
- expect(root.textContent).not.toContain('Best fit');
- }
- });
-});
+// ---------- Languages, rules ----------
-describe('the Settings row', () => {
- it('shows the same answer as the card, with the alternatives open', async () => {
- const { newApp, card, row } = mount();
- await newApp(ok(OK_STATUS)).loadLocalModel();
- for (const part of ['.local-model-hw', '.local-model-headline', '.local-model-pick', '.local-model-note']) {
- expect(texts(row, part)).toEqual(texts(card, part));
- expect(texts(row, part).length).toBeGreaterThan(0);
- }
- expect(texts(row, '.local-model-alt')).toEqual(texts(card, '.local-model-alt'));
- expect(texts(row, '.local-model-next')).toEqual([NEXT_STEP]);
- // The card folds the list behind a native disclosure; Settings scrolls, so it lists them.
- expect(card.querySelector('details.local-model-alts')).not.toBeNull();
- expect((card.querySelector('details') as HTMLDetailsElement).open).toBe(false);
- expect(row.querySelector('details')).toBeNull();
- // The row has no chrome of the card: no title, no Hide.
- expect(row.querySelector('button')).toBeNull();
- });
-
- it('keeps the alternatives unfolded across a repaint of the card', async () => {
- const { newApp, card } = mount();
- const app = newApp(ok(OK_STATUS));
- await app.loadLocalModel();
- (card.querySelector('details') as HTMLDetailsElement).open = true;
- app.renderLocalModel();
- expect((card.querySelector('details') as HTMLDetailsElement).open).toBe(true);
- });
-});
-
-describe('payload text is text', () => {
- const EVIL = '';
-
- it('renders markup in a name, a license or a sentence slot as characters, and runs nothing', async () => {
- const { dom, newApp, card, row } = mount();
- const evil = candidate({ modelLabel: EVIL, modelId: `m:${EVIL}`, cliLabel: EVIL, license: EVIL, needGb: EVIL });
- const answer = status(
- {
- // A slot value is server text too.
- headline: { text: LOCAL_MODEL_LABELS.noneMacos, vars: { version: EVIL, minimum: EVIL } },
- notes: [{ text: LOCAL_MODEL_LABELS.noneMemory, vars: { memory: EVIL } }],
- pick: evil,
- alternatives: [evil],
- budgetGb: EVIL,
- },
- { chip: EVIL, memGb: EVIL, freeDiskGb: EVIL }
- );
- await newApp(ok(answer)).loadLocalModel();
- expect(card.hidden).toBe(false);
- for (const root of [card, row]) {
- expect(root.querySelector('img')).toBeNull();
- expect(root.querySelector('script, iframe, a, [onerror]')).toBeNull();
- // The memory is a number or nothing: text in its place (`memGb` here) is never printed.
- expect(texts(root, '.local-model-hw')[0]).toBe(`${EVIL}, 48 GB memory, ${EVIL} GB free`);
- expect(texts(root, '.local-model-headline')[0]).toBe(
- `This Mac runs macOS ${EVIL}. Local models need macOS ${EVIL} or newer.`
- );
- expect(texts(root, '.local-model-name')[0]).toBe(`${EVIL} (m:${EVIL}) with ${EVIL}`);
- expect(texts(root, '.local-model-pick .local-model-line')[0]).toContain(` · ${EVIL}`);
- expect(texts(root, '.local-model-note')[0]).toContain(`This Mac has ${EVIL} GB of memory.`);
- }
- expect((dom.window as unknown as { __pwned?: number }).__pwned).toBeUndefined();
- });
-
- it('never assigns markup: the module has no innerHTML, outerHTML or insertAdjacentHTML', () => {
- expect(MODULE).not.toMatch(/innerHTML|outerHTML|insertAdjacentHTML|document\.write/);
- });
-
- it('shows a label the contract does not know as it came, still as text', async () => {
- const { newApp, card } = mount();
- const odd = candidate({ fitLabel: EVIL as Candidate['fitLabel'], speedLabel: undefined });
- await newApp(ok(status({ pick: odd }))).loadLocalModel();
- expect(texts(card, '.local-model-pill')).toEqual([EVIL]);
- expect(card.querySelector('img')).toBeNull();
- });
-});
-
-describe('hiding the card', () => {
- it('hides it on this device for good, and leaves the Settings row as the way back', async () => {
- const { dom, newApp, card, row } = mount();
- const app = newApp(ok(OK_STATUS));
- await app.loadLocalModel();
- const hide = card.querySelector('button.local-model-hide') as HTMLButtonElement;
- expect(hide.textContent).toBe('Hide');
- expect(hide.type).toBe('button');
- expect(hide.title).toBe('Hide this card on this device. It stays in App Settings, under Models.');
- hide.click();
- expect(card.hidden).toBe(true);
- expect(card.textContent).toBe('');
- expect(dom.window.localStorage.getItem(DISMISS_KEY)).toBe('1');
- expect(texts(row, '.local-model-name')[0]).toBe('Qwen3.8 27B (qwen3.8:27b) with OpenCode');
-
- // A reload is a new app instance over the same storage.
- const reloaded = newApp(ok(OK_STATUS));
- expect(reloaded.isLocalModelCardDismissed()).toBe(true);
- await reloaded.loadLocalModel();
- expect(card.hidden).toBe(true);
- expect(texts(row, '.local-model-headline')).toEqual(['Best fit for this Mac']);
- });
-
- it('shows the card on a device that never hid it', async () => {
- const { dom, newApp, card } = mount();
- expect(dom.window.localStorage.getItem(DISMISS_KEY)).toBeNull();
- await newApp(ok(OK_STATUS)).loadLocalModel();
- expect(card.hidden).toBe(false);
- });
-
- it('stays hidden for the page load when storage is blocked', async () => {
- const { dom, newApp, card } = mount();
- const storage = dom.window.Storage.prototype;
- vi.spyOn(storage, 'getItem').mockImplementation(() => {
- throw new Error('blocked');
- });
- vi.spyOn(storage, 'setItem').mockImplementation(() => {
- throw new Error('blocked');
- });
- const app = newApp(ok(OK_STATUS));
- await app.loadLocalModel();
- expect(card.hidden).toBe(false);
- app.dismissLocalModelCard();
- expect(card.hidden).toBe(true);
- app.renderLocalModel();
- expect(card.hidden).toBe(true);
- vi.restoreAllMocks();
- });
-
- it('writes nothing to the server: the only request the module ever makes is the GET', async () => {
- const { newApp, card } = mount();
- const app = newApp(ok(OK_STATUS));
- await app.loadLocalModel();
- (card.querySelector('button.local-model-hide') as HTMLButtonElement).click();
- expect(app._api.mock.calls).toEqual([['/api/local-model']]);
- });
-});
-
-describe('the request', () => {
- it('goes through the base path when Codeman is mounted under one', async () => {
- const before = "const CodemanBase = { base: '/cm', url: (path) => '/cm' + path };";
- const { newApp } = mount({ before });
- const app = newApp(ok(OK_STATUS));
- await app.loadLocalModel();
- expect(app._api.mock.calls).toEqual([['/cm/api/local-model']]);
- });
-
- it('is sent once a minute at most, and shared while in flight', async () => {
- const { dom, newApp, card } = mount();
- let now = 1_000_000;
- (dom.window as unknown as { Date: DateConstructor }).Date.now = () => now;
- const app = newApp(ok(OK_STATUS));
- const first = app.loadLocalModel();
- const second = app.loadLocalModel();
- await Promise.all([first, second]);
- expect(app._api).toHaveBeenCalledTimes(1);
-
- now += 59_000;
- await app.loadLocalModel();
- expect(app._api).toHaveBeenCalledTimes(1);
-
- now += 2_000;
- await app.loadLocalModel();
- expect(app._api).toHaveBeenCalledTimes(2);
- expect(card.hidden).toBe(false);
- });
-
- it('keeps the answer on screen when a later refresh fails', async () => {
- const { dom, newApp, card, row } = mount();
- let now = 1_000_000;
- (dom.window as unknown as { Date: DateConstructor }).Date.now = () => now;
- const answers: ApiResponse[] = [ok(OK_STATUS), null];
- const app = newApp(async () => answers.shift() ?? null);
- await app.loadLocalModel();
- now += 61_000;
- await app.loadLocalModel();
- expect(app._api).toHaveBeenCalledTimes(2);
- expect(card.hidden).toBe(false);
- expect(texts(row, '.local-model-headline')).toEqual(['Best fit for this Mac']);
- expect(row.textContent).not.toContain('Could not reach the server.');
- });
-});
-
-describe('with the real translator', () => {
- it('renders in Chinese, and follows a language or display-name change without a reload', async () => {
- const { newApp, card, row, i18n } = mount({ i18n: true });
- i18n.start();
- i18n.configure({ language: 'zh-CN' });
- const app = newApp(ok(OK_STATUS));
- await app.loadLocalModel();
- expect(texts(card, '.local-model-headline')).toEqual(['最适合此 Mac 的选择']);
- expect(texts(card, '.local-model-pill')).toEqual(['良好', '速度未知', '良好', '偏紧', '较慢']);
- expect((card.querySelector('.local-model-pill') as HTMLElement).title).toBe(
- '内存占用:需要此 Mac 可分配给模型的内存的 79%'
- );
- // Names and numbers ride the slots untouched.
- expect(texts(card, '.local-model-name')[0]).toBe('Qwen3.8 27B(qwen3.8:27b),搭配 OpenCode');
- expect(texts(row, '.local-model-hw')).toEqual(['Apple M5 Pro,内存 48 GB,可用磁盘空间 912 GB']);
-
- // Both containers are data-i18n-skip, so the module itself must repaint.
- i18n.configure({ language: 'en' });
+describe('in another language', () => {
+ it('repaints the button, the row and the open window when the language changes', async () => {
+ const page = mount({ i18n: true });
+ page.win.CodemanI18n.start();
+ await opened(page, IDLE);
+ expect(buttons(page.body)).toEqual(['Install on this Mac']);
+ page.win.CodemanI18n.configure({ language: 'zh-CN' });
await settle();
- expect(texts(card, '.local-model-headline')).toEqual(['Best fit for this Mac']);
- expect(texts(row, '.local-model-next')).toEqual([NEXT_STEP]);
-
- i18n.configure({ displayName: 'Acme' });
+ 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 可用']);
+ // 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 上测试');
+ // The static title is the page translator's.
+ expect(page.document.getElementById('localModelModalTitle')!.textContent).toBe('在此 Mac 上运行模型');
+ page.win.CodemanI18n.configure({ language: 'en' });
await settle();
- expect(texts(card, '.local-model-next')).toEqual([
- 'Acme cannot download or install this for you yet. That is the next step, and it is not built yet.',
+ expect(buttons(page.body)).toEqual(['Install on this Mac']);
+ });
+});
+
+describe('a repaint of everything', () => {
+ it('reaches a progress screen whole when the language changes, not its bar alone', async () => {
+ const page = mount({ i18n: true });
+ page.win.CodemanI18n.start();
+ await opened(page, INSTALLING);
+ expect(texts(page.body, '.lm-title')).toEqual(['Installing Qwen3.6 35B-A3B']);
+ page.win.CodemanI18n.configure({ language: 'zh-CN' });
+ await settle();
+ // Title, figure, steps, note and button: every word of the screen, not the bar's line only.
+ expect(texts(page.body, '.lm-title')).toEqual(['正在安装 Qwen3.6 35B-A3B']);
+ expect(texts(page.body, '.lm-figure')).toEqual(['已下载 6.2 GB,共 23.1 GB']);
+ expect(texts(page.body, '.lm-figure-aside')).toEqual(['每秒 58 MB,剩余约 5 分钟']);
+ expect(texts(page.body, 'ol.lm-steps > li')).toEqual([
+ '获取运行时',
+ '下载模型',
+ '加载并检查',
+ '安装 OpenCode',
+ '正在连接',
+ ]);
+ expect(buttons(page.body)).toEqual(['取消']);
+ expect(page.body.textContent).not.toMatch(/Installing|Cancel|You can close/);
+ page.win.CodemanI18n.configure({ language: 'en' });
+ await settle();
+ expect(buttons(page.body)).toEqual(['Cancel']);
+ expect(texts(page.body, 'ol.lm-steps > li')[0]).toBe('Getting the runtime');
+ });
+
+ it('follows a display name that changed while a download is on screen', async () => {
+ const page = mount({ i18n: true });
+ page.win.CodemanI18n.start();
+ await opened(page, INSTALLING);
+ page.win.CodemanI18n.configure({ language: 'en', displayName: 'Acme' });
+ await settle();
+ expect(texts(page.body, '.lm-aside')).toEqual([
+ 'You can close this window. The download continues while Acme is running.',
]);
});
- it('leaves a slot-shaped piece of a model name alone', async () => {
- const { newApp, card, i18n } = mount({ i18n: true });
- i18n.start();
- const odd = candidate({ modelLabel: 'Model {harness} {name}' });
- await newApp(ok(status({ pick: odd }))).loadLocalModel();
+ it('paints the whole screen again when the window is reopened on the same download', async () => {
+ const page = mount();
+ const { app } = await opened(page, INSTALLING);
+ app.closeLocalModelWindow();
+ // Whatever the closed window still holds is not trusted: here, a title something else wrote.
+ page.body.querySelector('.lm-title')!.textContent = 'stale';
+ app.openLocalModelWindow();
await settle();
- // The DOM translator would empty `{harness}` and brand `{name}`; the skip attribute keeps it out.
- expect(texts(card, '.local-model-name')[0]).toBe('Model {harness} {name} (qwen3.8:27b) with OpenCode');
+ expect(texts(page.body, '.lm-title')).toEqual(['Installing Qwen3.6 35B-A3B']);
+ expect(buttons(page.body)).toEqual(['Cancel']);
+ });
+});
+
+describe('the module keeps to its rules', () => {
+ /** U+2192, U+00B7, U+2013 and U+2014, built from their code points so this file holds none of them. */
+ const ORNAMENT = new RegExp(`[${String.fromCharCode(0x2192, 0x00b7, 0x2013, 0x2014)}]`);
+ const code = MODULE.split('\n')
+ .filter((line) => !/^\s*(\/\/|\*|\/\*)/.test(line))
+ .join('\n');
+
+ it('never says "Recommended" by itself: that headline is the server\'s to send', () => {
+ expect(code).not.toMatch(/recommended/i);
+ });
+
+ it("prints the developer tools sentence in the contract's exact words", () => {
+ expect(code).toContain(`'${LOCAL_MODEL_INSTALL_LABELS.developerToolsMissing}'`);
+ });
+
+ it('uses the words of the reader: no VRAM, no endpoint, no harness, no context window', () => {
+ const strings = [...MODULE.matchAll(/localModelT\(\s*'([^']+)'/g)].map((match) => match[1]);
+ expect(strings.length).toBeGreaterThan(60);
+ for (const text of strings) {
+ expect(text, text).not.toMatch(/VRAM|endpoint|harness|context window/i);
+ // No arrow on a button, no middle dot between facts, no long dash.
+ expect(text, text).not.toMatch(ORNAMENT);
+ }
+ });
+
+ it('asks the routes of the contract, under the base path', async () => {
+ const page = mount();
+ const context = page.dom.getInternalVMContext();
+ vm.runInContext('var CodemanBase = { url: (path) => "/codeman" + path };', context);
+ const { app } = await opened(page, INSTALLING, () => ok(PAUSED.install));
+ press(page.body, 'Cancel');
+ await settle();
+ expect(app._api.mock.calls[0][0]).toBe('/codeman/api/local-model');
+ expect(verbs(app)).toEqual([['POST', '/codeman/api/local-model/cancel', undefined]]);
});
});