From 9affa265bdad6888329676b473c6f3234179c0af Mon Sep 17 00:00:00 2001 From: Valera Brizhatiuk <19537764+plombeer31@users.noreply.github.com> Date: Tue, 15 Sep 2026 14:44:20 +0300 Subject: [PATCH] =?UTF-8?q?desktop:=20settings=20in=20Soft=20Tactile=20?= =?UTF-8?q?=E2=80=94=20frame,=20nav,=20Tasks,=20Skills,=20Privacy?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Manage window is redrawn from terminal text into a real window (ST-01…11, ST-33), with the same state, acts, keys and data calls. Frame: a 1060×700 well over the blurred scrim. The nav lists the eight Manage tabs: each menu row carries a .settab switcher with its count and chord keycap, and the other groups of the menu tree scroll under Commands. The body is a lifted card with a "Manage › Tab" crumb, the close button, the pane, and the Build / Agent / State data plate. The per-pane scroll restore is kept, and the nav's own scroll now survives a repaint too. Tasks: the filters as a segmented control, a search box, an auto-refresh readout (the only part the poll repaints), Refresh and New task, then a real table with status chips. The detail shows a data plate, the last error and Open session / Run now / Cancel task. The New task form has a kind segment, labelled fields, the next five firings beside them, and Create task disabled until the schedule parses. Cancelling a recurring task asks in an amber confirm over the card. Every hint is a keycap button. Skills: rows with an on/off switch, source chip, version and description, and the restart line after a toggle. The detail shows SKILL.md in a mono well and offers Remove. The Hub has its own search, result rows and an install card. The remove and security-scan confirms are danger modals over the view they came from. Privacy: the analytics switch row, session grants ("none active"), what is and is not sent, and the restart line. tuiBtn/tuiHints draw the leading key as a keycap; brackets and colons stay in the text at zero size, so labels read exactly as before. The TUI column headers and a few lowercase labels the smoke compares are kept as visually hidden text. tasksAct accepts filter: for the segments; the bare f key still cycles. __settingsStripRows counts the nav's single column. The 0.5.5 rules for Memory / MCP / Telegram / Import / LLM are removed from settings.css; the generic .tui* fallbacks are restyled. --- desktop/renderer/css/settings.css | 486 ++++++++++++++-------- desktop/renderer/renderer.js | 655 ++++++++++++++++++++---------- 2 files changed, 748 insertions(+), 393 deletions(-) diff --git a/desktop/renderer/css/settings.css b/desktop/renderer/css/settings.css index ee426fca..5a15b884 100644 --- a/desktop/renderer/css/settings.css +++ b/desktop/renderer/css/settings.css @@ -1,143 +1,26 @@ /* ============================================================ - Settings “Menu › Manage” — window frame and nav, Tasks, Skills, Memory, MCP, LLM, Telegram, Import, Privacy. Screens ST-01…33. - Loaded after styles.css (tokens + kit). Colours come from tokens only. - ============================================================ */ + Settings "Manage" — the window frame and nav for every tab, and the + Tasks, Skills and Privacy tabs. Screens ST-01…11, ST-33. + Memory / MCP / Telegram / Import live in css/settings-data.css and LLM in + css/settings-llm.css; both load after this file and draw inside the frame + defined here. Colours come from tokens only. -/* settings window */ -#settings{position:absolute;inset:0;background:var(--scrim);z-index:28;display:flex;align-items:center;justify-content:center} -.setwin{width:720px;max-width:calc(100% - 40px);height:560px;max-height:calc(100% - 40px);background:var(--bg-content);border-radius:0;box-shadow:var(--shadow-window);display:flex;flex-direction:column;overflow:hidden} -.settb{height:44px;flex:none;display:flex;align-items:center;gap:var(--s3);padding:0 var(--s4);background:var(--bg-window);border-bottom:1px solid var(--line)} -.settabs{display:flex;gap:2px;margin-left:var(--s5)} -.settab{height:30px;padding:0 10px;border-radius:0;display:flex;align-items:center;gap:5px;color:var(--text-secondary);font-size:13px} -.settab:hover{background:var(--bg-hover);color:var(--text-primary)} -.settab.on{background:var(--accent-wash);color:var(--accent-text);font-weight:500} -.setbody{flex:1;min-height:0;overflow-y:auto;padding:var(--s6)} - -/* ladder */ -.stepper{display:flex;align-items:center;margin:var(--s5) 0 var(--s6)} -.stopwrap{flex:1;display:flex;flex-direction:column;align-items:center;gap:var(--s2);position:relative} -.stopwrap::before{content:"";position:absolute;top:9px;left:-50%;width:100%;height:2px;background:var(--line)} -.stopwrap:first-child::before{display:none} -.stopwrap.filled::before{background:var(--accent)} -.stopdot{width:20px;height:20px;border-radius:0;border:2px solid var(--line);background:var(--bg-content);z-index:1;display:flex;align-items:center;justify-content:center} -.stopwrap.filled .stopdot{border-color:var(--accent);background:var(--accent)} -.stopwrap.on .stopdot{box-shadow:0 0 0 4px var(--accent-wash)} -.stoplb{font-size:11px;color:var(--text-secondary);text-align:center} -.stopwrap.on .stoplb{color:var(--text-primary);font-weight:500} -.card-h{height:32px;display:flex;align-items:center;gap:var(--s3);padding:0 var(--s4);box-shadow:inset 0 -1px 0 var(--line-soft);font-size:11px;color:var(--text-secondary)} - -/* ---------------- Item 7: settings surface (the TUI menu + Manage tabs) ---------------- */ -.setwin{width:900px;height:640px} -.settb .setttl{font-size:12px;font-weight:600;letter-spacing:.06em;text-transform:uppercase;color:var(--text-secondary);margin-left:var(--s3)} -.setcols{flex:1;min-height:0;display:flex} -.setmenu{width:236px;flex:none;overflow-y:auto;border-right:1px solid var(--line);padding:var(--s2) 0 var(--s4);font-family:var(--font-mono);font-size:12px;background:var(--bg-window)} -.menuhd{padding:10px 12px 2px;font-size:10px;letter-spacing:.06em;text-transform:uppercase;color:var(--text-tertiary)} -.menurow{display:flex;align-items:center;gap:6px;width:100%;height:24px;padding:0 12px;color:var(--text-primary);text-align:left;font-family:inherit;font-size:inherit} -.menurow.sub{padding-left:26px} -.menurow.parent{color:var(--text-secondary)} -button.menurow:hover{background:var(--bg-hover)} -.menurow.on{background:var(--accent-wash);color:var(--accent-text)} -.menurow.na{color:var(--text-tertiary);cursor:default} -.menurow .lb{flex:1;min-width:0;white-space:nowrap;overflow:hidden;text-overflow:ellipsis} -.menurow .ch{font-size:10px;color:var(--text-tertiary);white-space:nowrap} -.menurow .note{font-size:10px;color:var(--text-tertiary);white-space:nowrap} -.setmain{flex:1;min-width:0;display:flex;flex-direction:column} -.setmain .settabs{margin:0;padding:8px 10px 0;flex-wrap:nowrap;overflow-x:auto;scrollbar-width:thin;align-items:center;gap:0} -.setmain .settab{flex:none;font-family:var(--font-mono);font-size:12px;padding:0 2px;height:24px;gap:0;white-space:pre} -.setmain .settab::before{content:" ";white-space:pre} -.setmain .settab.on::before{content:"▸ "} -.tabsep{flex:none;white-space:pre;color:var(--text-tertiary);font-family:var(--font-mono);font-size:12px} -.setdiag{padding:4px 16px 8px;font-family:var(--font-mono);font-size:11px;color:var(--text-tertiary);white-space:pre-wrap;border-bottom:1px solid var(--line-soft)} -.tui{font-family:var(--font-mono);font-size:12.5px;line-height:20px;white-space:pre-wrap;display:flex;flex-direction:column;gap:2px;color:var(--text-primary)} -.tui b{color:var(--accent-text);font-weight:600} -.tuibar{white-space:pre} -.tuihead{color:var(--text-tertiary);white-space:pre;margin-top:6px} -.tuirow{display:block;height:24px;line-height:24px;width:100%;text-align:left;white-space:pre;overflow:hidden;text-overflow:ellipsis;font-family:inherit;font-size:inherit;padding:0 4px;border-radius:0;color:var(--text-primary)} -.tuirow:hover{background:var(--bg-hover)} -.tuirow.on{background:var(--accent-wash);color:var(--accent-text)} -.tuihint{color:var(--text-tertiary);display:flex;flex-wrap:wrap;gap:0 4px;align-items:center;margin-top:8px;white-space:pre} -.tuihint button,.tkkind{color:var(--text-secondary);font-family:inherit;font-size:inherit;padding:0 4px;border-radius:0;height:22px} -.tuihint button:hover,.tkkind:hover{background:var(--bg-hover);color:var(--text-primary)} -.tuihint button:disabled{color:var(--text-tertiary);background:transparent} -.tkkind.on{color:var(--accent-text);font-weight:600} -.tuimodal{border:1px solid var(--accent);border-radius:0;padding:8px 12px;margin:6px 0;display:flex;flex-direction:column;gap:2px} -.tuimodal.warn{border-color:var(--warn)} -.tkrow{display:flex;align-items:center;height:26px} -.tkrow .mk{width:1ch;white-space:pre;color:var(--accent-text)} -.tkrow .mk::before{content:" "} -.tkrow:focus-within .mk::before{content:"▸"} -.tkrow .lb{white-space:pre;color:var(--text-tertiary)} -.tkrow input{flex:1;min-width:0;background:transparent;border:0;outline:none;font:inherit;color:var(--accent-text);border-bottom:1px solid var(--line-soft)} -.tkrow input:focus{border-bottom-color:var(--accent)} -.tkrow input::placeholder{font-style:italic;color:var(--text-tertiary)} -#tk-search{background:transparent;border:0;border-bottom:1px solid var(--line-soft);outline:none;font:inherit;color:var(--accent-text);width:160px} -.st-running{color:var(--accent-text)} .st-completed{color:var(--success)} .st-failed,.st-blocked{color:var(--danger)} .st-cancelled{color:var(--warn)} .st-pending{color:var(--text-tertiary)} -.tuimsg{color:var(--accent-text)} .tuierr{color:var(--danger)} -.sk-on{color:var(--success)} .sk-off{color:var(--warn)} -.tuiwrap{padding:var(--s5)} -/* Item 7 (settings surface): the TUI's `↑ N above` / `↓ N below` window lines, clickable here. */ -.tuimore{display:block;color:var(--text-tertiary);font-family:inherit;font-size:inherit;padding:0 4px;height:22px;line-height:22px;text-align:left;border-radius:0;white-space:pre} -.tuimore:hover{background:var(--bg-hover);color:var(--text-primary)} -/* ---------------- Item 7 part B: the Skills, Memory and MCP tabs ---------------- */ -.tuirow.dim{color:var(--text-tertiary)} -.tuirow2{height:auto;line-height:20px;padding:2px 4px;white-space:pre} -.tuirow2 > span{display:block;white-space:pre;overflow:hidden;text-overflow:ellipsis} -.tuibody{white-space:pre-wrap;word-break:break-word;color:var(--text-primary)} -.tuibody > div{min-height:20px} -.skpf,.memch{color:var(--text-secondary);font-family:inherit;font-size:inherit;padding:0 2px;border-radius:0;height:22px;white-space:pre} -.skpf:hover,.memch:hover{background:var(--bg-hover);color:var(--text-primary)} -.skpf.on,.memch.on{color:var(--accent-text);font-weight:600} -.skpaccent{color:var(--accent-text)} -.skphub{display:block;width:100%;text-align:left;font-family:inherit;font-size:inherit;color:var(--text-primary);cursor:pointer} -.skphub:hover{background:var(--bg-hover)} -.tuimodal.danger{border-color:var(--danger)} -#skp-hubq{background:transparent;border:0;border-bottom:1px solid var(--line-soft);outline:none;font:inherit;color:var(--accent-text);width:220px} -.tuiarea{display:block;width:100%;box-sizing:border-box;margin-top:8px;background:var(--bg-sunken);border:1px solid var(--line);border-radius:0;padding:6px 8px;font:inherit;color:var(--text-primary);resize:vertical;outline:none;white-space:pre} -.tuiarea:focus{border-color:var(--accent)} -.tuiarea::placeholder{color:var(--text-tertiary)} -/* ---------------- Item 7 part C: the LLM, Telegram and Import tabs ---------------- */ -/* The add-provider wizard (the composer's selector popup) floats above the settings window while it is open. */ -#window:has(#settings) #overlays{z-index:29} -.llm-route{display:flex;flex-direction:column;gap:2px;margin-bottom:8px} -.llm-modehead{white-space:pre} -.llmmode{color:var(--text-tertiary);font-family:inherit;font-size:inherit;font-weight:600;padding:0 2px;border-radius:0;height:22px} -.llmmode:hover{background:var(--bg-hover);color:var(--text-primary)} -.llmmode.on{color:var(--accent-text)} -.llm-section{display:flex;flex-direction:column;gap:2px;margin-bottom:8px} -.llm-empty{font-weight:600;color:var(--text-primary);white-space:pre} -#llm-filter,#llm-url{background:transparent;border:0;border-bottom:1px solid var(--line-soft);outline:none;font:inherit;color:var(--accent-text);width:260px} -#llm-filter:focus,#llm-url:focus{border-bottom-color:var(--accent)} -#llm-filter::placeholder{color:var(--text-tertiary);font-style:normal} -#llm-url{width:360px} -.llm-start{border-color:var(--success)} -.tgcard{margin-top:6px} -#tg-token{background:transparent;border:0;border-bottom:1px solid var(--line-soft);outline:none;font:inherit;color:var(--accent-text);width:320px;letter-spacing:2px} -#tg-token:focus{border-bottom-color:var(--accent)} -.impform{margin-top:8px} -.impline{display:flex;align-items:center;min-height:24px;white-space:pre} -.impinp{flex:0 1 420px;min-width:0;background:transparent;border:0;border-bottom:1px solid var(--line-soft);outline:none;font:inherit;color:var(--accent-text)} -.impinp:focus{border-bottom-color:var(--accent)} -.impinp::placeholder{font-style:italic;color:var(--text-tertiary)} -.improw{white-space:pre} - -/* ---- Item 7A: the Hugging Face branch in Settings › LLM › Local, and - the Ollama signpost under the model lists ---- */ -.llm-hf input{width:100%;max-width:520px;height:26px;padding:0 6px;border:1px solid var(--line);border-radius:0; - background:var(--bg-sunken);color:var(--text-primary);font-family:var(--font-mono);font-size:12px} -.llm-hf input:disabled{color:var(--text-tertiary)} -.llm-hf .tuierr{white-space:pre-wrap;max-width:72ch;margin-top:4px} -.llm-ollama{margin-top:10px;padding-top:8px;border-top:1px solid var(--line-soft);max-width:72ch;line-height:1.5} - -/* ---- r4 review fixes (hf-models) ------------------------------------------ - The Hugging Face pick list is a port of the TUI's screen and carries its - leading indentation verbatim: HF_MMPROJ_LINE, the `↓ N more` line, the - hidden-files note and the RAM warning all open with three spaces that mark - them as annotations of the list rather than list items. `.tuirow` is - white-space:pre so the choice rows keep their padEnd/padStart columns, but - the four sub-lines beneath them were plain HTML and collapsed to one space. - `pre-wrap`, not `pre`: a long error must still wrap inside the pane. */ -.llm-hf .ter,.llm-hf .sk-off,.llm-hf .tuierr{white-space:pre-wrap} + Frame contract a tab can rely on: + #settings > .setwin > (nav.setnav > .setmenu) + .setmain + .setmain — the floating body card, position:relative (overlays anchor here) + .settb — crumb "Manage › Tab" + close + .setbody — the scrolling pane, 24px side padding (paneBox() repaints it) + .setplate — Build · Agent · State data plate + .set-pane — a tab's root: a column with 14px gaps + .set-toolbar > .set-tbrow — the tab's toolbar rows (filters · search · readout · actions) + .set-overlay > .tk-modal.set-modal — a confirm over the whole body card + .set-sr — text kept for assistive tech and text checks, not drawn + .set-z — characters kept in the text at zero size (brackets, colons) + ============================================================ */ +/* ---------------- Window ---------------- */ +#settings{position:absolute;inset:0;z-index:28;display:flex;align-items:center;justify-content:center; + background:var(--scrim);-webkit-backdrop-filter:blur(3px);backdrop-filter:blur(3px)} /* Item 5. The settings backdrop covers the 52px toolbar band, and `body.electron #toolbar` is a `-webkit-app-region: drag` region. Chromium unions the rects of elements that DECLARE a value and subtracts only those @@ -145,47 +28,292 @@ button.menurow:hover{background:var(--bg-hover)} contributes nothing and does not mask the region beneath it. So a real mousedown in the top band of the backdrop started a window drag and never produced a click, and the dismiss-on-outside-click branch could not fire - there. (The synthetic MouseEvents the suite dispatches bypass drag-region - hit testing entirely, which is why the checks stayed green over a dead - band.) #onboarding already applies this same pattern deliberately. */ + there. The smoke looks this rule up by its exact text — keep it verbatim. */ body.electron #settings{-webkit-app-region:no-drag} +/* The add-provider wizard (the composer's selector popup) floats above the settings window while it is open. */ +#window:has(#settings) #overlays{z-index:29} + +.setwin{position:relative;width:min(1060px, calc(100% - 48px));height:min(700px, calc(100% - 48px)); + display:grid;grid-template-columns:230px minmax(0,1fr); + background:var(--well);border-radius:var(--r-comp);box-shadow:var(--sh-pop);overflow:hidden} + +/* ---------------- Nav ---------------- */ +.setnav{min-width:0;min-height:0;display:flex;flex-direction:column;padding:14px 10px 12px} +.setmenu{flex:1;min-height:0;display:flex;flex-direction:column;gap:1px} +.setmenu .setnav-title{margin:4px 10px 10px;font:700 15px/20px var(--font-ui);letter-spacing:-.02em;color:var(--ink)} + +.menurow{position:relative;display:flex;align-items:center;gap:9px;width:100%;min-height:34px;padding:0 8px 0 10px;flex:none; + border:0;border-radius:var(--r-pill);background:transparent;color:var(--ink-2); + font:500 13.5px/1.2 var(--font-ui);letter-spacing:-.003em;text-align:left; + transition:background var(--t-state) var(--ease),color var(--t-state) var(--ease)} +.menurow > svg{flex:none;color:var(--ink-3)} +button.menurow{cursor:pointer} +button.menurow:hover{background:var(--hover);color:var(--ink)} +button.menurow:hover > svg{color:var(--ink-2)} +.menurow.on{background:var(--brand-wash);color:var(--blue)} +.menurow.on > svg{color:var(--blue)} +.menurow:focus-visible{outline:2px solid var(--brand);outline-offset:-2px} +.menurow .lb{flex:1;min-width:0;white-space:nowrap;overflow:hidden;text-overflow:ellipsis} +.menurow .ch{flex:none;display:inline-flex;align-items:center} +.menurow.on .ch .kc{color:var(--blue);border-color:color-mix(in srgb, var(--blue) 30%, transparent)} +/* The tab switcher inside a Manage row: label · count. */ +.settab{flex:1;min-width:0;align-self:stretch;display:flex;align-items:center;gap:8px} +.setcount{flex:none;font:400 11.5px/1 var(--font-mono);color:var(--ink-2);font-variant-numeric:tabular-nums} +.menurow.on .setcount{color:var(--blue)} +/* Parent / sub rows: none in the tree today, kept for the next node pulled across from the TUI registry. */ +.menurow.sub{padding-left:26px} +.menurow.parent{cursor:default} +/* Nodes the desktop cannot do keep their label, muted, with the note under it. */ +.menurow.na{align-items:flex-start;min-height:30px;padding:6px 10px;color:var(--ink-3);cursor:default} +.menurow.na > svg{margin-top:1px} +.menurow .lbw{flex:1;min-width:0;display:flex;flex-direction:column;gap:2px} +.menurow .note{font-size:11.5px;line-height:1.35;font-weight:400;color:var(--ink-2);white-space:normal} + +/* Commands: every other group of the menu tree, scrolling on its own. */ +.setnav-cmds{flex:1;min-height:0;overflow-y:auto;margin:12px -6px 0;padding:0 6px 6px; + display:flex;flex-direction:column;gap:1px;scrollbar-width:thin} +.setnav-h{margin:0 12px 2px;font:700 12px/16px var(--font-ui);color:var(--ink-2)} +.setnav-cmds .menuhd{margin:10px 12px 2px;font:500 11px/14px var(--font-ui);color:var(--ink-2);letter-spacing:0;text-transform:none} +.setnav-cmds .menurow{min-height:30px;font-size:13px;gap:8px} +.setnav-cmds .menurow > svg{width:14px;height:14px} +.setnav-foot{flex:none;display:flex;flex-direction:column;gap:2px;padding-top:8px} +.setnav-foot .menurow.na{font-size:12.5px} +.setnav-hint{display:flex;align-items:center;gap:6px;padding:4px 12px 0;font-size:12px;color:var(--ink-2)} + +/* ---------------- Body card ---------------- */ +.setmain{position:relative;min-width:0;min-height:0;margin:8px 8px 8px 0;display:flex;flex-direction:column; + background:var(--lift);border-radius:var(--r-card);box-shadow:var(--sh-card);overflow:hidden} +.settb{flex:none;height:62px;display:flex;align-items:center;gap:10px;padding:0 14px 0 24px} +.settb .grow{flex:1} +.settb .tk-crumb .k{font-size:12.5px;color:var(--ink-2)} +.settb .setttl{margin:0;font:600 20px/26px var(--font-ui);letter-spacing:-.03em;color:var(--ink)} +.setbody{flex:1;min-height:0;overflow-y:auto;overflow-x:hidden;padding:0 24px 22px;scrollbar-gutter:stable} + +/* The diagnostics data plate: sentence-case keys, mono values, one line each. */ +.setplate{flex:none;margin:0;padding:9px 20px 10px;background:transparent;box-shadow:inset 0 1px 0 var(--divider)} +.setplate dl{display:flex;flex-wrap:wrap;align-items:baseline;gap:1px 18px;margin:0} +.set-kv{display:flex;align-items:baseline;gap:6px;min-width:0;max-width:100%} +.set-kv dt{flex:none;margin:0;font:400 11.5px/1.5 var(--font-ui);color:var(--ink-2)} +.set-kv dd{margin:0;min-width:0;font:400 11.5px/1.5 var(--font-mono);color:var(--ink-2); + white-space:nowrap;overflow:hidden;text-overflow:ellipsis;user-select:text} +.set-kv--agent{flex:1 1 220px} +.set-kv--wide{flex:1 1 100%} + +/* ---------------- Utilities ---------------- */ +.set-sr{position:absolute;width:1px;height:1px;margin:-1px;padding:0;border:0;overflow:hidden; + clip:rect(0 0 0 0);clip-path:inset(50%);white-space:pre} +.set-z{font-size:0!important;letter-spacing:0} +.set-cap{margin:0;font-size:12.5px;line-height:1.45;color:var(--ink-2)} +.set-meta{margin:0;font:400 12px/1.5 var(--font-mono);color:var(--ink-2)} +.set-desc{margin:0;font-size:14px;line-height:1.5;color:var(--ink-2)} +.set-state{font-size:12.5px;color:var(--ink-2);white-space:nowrap} +.set-state.on{color:var(--ink)} + +/* ---------------- A tab's pane ---------------- */ +.set-pane{display:flex;flex-direction:column;gap:14px;min-width:0;padding-top:2px} +.set-pane > .tk-bar{min-height:34px} + +/* Toolbar */ +/* Two classes: `.tuibar` (kept on the toolbar for tkRefreshBar) and the kit's + `.tk-bar` both set align-items/flex-wrap at one class, and a centred column + would size each row to its content instead of the pane. */ +.tk-bar.set-toolbar{flex-direction:column;align-items:stretch;flex-wrap:nowrap;gap:10px;white-space:normal} +/* A toolbar row never wraps: the search box and the readout give up width + first, the buttons keep theirs. */ +.set-tbrow{display:flex;align-items:center;flex-wrap:nowrap;gap:8px;min-width:0} +.set-tbrow > *{flex:none} +.set-tbrow .grow{flex:1 1 0;min-width:0} +.set-seg{max-width:100%;overflow-x:auto;scrollbar-width:none} +.set-seg::-webkit-scrollbar{display:none} +.set-tbrow > .set-counts{flex:0 1 auto;min-width:0;overflow:hidden;text-overflow:ellipsis} +.set-tbrow > .set-readout{flex:0 1 auto} +/* The Skills readout is one word (auto / manual); the counts give up width instead. */ +.set-skills .set-tbrow > .set-readout{flex:none} +.set-search{flex:0 1 280px;min-width:120px;height:34px;padding:0 6px 0 12px;cursor:text;color:var(--ink-3);font-size:13px} +.set-tbrow > .set-search{flex:0 1 280px} +.set-tbrow > .set-hubsearch{flex:1 1 300px} +button.set-search{cursor:pointer;text-align:left} +button.set-search:hover{border-color:var(--bd)} +.set-search > svg{flex:none;width:15px;height:15px} +.set-search .ph,.set-search .v{flex:1;min-width:0;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;font-size:13px} +.set-search .ph{color:var(--ink-3)} +.set-search .v{color:var(--ink)} +.set-search input{font-size:13px} +.set-search .kc{flex:none} +.set-hubsearch{flex:1 1 300px} +.set-readout{display:inline-flex;align-items:center;gap:7px;min-width:0;height:30px;padding:0 10px;border-radius:var(--r-pill); + font:400 12px/1 var(--font-ui);color:var(--ink-2);white-space:nowrap;cursor:pointer; + transition:background var(--t-state) var(--ease),color var(--t-state) var(--ease)} +.set-readout:hover{background:var(--hover);color:var(--ink)} +.set-readout > span:last-child{min-width:0;overflow:hidden;text-overflow:ellipsis} +.set-readout .tk-spin{width:12px;height:12px} +.set-counts{font-size:12.5px;color:var(--ink-2);white-space:nowrap} + +/* Notices in a pane */ +.tuimsg.tk-notice,.tuierr.tk-notice{color:var(--ink)} +.set-note{display:block;margin-top:2px;font-size:12.5px;line-height:1.45;color:var(--ink-2)} +.set-softnote{background:var(--well);box-shadow:none;color:var(--ink-2)} +.set-softnote > svg{color:var(--ink-3)} + +/* Keycap hints: every hint is a button. Inline-block (not flex) so the label + stays one line of text: the keycap and the word read "e toggle". */ +.tuihint{display:flex;align-items:center;flex-wrap:wrap;gap:2px 10px;margin:0;font-size:12px;color:var(--ink-2)} +#settings .tk-hint,.tuiwrap .tk-hint{display:inline-block;height:24px;line-height:24px;padding:0 6px;white-space:nowrap} +#settings .tk-hint .kc,.tuiwrap .tk-hint .kc{vertical-align:middle;margin-top:-2px} +#settings button.tk-hint[disabled],.tuiwrap button.tk-hint[disabled]{color:var(--ink-3);background:transparent;cursor:default} +/* Hint buttons still drawn the 0.5.5 way by tabs not yet redrawn. */ +.tuihint > button:not(.tk-hint){height:24px;padding:0 6px;border-radius:8px;color:var(--ink-2);font-size:12px} +.tuihint > button:not(.tk-hint):hover{background:var(--hover);color:var(--ink)} +.tuihint > button:not(.tk-hint):disabled{color:var(--ink-3);background:transparent;cursor:default} + +/* Empty and loading states */ +.set-empty{padding:40px 20px 28px} +.set-empty .tk-hints{justify-content:center;margin-top:2px} + +/* The `↑ N above` / `↓ N below` window lines, clickable. */ +.tuimore{display:inline-flex;align-items:center;height:28px;padding:0 12px;border:0;border-radius:var(--r-pill);background:transparent; + color:var(--ink-2);font:500 12.5px/1 var(--font-ui);white-space:pre;text-align:left;cursor:pointer} +.tuimore:hover{background:var(--hover);color:var(--ink)} +.tk-list > .tuimore{align-self:center;margin:2px 0} + +/* Confirm over the body card */ +.set-overlay{position:absolute;inset:0;z-index:5;display:flex;align-items:center;justify-content:center;padding:24px; + background:color-mix(in srgb, var(--scrim) 60%, transparent);-webkit-backdrop-filter:blur(2px);backdrop-filter:blur(2px)} +.set-modal{width:min(440px, 100%)} +.set-modal--wide{width:min(500px, 100%)} +.set-mhead{display:flex;align-items:center;gap:10px} +.set-modal p.mono{font:400 12px/1.5 var(--font-mono);color:var(--ink-2)} +.set-modal .acts{flex-wrap:wrap} +/* Kit override (reported): `.btn-df` sets --on-strong (white) on --critical, + and the dark theme's critical is a light red (#FF6A5C) where white reads at + ~2.9:1. --on-fill is white on light and near-black on dark. */ +#settings .btn-df,.tuiwrap .btn-df{color:var(--on-fill)} +#settings .btn-df:hover,.tuiwrap .btn-df:hover{color:var(--on-fill)} +#settings .btn-df .kc,.tuiwrap .btn-df .kc{color:var(--on-fill);border-color:color-mix(in srgb, var(--on-fill) 35%, transparent)} +.set-scan{color:var(--critical);max-height:140px} + +/* ---------------- Tasks (ST-01…05) ---------------- */ +.set-tblcard{padding:4px;overflow-x:auto} +.set-tktbl th{height:34px} +.set-tktbl td{height:40px} +.set-tktbl th:first-child,.set-tktbl td:first-child{padding-left:12px} +.set-tktbl td.mono{font:400 12px/1 var(--font-mono);color:var(--ink-2)} +.set-tktbl .set-sched{max-width:240px;overflow:hidden;text-overflow:ellipsis} +.set-tktbl .set-msg{width:100%;max-width:0;overflow:hidden;text-overflow:ellipsis;color:var(--ink)} +.set-tktbl tbody tr:focus-within td{background:var(--hover)} +.set-tktbl tbody tr.on td{background:var(--brand-wash)} +.set-rowbtn{display:inline-flex;align-items:center;padding:0;border:0;border-radius:var(--r-pill);background:transparent;cursor:pointer} +.set-rowbtn:focus-visible{outline:2px solid var(--brand);outline-offset:2px} +.set-morerow td{height:34px;text-align:center} + +/* Detail */ +.set-wellcard{display:flex;flex-direction:column;gap:12px;min-width:0;padding:18px 20px;border-radius:var(--r-card);background:var(--well)} +.set-dtitle{margin:0;min-width:0;font:600 19px/1.3 var(--font-ui);letter-spacing:-.02em;color:var(--ink);overflow-wrap:anywhere} +.set-dtitle.mono{font:500 19px/1.3 var(--font-mono);letter-spacing:0} +.set-chips{display:flex;align-items:center;flex-wrap:wrap;gap:8px} +.set-plate{gap:8px 26px} +.set-plate dt{font-size:13px} +.set-plate dd{font-size:12.5px} +.set-lasterr{align-items:flex-start} +.set-lasterr > svg{margin-top:2px} +.set-lasterr .mono{font-size:12px;line-height:1.55;overflow-wrap:anywhere} +.set-section{display:flex;flex-direction:column;gap:2px} +.set-section .tk-sh{padding:0;min-height:22px;color:var(--ink)} + +/* New task form */ +.set-formgrid{display:grid;grid-template-columns:minmax(0,1.25fr) minmax(0,1fr);gap:22px;align-items:start} +.set-formcol{display:flex;flex-direction:column;gap:16px;min-width:0} +.set-form .tk-inp{width:100%;height:42px} +.set-form .tk-inp.mono{font-size:13.5px} +.set-prevcard{display:flex;flex-direction:column;gap:10px;min-width:0;padding:16px 18px;border-radius:var(--r-card);background:var(--well)} +.set-prevh b{font:600 13.5px/1.3 var(--font-ui);color:var(--ink)} +.set-firings{list-style:none;margin:0;padding:0;display:flex;flex-direction:column;gap:9px} +.set-firings li{display:flex;align-items:center;gap:8px;font:400 12.5px/1.3 var(--font-mono);color:var(--ink);font-variant-numeric:tabular-nums} +.set-firings li > svg{width:14px;height:14px;color:var(--ink-3)} +.set-atnote{line-height:1.5} +.set-formfoot{display:flex;align-items:center;flex-wrap:wrap;gap:8px;padding-top:4px} +.set-formfoot .grow{flex:1} +/* tkPreview writes an inline success colour onto the submit button while the + caret is in the form; an ink pill keeps its paper label. */ +.set-submit{color:var(--paper)!important} +.set-submit[disabled]{color:var(--ink-3)!important} + +/* ---------------- Skills (ST-06…11) ---------------- */ +.set-sklist,.set-hublist{padding:6px} +.set-skcols,.set-skrow{display:grid;grid-template-columns:40px minmax(120px,190px) 96px 72px minmax(0,1fr);align-items:center;column-gap:14px} +.set-skcols{padding:6px 12px 6px;font:600 12px/1.2 var(--font-ui);color:var(--ink-2)} +.set-skrow .t{font:500 13px/1.3 var(--font-mono);letter-spacing:0;color:var(--ink)} +.set-skrow .m{font:400 12px/1 var(--font-mono);color:var(--ink-2)} +.set-skrow .d{min-width:0;font-size:13px;color:var(--ink-2);white-space:nowrap} +.set-skrow.dim .t,.set-skrow.dim .m,.set-skrow.dim .d{color:var(--ink-3)} +.set-skrow.on .t{color:var(--blue)} +.set-src{min-width:0;display:flex} +.set-hubcta{padding:4px} +.set-hubcta .tk-li > svg{color:var(--ink-3)} +.set-hubrow{min-height:54px} +.set-hubrow .t{font:500 13px/1.35 var(--font-mono);letter-spacing:0} +.set-hubrow .d{white-space:nowrap} +.set-hubrow .m{font-size:12px;color:var(--ink-2)} +.set-srcchip{min-width:42px;justify-content:center} +.set-titlerow{display:flex;align-items:center;flex-wrap:wrap;gap:10px;min-width:0} +.set-skmd{margin:0;min-height:120px;padding:14px 16px;font-size:12px;line-height:1.6} +.set-mdmore{color:var(--ink-2)} +.set-loading{display:flex;align-items:center;gap:8px;color:var(--ink-2)} + +/* ---------------- Privacy (ST-33) ---------------- */ +.set-setlist{padding:6px} +.set-setlist .tk-setrow{padding:10px 16px} +.set-setlist .tk-help{margin-top:6px} +.set-privgrid{display:grid;grid-template-columns:repeat(auto-fit, minmax(220px, 1fr));gap:14px} +.set-privcol{display:flex;flex-direction:column;gap:8px;padding:14px 16px;border-radius:var(--r-card);background:var(--well)} +.set-privcol .tk-sh{padding:0;min-height:20px;color:var(--ink)} +.set-privul{list-style:none;margin:0;padding:0;display:flex;flex-direction:column;gap:7px} +.set-privul li{display:flex;align-items:center;gap:8px;font-size:13px;color:var(--ink-2)} + +/* ---------------- 0.5.5 text-tab fallbacks ---------------- + Tabs still drawn as TUI text (and the parts of them another slice has not + redrawn yet) keep these: a quiet Tactile version of the same markup. */ +.tui{display:flex;flex-direction:column;gap:6px;min-width:0;font:400 13px/1.5 var(--font-ui);color:var(--ink)} +.tui b{font-weight:600;color:var(--ink)} +.tui .ter{color:var(--ink-2)} +.tuibar:not(.set-toolbar){display:flex;align-items:center;flex-wrap:wrap;gap:4px 8px;min-height:32px;color:var(--ink-2);white-space:pre-wrap} +.tuihead{padding:6px 10px 2px;font:600 12px/1.4 var(--font-mono);color:var(--ink-2);white-space:pre;overflow:hidden} +.tuirow{display:block;width:100%;min-height:34px;padding:7px 10px;border:0;border-radius:var(--r-well);background:transparent; + color:var(--ink);text-align:left;font:400 12.5px/20px var(--font-mono);white-space:pre;overflow:hidden;text-overflow:ellipsis;cursor:pointer; + transition:background var(--t-state) var(--ease)} +.tuirow:hover{background:var(--hover)} +.tuirow.on{background:var(--brand-wash);color:var(--blue)} +.tuirow.dim{color:var(--ink-3)} +.tuirow2 > span{display:block;white-space:pre;overflow:hidden;text-overflow:ellipsis} +.tuimodal{display:flex;flex-direction:column;gap:6px;min-width:0;margin:4px 0;padding:14px 16px;border:0;border-radius:var(--r-card);background:var(--well)} +.tuimodal.warn{box-shadow:inset 0 0 0 1.5px color-mix(in srgb, var(--amber) 70%, transparent)} +.tuimodal.danger{box-shadow:inset 0 0 0 1.5px color-mix(in srgb, var(--critical) 55%, transparent)} +.tuibody{margin:0;padding:10px 12px;border-radius:var(--r-well);background:var(--well); + font:400 12px/1.6 var(--font-mono);color:var(--ink);white-space:pre-wrap;word-break:break-word} +.tuibody > div{min-height:19px} +.tuimsg{color:var(--blue)} +.tuierr{color:var(--critical)} +.tuiarea{display:block;width:100%;margin-top:8px;padding:10px 14px;border:1px solid transparent;border-radius:18px;background:var(--well); + font:400 12.5px/1.5 var(--font-mono);color:var(--ink);resize:vertical;outline:none;white-space:pre} +.tuiarea:focus{background:var(--lift);border-color:var(--brand);box-shadow:var(--focus-ring)} +.tuiarea::placeholder{color:var(--ink-3)} +.skpf{display:inline-flex;align-items:center;height:26px;padding:0 10px;border:0;border-radius:var(--r-pill);background:var(--hover); + color:var(--ink);font:500 12.5px/1 var(--font-ui);white-space:pre;cursor:pointer} +.skpf:hover{background:var(--press)} +.skpf.on{background:var(--brand-wash);color:var(--blue)} +.skpf[disabled]{background:transparent;color:var(--ink-3);cursor:default} +.skpaccent{color:var(--blue)} +.sk-on{color:var(--success)} +.sk-off{color:var(--amber-text)} + +/* The Tasks and Skills rooms (⌘2 / ⌘3 outside the window) wrap the same tab. */ +.tuiwrap{position:relative;max-width:1080px;margin:0 auto;padding:24px 28px} -/* --- Settings › LLM › Local --- */ -.llm-ram{margin-bottom:6px} -/* `.tuirow` is a fixed 24px line that hides its overflow — right for a - one-line TUI row, fatal for a model row that now carries a description, - a fit verdict and sometimes a caution. */ -.tuirow.llm-model{height:auto;line-height:20px;padding:3px 4px;margin-bottom:2px;white-space:pre-wrap} -/* Indented with padding rather than two leading spaces, so a caution that - wraps keeps its hanging indent instead of returning to column 0. */ -.tuirow .llm-sub{display:block;font-size:11.5px;color:var(--text-secondary); - font-family:var(--font-mono);line-height:1.5;white-space:pre-wrap;padding-left:2ch} -.tuirow .llm-fit-comfortable{color:var(--success)} -.tuirow .llm-fit-tight{color:var(--warn)} -.tuirow .llm-fit-over{color:var(--text-tertiary)} -.tuirow .llm-caution{color:var(--warn)} - -/* ---------------- Manage / Settings (B.8) ---------------- - The tab strip wraps instead of scrolling: a strip that cannot overflow - cannot hide its own navigation. The selected tab is marked by a rule under - it, the way selection is marked everywhere else in this system. */ -.settabs{display:flex;flex-wrap:wrap;gap:0;margin:0;padding:0 var(--s5); - border-bottom:1px solid var(--line)} -.settab{height:32px;padding:0 var(--s4);border-radius:0;display:flex;align-items:center;gap:var(--s1); - color:var(--ink-2);font-size:var(--fs-cap);font-weight:500;border:0;background:transparent; - box-shadow:inset 0 -1px 0 transparent} -.settab:hover{background:var(--panel);color:var(--ink)} -.settab.on{color:var(--ink);box-shadow:inset 0 calc(var(--rule) * -1) 0 var(--accent)} -.settab:focus-visible{outline:2px solid var(--accent);outline-offset:-2px} -/* The scrollbar never sits on top of the text it scrolls. */ -.setbody,.setmenu{scrollbar-gutter:stable;padding-right:var(--s2)} -/* The diagnostics data plate, at the foot of the window. */ -.setplate{margin:0;border:0;border-top:1px solid var(--line);background:var(--panel); - padding:var(--s3) var(--s5)} -.setplate dl{grid-template-columns:auto 1fr;gap:2px var(--s4)} - -/* The run mode (Settings › LLM). Three rows, and the worker count only when - fusion is the one running — a control for a mode you are not in is noise. */ -.runmoderows{border:1px solid var(--line);margin-bottom:var(--s4)} -.runmodeworkers{display:flex;gap:var(--s1);margin-top:var(--s2)} -.runmodeworkers .btn{min-width:32px;justify-content:center} +/* ---------------- Narrow window (≤1000px) ---------------- */ +@media (max-width:1000px){ + .setwin{grid-template-columns:212px minmax(0,1fr)} + .settb{padding-left:18px} + .setbody{padding:0 18px 18px} + .set-formgrid{gap:16px} + .set-skcols,.set-skrow{grid-template-columns:40px minmax(100px,150px) 84px 62px minmax(0,1fr);column-gap:10px} +} diff --git a/desktop/renderer/renderer.js b/desktop/renderer/renderer.js index 58bf93a9..dc22dda2 100644 --- a/desktop/renderer/renderer.js +++ b/desktop/renderer/renderer.js @@ -2971,6 +2971,9 @@ function renderSettings() { clicked. Kept per pane, so switching tabs still starts at the top. */ const keepScroll = old && SETTINGS_SCROLL.pane === settingsPaneId(S.settingsPane) ? ((old.querySelector('.setbody') || {}).scrollTop || 0) : 0; + // Soft Tactile: the nav's command list scrolls on its own, and the same + // rebuild would throw it back to the top on every poll. + const keepNavScroll = old ? ((old.querySelector('.setnav-cmds') || {}).scrollTop || 0) : 0; if (old) old.remove(); if (!S.settings) { MENUFOCUS.want = false; return; } const cur = settingsPaneId(S.settingsPane); @@ -2983,43 +2986,40 @@ function renderSettings() { is what the click handler's new branch tests. Deliberately NOT `data-close`: that branch runs act('close'), which does not clear S.settings. */ el.dataset.setclose = '1'; - /* B.8 — the strip scrolls, and it used to say so nowhere: "изначально - ваще непонятно что эту полосу можно перекрутить". The pipe separators - are gone (they were doing a rule's job badly) and the strip is wrapped in - a rail that fades at whichever end still has tabs behind it. */ - const strip = SETTINGS_TABS.map(([id, label]) => - '').join(''); - el.innerHTML = '
' - /* B.8 — one close control with a name on it, not three imitation macOS - lights of which one happened to be live. Every icon-only control in - this window carries a label or a tooltip: with the sidebar collapsed - the tester could not tell what any of the crosses did. */ - + 'Menu › Manage' - + '' - + '' - + '
' - + '
' + menuTreeHTML() + '
' + /* Soft Tactile (ST-01…33): a two-column window on the well. The nav on the + left IS the tab switcher — the eight Manage rows carry `.settab` (+`.on`, + `data-act="settings:"`, text "Label (N)") inside the menu rows the + TUI tree draws — and the body is a floating card: crumb + close, the + pane, and the diagnostics data plate at the foot. Each tab draws its own + toolbar at the top of `.setbody`; the frame never repeats the tab title. */ + const tabLabel = (SETTINGS_TABS.find((t) => t[0] === cur) || [cur, cur])[1]; + const buildText = BUILD ? BUILD.version + ' · ' + BUILD.platform + ' ' + BUILD.arch : '—'; + const agentText = S.live.binary || 'not started'; + const diag = diagLine(); + el.innerHTML = '
'; + + '
'; $('#window').appendChild(el); // r6 cloud item 5: put the operator back where they were reading. SETTINGS_SCROLL.pane = cur; if (keepScroll) { const body = el.querySelector('.setbody'); if (body) body.scrollTop = keepScroll; } + if (keepNavScroll) { const cmds = el.querySelector('.setnav-cmds'); if (cmds) cmds.scrollTop = keepNavScroll; } if (MENUFOCUS.want) { const first = el.querySelector('.setmenu button.menurow'); if (first) first.focus(); } } @@ -3044,24 +3044,66 @@ function tabSuffix(id) { function menuTreeHTML() { const cur = settingsPaneId(S.settingsPane); + // Soft Tactile nav icons, one per node (function-local: no top-level state). + const ICON = { + 'go.manage.tasks':'tasks', 'go.manage.skills':'skills', 'go.manage.memory':'memory', 'go.manage.mcp':'plug', + 'go.manage.llm':'cpu', 'go.manage.telegram':'send', 'go.manage.import':'import', 'go.manage.privacy':'lock', + 'session.new':'plus', 'session.switch':'chat', 'session.clear':'trash', 'session.context':'gauge', 'session.id':'hash', + 'session.window':'term', 'model.chat':'cpu', 'run.mode':'shield', 'run.abort':'stop', 'run.queue':'list', + 'run.steer':'arrowR', 'run.expand':'expand', 'run.collapse':'minus', 'setup.theme':'sun', 'setup.mouse':'dots', + 'setup.sidebar':'sidebar', 'setup.analytics':'lock', 'setup.skill':'skills', 'setup.task':'tasks', + 'help.commands':'search', 'help.tools':'bolt', 'help.dump':'download', 'help.report':'flag', 'help.quit':'x', + 'danger.uninstall':'trash', + }; + // The chord is live in the desktop too: ctrl+g then the key (the keydown + // handler's CHORD layer). The keycap shows the letter; the `ctrl+g ` prefix + // stays in the text at zero size, so the node still reads "ctrl+g t". + const chordCap = (n) => n.chord ? 'ctrl+g ' + esc(n.chord) + '' : ''; const row = (n, sub) => { const on = n.tab && n.tab === cur; - // The chord is live in the desktop too: ctrl+g then the key (the keydown handler's CHORD layer). - const chord = n.chord ? 'ctrl+g ' + esc(n.chord) + '' : ''; - if (n.na) return ''; - return ''; + const icon = ic(ICON[n.id] || 'dots'); + if (n.na) { + return ''; + } + if (n.tab) { + /* A Manage node is also the tab switcher: `.settab` carries the tab's + act, label and count ("Tasks (12)" as text; the brackets are drawn at + zero size so the row shows "Tasks 12"). A click on the label lands on + `settings:`, anywhere else on the row on `menu:go.manage.` — + the same destination. */ + const suffix = tabSuffix(n.tab); + const count = suffix ? ' (' + esc(suffix.slice(2, -1)) + ')' : ''; + return ''; + } + return ''; }; // r4-ui item 5: no node carries `sub` any more — `Observe` and `Manage` were // the only two, and both left. The branch (and `.menurow.sub` / `.parent` in - // styles.css) is kept on purpose: MENU_GROUPS is a copy of the TUI registry + // the stylesheet) is kept on purpose: MENU_GROUPS is a copy of the TUI registry // and the next node pulled across may well be a parent, and it is what // __menuSubRows() asserts zero of — delete the branch and that check stops // meaning anything. - return MENU_GROUPS.map(([label, nodes]) => - '' - + nodes.map((n) => n.sub - ? '' + n.sub.map((c) => row(c, true)).join('') - : row(n, false)).join('')).join(''); + const group = (nodes) => nodes.map((n) => n.sub + ? '' + n.sub.map((c) => row(c, true)).join('') + : row(n, false)).join(''); + /* Layout: "Manage" and its eight tabs on top; every other group of the + real tree under "Commands" in a list that scrolls on its own; the Danger + zone (not available) and the chord hint pinned at the foot. */ + const manage = MENU_GROUPS.filter(([label]) => label === 'Manage'); + const danger = MENU_GROUPS.filter(([label]) => label === 'Danger zone'); + const commands = MENU_GROUPS.filter(([label]) => label !== 'Manage' && label !== 'Danger zone'); + return manage.map(([label, nodes]) => '' + group(nodes)).join('') + + '
Commands
' + + commands.map(([label, nodes]) => '' + group(nodes)).join('') + + '
' + + '
' + + danger.map(([, nodes]) => group(nodes)).join('') + + '
⌃G then a letter
' + + '
'; } /* debug-diagnostics-line.tsx: `cwd | llama | llm — · step — | kv — | @@ -3190,7 +3232,7 @@ function settingsPane() { return comingNote(SETTINGS_TABS.find((t) => t[0] === p)[1]); } function comingNote(label) { - return '
' + esc(label) + '
coming in the next step of this branch
'; + return '
' + ic('clock') + '

