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 + '
' + + '
'; +} +/* 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.
' : '') + (mode === 'local' && LLMP.pulling ? llmDownloadBannerHTML() : '') + + (LLMP.msg ? (LLMP.msg.restart ? restartLine(LLMP.msg.text) : llmMsgHTML(LLMP.msg.text)) : '') + llmRouteCardHTML() + llmRunModeHTML() - + '
Mode: ' + LLM_PANEL_MODES.map((m, i) => (i ? ' | ' : '') - + '').join('') + '
' - + '
Press ←/→ to switch mode
' - + '
' + esc(llmStatusLine()) + '
' - + (LLMP.msg ? (LLMP.msg.restart ? restartLine(LLMP.msg.text) : '
' + esc(LLMP.msg.text) + '
') : '') + (mode === 'fallback' ? llmFallbackHTML() : mode === 'cloud' ? llmCloudHTML() : mode === 'external' ? llmExternalHTML() : llmLocalHTML()) - + hint + '
'; + + 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 '
' + ic(tone === 'ok' ? 'check' : 'alert') + + '' + esc(status) + '
'; +} +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 '
' + + '
' + llmSr('Mode: ') + + '' + + LLM_PANEL_MODES.map((m, i) => (i ? llmSr(' | ') : '') + + '').join('') + + '
' + + (tone === 'ready' || tone === 'busy' + ? '' + (tone === 'busy' ? '' : '') + + '' + esc(status) + '' : '') + + '' + + '' + + '' + + '
'; +} +/* 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 '
' + ic(bad ? 'alert' : 'info') + '' + esc(text) + '
'; +} +/* 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 '
Run mode
' + + '
' + + MODES.map(([id, label, why, icon]) => { + const on = id === mode; + return ''; + }).join('') + '
' + (mode === 'fusion' - ? '
' + ? '
' + + '' + 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.') - + '
' - + '
' + + '' + + '' + [1, 2, 3, 4, 6, 8].map((n) => - '').join('') - + '
' - : ''); + + '
' + : '') + + '
'; } function llmRouteCardHTML() { @@ -14226,11 +14296,33 @@ function llmRouteCardHTML() { const emb = llmProvider(llmActiveEmbId()); const lm = llmLocalModels(); const mode = lm.mode || 'external'; - return '
Active chat route' - + '
current: ' + esc(active ? active.id : 'unknown') + '' + (model ? ' / ' + esc(model) + '' : '') + '
' - + '
tools ' + (local ? 'grammar' : 'native_tools') + ' · cache ' + (local ? 'local slot/cache_prompt' : 'cloud: no slot affinity') + '
' - + '
provider embeddings: ' + (emb ? esc(emb.id) + (emb.defaultEmbeddingModel ? ' · ' + esc(emb.defaultEmbeddingModel) : '') : 'not configured') + '
' - + '
local daemon: ' + esc(llmFormatDaemon()) + ' · mode ' + esc(mode) + (mode === 'external' ? ' · ' + esc(lm.url || '') : '') + '
' + 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) => '
' + esc(label) + '' + llmSr(sep) + value + '
'; + return '
' + + '
Active chat route' + + (route ? '' + ic(ROUTE[route][0]) + esc(ROUTE[route][1]) + '' : '') + + '' + + '' + esc('local daemon ' + word) + '' + + '
' + + kv('current', ': ', (active ? llmProviderMark(active, 'xs') : '') + '' + esc(active ? active.id : 'unknown') + '' + + (model ? ' / ' + modelMark(model, 'xs') + '' + esc(model) + '' : '')) + + kv('tools', ' ', '' + (local ? 'grammar' : 'native_tools') + ' · cache ' + (local ? 'local slot/cache_prompt' : 'cloud: no slot affinity') + '') + + kv('provider embeddings', ': ', '' + (emb ? esc(emb.id) + (emb.defaultEmbeddingModel ? ' · ' + esc(emb.defaultEmbeddingModel) : '') : 'not configured') + '') + + kv('local daemon', ': ', '' + esc(daemon) + ' · mode ' + esc(mode) + (mode === 'external' ? ' · ' + esc(lm.url || '') : '') + '') + '
'; } /* llm-panel.tsx StatusLines: one line, the first that applies, else "status: ready". */ @@ -14251,38 +14343,90 @@ function llmStatusLine() { function llmReport(line, source) { LLMP.statusLine = line; LLMP.statusSource = source || 'cloud'; } /* llm-panel.tsx footerHint (the full form), each key a button. */ function llmFooterHint(mode) { - if (mode === 'fallback') return tuiHints(['j/k move', ['< > reorder', 'llm:fb:move:1'], ['a add link', 'llm:fb:add'], ['d remove', 'llm:fb:remove'], ['l toggle local', 'llm:fb:local'], ['←/→ switch pane', 'llm:mode:next'], ['r refresh', 'llm:refresh']]); - if (mode === 'local') return tuiHints(['j/k move', ['Enter selected action', 'llm:enter'], + const lead = '
Press ←/→ to switch mode'; + if (mode === 'fallback') return lead + tuiHints(['j/k move', ['< > reorder', 'llm:fb:move:1'], ['a add link', 'llm:fb:add'], ['d remove', 'llm:fb:remove'], ['l toggle local', 'llm:fb:local'], ['←/→ switch pane', 'llm:mode:next'], ['r refresh', 'llm:refresh']]) + '
'; + if (mode === 'local') return lead + tuiHints(['j/k move', ['Enter selected action', 'llm:enter'], // Item 7A — llm-panel-key-bindings.ts `a`: the reference is parsed and the repo listed in the // main process (desktop/main/huggingface.ts, a port of the agent's own huggingface-* modules), // the choice is written into localModels.customModels, and the download is `atag models pull`. ['a add from hugging face', 'llm:hf'], ['←/→ switch Local/Cloud/External/Fallback', 'llm:mode:next'], ['s start/stop', 'llm:daemon'], ['r refresh', 'llm:refresh'], - ['E embeddings on/off', 'llm:embToggle'], ['d remove', 'llm:remove'], ['B backend update', 'llm:backend'], ['U auto-update', 'llm:autoUpdate'], ['G device', 'llm:device'], ['L LLM logs', 'llm:logs']]); - return tuiHints(['j/k move', ['Enter selected action', 'llm:enter'], ['←/→ switch Local/Cloud/External/Fallback', 'llm:mode:next'], ['f filter', 'llm:filter'], - ['n add provider', 'llm:add'], ['c configure', 'llm:configure'], ['r refresh', 'llm:refresh'], ['e embedding', 'llm:embedding'], ['d remove', 'llm:remove'], ['L LLM logs', 'llm:logs']]); + ['E embeddings on/off', 'llm:embToggle'], ['d remove', 'llm:remove'], ['B backend update', 'llm:backend'], ['U auto-update', 'llm:autoUpdate'], ['G device', 'llm:device'], ['L LLM logs', 'llm:logs']]) + '
'; + return lead + tuiHints(['j/k move', ['Enter selected action', 'llm:enter'], ['←/→ switch Local/Cloud/External/Fallback', 'llm:mode:next'], ['f filter', 'llm:filter'], + ['n add provider', 'llm:add'], ['c configure', 'llm:configure'], ['r refresh', 'llm:refresh'], ['e embedding', 'llm:embedding'], ['d remove', 'llm:remove'], ['L LLM logs', 'llm:logs']]) + '
'; } 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 ''; -} -function llmSectionHTML(title, rows, offset, cursor, empty, emphasise) { - return '
' + esc(title) + '' - + (rows.length ? rows.map((r, i) => llmRowHTML(r, offset + i, cursor)).join('') - : '
' + esc(empty || 'No rows in this section yet.') + '
') + '
'; + return open + radio + modelMark(m.id, '') + + '' + t + '' + + (row.sub ? '' + esc(row.sub + (m.size ? ' · ' + m.size : '')) + '' : '') + + (row.fitNote ? '' + esc(row.fitNote) + '' : '') + + (row.caution ? '' + esc(row.caution) + '' : '') + + '' + effect + ''; + } + if (row.kind === 'cloudProvider') { + const p = row.provider; + const head = p.id + ' [' + p.kind + '] '; + const auth = row.text.indexOf(head) === 0 ? row.text.slice(head.length) : ''; + // Until the key names are read every row says "missing key"; it is drawn neutral until they are. + const tone = !llmKeysKnown() ? 'line' : auth === 'key ok' ? 'green' : auth === 'missing key' ? 'red' : 'line'; + const t = '' + esc(p.id) + '' + + ' ' + bracket(p.kind) + '' + + ' ' + chip(tone, esc(auth)) + // Saved without a key check (the wizard's Save unchecked) — the same flag the composer's provider list shows. + + (UNVERIFIED.indexOf(p.id) >= 0 ? ' ' + chip('amber', 'Unverified') : ''); + return open + radio + llmProviderMark(p, 'sm') + '' + t + '' + effect + ''; + } + if (row.kind === 'cloudChatModel' || row.kind === 'cloudEmbeddingModel') { + return open + radio + modelMark(row.modelId, 'sm') + + '' + esc(row.providerId + '/' + row.modelId) + ' ' + + chip('line', bracket(row.kind === 'cloudChatModel' ? 'text' : 'embedding')) + '' + + effect + ''; + } + if (row.kind === 'externalUrl') { + const m = /\[([^\]]*)\]$/.exec(row.text); + const status = m ? m[1] : ''; + const tone = /^healthy/.test(status) ? 'green' : status === 'unreachable' ? 'amber' : 'line'; + return open + '' + ic('server') + '' + + 'base URL ' + esc(row.url) + ' ' + chip(tone, bracket(status)) + '' + + '' + esc(row.enterEffect) + ''; + } + return open + '' + esc(row.text) + '' + effect + ''; +} +function llmSectionHTML(title, rows, offset, cursor, empty, emphasise, extra) { + const x = extra || {}; + return '
' + + '
' + esc(title) + '' + (x.actions || '') + '
' + + (x.note || '') + + (rows.length ? '
' + rows.map((r, i) => llmRowHTML(r, offset + i, cursor)).join('') + '
' + : '
' + 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…') + '
' - + llmSectionHTML('Local text models', text, 0, cursor) + return llmSectionHTML('Local text models', text, 0, cursor, null, false, { + note: '

