From aac2b1c0689aefeb140e08c24278ec486611fe10 Mon Sep 17 00:00:00 2001 From: Agando Date: Sat, 7 Nov 2026 18:19:47 +0100 Subject: feat(newos4): six interface modes (easy -> cockpit) over one terminal Symptom: newOS4 had exactly one layout. A first-time user had no way in that did not require learning commands, and an operator had no surface showing live runtime state; the only way to see heap/VFS/tool-call telemetry was to type cat /sys/... Root cause: no UI layer existed. main.html hard-codes one flex column, and terminal.js binds the stock nodes by id at construction, so an alternative layout had to either duplicate the output stream or reparent the real nodes. Fix: - modules/core/module-uishell.js (new): mode registry, #ui-root, stock-node snapshot/restore, identity check that reverts to classic when a mode removes or clones #terminal-container, shell-docked #ui-foot (the AI stop button exists in every mode), ui command, gated first-run picker, ?ui= honoured only if the mode actually registers, lazy mode loading via the same loader ext modules use. - modules/ext/ui-{easy,chat,studio,workspace,commander,cockpit}.js (new): six reparenting skins with scoped CSS. Workspace/commander/cockpit read real /sys/* mounts and captured command output; no value on screen is invented. - modules/test/test-ui-shell.js (new): 25 assertions - teardown, clone rejection, failed-mount fallback, footer docking, destructive-mode rejection. - terminal.js: register the shell; make one failing ext module stop aborting the rest of the serial load loop (it did, silently, which is why six ui-* files at the end of extKeys never loaded). - module-loader.js: LATEST_VER v4.6.152. The six ui-* keys are deliberately NOT in extKeys - see the rate-limit note below. - admin/scripts/probe-ui-modes.py (new): 6 modes x 2 viewports plus a --cycle pass that switches all seven states on ONE page and asserts the output node keeps its identity. Also fixed, found while testing (pre-existing, production-affecting): - nginx limit_req zone=webx11 burst 50 -> 200. One newOS4 boot fans out ~40 assets; the bucket emptied mid-boot and every later module got 503. Measured in webx11.error.log: "limiting requests, excess: 50.696 by zone webx11" against modules/ext/ui-chat.js. A reload within ~50 s silently broke modules. Sustained rate unchanged at 60r/m. Verified: node modules/test/test-ui-shell.js -> 25/25; removing both _restoreStock() calls measures 20/25 with five FAILs; probe-ui-modes.py --cycle against production -> ALL PASS, 13 checks; served bytes: v4.6.152 x3 in main.html, class UIShellModule x1 in core-bundle.js, six ui-* packages in packages.json. --- modules/ext/ui-cockpit.js | 122 ++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 122 insertions(+) create mode 100644 modules/ext/ui-cockpit.js (limited to 'modules/ext/ui-cockpit.js') diff --git a/modules/ext/ui-cockpit.js b/modules/ext/ui-cockpit.js new file mode 100644 index 0000000..ffb6c2a --- /dev/null +++ b/modules/ext/ui-cockpit.js @@ -0,0 +1,122 @@ +// UI mode: cockpit (level 6). Eight live panes straight from /sys/ + real commands. +class UICockpitMode { + constructor() { this.commands = {}; this.terminal = null; } + requirements() { return { met: true, missing: [] }; } + getHelpInfo() { return {}; } + registerCommands() {} + init(terminal) { + this.terminal = terminal; + if (!terminal.uiShell) return; + terminal.uiShell.registerMode({ + id: 'cockpit', label: 'Cockpit', level: 6, icon: '🛰', + tagline: 'Live system telemetry.', + mount: (api) => this.mount(api) + }); + } + mount(api) { + api.injectCSS('cockpit', ` +body[data-ui-mode="cockpit"] { font-size: 13px; } +body[data-ui-mode="cockpit"] #ui-root { display: grid; grid-template-rows: 1fr 190px; min-height: 0; } +#ck-panes { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 1px; + background: var(--border-color); min-height: 0; overflow-y: auto; } +#ck-panes .pane { background: var(--bg-color); display: flex; flex-direction: column; + min-height: 130px; min-width: 0; transition: outline-color .2s; } +#ck-panes .pane h5 { margin: 0; padding: 5px 8px; font-size: .72em; letter-spacing: .1em; + text-transform: uppercase; color: var(--text-muted); border-bottom: 1px solid var(--border-color); + display: flex; gap: 8px; align-items: center; } +#ck-panes .pane h5 .n { margin-left: auto; color: var(--accent-color); } +#ck-panes pre { flex: 1 1 auto; margin: 0; padding: 6px 8px; overflow: auto; white-space: pre-wrap; + word-break: break-word; font-family: var(--font-primary); font-size: .72em; line-height: 1.35; } +#ck-term { display: flex; flex-direction: column; min-height: 0; border-top: 1px solid var(--border-color); } +#ck-term-bar { flex: 0 0 auto; display: flex; gap: 6px; align-items: center; padding: 4px 8px; + background: var(--bg-secondary); font-size: .78em; } +#ck-term-bar .title { flex: 1 1 auto; color: var(--text-muted); } +#ck-out { flex: 1 1 auto; min-height: 0; overflow: auto; padding: 4px 8px; font-size: .82em; } +#ck-in { flex: 0 0 auto; padding: 4px 8px 8px; } +#ck-in #input-area { display: flex; gap: 6px; align-items: flex-end; } +#ck-in .prompt-label, #ck-in #autocomplete-dropdown { display: none; } +#ck-in #command-input { width: 100%; font-family: var(--font-primary); font-size: .85em; } +@media (max-width: 900px) { + body[data-ui-mode="cockpit"] #ui-root { grid-template-rows: 1fr 44vh; } + #ck-panes { grid-template-columns: 1fr 1fr; } +} +`); + + const root = api.root; + root.innerHTML = ''; + const panes = api.el('div', { id: 'ck-panes' }); + const termWrap = api.el('div', { id: 'ck-term' }); + root.appendChild(panes); root.appendChild(termWrap); + + const defs = [ + { id: 'heap', label: 'heap', cmd: 'cat /sys/heap/stats' }, + { id: 'breakdown', label: 'breakdown', cmd: 'cat /sys/heap/breakdown' }, + { id: 'vfs', label: 'vfs', cmd: 'cat /sys/vfs/stats' }, + { id: 'tools', label: 'tool calls', cmd: 'cat /sys/logs/tool-calls' }, + { id: 'errors', label: 'errors', cmd: 'cat /sys/logs/errors' }, + { id: 'dom', label: 'dom', cmd: 'cat /sys/dom/node-count' }, + { id: 'agents', label: 'agents', cmd: 'agent-list' }, + { id: 'leases', label: 'leases', cmd: 'lease-list' } + ]; + const slots = {}; + defs.forEach((d, i) => { + const pane = api.el('div', { class: 'pane' }); + pane.dataset.pane = String(i + 1); + const h = api.el('h5'); + h.appendChild(api.el('span', { text: 'F' + (i + 1) + ' ' + d.label })); + h.appendChild(api.el('span', { class: 'n' })); + const pre = api.el('pre', { text: '…' }); + pane.appendChild(h); pane.appendChild(pre); + slots[d.id] = { pre, n: h.querySelector('.n') }; + panes.appendChild(pane); + }); + + const bar = api.el('div', { id: 'ck-term-bar' }); + bar.appendChild(api.el('span', { class: 'title', text: 'terminal · F1-F8 focus · F5 refresh' })); + const styleBtn = api.el('button', { class: 'wx11-btn', text: '⇄' }); + styleBtn.addEventListener('click', () => api.exec('ui picker')); + bar.appendChild(styleBtn); + const out = api.el('div', { id: 'ck-out' }); + const inp = api.el('div', { id: 'ck-in' }); + termWrap.appendChild(bar); termWrap.appendChild(out); termWrap.appendChild(inp); + api.attach(out, api.stock.output); + api.attach(inp, api.stock.input); + + // Generation guard: eight async captures per tick at 5s — a tick can outlive teardown. + let gen = 0; + const refresh = async () => { + const my = ++gen; + for (const d of defs) { + try { + const text = await api.capture(d.cmd); + if (my !== gen) return; + const lines = (text || '').trim() ? String(text).trim().split('\n') : ['(empty)']; + slots[d.id].pre.textContent = lines.join('\n'); + slots[d.id].n.textContent = lines.length + 'L'; + } catch (e) { + if (my !== gen) return; + slots[d.id].pre.textContent = 'unavailable: ' + e.message; + slots[d.id].n.textContent = 'ERR'; + } + } + }; + refresh(); + const timer = api.interval(refresh, 5000); + + const onKey = (ev) => { + const m = /^F([1-8])$/.exec(String(ev.key)); + if (!m) return; + ev.preventDefault(); + const idx = parseInt(m[1], 10); + if (idx === 5) { refresh(); return; } // F5 = refresh, as labelled + const pane = panes.querySelector('[data-pane="' + idx + '"]'); + if (!pane) return; + pane.scrollIntoView({ block: 'nearest' }); + pane.style.outline = '1px solid var(--accent-color)'; + setTimeout(() => { pane.style.outline = ''; }, 900); + }; + document.addEventListener('keydown', onKey); + + return () => { gen++; document.removeEventListener('keydown', onKey); api.clearInterval(timer); }; + } +} -- cgit v1.2.3