aboutsummaryrefslogtreecommitdiff
path: root/modules/ext
diff options
context:
space:
mode:
authorAgando <roduccomm@gmail.com>2026-11-07 18:19:47 +0100
committerAgando <roduccomm@gmail.com>2026-11-07 18:19:47 +0100
commitaac2b1c0689aefeb140e08c24278ec486611fe10 (patch)
tree09f9f238378a453acc9c3515e8007622d8efdd71 /modules/ext
parenta64aae243cb95f021f21af791f29645f67a098d1 (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')
-rw-r--r--modules/ext/ui-chat.js99
-rw-r--r--modules/ext/ui-cockpit.js122
-rw-r--r--modules/ext/ui-commander.js188
-rw-r--r--modules/ext/ui-easy.js83
-rw-r--r--modules/ext/ui-studio.js146
-rw-r--r--modules/ext/ui-workspace.js133
6 files changed, 771 insertions, 0 deletions
diff --git a/modules/ext/ui-chat.js b/modules/ext/ui-chat.js
new file mode 100644
index 0000000..32c67e7
--- /dev/null
+++ b/modules/ext/ui-chat.js
@@ -0,0 +1,99 @@
+// UI mode: chat (level 2). Single column, sticky composer, slash chips, raw-terminal drawer.
+class UIChatMode {
+ 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: 'chat', label: 'Chat', level: 2, icon: 'πŸ’¬',
+ tagline: 'Conversation first.',
+ mount: (api) => this.mount(api)
+ });
+ }
+ mount(api) {
+ api.injectCSS('chat', `
+body[data-ui-mode="chat"] #ui-root { display: flex; flex-direction: column; }
+#chat-head { flex: 0 0 auto; display: flex; align-items: center; gap: 8px; padding: 10px 14px;
+ border-bottom: 1px solid var(--border-color); background: var(--bg-secondary); }
+#chat-head .title { font-weight: 600; flex: 1 1 auto; }
+#chat-head button { min-height: 44px; }
+#chat-stream { flex: 1 1 auto; min-height: 0; overflow-y: auto; padding: 12px 14px;
+ display: flex; flex-direction: column; gap: 6px; max-width: 860px; width: 100%; margin: 0 auto; }
+#chat-stream .output-line { font-family: inherit; font-size: .95em; line-height: 1.5;
+ white-space: pre-wrap; word-break: break-word; padding: 10px 12px; border-radius: 14px;
+ background: var(--bg-secondary); border: 1px solid var(--border-color); max-width: 88%; }
+#chat-stream .output-line.info { background: var(--ai-bg-color); border-color: var(--border-color); color: var(--ai-text-color); }
+#chat-stream .output-line.error { border-color: var(--error-color); }
+#chat-chips { flex: 0 0 auto; padding: 8px 14px 0; overflow-x: auto; white-space: nowrap;
+ max-width: 860px; margin: 0 auto; width: 100%; }
+#chat-composer { flex: 0 0 auto; padding: 8px 14px 14px; max-width: 860px; margin: 0 auto; width: 100%; }
+#chat-composer #input-area { display: flex; gap: 8px; align-items: flex-end; background: var(--bg-secondary);
+ border: 1px solid var(--border-color); border-radius: 16px; padding: 8px; }
+#chat-composer .prompt-label { display: none; }
+#chat-composer #command-input { width: 100%; font-family: inherit; font-size: 1em; min-height: 44px; background: transparent; border: 0; }
+#chat-composer #autocomplete-dropdown { display: none; }
+body[data-ui-mode="chat"] #raw-drawer { position: absolute; inset: 0 0 34px 0; z-index: 60;
+ background: var(--bg-color); display: none; flex-direction: column; padding: 10px; }
+body[data-ui-mode="chat"] #raw-drawer.open { display: flex; }
+body[data-ui-mode="chat"] #raw-drawer #terminal-container { flex: 1 1 auto; min-height: 0; overflow: auto;
+ font-family: var(--font-primary); font-size: .9em; }
+`);
+
+ const root = api.root;
+ root.innerHTML = '';
+ const head = api.el('div', { id: 'chat-head' });
+ head.appendChild(api.el('span', { class: 'title', text: 'newOS4' }));
+ const rawBtn = api.el('button', { class: 'wx11-btn', text: '⌨ Plain terminal' });
+ const uiBtn = api.el('button', { class: 'wx11-btn', text: '⇄ Style' });
+ uiBtn.addEventListener('click', () => api.exec('ui picker'));
+ head.appendChild(uiBtn);
+ head.appendChild(rawBtn);
+
+ const stream = api.el('div', { id: 'chat-stream' });
+ const chips = api.el('div', { id: 'chat-chips' });
+ const composer = api.el('div', { id: 'chat-composer' });
+ const drawer = api.el('div', { id: 'raw-drawer' });
+
+ const chipMap = { '/help': 'help', '/files': 'ls', '/modules': 'modules', '/sys': 'cat /sys/info' };
+ ['/help', '/files', '/modules', '/sys', '/style'].forEach(label => {
+ const chip = api.el('span', { class: 'wx11-chip', text: label });
+ chip.addEventListener('click', () => {
+ if (label === '/style') return void api.exec('ui picker');
+ api.exec(chipMap[label]);
+ });
+ chips.appendChild(chip);
+ });
+
+ root.appendChild(head);
+ root.appendChild(stream);
+ root.appendChild(chips);
+ root.appendChild(composer);
+ root.appendChild(drawer);
+
+ api.attach(stream, api.stock.output); // visible stream first
+ api.attach(composer, api.stock.input);
+ // Do NOT attach the output to the drawer here: it is one node, the last attach wins, and
+ // the drawer starts hidden β€” the stream would render empty. The toggle moves it.
+
+ const field = document.getElementById('command-input');
+ if (field) field.placeholder = 'Message newOS4…';
+
+ let open = false;
+ rawBtn.addEventListener('click', () => {
+ open = !open;
+ if (open) { api.attach(drawer, api.stock.output); drawer.classList.add('open'); }
+ else { api.attach(stream, api.stock.output); drawer.classList.remove('open'); }
+ rawBtn.textContent = open ? 'πŸ’¬ Chat view' : '⌨ Plain terminal';
+ });
+
+ return () => {
+ open = false;
+ drawer.classList.remove('open');
+ const f = document.getElementById('command-input');
+ if (f) f.placeholder = 'Type a command...'; // leave the next mode a clean prompt
+ };
+ }
+}
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); };
+ }
+}
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);
+ };
+ }
+}
diff --git a/modules/ext/ui-easy.js b/modules/ext/ui-easy.js
new file mode 100644
index 0000000..eddf11d
--- /dev/null
+++ b/modules/ext/ui-easy.js
@@ -0,0 +1,83 @@
+// UI mode: easy (level 1). Nothing here parses commands; every button calls executeCommand.
+class UIEasyMode {
+ 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: 'easy', label: 'Easy', level: 1, icon: '🌱',
+ tagline: 'Just ask. No commands.',
+ mount: (api) => this.mount(api)
+ });
+ }
+ mount(api) {
+ api.injectCSS('easy', `
+body[data-ui-mode="easy"] { font-family: system-ui, -apple-system, "Segoe UI", sans-serif; }
+body[data-ui-mode="easy"] #ui-root { display: flex; flex-direction: column; padding: 14px 14px 0; gap: 12px; }
+#easy-top { flex: 0 0 auto; }
+#easy-top h1 { font-size: 1.25em; margin: 0 0 4px; font-weight: 600; }
+#easy-actions { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; margin-top: 12px; }
+#easy-actions .wx11-btn { display: flex; align-items: center; gap: 10px; font-size: 1em; min-height: 62px; }
+#easy-actions .ico { font-size: 1.5em; line-height: 1; }
+#easy-chat { flex: 1 1 auto; min-height: 0; overflow-y: auto; display: flex; flex-direction: column; gap: 8px; }
+#easy-chat .output-line { background: var(--bg-secondary); border: 1px solid var(--border-color);
+ border-radius: 12px; padding: 10px 12px; font-family: inherit; font-size: .95em; line-height: 1.45;
+ white-space: pre-wrap; word-break: break-word; }
+#easy-chat .output-line.error { border-color: var(--error-color); }
+#easy-chat .output-line.success { border-color: var(--accent-color); }
+#easy-composer { flex: 0 0 auto; display: flex; gap: 8px; align-items: flex-end; }
+#easy-composer #input-area { flex: 1 1 auto; display: flex; gap: 6px; align-items: flex-end; }
+#easy-composer .prompt-label { display: none; }
+#easy-composer #command-input { font-size: 1em; min-height: 48px; font-family: inherit; width: 100%; }
+#easy-composer #autocomplete-dropdown { display: none; }
+@media (max-width: 600px) { #easy-actions { grid-template-columns: 1fr; } }
+`);
+
+ const root = api.root;
+ root.innerHTML = '';
+ const top = api.el('div', { id: 'easy-top' });
+ top.appendChild(api.el('h1', { text: 'Hi β€” what do you want to do?' }));
+ top.appendChild(api.el('div', { class: 'wx11-dim', text: 'Tap a card, or just type below in plain language.' }));
+
+ const styleBtn = api.el('button', { class: 'wx11-btn', text: '⇄ Change view' });
+ styleBtn.style.cssText = 'margin-top:10px;width:100%;text-align:center';
+ styleBtn.addEventListener('click', () => api.exec('ui picker'));
+ top.appendChild(styleBtn);
+
+ const actions = api.el('div', { id: 'easy-actions' });
+ const cards = [
+ { ico: 'πŸ€–', label: 'Ask the AI', run: () => api.exec('ai "hello β€” what can you help me with?"') },
+ { ico: 'πŸ“', label: 'My files', run: () => api.exec('ls') },
+ { ico: 'πŸŽ“', label: 'Learn', run: () => api.exec('course') },
+ { ico: 'βš™οΈ', label: 'Settings', run: () => api.exec('config') }
+ ];
+ cards.forEach(c => {
+ const b = api.el('button', { class: 'wx11-btn' });
+ b.appendChild(api.el('span', { class: 'ico', text: c.ico }));
+ b.appendChild(api.el('span', { text: c.label }));
+ b.addEventListener('click', c.run);
+ actions.appendChild(b);
+ });
+ top.appendChild(actions);
+
+ const chat = api.el('div', { id: 'easy-chat' });
+ const composer = api.el('div', { id: 'easy-composer' });
+ root.appendChild(top);
+ root.appendChild(chat);
+ root.appendChild(composer);
+
+ // one output stream: the real terminal is restyled into bubbles inside #easy-chat
+ api.attach(chat, api.stock.output);
+ api.attach(composer, api.stock.input);
+ const field = document.getElementById('command-input');
+ if (field) field.placeholder = 'Ask anything…';
+
+ return () => {
+ const f = document.getElementById('command-input');
+ if (f) f.placeholder = 'Type a command...';
+ };
+ }
+}
diff --git a/modules/ext/ui-studio.js b/modules/ext/ui-studio.js
new file mode 100644
index 0000000..adda6a1
--- /dev/null
+++ b/modules/ext/ui-studio.js
@@ -0,0 +1,146 @@
+// UI mode: studio (level 3). Chat + artifact pane fed by the real VFS.
+class UIStudioMode {
+ 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: 'studio', label: 'Studio', level: 3, icon: '🎨',
+ tagline: 'Chat plus a place for your files.',
+ mount: (api) => this.mount(api)
+ });
+ }
+ mount(api) {
+ api.injectCSS('studio', `
+body[data-ui-mode="studio"] #ui-root { display: grid; grid-template-columns: 1.15fr .85fr; min-height: 0; }
+#st-left { display: flex; flex-direction: column; min-width: 0; min-height: 0;
+ border-right: 1px solid var(--border-color); }
+#st-left-bar { flex: 0 0 auto; padding: 8px 10px; border-bottom: 1px solid var(--border-color);
+ display: flex; gap: 6px; align-items: center; background: var(--bg-secondary); }
+#st-left-bar .title { flex: 1 1 auto; font-weight: 600; font-size: .95em; }
+#st-chat { flex: 1 1 auto; min-height: 0; overflow-y: auto; padding: 10px; }
+#st-composer { flex: 0 0 auto; padding: 8px; border-top: 1px solid var(--border-color); }
+#st-composer #input-area { display: flex; gap: 6px; align-items: flex-end; }
+#st-composer .prompt-label, #st-composer #autocomplete-dropdown { display: none; }
+#st-composer #command-input { width: 100%; font-family: inherit; font-size: .95em; }
+#st-right { display: flex; flex-direction: column; min-width: 0; min-height: 0; }
+#st-tabs { flex: 0 0 auto; display: flex; gap: 2px; padding: 6px 8px 0; background: var(--bg-secondary);
+ border-bottom: 1px solid var(--border-color); overflow-x: auto; }
+#st-tabs button { min-height: 40px; padding: 6px 12px; background: transparent; border: 1px solid transparent;
+ border-bottom: 0; color: var(--text-muted); font: inherit; font-size: .85em; cursor: pointer; border-radius: 8px 8px 0 0; }
+#st-tabs button.active { background: var(--bg-tertiary); color: var(--text-color); border-color: var(--border-color); }
+#st-body { flex: 1 1 auto; min-height: 0; overflow: auto; padding: 10px; }
+#st-body ul { list-style: none; margin: 0; padding: 0; }
+#st-body li { padding: 9px 10px; border: 1px solid var(--border-color); border-radius: 8px;
+ margin-bottom: 6px; cursor: pointer; display: flex; gap: 8px; align-items: center; font-size: .9em; }
+#st-body li:active { background: var(--bg-tertiary); }
+#st-body .dir { color: var(--info-color); }
+#st-body .size { margin-left: auto; color: var(--text-muted); font-size: .8em; }
+#st-preview { white-space: pre-wrap; word-break: break-word; font-size: .85em; line-height: 1.5; }
+@media (max-width: 820px) {
+ body[data-ui-mode="studio"] #ui-root { grid-template-columns: 1fr; grid-template-rows: 1fr auto; }
+ #st-left { border-right: 0; }
+ #st-right { border-top: 1px solid var(--border-color); max-height: 42vh; }
+}
+`);
+
+ const root = api.root;
+ root.innerHTML = '';
+ const left = api.el('div', { id: 'st-left' });
+ const leftBar = api.el('div', { id: 'st-left-bar' });
+ leftBar.appendChild(api.el('span', { class: 'title', text: 'Chat' }));
+ const styleBtn = api.el('button', { class: 'wx11-btn', text: '⇄' });
+ styleBtn.addEventListener('click', () => api.exec('ui picker'));
+ leftBar.appendChild(styleBtn);
+ const chat = api.el('div', { id: 'st-chat' });
+ const composer = api.el('div', { id: 'st-composer' });
+ left.appendChild(leftBar); left.appendChild(chat); left.appendChild(composer);
+
+ const right = api.el('div', { id: 'st-right' });
+ const tabs = api.el('div', { id: 'st-tabs' });
+ const bodyEl = api.el('div', { id: 'st-body' });
+ right.appendChild(tabs); right.appendChild(bodyEl);
+
+ root.appendChild(left); root.appendChild(right);
+ api.attach(chat, api.stock.output);
+ api.attach(composer, api.stock.input);
+
+ const state = { tab: 'files', path: '/' };
+
+ const renderFiles = () => {
+ bodyEl.innerHTML = '';
+ const ul = api.el('ul');
+ let entries = [];
+ try { entries = api.fs.readDir(state.path) || []; } catch (e) { entries = []; }
+ // MEASURED SHAPE (virtual-fs.js:771-784): [{ name, type, size, _virtual }].
+ // type is 'dir' | 'file' | '?'; /sys/ is injected into the ROOT listing only.
+ if (!Array.isArray(entries)) entries = [];
+ if (state.path !== '/') {
+ const up = api.el('li', { text: 'β¬… ..' });
+ up.addEventListener('click', () => {
+ state.path = state.path.replace(/\/[^/]+$/, '') || '/';
+ renderFiles();
+ });
+ ul.appendChild(up);
+ }
+ entries.slice(0, 200).forEach(entry => {
+ const name = String(entry.name);
+ const isDir = entry.type === 'dir' || entry._virtual;
+ const full = (state.path === '/' ? '' : state.path) + '/' + name;
+ const li = api.el('li');
+ li.appendChild(api.el('span', { class: isDir ? 'dir' : '',
+ text: (isDir ? 'πŸ“ ' : 'πŸ“„ ') + name }));
+ if (!isDir && entry.size) li.appendChild(api.el('span', { class: 'size', text: entry.size + 'B' }));
+ li.addEventListener('click', () => {
+ if (isDir) { state.path = full; renderFiles(); }
+ else { setTab('preview', full); }
+ });
+ ul.appendChild(li);
+ });
+ if (!entries.length) bodyEl.appendChild(api.el('div', { class: 'wx11-dim', text: 'Empty folder.' }));
+ bodyEl.appendChild(ul);
+ };
+
+ const setTab = (which, filePath) => {
+ state.tab = which;
+ Array.prototype.forEach.call(tabs.children, b => {
+ b.classList.toggle('active', b.dataset.tab === which);
+ });
+ if (which === 'files') return renderFiles();
+ if (which === 'preview') {
+ bodyEl.innerHTML = '';
+ const pre = api.el('pre', { id: 'st-preview' });
+ try {
+ pre.textContent = api.fs.readFile(filePath || '/README.md') || '(empty file)';
+ } catch (e) { pre.textContent = 'Cannot read ' + (filePath || '/README.md') + ': ' + e.message; }
+ bodyEl.appendChild(pre);
+ return;
+ }
+ if (which === 'editor') {
+ bodyEl.innerHTML = '';
+ const target = filePath || '/notes.md';
+ const btn = api.el('button', { class: 'wx11-btn', text: 'πŸ“ Open the editor for ' + target });
+ btn.addEventListener('click', () => {
+ api.exec('edit ' + target);
+ api.toast('Editor takes over the chat pane β€” close it to return');
+ });
+ bodyEl.appendChild(btn);
+ bodyEl.appendChild(api.el('div', { class: 'wx11-dim', style: 'margin-top:8px',
+ text: 'The editor module takes over the terminal pane; close it to come back.' }));
+ }
+ };
+
+ [['files', 'πŸ“ Files'], ['preview', 'πŸ‘ Preview'], ['editor', 'πŸ“ Editor']].forEach(([id, label]) => {
+ const b = api.el('button', { text: label });
+ b.dataset.tab = id;
+ b.addEventListener('click', () => setTab(id));
+ tabs.appendChild(b);
+ });
+ setTab('files');
+
+ return () => { /* buttons die with the root */ };
+ }
+}
diff --git a/modules/ext/ui-workspace.js b/modules/ext/ui-workspace.js
new file mode 100644
index 0000000..be7a5e0
--- /dev/null
+++ b/modules/ext/ui-workspace.js
@@ -0,0 +1,133 @@
+// UI mode: workspace (level 4). Card grid; each card is the captured text of a real command.
+// api.capture() is for these fixed strings only β€” user input must use api.exec().
+class UIWorkspaceMode {
+ 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: 'workspace', label: 'Workspace', level: 4, icon: 'πŸ—‚',
+ tagline: 'Everything at a glance.',
+ mount: (api) => this.mount(api)
+ });
+ }
+ mount(api) {
+ api.injectCSS('workspace', `
+body[data-ui-mode="workspace"] #ui-root { display: flex; flex-direction: column; }
+#ws-bar { flex: 0 0 auto; display: flex; align-items: center; gap: 8px; padding: 8px 12px;
+ border-bottom: 1px solid var(--border-color); background: var(--bg-secondary); }
+#ws-bar .title { flex: 1 1 auto; font-weight: 600; font-size: .95em; }
+#ws-bar .stamp { color: var(--text-muted); font-size: .75em; }
+#ws-grid { flex: 1 1 auto; min-height: 0; overflow-y: auto; padding: 12px;
+ display: grid; grid-template-columns: repeat(auto-fill, minmax(270px, 1fr)); gap: 10px; align-content: start; }
+#ws-grid .wx11-card { display: flex; flex-direction: column; min-height: 150px; max-height: 260px; }
+#ws-grid h3 { margin: 0 0 8px; font-size: .85em; text-transform: uppercase; letter-spacing: .06em;
+ color: var(--text-muted); display: flex; gap: 6px; align-items: center; }
+#ws-grid h3 .run { margin-left: auto; font-size: 1.1em; cursor: pointer; padding: 4px 8px; }
+#ws-out { display: none; flex: 0 0 auto; height: 38vh; border-top: 1px solid var(--border-color);
+ background: var(--bg-color); }
+#ws-out.open { display: flex; flex-direction: column; }
+#ws-out #terminal-container { flex: 1 1 auto; min-height: 0; overflow: auto; padding: 6px 10px; font-size: .85em; }
+#ws-composer { flex: 0 0 auto; border-top: 1px solid var(--border-color); padding: 8px 12px; }
+#ws-composer #input-area { display: flex; gap: 6px; align-items: flex-end; }
+#ws-composer .prompt-label, #ws-composer #autocomplete-dropdown { display: none; }
+#ws-composer #command-input { width: 100%; font-family: inherit; }
+@media (max-width: 600px) { #ws-grid { grid-template-columns: 1fr; } #ws-grid .wx11-card { max-height: none; } }
+`);
+
+ const root = api.root;
+ root.innerHTML = '';
+ const bar = api.el('div', { id: 'ws-bar' });
+ bar.appendChild(api.el('span', { class: 'title', text: 'Workspace' }));
+ bar.appendChild(api.el('span', { class: 'stamp', id: 'ws-stamp' }));
+
+ const styleBtn = api.el('button', { class: 'wx11-btn', text: '⇄ Style' });
+ styleBtn.addEventListener('click', () => api.exec('ui picker'));
+ const refreshBtn = api.el('button', { class: 'wx11-btn', text: '⟳ Refresh' });
+ const outBtn = api.el('button', { class: 'wx11-btn', text: '⌨ Output' });
+ let drawerOpen = false;
+ outBtn.addEventListener('click', () => {
+ drawerOpen = !drawerOpen;
+ const d = document.getElementById('ws-out');
+ if (d) d.classList.toggle('open', drawerOpen);
+ outBtn.textContent = drawerOpen ? '⌨ Hide' : '⌨ Output';
+ });
+ bar.appendChild(refreshBtn);
+ bar.appendChild(outBtn);
+ bar.appendChild(styleBtn);
+
+ const grid = api.el('div', { id: 'ws-grid' });
+ const drawer = api.el('div', { id: 'ws-out' });
+ // Output visible by default: a card grid with the terminal collapsed reads as "where did my
+ // answer go?" β€” and the probe (correctly) measures a 0x0 pane as invisible.
+ drawer.classList.add('open');
+ drawerOpen = true;
+ outBtn.textContent = '⌨ Hide';
+ const composer = api.el('div', { id: 'ws-composer' });
+
+ const cards = [
+ { id: 'files', ico: 'πŸ“', title: 'Files', cmd: 'ls -la' },
+ { id: 'sys', ico: 'πŸ–₯', title: 'Heap', cmd: 'cat /sys/heap/stats' },
+ { id: 'modules', ico: '🧩', title: 'Modules', cmd: 'modules' },
+ { id: 'agents', ico: 'πŸ€–', title: 'Agents', cmd: 'agent-list' },
+ { id: 'cron', ico: '⏱', title: 'Scheduled', cmd: 'cron-list' },
+ { id: 'errors', ico: '🐞', title: 'Recent errors', cmd: 'cat /sys/logs/errors' }
+ ];
+ const pres = {};
+ cards.forEach(c => {
+ const card = api.el('div', { class: 'wx11-card' });
+ const h = api.el('h3');
+ h.appendChild(api.el('span', { text: c.ico }));
+ h.appendChild(api.el('span', { text: c.title }));
+ const run = api.el('span', { class: 'run', text: 'β–Έ' });
+ run.addEventListener('click', () => {
+ const d = document.getElementById('ws-out');
+ if (d && !d.classList.contains('open')) d.classList.add('open');
+ api.exec(c.cmd);
+ api.toast('Running: ' + c.cmd);
+ });
+ h.appendChild(run);
+ const pre = api.el('pre', { class: 'wx11-pre', text: '…' });
+ pres[c.id] = pre;
+ card.appendChild(h); card.appendChild(pre);
+ grid.appendChild(card);
+ });
+
+ root.appendChild(bar);
+ root.appendChild(grid);
+ root.appendChild(drawer);
+ root.appendChild(composer);
+ api.attach(composer, api.stock.input);
+ api.attach(drawer, api.stock.output); // the run-in-terminal output needs somewhere to land
+
+ // Generation guard: capture() is async, so a 15s tick can land AFTER teardown and write into
+ // a detached tree (invisible work, retained DOM). Every refresh claims a generation.
+ let gen = 0;
+ const refresh = async () => {
+ const my = ++gen;
+ let changed = 0;
+ for (const c of cards) {
+ try {
+ const out = await api.capture(c.cmd);
+ if (my !== gen) return;
+ pres[c.id].textContent = (out && out.trim()) ? out : '(empty)';
+ changed++;
+ } catch (e) {
+ if (my !== gen) return;
+ pres[c.id].textContent = 'unavailable: ' + e.message;
+ }
+ }
+ if (my !== gen) return;
+ const stamp = document.getElementById('ws-stamp');
+ if (stamp) stamp.textContent = changed + '/' + cards.length + ' Β· ' +
+ new Date().toLocaleTimeString();
+ };
+ refreshBtn.addEventListener('click', refresh);
+ refresh();
+ const timer = api.interval(refresh, 15000);
+ return () => { gen++; api.clearInterval(timer); };
+ }
+}