diff options
| author | Agando <roduccomm@gmail.com> | 2026-11-07 18:19:47 +0100 |
|---|---|---|
| committer | Agando <roduccomm@gmail.com> | 2026-11-07 18:19:47 +0100 |
| commit | aac2b1c0689aefeb140e08c24278ec486611fe10 (patch) | |
| tree | 09f9f238378a453acc9c3515e8007622d8efdd71 /modules/ext/ui-commander.js | |
| parent | a64aae243cb95f021f21af791f29645f67a098d1 (diff) | |
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.
Diffstat (limited to 'modules/ext/ui-commander.js')
| -rw-r--r-- | modules/ext/ui-commander.js | 188 |
1 files changed, 188 insertions, 0 deletions
diff --git a/modules/ext/ui-commander.js b/modules/ext/ui-commander.js new file mode 100644 index 0000000..7f980ea --- /dev/null +++ b/modules/ext/ui-commander.js @@ -0,0 +1,188 @@ +// UI mode: commander (level 5). Left rail + dominant terminal + telemetry rail + Ctrl-K palette. +class UICommanderMode { + 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: 'commander', label: 'Commander', level: 5, icon: '⌨️', + tagline: 'Terminal with rails.', + mount: (api) => this.mount(api) + }); + } + /* every command the running system knows, from the modules that own them */ + _commands(api) { + const out = []; + const mods = (api.terminal.moduleManager && api.terminal.moduleManager.modules) || {}; + for (const name of Object.keys(mods)) { + const m = mods[name]; + if (!m || typeof m.getHelpInfo !== 'function') continue; + let info = {}; + try { info = m.getHelpInfo() || {}; } catch (e) { continue; } + for (const cmd of Object.keys(info)) { + out.push({ cmd, desc: typeof info[cmd] === 'string' ? info[cmd] : '', mod: name }); + } + } + return out.sort((a, b) => a.cmd.localeCompare(b.cmd)); + } + mount(api) { + api.injectCSS('commander', ` +body[data-ui-mode="commander"] #ui-root { display: grid; grid-template-columns: 232px 1fr 260px; min-height: 0; } +#cm-left, #cm-right { display: flex; flex-direction: column; min-height: 0; overflow-y: auto; + background: var(--bg-secondary); font-size: .85em; } +#cm-left { border-right: 1px solid var(--border-color); } +#cm-right { border-left: 1px solid var(--border-color); } +#cm-left h4, #cm-right h4 { margin: 0; padding: 9px 10px; font-size: .72em; letter-spacing: .08em; + text-transform: uppercase; color: var(--text-muted); border-bottom: 1px solid var(--border-color); } +#cm-left .item { padding: 9px 10px; cursor: pointer; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } +#cm-left .item:hover { background: var(--bg-tertiary); } +#cm-center { display: flex; flex-direction: column; min-width: 0; min-height: 0; } +#cm-center-bar { flex: 0 0 auto; display: flex; align-items: center; gap: 8px; padding: 6px 10px; + border-bottom: 1px solid var(--border-color); } +#cm-center-bar .title { flex: 1 1 auto; font-size: .85em; color: var(--text-muted); } +#cm-term { flex: 1 1 auto; min-height: 0; overflow: auto; padding: 8px 10px; } +#cm-composer { flex: 0 0 auto; border-top: 1px solid var(--border-color); padding: 6px 8px; } +#cm-composer #input-area { display: flex; gap: 6px; align-items: flex-end; } +#cm-composer .prompt-label, #cm-composer #autocomplete-dropdown { display: none; } +#cm-composer #command-input { width: 100%; font-family: var(--font-primary); font-size: .9em; } +#cm-right pre { margin: 0; padding: 8px 10px; white-space: pre-wrap; word-break: break-word; font-size: .74em; line-height: 1.4; } +#cm-palette { position: fixed; inset: 0; z-index: 80; background: rgba(0,0,0,.6); display: none; + padding: 12vh 16px 16px; } +#cm-palette.open { display: block; } +#cm-palette .box { max-width: 640px; margin: 0 auto; background: var(--bg-secondary); + border: 1px solid var(--accent-color); border-radius: 10px; overflow: hidden; } +#cm-palette input { width: 100%; padding: 14px; font: inherit; font-size: 1em; background: var(--bg-tertiary); + color: var(--text-color); border: 0; outline: 0; } +#cm-palette ul { list-style: none; margin: 0; padding: 0; max-height: 46vh; overflow-y: auto; } +#cm-palette li { padding: 11px 14px; cursor: pointer; border-top: 1px solid var(--border-color); font-size: .9em; } +#cm-palette li.sel { background: var(--bg-tertiary); border-left: 3px solid var(--accent-color); } +#cm-palette li .d { color: var(--text-muted); font-size: .82em; display: block; margin-top: 2px; } +@media (max-width: 900px) { + body[data-ui-mode="commander"] #ui-root { grid-template-columns: 1fr; } + #cm-left, #cm-right { display: none; } +} +`); + + const root = api.root; + root.innerHTML = ''; + const left = api.el('div', { id: 'cm-left' }); + const center = api.el('div', { id: 'cm-center' }); + const right = api.el('div', { id: 'cm-right' }); + root.appendChild(left); root.appendChild(center); root.appendChild(right); + + // left rail: the real VFS root listing + left.appendChild(api.el('h4', { text: 'Files' })); + try { + // readDir('/') returns [{name,type,...}] and always includes the virtual `sys` dir + const entries = api.fs.readDir('/') || []; + entries.slice(0, 40).forEach(entry => { + const name = String(entry.name); + const isDir = entry.type === 'dir' || entry._virtual; + const it = api.el('div', { class: 'item', text: (isDir ? '📁 ' : '📄 ') + name }); + it.addEventListener('click', () => api.exec((isDir ? 'ls ' : 'cat ') + name)); + left.appendChild(it); + }); + } catch (e) { left.appendChild(api.el('div', { class: 'item', text: '(' + e.message + ')' })); } + + const bar = api.el('div', { id: 'cm-center-bar' }); + bar.appendChild(api.el('span', { class: 'title', text: 'terminal' })); + const palBtn = api.el('button', { class: 'wx11-btn', text: '⌘K' }); + const styleBtn = api.el('button', { class: 'wx11-btn', text: '⇄' }); + styleBtn.addEventListener('click', () => api.exec('ui picker')); + bar.appendChild(palBtn); bar.appendChild(styleBtn); + + const term = api.el('div', { id: 'cm-term' }); + const composer = api.el('div', { id: 'cm-composer' }); + center.appendChild(bar); center.appendChild(term); center.appendChild(composer); + api.attach(term, api.stock.output); + api.attach(composer, api.stock.input); + + // right rail: live /sys telemetry + right.appendChild(api.el('h4', { text: 'sys' })); + const pre = api.el('pre', { text: '…' }); + right.appendChild(pre); + let gen = 0; + const refresh = async () => { + const my = ++gen; + try { + const text = await api.capture('cat /sys/heap/stats'); + if (my !== gen) return; + pre.textContent = text || '(empty)'; + } catch (e) { + if (my !== gen) return; + pre.textContent = 'unavailable: ' + e.message; + } + }; + refresh(); + const timer = api.interval(refresh, 20000); + + // palette + const all = this._commands(api); + let filtered = all.slice(); + let sel = 0; + const pal = api.el('div', { id: 'cm-palette' }); + pal.innerHTML = '<div class="box"><input id="cm-pal-input" placeholder="Run a command…" ' + + 'autocomplete="off" spellcheck="false"><ul id="cm-pal-list"></ul></div>'; + document.body.appendChild(pal); + const input = pal.querySelector('#cm-pal-input'); + const listEl = pal.querySelector('#cm-pal-list'); + + const draw = () => { + listEl.innerHTML = ''; + filtered.slice(0, 40).forEach((c, i) => { + const li = api.el('li', { class: i === sel ? 'sel' : '' }); + // textContent, not innerHTML: help strings are code-authored but not markup + li.appendChild(api.el('b', { text: String(c.cmd) })); + li.appendChild(api.el('span', { class: 'd', text: String(c.desc || c.mod).slice(0, 90) })); + li.addEventListener('click', () => pick(c)); + listEl.appendChild(li); + }); + }; + const filter = () => { + const q = input.value.trim().toLowerCase(); + filtered = q ? all.filter(c => c.cmd.toLowerCase().indexOf(q) !== -1 || + String(c.desc).toLowerCase().indexOf(q) !== -1) : all.slice(); + sel = 0; draw(); + }; + const pick = (entry) => { + closePalette(); + const f = document.getElementById('command-input'); + // A usage like "ai <prompt>" or "cron-every 60 cmd" must be EDITABLE, not executed + // half-written — running it would fire a command with a missing argument. + const needsArg = /[<\[]/.test(String(entry.desc || '')); + if (needsArg) { + if (f) { f.value = entry.cmd + ' '; f.focus(); } + api.toast('Edit the command, then send'); + return; + } + api.exec(entry.cmd); + }; + const openPalette = () => { input.value = ''; filter(); pal.classList.add('open'); input.focus(); }; + const closePalette = () => pal.classList.remove('open'); + + palBtn.addEventListener('click', () => openPalette()); + input.addEventListener('input', filter); + input.addEventListener('keydown', (ev) => { + if (ev.key === 'Escape') return closePalette(); + if (ev.key === 'ArrowDown') { sel = Math.min(sel + 1, filtered.length - 1); draw(); ev.preventDefault(); return; } + if (ev.key === 'ArrowUp') { sel = Math.max(sel - 1, 0); draw(); ev.preventDefault(); return; } + if (ev.key === 'Enter' && filtered[sel]) { pick(filtered[sel]); ev.preventDefault(); } + }); + const onKey = (ev) => { + if ((ev.ctrlKey || ev.metaKey) && String(ev.key).toLowerCase() === 'k') { + ev.preventDefault(); openPalette(); + } + }; + document.addEventListener('keydown', onKey); + + return () => { + gen++; + document.removeEventListener('keydown', onKey); + pal.remove(); + api.clearInterval(timer); + }; + } +} |