diff --git a/desktop/renderer/css/settings-llm.css b/desktop/renderer/css/settings-llm.css
index faf208cc..4e3b166d 100644
--- a/desktop/renderer/css/settings-llm.css
+++ b/desktop/renderer/css/settings-llm.css
@@ -2,4 +2,235 @@
Settings tab — LLM: run mode, local models, cloud providers, external
endpoint, fallback chain, llama-server log. Screens ST-20…27.
Loaded after css/settings.css (frame, nav, Tasks, Skills, Privacy).
+
+ Two contracts shape this file:
+ 1. The smoke and model-picks.drive.mjs read this pane's innerText for the
+ TUI's own sentences ("Mode: Local | Cloud | …", "current: aimlapi /
+ gpt-5.5", "aimlapi [aimlapi] key ok"). Every child of a flex or grid box
+ starts a new innerText line, so anything that must read as one sentence
+ is laid out inline or inline-block (.llm-ib, .llm-kv, .llm-seg), and
+ punctuation the design does not show sits in .llm-sr (zero font size,
+ still rendered, so still in the text).
+ 2. Rules here outrank the 0.5.5 LLM rules that css/settings.css may still
+ carry (.tuirow 24px lines, #llm-filter underline, .llmmode) — every
+ selector is scoped under .llm-pane, and the two ids keep their id.
============================================================ */
+
+.llm-pane{display:flex;flex-direction:column;gap:14px;min-width:0;position:relative;
+ font:400 13.5px/1.45 var(--font-ui);letter-spacing:-.003em;color:var(--ink);white-space:normal;padding-bottom:4px;
+ container:llmpane / inline-size}
+
+/* ---- Toolbar: pane segment · status · refresh · log ---- */
+.llm-pane .llm-bar{gap:10px;min-height:34px}
+.llm-pane .llm-modehead{min-width:0}
+.llm-pane .llm-seg{display:inline-block;white-space:nowrap;font-size:0;line-height:0;vertical-align:middle}
+.llm-pane .llm-seg .llmmode{display:inline-flex;align-items:center;vertical-align:top;height:28px;padding:0 13px;border-radius:var(--r-pill);
+ background:transparent;color:var(--ink-2);font:600 12.5px/1 var(--font-ui);letter-spacing:0;white-space:nowrap;box-shadow:none;
+ transition:background var(--t-state) var(--ease),color var(--t-state) var(--ease)}
+.llm-pane .llm-seg .llmmode + .llm-sr + .llmmode{margin-left:2px}
+.llm-pane .llm-seg .llmmode:hover{background:transparent;color:var(--ink)}
+.llm-pane .llm-seg .llmmode.on{background:var(--lift);color:var(--ink);box-shadow:var(--sh-soft)}
+.llm-pane .llm-status{display:inline-flex;align-items:center;gap:7px;min-width:0;font-size:12.5px;color:var(--ink-2)}
+.llm-pane .llm-status .tk-spin{width:12px;height:12px}
+
+/* ---- Notices the pane raises ---- */
+.llm-pane .tk-notice{align-items:flex-start;overflow-wrap:anywhere}
+.llm-pane .tk-notice > svg,.llm-pane .tk-notice > .tk-spin{margin-top:2px}
+.llm-pane .tk-notice > .logo{margin-top:-2px}
+.llm-pane .llm-msg{background:var(--well);box-shadow:none}
+.llm-pane .llm-msg > svg{color:var(--ink-2)}
+.llm-pane .llm-note{font-size:12.5px;line-height:1.45;color:var(--ink-2);margin:0}
+.llm-pane .llm-start .grow,.llm-pane .llm-dl-body{display:flex;flex-direction:column;gap:2px;min-width:0}
+.llm-pane .llm-start b,.llm-pane .llm-dl-body b{font-size:13.5px;font-weight:600}
+.llm-pane .llm-dl .tk-ico{margin-top:-2px}
+.llm-pane .llm-dl .btn{margin:-4px -2px 0 0}
+.llm-pane .llm-dl-line{display:block;margin-top:6px;padding:7px 11px;border-radius:var(--r-well);background:var(--lift);
+ font:400 12px/1.5 var(--font-mono);color:var(--ink);white-space:pre-wrap;overflow-wrap:anywhere}
+
+/* ---- Route card: what answers now ---- */
+.llm-pane .llm-route{display:flex;flex-direction:column;gap:3px;min-width:0;padding:13px 18px 14px;border-radius:var(--r-block);background:var(--well)}
+.llm-pane .llm-route-head{display:flex;align-items:center;gap:8px;min-height:24px;margin-bottom:4px}
+.llm-pane .llm-route-head b{font-size:13.5px;font-weight:600}
+.llm-pane .llm-route-head .tk-chip .tk-dot{width:7px;height:7px}
+.llm-pane .llm-kv{display:block;min-width:0;padding-left:136px;text-indent:-136px;font-size:12.5px;line-height:24px;color:var(--ink);overflow-wrap:anywhere}
+.llm-pane .llm-k{display:inline-block;width:136px;text-indent:0;color:var(--ink-2);vertical-align:top}
+.llm-pane .llm-kv .mono{font-size:12px;line-height:24px}
+.llm-pane .llm-kv b.mono{font-weight:500}
+.llm-pane .llm-kv .logo,.llm-pane .llm-kv .tk-ico{vertical-align:middle;margin:-3px 6px 0 0;text-indent:0}
+.llm-pane .llm-slash{color:var(--ink-3);padding:0 2px}
+
+/* ---- Sections and lists ---- */
+.llm-pane .llm-section{display:flex;flex-direction:column;gap:8px;min-width:0;margin:0}
+.llm-pane .llm-sh{min-height:30px;padding:0 2px;gap:10px;color:var(--ink);font-size:13.5px}
+.llm-pane .llm-sh-t{font-weight:600;color:var(--ink);white-space:nowrap}
+.llm-pane .llm-sh .btn .logo{margin-left:-4px}
+.llm-pane .llm-ram{margin:-6px 2px 2px;font-size:12.5px;line-height:1.45;color:var(--ink-2)}
+.llm-pane .llm-list{padding:4px}
+.llm-pane .llm-empty{padding:14px 16px;border-radius:var(--r-card);background:var(--well);color:var(--ink-2);font-size:13px;font-weight:400;white-space:normal}
+.llm-pane .llm-empty.is-strong{color:var(--ink);font-weight:500}
+.llm-pane .llm-counter{margin:0;padding:0 6px;font:400 11.5px/1.5 var(--font-mono);color:var(--ink-2)}
+.llm-pane .llm-loading{display:flex;align-items:center;gap:8px;padding:6px 6px;font-size:12.5px;color:var(--ink-2)}
+
+/* ---- Rows ---- */
+.llm-pane .tuirow.llm-row{display:flex;align-items:center;gap:12px;width:100%;height:auto;min-height:46px;margin:0;padding:8px 12px;
+ border-radius:var(--r-row);background:transparent;color:var(--ink);text-align:left;
+ font:400 13.5px/1.35 var(--font-ui);letter-spacing:-.003em;white-space:normal;overflow:visible;text-overflow:clip}
+.llm-pane .tuirow.llm-row:hover{background:var(--hover)}
+.llm-pane .tuirow.llm-row.on{background:var(--brand-wash);color:var(--ink)}
+.llm-pane .tuirow.llm-model{align-items:flex-start;padding:10px 12px}
+.llm-pane .llm-model .tk-radio{margin-top:6px}
+.llm-pane .llm-model > .logo,.llm-pane .llm-model > .tk-ico{margin-top:1px}
+.llm-pane .llm-row .body{flex:1;min-width:0;display:flex;flex-direction:column;gap:1px}
+.llm-pane .llm-row .t{display:block;white-space:normal;overflow:visible;font-size:13.5px;font-weight:500;line-height:24px;color:var(--ink)}
+.llm-pane .llm-row.on .t{color:var(--ink)}
+.llm-pane .llm-id{font-size:13px;line-height:20px;font-weight:500;color:var(--ink)}
+.llm-pane .llm-size{font:400 12px/1 var(--font-mono);color:var(--ink-2);white-space:nowrap}
+.llm-pane .llm-kind{font:400 11.5px/1 var(--font-mono);color:var(--ink-2)}
+.llm-pane .llm-k2{font-size:12px;color:var(--ink-2)}
+.llm-pane .llm-ib{display:inline-block;line-height:22px;vertical-align:1px;white-space:nowrap}
+.llm-pane .llm-row .t .tk-chip{margin-left:2px}
+.llm-pane .llm-row .d{display:block;font-size:12.5px;line-height:1.45;color:var(--ink-2);white-space:normal;overflow:visible;text-overflow:clip}
+/* The blurb, fit and caution lines are prose now, not a mono TUI line with a 2ch indent. */
+.llm-pane .llm-row .llm-sub{font-family:var(--font-ui);padding-left:0}
+/* The fit verdict (MOD-04): runs comfortably green, a tight fit amber, out of
+ reach in the quiet ink that still reads at this size. */
+.llm-pane .llm-row .llm-fit-comfortable{color:var(--success)}
+.llm-pane .llm-row .llm-fit-tight{color:var(--amber-text)}
+.llm-pane .llm-row .llm-fit-over{color:var(--ink-2)}
+.llm-pane .llm-row .llm-caution{color:var(--amber-text)}
+.llm-pane .llm-effect{flex:0 1 auto;display:inline-flex;align-items:center;gap:6px;max-width:42%;margin-left:auto;
+ font:400 11.5px/1.4 var(--font-mono);color:var(--ink-2);text-align:right;white-space:normal;overflow-wrap:anywhere}
+.llm-pane .llm-model .llm-effect{margin-top:3px}
+.llm-pane .llm-effect--current{color:var(--success)}
+.llm-pane .llm-effect .tk-spin{width:12px;height:12px}
+.llm-pane .llm-row .llm-effect-d{font-family:var(--font-mono);font-size:11.5px}
+
+/* ---- Run mode (ST-20, ST-22) ---- */
+.llm-pane .llm-rm-grid{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:8px}
+.llm-pane .llm-rm{display:flex;align-items:center;gap:10px;min-width:0;min-height:62px;padding:10px 12px 10px 14px;border-radius:18px;
+ background:var(--lift);box-shadow:inset 0 0 0 1px var(--bd);color:var(--ink);text-align:left;
+ transition:background var(--t-state) var(--ease),box-shadow var(--t-state) var(--ease)}
+.llm-pane .llm-rm:hover{background:var(--hover)}
+.llm-pane .llm-rm.on{background:var(--lift);box-shadow:0 0 0 2px var(--brand)}
+.llm-pane .llm-rm-body{flex:1;min-width:0;display:flex;flex-direction:column;gap:1px}
+.llm-pane .llm-rm-t{font-size:14px;font-weight:600;line-height:1.3}
+.llm-pane .llm-rm-d{font-size:12px;line-height:1.35;color:var(--ink-2)}
+.llm-pane .llm-workers{display:flex;align-items:center;flex-wrap:wrap;gap:8px 14px;padding:0 2px}
+.llm-pane .llm-workers .tk-help{flex:1;min-width:220px}
+.llm-pane .llm-workerseg button{min-width:34px;padding:0 10px}
+
+/* ---- Cloud: provider, filter, price facet ---- */
+.llm-pane .llm-prov{min-width:0;font-size:12.5px;color:var(--ink-2)}
+.llm-pane .llm-prov .logo,.llm-pane .llm-prov .tk-ico{vertical-align:middle;margin:-2px 5px 0 2px}
+.llm-pane .llm-prov b{font-weight:500;color:var(--ink)}
+.llm-pane .llm-modelbar{gap:10px 12px}
+.llm-pane .llm-filter{flex:1;min-width:200px}
+.llm-pane .llm-filterbox{display:inline-flex;width:100%;height:36px;vertical-align:middle}
+.llm-pane .llm-filterbox > svg{width:15px;height:15px;color:var(--ink-3)}
+.llm-pane #llm-filter{flex:1;width:auto;min-width:0;height:100%;padding:0;border:0;border-radius:0;background:transparent;outline:none;
+ color:var(--ink);font:400 13.5px/1 var(--font-ui)}
+.llm-pane #llm-filter::placeholder{color:var(--ink-3)}
+.llm-pane .llm-price{display:flex;align-items:center;gap:8px;flex:none}
+.llm-pane .btn.llm-ib{display:inline-block;line-height:26px;text-align:center}
+.llm-pane .llm-price-note{font-size:12px;color:var(--ink-2)}
+
+/* ---- External ---- */
+.llm-pane .llm-extra{display:flex;flex-direction:column;gap:8px}
+.llm-pane .llm-line{display:flex;align-items:center;gap:10px;min-height:44px;padding:5px 8px 5px 10px;border-radius:var(--r-row);background:var(--well)}
+.llm-pane .llm-line-t{font-size:13px;color:var(--ink-2)}
+.llm-pane .llm-line-t .mono{color:var(--ink)}
+.llm-pane .llm-back{margin:0;padding:0 4px;font-size:12.5px;color:var(--ink-2)}
+.llm-pane .llm-link{display:inline-block;color:var(--blue);font-weight:600;font-size:inherit;text-decoration:underline;text-underline-offset:2px}
+.llm-pane .llm-link:hover{color:var(--blue-deep)}
+.llm-pane .tk-hint.llm-kb{display:inline-block;height:28px;line-height:28px;padding:0 10px;border-radius:var(--r-pill);
+ background:var(--lift);box-shadow:inset 0 0 0 1px var(--bd);color:var(--ink);font-size:12.5px;font-weight:500;white-space:nowrap}
+.llm-pane .tk-hint.llm-kb:hover{background:var(--hover)}
+.llm-pane .llm-kb .kc{vertical-align:1px;margin-right:1px}
+
+/* ---- Fallback (ST-25) ---- */
+.llm-pane .llm-honest{display:flex;align-items:center;gap:8px;margin:0;padding:0 2px;font-size:12.5px;color:var(--ink-2)}
+.llm-pane .llm-honest > svg{width:15px;height:15px;color:var(--ink-2)}
+.llm-pane .llm-idx{display:inline-block;width:24px;height:24px;margin-right:10px;border-radius:50%;background:var(--hover);
+ font:500 11.5px/24px var(--font-mono);color:var(--ink-2);text-align:center;vertical-align:middle}
+.llm-pane .llm-fb-row .t .logo,.llm-pane .llm-fb-row .t .tk-ico{vertical-align:middle;margin:-2px 8px 0 0}
+/* A mark inside a sentence the smoke reads ("current: aimlapi / gpt-5.5",
+ "provider: aimlapi", "1. aimlapi"): Blink's innerText breaks the line
+ around an atomic inline that holds a block-level image — and a grid item,
+ or the kit's svg{display:block}, is one. Inline-block box, inline image,
+ centred by margin, keeps the sentence whole. */
+.llm-pane .llm-kv :is(.logo,.tk-ico),.llm-pane .llm-prov :is(.logo,.tk-ico),.llm-pane .llm-fb-row .t :is(.logo,.tk-ico){display:inline-block;line-height:0;text-align:center}
+.llm-pane .llm-kv .logo img,.llm-pane .llm-prov .logo img,.llm-pane .llm-fb-row .t .logo img{display:inline;vertical-align:top;margin-top:19%}
+.llm-pane .llm-kv .tk-ico svg,.llm-pane .llm-prov .tk-ico svg,.llm-pane .llm-fb-row .t .tk-ico svg{display:inline;vertical-align:top;margin-top:calc(50% - 6.5px)}
+.llm-pane .llm-fb-row .d{padding-left:66px}
+.llm-pane .llm-mvs{display:flex;align-items:center;justify-content:flex-end;gap:2px;flex:none;width:56px}
+.llm-pane .llm-mv.is-off{color:var(--ink-3);cursor:default;background:transparent}
+.llm-pane .llm-lock{display:grid;place-items:center;width:26px;height:26px;color:var(--ink-3)}
+.llm-pane .llm-fbl{display:flex;align-items:center;gap:10px;min-height:44px;padding:2px 4px}
+.llm-pane .llm-fbl-t{font-size:13.5px;color:var(--ink)}
+.llm-pane .llm-fbl-t b{font-weight:600}
+
+/* ---- Ollama signpost and the key strip ---- */
+.llm-pane .llm-ollama{display:flex;align-items:flex-start;gap:10px;margin:0;padding:12px 14px;border:0;border-radius:18px;max-width:none;
+ background:var(--well);font-size:12.5px;line-height:1.5;color:var(--ink-2)}
+.llm-pane .llm-foot{display:flex;flex-direction:column;gap:4px;padding-top:2px}
+.llm-pane .llm-hintline{padding:0 6px;font-size:12px;color:var(--ink-2)}
+.llm-pane .tuihint{display:flex;flex-wrap:wrap;align-items:center;gap:2px 4px;margin:0;white-space:normal;font-family:var(--font-ui);color:var(--ink-2)}
+.llm-pane .tuihint .tk-hint{height:24px;padding:0 6px;border-radius:8px;font:400 12px/24px var(--font-ui);color:var(--ink-2);white-space:nowrap;background:transparent}
+.llm-pane .tuihint button.tk-hint:hover{background:var(--hover);color:var(--ink)}
+.llm-pane .tuihint .tk-hint[disabled]{color:var(--ink-3);background:transparent;cursor:default}
+
+/* ---- Confirms and prompts float over the inert pane (ST-24, ST-27) ---- */
+.llm-pane .llm-modal-layer{position:sticky;top:0;z-index:5;height:0;display:flex;align-items:flex-start;justify-content:center;overflow:visible}
+.llm-pane .llm-modal-layer > .llm-modal{flex:none;width:min(480px, calc(100% - 32px));margin-top:clamp(36px, 12vh, 132px)}
+.llm-pane .llm-behind{display:flex;flex-direction:column;gap:14px;min-width:0;opacity:.42;filter:blur(2px) saturate(.7);pointer-events:none;user-select:none}
+.llm-pane .llm-modal{gap:12px;padding:20px 20px 16px;border-radius:var(--r-sheet)}
+.llm-pane .llm-modal-h{display:flex;align-items:center;gap:10px}
+.llm-pane .llm-modal-h h4{font-size:16.5px;line-height:1.3;overflow-wrap:anywhere}
+.llm-pane .llm-modal p .mono{font-size:12.5px}
+.llm-pane .llm-modal .tk-help{margin:0}
+.llm-pane .llm-modal .acts .kc{margin-left:4px}
+.llm-pane .llm-working{display:flex;align-items:center;gap:8px;margin:0;font-size:12.5px;color:var(--ink-2)}
+.llm-pane .llm-modal #llm-url{width:100%;height:40px;padding:0 15px;border:1px solid transparent;border-radius:var(--r-pill);outline:none;
+ background:var(--well);color:var(--ink);font:400 13px/1 var(--font-mono)}
+.llm-pane .llm-modal #llm-url:focus{background:var(--lift);border-color:var(--brand);box-shadow:var(--focus-ring)}
+.llm-pane .llm-modal #llm-url.is-error{background:var(--lift);border-color:var(--critical);box-shadow:0 0 0 4px var(--critical-wash)}
+
+/* ---- Hugging Face branch ---- */
+.llm-pane .llm-hf{display:flex;flex-direction:column;gap:12px;min-width:0}
+.llm-pane .llm-hf-card{display:flex;flex-direction:column;gap:14px;padding:18px 18px 16px;border-radius:var(--r-block);background:var(--well)}
+.llm-pane .llm-hf-head{display:flex;align-items:flex-start;gap:14px}
+.llm-pane .llm-hf-title{min-width:0}
+.llm-pane .llm-h{margin:2px 0 0;font-size:17px;font-weight:600;line-height:1.3;letter-spacing:-.02em;color:var(--ink)}
+.llm-pane .llm-h-tail{font-size:13.5px;font-weight:400;letter-spacing:-.003em;color:var(--ink-2)}
+.llm-pane .llm-hf-ex{margin:4px 0 0;font:400 12px/1.5 var(--font-mono);color:var(--ink-2);overflow-wrap:anywhere}
+.llm-pane .llm-hf-row{display:flex;align-items:center;gap:8px}
+.llm-pane .llm-hf-inp{flex:1;height:44px;background:var(--lift)}
+.llm-pane .llm-hf-inp > svg{width:15px;height:15px;color:var(--ink-3)}
+.llm-pane .llm-hf-inp input{width:auto;max-width:none;height:100%;padding:0;border:0;border-radius:0;background:transparent;color:var(--ink);font:400 13.5px/1 var(--font-mono)}
+.llm-pane .llm-hf-inp input:disabled{color:var(--ink-3)}
+.llm-pane .llm-hf-err .grow{white-space:pre-wrap}
+.llm-pane .llm-hf-acts{display:flex;align-items:center;justify-content:flex-end;gap:8px}
+.llm-pane .llm-hf-repo{gap:8px}
+.llm-pane .llm-hf-file .llm-id{font-size:12.5px}
+.llm-pane .llm-mmproj{display:flex;align-items:center;gap:8px}
+.llm-pane .llm-mmproj > svg{width:15px;height:15px;color:var(--ink-2)}
+
+/* ---- llama-server log (ST-26) ---- */
+.llm-pane .llm-logbar{flex-wrap:nowrap}
+.llm-pane .llm-logpath{min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font:400 12px/1.4 var(--font-mono);color:var(--ink-2)}
+.llm-pane .llm-log{margin:0;min-height:300px;padding:12px 16px;border-radius:var(--r-block);font-size:12px;line-height:1.75;white-space:pre-wrap}
+.llm-pane .llm-log--empty{display:grid;place-items:center;background:var(--well);color:var(--ink-2);font-size:13px;text-align:center}
+.llm-pane .llm-log-err{color:var(--critical)}
+.llm-pane .llm-log-warn{color:var(--amber-text)}
+.llm-pane .llm-log-ok{color:var(--blue)}
+
+/* ---- Narrow window: the run modes stack, the row action drops under the text ---- */
+@container llmpane (max-width: 600px){
+ .llm-pane .llm-rm-grid{grid-template-columns:minmax(0,1fr)}
+ .llm-pane .llm-rm{min-height:50px}
+ .llm-pane .llm-effect{max-width:36%}
+ .llm-pane .llm-price-note{flex-basis:100%}
+}
+
+/* Last, so nothing above can give it back a size. */
+.llm-pane .llm-sr{font-size:0;letter-spacing:0}
diff --git a/desktop/renderer/renderer.js b/desktop/renderer/renderer.js
index 58bf93a9..15c719ba 100644
--- a/desktop/renderer/renderer.js
+++ b/desktop/renderer/renderer.js
@@ -14153,27 +14153,92 @@ function llmRemoveLink(links, providerId) {
function llmTab() {
llmEnsurePoll();
if (BR && LLMP.lastRefreshedAt === null && !LLMP.inflight) setTimeout(llmRefresh, 0); // reached without settingsPaneEntered (the --models harness's __pane)
- if (LLMP.view === 'logs') return '
' + llmLogsHTML() + '
';
+ if (LLMP.view === 'logs') return '
' + llmLogsHTML() + '
';
+ /* Soft Tactile (ST-24, ST-27): a confirm or a prompt floats over the pane
+ instead of replacing it. The pane behind is `inert`, so it takes no
+ click, no focus and no key — the same reach as when the modal took the
+ whole pane, which is what llmKey's modal branches already assume.
+ Item 7A: llm-panel.tsx gives the Hugging Face branch the whole pane —
+ "it owns every key while it is open, so drawing the model list behind
+ it would be a list nothing can reach." */
const modal = llmModalHTML();
- if (modal) return '
' + modal + '
';
- // Item 7A: llm-panel.tsx gives the Hugging Face branch the whole pane —
- // "it owns every key while it is open, so drawing the model list behind
- // it would be a list nothing can reach."
- if (LLMHF.open) return '
' + llmHfHTML() + '
';
+ const body = LLMHF.open ? llmHfHTML() : llmPanelHTML();
+ if (!modal) return '
' + body + '
';
+ return '
' + modal + '
'
+ + '
' + body + '
';
+}
+/* The panel under the toolbar, top to bottom as SET-LLM-00 lists it:
+ banners, the message, the route card, run mode, the pane, its keys. */
+function llmPanelHTML() {
const mode = LLMP.mode;
- const hint = llmFooterHint(mode);
- return '
'
- + (LLMP.daemonPhase === 'starting' ? '
⟳ Model is starting — please stand by
Loading the model into llama-server. Inputs are paused until it is ready.
' : '')
+ const status = llmStatusLine();
+ const tone = llmStatusTone(status);
+ return llmBarHTML(mode, status, tone)
+ // External reports its probe under its own row (ST-24); the other panes report at the top.
+ + (mode !== 'external' ? llmStatusNoteHTML(status, tone) : '')
+ + (LLMP.daemonPhase === 'starting'
+ ? '
Model is starting — please stand by'
+ + 'Loading the model into llama-server. Inputs are paused until it is ready.
';
+ + llmFooterHint(mode);
+}
+/* How loud the status line is on screen. The sentence is llmStatusLine's,
+ unchanged: a quiet readout in the toolbar while nothing is wrong or while
+ something is on its way, a notice when a step reported back. */
+function llmStatusNoteHTML(status, tone) {
+ if (tone !== 'ok' && tone !== 'err') return '';
+ return '
';
+}
+function llmStatusTone(line) {
+ if (line === 'status: ready') return 'ready';
+ if (/^probing |: (loading|updating)$/.test(line)) return 'busy';
+ if (/^local-llm URL saved|^cloud providers: Active (text|embedding provider): /.test(line)) return 'ok';
+ return 'err';
+}
+/* The pane toolbar (ST-20…25): the four panes as a segmented control, the
+ status readout, refresh and the daemon log. Drivers read
+ `#settings .llmmode.on` and click `[data-act="llm:mode:cloud"]`; the smoke
+ reads this strip's text as "Mode: Local | Cloud | External llama.cpp |
+ Fallback". So the label and the pipes stay in the text as zero-size glyphs
+ (llmSr), and the segment is inline-block — the children of a flex or grid
+ box each start a new innerText line, which would split that sentence. */
+function llmBarHTML(mode, status, tone) {
+ return '
';
+}
+/* Text that stays in innerText (the smoke and the drivers compare the TUI's
+ sentences) but takes no room on screen: zero font size, still rendered. */
+function llmSr(text) { return '' + esc(text) + ''; }
+function llmMsgHTML(text) {
+ const bad = /^! /.test(text);
+ return '
';
+}
+/* A provider entry's mark: its id's logo, else its kind's, else the server icon. */
+function llmProviderMark(p, size) {
+ if (!p) return providerMark('', size);
+ return providerMark(providerLogoKey(p.id) ? p.id : (p.kind || p.id), size);
+}
+/* A keyboard hint that is also a button, whose text reads "s start/stop"
+ whole (inline-block, so the keycap does not split it into lines). */
+function llmKeyBtn(key, rest, act) {
+ return '';
}
/* llm-panel.tsx RouteCard. `current:` is the provider's chat model; for the local route the TUI shows the daemon's /props model —
the desktop has no /props, so `atag models status` "active model:" stands in, then localModels.managed.modelId (the critique's fallback). */
@@ -14187,34 +14252,39 @@ function llmRunModeHTML() {
const mode = cfg.mode || 'cloud';
const workers = (cfg.fusion && cfg.fusion.workers) || 3;
const MODES = [
- ['local', 'Local', 'everything runs on this Mac'],
- ['cloud', 'Cloud', 'everything runs on the provider'],
- ['fusion', 'Fusion', 'a cloud model plans, local workers do the work'],
+ ['local', 'Local', 'everything runs on this Mac', 'laptop'],
+ ['cloud', 'Cloud', 'everything runs on the provider', 'cloud'],
+ ['fusion', 'Fusion', 'a cloud model plans, local workers do the work', 'bolt'],
];
const localReady = !!(LIVE_CONFIG && LIVE_CONFIG.localModels
&& LIVE_CONFIG.localModels.managed && LIVE_CONFIG.localModels.managed.modelId);
- return '
Run mode
'
- + '
'
- + MODES.map(([id, label, why]) =>
- '').join('')
+ // ST-20 / ST-22: three selectable cards, the worker count as a segmented choice.
+ return '
'
+ + ''
+ esc('Workers: ' + workers + '. ')
+ esc(localReady
? 'The orchestrator uses the provider above; the workers use the local model.'
: 'Fusion needs a local model as well — choose one under Local, or the workers have nothing to run on.')
- + '
'
+ const daemon = llmFormatDaemon();
+ const route = llmRouteMode();
+ const ROUTE = {cloud:['cloud', 'Cloud'], local:['laptop', 'Local'], external:['server', 'External llama.cpp']};
+ /* The daemon chip only restates llmFormatDaemon's own word — running,
+ loading, starting, stopping, stopped, unreachable, unknown — in the
+ status colours. It never says more than `atag models status` did. */
+ const word = /^running/.test(daemon) ? 'running' : /^loading/.test(daemon) ? 'loading' : /unreachable$/.test(daemon) ? 'unreachable' : daemon;
+ const tone = word === 'running' ? ['tk-chip--green', 'tk-dot--green']
+ : word === 'loading' || word === 'starting' ? ['tk-chip--blue', 'tk-dot--brand tk-dot--pulse']
+ : word === 'stopping' ? ['tk-chip--amber', 'tk-dot--amber']
+ : word === 'unreachable' ? ['tk-chip--red', 'tk-dot--red'] : ['', 'tk-dot--hollow'];
+ /* Each line reads as the TUI line ("current: aimlapi / gpt-5.5",
+ "local daemon: stopped · mode managed") — the smoke compares those —
+ laid out as label and value: the colon is a zero-size glyph and the
+ marks are inline, with no text of their own. */
+ const kv = (label, sep, value) => '
';
}
function llmRowHTML(row, index, cursor) {
const selected = index === cursor;
- const mark = row.active ? '*' : selected ? '>' : ' ';
const extra = row.kind === 'localTextModel' && !row.model.downloaded ? ' data-pull-local="' + esc(row.model.id) + '"' : '';
- /* A row that carries the model's blurb and its fit is four lines tall,
- and `.tuirow` is a 24px single-line row with `overflow:hidden` — which
- clipped every added line to nothing ON SCREEN while `innerText` went on
- returning all four. `llm-model` unfixes the height. */
- return ''
- + esc(mark + ' ' + row.text) + ' · ' + esc(row.enterEffect) + ''
+ /* Soft Tactile list row (ST-20, ST-23). Its first line still reads as the
+ TUI row's text — "qwen-3.5-9b [downloaded] ★ best fit for this machine",
+ "aimlapi [aimlapi] key ok" — because model-picks.drive.mjs and the smoke
+ read it off innerText: brackets are zero-size glyphs and the chips on
+ that line are inline-block. A row that carries the model's blurb and its
+ fit keeps `.llm-model` and `.llm-sub` on those lines: the driver measures
+ the first one, because `.tuirow` was once a clipped 24px line. */
+ const open = '';
+ const radio = '';
+ // The action Enter (or a click) takes on this row, as the TUI words it.
+ const effect = ''
+ + (row.primaryAction === 'downloading' ? '' : '') + esc(row.enterEffect) + '';
+ const chip = (tone, html) => '' + html + '';
+ const bracket = (s) => llmSr('[') + esc(s) + llmSr(']');
+ if (row.kind === 'localTextModel' || row.kind === 'localEmbeddingModel') {
+ const m = row.model;
+ const chat = row.kind === 'localTextModel';
+ const t = '' + esc(m.id) + ''
+ + (chat ? '' : ' ' + esc(m.size) + '')
+ + ' ' + chip(m.downloaded ? 'green' : 'line', bracket(m.downloaded ? 'downloaded' : 'remote'))
+ + (row.text.indexOf('★ best fit for this machine') >= 0 ? ' ' + chip('indigo', '★ best fit for this machine') : '')
+ + (chat && m.tag ? ' ' + chip('blue', esc(m.tag)) : '');
// r7 models: what the model is, how it fits this machine, and the one
// caution it earns. Absent on a row that has no catalogue entry.
- + (row.sub ? '' + esc(row.sub) + '' : '')
- + (row.fitNote ? '' + esc(row.fitNote) + '' : '')
- + (row.caution ? '' + esc(row.caution) + '' : '')
- + '';
-}
-function llmSectionHTML(title, rows, offset, cursor, empty, emphasise) {
- return '
' + esc(empty || 'No rows in this section yet.') + '
')
+ + '';
}
function llmLocalHTML() {
const rows = llmLocalRows();
@@ -14291,10 +14435,11 @@ function llmLocalHTML() {
const emb = rows.filter((r) => r.kind === 'localEmbeddingModel');
const ram = hostRamGb();
// r7 models: the basis for the order and for every fit line below it.
- return '
' + esc(ram
- ? 'Ordered for this machine — it reports ' + ram + ' GB of RAM. Every fit line under a model is measured against that.'
- : 'Reading this machine’s RAM…') + '
' + esc(ram
+ ? 'Ordered for this machine — it reports ' + ram + ' GB of RAM. Every fit line under a model is measured against that.'
+ : 'Reading this machine’s RAM…') + '
',
+ actions: '' + logoHTML('huggingface', 'xs') + 'Add from Hugging Face'})
+ llmSectionHTML('Local embeddings', emb, text.length, cursor)
// Item 7B — the Ollama signpost. atomic-agent has no Ollama download
// path of any kind: `grep -rni ollama src/` finds provider presets, a
@@ -14303,7 +14448,7 @@ function llmLocalHTML() {
// capability this window invented. What does work — adding the running
// server as an OpenAI-compatible provider — is named first, with its
// exact route, because that is the question this line exists to answer.
- + '
'
- // The price facet needs the catalogue's pricing, which `atag models search --json` does not print: the facet stays at `all` and `p` is inert.
- + '
price: ' + esc(LLMP.pricing) + ' · p cycles free/paid/all
'
+ + '' + llmSr('filter: ') + ''
+ // The price facet needs the catalogue's pricing, which `atag models search --json` does not print: the facet stays at `all` and `p` is inert.
+ + ''
+ + esc('price: ' + LLMP.pricing) + llmSr(' · p cycles free/paid/all') + 'pricing is not exposed'
+ + '
'
+ + '
' + llmCloudModelListHTML() + '
'
+ llmSectionHTML('Cloud embeddings', emb, embOffset, cursor);
}
/* The 12-row window of the text-model rows around the cursor, with the TUI's counter line. */
@@ -14330,70 +14482,118 @@ function llmCloudModelListHTML() {
const start = Math.max(0, Math.min(cursorInSection - Math.floor(LLM_MODEL_WINDOW / 2), rows.length - LLM_MODEL_WINDOW));
const visible = rows.slice(start, start + LLM_MODEL_WINDOW);
const counter = rows.length === 0 ? 'no match' : (cursorInSection + 1) + '/' + rows.length + (rows.length !== section.models.length ? ' of ' + section.models.length : '');
- return (section.status === 'loading' ? '
fetching model list…
' : '')
- + (section.status === 'error' ? '
model list unavailable (' + esc(section.error || 'unknown error') + ') - showing current model only
Every configured provider is already in the chain.
')
+ + '
↑/↓ move · Enter add · Esc cancel
';
}
const rows = llmFallbackRows();
+ const declared = llmDeclaredChain(view.links);
+ /* ST-25: ordered rows with move buttons on the selected link (the moves
+ act on the selected link, as `<` and `>` do), the appended local last
+ resort locked, and the toggle as a switch. */
+ const move = (delta, can, icon, label) => '' + ic(icon) + '';
// llm-fallback-rows.tsx StatusLine shows the last `provider_switched` event of the TUI process; the serve API exposes no fallover events.
- return '
status: fallover events are not exposed by the agent\'s HTTP API
'
- + '
Fallback chain'
- + (view.links.length === 0 ? '
No chain configured. Falls back to the active provider only.
' : '')
- + rows.map((r, i) => {
+ return '
' + ic('info') + 'status: fallover events are not exposed by the agent\'s HTTP API
'
+ + '
Fallback chain
'
+ + (view.links.length === 0 ? '
No chain configured. Falls back to the active provider only.
' : '')
+ + (rows.length ? '
' + rows.map((r, i) => {
const sel = i === cursor;
- if (r.kind === 'add') return '' + (sel ? '>' : ' ') + ' + add link · Enter or a to choose a provider';
+ if (r.kind === 'add') return ''
+ + '' + ic('plus') + 'Add linkEnter or a to choose a provider';
const l = r.link;
const note = l.isActive ? 'active (primary)' : l.isAppendedLocal ? 'local last resort (appendLocal)' : 'fallover link';
- return '' + (sel ? '>' : ' ') + ' '
- + esc((r.index + 1) + '. ' + l.providerId + (l.modelLabel ? '/' + l.modelLabel : '') + ' [' + l.kind + ']') + ' · ' + esc(note) + '';
- }).join('') + '
'
- + '
append local as last resort: ' + (view.appendLocal ? 'on' : 'off') + ' · l to toggle
'
- + '
<> move priority · a add link · d remove · l toggle local
';
}
/* llm-panel-modals.tsx PromptBox copy, one at a time; a modal takes the whole pane as in the TUI. */
function llmModalHTML() {
const c = LLMP.confirm;
if (c) {
- const err = c.error ? '
! ' + esc(c.error) + '
' : '';
- const busy = c.submitting ? '
working…
' : '';
- if (c.kind === 'removeProvider') return '
'
+ (busy ? tuiHints([['esc cancel', 'llm:hf:cancel']])
: tuiHints([['enter look it up', 'llm:hf:look'], ['ctrl+l clear', 'llm:hf:clear'], ['esc back to the list', 'llm:hf:close']]))
- + '
';
+ + '';
}
function llmHfPickHTML() {
const repo = LLMHF.repo;
@@ -14496,21 +14706,26 @@ function llmHfPickHTML() {
// The fit check warns in one direction and nothing acts on it: weights
// bigger than RAM still start, because llama.cpp maps the file.
const warning = selected ? llmHfRamWarning(selected.fileSizeGb, LLMHF.ram) : null;
- return '
';
+ + '';
}
/* huggingface-fit.ts ramWarningFor, ported so the sentence matches the TUI's. */
function llmHfRamWarning(fileSizeGb, hostRamGb) {
@@ -14521,9 +14736,11 @@ function llmHfRamWarning(fileSizeGb, hostRamGb) {
/* llm-panel.tsx DownloadBanner: the CLI streams lines, not a byte count, so the last line stands where the TUI draws its bar. */
function llmDownloadBannerHTML() {
const p = LLMP.pulling;
- return '
')
+ tuiHints([['Esc back', 'llm:back'], ['r refresh', 'llm:logsRefresh']]);
}
function llmFormatBytes(n) { if (n < 1024) return n + ' B'; if (n < 1024 * 1024) return (n / 1024).toFixed(1) + ' KB'; return (n / (1024 * 1024)).toFixed(1) + ' MB'; }
diff --git a/desktop/test/model-picks.drive.mjs b/desktop/test/model-picks.drive.mjs
index b6fa53e9..43d4fde3 100644
--- a/desktop/test/model-picks.drive.mjs
+++ b/desktop/test/model-picks.drive.mjs
@@ -221,6 +221,15 @@ try {
await app.clickSel('.sb-settings');
await app.clickText('LLM', { scope: '#settings' });
await sleep(2000);
+ /* The pane the tab opens on follows the chat route, and the route is
+ only known once the tab's first refresh lands (llmTabEntered arms
+ the sync, llmRefreshRun spends it). Read the strip before that and
+ it still shows the default, Local — then flips to Cloud under the
+ rows this leg is waiting for. Wait for the refresh; looking only. */
+ const early = await app.eval("(document.querySelector('#settings .llmmode.on') || {}).innerText || ''");
+ await app.waitFor('window.__llmPane && window.__llmPane().refreshed !== null', 'the LLM tab’s first refresh', { timeout: 90000, quiet: true });
+ await sleep(500);
+ say(` mode strip 2 s after opening: ${JSON.stringify(early.trim())}; after the first refresh: ${JSON.stringify((await app.eval("(document.querySelector('#settings .llmmode.on') || {}).innerText || ''")).trim())}`);
}
pane = await app.eval("(document.querySelector('#settings .llmmode.on') || {}).innerText || ''");
/* This state directory routes at a cloud provider, so the LLM tab