feat(local-model): one frame for the window, honest figures, a table for the consent

The window is one frame for the whole flow: a fixed height on desktop, App
Settings' boxed close button, the buttons in a foot of their own outside
the part that scrolls, so a click never moves the next button away and
nothing is cut off; the short screens are centred in it on desktop and sit
above the buttons on a phone.

The chooser lists the pick first and then the other models by download
size, largest first, a column the reader can see. A pick that only just
fits says "Best fit, tight". The memory figure is an estimate and says so
once, in whole GB; its legend names the peak, and the installed line uses
the same words beside what the model takes now, so the two figures are not
read as a contradiction. The legend, the column headers and the foot's
promise are set in --text-dim, the bar's track in --text-muted so its free
part can be seen, and only the download figure keeps tabular figures.

The consent screen is the download as a table: the chosen row, the runtime,
the coding agent with its installer's command as the row's second line, the
total in the strong weight, then where it goes in one line. The download bar
means the model download alone and is gone once the model is in; the screen
that ends an install shows "Start a session" alone; an error on an installed
model is the headline with a grey dot; an error's first sentence is the
headline; Remove is quiet everywhere but red in its own question. The
no-recommendation reasons are one sentence each, and the untested note is
in Codeman's voice. A refusal the server names is printed from its sentence.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Codeman maintainer committed 2026-11-06 23:55:02 +01:00
1 parent 3c4bb91d6f
commit 77d29d4621
9 files changed
+777 -314

No files matched your search

