diff --git a/desktop/renderer/css/settings-data.css b/desktop/renderer/css/settings-data.css index 522e91d5..594d7107 100644 --- a/desktop/renderer/css/settings-data.css +++ b/desktop/renderer/css/settings-data.css @@ -1,4 +1,154 @@ /* ============================================================ Settings tabs — Memory, MCP, Telegram, Import. Screens ST-12…19, ST-28…32. Loaded after css/settings.css (frame, nav, Tasks, Skills, Privacy). + Everything here is scoped to .sd-pane — the root these four tabs emit — + and composes the kit in styles.css; no kit rule is redefined. ============================================================ */ + +/* ---- Pane ---- */ +.sd-pane{display:flex;flex-direction:column;gap:14px;min-width:0;position:relative;container-type:inline-size; + font-family:var(--font-ui);font-size:14px;line-height:1.45;white-space:normal;color:var(--ink)} +.sd-pane .grow,.sd-grow{flex:1;min-width:0} +.sd-pane > .tk-bar{min-height:34px} + +/* The terminal twin: the TUI strings the settings smoke reads from innerText. + Rendered (so innerText keeps it), clipped to nothing, hidden from assistive tech. */ +.sd-twin{position:absolute;top:0;left:0;width:1px;height:1px;overflow:hidden;clip-path:inset(50%);white-space:pre;pointer-events:none} + +/* ---- Shared text ---- */ +.sd-cap{margin:0;font-size:12.5px;line-height:1.45;color:var(--ink-2);display:flex;align-items:center;gap:6px} +.sd-cap > svg{width:14px;height:14px;color:var(--ink-3)} +.sd-cap.sd-block{display:block} +.sd-mono{font-family:var(--font-mono);letter-spacing:0} +.sd-code{font:400 12px/1 var(--font-mono);letter-spacing:0;padding:2px 5px;border-radius:6px;background:var(--panel-2);color:var(--ink);white-space:nowrap} +.sd-title{margin:0;font-size:19px;line-height:1.25;font-weight:600;letter-spacing:-.02em;color:var(--ink);min-width:0;overflow-wrap:anywhere} +.sd-title.sd-mono{font-size:18px;font-weight:500;letter-spacing:0} +.sd-desc{margin:0;font-size:13.5px;line-height:1.5;color:var(--ink-2)} +.sd-status{display:inline-flex;align-items:center;gap:7px;min-width:0;font-size:12.5px;color:var(--ink-2)} +.sd-status .tk-dot{width:7px;height:7px} +.sd-status > span{white-space:nowrap;overflow:hidden;text-overflow:ellipsis} +.sd-quiet.tk-notice{background:var(--well);box-shadow:none;color:var(--ink-2);font-size:12.5px} +.sd-quiet.tk-notice > svg{color:var(--ink-3)} +.sd-more{align-self:flex-start} +.sd-pane .tk-empty{padding:30px 20px} +.sd-pane .tk-chip.sd-mono{font-family:var(--font-mono);font-size:11px} +/* `.tk-modal p` / `.sd-tgcard p` (0,1,1) outrank the kit's help tones (0,1,0); an error must stay red. */ +.sd-pane .tk-help--err{color:var(--critical)} + +/* The shared settings helpers, as they sit inside these panes + (settings.css still styles .tuihint/.tuimsg for the terminal-text tabs). */ +.sd-pane .tuihint{margin:0;white-space:normal;color:var(--ink-2)} +.sd-pane .tuihint .tk-hint{font:400 12px/1 var(--font-ui);height:24px;padding:0 6px;border-radius:8px;color:var(--ink-2)} +.sd-pane .tuihint button.tk-hint:hover{background:var(--hover);color:var(--ink)} +.sd-pane .tuihint button.tk-hint:disabled{background:transparent;color:var(--ink-3);cursor:default} +.sd-pane .tuimsg{color:var(--ink)} +.sd-pane .tk-bar > .tk-hint{font:400 12px/1 var(--font-ui);height:26px;padding:0 9px;border-radius:var(--r-pill);color:var(--ink-2)} +.sd-pane .tk-bar > .tk-hint:hover{background:var(--hover);color:var(--ink)} + +/* ---- Memory · ST-12…15 ---- */ +.sd-tblwrap{padding:4px;overflow-x:auto} +.sd-memtbl td.mono{font:600 12.5px/1.3 var(--font-mono);color:var(--ink)} +.sd-memtbl td.sd-val{white-space:normal;color:var(--ink);min-width:160px} +.sd-memtbl tbody tr.on td:first-child{color:var(--blue)} +.sd-memtbl .tk-chip svg{width:12px;height:12px} +.sd-memtbl th:nth-child(3),.sd-memtbl td:nth-child(3),.sd-memtbl th:nth-child(4),.sd-memtbl td:nth-child(4){width:1%} +.sd-memrow .sd-id{flex:none;min-width:44px;font:400 12px/1.3 var(--font-mono);color:var(--ink-2);white-space:nowrap} +.sd-memrow.on .sd-id{color:var(--blue)} +.sd-memrow .t{font-size:13.5px;font-weight:500} +.sd-memrow .m{font-size:11.5px;color:var(--ink-2)} +.sd-tags{display:flex;flex-wrap:wrap;gap:4px;margin-top:4px} + +.sd-note{display:grid;grid-template-columns:minmax(0,1.3fr) minmax(0,1fr);gap:18px;align-items:start} +@container (max-width:620px){.sd-note{grid-template-columns:minmax(0,1fr)}} +.sd-notemain,.sd-detail{display:flex;flex-direction:column;gap:12px;min-width:0} +.sd-prose{font-size:14.5px;line-height:1.6;color:var(--ink);white-space:pre-wrap;overflow-wrap:anywhere} +.sd-plate{gap:8px 22px;margin-top:4px} +.sd-plate .sd-span{grid-column:1 / -1;color:var(--ink-2)} +.sd-links{display:flex;flex-direction:column;gap:1px;padding:12px 10px;border-radius:var(--r-card);background:var(--well);min-width:0} +.sd-lh{padding:2px 8px 4px;font-size:13.5px;font-weight:600;color:var(--ink)} +.sd-lsec{padding:8px 8px 2px;font-size:12px;font-weight:600;color:var(--ink-2)} +.sd-link{display:flex;align-items:center;gap:8px;width:100%;min-height:34px;padding:0 6px 0 8px;border-radius:12px;font-size:13px;color:var(--ink); + transition:background var(--t-state) var(--ease)} +.sd-link:hover{background:var(--hover)} +.sd-link.on{background:var(--brand-wash)} +.sd-link > svg{width:14px;height:14px;color:var(--ink-3)} +.sd-lid{font:400 12.5px/1 var(--font-mono);color:var(--ink)} +.sd-link.on .sd-lid{color:var(--blue)} +.sd-lkind{min-width:0;color:var(--ink-2);white-space:nowrap;overflow:hidden;text-overflow:ellipsis} +.sd-w{font-family:var(--font-mono);font-size:11px;letter-spacing:0} +.sd-links .tk-chip{background:var(--lift)} +.sd-links .tk-chip.tk-chip--blue{background:var(--brand-wash)} +.sd-chips{display:flex;flex-wrap:wrap;gap:4px;padding:2px 8px 4px} +.sd-links .sd-cap{padding:2px 8px} + +/* ---- MCP · ST-16…19 ---- */ +.sd-srv .body{gap:3px} +.sd-srvname{display:flex;align-items:center;gap:8px;min-width:0} +.sd-srv .t{font:500 13.5px/1.3 var(--font-mono);letter-spacing:0} +.sd-srv .d{white-space:nowrap} +.sd-srv .tk-chip--line,.sd-srvhead .tk-chip--line{height:20px;padding:0 7px;font-size:11px} +.sd-srv .sd-tools{min-width:64px;text-align:right;font-size:12px;color:var(--ink-2)} +.sd-srvhead{display:flex;align-items:center;gap:10px;flex-wrap:wrap;min-width:0} +.sd-count{margin-left:6px;font:400 11.5px/1 var(--font-mono);color:var(--ink-3)} +.tk-seg button.on .sd-count{color:var(--ink-2)} +.sd-tool{min-height:42px} +.sd-toolname{flex:none;width:200px;font:400 13px/1.3 var(--font-mono);color:var(--ink);overflow:hidden;text-overflow:ellipsis;white-space:nowrap} +.sd-tool .d{flex:1;min-width:0;white-space:nowrap} +.sd-tool.on .sd-toolname{color:var(--blue)} +@container (max-width:560px){.sd-toolname{width:150px}} + +/* A modal over the dimmed, inert view it belongs to (add server, remove confirm). */ +.sd-stage{display:grid;min-width:0} +.sd-stage > *{grid-area:1 / 1;min-width:0} +.sd-behind{display:flex;flex-direction:column;gap:14px;opacity:.4;pointer-events:none;user-select:none} +.sd-over{z-index:1;align-self:start;justify-self:center;width:min(560px,100%);padding:4px 0 8px} +.sd-modal.tk-modal{gap:12px;padding:20px} +.sd-mhead{display:flex;align-items:center;gap:10px} +.sd-modal .tk-plate{margin:0} +.sd-modal .acts{flex-wrap:wrap} +/* Short enough that the error line and Add stay in view on a 620px window; it grows by drag. */ +textarea.tk-inp.sd-json{width:100%;height:104px;min-height:80px;font:400 12.5px/1.55 var(--font-mono);letter-spacing:0;white-space:pre;overflow:auto} + +/* ---- Telegram · ST-28…30 ---- */ +.sd-tgcard.tk-card.pad{width:min(480px,100%);align-self:center;gap:12px;padding:28px;margin:10px 0 4px} +.sd-tgcard.sd-center{align-items:center;text-align:center} +.sd-tgcard p{margin:0;font-size:14px;line-height:1.55;color:var(--ink-2)} +.sd-tgcard .sd-title{font-size:20px} +.sd-tgcard.sd-center .btn{margin-top:4px} +.sd-tokwrap.tk-inpwrap{height:44px;padding:0 14px} +.sd-tokwrap > svg{color:var(--ink-3)} +.sd-tokwrap input{font:400 13.5px/1 var(--font-mono);letter-spacing:.06em} +.sd-tokwrap.is-error{background:var(--lift);border-color:var(--critical);box-shadow:0 0 0 4px var(--critical-wash)} +.sd-acts{display:flex;align-items:center;gap:8px;margin-top:2px} +.sd-well{display:flex;flex-direction:column;gap:8px;padding:16px 18px;border-radius:var(--r-card);background:var(--well)} +.sd-pairhead{display:flex;align-items:center;gap:10px} +.sd-pairhead b{font-size:15px;font-weight:600;color:var(--ink)} +.sd-pair p{margin:0;font-size:13.5px;line-height:1.5;color:var(--ink-2)} +.sd-rows.tk-card{padding:4px 0} +.sd-rows .tk-setrow{gap:10px;flex-wrap:wrap} +.sd-rows .tk-setrow .body{flex:1 1 220px} +.sd-rows .tk-setrow .d{overflow-wrap:anywhere} +.sd-rows .tk-setrow .d.sd-mono{font-size:12px} + +/* ---- Import · ST-31, ST-32 ---- */ +.sd-imphead{gap:10px} +.sd-form{display:flex;flex-direction:column;gap:14px;max-width:640px;min-width:0} +.sd-frow{border-radius:var(--r-row);transition:background var(--t-state) var(--ease)} +.sd-form > .tk-field.sd-frow{padding:8px 10px;margin:0 -10px} +.sd-form .tk-field .tk-inp{width:100%} +.sd-form .sd-limit{max-width:220px} +.sd-form .tk-field > .tk-seg{justify-self:start} +/* importKey's form cursor (the TUI's ▸): a quiet wash and a blue label, not a selection. */ +.sd-frow.sd-kfocus{background:var(--hover)} +.sd-frow.sd-kfocus .tk-lbl,.sd-frow.sd-kfocus .t{color:var(--blue)} +.sd-rows .tk-setrow.sd-frow{margin:0 4px;padding:8px 12px} +.sd-rows .tk-setrow.sd-kfocus,.sd-rows .tk-setrow.sd-kfocus + .tk-setrow{box-shadow:none} +.btn.sd-kfocus{box-shadow:0 0 0 2px var(--lift),0 0 0 4px var(--brand)} +.sd-form .tk-seg .logo{margin-left:-6px;margin-right:1px} +.sd-run{display:flex;align-items:center;gap:10px} +.sd-sum{display:block;line-height:28px;font-size:12px} +.sd-sep{margin:0 3px;color:var(--ink-3)} +.sd-imptbl{table-layout:fixed} +.sd-imptbl th:nth-child(1),.sd-imptbl th:nth-child(2){width:112px} +.sd-imptbl td.sd-path{font:400 12px/1.3 var(--font-mono);color:var(--ink);overflow:hidden;text-overflow:ellipsis} +.sd-reason{color:var(--ink-2)} diff --git a/desktop/renderer/renderer.js b/desktop/renderer/renderer.js index 58bf93a9..569c337e 100644 --- a/desktop/renderer/renderer.js +++ b/desktop/renderer/renderer.js @@ -13158,37 +13158,80 @@ function memStatusLine() { MEM.search.trim() ? 'search: "' + MEM.search.trim() + '"' : null, memVisibleRows().length + ' shown'].filter(Boolean).join(' · '); } +/* Soft Tactile — the settings smoke (main.ts) still reads the TUI's own + strings out of the body's innerText (`[1:profile]`, the list header, the + import labels…). The redrawn tabs no longer paint them, so each view keeps + them in one visually hidden, aria-hidden "terminal twin" built from the + same state. Delete the twins when the smoke reads the new markup. */ +function sdTwin(text) { return ''; } function memoryTab() { ensureMemoryPoll(); - const labels = MEM.available.map((ch, idx) => { - const label = (idx + 1) + ':' + ch; - return ''; - }).join(' '); - return '
' + labels + '
' - + '
' + esc(memStatusLine()) + '
' - + (MEM.lastError ? '
! ' + esc(MEM.lastError) + '
' : '') + // Every channel the agent knows, as one segmented switch; the ones memory.*.enabled keeps off are disabled and say so. + const flagOf = {profile:'profile', notes:'notes', lessons:'lessons', procedures:'procedures', links:'links', votes:'voting'}; + const seg = MEM_CHANNEL_ORDER.map((ch) => { + const on = ch === MEM.channel; + const off = !MEM.available.includes(ch); + return ''; + }).join(''); + const filter = MEM.channel === 'notes' && MEM.mode === 'list' + ? '
' + MEM_NOTES_FILTERS.map((f) => '').join('') + '
' + : ''; + const dot = MEM.loading ? 'tk-dot--brand tk-dot--pulse' : MEM.auto ? 'tk-dot--green' : 'tk-dot--hollow'; + const bar = MEM.available.map((ch, idx) => { const label = (idx + 1) + ':' + ch; return ch === MEM.channel ? '[' + label + ']' : label; }).join(' '); + return '
' + + '
' + seg + '
' + filter + '' + + '' + esc(memStatusLine()) + '
' + + sdTwin(bar) + + (MEM.lastError ? '
' + ic('alert') + '' + esc(MEM.lastError) + '
' : '') + (MEM.mode === 'list' ? memListHTML() : memDetailHTML()) + '
'; } function memListHTML() { - if (MEM.channelHint) return '
' + esc(MEM.channelHint) + '
'; + if (MEM.channelHint) { + // ST-15: the setting that keeps this channel empty, named in mono. + const m = /^(\S+=false)(.*)$/.exec(MEM.channelHint); + return '
' + ic('info') + '' + + (m ? '' + esc(m[1]) + '' + esc(m[2]) : esc(MEM.channelHint)) + '
' + + '
' + ic('memory') + '

This channel is turned off

'; + } const rows = memVisibleRows(); if (!rows.length) { - if (MEM.lastRefreshedAt === null) return '
(loading…)
'; - return '
(empty) — press `r` to refresh' + (MEM.channel === 'notes' ? ' · `f` cycles active/archived/all' : '') + '
' + memHintsHTML(); + if (MEM.lastRefreshedAt === null) return '

loading…

'; + const none = {profile:'No profile facts', notes:'No notes', lessons:'No lessons', procedures:'No procedures', links:'No links', votes:'No vote events'}[MEM.channel] || 'Nothing here'; + return '
' + ic('memory') + '

' + esc(none) + '

' + + '

Press r to refresh' + (MEM.channel === 'notes' ? ' · f cycles active, archived and all' : '') + '

' + memHintsHTML(); } const cur = Math.max(0, Math.min(MEM.cursor, rows.length - 1)); const start = computeWindowStart(cur, rows.length, MEM_MAX_ROWS); const page = rows.slice(start, start + MEM_MAX_ROWS); const hiddenAfter = Math.max(0, rows.length - start - page.length); - return '
primary secondary / meta [' + esc(MEM.channel) + ']
' - + (start > 0 ? '' : '') - + page.map((r, idx) => { + const more = (dir, n) => ''; + let list; + if (MEM.channel === 'profile') { + // ST-12: key · value · pinned or contextual · vote score. meta is `pinned|contextual[ · vote ±n]` (memRowsProfile). + list = '
' + + page.map((r, idx) => { + const i = idx + start, sel = i === cur; + const [kind, vote] = r.meta.split(' · '); + return '' + + '' + + '' + + ''; + }).join('') + '
KeyValueKindVotes
' + esc(r.primary) + '' + esc(r.secondary) + '' + (kind === 'pinned' ? '' + ic('pin') + 'pinned' : '' + esc(kind || 'contextual') + '') + '' + esc(vote ? vote.replace(/^vote /, '') : '0') + '
'; + } else { + // ST-13: id · first line · tag chips (notes, lessons, procedures); links and votes keep their meta on the right. + const tagged = MEM.channel === 'notes' || MEM.channel === 'lessons' || MEM.channel === 'procedures'; + list = '
' + page.map((r, idx) => { const i = idx + start, sel = i === cur; - // MemoryRow: `{chevron} {primary(30)}{secondary(36)} · {meta(28)}` - return ''; - }).join('') - + (hiddenAfter > 0 ? '' : '') + const tags = tagged && r.meta ? r.meta.split(', ').map((t) => '' + esc(t) + '').join('') : ''; + return ''; + }).join('') + '
'; + } + return sdTwin(' primary secondary / meta [' + MEM.channel + ']') + + (start > 0 ? more('up', start) : '') + list + (hiddenAfter > 0 ? more('down', hiddenAfter) : '') + memHintsHTML(); } function memHintsHTML() { @@ -13199,18 +13242,85 @@ function memHintsHTML() { } function memDetailHTML() { const d = MEM.detail; - if (!d) return '
(loading…)
'; + if (!d) return '

loading…

'; const title = d.channel === 'profile' ? 'profile: ' + d.key : d.channel === 'notes' ? 'note #' + d.id : d.channel === 'lessons' ? 'lesson #' + d.id : d.channel === 'procedures' ? 'procedure #' + d.id : d.channel; - const lines = d.body.split('\n'); - const hidden = lines.length - MEM_DETAIL_LINES; const hints = [['Esc back', 'memory:back'], ['r refresh', 'memory:refresh']]; if (d.channel === 'notes') { hints.push(['g expand graph', 'memory:expand']); hints.push(['Enter neighbor', 'memory:neighbor']); } - return '
' + esc(title) + '
' - + '
' + tuiBodyLines(lines.slice(0, MEM_DETAIL_LINES)) - + (hidden > 0 ? '
… (' + hidden + ' more lines hidden)
' : '') + '
' + const back = {profile:'Profile', notes:'Notes', lessons:'Lessons', procedures:'Procedures', links:'Links', votes:'Votes'}[d.channel] || 'Back'; + const linksOn = memFlag('links') === true; + return '
' + + (d.channel === 'notes' ? '' : '') + + '
' + + (d.channel === 'notes' ? memNoteDetailView(d, title) : memPlateDetailView(d, title)) + tuiHints(hints); } +/* ST-14 — formatNoteDetailBody's text, drawn as a page: the header lines + become a plate, the content a prose well, the `--- links ---` section a + card of neighbour rows. Nothing is re-queried; the body is the source. */ +function memNoteDetailView(d, title) { + const body = d.body, cut = body.indexOf('\n\n'); + const head = (cut < 0 ? body : body.slice(0, cut)).split('\n').slice(1); // line 0 is `#id`, the title says it + let rest = cut < 0 ? '' : body.slice(cut + 2); + const at = rest.lastIndexOf('\n\n--- links ---'); + const links = at < 0 ? null : rest.slice(at + 2).split('\n').slice(1); + if (at >= 0) rest = rest.slice(0, at); + const lines = rest.split('\n'), hidden = lines.length - MEM_DETAIL_LINES; + const plate = head.map((l) => { + const m = /^([a-z_]+): (.*)$/.exec(l); + if (m) return '
' + esc(m[1].replace(/_/g, ' ')) + '
' + esc(m[2]) + '
'; + const a = /^archived (→ .*)$/.exec(l); + return a ? '
archived
' + esc(a[1]) + '
' : '
' + esc(l) + '
'; + }).join(''); + // memPickNeighbor's order — outgoing, incoming, expanded — so the row Enter would open carries .on. + const all = d.outgoing.length + d.incoming.length + d.expandedNeighbors.length; + const pick = all ? Math.max(0, Math.min(MEM.cursor, all - 1)) : -1; + let k = 0; + let card = ''; + if (links) { + const rows = []; + links.forEach((l) => { + const e = /^ {2}([→←]) #(\d+) (.*) \(w=([^)]*)\)$/.exec(l); + if (l === 'outgoing:' || l === 'incoming:') { rows.push('
' + (l === 'outgoing:' ? 'Outgoing' : 'Incoming') + '
'); return; } + if (e) { + const on = k++ === pick; + rows.push(''); + return; + } + const x = /^expanded \(g\): (.*)$/.exec(l); + if (x) { + rows.push('
Expanded (g)
' + x[1].split(', ').map((s) => { + const id = s.replace(/^#/, ''), on = k++ === pick; + return ''; + }).join('') + '
'); + return; + } + if (l === '(none)') rows.push('

No links

'); + }); + card = ''; + } + return '

' + esc(title) + '

' + + '
' + esc(lines.slice(0, MEM_DETAIL_LINES).join('\n').trim()) + '
' + + (hidden > 0 ? '

… (' + hidden + ' more lines hidden)

' : '') + + '
' + plate + '
' + card + '
'; +} +/* Profile, lesson, procedure, link and vote details: the `key: value` lines + before the first blank line become a plate, the rest stays machine text. */ +function memPlateDetailView(d, title) { + const body = d.body, cut = body.indexOf('\n\n'); + const head = (cut < 0 ? body : body.slice(0, cut)).split('\n'); + const lines = cut < 0 ? [] : body.slice(cut + 2).split('\n'); + const hidden = lines.length - MEM_DETAIL_LINES; + const plate = head.map((l) => { + const m = /^([a-z_ ]+): (.*)$/.exec(l); + return m && !m[2].includes(' · ') ? '
' + esc(m[1].replace(/_/g, ' ')) + '
' + esc(m[2]) + '
' : '
' + esc(l) + '
'; + }).join(''); + return '

' + esc(title) + '

' + plate + '
' + + (lines.length ? '
' + esc(lines.slice(0, MEM_DETAIL_LINES).join('\n')) + '
' + (hidden > 0 ? '

… (' + hidden + ' more lines hidden)

' : '') : '') + + '
'; +} /* memory-detail-text.ts, verbatim. */ const MEM_MAX_DETAIL_CHARS = 12000; function memTruncateDetail(text) { return text.length <= MEM_MAX_DETAIL_CHARS ? text : text.slice(0, MEM_MAX_DETAIL_CHARS) + '\n\n[truncated]'; } @@ -13398,13 +13508,13 @@ function memoryAct(what) { if (verb === 'auto') { MEM.auto = !MEM.auto; render(); return; } if (verb === 'filter') { if (MEM.channel !== 'notes') return; - MEM.notesFilter = MEM_NOTES_FILTERS[(MEM_NOTES_FILTERS.indexOf(MEM.notesFilter) + 1) % MEM_NOTES_FILTERS.length]; MEM.cursor = 0; + MEM.notesFilter = MEM_NOTES_FILTERS.includes(arg) ? arg : MEM_NOTES_FILTERS[(MEM_NOTES_FILTERS.indexOf(MEM.notesFilter) + 1) % MEM_NOTES_FILTERS.length]; MEM.cursor = 0; // `filter:` picks one (the segmented control); bare `filter` cycles as `f` does memRefresh(); return; } if (verb === 'page') { const n = memVisibleRows().length; MEM.cursor = Math.max(0, Math.min(MEM.cursor + (arg === 'up' ? -MEM_MAX_ROWS : MEM_MAX_ROWS), n - 1)); render(); return; } if (verb === 'back') { MEM.mode = 'list'; MEM.detailRowKey = null; MEM.detail = null; render(); return; } if (verb === 'expand') { memExpandNeighbors(); return; } - if (verb === 'neighbor') { const id = memPickNeighbor(); if (id !== null) memOpenNoteById(id); return; } + if (verb === 'neighbor') { const id = /^\d+$/.test(arg) ? +arg : memPickNeighbor(); if (id !== null) memOpenNoteById(id); return; } // `neighbor:` is a clicked link row; bare `neighbor` is Enter's pick } /* memory-key-bindings.ts. */ function memoryKey(e, k, inText) { @@ -13505,30 +13615,52 @@ function mcpTab() { ensureMcpPoll(); const rows = mcpRows(); const hint = mcpHint(); - return '
' + esc(mcpStatusLine()) + '
' - + (hint.startsWith('<') ? hint : '
' + esc(hint) + '
') - + (MCP.lastError ? '
! ' + esc(MCP.lastError) + '
' : '') - + (MCP.msg ? (MCP.msg.restart ? '
' + esc(MCP.msg.text) + '
' : '
' + esc(MCP.msg.text) + '
') : '') - + (!rows.length ? '
no MCP servers configured — add entries under `mcp.servers[]` in config.json
' : '') - + (MCP.addModal ? mcpAddModalHTML() : (MCP.mode === 'list' ? mcpListHTML(rows) : mcpDetailHTML()) + (MCP.removeConfirm ? mcpRemoveModalHTML() : '')) + const dot = MCP.loading ? 'tk-dot--brand tk-dot--pulse' : MCP.auto ? 'tk-dot--green' : 'tk-dot--hollow'; + const view = MCP.mode === 'list' ? mcpListHTML(rows) : mcpDetailHTML(); + // ST-18: a modal sits over the dimmed (inert) list or detail it belongs to; its keys and buttons are its own. + const modal = MCP.addModal ? mcpAddModalHTML() : MCP.removeConfirm ? mcpRemoveModalHTML() : ''; + return '
' + + '
' + esc(mcpStatusLine()) + '' + + (MCP.mode === 'list' ? '' : '') + '
' + + (MCP.lastError ? '
' + ic('alert') + '' + esc(MCP.lastError) + '
' : '') + + (MCP.msg ? (MCP.msg.restart ? restartLine(MCP.msg.text) : '
' + ic('info') + '' + esc(MCP.msg.text) + '
') : '') + + (modal + ? '
' + view + '
' + modal + '
' + : view + (hint.startsWith('<') ? hint : '')) + '
'; } function mcpPad(text, width) { text = String(text); return text.length >= width ? text.slice(0, width - 1) + ' ' : text.padEnd(width); } +/* The server's mark: GitHub's when the name, command or URL says github, else the plug badge. */ +function mcpMark(cfg) { + const t = (cfg && cfg.transport) || {}; + const hay = [cfg && cfg.name, t.command, Array.isArray(t.args) ? t.args.join(' ') : '', t.url].join(' ').toLowerCase(); + return (/github/.test(hay) && logoHTML('github', '')) || '' + ic('plug') + ''; +} function mcpListHTML(rows) { - if (!rows.length) return '
(no servers)
'; + if (!rows.length) { + return '
' + ic('plug') + '

No MCP servers

' + + '

no MCP servers configured — add entries under `mcp.servers[]` in config.json

' + sdTwin('(no servers)'); + } const cur = Math.max(0, Math.min(MCP.cursor, rows.length - 1)); const start = Math.max(0, Math.min(rows.length - MCP_MAX_ROWS, Math.max(0, cur - Math.floor(MCP_MAX_ROWS / 2)))); const slice = rows.slice(start, Math.min(rows.length, start + MCP_MAX_ROWS)); - const note = rows.some((r) => r.enabled) ? '
state not exposed — no MCP status route in this agent
' : ''; - return '
' + slice.map((r, idx) => { + const cfgs = mcpServers(); + // ST-16: mark · name + honest state chip · description · transport · trust class · tool count. + const twin = slice.map((r, idx) => (idx + start === cur ? '>' : ' ') + ' ' + mcpPad(r.name, 18) + mcpPad('[' + r.state + ']', 11) + mcpPad(r.transportKind, 18) + mcpPad(r.trust, 16) + + r.toolCount + ' tools · — res · — prompts' + (r.description ? '\n ' + r.description : '')).join('\n'); + return sdTwin(twin) + '
' + slice.map((r, idx) => { const i = idx + start, sel = i === cur; - // Row: `> name(18)[state](11)transport(18)trust(16) tools · — res · — prompts`, then the description - return ''; - }).join('') + '
' + note; + const stateTitle = r.state === 'disabled' ? 'disabled in config.json' : 'state not exposed — no MCP status route in this agent'; + return ''; + }).join('') + '
' + + (rows.some((r) => r.enabled) ? '
' + ic('info') + 'state not exposed — no MCP status route in this agent
' : ''); } function mcpDescribeTransport(cfg) { const t = cfg.transport || {}; @@ -13542,52 +13674,62 @@ function mcpDescribeTransport(cfg) { } function mcpDetailHTML() { const cfg = mcpServers().find((s) => s.name === MCP.detailName); - if (!cfg) return '
(no server selected)
'; + if (!cfg) return '

(no server selected)

'; const row = mcpRows().find((r) => r.name === cfg.name); const tools = mcpToolsFor(cfg.name); const counts = {tools:String(tools.length), resources:'—', prompts:'—'}; - const bar = MCP_TAB_ORDER.map((tab, idx) => { - const label = (idx + 1) + ':' + tab + '(' + counts[tab] + ')'; - return ''; - }).join(' '); - let body; - if (MCP.detailTab !== 'tools') body = '
not exposed by the agent\'s HTTP API
'; - else if (!tools.length) body = '
(empty)
'; + const state = row ? row.state : '—', trust = row ? row.trust : 'approval_gated'; + const stateTitle = state === 'disabled' ? 'disabled in config.json' : 'state not exposed — no MCP status route in this agent'; + const twinBar = MCP_TAB_ORDER.map((tab, idx) => { const label = (idx + 1) + ':' + tab + '(' + counts[tab] + ')'; return tab === MCP.detailTab ? '[' + label + ']' : label; }).join(' '); + const seg = '
' + MCP_TAB_ORDER.map((tab) => '').join('') + '
'; + let body, twinBody = ''; + if (MCP.detailTab !== 'tools') body = '
' + ic('eyeOff') + '

not exposed by the agent\'s HTTP API

'; + else if (!tools.length) { body = '
' + ic('plug') + '

No tools

'; twinBody = '(empty)'; } else { const cur = Math.max(0, Math.min(MCP.detailCursor, tools.length - 1)); const start = Math.max(0, Math.min(tools.length - MCP_MAX_ROWS, Math.max(0, cur - Math.floor(MCP_MAX_ROWS / 2)))); - body = tools.slice(start, start + MCP_MAX_ROWS).map((t, idx) => { - const sel = idx + start === cur; + // ST-17: tool name in mono · description; the cursor row (j/k) carries .on. + body = '
' + tools.slice(start, start + MCP_MAX_ROWS).map((t, idx) => '
' + + '' + esc(t.rawName) + '' + esc(t.description) + '
').join('') + '
' // mcp-detail.tsx formatTool prints `rawName (resourceClass)`; the resource class is not on /api/capabilities. - return '
' + (sel ? '> ' : ' ') + esc(t.rawName) + '
' + (t.description ? '
' + esc(t.description) + '
' : ''); - }).join('') + '
(resource class is not exposed by the agent\'s HTTP API)
'; - } - return '
' + esc(cfg.name) + ' [' + esc(row ? row.state : '—') + '] · trust: ' + esc(row ? row.trust : 'approval_gated') + '
' - + (cfg.description ? '
' + esc(cfg.description) + '
' : '') - + '
' + esc(mcpDescribeTransport(cfg)) + '
' - + (row && row.enabled ? '
state not exposed — no MCP status route in this agent
' : '') - + '
' + bar + '
' - + '
' + body + '
'; + + '

Resource class is not exposed by the agent\'s HTTP API.

'; + } + return '
' + + '
' + + sdTwin(cfg.name + ' [' + state + '] · trust: ' + trust + '\n' + twinBar + (twinBody ? '\n' + twinBody : '')) + + '
' + mcpMark(cfg) + '

' + esc(cfg.name) + '

' + + '' + esc(trust) + '' + + '' + (state === 'disabled' ? 'disabled' : 'state —') + '
' + + (cfg.description ? '

' + esc(cfg.description) + '

' : '') + + '
' + esc(mcpDescribeTransport(cfg)) + '
' + + (row && row.enabled ? '

' + ic('info') + 'state not exposed — no MCP status route in this agent

' : '') + + '
' + seg + '
' + body; } function mcpAddModalHTML() { const m = MCP.addModal; - return '
+ add MCP server' - + '
Paste one MCP server config as JSON. Bare object, or the Claude Desktop / Cursor envelope `{ "mcpServers": { ... } }`.
' - + '
Top-level `command` + `args` (no `transport` wrapper) is also accepted and auto-promoted to stdio.
' - + '' - + (m.error ? '
! ' + esc(m.error) + '
' : '') - + (m.submitting ? '
writing config…
' : '') - + '
' + tuiBtn('Enter: submit', 'mcp:addSubmit', {disabled: m.submitting}) + '· Shift/Alt+Enter: newline ·' + tuiBtn('Esc: cancel', 'mcp:addCancel') + '· restart Atomic Agent for the new server to connect
' - + '
'; + return ''; } function mcpRemoveModalHTML() { const c = MCP.removeConfirm; - return '
remove MCP server?' - + '
name: ' + esc(c.name) + '
' - + '
rewrites config.json; restart Atomic Agent to drop the live connection.
' - + (c.error ? '
! ' + esc(c.error) + '
' : '') - + (c.submitting ? '
working…
' : '
' + tuiBtn('y / Enter = confirm', 'mcp:removeConfirm') + '·' + tuiBtn('n / Esc = keep', 'mcp:removeCancel') + '
') - + '
'; + return '
' + + '
' + ic('trash') + '

Remove MCP server?

' + + '
name
' + esc(c.name) + '
' + + '

Rewrites config.json; restart Atomic Agent to drop the live connection.

' + + (c.error ? '

' + esc(c.error) + '

' : '') + + '
' + (c.submitting ? 'working…' : '') + + '' + + '
'; } /* persist-mcp-server.ts parseAddServerJson: the three accepted shapes — a bare object, the `{ mcpServers: { name: {…} } }` envelope with exactly @@ -15250,47 +15392,71 @@ function telegramTab() { const owner = tgOwner(); let body = ''; if (TG.mode === 'tokenPrompt') body += tgTokenPromptHTML(); - else if (hasToken === null) body += '
reading .env…
'; + else if (hasToken === null) body += '

reading .env…

'; else if (!hasToken) { - // setup-state.ts not_connected (no token). - body += '
Connect Telegram' - + '
Create a bot with @BotFather, copy the token, and paste it here. The token is stored only on this machine.
' - + '
'; + // setup-state.ts not_connected (no token). ST-28: one card, one action. + body += '
' + + '' + ic('send') + '' + + '

Connect Telegram

' + + '

Create a bot with @BotFather, copy the token, and paste it here. The token is stored only on this machine.

' + + '' + + sdTwin('Press Enter to paste a bot token') + '
'; } else if (owner === null) { // setup-state.ts needs_pairing; the CTA would open the pairing window, which only the live channel can. - body += '
One last step — confirm it\'s you' - + '
Open Telegram, DM your bot any message. Atomic Agent will recognise you as the owner.
' - + '
' + esc(TG_PAIRING_NOTE) + '
'; + body += '
' + ic('user') + 'One last step — confirm it\'s you
' + + '

Open Telegram, DM your bot any message. Atomic Agent will recognise you as the owner.

' + + '

' + esc(TG_PAIRING_NOTE) + '

'; } else { // Token + owner: the TUI would say "✅ Telegram is connected" only with the channel `up`, which the desktop cannot see. - body += '
channel state is not exposed by the agent\'s HTTP API — the Telegram tab in `atag tui` shows it live
'; + body += '
' + ic('info') + 'channel state is not exposed by the agent\'s HTTP API — the Telegram tab in `atag tui` shows it live
'; } const advanced = TG.showAdvanced || (hasToken && owner !== null); if (advanced && TG.mode !== 'tokenPrompt') body += tgAdvancedHTML(enabled, hasToken, owner); - // telegram-panel.tsx keeps `· ` inside AdvancedControls; here it is always shown, because the desktop's message carries the restart the serve process needs. - if (TG.message) body += '
· ' + esc(TG.message) + (TG.restart ? ' (the agent loads .env and config.json at start) ' : '') + '
'; - return '
' + body - + '
'; -} -/* telegram-panel.tsx AdvancedControls; `state` is the one fact the desktop cannot read. */ + // telegram-panel.tsx keeps `· ` inside AdvancedControls; here it leads the tab, because the desktop's message carries the restart the serve process needs. + const msg = TG.message ? '
' + ic('info') + '' + esc(TG.message) + + (TG.restart ? ' (the agent loads .env and config.json at start)' : '') + '' + + (TG.restart ? '' : '') + '
' : ''; + return '
' + + tuiBtn(TG.showAdvanced ? 'a — hide advanced' : 'a — advanced', 'telegram:advanced') + '
' + msg + body + '
'; +} +/* telegram-panel.tsx AdvancedControls; `state` is the one fact the desktop cannot read. ST-30: one row per fact, its actions beside it. */ function tgAdvancedHTML(enabled, hasToken, owner) { - return '
state unknown' - + ' enabled ' + (enabled === null ? '—' : enabled ? 'yes' : 'no') + '' - + ' token ' + (hasToken === null ? '—' : hasToken ? 'set' : 'missing') + '' - + ' owner ' + (owner === null ? 'unset' : esc(String(owner))) + '
' - + (TG.lastError ? '
! ' + esc(TG.lastError) + '
' : '') - + '
' + tuiBtn('e — ' + (enabled ? 'disable' : 'enable'), 'telegram:enable', {disabled:TG.busy || enabled === null}) + '·' + tuiBtn('r — restart', 'telegram:restart') + '·' + tuiBtn('R — refresh', 'telegram:refresh') + '
' - + '
' + tuiBtn('T — clear token', 'telegram:clearToken', {disabled:TG.busy || !hasToken}) + '·' + tuiBtn('O — clear owner', 'telegram:clearOwner', {disabled:TG.busy || owner === null}) + '·' + tuiBtn('t — change token', 'telegram:token') + '·' + tuiBtn('o — re-pair', 'telegram:pair', {disabled:true, title:TG_PAIRING_NOTE}) + '
' - ; + const busy = TG.busy; + const facts = 'state unknown enabled ' + (enabled === null ? '—' : enabled ? 'yes' : 'no') + ' token ' + (hasToken === null ? '—' : hasToken ? 'set' : 'missing') + + ' owner ' + (owner === null ? 'unset' : String(owner)); + const tokenChip = hasToken === null ? '' : hasToken ? 'set' : 'missing'; + const ownerChip = owner === null ? 'unset' : '' + esc(String(owner)) + ''; + return sdTwin(facts) + + (TG.lastError ? '
' + ic('alert') + '' + esc(TG.lastError) + '
' : '') + + '
' + + '
State
The channel runs inside the agent; its live state is not readable here.
' + + 'unknown
' + + '
Enabled
telegram.enabled
' + + '
' + + '
Token
TELEGRAM_BOT_TOKEN in .env
' + tokenChip + + '' + + '
' + + '
Owner
telegram.ownerUserId
' + ownerChip + + '' + + '
' + + '
Channel
Restarts with the agent runtime.
' + + '' + + '
' + + '
' + + tuiHints(['e ' + (enabled ? 'disable' : 'enable'), 'r restart', 'R refresh', 't change token', 'T clear token', 'O clear owner']); } -/* telegram-token-prompt.tsx: a password input masks the token; the value never reaches state or the DOM as text. */ +/* telegram-token-prompt.tsx: a password input masks the token; the value never reaches state or the DOM as text. ST-29. */ function tgTokenPromptHTML() { const t = TG.token; - return '
bot token' - + '
Paste the token issued by @BotFather. Saved to .env at mode 0600.
' - + '
>
' - + (t.error ? '
! ' + esc(t.error) + '
' : '') - + '
· · Backspace to edit' + (t.submitting ? ' · saving…' : '') + '
'; + return '
' + + '

Bot token

' + + '

Paste the token issued by @BotFather. Saved to .env at mode 0600.

' + + '' + + (t.error ? '

' + esc(t.error) + '

' : '') + + '
' + (t.submitting ? 'saving…' : '') + '' + + '' + + '
'; } function tgSetMessage(text, restart) { TG.message = text; TG.restart = !!restart; TG.lastError = null; } /* tui-telegram-orchestrator.ts submitToken: empty fails locally, then @@ -15395,44 +15561,76 @@ function impDefaultDir(source) { return IMP.defaults ? (IMP.defaults[source] || function importTab() { const f = IMP.form; const sourceLabel = f.source === 'openclaw' ? 'OpenClaw' : 'Hermes'; - let body = 'Import · ' + sourceLabel + ' → Atomic Agent'; - if (IMP.notice) body += '
! ' + esc(IMP.notice) + '
'; + const report = (IMP.mode === 'preview' || IMP.mode === 'done') && IMP.report; + let body = report ? '' : '
' + logoHTML(f.source === 'openclaw' ? 'openclaw' : 'hermes', 'sm') + '

' + sourceLabel + ' → Atomic Agent

'; + if (IMP.notice) body += '
' + ic('alert') + '' + esc(IMP.notice) + '
'; if (IMP.mode === 'configure') body += impFormHTML(f); - else if (IMP.mode === 'running') body += '
importing… please wait
'; - else if ((IMP.mode === 'preview' || IMP.mode === 'done') && IMP.report) body += impReportHTML(IMP.report, IMP.mode === 'done'); - return '
' + body + '
'; + else if (IMP.mode === 'running') body += '

importing… please wait

'; + else if (report) body += impReportHTML(IMP.report, IMP.mode === 'done'); + return '
' + sdTwin('Import · ' + sourceLabel + ' → Atomic Agent') + body + '
'; } -function impLabel(label, focused) { return '' + esc((focused ? '▸' : ' ') + ' ' + label.padEnd(10) + ': ') + ''; } +/* The TUI's form label (`▸ source-of : `), kept for the terminal twin. */ +function impLabel(label, focused) { return (focused ? '▸' : ' ') + ' ' + label.padEnd(10) + ': '; } function impFormHTML(f) { const fc = f.focus; - const text = (label, field, placeholder) => '
' + impLabel(label, fc === field) + '
'; - const toggle = (label, field, hint) => '
' + impLabel(label, fc === field) + '' + (hint ? ' ' + esc(hint) + '' : '') + '
'; - return '
' - + '
' + impLabel('source-of', fc === 'sourceType') + ' /
' - + text('source', 'source', f.source === 'openclaw' ? '~/.openclaw' : '~/.hermes') - + toggle('sessions', 'sessions') + toggle('cron', 'cron') - + (f.source === 'hermes' ? toggle('secrets', 'secrets', 'OPENROUTER_API_KEY / AIMLAPI_API_KEY') : '') - + toggle('overwrite', 'overwrite', 'replace differing destinations') - + text('limit', 'limit', '(no limit)') - + '
' - + '
↑↓ move · ←/→ switch source · space toggle · type to edit · Enter on Run = preview · Ctrl+Enter preview
'; -} -/* import-panel.tsx ReportView / ReportRow / SummaryRow, over the parsed CLI report. */ + const hermes = f.source === 'hermes'; + // The agent's own option descriptions (src/import//import-options.ts). + const desc = hermes + ? {sessions:'Conversation history (state.db) → sessions.sqlite', cron:'Scheduled jobs (cron/jobs.json) → tasks.sqlite'} + : {sessions:'Transcript logs (agents//sessions/*.jsonl) → sessions.sqlite', cron:'Scheduled jobs (state/openclaw.sqlite cron_jobs) → tasks.sqlite'}; + const sw = (field, title, d) => '
' + + '
' + title + '
' + d + '
' + + '
'; + const twin = [impLabel('source-of', fc === 'sourceType') + (hermes ? '‹hermes› / openclaw ' : ' hermes / ‹openclaw›'), impLabel('source', fc === 'source') + f.sourceDir, + impLabel('sessions', fc === 'sessions') + (f.sessions ? '[✓]' : '[ ]'), impLabel('cron', fc === 'cron') + (f.cron ? '[✓]' : '[ ]'), + hermes ? impLabel('secrets', fc === 'secrets') + (f.secrets ? '[✓]' : '[ ]') : null, impLabel('overwrite', fc === 'overwrite') + (f.overwrite ? '[✓]' : '[ ]'), + impLabel('limit', fc === 'limit') + (f.limit || '(no limit)')].filter((l) => l !== null).join('\n'); + // ST-31. Only the two text inputs carry data-imp-focus: importKey's move() calls setSelectionRange on whatever does. + return '
' + + '
Source
' + + ['hermes', 'openclaw'].map((s) => '').join('') + '
' + + '
' + + '
' + + '
' + sw('sessions', 'Sessions', esc(desc.sessions)) + sw('cron', 'Cron jobs', esc(desc.cron)) + + (hermes ? sw('secrets', 'Secrets', 'OPENROUTER_API_KEY / AIMLAPI_API_KEY') : '') + + sw('overwrite', 'Overwrite', 'replace differing destinations') + '
' + + '
' + + '
' + + '
' + + '

↑↓ move · ←/→ switch source · space toggle · type to edit · Enter on Run = preview · Ctrl+Enter preview

' + + sdTwin(twin) + '
'; +} +/* import-panel.tsx ReportView / ReportRow / SummaryRow, over the parsed CLI report. ST-32. */ function impReportHTML(report, executed) { const items = report.items.slice(0, IMP_REPORT_ROWS); const hidden = report.items.length - items.length; const s = report.summary; - const color = (st) => ({migrated:'sk-on', skipped:'ter', conflict:'sk-off', error:'tuierr'}[st] || 'ter'); - return '
' + (executed ? 'result' : 'preview (dry-run)') + ' · ' + report.items.length + ' item' + (report.items.length === 1 ? '' : 's') + '
' - + items.map((it) => { - const arrow = it.source && it.destination ? it.source + ' → ' + it.destination : (it.source || it.destination || ''); - return '
' + esc(it.status.padEnd(8)) + ' [' + esc(it.kind) + '] ' + esc(arrow) + (it.reason ? ' (' + esc(it.reason) + ')' : '') + '
'; - }).join('') - + (hidden > 0 ? '
… ' + hidden + ' more
' : '') - + '
migrated=' + s.migrated + ' · skipped=' + s.skipped + ' · conflict=' + s.conflict + ' · error=' + s.error + '
' - + (IMP.state === 'nothing' ? '
Nothing to import.
' : '') + const n = report.items.length, noun = n === 1 ? ' item' : ' items'; + const tone = (st) => ({migrated:'tk-chip--green', conflict:'tk-chip--amber', error:'tk-chip--red'}[st] || ''); + const arrowOf = (it) => (it.source && it.destination ? it.source + ' → ' + it.destination : (it.source || it.destination || '')); + const twin = [(executed ? 'result' : 'preview (dry-run)') + ' · ' + n + noun] + .concat(items.map((it) => it.status.padEnd(8) + ' [' + it.kind + '] ' + arrowOf(it) + (it.reason ? ' (' + it.reason + ')' : ''))).join('\n'); + // The summary stays one line of text (`migrated=0 · skipped=2 · …`): the chips are inline, not flex items. + const sum = (label, v, t) => '' + label + '=' + v + ''; + return '

' + (executed ? 'Result' : 'Preview') + ' · ' + n + noun + '

' + + (executed ? (IMP.state === 'applied' ? 'applied' : '') : 'dry run') + + '' + + (executed + ? '' + : '') + + '
' + + '
' + sum('migrated', s.migrated, 'tk-chip--green') + ' · ' + sum('skipped', s.skipped, '') + + ' · ' + sum('conflict', s.conflict, 'tk-chip--amber') + ' · ' + sum('error', s.error, 'tk-chip--red') + '
' + + (items.length ? '
' + + items.map((it) => '' + + '' + + '').join('') + + '
OutcomeKindItem
' + esc(it.status) + '' + esc(it.kind) + '' + esc(arrowOf(it)) + (it.reason ? ' (' + esc(it.reason) + ')' : '') + '
' : '') + + (hidden > 0 ? '

… ' + hidden + ' more

' : '') + + (IMP.state === 'nothing' ? '
' + ic('info') + 'Nothing to import.
' : '') + (executed ? tuiHints([['Enter / Esc back to form', 'import:reset']]) : tuiHints([['y / Enter apply', 'import:apply', {disabled:IMP.busy}], ['e edit', 'import:reset'], ['Esc cancel', 'import:reset']])) - + '
'; + + sdTwin(twin); } /* import-orchestrator.ts runImport: the option set from the toggles, the limit parsed, then one `atag import` subprocess — never while a turn is