' + 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: ''}) + 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. - + '
' + esc(OLLAMA_SIGNPOST) + '
'; + + '
' + logoHTML('ollama', 'sm') + '' + esc(OLLAMA_SIGNPOST) + '
'; } function llmCloudHTML() { const rows = llmCloudRows(); @@ -14312,13 +14457,20 @@ function llmCloudHTML() { const emb = rows.filter((r) => r.kind === 'cloudEmbeddingModel'); const section = llmCloudSection(); const embOffset = providers.length + section.filtered.length; - return llmSectionHTML('Cloud providers', providers, 0, cursor, 'No cloud providers configured. Press n to add one.', true) - + '
Cloud text models' - + '
provider: ' + esc(section.provider ? section.provider.id : 'none') + '
' - + '
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. - + '
price: ' + esc(LLMP.pricing) + ' ·
' - + '
' + llmCloudModelListHTML() + '
' + const sp = section.provider; + return llmSectionHTML('Cloud providers', providers, 0, cursor, 'No cloud providers configured. Press n to add one.', true, + {actions: ''}) + + '
' + + '
Cloud text models' + + 'provider: ' + (sp ? llmProviderMark(sp, 'xs') : '') + '' + esc(sp ? sp.id : 'none') + '
' + + '
' + + '' + 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. + + '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
' : '') - + visible.map((r, i) => llmRowHTML(r, section.sectionStart + start + i, cursor)).join('') - + '
↑/↓ move (' + esc(counter) + ')' + (LLMP.filterFocused ? ' · type to filter · Enter select · Esc done' : '') + '
'; + return (section.status === 'loading' ? '
fetching model list…
' : '') + + (section.status === 'error' ? '
' + ic('alert') + 'model list unavailable (' + esc(section.error || 'unknown error') + ') - showing current model only
' : '') + + (visible.length ? '
' + visible.map((r, i) => llmRowHTML(r, section.sectionStart + start + i, cursor)).join('') + '
' : '') + + '
↑/↓ move (' + esc(counter) + ')' + (LLMP.filterFocused ? ' · type to filter · Enter select · Esc done' : '') + '
'; } function llmExternalHTML() { const rows = llmExternalRows(); + const status = llmStatusLine(); return llmSectionHTML('External llama.cpp', rows, 0, LLMP.cursor.external) - + '
managed daemon: ' + esc(llmFormatDaemon()) + ' ·
' - + '
← : pick a managed model to switch back
'; + + llmStatusNoteHTML(status, llmStatusTone(status)) + + '
' + + '
' + ic('cpu') + '' + + 'managed daemon: ' + esc(llmFormatDaemon()) + '' + + '' + llmKeyBtn('s', 'start/stop', 'llm:daemon') + '
' + + '