+45 -27
View File
@@ -1435,7 +1435,8 @@
estimated: '估算',
'The selected model needs {share}% of the memory this Mac can give a model':
'所选模型需要此 Mac 可分配给模型的内存的 {share}%',
'The model at {context} tokens of context': '模型在 {context} 个 Token 上下文下的占用',
'The model at its peak, with all {context} tokens of context in use':
'模型的峰值占用({context} 个 Token 的上下文全部用满时)',
'What stays free': '剩余可用',
'Models that fit this Mac': '此 Mac 装得下的模型',
Speed: '速度',
@@ -1444,20 +1445,20 @@
'{speed} tokens/s': '{speed} Token/秒',
'about {speed} tokens/s': '约 {speed} Token/秒',
'Runs with': '搭配',
'Not on this Mac yet. It is installed for you.': '此 Mac 上尚未安装,将为你一并安装。',
'Not on this Mac yet. Codeman installs it for you.': '此 Mac 上尚未安装,{name} 会为你一并安装。',
'The first answer can take minutes.': '第一次回答可能需要几分钟。',
'No account and no API key. The model runs only on this Mac.': '无需账号,也无需 API 密钥。模型只在此 Mac 上运行。',
'Install on this Mac': '安装到此 Mac',
// The confirm screen: what is downloaded, where it goes.
'Runs with {cli}, at {context} tokens of context.': '搭配 {cli},上下文为 {context} 个 Token。',
'What is downloaded': '将下载的内容',
'Ollama {version}, the program that runs the model': 'Ollama {version},用于运行模型的程序',
// The confirm screen: the download as a table, where it goes.
'What will be downloaded': '将下载的内容',
'At {context} tokens of context, with {cli}': '上下文 {context} 个 Token,搭配 {cli}',
'Ollama {version}': 'Ollama {version}',
'The program that runs the model': '用于运行模型的程序',
Total: '合计',
'{cli} is not on this Mac yet. Its own installer is run with this command:':
'此 Mac 上尚未安装 {cli}。将用以下命令运行它自带的安装程序:',
'{cli} is not on this Mac yet. It is installed with its own installer.':
'此 Mac 上尚未安装 {cli}。将用它自带的安装程序进行安装。',
'The model and the program that runs it go into:': '模型和运行它的程序将存放在:',
'Not on this Mac yet. Its own installer fetches it with this command:':
'此 Mac 上尚未安装。它自带的安装程序会用以下命令获取它:',
'Not on this Mac yet. Its own installer fetches it.': '此 Mac 上尚未安装。它自带的安装程序会获取它。',
'Goes into': '存放位置:',
'No administrator password.': '无需管理员密码。',
'Download and install': '下载并安装',
// Progress, and the same picture paused.
@@ -1474,9 +1475,14 @@
'Loading and checking': '加载并检查',
'Installing {cli}': '安装 {cli}',
Connecting: '正在连接',
'Download of the model': '模型下载进度',
'0 of {total} GB': '已下载 0 GB,共 {total} GB',
'You can close this window. The download continues while Codeman is running.':
'你可以关闭此窗口。只要 {name} 在运行,下载就会继续。',
'Nothing is downloaded until you continue.': '在你继续之前不会下载任何内容。',
'You can close this window. The install continues while Codeman is running.':
'你可以关闭此窗口。只要 {name} 在运行,安装就会继续。',
'The download waits until you continue.': '下载会等你点击继续后再进行。',
'The install waits until you continue.': '安装会等你点击继续后再进行。',
Continue: '继续',
'Choose another model': '选择其他模型',
// After an error: the one action that helps.
@@ -1488,11 +1494,11 @@
'{model} is ready on this Mac.': '{model} 已在此 Mac 上准备就绪。',
'{model} is installed on this Mac.': '{model} 已安装在此 Mac 上。',
'Runs with {cli}.': '搭配 {cli} 运行。',
'Context {context} tokens. Takes {memory} GB of memory when loaded, all on the GPU.':
'上下文 {context} 个 Token。加载后占用 {memory} GB 内存,全部位于 GPU 上。',
'Context {context} tokens. Takes {memory} GB of memory when loaded.':
'上下文 {context} 个 Token。加载后占用 {memory} GB 内存。',
'Context {context} tokens.': '上下文 {context} 个 Token。',
'Takes {memory} GB of memory now, and up to about {peak} GB at its peak, with all of the context in use.':
'目前占用 {memory} GB 内存;上下文全部用满时,峰值约为 {peak} GB。',
'Takes {memory} GB of memory now.': '目前占用 {memory} GB 内存。',
'All of it is on the GPU.': '全部位于 GPU 上。',
'Start a session': '开始会话',
'Remove the local model from this Mac?': '要从此 Mac 移除本地模型吗?',
'Also delete the downloaded model': '同时删除已下载的模型',
@@ -1503,7 +1509,7 @@
'Recommended for this Mac': '为此 Mac 推荐',
'This Mac can run a small model. It handles small edits and questions. It does not replace a cloud model.':
'此 Mac 可以运行小型模型。它能处理小幅修改和问答,但不能替代云端模型。',
'Not yet tested by us on a Mac like this.': '我们尚未在类似的 Mac 上测试过。',
'Codeman has not run this on a Mac like this yet.': '{name} 尚未在同类 Mac 上运行过它。',
'The best fit is a small model. It handles small edits and questions. It does not replace a cloud model.':
'最适合的是一个小型模型。它能处理小幅修改和问答,但不能替代云端模型。',
'{model} fits the memory of this Mac too, but its download needs about {missing} GB more free disk space.':
@@ -1514,24 +1520,25 @@
'Speed unknown': '速度未知',
'Codeman recommends a local model only on a Mac with Apple silicon.':
'{name} 仅在搭载 Apple 芯片的 Mac 上推荐本地模型。',
'This Mac has an Intel processor. Local coding models need Apple silicon.':
'此 Mac 使用 Intel 处理器。本地编程模型需要 Apple 芯片。',
'This Mac has {memory} GB of memory. That is too little to run a model that can drive a coding agent, so Codeman recommends none here.':
'此 Mac 有 {memory} GB 内存,不足以运行能够驱动编程智能体的模型,因此 {name} 在此不推荐任何模型。',
'Codeman estimates that this Mac can give a model about {budget} GB of memory. The smallest model a coding agent can work with needs about {need} GB. That leaves no safe margin, so Codeman recommends none here.':
'据 {name} 估算,此 Mac 可分配给模型的内存约为 {budget} GB。编程智能体能够使用的最小模型约需 {need} GB。这样没有留出安全余量,因此 {name} 在此不推荐任何模型。',
'Local coding models need Apple silicon, and this Mac has an Intel processor.':
'本地编程模型需要 Apple 芯片,而此 Mac 使用的是 Intel 处理器。',
'With {memory} GB of memory, this Mac cannot run a model that can drive a coding agent.':
'此 Mac 只有 {memory} GB 内存,无法运行能够驱动编程智能体的模型。',
'This Mac can give a model about {budget} GB of memory, and the smallest model a coding agent can work with needs about {need} GB.':
'此 Mac 可分配给模型的内存约为 {budget} GB,而编程智能体能够使用的最小模型约需 {need} GB。',
'Codeman could not read how much memory this Mac has, so it recommends no model here.':
'{name} 无法读取此 Mac 的内存大小,因此在此不推荐任何模型。',
'Not enough free disk space. The smallest model that fits this Mac is a {download} GB download, and {reserve} GB must stay free after it. {free} GB is free now. Free up about {missing} GB.':
'可用磁盘空间不足。适合此 Mac 的最小模型需要下载 {download} GB,下载后还必须保留 {reserve} GB 的可用空间。当前可用空间为 {free} GB。请腾出约 {missing} GB。',
'This Mac runs macOS {version}. Local models need macOS {minimum} or newer.':
'此 Mac 运行的是 macOS {version}。本地模型需要 macOS {minimum} 或更高版本。',
'Free up about {missing} GB of disk space to install the smallest model that fits this Mac.':
'请腾出约 {missing} GB 磁盘空间,以便安装适合此 Mac 的最小模型。',
'Local models need macOS {minimum} or newer, and this Mac runs macOS {version}.':
'本地模型需要 macOS {minimum} 或更高版本,而此 Mac 运行的是 macOS {version}。',
'No coding agent that Codeman can connect to a local model is available on this Mac.':
'此 Mac 上没有 {name} 可以连接到本地模型的编程智能体。',
// The tag of a model row (LOCAL_MODEL_TAG_LABELS in src/types/local-model.ts). Tight and Slow are
// the entries above.
'Tested on a Mac like this': '已在同类 Mac 上测试',
'Best fit': '最适合',
'Best fit, tight': '最适合,内存偏紧',
'Small model': '小型模型',
// The sentences of the install (LOCAL_MODEL_INSTALL_LABELS in src/types/local-model-install.ts):
// why a job stopped, why a request was refused, and the one sentence about the developer tools.
@@ -1569,6 +1576,17 @@
'Sessions are still using this model: {sessions}. Close them first.':
'仍有会话在使用此模型:{sessions}。请先关闭它们。',
'That is not possible in the current state.': '当前状态下无法执行此操作。',
'This model at this context is not offered for this Mac right now.':
'目前不为此 Mac 提供这一上下文大小下的该模型。',
'This coding agent is not offered for this model.': '该模型不提供这个编程智能体。',
'{cli} is not installed and cannot be installed from here.': '{cli} 尚未安装,也无法从这里安装。',
'Only {model} is installed on this Mac. Remove it and install another model first.':
'此 Mac 上只安装了 {model}。请先移除它,再安装其他模型。',
// What the endpoint routes answer for the record of the model on this Mac (shown as a toast).
'This endpoint belongs to the model Codeman runs on this Mac. Change or remove it in the local model window.':
'此端点属于 {name} 在此 Mac 上运行的模型。请在本地模型窗口中更改或移除它。',
'This endpoint id is reserved for the model Codeman runs on this Mac. Pick another id.':
'此端点标识已保留给 {name} 在此 Mac 上运行的模型。请换一个标识。',
'Coding agents use git and Python. This Mac has no developer tools yet, so macOS will offer to install them the first time an agent needs one.':
'编程智能体会用到 git 和 Python。此 Mac 尚未安装开发者工具,因此在智能体首次需要时,macOS 会提示安装。',
});
+3
View File
@@ -3693,6 +3693,9 @@
<button class="modal-close" onclick="app.closeLocalModelWindow()" aria-label="Close">&times;</button>
</div>
<div class="modal-body lm-body" id="localModelWindowBody" data-i18n-skip></div>
<!-- The buttons of the current screen. Outside the body, so they keep their
place while the body changes and scrolls. -->
<div class="lm-foot-slot" id="localModelWindowFoot" data-i18n-skip></div>
</div>
</div>
+285 -111
View File
@@ -26,15 +26,28 @@
* |-------------|-------------------------------------------------------------------|
* | `choose` | idle: the memory bar, the models that fit as a radio group, the |
* | | coding agent, "Install on this Mac" |
* | `confirm` | after that button: what is downloaded with sizes, where it goes |
* | `confirm` | after that button: the download as a table with its total, where |
* | | it goes |
* | `progress` | installing: the download bar, the steps, Cancel |
* | `paused` | paused (Cancel or a server restart): Continue or Remove |
* | `failed` | failed: the server's sentence and the ONE action that helps |
* | `installed` | installed: running or stopped, Start a session, Stop/Start, Remove |
* | | (right after an install: Start a session alone) |
* | `none` | idle and nothing fits: the hardware line, the reason, Close |
*
* The memory bar is the one strong element: the memory this Mac can give a model, filled to
* what the selected model needs (`budgetShare`). Selecting another row moves the fill.
* what the selected model needs at its PEAK (`budgetShare`). Selecting another row moves the
* fill. The legend under it and the line of an installed model use the same words for that
* peak, so the two figures a person meets (what the model takes now, what it can take) are
* never read as a contradiction.
*
* The window is ONE frame for the whole flow: the header and the foot (`#localModelWindowFoot`,
* the buttons) stay where they are and only the body between them changes and scrolls, so a
* button never moves away from under the pointer and is never cut off.
*
* The rows of the chooser are the server's: its pick first, then the other models by download
* size, largest first, which is a column the reader can see. The server's own order of the
* alternatives is its ranking (what it would offer next), not a reading order.
*
* ## Polling
*
@@ -53,11 +66,13 @@
* - Names and labels come from the payload: this file holds no list of models or coding
* agents and never compares a CLI id with a literal. The id is passed on as data
* (`cli` of the install request, the session start).
* - Sizes are printed exactly as sent (`needGb`, `budgetGb`, `downloadGb`, `installGb`,
* `completedGb`, `totalGb`: decimal GB as text). Bytes are never turned into a size here.
* The installed memory alone is `memClassGb`, the size the Mac is sold as. The two byte
* - Sizes are printed exactly as sent (`downloadGb`, `installGb`, `completedGb`, `totalGb`,
* `memoryGb`: decimal GB as text). Bytes are never turned into a size here. The one figure
* that is rounded is the memory estimate beside the bar and in the installed line: it says
* "about", so it is printed in whole GB from the server's own text (`needGb`, `budgetGb`).
* The installed memory alone is `memClassGb`, the size the Mac is sold as. The byte
* figures this file does read are the consent total (sent back unchanged as
* `acknowledgedBytes`) and the two ends of a bar.
* `acknowledgedBytes`), the two ends of a bar and the download size the rows are ordered by.
* - The consent is bound to the size shown: the install request carries the server's own total
* for the row (`Candidate.installBytes`), and a 409 `size-changed` shows the server's new
* total and asks again.
@@ -85,6 +100,9 @@ const LOCAL_MODEL_MAX_AGE_MS = 60000;
/** How often the open window asks where the install stands. */
const LOCAL_MODEL_POLL_MS = 1000;
/** The tag kinds of the server's pick (`CandidateTagKind`): the ones drawn in the accent colour. */
const LOCAL_MODEL_ACCENT_TAGS = ['tested', 'best', 'best-tight'];
/** i18n.js's translator with the `{placeholder}` slots filled, or the English source filled the same way. */
function localModelT(source, vars) {
const values = vars && typeof vars === 'object' ? vars : {};
@@ -163,12 +181,25 @@ function localModelInstall(status) {
return { state: 'idle', running: false, developerTools: 'unknown', actions: [] };
}
/** The rows of the chooser: the pick first, then the other models that fit. */
/**
* The rows of the chooser: the server's pick first, then the other models that fit by download
* size, largest first. The download is a column of the list, so the order can be read off the
* screen; the server sends the alternatives in its own ranking, which no column shows.
*/
function localModelCandidates(status) {
const recommendation = (status && status.recommendation) || {};
if (recommendation.verdict === 'none' || !recommendation.pick) return [];
const rest = Array.isArray(recommendation.alternatives) ? recommendation.alternatives : [];
return [recommendation.pick, ...rest].filter((row) => row && typeof row.modelId === 'string');
const rest = (Array.isArray(recommendation.alternatives) ? recommendation.alternatives : []).filter(
(row) => row && typeof row.modelId === 'string'
);
const bytes = (row) => (Number.isFinite(row.downloadBytes) ? row.downloadBytes : 0);
// A stable sort: rows of one size keep the server's order.
const bySize = rest
.map((row, index) => ({ row, index }))
.sort((a, b) => bytes(b.row) - bytes(a.row) || a.index - b.index);
return [recommendation.pick, ...bySize.map((entry) => entry.row)].filter(
(row) => row && typeof row.modelId === 'string'
);
}
/**
@@ -202,6 +233,29 @@ function localModelGb(value) {
return `${value} GB`;
}
/**
* A memory estimate in whole GB, from the server's decimal GB text ('36.0' is '36'). Only for
* a figure that says "about": an estimate printed to the decimal reads as a measurement.
* Text that is no number is returned as it is.
*/
function localModelWholeGb(value) {
const number = Number(value);
return typeof value === 'string' && value !== '' && Number.isFinite(number)
? String(Math.round(number))
: String(value ?? '');
}
/**
* A sentence group split after its first sentence: `[first, rest]`. The first is what a
* headline carries; the rest is read in the body size. Works on the translated text, so both
* the Latin full stop (followed by a space) and the ideographic one end a sentence.
*/
function localModelSentences(text) {
const value = String(text ?? '').trim();
const match = /^(.+?(?:[.!?](?=\s)|[。!?]))\s*(.+)$/su.exec(value);
return match ? [match[1], match[2]] : [value, ''];
}
/** "about 68 tokens/s", the measured figure without "about", or that there is none. */
function localModelSpeedText(candidate) {
const speed = localModelSpeed(candidate.tokensPerSecond);
@@ -270,11 +324,20 @@ function localModelBar({ share, role, label, kind, from }) {
return bar;
}
/** One line of the confirm screen's download list: what, and how large. */
function localModelSizeRow(label, gb, { strong = false } = {}) {
/**
* One row of the confirm screen's table: what is fetched, how large it is, and under the name
* a second line that says what it is (`note`) and, for a coding agent, the literal command of
* its installer (`command`). A row without a size (the coding agent: its installer decides)
* has none.
*/
function localModelSizeRow(label, { gb, note, command, strong = false } = {}) {
const row = localModelEl('div', strong ? 'lm-size-row lm-size-total' : 'lm-size-row');
row.appendChild(localModelEl('span', 'lm-size-what', label));
row.appendChild(localModelEl('span', 'lm-size-gb', localModelGb(gb)));
const what = localModelEl('div', 'lm-size-what');
what.appendChild(localModelEl('span', 'lm-size-name', label));
if (note) what.appendChild(localModelEl('span', 'lm-size-note', note));
if (command) what.appendChild(localModelEl('code', 'lm-command', command));
row.appendChild(what);
if (gb) row.appendChild(localModelEl('span', 'lm-size-gb', localModelGb(gb)));
return row;
}
@@ -343,12 +406,26 @@ Object.assign(CodemanApp.prototype, {
this.renderLocalModel();
return;
}
this._noteLocalModelInstall(data.install);
this._localModelStatus = data;
this._localModelState = 'ready';
this._localModelLoadedAt = Date.now();
this.renderLocalModel();
},
/**
* Called with the `install` object that is about to replace the one on screen. An install
* that was running and is installed now finished in front of the person: the window then
* shows the screen that ends it (see `justInstalled`). Any other state ends that moment.
*/
_noteLocalModelInstall(next) {
const ui = this._localModelWindowState();
const before = localModelInstall(this._localModelStatus).state;
const after = next && typeof next.state === 'string' ? next.state : 'idle';
if (after !== 'installed') ui.justInstalled = false;
else if (before === 'installing' && ui.open) ui.justInstalled = true;
},
_localModelRefused() {
return this._localModelState === 'refused' || localModelNotAdmin();
},
@@ -373,6 +450,11 @@ Object.assign(CodemanApp.prototype, {
/** The Remove question is open, and whether the downloaded model goes too. */
confirmRemove: false,
removeModel: true,
/**
* The install finished while this window was open: the screen that ends it shows
* "Start a session" alone. Stop and Remove belong to the window opened later.
*/
justInstalled: false,
};
}
return this._localModelUi;
@@ -417,7 +499,15 @@ Object.assign(CodemanApp.prototype, {
if (!modal || this._localModelRefused()) return;
const ui = this._localModelWindowState();
if (ui.open) return;
Object.assign(ui, { open: true, view: 'auto', busy: false, notice: null, expected: null, confirmRemove: false });
Object.assign(ui, {
open: true,
view: 'auto',
busy: false,
notice: null,
expected: null,
confirmRemove: false,
justInstalled: false,
});
this._forgetLocalModelPaint();
modal.classList.add('active');
// A trap of its own: the window can open on top of App Settings, whose trap must stay
@@ -503,11 +593,17 @@ Object.assign(CodemanApp.prototype, {
const data = json && typeof json === 'object' && json.data && typeof json.data === 'object' ? json.data : null;
let result;
if (res?.ok && json?.success === true && data && typeof data.state === 'string') {
this._noteLocalModelInstall(data);
if (this._localModelStatus) this._localModelStatus = { ...this._localModelStatus, install: data };
result = { ok: true, view: data };
} else if (res?.status === 409 && data) {
ui.notice = { message: data.message, sessions: data.sessions };
result = { ok: false, conflict: data };
} else if (res && data && data.message && typeof data.message.text === 'string') {
// A refusal the server names (a 400, a full disk) carries its sentence with the slots
// apart, like a 409, so it is printed in the window's own language.
ui.notice = { message: data.message };
result = { ok: false };
} else {
ui.notice = res
? { refused: true, detail: typeof json?.error === 'string' ? json.error : '' }
@@ -732,9 +828,12 @@ Object.assign(CodemanApp.prototype, {
const meter = body.querySelector('.lm-meter');
if (frame === this._localModelFrameKey && (screen === 'progress' || screen === 'paused') && meter) {
meter.replaceWith(this._localModelDownloadMeter(screen, last));
this._settleLocalModelBars(body);
return;
const moved = this._localModelDownloadMeter(screen, last);
if (moved) {
meter.replaceWith(moved);
this._settleLocalModelBars(body);
return;
}
}
this._localModelFrameKey = frame;
@@ -742,9 +841,17 @@ Object.assign(CodemanApp.prototype, {
const active = document.activeElement;
const inside = !!(modal && active && modal.contains(active));
const focusKey = inside && active.dataset ? active.dataset.lmKey : undefined;
const scrolled = body.scrollTop;
const scrolled = body.dataset.screen === screen ? body.scrollTop : 0;
const parts = this._buildLocalModelScreen(screen, last);
// The buttons (or the Remove question) are the last part a screen builds. They go into
// the foot of the frame, outside the part that scrolls, so they stay in one place.
const foot = document.getElementById('localModelWindowFoot');
const tail = parts[parts.length - 1];
const actions =
foot && tail && tail.classList && (tail.classList.contains('lm-foot') || tail.classList.contains('lm-remove'));
body.dataset.screen = screen;
body.replaceChildren(...this._buildLocalModelScreen(screen, last));
body.replaceChildren(...(actions ? parts.slice(0, -1) : parts));
if (foot) foot.replaceChildren(...(actions ? [tail] : []));
body.scrollTop = scrolled;
this._settleLocalModelBars(body);
if (inside || active === document.body) this._focusLocalModelWindow(focusKey);
@@ -776,7 +883,8 @@ Object.assign(CodemanApp.prototype, {
const modal = document.getElementById('localModelModal');
const body = document.getElementById('localModelWindowBody');
if (!modal || !body) return;
const keyed = [...body.querySelectorAll('[data-lm-key]')].filter((el) => !el.disabled);
// The body and the foot: a screen's controls live in both.
const keyed = [...modal.querySelectorAll('[data-lm-key]')].filter((el) => !el.disabled);
const target =
(key && keyed.find((el) => el.dataset.lmKey === key && el.getAttribute('tabindex') !== '-1')) ||
keyed.find((el) => el.dataset.lmKey === 'model' && el.getAttribute('aria-checked') === 'true') ||
@@ -836,27 +944,23 @@ Object.assign(CodemanApp.prototype, {
);
},
/** One model row: the radio of the chooser, or the same line as plain text on the confirm screen. */
_localModelRow(candidate, { checked, interactive, tabStop }) {
/** One model row of the chooser: a radio with the name, the tag, the speed and the download. */
_localModelRow(candidate, { checked, tabStop }) {
const row = localModelEl('div', 'lm-model');
if (interactive) {
row.setAttribute('role', 'radio');
row.setAttribute('aria-checked', checked ? 'true' : 'false');
row.setAttribute('tabindex', tabStop ? '0' : '-1');
row.dataset.lmKey = 'model';
row.dataset.modelId = candidate.modelId;
row.appendChild(localModelEl('span', 'lm-radio'));
} else {
row.classList.add('lm-model-static');
}
row.setAttribute('role', 'radio');
row.setAttribute('aria-checked', checked ? 'true' : 'false');
row.setAttribute('tabindex', tabStop ? '0' : '-1');
row.dataset.lmKey = 'model';
row.dataset.modelId = candidate.modelId;
row.appendChild(localModelEl('span', 'lm-radio'));
if (checked) row.classList.add('lm-model-on');
row.appendChild(localModelEl('span', 'lm-model-name', String(candidate.modelLabel || candidate.modelId)));
const tagSlot = localModelEl('span', 'lm-model-tag');
const label = candidate.tag && typeof candidate.tag.label === 'string' ? candidate.tag.label : '';
if (label) {
const tag = localModelEl('span', 'lm-tag', localModelT(label));
// The pick, and a row run on a Mac like this one, take the accent. Every other tag is quiet.
if (candidate.tag.kind === 'best' || candidate.tag.kind === 'tested') tag.classList.add('lm-tag-accent');
// The tags of the row the window opens on take the accent. Every other tag is quiet.
if (LOCAL_MODEL_ACCENT_TAGS.includes(candidate.tag.kind)) tag.classList.add('lm-tag-accent');
tagSlot.appendChild(tag);
}
row.appendChild(tagSlot);
@@ -878,7 +982,8 @@ Object.assign(CodemanApp.prototype, {
// The memory bar: what this Mac can give a model, filled to what the selected one needs.
const meter = localModelEl('div', 'lm-meter');
const head = localModelEl('div', 'lm-meter-head');
const sizes = { need: candidate.needGb, budget: recommendation.budgetGb };
// An estimate, so whole GB: "about 36 of 38", never an estimate to the decimal.
const sizes = { need: localModelWholeGb(candidate.needGb), budget: localModelWholeGb(recommendation.budgetGb) };
head.appendChild(localModelEl('span', 'lm-figure', localModelT('About {need} of {budget} GB of memory', sizes)));
// A formula's figure says so, once. A wired limit and the runtime's own figure are facts.
if (recommendation.budgetSource === 'estimated') {
@@ -897,7 +1002,14 @@ Object.assign(CodemanApp.prototype, {
);
const legend = localModelEl('div', 'lm-legend');
const context = localModelCount(candidate.context);
legend.appendChild(localModelEl('span', '', localModelT('The model at {context} tokens of context', { context })));
// The fill is the model at its PEAK. The installed line uses the same words for it.
legend.appendChild(
localModelEl(
'span',
'',
localModelT('The model at its peak, with all {context} tokens of context in use', { context })
)
);
legend.appendChild(localModelEl('span', '', localModelT('What stays free')));
meter.appendChild(legend);
parts.push(meter);
@@ -925,7 +1037,7 @@ Object.assign(CodemanApp.prototype, {
group.setAttribute('aria-labelledby', 'localModelListLabel');
for (const row of candidates) {
const checked = row === candidate;
const el = this._localModelRow(row, { checked, interactive: true, tabStop: checked });
const el = this._localModelRow(row, { checked, tabStop: checked });
el.addEventListener('click', () => this.selectLocalModel(row.modelId));
group.appendChild(el);
}
@@ -962,7 +1074,7 @@ Object.assign(CodemanApp.prototype, {
});
runs.appendChild(select);
const hints = [];
if (harness.installed === false) hints.push(localModelT('Not on this Mac yet. It is installed for you.'));
if (harness.installed === false) hints.push(localModelT('Not on this Mac yet. Codeman installs it for you.'));
if (harness.slowFirstTurn === true) hints.push(localModelT('The first answer can take minutes.'));
if (hints.length) runs.appendChild(localModelEl('span', 'lm-runs-hint', hints.join(' ')));
parts.push(runs);
@@ -1014,6 +1126,13 @@ Object.assign(CodemanApp.prototype, {
this.selectLocalModel(candidates[next].modelId);
},
/**
* The confirm screen: the download as a table. One row per thing that is fetched (the chosen
* model, the program that runs it while it is missing, the coding agent while it is
* missing), each with what it is in a second line, and the total under them in the strong
* weight. The table IS the screen; around it only where it goes and, on a Mac without the
* developer tools, the one sentence about them.
*/
_buildLocalModelConfirm() {
const ui = this._localModelWindowState();
const status = this._localModelStatus;
@@ -1023,51 +1142,48 @@ Object.assign(CodemanApp.prototype, {
const hw = this._localModelHardwareEl();
if (hw) parts.push(hw);
// The list collapses to the chosen row.
const chosen = localModelEl('div', 'lm-list');
const one = localModelEl('div', 'lm-models');
one.appendChild(this._localModelRow(candidate, { checked: true, interactive: false }));
chosen.appendChild(one);
parts.push(chosen);
const cli = String(harness.cliLabel || harness.cli);
const runs = localModelT('Runs with {cli}, at {context} tokens of context.', {
const sizes = localModelEl('div', 'lm-sizes');
sizes.appendChild(localModelEl('h4', 'lm-heading', localModelT('What will be downloaded')));
// The list collapses to the chosen row: it is the first row of the table.
const about = localModelT('At {context} tokens of context, with {cli}', {
cli,
context: localModelCount(candidate.context),
});
parts.push(localModelEl('p', 'lm-aside', runs));
// What is downloaded, with sizes: the model, the runtime while it is missing, the total.
const sizes = localModelEl('div', 'lm-sizes');
sizes.appendChild(localModelEl('h4', 'lm-heading', localModelT('What is downloaded')));
sizes.appendChild(localModelSizeRow(String(candidate.modelLabel || candidate.modelId), candidate.downloadGb));
sizes.appendChild(
localModelSizeRow(String(candidate.modelLabel || candidate.modelId), { gb: candidate.downloadGb, note: about })
);
const runtime = install.runtime || {};
if (runtime.installed !== true && Number(runtime.downloadBytes) > 0 && runtime.downloadGb) {
const what = localModelT('Ollama {version}, the program that runs the model', { version: runtime.version });
sizes.appendChild(localModelSizeRow(what, runtime.downloadGb));
sizes.appendChild(
localModelSizeRow(localModelT('Ollama {version}', { version: runtime.version }), {
gb: runtime.downloadGb,
note: localModelT('The program that runs the model'),
})
);
}
// The coding agent, when it is missing: its own installer fetches it, with this literal command.
if (harness.installed === false) {
const note = harness.installCommand
? localModelT('Not on this Mac yet. Its own installer fetches it with this command:')
: localModelT('Not on this Mac yet. Its own installer fetches it.');
sizes.appendChild(
localModelSizeRow(cli, { note, command: harness.installCommand ? String(harness.installCommand) : '' })
);
}
const expected = ui.expected && ui.expected.modelId === candidate.modelId ? ui.expected : null;
const total = expected ? expected.gb : candidate.installGb;
if (total) sizes.appendChild(localModelSizeRow(localModelT('Total'), total, { strong: true }));
if (total) sizes.appendChild(localModelSizeRow(localModelT('Total'), { gb: total, strong: true }));
parts.push(sizes);
// The coding agent, when it is missing: the literal command its installer runs.
if (harness.installed === false) {
const agent = localModelEl('div', 'lm-agent');
const lead = harness.installCommand
? localModelT('{cli} is not on this Mac yet. Its own installer is run with this command:', { cli })
: localModelT('{cli} is not on this Mac yet. It is installed with its own installer.', { cli });
agent.appendChild(localModelEl('p', 'lm-aside', lead));
if (harness.installCommand) agent.appendChild(localModelEl('code', 'lm-command', String(harness.installCommand)));
parts.push(agent);
}
const where = localModelEl('div', 'lm-where');
if (install.folder) {
where.appendChild(localModelEl('p', 'lm-aside', localModelT('The model and the program that runs it go into:')));
where.appendChild(localModelEl('code', 'lm-command', String(install.folder)));
const where = localModelEl('p', 'lm-where');
where.appendChild(localModelEl('span', '', localModelT('Goes into')));
where.appendChild(document.createTextNode(' '));
where.appendChild(localModelEl('code', 'lm-path', String(install.folder)));
parts.push(where);
}
where.appendChild(localModelEl('p', 'lm-aside', localModelT('No administrator password.')));
parts.push(where);
const tools = this._localModelDeveloperToolsEl();
if (tools) parts.push(tools);
@@ -1075,6 +1191,7 @@ Object.assign(CodemanApp.prototype, {
if (notice) parts.push(notice);
const foot = localModelEl('div', 'lm-foot');
foot.appendChild(localModelEl('p', 'lm-foot-note', localModelT('No administrator password.')));
const buttons = localModelEl('div', 'lm-buttons');
buttons.appendChild(
localModelButton(localModelT('Back'), { key: 'back', disabled: ui.busy, onClick: () => this.chooseLocalModel() })
@@ -1160,22 +1277,24 @@ Object.assign(CodemanApp.prototype, {
},
/**
* The download bar and its figure: "6.2 of 23.1 GB", then the speed and the time left. A step
* that downloads nothing names itself instead, with the bar full once the model is in.
* The download bar and its figure: "6.2 of 23.1 GB", then the speed and the time left. The
* bar means ONE thing, the model download: while the runtime is fetched (seconds) it stands
* at the start with the size that is coming, it fills while the model arrives, and once the
* model is in it is gone (`null`): the step list carries the rest.
*/
_localModelDownloadMeter(screen, last) {
const install = localModelInstall(this._localModelStatus);
if (install.step !== 'runtime' && install.step !== 'model') return null;
const paused = screen === 'paused';
const progress = install.progress;
const progress = install.step === 'model' ? install.progress : null;
const meter = localModelEl('div', 'lm-meter');
const head = localModelEl('div', 'lm-meter-head');
const steps = Array.isArray(install.steps) ? install.steps : [];
const now = localModelStepLabel(install.step, String(install.cliLabel || ''));
let share = steps.indexOf(install.step) > steps.indexOf('model') ? 1 : 0;
const label = localModelT('Download of the model');
let share = 0;
if (progress && Number(progress.totalBytes) > 0) {
share = Number(progress.completedBytes) / Number(progress.totalBytes);
const sizes = { done: progress.completedGb, total: progress.totalGb };
head.appendChild(localModelEl('span', 'lm-figure', localModelT('{done} of {total} GB', sizes)));
head.appendChild(localModelEl('span', 'lm-figure lm-figure-count', localModelT('{done} of {total} GB', sizes)));
const left = localModelTimeLeft(progress.secondsLeft);
let rate = '';
if (!paused && progress.mbPerSecond && left) {
@@ -1185,10 +1304,15 @@ Object.assign(CodemanApp.prototype, {
}
if (rate) head.appendChild(localModelEl('span', 'lm-figure-aside', rate));
} else {
head.appendChild(localModelEl('span', 'lm-figure', now));
// Nothing of the model has arrived yet: the size that is coming, from the row it was chosen on.
const row = localModelCandidates(this._localModelStatus).find(
(candidate) => install.model && candidate.modelId === install.model.id
);
const text = row && row.downloadGb ? localModelT('0 of {total} GB', { total: row.downloadGb }) : label;
head.appendChild(localModelEl('span', 'lm-figure lm-figure-count', text));
}
meter.appendChild(head);
meter.appendChild(localModelBar({ share, role: 'progressbar', kind: 'download', from: last.download, label: now }));
meter.appendChild(localModelBar({ share, role: 'progressbar', kind: 'download', from: last.download, label }));
return meter;
},
@@ -1204,8 +1328,9 @@ Object.assign(CodemanApp.prototype, {
: localModelT('Installing {model}', { model });
parts.push(localModelEl('p', 'lm-title', title));
// The download bar takes the place of the memory bar.
parts.push(this._localModelDownloadMeter(screen, last));
// The download bar takes the place of the memory bar, for as long as something downloads.
const meter = this._localModelDownloadMeter(screen, last);
if (meter) parts.push(meter);
const list = this._localModelStepsEl(install, { stopped: paused });
if (list) parts.push(list);
@@ -1215,9 +1340,18 @@ Object.assign(CodemanApp.prototype, {
return parts;
}
const note = paused
? localModelT('Nothing is downloaded until you continue.')
: localModelT('You can close this window. The download continues while Codeman is running.');
// What waits or goes on is the download while there is one, the install after it.
const downloading = !!meter;
let note;
if (paused) {
note = downloading
? localModelT('The download waits until you continue.')
: localModelT('The install waits until you continue.');
} else {
note = downloading
? localModelT('You can close this window. The download continues while Codeman is running.')
: localModelT('You can close this window. The install continues while Codeman is running.');
}
parts.push(localModelEl('p', 'lm-aside', note));
const notice = this._localModelNoticeEl();
if (notice) parts.push(notice);
@@ -1269,6 +1403,7 @@ Object.assign(CodemanApp.prototype, {
/**
* The ONE action that helps after an error, as a button, or null when the server does not
* offer it right now. `error.next` names it; `install.actions` says whether it is possible.
* It is the main button of the screen, except a removal, which never takes the accent.
*/
_localModelNextButton(install, error) {
const ui = this._localModelWindowState();
@@ -1289,7 +1424,12 @@ Object.assign(CodemanApp.prototype, {
return primary(localModelT('Start'), () => void this.setLocalModelRuntime('start'));
}
if (error.next === 'remove' && can('remove')) {
return primary(localModelT('Remove'), () => void this.removeLocalModel());
// The one action that helps, and still a removal: quiet here, red only in its question.
return localModelButton(localModelT('Remove'), {
key: 'primary',
disabled: ui.busy,
onClick: () => void this.removeLocalModel(),
});
}
if (error.next === 'next-model' && can('install')) {
const candidates = localModelCandidates(this._localModelStatus);
@@ -1303,10 +1443,15 @@ Object.assign(CodemanApp.prototype, {
return null;
},
/** An error as the window shows it: the server's sentence, and its raw detail folded away. */
/**
* An error as the window shows it: the first sentence of the server's text as the headline,
* what follows it in the body size, and the raw detail folded away.
*/
_localModelErrorEl(error) {
const box = localModelEl('div', 'lm-error');
box.appendChild(localModelEl('p', 'lm-title', localModelMessage(error.message)));
const [headline, rest] = localModelSentences(localModelMessage(error.message));
box.appendChild(localModelEl('p', 'lm-title', headline));
if (rest) box.appendChild(localModelEl('p', 'lm-lead', rest));
if (typeof error.detail === 'string' && error.detail) {
const details = localModelEl('details', 'lm-details');
details.appendChild(localModelEl('summary', '', localModelT('Details')));
@@ -1351,44 +1496,70 @@ Object.assign(CodemanApp.prototype, {
return parts;
},
/**
* The line of facts under an installed model: the context, what the model takes now (what
* the runtime reported when the install checked it) and, in the words of the chooser's
* legend, what it can take at its peak (the row's own estimate, so the two figures a person
* meets are told apart instead of read as a contradiction).
*/
_localModelFactsLine(install) {
const loaded = install.loaded || {};
const context = localModelCount(loaded.context ?? install.context);
const row = localModelCandidates(this._localModelStatus).find(
(candidate) =>
install.model &&
candidate.modelId === install.model.id &&
candidate.context === (loaded.context ?? install.context)
);
const facts = { context, memory: loaded.memoryGb, peak: row ? localModelWholeGb(row.needGb) : '' };
const sentences = [];
if (context) sentences.push(localModelT('Context {context} tokens.', facts));
if (loaded.memoryGb && facts.peak) {
sentences.push(
localModelT(
'Takes {memory} GB of memory now, and up to about {peak} GB at its peak, with all of the context in use.',
facts
)
);
} else if (loaded.memoryGb) {
sentences.push(localModelT('Takes {memory} GB of memory now.', facts));
}
if (loaded.memoryGb && loaded.onGpu === true) sentences.push(localModelT('All of it is on the GPU.'));
return sentences.join(' ');
},
_buildLocalModelInstalled() {
const ui = this._localModelWindowState();
const install = localModelInstall(this._localModelStatus);
const model = install.model ? String(install.model.label || install.model.id || '') : '';
const error = install.error;
const parts = [];
const title = install.running
? localModelT('{model} is ready on this Mac.', { model })
: localModelT('{model} is installed on this Mac.', { model });
parts.push(localModelEl('p', 'lm-title', title));
// The measured facts, in one line: what the runtime reported when the install checked it.
const loaded = install.loaded || {};
const facts = { context: localModelCount(loaded.context ?? install.context), memory: loaded.memoryGb };
let line = '';
if (loaded.memoryGb && loaded.onGpu === true) {
line = localModelT('Context {context} tokens. Takes {memory} GB of memory when loaded, all on the GPU.', facts);
} else if (loaded.memoryGb) {
line = localModelT('Context {context} tokens. Takes {memory} GB of memory when loaded.', facts);
} else if (facts.context) {
line = localModelT('Context {context} tokens.', facts);
// Something blocks a session: that sentence is the headline, not "is ready". Its one
// action is the main button below.
if (error) {
parts.push(this._localModelErrorEl(error));
} else {
const title = install.running
? localModelT('{model} is ready on this Mac.', { model })
: localModelT('{model} is installed on this Mac.', { model });
parts.push(localModelEl('p', 'lm-title', title));
}
const state = localModelEl('div', 'lm-state');
const dot = localModelEl('span', install.running ? 'lm-dot lm-dot-on' : 'lm-dot');
// Green says "a session can be started". A model that is blocked is not that.
const dot = localModelEl('span', install.running && !error ? 'lm-dot lm-dot-on' : 'lm-dot');
dot.setAttribute('aria-hidden', 'true');
state.appendChild(dot);
state.appendChild(
localModelEl('span', 'lm-state-text', install.running ? localModelT('Running') : localModelT('Stopped'))
);
const running = install.running ? localModelT('Running') : localModelT('Stopped');
state.appendChild(localModelEl('span', 'lm-state-text', error ? `${model}, ${running}` : running));
parts.push(state);
const about = [];
if (install.cliLabel) about.push(localModelT('Runs with {cli}.', { cli: String(install.cliLabel) }));
const line = this._localModelFactsLine(install);
if (line) about.push(line);
if (about.length) parts.push(localModelEl('p', 'lm-aside', about.join(' ')));
// Something blocks a session: the server's sentence. Its one action is the main button below.
const error = install.error;
if (error) parts.push(this._localModelErrorEl(error));
if (ui.confirmRemove) {
parts.push(this._localModelRemoveEl());
return parts;
@@ -1400,9 +1571,12 @@ Object.assign(CodemanApp.prototype, {
const can = (action) => install.actions.includes(action);
const next = error ? this._localModelNextButton(install, error) : null;
// The screen that ends an install has one button. Stop and Remove are for the window
// opened later (or for this one once anything else was said in it).
const ending = ui.justInstalled === true && can('start-session') && !error && !notice;
const foot = localModelEl('div', 'lm-foot');
const buttons = localModelEl('div', 'lm-buttons');
if (can('remove') && error?.next !== 'remove') {
if (!ending && can('remove') && error?.next !== 'remove') {
buttons.appendChild(
localModelButton(localModelT('Remove'), {
key: 'remove',
@@ -1411,7 +1585,7 @@ Object.assign(CodemanApp.prototype, {
})
);
}
if (can('stop')) {
if (!ending && can('stop')) {
buttons.appendChild(
localModelButton(localModelT('Stop'), {
key: 'stop',
+17 -13
View File
@@ -1870,14 +1870,26 @@ html.mobile-init .file-browser-panel {
}
.local-model-window > .lm-body {
flex: 1;
padding: 1rem 1rem calc(1rem + var(--safe-area-bottom));
padding: 1rem;
}
/* The window fills the screen, so its actions sit at the bottom, under the thumb. */
.lm-body > .lm-foot,
.lm-body > .lm-remove {
margin-top: auto;
.local-model-window > .lm-foot-slot {
padding: 0 1rem calc(1rem + var(--safe-area-bottom));
}
/* The short screens (progress, paused, a failure, installed, nothing fits)
hold a few lines. On a screen this tall they sit right above the buttons,
where the eye and the thumb already are, not at the top with half a
screen of nothing between the two. */
.local-model-window > .lm-body:is(
[data-screen='progress'],
[data-screen='paused'],
[data-screen='failed'],
[data-screen='installed'],
[data-screen='none']
) {
justify-content: flex-end;
}
/* Ralph wizard responsive */
@@ -4332,14 +4344,6 @@ html[data-session-list="sidebar"] .session-sidebar .session-tab .tab-close {
grid-column: 3;
}
.lm-model-static .lm-model-name {
grid-column: 1 / span 2;
}
.lm-model-static .lm-model-speed {
grid-column: 1 / span 2;
}
.lm-foot {
flex-direction: column;
align-items: stretch;
+149 -45
View File
@@ -22315,11 +22315,16 @@ html[data-tile-anim="soft"] .tile-grid-ghosts.tile-grid-ghosts--release:not(.til
row carries the accent on its reading edge and a faint fill.
Theme tokens only, never a black or white alpha (those vanish on the light
skins). The bar's track is `--control-border-hover`: the fill of the app's
inputs (`--bg-input`) is the window's own surface on the dark skins, so the
part of the bar that stays free could not be seen. Nothing here is keyed on
a CLI or a model id. The only motion is the bar's width, and it stops under
prefers-reduced-motion.
skins). The bar's track is `--text-muted`: the part of the bar that stays
FREE is half of what the bar says, so it must be seen against the window
(about 3:1 or better on every skin; the fill of the app's inputs,
`--bg-input`, is the window's own surface on the dark skins, and a border
token is 1.5:1). Nothing here is keyed on a CLI or a model id. The only
motion is the bar's width, and it stops under prefers-reduced-motion.
The window is ONE frame for the whole flow: a fixed height on desktop, the
header on top, the buttons in a foot of their own (`.lm-foot-slot`) and the
body scrolling between them. A click never moves the next button away.
═══════════════════════════════════════════════════════════════ */
/* The welcome button: one more chip, on its own line under the launchers. */
@@ -22337,13 +22342,16 @@ html[data-tile-anim="soft"] .tile-grid-ghosts.tile-grid-ghosts--release:not(.til
flex-shrink: 0;
}
/* The window: App Settings' chrome (`.modal`, `.modal-header`, `.modal-close`)
at a width of its own, the body scrolling under a fixed header. */
/* The window: App Settings' chrome (`.modal`, `.modal-header`, the boxed
close button) at a width of its own. One frame for every screen: the height
is fixed, so the header, the close button and the main button stay where
they are from the chooser to "Start a session". */
.modal-content.local-model-window {
display: flex;
flex-direction: column;
width: min(640px, 94vw);
max-width: min(640px, 94vw);
height: min(600px, 88vh);
max-height: 88vh;
}
@@ -22351,8 +22359,36 @@ html[data-tile-anim="soft"] .tile-grid-ghosts.tile-grid-ghosts--release:not(.til
flex-shrink: 0;
}
/* App Settings' close button: a 30px box, not the bare glyph of a plain modal. */
.local-model-window > .modal-header .modal-close {
display: inline-flex;
align-items: center;
justify-content: center;
width: 30px;
height: 30px;
padding: 0;
border-radius: 9px;
font-size: 1.2rem;
color: var(--text-dim);
background: transparent;
transition:
background 0.15s ease,
color 0.15s ease,
transform 0.1s ease;
}
.local-model-window > .modal-header .modal-close:hover {
background: var(--bg-hover);
color: var(--text);
}
.local-model-window > .modal-header .modal-close:active {
transform: scale(0.94);
}
.local-model-window > .lm-body {
display: flex;
flex: 1;
flex-direction: column;
gap: 1.05rem;
min-height: 0;
@@ -22361,6 +22397,37 @@ html[data-tile-anim="soft"] .tile-grid-ghosts.tile-grid-ghosts--release:not(.til
text-align: start;
}
/* The short screens (progress, paused, a failure, installed, nothing fits)
hold a few lines in a frame sized for the chooser: they sit in the middle of
it, a status in its own room, not a heading with a void under it. */
.local-model-window
> .lm-body:is(
[data-screen='progress'],
[data-screen='paused'],
[data-screen='failed'],
[data-screen='installed'],
[data-screen='none']
) {
justify-content: center;
padding-bottom: 2.2rem;
}
/* The foot of the frame: the buttons of the screen (or the Remove question),
outside the part that scrolls. */
.local-model-window > .lm-foot-slot {
flex-shrink: 0;
padding: 0 1.25rem 1.15rem;
text-align: start;
}
.local-model-window > .lm-foot-slot:empty {
display: none;
}
.lm-foot-slot p {
margin: 0;
}
.lm-body p,
.lm-body h4 {
margin: 0;
@@ -22437,6 +22504,11 @@ html[data-tile-anim="soft"] .tile-grid-ghosts.tile-grid-ghosts--release:not(.til
font-weight: 680;
letter-spacing: -0.012em;
color: var(--text);
}
/* Only the figure that ticks (the download) holds its digits in fixed cells.
In a sentence fixed cells leave a hole around every 1. */
.lm-figure-count {
font-variant-numeric: tabular-nums;
}
@@ -22449,7 +22521,7 @@ html[data-tile-anim="soft"] .tile-grid-ghosts.tile-grid-ghosts--release:not(.til
.lm-bar {
height: 10px;
border-radius: 999px;
background: var(--control-border-hover);
background: var(--text-muted);
overflow: hidden;
}
@@ -22464,9 +22536,9 @@ html[data-tile-anim="soft"] .tile-grid-ghosts.tile-grid-ghosts--release:not(.til
display: flex;
justify-content: space-between;
gap: 1rem;
font-size: 0.68rem;
font-size: 0.7rem;
line-height: 1.4;
color: var(--text-muted);
color: var(--text-dim);
}
.lm-legend > :last-child {
@@ -22507,8 +22579,8 @@ html[data-tile-anim="soft"] .tile-grid-ghosts.tile-grid-ghosts--release:not(.til
.lm-caption {
padding-bottom: 0.3rem;
font-size: 0.68rem;
color: var(--text-muted);
font-size: 0.7rem;
color: var(--text-dim);
}
.lm-caption-label {
@@ -22551,15 +22623,6 @@ html[data-tile-anim="soft"] .tile-grid-ghosts.tile-grid-ghosts--release:not(.til
background: rgba(var(--accent-rgb), 0.075);
}
/* The chosen row of the confirm screen: the same line, nothing to press. */
.lm-model-static {
cursor: default;
}
.lm-model-static .lm-model-name {
grid-column: 1 / span 2;
}
.lm-radio {
box-sizing: border-box;
width: 14px;
@@ -22674,60 +22737,79 @@ html[data-tile-anim="soft"] .tile-grid-ghosts.tile-grid-ghosts--release:not(.til
font-size: 0.74rem;
}
/* ── the confirm screen ───────────────────────────────────────────── */
/* ── the confirm screen: the download as a table ──────────────────── */
/* One row per thing that is fetched: its name in the text colour with what it
is underneath, its size at the right edge, and the total under a rule in the
strong weight. The table is the screen; nothing else on it competes. */
.lm-sizes {
display: flex;
flex-direction: column;
}
.lm-sizes .lm-heading {
margin-bottom: 0.3rem;
margin-bottom: 0.35rem;
font-size: 0.84rem;
font-weight: 650;
letter-spacing: -0.005em;
color: var(--text);
}
.lm-size-row {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 1rem;
padding: 0.28rem 0;
font-size: 0.8rem;
color: var(--text-dim);
gap: 1.2rem;
padding: 0.6rem 0;
border-top: 1px solid var(--border);
}
.lm-size-what {
display: flex;
flex-direction: column;
gap: 0.18rem;
min-width: 0;
}
.lm-size-name {
font-size: 0.86rem;
font-weight: 600;
color: var(--text);
overflow-wrap: anywhere;
}
.lm-size-note {
font-size: 0.74rem;
line-height: 1.45;
color: var(--text-dim);
overflow-wrap: anywhere;
}
.lm-size-gb {
flex-shrink: 0;
font-size: 0.86rem;
font-weight: 560;
color: var(--text);
font-variant-numeric: tabular-nums;
white-space: nowrap;
}
.lm-size-total {
margin-top: 0.25rem;
padding-top: 0.55rem;
border-top: 1px solid var(--border);
font-weight: 650;
color: var(--text);
padding-top: 0.75rem;
border-top-color: var(--text-muted);
}
.lm-agent,
.lm-where {
display: flex;
flex-direction: column;
gap: 0.35rem;
.lm-size-total .lm-size-name,
.lm-size-total .lm-size-gb {
font-size: 1.06rem;
font-weight: 680;
letter-spacing: -0.012em;
}
/* A literal command and a folder: text to read and to copy, so it is set in
the mono face and selects whole. */
/* A literal command: text to read and to copy, so it is set in the mono face
and selects whole. It sits in its row, under the sentence that names it. */
.lm-command {
display: block;
padding: 0.42rem 0.6rem;
border-radius: 6px;
background: var(--bg-input);
margin-top: 0.15rem;
color: var(--text);
font-family: var(--font-mono);
font-size: 0.72rem;
@@ -22736,6 +22818,21 @@ html[data-tile-anim="soft"] .tile-grid-ghosts.tile-grid-ghosts--release:not(.til
user-select: all;
}
/* Where it goes: one quiet line under the table. */
.lm-where {
font-size: 0.74rem;
line-height: 1.5;
color: var(--text-dim);
overflow-wrap: anywhere;
}
.lm-path {
color: var(--text);
font-family: var(--font-mono);
font-size: 0.72rem;
user-select: all;
}
/* ── progress: the steps ARE a sequence, so they are a list ───────── */
.lm-steps {
display: flex;
@@ -22853,7 +22950,7 @@ html[data-tile-anim="soft"] .tile-grid-ghosts.tile-grid-ghosts--release:not(.til
}
.lm-details {
margin-top: 0.55rem;
margin-top: 0.2rem;
font-size: 0.74rem;
color: var(--text-dim);
}
@@ -22882,6 +22979,13 @@ html[data-tile-anim="soft"] .tile-grid-ghosts.tile-grid-ghosts--release:not(.til
overflow-wrap: anywhere;
}
/* An error: its first sentence as the headline, the rest in the body size. */
.lm-error {
display: flex;
flex-direction: column;
gap: 0.4rem;
}
.lm-remove {
display: flex;
flex-direction: column;
@@ -22922,9 +23026,9 @@ html[data-tile-anim="soft"] .tile-grid-ghosts.tile-grid-ghosts--release:not(.til
.lm-foot-note {
min-width: 0;
font-size: 0.72rem;
font-size: 0.74rem;
line-height: 1.45;
color: var(--text-muted);
color: var(--text-dim);
}
.lm-buttons {