Files
Codeman/src/web/public/local-model-ui.js
T
Codeman maintainer eb91279a3b feat(local-model): a memory map and real colour in the chooser, the button first
The owner's verdict on the chooser: nice, but not nice enough, too much just
blue. He also wants "Run a local model" on top of the start screen, above
Run Claude Code.

The memory bar is now a map: the model, its context and the brief peaks side
by side on the track of what this Mac can give a model, each with its size in
a legend, and the verdict on the fit beside the figure. recommend() sends what
the need is made of as `Candidate.memory`.

Colour now carries meaning: green for good and tested, amber for tight, orange
for slow, teal for the context. Violet is the feature's own colour, on its
start screen button and its main action. Every row has a size tile and a speed
gauge scaled to the fastest row in the list. The light skins get deeper values
of the same colours, and the window is 40 px taller to hold it without
scrolling.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-07 03:21:35 +01:00

1736 lines
78 KiB
JavaScript

/**
* @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` (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:
*
* 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 }
*
* ## Three surfaces, one window
*
* - Welcome screen: ONE large button (`#welcomeLocalModelBtn`) above the launchers, on 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):
*
* | 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: the download as a table with its total, where |
* | | it goes |
* | `progress` | installing: the download bar, the steps, Cancel |
* | `paused` | paused (Cancel or a server restart): Continue or Remove |
* | `failed` | failed: the server's sentence and the ONE action that helps |
* | `installed` | installed: running or stopped, Start a session, Stop/Start, Remove |
* | | (right after an install: Start a session alone) |
* | `none` | idle and nothing fits: the hardware line, the reason, Close |
*
* The memory bar is the one strong element: the memory this Mac can give a model, filled to
* what the selected model needs at its PEAK (`budgetShare`). Selecting another row moves the
* fill. The legend under it and the line of an installed model use the same words for that
* peak, so the two figures a person meets (what the model takes now, what it can take) are
* never read as a contradiction.
*
* The window is ONE frame for the whole flow: the header and the foot (`#localModelWindowFoot`,
* the buttons) stay where they are and only the body between them changes and scrolls, so a
* button never moves away from under the pointer and is never cut off.
*
* The rows of the chooser are the server's: its pick first, then the other models by download
* size, largest first, which is a column the reader can see. The server's own order of the
* alternatives is its ranking (what it would offer next), not a reading order.
*
* ## Polling
*
* 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 (`<html lang>` 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 (`downloadGb`, `installGb`, `completedGb`, `totalGb`,
* `memoryGb`: decimal GB as text). Bytes are never turned into a size here. The one figure
* that is rounded is the memory estimate beside the bar and in the installed line: it says
* "about", so it is printed in whole GB from the server's own text (`needGb`, `budgetGb`).
* The installed memory alone is `memClassGb`, the size the Mac is sold as. The byte
* figures this file does read are the consent total (sent back unchanged as
* `acknowledgedBytes`), the two ends of a bar and the download size the rows are ordered by.
* - The consent is bound to the size shown: the install request carries the server's own total
* for the row (`Candidate.installBytes`), and a 409 `size-changed` shows the server's new
* total and asks again.
* - 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
* i18n.js. `{name}` is the translator's own slot (the display name) and is never used here.
* - The word "Recommended" is not in this file. Which headline a Mac gets is the server's call.
*
* @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
*/
/** 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;
/** The tag kinds of the server's pick (`CandidateTagKind`): the ones drawn in the accent colour. */
const LOCAL_MODEL_ACCENT_TAGS = ['tested', 'best', 'best-tight'];
/** i18n.js's translator with the `{placeholder}` slots filled, or the English source filled the same way. */
function localModelT(source, vars) {
const values = vars && typeof vars === 'object' ? vars : {};
if (typeof codemanT === 'function') return codemanT(source, values);
return String(source).replace(/\{([a-zA-Z]\w*)\}/g, (_match, key) => String(values[key] ?? ''));
}
/** The route, under the reverse-proxy base path when there is one. */
function localModelUrl(path) {
return typeof CodemanBase !== 'undefined' && CodemanBase ? CodemanBase.url(path) : path;
}
function localModelEl(tag, className, text) {
const el = document.createElement(tag);
if (className) el.className = className;
if (text !== undefined) el.textContent = text;
return el;
}
/** A sentence the server chose: `{ text, vars }` from the payload. */
function localModelMessage(message) {
if (!message || typeof message.text !== 'string') return '';
return localModelT(message.text, message.vars);
}
/** A token count as people write it (131,072). */
function localModelCount(value) {
return Number.isFinite(value) ? Number(value).toLocaleString('en-US') : String(value ?? '');
}
/** Tokens per second: whole from 10, one decimal below. Empty when the server sent none. */
function localModelSpeed(value) {
if (!Number.isFinite(value) || value <= 0) return '';
return String(value >= 10 ? Math.round(value) : Math.round(value * 10) / 10);
}
/** 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');
}
/**
* Chip, memory and free disk in one line. The memory is the size the Mac is sold as
* (`memClassGb`: 48 for a Mac whose 48 x 2^30 bytes are 51.5 decimal GB), a number or nothing:
* an unknown size (0) is left out, never printed.
*/
function localModelHardwareLine(hw) {
const chip = String(hw.chip || hw.model || '');
const memory = Number.isFinite(hw.memClassGb) && hw.memClassGb > 0 ? String(hw.memClassGb) : '';
const sizes = { chip, memory, free: hw.freeDiskGb };
if (!memory) return chip;
if (chip && hw.freeDiskGb) return localModelT('{chip}, {memory} GB memory, {free} GB free', sizes);
if (chip) return localModelT('{chip}, {memory} GB memory', sizes);
if (hw.freeDiskGb) return localModelT('{memory} GB memory, {free} GB free', sizes);
return localModelT('{memory} GB memory', sizes);
}
/** 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;
const recommendation = data && typeof data === 'object' ? data.recommendation : null;
if (!recommendation || typeof recommendation !== 'object' || typeof recommendation.verdict !== 'string') return null;
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 server's pick first, then the other models that fit by download
* size, largest first. The download is a column of the list, so the order can be read off the
* screen; the server sends the alternatives in its own ranking, which no column shows.
*/
function localModelCandidates(status) {
const recommendation = (status && status.recommendation) || {};
if (recommendation.verdict === 'none' || !recommendation.pick) return [];
const rest = (Array.isArray(recommendation.alternatives) ? recommendation.alternatives : []).filter(
(row) => row && typeof row.modelId === 'string'
);
const bytes = (row) => (Number.isFinite(row.downloadBytes) ? row.downloadBytes : 0);
// A stable sort: rows of one size keep the server's order.
const bySize = rest
.map((row, index) => ({ row, index }))
.sort((a, b) => bytes(b.row) - bytes(a.row) || a.index - b.index);
return [recommendation.pick, ...bySize.map((entry) => entry.row)].filter(
(row) => row && typeof row.modelId === 'string'
);
}
/**
* 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`;
}
/**
* A memory estimate in whole GB, from the server's decimal GB text ('36.0' is '36'). Only for
* a figure that says "about": an estimate printed to the decimal reads as a measurement.
* Text that is no number is returned as it is.
*/
function localModelWholeGb(value) {
const number = Number(value);
return typeof value === 'string' && value !== '' && Number.isFinite(number)
? String(Math.round(number))
: String(value ?? '');
}
/**
* A sentence group split after its first sentence: `[first, rest]`. The first is what a
* headline carries; the rest is read in the body size. Works on the translated text, so both
* the Latin full stop (followed by a space) and the ideographic one end a sentence.
*/
function localModelSentences(text) {
const value = String(text ?? '').trim();
const match = /^(.+?(?:[.!?](?=\s)|[。!?]))\s*(.+)$/su.exec(value);
return match ? [match[1], match[2]] : [value, ''];
}
/** "about 68 tokens/s", the measured figure without "about", or that there is none. */
function localModelSpeedText(candidate) {
const speed = localModelSpeed(candidate.tokensPerSecond);
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;
}
/**
* The memory map: one bar for what this Mac can give a model, split into what the selected
* model takes of it. The track that stays is what stays free. A candidate without its parts
* (`memory`) gets one plain fill.
*/
function localModelMemoryBar(candidate, budgetBytes, last, label) {
const percent = (bytes) => (budgetBytes > 0 ? Math.max(0, (bytes / budgetBytes) * 100) : 0);
const parts = candidate.memory;
const bar = localModelBar({ share: candidate.budgetShare, role: 'meter', kind: 'memory', from: last.memory, label });
if (!parts) return bar;
const fills = [
['model', parts.modelBytes],
['context', parts.contextBytes],
['peak', parts.peakBytes],
];
bar.replaceChildren(
...fills.map(([name, bytes]) => {
const to = Math.min(100, Math.round(percent(bytes) * 10) / 10);
const fill = localModelEl('div', `lm-bar-fill lm-part-${name}`);
fill.dataset.kind = `memory-${name}`;
fill.dataset.to = String(to);
const from = last[fill.dataset.kind];
fill.style.width = `${Number.isFinite(from) ? from : to}%`;
return fill;
})
);
return bar;
}
/** The legend under the memory map: a swatch, what it stands for and its size in whole GB. */
function localModelMemoryParts(candidate, budgetBytes) {
const parts = candidate.memory;
if (!parts) return null;
const gb = (bytes) => localModelGb(String(Math.round(Math.max(0, bytes) / 1e9)));
const free = budgetBytes - parts.modelBytes - parts.contextBytes - parts.peakBytes;
const rows = [
['model', localModelT('The model'), parts.modelBytes],
[
'context',
localModelT('Context, {context} tokens', { context: localModelCount(candidate.context) }),
parts.contextBytes,
],
['peak', localModelT('Brief peaks'), parts.peakBytes],
['free', localModelT('Stays free'), free],
];
const legend = localModelEl('div', 'lm-legend');
for (const [name, text, bytes] of rows) {
const item = localModelEl('span', 'lm-legend-item');
item.appendChild(localModelEl('span', `lm-swatch lm-part-${name}`));
item.appendChild(localModelEl('span', 'lm-legend-name', text));
item.appendChild(localModelEl('span', 'lm-legend-size', gb(bytes)));
legend.appendChild(item);
}
return legend;
}
/** The size tile of a model row: its billions of parameters, coloured by how large it is. */
function localModelTile(candidate) {
const size = Number(candidate.paramsB);
if (!Number.isFinite(size) || size <= 0) return localModelEl('span', 'lm-tile');
const tone = size >= 30 ? 'large' : size >= 15 ? 'mid' : 'small';
const tile = localModelEl('span', `lm-tile lm-tile-${tone}`, `${Math.floor(size)}B`);
tile.setAttribute('aria-hidden', 'true');
return tile;
}
/**
* One row of the confirm screen's table: what is fetched, how large it is, and under the name
* a second line that says what it is (`note`) and, for a coding agent, the literal command of
* its installer (`command`). A row without a size (the coding agent: its installer decides)
* has none.
*/
function localModelSizeRow(label, { gb, note, command, strong = false } = {}) {
const row = localModelEl('div', strong ? 'lm-size-row lm-size-total' : 'lm-size-row');
const what = localModelEl('div', 'lm-size-what');
what.appendChild(localModelEl('span', 'lm-size-name', label));
if (note) what.appendChild(localModelEl('span', 'lm-size-note', note));
if (command) what.appendChild(localModelEl('code', 'lm-command', command));
row.appendChild(what);
if (gb) row.appendChild(localModelEl('span', 'lm-size-gb', localModelGb(gb)));
return row;
}
Object.assign(CodemanApp.prototype, {
// ═══════════════════════════════════════════════════════════════
// Data
// ═══════════════════════════════════════════════════════════════
/**
* 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({ force = false } = {}) {
this._wireLocalModel();
if (this._localModelRefused()) {
this.renderLocalModel();
return Promise.resolve();
}
if (this._localModelInFlight) return this._localModelInFlight;
const fresh = this._localModelStatus && Date.now() - (this._localModelLoadedAt || 0) < LOCAL_MODEL_MAX_AGE_MS;
if (fresh && !force) {
this.renderLocalModel();
return Promise.resolve();
}
// An answer already on screen stays there while it is refreshed.
if (!this._localModelStatus) this._localModelState = 'loading';
this.renderLocalModel();
this._localModelInFlight = this._fetchLocalModel().finally(() => {
this._localModelInFlight = null;
});
return this._localModelInFlight;
},
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'));
let body = null;
if (res?.ok || res?.status === 403) {
try {
body = await res.json();
} catch {
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;
}
const data = res?.ok ? localModelPayload(body) : null;
if (!data) {
// A failed refresh must not blank an answer the user is reading: keep the last good one.
if (!this._localModelStatus) this._localModelState = 'failed';
this.renderLocalModel();
return;
}
this._noteLocalModelInstall(data.install);
this._localModelStatus = data;
this._localModelState = 'ready';
this._localModelLoadedAt = Date.now();
this.renderLocalModel();
},
/**
* Called with the `install` object that is about to replace the one on screen. An install
* that was running and is installed now finished in front of the person: the window then
* shows the screen that ends it (see `justInstalled`). Any other state ends that moment.
*/
_noteLocalModelInstall(next) {
const ui = this._localModelWindowState();
const before = localModelInstall(this._localModelStatus).state;
const after = next && typeof next.state === 'string' ? next.state : 'idle';
if (after !== 'installed') ui.justInstalled = false;
else if (before === 'installing' && ui.open) ui.justInstalled = true;
},
_localModelRefused() {
return this._localModelState === 'refused' || localModelNotAdmin();
},
/** 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,
/**
* The install finished while this window was open: the screen that ends it shows
* "Start a session" alone. Stop and Remove belong to the window opened later.
*/
justInstalled: false,
};
}
return this._localModelUi;
},
/** 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,
justInstalled: false,
});
this._forgetLocalModelPaint();
modal.classList.add('active');
// A trap of its own: the window can open on top of App Settings, whose trap must stay
// 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') {
this._noteLocalModelInstall(data);
if (this._localModelStatus) this._localModelStatus = { ...this._localModelStatus, install: data };
result = { ok: true, view: data };
} else if (res?.status === 409 && data) {
ui.notice = { message: data.message, sessions: data.sessions };
result = { ok: false, conflict: data };
} else if (res && data && data.message && typeof data.message.text === 'string') {
// A refusal the server names (a 400, a full disk) carries its sentence with the slots
// apart, like a 409, so it is printed in the window's own language.
ui.notice = { message: data.message };
result = { ok: false };
} else {
ui.notice = res
? { refused: true, detail: typeof json?.error === 'string' ? json.error : '' }
: { 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 surface: the welcome button, the App Settings row and, when open, the window. */
renderLocalModel() {
this._renderLocalModelButton();
this._renderLocalModelSettings();
this._renderLocalModelWindow();
},
/** Whether the server answered for a Mac (the only case in which anything about "this Mac" shows). */
_localModelOnMac() {
const status = this._localModelStatus;
return !!status && !this._localModelRefused() && status.recommendation.reason !== 'not-macos';
},
_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 || 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(localModelEl('span', 'set-row-label', localModelT('Could not reach the server.')));
return;
}
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) {
const moved = this._localModelDownloadMeter(screen, last);
if (moved) {
meter.replaceWith(moved);
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.dataset.screen === screen ? body.scrollTop : 0;
const parts = this._buildLocalModelScreen(screen, last);
// The buttons (or the Remove question) are the last part a screen builds. They go into
// the foot of the frame, outside the part that scrolls, so they stay in one place.
const foot = document.getElementById('localModelWindowFoot');
const tail = parts[parts.length - 1];
const actions =
foot && tail && tail.classList && (tail.classList.contains('lm-foot') || tail.classList.contains('lm-remove'));
body.dataset.screen = screen;
body.replaceChildren(...(actions ? parts.slice(0, -1) : parts));
if (foot) foot.replaceChildren(...(actions ? [tail] : []));
body.scrollTop = scrolled;
this._settleLocalModelBars(body);
if (inside || active === document.body) this._focusLocalModelWindow(focusKey);
},
/**
* 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;
// The body and the foot: a screen's controls live in both.
const keyed = [...modal.querySelectorAll('[data-lm-key]')].filter((el) => !el.disabled);
const target =
(key && keyed.find((el) => el.dataset.lmKey === key && el.getAttribute('tabindex') !== '-1')) ||
keyed.find((el) => el.dataset.lmKey === 'model' && el.getAttribute('aria-checked') === 'true') ||
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 of the chooser: radio, size tile, name, tag, speed with its gauge, download. */
_localModelRow(candidate, { checked, tabStop, topSpeed }) {
const row = localModelEl('div', 'lm-model');
row.setAttribute('role', 'radio');
row.setAttribute('aria-checked', checked ? 'true' : 'false');
row.setAttribute('tabindex', tabStop ? '0' : '-1');
row.dataset.lmKey = 'model';
row.dataset.modelId = candidate.modelId;
row.appendChild(localModelEl('span', 'lm-radio'));
if (checked) row.classList.add('lm-model-on');
row.appendChild(localModelTile(candidate));
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 tags of the row the window opens on take the accent. Every other tag is quiet.
if (LOCAL_MODEL_ACCENT_TAGS.includes(candidate.tag.kind)) tag.classList.add('lm-tag-accent');
tag.classList.add(`lm-tag-${candidate.tag.kind}`);
tagSlot.appendChild(tag);
}
row.appendChild(tagSlot);
const speed = localModelEl('span', 'lm-model-speed', localModelSpeedText(candidate));
const gauge = localModelEl(
'span',
`lm-gauge lm-gauge-${candidate.speed === 'slow' ? 'slow' : candidate.speed === 'ok' ? 'ok' : 'unknown'}`
);
const fast = Number(candidate.tokensPerSecond);
if (Number.isFinite(fast) && topSpeed > 0) {
const needle = localModelEl('span', 'lm-gauge-fill');
needle.style.width = `${Math.max(6, Math.round((fast / topSpeed) * 100))}%`;
gauge.appendChild(needle);
}
speed.appendChild(gauge);
row.appendChild(speed);
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');
// An estimate, so whole GB: "about 36 of 38", never an estimate to the decimal.
const sizes = { need: localModelWholeGb(candidate.needGb), budget: localModelWholeGb(recommendation.budgetGb) };
head.appendChild(localModelEl('span', 'lm-figure', localModelT('About {need} of {budget} GB of memory', sizes)));
// A formula's figure says so, once. A wired limit and the runtime's own figure are facts.
if (recommendation.budgetSource === 'estimated') {
head.appendChild(localModelEl('span', 'lm-figure-aside', localModelT('estimated')));
}
const fitWord =
candidate.fit === 'tight' ? localModelT('Tight fit') : candidate.fit === 'good' ? localModelT('Good fit') : '';
if (fitWord) head.appendChild(localModelEl('span', `lm-fit lm-fit-${candidate.fit}`, fitWord));
meter.appendChild(head);
const share = String(Math.round((Number.isFinite(candidate.budgetShare) ? candidate.budgetShare : 0) * 100));
// The bar is the model at its PEAK, split into what makes that peak.
meter.appendChild(
localModelMemoryBar(
candidate,
Number(recommendation.budgetBytes),
last,
localModelT('The selected model needs {share}% of the memory this Mac can give a model', { share })
)
);
const legend = localModelMemoryParts(candidate, Number(recommendation.budgetBytes));
if (legend) 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');
const topSpeed = Math.max(0, ...candidates.map((row) => Number(row.tokensPerSecond) || 0));
for (const row of candidates) {
const checked = row === candidate;
const el = this._localModelRow(row, { checked, tabStop: checked, topSpeed });
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. Codeman installs it for you.'));
if (harness.slowFirstTurn === true) hints.push(localModelT('The first answer can take minutes.'));
if (hints.length) runs.appendChild(localModelEl('span', 'lm-runs-hint', hints.join(' ')));
parts.push(runs);
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);
},
/**
* The confirm screen: the download as a table. One row per thing that is fetched (the chosen
* model, the program that runs it while it is missing, the coding agent while it is
* missing), each with what it is in a second line, and the total under them in the strong
* weight. The table IS the screen; around it only where it goes and, on a Mac without the
* developer tools, the one sentence about them.
*/
_buildLocalModelConfirm() {
const ui = this._localModelWindowState();
const status = this._localModelStatus;
const install = localModelInstall(status);
const { candidate, harness } = this._localModelSelection();
const parts = [];
const hw = this._localModelHardwareEl();
if (hw) parts.push(hw);
const cli = String(harness.cliLabel || harness.cli);
const sizes = localModelEl('div', 'lm-sizes');
sizes.appendChild(localModelEl('h4', 'lm-heading', localModelT('What will be downloaded')));
// The list collapses to the chosen row: it is the first row of the table.
const about = localModelT('At {context} tokens of context, with {cli}', {
cli,
context: localModelCount(candidate.context),
});
sizes.appendChild(
localModelSizeRow(String(candidate.modelLabel || candidate.modelId), { gb: candidate.downloadGb, note: about })
);
const runtime = install.runtime || {};
if (runtime.installed !== true && Number(runtime.downloadBytes) > 0 && runtime.downloadGb) {
sizes.appendChild(
localModelSizeRow(localModelT('Ollama {version}', { version: runtime.version }), {
gb: runtime.downloadGb,
note: localModelT('The program that runs the model'),
})
);
}
// The coding agent, when it is missing: its own installer fetches it, with this literal command.
if (harness.installed === false) {
const note = harness.installCommand
? localModelT('Not on this Mac yet. Its own installer fetches it with this command:')
: localModelT('Not on this Mac yet. Its own installer fetches it.');
sizes.appendChild(
localModelSizeRow(cli, { note, command: harness.installCommand ? String(harness.installCommand) : '' })
);
}
const expected = ui.expected && ui.expected.modelId === candidate.modelId ? ui.expected : null;
const total = expected ? expected.gb : candidate.installGb;
if (total) sizes.appendChild(localModelSizeRow(localModelT('Total'), { gb: total, strong: true }));
parts.push(sizes);
if (install.folder) {
const where = localModelEl('p', 'lm-where');
where.appendChild(localModelEl('span', '', localModelT('Goes into')));
where.appendChild(document.createTextNode(' '));
where.appendChild(localModelEl('code', 'lm-path', String(install.folder)));
parts.push(where);
}
const tools = this._localModelDeveloperToolsEl();
if (tools) parts.push(tools);
const notice = this._localModelNoticeEl();
if (notice) parts.push(notice);
const foot = localModelEl('div', 'lm-foot');
foot.appendChild(localModelEl('p', 'lm-foot-note', localModelT('No administrator password.')));
const buttons = localModelEl('div', 'lm-buttons');
buttons.appendChild(
localModelButton(localModelT('Back'), { key: 'back', disabled: ui.busy, onClick: () => this.chooseLocalModel() })
);
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. The
* bar means ONE thing, the model download: while the runtime is fetched (seconds) it stands
* at the start with the size that is coming, it fills while the model arrives, and once the
* model is in it is gone (`null`): the step list carries the rest.
*/
_localModelDownloadMeter(screen, last) {
const install = localModelInstall(this._localModelStatus);
if (install.step !== 'runtime' && install.step !== 'model') return null;
const paused = screen === 'paused';
const progress = install.step === 'model' ? install.progress : null;
const meter = localModelEl('div', 'lm-meter');
const head = localModelEl('div', 'lm-meter-head');
const label = localModelT('Download of the model');
let share = 0;
if (progress && Number(progress.totalBytes) > 0) {
share = Number(progress.completedBytes) / Number(progress.totalBytes);
const sizes = { done: progress.completedGb, total: progress.totalGb };
head.appendChild(localModelEl('span', 'lm-figure lm-figure-count', 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 {
// Nothing of the model has arrived yet: the size that is coming, from the row it was chosen on.
const row = localModelCandidates(this._localModelStatus).find(
(candidate) => install.model && candidate.modelId === install.model.id
);
const text = row && row.downloadGb ? localModelT('0 of {total} GB', { total: row.downloadGb }) : label;
head.appendChild(localModelEl('span', 'lm-figure lm-figure-count', text));
}
meter.appendChild(head);
meter.appendChild(localModelBar({ share, role: 'progressbar', kind: 'download', from: last.download, label }));
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, for as long as something downloads.
const meter = this._localModelDownloadMeter(screen, last);
if (meter) parts.push(meter);
const list = this._localModelStepsEl(install, { stopped: paused });
if (list) parts.push(list);
if (ui.confirmRemove) {
parts.push(this._localModelRemoveEl());
return parts;
}
// What waits or goes on is the download while there is one, the install after it.
const downloading = !!meter;
let note;
if (paused) {
note = downloading
? localModelT('The download waits until you continue.')
: localModelT('The install waits until you continue.');
} else {
note = downloading
? localModelT('You can close this window. The download continues while Codeman is running.')
: localModelT('You can close this window. The install continues while Codeman is running.');
}
parts.push(localModelEl('p', 'lm-aside', note));
const notice = this._localModelNoticeEl();
if (notice) parts.push(notice);
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.
* It is the main button of the screen, except a removal, which never takes the accent.
*/
_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')) {
// The one action that helps, and still a removal: quiet here, red only in its question.
return localModelButton(localModelT('Remove'), {
key: 'primary',
disabled: ui.busy,
onClick: () => void this.removeLocalModel(),
});
}
if (error.next === 'next-model' && can('install')) {
const candidates = localModelCandidates(this._localModelStatus);
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 first sentence of the server's text as the headline,
* what follows it in the body size, and the raw detail folded away.
*/
_localModelErrorEl(error) {
const box = localModelEl('div', 'lm-error');
const [headline, rest] = localModelSentences(localModelMessage(error.message));
box.appendChild(localModelEl('p', 'lm-title', headline));
if (rest) box.appendChild(localModelEl('p', 'lm-lead', rest));
if (typeof error.detail === 'string' && error.detail) {
const details = localModelEl('details', 'lm-details');
details.appendChild(localModelEl('summary', '', localModelT('Details')));
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;
},
/**
* The line of facts under an installed model: the context, what the model takes now (what
* the runtime reported when the install checked it) and, in the words of the chooser's
* legend, what it can take at its peak (the row's own estimate, so the two figures a person
* meets are told apart instead of read as a contradiction).
*/
_localModelFactsLine(install) {
const loaded = install.loaded || {};
const context = localModelCount(loaded.context ?? install.context);
const row = localModelCandidates(this._localModelStatus).find(
(candidate) =>
install.model &&
candidate.modelId === install.model.id &&
candidate.context === (loaded.context ?? install.context)
);
const facts = { context, memory: loaded.memoryGb, peak: row ? localModelWholeGb(row.needGb) : '' };
const sentences = [];
if (context) sentences.push(localModelT('Context {context} tokens.', facts));
if (loaded.memoryGb && facts.peak) {
sentences.push(
localModelT(
'Takes {memory} GB of memory now, and up to about {peak} GB at its peak, with all of the context in use.',
facts
)
);
} else if (loaded.memoryGb) {
sentences.push(localModelT('Takes {memory} GB of memory now.', facts));
}
if (loaded.memoryGb && loaded.onGpu === true) sentences.push(localModelT('All of it is on the GPU.'));
return sentences.join(' ');
},
_buildLocalModelInstalled() {
const ui = this._localModelWindowState();
const install = localModelInstall(this._localModelStatus);
const model = install.model ? String(install.model.label || install.model.id || '') : '';
const error = install.error;
const parts = [];
// Something blocks a session: that sentence is the headline, not "is ready". Its one
// action is the main button below.
if (error) {
parts.push(this._localModelErrorEl(error));
} else {
const title = install.running
? localModelT('{model} is ready on this Mac.', { model })
: localModelT('{model} is installed on this Mac.', { model });
parts.push(localModelEl('p', 'lm-title', title));
}
const state = localModelEl('div', 'lm-state');
// Green says "a session can be started". A model that is blocked is not that.
const dot = localModelEl('span', install.running && !error ? 'lm-dot lm-dot-on' : 'lm-dot');
dot.setAttribute('aria-hidden', 'true');
state.appendChild(dot);
const running = install.running ? localModelT('Running') : localModelT('Stopped');
state.appendChild(localModelEl('span', 'lm-state-text', error ? `${model}, ${running}` : running));
parts.push(state);
const about = [];
if (install.cliLabel) about.push(localModelT('Runs with {cli}.', { cli: String(install.cliLabel) }));
const line = this._localModelFactsLine(install);
if (line) about.push(line);
if (about.length) parts.push(localModelEl('p', 'lm-aside', about.join(' ')));
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;
// The screen that ends an install has one button. Stop and Remove are for the window
// opened later (or for this one once anything else was said in it).
const ending = ui.justInstalled === true && can('start-session') && !error && !notice;
const foot = localModelEl('div', 'lm-foot');
const buttons = localModelEl('div', 'lm-buttons');
if (!ending && can('remove') && error?.next !== 'remove') {
buttons.appendChild(
localModelButton(localModelT('Remove'), {
key: 'remove',
disabled: ui.busy,
onClick: () => void this.removeLocalModel(),
})
);
}
if (!ending && 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;
// 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._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 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());
},
});