← : pick a managed model to switch back

' + + '
'; } function llmFallbackHTML() { const view = llmFallbackView(); const cursor = LLMP.cursor.fallback; if (LLMP.fallbackPicker) { const c = LLMP.fallbackPicker.cursor; - return '
Add fallback link' - + (view.addableProviderIds.length ? view.addableProviderIds.map((id, i) => '').join('') - : '
Every configured provider is already in the chain.
') - + '
↑/↓ move · Enter add ·
'; + const ids = view.addableProviderIds; + return '
Add fallback link' + + '
' + + (ids.length ? '
' + ids.map((id, i) => { + const p = llmProvider(id); + return ''; + }).join('') + '
' + : '
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 ''; + if (r.kind === 'add') return ''; const l = r.link; const note = l.isActive ? 'active (primary)' : l.isAppendedLocal ? 'local last resort (appendLocal)' : 'fallover link'; - return ''; - }).join('') + '
' - + '
append local as last resort: ' + (view.appendLocal ? 'on' : 'off') + ' ·
' - + '
move priority · · ·
'; + const tone = l.isActive ? ' tk-chip--green' : l.isAppendedLocal ? ' tk-chip--indigo' : ''; + const pos = declared.indexOf(l.providerId); + const p = llmProvider(l.providerId); + return ''; + }).join('') + '
' : '') + + '
' + + '' + + 'append local as last resort: ' + (view.appendLocal ? 'on' : 'off') + '' + + '' + llmKeyBtn('l', 'to toggle', 'llm:fb: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 '
Remove provider ' + esc(c.id) + '?' + err + busy - + tuiHints([['y confirm', 'llm:confirm', {disabled:c.submitting}], ['n/Esc cancel', 'llm:cancel']]) + '
'; - if (c.kind === 'removeLocal') return '
Delete local model ' + esc(c.id) + '?' - + '
Removes GGUF/mmproj files. y confirm · n/Esc cancel
' + err + busy + tuiHints([['y confirm', 'llm:confirm', {disabled:c.submitting}], ['n/Esc cancel', 'llm:cancel']]) + '
'; - if (c.kind === 'removeEmbedding') return '
Delete local embedding model ' + esc(c.id) + '?' + const err = c.error ? '