' + esc(label) + '

coming in the next step of this branch

'; } /* src/tui/privacy/components/privacy-panel.tsx after PR #303: analytics + session grants, no ladder. The desktop's approval path only offers @@ -3202,21 +3244,40 @@ function privacyPane() { const eff = privacyEffective(); const known = typeof eff === 'boolean'; const on = known && eff; - return '
' - + 'Analytics' - + '
anonymous usage ' + (known ? '' + (on ? 'on' : 'off') + '' - : PRIV.effectiveBusy || (BR && PRIV.effective === null && !PRIV.lastError) ? '' - : '— (analytics.enabled is not set in config.json and `atag config get analytics.enabled` did not answer)') - + (PRIV.busy ? '' : '') + '
' - + '
Product analytics + crash reports, fully anonymous. No message content, paths, args, or IP ever leave this machine — only an install id and coarse counters.
' - + 'Session grants' - + '
none active — grants you make with [s] / [a] at a prompt appear here for this session
' - + (PRIV.message ? '
' + esc(PRIV.message) - + ' (the running agent picks it up after Restart Agent Runtime) ' - + '
' : '') - + (PRIV.lastError ? '
' + esc(PRIV.lastError) + '
' : '') - + '
' - + '·
' + const pending = !known && (PRIV.effectiveBusy || (BR && PRIV.effective === null && !PRIV.lastError)); + // ST-33: the state word the TUI prints ("anonymous usage on / off / …"), kept as the switch row's accessible text. + const stateWord = known ? (on ? 'on' : 'off') : pending ? '…' : '—'; + const unknownNote = !known && !pending + ? '
— (analytics.enabled is not set in config.json and `atag config get analytics.enabled` did not answer)
' : ''; + // What is and is not sent — both lists are the TUI sentence below, split into its parts. + const sentList = (title, items, icon, tone) => '
' + esc(title) + '
    ' + + items.map((t) => '
  • ' + ic(icon) + '' + esc(t) + '
  • ').join('') + '
'; + return '
' + // The TUI's own restart sentence for this toggle, in the shared restart-line look. + + (PRIV.message ? '
' + ic('refresh') + + '' + esc(PRIV.message) + ' (the running agent picks it up after Restart Agent Runtime)' + + '
' : '') + + (PRIV.lastError ? '
' + ic('alert') + '' + esc(PRIV.lastError) + '
' : '') + + '
' + + '
' + + '
Analytics · anonymous usage ' + stateWord + '
' + + '
Product analytics + crash reports, fully anonymous. No message content, paths, args, or IP ever leave this machine — only an install id and coarse counters.
' + + unknownNote + '
' + + '' + + '' + + '
' + + '
' + + '
Session grants
' + + '
The desktop answers each approval once, allow or deny, so a session holds no standing grants.
' + + 'none active' + + '
' + + '
' + + '
' + + sentList('What is sent', ['An install id', 'Coarse counters', 'Crash reports'], 'check', 'tk-ico--green') + + sentList('What never leaves this machine', ['Message content', 'Paths', 'Tool arguments', 'IP address'], 'x', 'tk-ico--red') + + '
' + + tuiHints([['a: analytics ' + (on ? 'off' : 'on'), 'privacy:analytics', {disabled: !known || PRIV.busy}], ['r: refresh', 'privacy:refresh']]) + '
'; } /* The value the TUI shows: the user file's key when set, else the @@ -12031,13 +12092,15 @@ async function tasksRefresh(quiet) { /* The Tasks list's filter bar only (the `refresh: auto (Ns ago)` clock); skipped while the `/` search input lives inside it. */ function tkRefreshBar() { - if (TK.mode !== 'list' || TK.searchOpen) return; + if (TK.mode !== 'list') return; const box = S.settings ? document.querySelector('#settings .setbody') : document.querySelector('#content .tuiwrap'); - const bar = box && box.querySelector('.tuibar'); - if (!bar) return; + // Soft Tactile: only the readout ticks; the search box and the buttons in + // the same toolbar are left alone, so a caret or a focus ring survives. + const readout = box && box.querySelector('.tuibar .set-readout'); + if (!readout || readout.contains(document.activeElement)) return; const tmp = document.createElement('div'); - tmp.innerHTML = tkFilterBar(tkVisibleRows().length); - bar.replaceWith(tmp.firstElementChild); + tmp.innerHTML = tkReadoutHTML(tkVisibleRows().length); + readout.replaceWith(tmp.firstElementChild); } /* Repaint the Tasks tab in place and keep the focus on the button (by its data-act) the user was on. With nothing focused inside the window a full @@ -12062,35 +12125,69 @@ function tasksTab() { function tkStatusClass(status) { return {running:'st-running', completed:'st-completed', failed:'st-failed', blocked:'st-blocked', cancelled:'st-cancelled'}[status] || 'st-pending'; } +/* Soft Tactile (ST-01): the Tasks toolbar — the filters as a segmented + control (every TK_FILTER_ORDER value), then search, the auto-refresh + readout, Refresh and New task. `.tuibar` stays on it: tkRefreshBar finds the + readout inside. */ function tkFilterBar(visibleCount) { - const now = Date.now(); + const seg = '
' + + TK_FILTER_ORDER.map((f) => '').join('') + + '
'; + const search = TK.searchOpen + ? '' + : ''; + return '
' + + '
' + seg + '
' + + '
' + search + + (TK.search.length && !TK.searchOpen ? '' : '') + + tkReadoutHTML(visibleCount) + + '' + + '' + + '' + + '
'; +} +/* The readout tkRefreshBar repaints on each poll: `auto · refreshed 4s ago · + 7 of 12`. A click is the TUI's `a auto`. */ +function tkReadoutHTML(visibleCount) { const mode = TK.auto ? 'auto' : 'manual'; let refresh; - if (TK.lastRefreshedAt === null) refresh = mode + ' (never)'; + if (TK.lastRefreshedAt === null) refresh = 'never refreshed'; else { - const seconds = Math.floor(Math.max(0, now - TK.lastRefreshedAt) / 1000); - refresh = seconds < 1 ? mode + ' (just now)' : seconds < 60 ? mode + ' (' + seconds + 's ago)' : mode + ' (' + Math.floor(seconds / 60) + 'm ago)'; + const seconds = Math.floor(Math.max(0, Date.now() - TK.lastRefreshedAt) / 1000); + refresh = seconds < 1 ? 'refreshed just now' : seconds < 60 ? 'refreshed ' + seconds + 's ago' : 'refreshed ' + Math.floor(seconds / 60) + 'm ago'; } - const search = TK.searchOpen - ? ' · /' - : TK.search.length ? ' · /' + esc(TK.search) : ''; - return '
Tasks filter: ' + esc(TK.filter) + ' · ' + visibleCount + '/' + TK.rows.length - + search + ' · refresh: ' + esc(refresh) + (TK.loading ? ' · loading…' : '') + '
'; + return ''; +} +/* Status chip: running pulses brand, failed red, blocked amber (waiting on + something), completed green words, cancelled an outline. */ +function tkStatusChip(status) { + const tone = {running:'tk-chip--blue', completed:'tk-chip--green', failed:'tk-chip--red', blocked:'tk-chip--amber', cancelled:'tk-chip--line'}[status] || ''; + return '' + + (status === 'running' ? '' : '') + esc(status) + ''; } function tkMessages() { - return (TK.msg ? '
' + esc(TK.msg) + '
' : '') - + (TK.msg && TK.note ? '
' + esc(TK.note) + '
' : '') - + (TK.err ? '
! ' + esc(TK.err) + '
' : ''); + return (TK.msg ? '
' + ic('info') + '' + esc(TK.msg) + + (TK.note ? '' + esc(TK.note) + '' : '') + '
' : '') + + (TK.err ? '
' + ic('alert') + '' + esc(TK.err) + '' + + '
' : ''); } +/* ST-04: recurring tasks ask first (one-shot tasks cancel straight away). */ function tkCancelModal() { if (!TK.cancel) return ''; - return '
cancel ' + (TK.cancel.isRecurring ? 'recurring ' : '') + 'task?' - + '
id: ' + esc(TK.cancel.taskId) + '
' - + '
this stops all future firings.' - + '·
'; + return '
' + + '
' + ic('alert') + '

Cancel ' + (TK.cancel.isRecurring ? 'recurring ' : '') + 'task?

' + + '

id: ' + esc(TK.cancel.taskId) + '

' + + '

This stops all future firings.

' + + '
' + + '
' + + '
'; } +/* One keycap hint button: `n new`, `R run-now`… (the Tasks tab's own hint strip). */ function tkHint(key, label, act) { - return '·'; + return ''; } function tkListHTML() { const rows = tkVisibleRows(); @@ -12098,7 +12195,15 @@ function tkListHTML() { const cur = Math.max(0, Math.min(TK.cursor, rows.length - 1)); let body; if (!rows.length) { - body = '
no tasks match the current filter — press `n` to create one, `f` to cycle filter, `r` to refresh.
'; + // The TUI sentence is the pane's accessible text; the same three keys are buttons below it. + const what = TK.filter === 'all' ? 'tasks' : TK.filter + ' tasks'; + body = '
' + + 'no tasks match the current filter — press `n` to create one, `f` to cycle filter, `r` to refresh.' + + '' + + '' + + '' + + '' + + '
'; } else { // tasks-list.tsx:37-41: window the rows around the cursor (row-window.ts // computeWindowStart) and say how many are hidden above / below. The @@ -12108,88 +12213,125 @@ function tkListHTML() { const page = rows.slice(start, start + TK_MAX_ROWS); const hiddenBefore = start; const hiddenAfter = Math.max(0, rows.length - start - page.length); - body = '
status schedule next-run session message
' - + (hiddenBefore > 0 ? '' : '') + const more = (dir, n) => ''; + body = '
' + // The TUI's column header, kept as the table's accessible caption. + + '' + + '' + + (hiddenBefore > 0 ? more('up', hiddenBefore) : '') + page.map((row, idx) => { const i = idx + start; const sel = i === cur; - return ''; + return '' + + '' + + '' + + '' + + '' + + ''; }).join('') - + (hiddenAfter > 0 ? '' : ''); + + (hiddenAfter > 0 ? more('down', hiddenAfter) : '') + + '
status schedule next-run session message
StatusScheduleNext runSessionMessage
' + esc(row.scheduleLabel) + '' + esc(formatRelativeMs(row.scheduledFor, now)) + '' + esc(row.sessionId ? tkShortId(row.sessionId) : '—') + '' + esc(row.userMessage) + '
'; } - const hints = '
j/k move·Enter detail·' + const selRow = rows.length ? rows[cur] : null; + const hints = '
j/k move' + + (selRow ? tkHint('Enter', 'detail', 'tasks:detail:' + esc(selRow.id)) : 'Enter detail') + tkHint('n', 'new', 'tasks:new') + tkHint('c', 'cancel', 'tasks:cancel') + tkHint('R', 'run-now', 'tasks:run') + tkHint('r', 'refresh', 'tasks:refresh') + tkHint('a', 'auto', 'tasks:auto') + tkHint('f', 'filter', 'tasks:filter') - + tkHint('/', 'search', 'tasks:search') + '
'; - return '
' + tkFilterBar(rows.length) + tkMessages() + tkCancelModal() + body + hints + '
'; + + tkHint('/', 'search', 'tasks:search') + tkHint('Esc', 'clear search', 'tasks:clearSearch') + '
'; + return '
' + tkFilterBar(rows.length) + tkMessages() + body + hints + tkCancelModal() + '
'; } +/* ST-02: everything the agent exposes about one task, the last error in + full, and its three actions. */ function tkDetailHTML() { const row = TK.rows.find((r) => r.id === TK.detailId); + const back = ''; if (!row) { - return '
task ' + esc(TK.detailId || '?') + ' not found in the current snapshot. Press Esc to return to the list.
' - + '
'; + return '
' + back + '
' + + '
' + ic('alert') + 'task ' + esc(TK.detailId || '?') + ' not found in the current snapshot. Press Esc to return to the list.
' + + tuiHints([['Esc back', 'tasks:back']]) + '
'; } const now = Date.now(); const id = row.id; - return '
' + tkMessages() + tkCancelModal() - + '
' + esc(id) + ' · ' + esc(row.origin) + '
' - + '
status: ' + esc(row.status) + ' schedule: ' + esc(row.scheduleLabel) + (row.recurring ? ' (recurring)' : '') + '
' - + '
next-run: ' + esc(formatRelativeMs(row.scheduledFor, now)) + ' (' + esc(row.scheduledFor !== null ? formatUnixMs(row.scheduledFor) : '-') + ')
' - + '
attempts: ' + row.attempts + '/' + row.maxAttempts + ' session: ' + esc(row.sessionId ?? '—') + '
' - + '
created: ' + esc(formatUnixMs(row.createdAt)) + ' · updated: ' + esc(formatUnixMs(row.updatedAt)) - + (row.completedAt !== null ? ' · completed: ' + esc(formatUnixMs(row.completedAt)) : '') + '
' - + '
message:
' + esc(row.userMessage) + '
' - + (row.lastError ? '
last error: ' + esc(row.lastError) + '
' : '') - + '
recent firings:
' + const plate = [ + ['Task', id + ' · ' + row.origin], + ['Schedule', row.scheduleLabel + (row.recurring ? ' (recurring)' : '')], + ['Next run', formatRelativeMs(row.scheduledFor, now) + ' (' + (row.scheduledFor !== null ? formatUnixMs(row.scheduledFor) : '-') + ')'], + ['Attempts', row.attempts + '/' + row.maxAttempts], + ['Session', row.sessionId ?? '—'], + ['Created', formatUnixMs(row.createdAt) + ' · updated ' + formatUnixMs(row.updatedAt) + (row.completedAt !== null ? ' · completed ' + formatUnixMs(row.completedAt) : '')], + ]; + return '
' + tkMessages() + + '
' + back + '' + + '' + + '' + + '
' + + '
' + + '

' + esc(row.userMessage || id) + '

' + + '
' + tkStatusChip(row.status) + (row.recurring ? 'recurring' : '') + + 'attempts ' + row.attempts + '/' + row.maxAttempts + '
' + + '
' + plate.map(([k, v]) => '
' + esc(k) + '
' + esc(v) + '
').join('') + '
' + + '
' + + (row.lastError ? '
' + ic('alert') + 'last error: ' + esc(row.lastError) + '
' : '') + + '
Recent firings
' // The TUI builds this feed in-process by diffing records between ticks; the HTTP API has no such surface. - + '
(firings are not exposed by the agent\'s HTTP API)
' - + '
' + tkHint('o', 'open session', 'tasks:open:' + esc(id)) + tkHint('R', 'run-now', 'tasks:run:' + esc(id)) - + tkHint('c', 'cancel', 'tasks:cancel:' + esc(id)) + '
' + + '

(firings are not exposed by the agent\'s HTTP API)

' + + '
' + tkHint('o', 'open session', 'tasks:open:' + esc(id)) + tkHint('R', 'run-now', 'tasks:run:' + esc(id)) + + tkHint('c', 'cancel', 'tasks:cancel:' + esc(id)) + tkHint('Esc', 'back', 'tasks:back') + '
' + + tkCancelModal() + '
'; } function tkNewForm() { return {kind:'cron', cronExpression:'', intervalSeconds:'', atIsoOrMs:'', tz:'', message:'', preview:{ok:false, error:null, nextFirings:[]}, submitting:false, error:null, timer:null}; } +/* ST-03: kind as a segmented control, labelled fields, the next five + firings beside them, and Create task disabled until the schedule parses. */ function tkFormHTML() { const f = TK.form || (TK.form = tkNewForm()); - const label = (l) => ' ' + esc(l.padEnd(10)) + ': '; - const field = (l, name, value, placeholder) => - '
' + label(l) + '
'; - const expr = f.kind === 'cron' ? field('cron', 'cronExpression', f.cronExpression, '0 * * * * (standard 5-field cron)') - : f.kind === 'interval' ? field('every (s)', 'intervalSeconds', f.intervalSeconds, '300') - : field('at', 'atIsoOrMs', f.atIsoOrMs, '2026-05-01T09:00:00Z or Unix-ms'); + const field = (l, name, value, placeholder, help, mono) => + ''; + const expr = f.kind === 'cron' ? field('Cron', 'cronExpression', f.cronExpression, '0 * * * *', 'standard 5-field cron', true) + : f.kind === 'interval' ? field('Every (seconds)', 'intervalSeconds', f.intervalSeconds, '300', '', true) + : field('At', 'atIsoOrMs', f.atIsoOrMs, '2026-05-01T09:00:00Z', 'an ISO time or Unix-ms', true); const canSubmit = f.preview.ok && !f.submitting; - return '
new task' - + '
' + label('kind') + ['cron','interval','at'].map((k, i) => - (i ? ' / ' : '') + '').join('') + '
' - + expr - + (f.kind === 'cron' ? field('tz', 'tz', f.tz, '(optional, e.g. Europe/Berlin)') : '') - + field('message', 'message', f.message, 'what should the agent do when this fires?') - + '
' + tkPreviewHTML(f) + '
' - + '
' - + ' · Tab next · Shift+Tab back · ' + (f.submitting ? ' · submitting…' : '') + '
' - + '
'; + return '
' + + '
' + + '
' + + '
' + + '
Kind
' + + ['cron','interval','at'].map((k) => '').join('') + + '
' + + expr + + (f.kind === 'cron' ? field('Time zone', 'tz', f.tz, 'optional, e.g. Europe/Berlin', '', true) : '') + + field('Message', 'message', f.message, 'what should the agent do when this fires?', '', false) + + '
' + + '
' + tkPreviewHTML(f) + '
' + + '
' + + '
' + + 'Tab next · Shift+Tab back' + (f.submitting ? ' · submitting…' : '') + '' + + '' + + '' + + '' + + '
' + + '
'; } function tkPreviewHTML(f) { - if (f.error) return '
error: ' + esc(f.error) + '
'; - if (f.preview.error) return '
error: ' + esc(f.preview.error) + '
'; - if (!f.preview.nextFirings.length) return '
(preview unavailable)
'; - return '
next firings:
' - + f.preview.nextFirings.map((ms) => '
· ' + esc(formatUnixMs(ms)) + '
').join('') + const head = '
next firings:
'; + if (f.error) return '
Next firings
error: ' + esc(f.error) + '
'; + if (f.preview.error) return '
Next firings
error: ' + esc(f.preview.error) + '
'; + if (!f.preview.nextFirings.length) return '
Next firings
(preview unavailable)
'; + return head + + '
    ' + f.preview.nextFirings.map((ms) => '
  • ' + ic('clock') + '' + esc(formatUnixMs(ms)) + '
  • ').join('') + '
' // Item 7: honest degradation on 0.5.4 — the desktop cannot reach TaskRunner.create, only the CLI. // The desktop submits through `atag task create --at`; on agent 0.5.4 that CLI path writes the // one-shot through the bare TaskStore with no next-run (scheduled_for NULL), which the scheduler // treats as due now — the row will show next-run "-" and be picked up at the next tick. The TUI // creates in-process through TaskRunner.create and keeps the `at`; the desktop says so instead // of pretending. - + (f.kind === 'at' ? '
' + esc(tkAtNote('the time above')) + '
' : ''); + + (f.kind === 'at' ? '
' + esc(tkAtNote('the time above')) + '
' : ''); } /* The same caveat, worded for the preview ("the time above") and for the success line/toast after submit ("the `at` time"). */ @@ -12296,7 +12438,7 @@ function tasksAct(what) { if (verb === 'refresh') { tasksRefresh(); return; } if (verb === 'page') { const n = tkVisibleRows().length; TK.cursor = Math.max(0, Math.min(TK.cursor + (arg === 'up' ? -TK_MAX_ROWS : TK_MAX_ROWS), n - 1)); render(); return; } if (verb === 'auto') { TK.auto = !TK.auto; render(); return; } - if (verb === 'filter') { TK.filter = TK_FILTER_ORDER[(TK_FILTER_ORDER.indexOf(TK.filter) + 1) % TK_FILTER_ORDER.length]; TK.cursor = 0; render(); return; } + if (verb === 'filter') { TK.filter = arg && TK_FILTER_ORDER.includes(arg) ? arg : TK_FILTER_ORDER[(TK_FILTER_ORDER.indexOf(TK.filter) + 1) % TK_FILTER_ORDER.length]; TK.cursor = 0; render(); return; } // `filter:` = a segment of the filter control; bare `filter` cycles (the `f` key) if (verb === 'search') { TK.searchOpen = true; render(); const n = $('#tk-search'); if (n) n.focus(); return; } if (verb === 'clearSearch') { TK.searchOpen = false; TK.search = ''; TK.cursor = 0; render(); return; } } @@ -12457,7 +12599,8 @@ if (typeof window !== 'undefined') { window.__privacySet = (on) => privacySet(on).then(() => privacyEffective()); // the slash verbs' write path window.__privacyIdle = () => !PRIV.busy && PRIV.pending === 0; // no analytics write queued or in flight window.__runSlash = (line) => { runSlash(String(line).replace(/^\//, '').split(/\s+/)); }; // exactly what Enter on a `/…` composer line does - window.__settingsStripRows = () => new Set([...document.querySelectorAll('#settings .settab')].map((b) => b.offsetTop)).size; // 1 = the TUI's single-line strip + // 1 = one strip that never wraps. Soft Tactile draws the tabs as the nav's single column, so the strip's one line is its one column (offsetLeft). + window.__settingsStripRows = () => new Set([...document.querySelectorAll('#settings .settab')].map((b) => b.offsetLeft)).size; window.__menuNodes = () => { const out = []; MENU_GROUPS.forEach(([group, nodes]) => nodes.forEach((n) => { @@ -12512,10 +12655,24 @@ function tuiBodyLines(lines) { hint in Settings is also something a mouse can press. `opts.cls` adds to the class, it no longer replaces it. */ function tuiBtn(label, act, opts) { - return ''; + return ''; } function tuiHints(parts) { - return '
' + parts.map((p) => (typeof p === 'string' ? '' + esc(p) + '' : tuiBtn(p[0], p[1], p[2]))).join('') + '
'; + return '
' + parts.map((p) => (typeof p === 'string' ? '' + tuiKeyLabel(p) + '' : tuiBtn(p[0], p[1], p[2]))).join('') + '
'; +} +/* The leading key of a hint label drawn as a keycap: `e toggle`, `Esc back`, + `[y] delete`, `a: analytics off`, `j/k move`. Only the look changes — the + brackets and the colon stay in the text at zero size, so the label reads + exactly as the TUI wrote it (copy, screen readers, and the checks that + compare it). A label with no recognisable key stays plain text. */ +function tuiKeyLabel(label) { + const s = String(label == null ? '' : label); + const m = /^(\[([^\]\s]{1,8})\]|(j\/k|Ctrl\+Enter|Shift\+Tab|Enter|enter|Esc|esc|Tab|Space|[A-Za-z]|[\/<>?]))(:?)( .+)$/.exec(s); + if (!m) return esc(s); + const key = m[2] !== undefined ? m[2] : m[3]; + const cap = '' + esc(key) + ''; + const body = m[2] !== undefined ? '[' + cap + ']' : cap; + return body + (m[4] ? ':' : '') + esc(m[5]); } /* A change that only lands after a runtime restart says so where it was made, with the one button that makes it land. */ @@ -12569,35 +12726,57 @@ async function skpReloadRows() { function skillsTab() { ensureSkillsPoll(); - if (SKP.installConfirm) return '
' + skpInstallConfirmHTML() + '
'; - if (SKP.removeConfirm) return '
' + skpRemoveConfirmHTML() + '
'; - if (SKP.hubCard) return '
' + skpHubCardHTML() + '
'; - if (SKP.mode === 'hub') { - if (SKP.hubCardLoading) return '
loading skill card…
'; - return '
' + skpHubListHTML() + '
'; - } - const rows = SK.rows || []; - const visible = skpVisibleRows(); - const enabledCount = rows.filter((r) => r.enabled).length; - // FilterBar: `filter: all · enabled · disabled N shown · E enabled · D disabled · auto · … built-in tools: /tools` - const bar = '
filter: ' - + SKP_FILTERS.map((f, i) => (i ? ' · ' : '') + '').join('') - + ' ' + visible.length + ' shown · ' + enabledCount + ' enabled · ' + (rows.length - enabledCount) + ' disabled' - + (SKP.auto ? ' · auto' : '') + (SK.busy ? ' · …' : '') + ' built-in tools:
'; - return '
' + bar - + (SKP.lastError ? '
! ' + esc(SKP.lastError) + '
' : '') - + (SK.err ? '
! ' + esc(SK.err) + '
' : '') - + skpMessages() - + (SKP.mode === 'detail' ? skpDetailHTML() : skpListHTML(visible)) + '
'; + // Soft Tactile (ST-08, ST-11): a confirm is a modal over the view it was + // raised from instead of replacing it; the same two states win as before. + const overlay = SKP.installConfirm ? skpInstallConfirmHTML() : SKP.removeConfirm ? skpRemoveConfirmHTML() : ''; + let view; + if (SKP.hubCard) view = skpHubCardHTML(); + else if (SKP.mode === 'hub') { + view = SKP.hubCardLoading ? '

loading skill card…

' : skpHubListHTML(); + } else { + const rows = SK.rows || []; + const visible = skpVisibleRows(); + const enabledCount = rows.filter((r) => r.enabled).length; + // FilterBar: `filter: all · enabled · disabled N shown · E enabled · D disabled · auto · … built-in tools: /tools` — + // a segmented control, the counts, the auto readout, Built-in tools and the Skills Hub (ST-06). + const bar = SKP.mode === 'detail' ? '' : '
' + + '
filter: ' + + SKP_FILTERS.map((f) => '').join('') + + '
' + + '' + visible.length + ' shown · ' + enabledCount + ' enabled · ' + (rows.length - enabledCount) + ' disabled' + + '' + + '' + + '' + + '' + + '
'; + view = bar + + (SKP.lastError ? '
' + ic('alert') + '' + esc(SKP.lastError) + '
' : '') + + (SK.err ? '
' + ic('alert') + '' + esc(SK.err) + '
' : '') + + skpMessages() + + (SKP.mode === 'detail' ? skpDetailHTML() : skpListHTML(visible)); + } + return '
' + view + overlay + '
'; } function skpMessages() { if (!SKP.msg) return ''; - return SKP.msg.restart ? restartLine(SKP.msg.text) : '
' + esc(SKP.msg.text) + '
'; + return SKP.msg.restart ? restartLine(SKP.msg.text) : '
' + ic('info') + '' + esc(SKP.msg.text) + '
'; +} +/* Source chip: project (this workspace) in blue, bundled as an outline, a `missing` disable-list entry amber. */ +function skpSourceChip(source) { + const tone = {project:' tk-chip--blue', bundled:' tk-chip--line', missing:' tk-chip--amber'}[source] || ''; + return '' + esc(source) + ''; } function skpListHTML(visible) { - if (!SK.rows && !SK.err) return '
loading skill list…
'; + if (!SK.rows && !SK.err) return '

loading skill list…

'; if (!visible.length) { - return '
no skills match the current filter — install one with `atomic-agent skill install`, or press `f` to cycle filter / `r` to refresh.
' + // The TUI sentence is the accessible text; the visible copy and the hint strip below carry the same three ways out. + return '
' + + 'no skills match the current filter — install one with `atomic-agent skill install`, or press `f` to cycle filter / `r` to refresh.' + + '' + + '' + + '
' + skpHintsHTML() + skpHubCtaHTML(); } const cur = Math.max(0, Math.min(SKP.cursor, visible.length - 1)); @@ -12605,17 +12784,23 @@ function skpListHTML(visible) { const page = visible.slice(start, start + SKP_MAX_ROWS); const hiddenBefore = start; const hiddenAfter = Math.max(0, visible.length - start - page.length); - return '
state source version name description
' + // ST-06: one row per skill — switch · name · source · version · description. The switch is the + // `e toggle` of that row (a span: the row itself is the button that opens the detail). + return '
' + + ' state source version name description' + + '' + (hiddenBefore > 0 ? '' : '') + page.map((r, idx) => { const i = idx + start, sel = i === cur; - // SkillRow: `{chevron} {state(9)}` `{[source](9)} {v(8)}` `{name(26)}` `{description(60)}` - return ''; + return ''; }).join('') + (hiddenAfter > 0 ? '' : '') + + '
' + skpHintsHTML() + skpHubCtaHTML(); } function skpHintsHTML() { @@ -12623,46 +12808,66 @@ function skpHintsHTML() { ['r refresh', 'skills:refresh'], ['a auto', 'skills:auto'], ['f filter', 'skills:filter']]); } function skpHubCtaHTML() { - return ''; + return '
'; } +/* ST-07: name, state, source and version, then SKILL.md itself. */ function skpDetailHTML() { const name = SKP.detailName; - if (!name) return '
(no skill selected)
'; + const back = ''; + if (!name) return '
' + back + '

(no skill selected)

'; const row = (SK.rows || []).find((r) => r.name === name); const enabled = row ? row.enabled : true; let body; - if (SKP.detailBody === null) body = '
(loading…)
'; + if (SKP.detailBody === null) body = '
(loading…)
'; else { const lines = SKP.detailBody.split('\n'); const hidden = lines.length - SKP_DETAIL_LINES; - body = tuiBodyLines(lines.slice(0, SKP_DETAIL_LINES)) - + (hidden > 0 ? '
… (' + hidden + ' more line' + (hidden === 1 ? '' : 's') + ' hidden)
' : ''); - } - return '
' + esc(name) + ' ' + (enabled ? 'enabled' : 'disabled') + '' - + (row ? ' [' + esc(row.source) + '] v' + esc(row.version) + '' : '') + '
' - + (row && row.description ? '
' + esc(row.description) + '
' : '') - + '
' + body + '
' + body = '
' + esc(lines.slice(0, SKP_DETAIL_LINES).join('\n'))
+      + (hidden > 0 ? '\n\n… (' + hidden + ' more line' + (hidden === 1 ? '' : 's') + ' hidden)' : '') + '
'; + } + // Remove is offered where `d remove` can act: a global skill gets the confirm, a project one the line saying where it lives. + const canRemove = row && (row.source === 'global' || row.source === 'project'); + return '
' + back + '' + + '' + + '' + (enabled ? 'enabled' : 'disabled') + '' + + (canRemove ? '' : '') + + '
' + + '

' + esc(name) + '

' + (row ? skpSourceChip(row.source) + 'v' + esc(row.version) + '' : '') + '
' + + (row && row.description ? '

' + esc(row.description) + '

' : '') + + body + tuiHints([['Esc back', 'skills:back'], ['e toggle', 'skills:toggle:' + name], ['r refresh', 'skills:refresh']]); } +/* ST-08: the red confirm, over the list. */ function skpRemoveConfirmHTML() { const c = SKP.removeConfirm; - return '
Remove skill' - + '
delete global skill ' + esc(c.name) + '?
' + return '
' + + '
' + ic('trash') + '

Remove skill

' + + '

Delete global skill ' + esc(c.name) + '?

' // skills-remove-confirm.tsx warns when the skill is a bundled starter; listStarterSkillNames is not exposed by the agent, so the check cannot run here. - + '
(bundled-starter check unavailable)
' - + (c.error ? '
! ' + esc(c.error) + '
' : '') - + (c.submitting ? '
removing…
' - : tuiHints([['[y] delete', 'skills:removeConfirm'], ['[n] cancel', 'skills:removeCancel']])) + '
'; + + '

(bundled-starter check unavailable)

' + + (c.error ? '
' + esc(c.error) + '
' : '') + + (c.submitting ? '
removing…
' + : '
' + + '
') + + '
'; } +/* ST-11: installing past a DANGEROUS verdict is an explicit, acknowledged override. */ function skpInstallConfirmHTML() { const c = SKP.installConfirm; - return '
Security scan: ' + esc(String(c.verdict).toUpperCase()) + '' - + '
install ' + esc(c.identifier) + '?
' + return '
' + + '
' + ic('shield') + '

Security scan: ' + esc(String(c.verdict).toUpperCase()) + '

' + + '

Install ' + esc(c.identifier) + '?

' // The TUI lists the scan findings (`[rule] file:line excerpt`); `atag skill install` prints only its blocked line, shown plainly — no invented rule id in the finding slot. - + '
' + esc(c.message) + '
' - + '
(findings are not printed by `atag skill install` — the verdict line above is all the CLI reports)
' - + (SKP.installing ? '
installing…
' - : '
' + tuiBtn('[y] install anyway (risk acknowledged)', 'skills:installAck') + ' ' + tuiBtn('[n] cancel', 'skills:installCancel') + '
') + '
'; + + '
' + esc(c.message) + '
' + + '

(findings are not printed by `atag skill install` — the verdict line above is all the CLI reports)

' + + (SKP.installing ? '
installing…
' + : '
' + + '
') + + '
'; } function skpHubIdLabel(identifier) { // skills-hub-list.tsx formatHubIdentifier: `owner/…/dir` for nested repo paths @@ -12678,65 +12883,87 @@ function formatDownloads(n) { if (n < 1000000) return trim(n / 1000) + 'k'; return trim(n / 1000000) + 'M'; } +/* ST-09: the hub — back to Installed, the search box, the count, and one row per result. */ function skpHubListHTML() { const q = SKP.hubQuery; - const search = SKP.hubSearchEditing - ? '' - : '' + esc(q.length ? q : '(all)') + ''; const n = SKP.hubRows.length; + // Not editing: a button that opens the search (`/`), carrying the TUI's `hub search: ` as its text. + const search = SKP.hubSearchEditing + ? '' + : ''; let body; - if (SKP.hubLoading && !n) body = '
browsing the skill hub…
'; - else if (!n) body = '
no skills found — press `/` to search, `r` to re-browse, or `Esc` to go back.
'; - else { + if (SKP.hubLoading && !n) body = '

browsing the skill hub…

'; + else if (!n) { + body = '
no skills found — press `/` to search, `r` to re-browse, or `Esc` to go back.' + + '' + + '
'; + } else { const cur = Math.max(0, Math.min(SKP.hubCursor, n - 1)); const start = computeWindowStart(cur, n, SKP_HUB_ROWS); const page = SKP.hubRows.slice(start, start + SKP_HUB_ROWS); const hiddenAfter = Math.max(0, n - start - page.length); - body = (start > 0 ? '' : '') + body = '
' + + (start > 0 ? '' : '') + page.map((r, idx) => { const i = idx + start, sel = i === cur; - // HubRow: `{chevron} {claw|gh } {identifier(32)}{↓dl(9)}{description(48)}` - return ''; + const claw = r.source === 'clawhub'; + // HubRow: source · identifier · downloads · description. + return ''; }).join('') - + (hiddenAfter > 0 ? '' : ''); + + (hiddenAfter > 0 ? '' : '') + + '
'; } - return '
hub search: ' + search + (SKP.hubSearchEditing ? '' : '') - + ' ' + n + ' result' + (n === 1 ? '' : 's') + (SKP.hubLoading ? ' · …' : '') + '
' - + (SKP.hubError ? '
! ' + esc(SKP.hubError) + '
' : '') - + (SKP.installError ? '
! install failed: ' + esc(SKP.installError) + '
' : '') + return '
' + + '' + + search + + '' + n + ' result' + (n === 1 ? '' : 's') + '' + (SKP.hubLoading ? '' : '') + + '' + + '
' + + (SKP.hubError ? '
' + ic('alert') + '' + esc(SKP.hubError) + '
' : '') + + (SKP.installError ? '
' + ic('alert') + 'install failed: ' + esc(SKP.installError) + '
' : '') + skpMessages() + body + tuiHints(['j/k move', ['Enter open card', 'skills:card'], ['/ search', 'skills:hubSearch'], ['r re-browse', 'skills:rebrowse'], ['Esc back', 'skills:back']]); } +/* ST-10: owner, downloads, version and the SKILL.md preview before installing. */ function skpHubCardHTML() { const c = SKP.hubCard; - const badge = c.source === 'clawhub' ? 'claw' : 'gh'; + const claw = c.source === 'clawhub'; + const badge = claw ? 'claw' : 'gh'; let body; - if (c.body === null) body = '
' + esc(c.bodyError || 'loading SKILL.md…') + '
'; - else { + if (c.body === null) { + body = c.bodyError + ? '
' + ic('info') + '' + esc(c.bodyError) + '
' + : '
loading SKILL.md…
'; + } else { const lines = c.body.split('\n'); const start = Math.max(0, Math.min(SKP.cardScroll, Math.max(0, lines.length - SKP_CARD_LINES))); const win = lines.slice(start, start + SKP_CARD_LINES); const below = Math.max(0, lines.length - (start + win.length)); body = (start > 0 ? '' : '') - + tuiBodyLines(win) + + '
' + esc(win.join('\n')) + '
' + (below > 0 ? '' : ''); } const canInstall = !!c.installId; - return '
[' + badge + '] ' + esc(c.name) + ' ' + esc(c.identifier) + '
' - + '
owner ' + esc(c.repo) + ' · ↓' + esc(formatDownloads(c.downloads)) + ' · v' + esc(c.version === null ? '—' : c.version) + '
' - + (c.version === null ? '
(version is not printed by `atag skill browse`)
' : '') - + (c.description ? '
' + esc(c.description) + '
' : '') - + '
' + body + '
' - + (SKP.installError ? '
! install failed: ' + esc(SKP.installError) + '
' : '') - + (SKP.installing ? '
installing…
' - : tuiHints([['[i] install', 'skills:install', {disabled: !canInstall}], ['[n] cancel', 'skills:back'], 'j/k scroll'])) + return '
' + + (SKP.installing ? 'installing…' + : '') + + '
' + + '
[' + badge + '] ' + + '

' + esc(c.name) + '

' + esc(c.identifier) + '
' + + '

owner ' + esc(c.repo) + ' · ↓' + esc(formatDownloads(c.downloads)) + ' · v' + esc(c.version === null ? '—' : c.version) + '

' + + (c.version === null ? '

(version is not printed by `atag skill browse`)

' : '') + + (c.description ? '

' + esc(c.description) + '

' : '') + + body + + (SKP.installError ? '
' + ic('alert') + 'install failed: ' + esc(SKP.installError) + '
' : '') + + (SKP.installing ? '' : tuiHints([['[i] install', 'skills:install', {disabled: !canInstall}], ['[n] cancel', 'skills:back'], 'j/k scroll'])) // `atag skill install` takes `@owner/slug`; a catalog-browse row carries only the slug (ClawHub's browse API prints no owner) and the detail answer carries none either. - + (!canInstall && c.source === 'clawhub' ? '
(install needs `@owner/slug` — this browse row has no owner; `/` search lists owner-qualified rows)
' : '') - + '
'; + + (!canInstall && claw ? '

(install needs `@owner/slug` — this browse row has no owner; `/` search lists owner-qualified rows)

' : ''); } /* Loaders and actions. */