! ' + esc(c.error) + '

' : ''; + const busy = c.submitting ? '

working…

' : ''; + const head = (title) => '
' + ic('trash') + '

' + esc(title) + '

'; + // ST-27: Cancel · N and the destructive fill · Y; the keys stay y / n / Esc. + const acts = (label, yes) => '
' + + '
'; + if (c.kind === 'removeProvider') return '
' + head('Remove provider ' + c.id + '?') + + err + busy + acts('Remove', {disabled:c.submitting}) + '
'; + if (c.kind === 'removeLocal') { + const m = (LLMP.local || []).find((x) => x.id === c.id); + return '
' + head('Delete local model ' + c.id + '?') + + '

Removes GGUF/mmproj files' + (m && m.size ? ' — ' + esc(m.size) + '' : '') + '.

' + + err + busy + acts('Delete', {disabled:c.submitting}) + '
'; + } + if (c.kind === 'removeEmbedding') return '
' + head('Delete local embedding model ' + c.id + '?') // `atag models remove` accepts chat catalogue ids only (runLocalModelsRemove isKnownLocalModelId), so the desktop cannot delete an embedding GGUF. - + '
y confirm · n/Esc cancel
(no CLI removes an embedding model on this agent — `atag models remove` accepts chat models only)
' + err - + tuiHints([['y confirm', 'llm:confirm', {disabled:true, title:'`atag models remove` accepts chat models only'}], ['n/Esc cancel', 'llm:cancel']]) + '
'; + + '

(no CLI removes an embedding model on this agent — `atag models remove` accepts chat models only)

' + err + + acts('Delete', {disabled:true, title:'`atag models remove` accepts chat models only'}) + '
'; } if (LLMP.externalDraft !== null) { - return '
External llama.cpp base URL' - + '
' - + (LLMP.externalInvalid ? '
invalid URL
' : '') - + '
Saved after a /health probe succeeds. ·
'; + return ''; } if (LLMP.steerUrl !== null) { const url = LLMP.steerUrl; const ollama = llmLooksLikeOllama(url); - return '
' + (ollama ? 'Ollama detected — add it as a cloud provider?' : 'OpenAI-compatible server — add it as a cloud provider?') + '' - + '
' + esc(url + ' answers like ' + (ollama ? 'Ollama' : 'an OpenAI-compatible server') + ', which the External llama.cpp route cannot drive.') + '
' - + tuiHints([['y open the provider wizard with this URL', 'llm:steer:y'], ['n/Esc dismiss', 'llm:steer:n']]) + '
'; + // ST-24: an amber prompt, because nothing failed — the server is just the other kind. + return '
' + + '
' + (ollama ? logoHTML('ollama', '') : '' + ic('info') + '') + + '

' + (ollama ? 'Ollama detected — add it as a cloud provider?' : 'OpenAI-compatible server — add it as a cloud provider?') + '

' + + '

' + esc(url) + '' + esc(' answers like ' + (ollama ? 'Ollama' : 'an OpenAI-compatible server') + ', which the External llama.cpp route cannot drive.') + '

' + + '
' + + '
'; } return ''; } @@ -14472,17 +14672,27 @@ async function llmHfAdd() { function llmHfHTML() { if (LLMHF.step === 'pick' && LLMHF.repo) return llmHfPickHTML(); const busy = LLMHF.busy; - return '
' - + '
Which model? ' + esc(HF_REF_TITLE_TAIL) + '
' - + '
' + esc(HF_REF_EXAMPLES_LINE) + '
' - + '
' - + (!busy && LLMHF.reference.length > 0 ? '
' : '') - + (busy ? '
asking huggingface.co…
' : '') - + (LLMHF.error ? '
' + esc(LLMHF.error) + '
' : '') + // ON-05's form, inside the pane: the Hugging Face field, then its buttons; the TUI's hint strip stays under it. + return '
' + + '
' + + '
' + logoHTML('huggingface', 'lg') + + '

Which model? ' + esc(HF_REF_TITLE_TAIL) + '

' + + '

' + esc(HF_REF_EXAMPLES_LINE) + '

' + + '
' + + (!busy && LLMHF.reference.length > 0 ? '' : '') + + '
' + + (busy ? '
asking huggingface.co…
' : '') + + (LLMHF.error ? '
' + ic('alert') + '' + esc(LLMHF.error) + '
' : '') + + '
' + + (busy ? '' + : '' + + '') + + '
' + + '
' + (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 '
' + esc(repo.repoId) + '' - + visible.map((c, i) => { + return '
' + + '
' + logoHTML('huggingface', 'sm') + '' + esc(repo.repoId) + '
' + + '
' + visible.map((c, i) => { const at = start + i; const active = at === cursor; - // hfChoiceLine, verbatim: `${active ? '› ' : ' '}${filename.padEnd(44)}${sizeLabel.padStart(9)}` - const line = (active ? '› ' : ' ') + String(c.filename).padEnd(44) + String(c.sizeLabel).padStart(9); - return ''; - }).join('') - + (below > 0 ? '
' + esc(' ↓ ' + below + ' more') + '
' : '') - + (repo.hidden ? '
' + esc(' ' + repo.hidden) + '
' : '') - + (repo.mmproj ? '
' + esc(HF_MMPROJ_LINE) + '
' : '') - + (warning ? '
' + esc(' ⚠ ' + warning) + '
' : '') - + (LLMHF.error ? '
' + esc(' ' + LLMHF.error) + '
' : '') + // hfChoiceLine's two columns, filename and size, now as a list row. + return ''; + }).join('') + '
' + + (below > 0 ? '

' + esc('↓ ' + below + ' more') + '

' : '') + + (repo.hidden ? '

' + esc(repo.hidden) + '

' : '') + + (repo.mmproj ? '

' + ic('image') + '' + esc(HF_MMPROJ_LINE.trim()) + '

' : '') + + (warning ? '
' + ic('alert') + '' + llmSr('⚠ ') + esc(warning) + '
' : '') + + (LLMHF.error ? '
' + ic('alert') + '' + esc(LLMHF.error) + '
' : '') + + '
' + + '
' + tuiHints(['j/k move', ['Enter download', 'llm:hf:add'], ['esc back', 'llm:hf:back']]) - + '
'; + + ''; } /* 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 '
downloading — ' + esc(p.id) + '
model: ' + esc(p.id) + '
' - + '
' + esc(LLMP.pullLog[LLMP.pullLog.length - 1] || 'starting…') + '
' - + tuiHints([['cancel', 'llm:cancelPull']]) + '
'; + // ST-21: a blue notice; the last CLI line keeps #llm-pull-line, which the pull subscriber writes into directly. + return '
' + ic('download') + '' + + 'downloading — ' + esc(p.id) + 'model: ' + esc(p.id) + '' + + '' + esc(LLMP.pullLog[LLMP.pullLog.length - 1] || 'starting…') + '' + + '
'; } /* local-llm-logs-panel.tsx: header (path or the waiting line), size · tail · last read, error, the last 30 lines coloured. */ function llmLogsHTML() { @@ -14531,12 +14748,17 @@ function llmLogsHTML() { const header = l && l.path ? l.path : '(waiting for the first daemon start — no log file yet)'; const lines = l ? l.text.split('\n').filter((x) => x.length > 0) : []; const tail = lines.slice(-LLM_LOG_LINES); - const color = (line) => { const lower = line.toLowerCase(); if (/\b(error|fatal|fail|abort)\b/.test(lower)) return 'tuierr'; if (/\b(warn|warning)\b/.test(lower)) return 'sk-off'; if (/\b(loading|loaded|ready|listening)\b/.test(lower)) return 'sk-on'; return ''; }; - return '
' + esc(header) + '
' - + (l && typeof l.size === 'number' ? '
' + esc(llmFormatBytes(l.size)) + (l.truncated ? ' · showing tail only' : '') + (l.lastReadAt ? ' · last read ' + new Date(l.lastReadAt).toLocaleTimeString() : '') + '
' : '') - + (l && l.error ? '
' + esc(l.error) + '
' : '') - + '
' + (tail.length === 0 ? '
' + (l && l.error ? '' : '(log is empty — start the daemon to see output)') + '
' - : tail.map((line) => '
' + esc(line) + '
').join('')) + '
' + const color = (line) => { const lower = line.toLowerCase(); if (/\b(error|fatal|fail|abort)\b/.test(lower)) return 'llm-log-err'; if (/\b(warn|warning)\b/.test(lower)) return 'llm-log-warn'; if (/\b(loading|loaded|ready|listening)\b/.test(lower)) return 'llm-log-ok'; return ''; }; + const meta = l && typeof l.size === 'number' + ? llmFormatBytes(l.size) + (l.truncated ? ' · showing tail only' : '') + (l.lastReadAt ? ' · last read ' + new Date(l.lastReadAt).toLocaleTimeString() : '') : ''; + // ST-26: back, the log path, size and read time, then the tail in a mono well — errors red, warnings amber, loading and ready in accent. + return '
' + + '' + esc(header) + '' + + (meta ? '' + esc(meta) + '' : '') + + '
' + + (l && l.error ? '
' + ic('alert') + '' + esc(l.error) + '
' : '') + + (tail.length === 0 ? '
' + (l && l.error ? '' : '(log is empty — start the daemon to see output)') + '
' + : '
' + tail.map((line) => '' + esc(line) + '').join('\n') + '
') + 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