summaryrefslogtreecommitdiff
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
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.
-rw-r--r--README.md7
-rw-r--r--help.html15
-rw-r--r--main.html9
-rw-r--r--modules-index.json3
-rw-r--r--modules/core/core-bundle.js531
-rw-r--r--modules/core/module-loader.js5
-rw-r--r--modules/core/module-uishell.js508
-rw-r--r--modules/core/terminal.js21
-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
-rw-r--r--modules/test/test-ui-shell.js214
-rw-r--r--packages.json30
-rw-r--r--scripts/rebuild-bundles.py1
17 files changed, 2107 insertions, 8 deletions
diff --git a/README.md b/README.md
index eb8ef20..168ff7c 100644
--- a/README.md
+++ b/README.md
@@ -45,6 +45,13 @@ a few paths that existed in the source tree (`backups/`, `data/`, personal profi
See `CHANGELOG.md`; the loader version is `vX.Y.Z` in `main.html` and `modules/module-loader.js`.
+## Interface modes (v4.6.152)
+
+Six interfaces over the same terminal: `ui easy` (cards + bubbles), `ui chat` (conversation),
+`ui studio` (chat + files), `ui workspace` (live card grid), `ui commander` (rails + Ctrl-K palette),
+`ui cockpit` (eight /sys panes). `ui picker` opens the chooser, `ui classic` returns to the original
+layout, and `?ui=<mode>` deep-links.
+
## Licence
MIT — see `LICENSE`.
diff --git a/help.html b/help.html
index 7f9161d..ebf5e29 100644
--- a/help.html
+++ b/help.html
@@ -482,6 +482,21 @@ diff a.txt b.txt</pre>
</section>
<!-- ── Commands ── -->
+ <section id="interface-modes">
+ <h2>🖥 Interface modes</h2>
+ <p>newOS4 ships six interfaces over the same terminal. Switch any time — <code>classic</code> is the original layout.</p>
+ <ul>
+ <li><code>ui</code> — list the modes and show usage</li>
+ <li><code>ui easy</code> — four action cards and bubble chat; nothing to memorise</li>
+ <li><code>ui chat</code> — single-column conversation with slash chips</li>
+ <li><code>ui studio</code> — chat plus a files / preview / editor pane</li>
+ <li><code>ui workspace</code> — card grid of live panes (files, heap, modules, agents, cron, errors)</li>
+ <li><code>ui commander</code> — terminal with side rails and a Ctrl-K command palette</li>
+ <li><code>ui cockpit</code> — eight live /sys panes plus a terminal strip, 5 s refresh</li>
+ <li><code>ui next</code> — cycle · <code>ui picker</code> — the chooser · <code>ui classic</code> — back</li>
+ </ul>
+ </section>
+
<section id="commands">
<p>📖 <b>New to feedback?</b> <a href="/feedback-guide.html">How feedback works</a> — which channel to use, what gets sent, and when to expect an answer.</p>
diff --git a/main.html b/main.html
index d9fa261..b3f7bca 100644
--- a/main.html
+++ b/main.html
@@ -2,7 +2,7 @@
<html lang="en">
<head>
<meta charset="UTF-8">
- <meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
+ <meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover, interactive-widget=resizes-content">
<meta name="theme-color" content="#000000">
<title>WebX11 Module System</title>
<style>
@@ -1103,6 +1103,7 @@
<span id="splash-gear" onclick="openStartupModulesSplash(event)" title="Select startup modules">⚙</span>
<div id="splash-title">⚡ WebX11</div>
<div id="splash-version"></div>
+ <div style="margin-top:8px;font-size:.75em;color:var(--text-muted)">Type <b>ui picker</b> (or <b>ui easy|chat|studio|workspace|commander|cockpit</b>) any time to change the interface.</div>
<div id="splash-progress-track"><div id="splash-progress-fill"></div></div>
<div id="splash-phase">Initializing...</div>
<div id="splash-filename"></div>
@@ -1349,13 +1350,13 @@
</script>
<!-- v4.5.63: A2A scratchpad fixes + agent-register arg fix -->
-<script src="modules/core/core-bundle.js?v=v4.6.151"></script>
+<script src="modules/core/core-bundle.js?v=v4.6.152"></script>
<!-- Async dependency-ordered module loader now only initializes terminal and loads optional ext modules -->
-<script src="modules/core/module-loader.js?v=v4.6.151"></script>
+<script src="modules/core/module-loader.js?v=v4.6.152"></script>
<!-- Critical ext bundle loads AFTER webx11 exists so registerModule calls work -->
-<script src="modules/ext/critical-ext-bundle.js?v=v4.6.151"></script>
+<script src="modules/ext/critical-ext-bundle.js?v=v4.6.152"></script>
<script>
// ═══ ONE TRUE VERSION SOURCE ═══
diff --git a/modules-index.json b/modules-index.json
index 81f23af..bbb7d22 100644
--- a/modules-index.json
+++ b/modules-index.json
@@ -1,5 +1,5 @@
{
- "version": "2026-11-02-atlas",
+ "version": "2026-11-07-atlas",
"generated": "2026-06-30T00:00:00.000Z",
"modules": {
"module-agent-registry": {
@@ -650,6 +650,7 @@
"class": "CryptoGzipModule",
"auto_load": false,
"commands": [
+ "curl",
"decrypt",
"encrypt",
"gunzip",
diff --git a/modules/core/core-bundle.js b/modules/core/core-bundle.js
index 4483709..36f6e7a 100644
--- a/modules/core/core-bundle.js
+++ b/modules/core/core-bundle.js
@@ -4071,6 +4071,7 @@ Only output XML function calls for tool tasks. If no tool is needed, answer norm
this._safeRegister('SmartContextModule', 'smart context');
this._safeRegister('SimpleGUIModule', 'simple GUI builder');
this._safeRegister('DebugSupervisorModule', 'debug supervisor');
+ this._safeRegister('UIShellModule', 'UI shell');
// v4.5.47: Critical ext modules promoted to boot path
this._safeRegister('EditorModule', 'editor');
this._safeRegister('ShellModule', 'shell');
@@ -4119,6 +4120,7 @@ Only output XML function calls for tool tasks. If no tool is needed, answer norm
BusyBoxModule: typeof BusyBoxModule !== 'undefined' ? BusyBoxModule : undefined,
SmartContextModule: typeof SmartContextModule !== 'undefined' ? SmartContextModule : undefined,
DebugSupervisorModule: typeof DebugSupervisorModule !== 'undefined' ? DebugSupervisorModule : undefined,
+ UIShellModule: typeof UIShellModule !== 'undefined' ? UIShellModule : undefined,
BillingModule: typeof BillingModule !== 'undefined' ? BillingModule : undefined,
};
const cls = registry[className];
@@ -4208,7 +4210,24 @@ Only output XML function calls for tool tasks. If no tool is needed, answer norm
const pkg = index.packages[key];
if (pkg) {
const url = pkg.url + (pkg.url.includes('?') ? '&v=' : '?v=') + cacheVer;
- await this.moduleManager.loadModuleFromURL(url);
+ // One bad module must not abort the rest: this loop is serial and used to
+ // return on the first 404/503/parse error, silently skipping every key
+ // after it (measured 2026-11-07: a 503 on an earlier key meant the six
+ // ui-* modes at the end never loaded).
+ try {
+ await this.moduleManager.loadModuleFromURL(url);
+ } catch (e) {
+ // A 503 here is nginx `limit_req zone=webx11` (60r/m, burst 50): the
+ // bucket is per-IP, so a boot with many modules drains it and every
+ // later module is refused. Back off and retry instead of losing the
+ // module — measured 2026-11-07, six ui-* files 503'd every load.
+ try {
+ await new Promise(r => setTimeout(r, 700));
+ await this.moduleManager.loadModuleFromURL(url);
+ } catch (e2) {
+ console.warn(`[Ext] ${key} failed to load: ${e2.message}`);
+ }
+ }
}
}
}
@@ -22939,4 +22958,514 @@ class DebugSupervisorModule {
}
}
+/* === module-uishell.js === */
+// newOS4 UI shell (v4.6.152) — one output stream, seven chromes.
+//
+// Rules this file exists to enforce:
+// • modes REPARENT the stock nodes (#terminal-container, #input-area, #quick-actions,
+// #status-bar); they never recreate them. terminal.js holds those references from
+// construction, so a fresh node silently orphans the AI output.
+// • teardown restores the stock nodes and clears the mode's DOM; the post-mount check compares
+// node IDENTITY, because a same-id clone passes a presence check.
+// • the shell, not the mode, docks #status-bar (it holds the AI stop button) into #ui-foot.
+// • ext modules are not a sandbox — they already hold window.webx11. The api() below is
+// ergonomics, not a privilege boundary. Do not "harden" it into theatre.
+class UIShellModule {
+ constructor() {
+ this.commands = {};
+ this.terminal = null;
+ this.modes = {}; // id -> { id, label, level, icon, tagline, mount }
+ this.current = 'classic';
+ this._root = null;
+ this._cleanup = null;
+ this._where = null; // [{ el, parent, next }] captured on first mount
+ this._terminalEl = null; // identity of the one real output node
+ this._cssIds = [];
+ this._pending = null; // mode requested before its ext module loaded
+ this._picker = null;
+ this._fromQuery = false;
+ this.key = 'webx11_ui_mode';
+ this.seenKey = 'webx11_ui_seen';
+ }
+
+ requirements() { return { met: true, missing: [] }; }
+
+ getHelpInfo() {
+ return {
+ 'ui': 'ui [list|<mode>|next|picker|classic] — Switch interface: classic | easy | chat | studio | workspace | commander | cockpit'
+ };
+ }
+
+ registerCommands() { this.commands['ui'] = (args) => this.handle(args || []); }
+
+ /* ---------- boot ---------- */
+ init(terminal) {
+ this.terminal = terminal;
+ terminal.uiShell = this; // ext mode modules reach the shell through this
+ this._injectBaseCSS();
+ this._root = this._ensureRoot();
+
+ // ?ui= is a REQUEST, not a preference: honoured only if that mode actually registers, so a
+ // shared link with a typo cannot poison localStorage or suppress onboarding.
+ let wanted = localStorage.getItem(this.key) || 'classic';
+ try {
+ const q = new URLSearchParams(location.search).get('ui');
+ if (q) { wanted = q; this._fromQuery = true; }
+ } catch (e) { /* no URLSearchParams: ignore the request */ }
+ const applied = this.apply(wanted, { silent: true, persist: this._fromQuery });
+ if (!applied) this._pending = wanted; // ext modules register ~800 ms later
+
+ // A requested/stored mode that never registers (404, parse error) is forgotten rather than
+ // kept as a dead preference forever.
+ this._sweepT = setTimeout(() => this._sweepPending(), 6000);
+
+ // First run: show the picker only once the mode modules have actually registered, otherwise
+ // the user can tap a card before its module exists and get "not loaded".
+ if (!localStorage.getItem(this.seenKey)) {
+ let waited = 0;
+ const tryShow = () => {
+ if (localStorage.getItem(this.seenKey)) return;
+ waited += 400;
+ if (Object.keys(this.modes).length >= 4 || waited >= 4000) { this.showPicker(); return; }
+ this._pickerT = setTimeout(tryShow, 400);
+ };
+ this._pickerT = setTimeout(tryShow, 800);
+ }
+ }
+
+ _sweepPending() {
+ if (!this._pending) return;
+ if (this.modes[this._pending]) return; // registered late: apply() already ran
+ if (this.terminal) {
+ this.terminal.print('Interface mode \'' + this._pending + '\' did not load — staying on ' +
+ this.current + '.', 'warning');
+ }
+ this._pending = null;
+ localStorage.setItem(this.key, this.current);
+ }
+
+ /* ---------- registry ---------- */
+ registerMode(spec) {
+ if (!spec || !spec.id || typeof spec.mount !== 'function') {
+ console.warn('[UIShell] ignoring invalid mode spec', spec && spec.id);
+ return false;
+ }
+ this.modes[spec.id] = spec;
+ if (this._pending === spec.id) {
+ this._pending = null;
+ this.apply(spec.id, { silent: true, persist: false });
+ }
+ return true;
+ }
+
+ list() { return Object.keys(this.modes).map(id => this.modes[id]).sort((a, b) => a.level - b.level); }
+
+ /* ---------- switching ---------- */
+ apply(id, opts) {
+ const o = opts || {};
+ const want = id || 'classic';
+ // A user-initiated switch cancels a pending boot restore, or a slow ext module would later
+ // yank the interface back to whatever boot asked for.
+ if (!o.silent) { this._pending = null; this._closePicker(); }
+ if (want !== 'classic' && !this.modes[want]) {
+ // Lazy load. The six mode files are NOT in extKeys: a full eager boot issues ~55
+ // requests and trips nginx `limit_req zone=webx11 (60r/m, burst 50)`, which 503s every
+ // module after the limit — measured 2026-11-07 in the dev copy.
+ if (o.retried) {
+ if (this.terminal) this.terminal.print(`UI mode '${want}' is not available.`, 'warning');
+ return false;
+ }
+ this._pending = want;
+ if (this.terminal) this.toast('Loading ' + want + '…', 1500);
+ this._loadMode(want).then((ok) => {
+ if (ok) this.apply(want, { force: true, retried: true, persist: o.persist });
+ else if (this.terminal) {
+ this.terminal.print(`UI mode '${want}' could not be loaded — available: ` +
+ (['classic'].concat(Object.keys(this.modes)).join(', ')), 'warning');
+ }
+ });
+ return false;
+ }
+ if (want === this.current && !o.force) {
+ // classic is a REAL state, not "attribute absent": the probe, the CSS scoping and
+ // _syncChrome all key off data-ui-mode, so it must be set even when nothing changes.
+ document.body.dataset.uiMode = want;
+ document.body.classList.add('wx11-ui-' + want);
+ if (o.persist !== false) localStorage.setItem(this.key, want);
+ return true;
+ }
+
+ this._teardown();
+ this.current = want;
+ document.body.dataset.uiMode = want;
+ document.body.classList.add('wx11-ui-' + want);
+ if (o.persist !== false) localStorage.setItem(this.key, want);
+
+ if (want === 'classic') { this._hideFooter(); this._syncChrome(); return true; }
+
+ const api = this.api();
+ let cleanup = null;
+ try {
+ cleanup = this.modes[want].mount(api);
+ } catch (e) {
+ console.error('[UIShell] mount failed for ' + want + ':', e);
+ this._abortToClassic(want, 'could not start (' + e.message + ')');
+ return false;
+ }
+ this._cleanup = typeof cleanup === 'function' ? cleanup : null;
+
+ // Identity, not just presence: a mode that appends its own <div id="terminal-container">
+ // would pass a presence check while terminal.js keeps printing into the detached original.
+ const out = document.getElementById('terminal-container');
+ const inDoc = !!out && !!document.body.contains(out);
+ if (!out || !inDoc || (this._terminalEl && out !== this._terminalEl)) {
+ this._abortToClassic(want, 'removed or replaced the terminal pane');
+ return false;
+ }
+ this._dockFooter();
+ document.dispatchEvent(new CustomEvent('wx11:ui-changed', { detail: { mode: want } }));
+ return true;
+ }
+
+ _abortToClassic(id, why) {
+ if (this.terminal) this.terminal.print(`UI mode '${id}' ${why} — reverted to classic.`, 'error');
+ try { if (this._cleanup) this._cleanup(); } catch (e) { /* mode is already broken */ }
+ this._cleanup = null;
+ this._restoreStock();
+ if (this._root) this._root.innerHTML = '';
+ this.current = 'classic';
+ document.body.classList.remove('wx11-ui-' + id);
+ document.body.dataset.uiMode = 'classic';
+ this._hideFooter();
+ this._cssSweep();
+ this._syncChrome();
+ }
+
+ next() {
+ const order = ['classic'].concat(this.list().map(m => m.id));
+ const i = order.indexOf(this.current);
+ return this.apply(order[(i + 1) % order.length]);
+ }
+
+ // Fetch a mode on demand. Uses the same cache-busting version the loader uses, so a mode can
+ // never be served stale after a version bump. Public moduleManager methods only, and no
+ // printing: a mode load must not litter the chat.
+ async _loadMode(id) {
+ const mm = this.terminal && this.terminal.moduleManager;
+ if (!mm) return this._loadFail(id, 'no module manager');
+ const ver = localStorage.getItem('webx11_cache_ver') || mm.LATEST_VER || '0';
+ let pkg = null;
+ try {
+ // no-store: the loader already cached packages.json under the same versioned URL.
+ const idx = await (await fetch('packages.json?v=' + ver, { cache: 'no-store' })).json();
+ // Mode ids are bare (`easy`); package keys are prefixed (`ui-easy`). Looking up only the
+ // bare id reported "no package entry" for every mode — measured 2026-11-07.
+ pkg = idx && idx.packages && (idx.packages['ui-' + id] || idx.packages[id]);
+ } catch (e) {
+ return this._loadFail(id, 'packages.json unreadable: ' + e.message);
+ }
+ if (!pkg || !pkg.url) return this._loadFail(id, 'no package entry (ver=' + ver + ')');
+ const url = pkg.url + (pkg.url.indexOf('?') === -1 ? '?v=' : '&v=') + ver;
+ // Reuse the SAME loader every ext module uses (fetch → createModuleClass → registerModule →
+ // init). A bespoke fetch/instantiate path here drifted from it twice and cost a debug cycle.
+ // Two attempts: a 503 from nginx `limit_req` is transient, and a mode that silently fails to
+ // load looks exactly like a broken button.
+ let ok = await mm.loadModuleFromURL(url);
+ if (!ok) {
+ await new Promise(r => setTimeout(r, 900));
+ ok = await mm.loadModuleFromURL(url);
+ }
+ if (!ok) return this._loadFail(id, 'loader refused ' + url);
+ if (!this.modes[id]) return this._loadFail(id, 'loaded but registered no mode');
+ return true;
+ }
+
+ _loadFail(id, why) {
+ console.warn('[UIShell] could not load mode ' + id + ': ' + why);
+ if (this.terminal && this.terminal.print) {
+ this.terminal.print('Interface mode ' + id + ' could not be loaded (' + why + ').', 'warning');
+ }
+ return false;
+ }
+
+ _teardown() {
+ if (this._cleanup) { try { this._cleanup(); } catch (e) { console.warn('[UIShell] unmount:', e.message); } }
+ this._cleanup = null;
+ // Restore first: the mode's subtree may still hold the stock nodes, and any
+ // getElementById() between these two lines would see nothing. The load-bearing part is that
+ // the restore RUNS — dropping it is what A5 catches (reordering alone still passes).
+ this._restoreStock();
+ if (this._root) this._root.innerHTML = '';
+ this._cssSweep();
+ document.body.classList.remove('wx11-ui-' + this.current);
+ }
+
+ /* ---------- stock DOM bookkeeping ---------- */
+ _stockEls() {
+ return ['terminal-container', 'input-area', 'quick-actions', 'status-bar']
+ .map(id => document.getElementById(id)).filter(Boolean);
+ }
+
+ _snapshotStock() {
+ if (this._where) return;
+ this._terminalEl = document.getElementById('terminal-container');
+ this._where = this._stockEls().map(el => ({ el, parent: el.parentNode, next: el.nextSibling || null }));
+ }
+
+ _restoreStock() {
+ if (!this._where) return;
+ for (const w of this._where) {
+ if (!w.parent) continue;
+ const ref = w.next && w.next.parentNode === w.parent ? w.next : null;
+ try { w.parent.insertBefore(w.el, ref); } catch (e) { /* detached parent */ }
+ }
+ }
+
+ /* ---------- chrome ---------- */
+ _syncChrome() {
+ const qa = document.getElementById('quick-actions');
+ if (qa) qa.style.display = this.current === 'classic' ? '' : 'none';
+ }
+
+ _ensureRoot() {
+ let root = document.getElementById('ui-root');
+ if (!root) {
+ root = document.createElement('div');
+ root.id = 'ui-root';
+ root.appendChild(this._ensureFoot());
+ document.body.appendChild(root);
+ }
+ return root;
+ }
+
+ // Every mode gets the status bar (network state + the AI "stop" button) whether it asks for it
+ // or not. A mode that forgets it would strand a streaming answer with no way to stop.
+ _ensureFoot() {
+ let foot = document.getElementById('ui-foot');
+ if (!foot) {
+ foot = document.createElement('div');
+ foot.id = 'ui-foot';
+ }
+ return foot;
+ }
+
+ _dockFooter() {
+ const foot = this._ensureFoot();
+ const bar = document.getElementById('status-bar');
+ if (bar && bar.parentNode !== foot) foot.appendChild(bar);
+ if (this._root && foot.parentNode !== this._root) this._root.appendChild(foot);
+ foot.style.display = '';
+ }
+
+ _hideFooter() {
+ const foot = document.getElementById('ui-foot');
+ if (foot) foot.style.display = 'none';
+ }
+
+ _injectBaseCSS() {
+ if (document.getElementById('wx11-ui-base-css')) return;
+ const st = document.createElement('style');
+ st.id = 'wx11-ui-base-css';
+ st.textContent = [
+ '#ui-root{position:fixed;inset:0;display:none;overflow:hidden;z-index:40;background:var(--bg-color);color:var(--text-color)}',
+ 'body[data-ui-mode]:not([data-ui-mode="classic"]) #ui-root{display:block}',
+ 'body[data-ui-mode]:not([data-ui-mode="classic"]) #cmd-cards{display:none!important}',
+ 'body[data-ui-mode]:not([data-ui-mode="classic"]) #ui-root{padding-bottom:34px}',
+ // The stock containers are flex children of <body>; inside a pane they must be able to
+ // shrink, or every mode overflows vertically and the input scrolls off-screen.
+ '#ui-root #terminal-container{flex:1 1 auto;min-height:0;height:auto;overflow-y:auto;padding:0}',
+ '#ui-root #input-area{flex:0 0 auto}',
+ '#ui-root #status-bar{flex:0 0 auto}',
+ '#ui-foot{position:absolute;left:0;right:0;bottom:0;height:34px;display:flex;align-items:center;',
+ 'gap:8px;padding:0 10px;background:var(--bg-secondary);border-top:1px solid var(--border-color);',
+ 'font-size:.78em;z-index:5}',
+ '#ui-foot #status-bar{border:0;background:transparent;flex:1 1 auto;display:flex;',
+ 'align-items:center;justify-content:space-between;gap:8px;font-size:1em;padding:0}',
+ '.wx11-pane{display:flex;flex-direction:column;min-height:0;min-width:0;overflow:hidden}',
+ '.wx11-card{background:var(--bg-secondary);border:1px solid var(--border-color);border-radius:10px;padding:12px}',
+ '.wx11-btn{min-height:44px;padding:10px 14px;background:var(--bg-tertiary);color:var(--text-color);',
+ 'border:1px solid var(--border-color);border-radius:8px;font:inherit;cursor:pointer;text-align:left}',
+ '.wx11-btn:active{background:var(--accent-color);color:#000}',
+ '.wx11-chip{display:inline-block;min-height:40px;padding:8px 12px;margin:0 6px 6px 0;border-radius:999px;',
+ 'border:1px solid var(--border-color);background:var(--bg-tertiary);font-size:.85em;cursor:pointer}',
+ '.wx11-dim{color:var(--text-muted);font-size:.85em}',
+ '.wx11-pre{margin:0;white-space:pre-wrap;word-break:break-word;font-size:.8em;line-height:1.35;',
+ 'max-height:100%;overflow:auto;color:var(--text-color)}',
+ '@media (max-width:600px){.wx11-btn{min-height:48px}}'
+ ].join('\n');
+ document.head.appendChild(st);
+ }
+
+ injectCSS(id, css) {
+ this.removeCSS(id);
+ const st = document.createElement('style');
+ st.id = 'wx11-ui-css-' + id;
+ st.textContent = css;
+ document.head.appendChild(st);
+ this._cssIds.push(id);
+ }
+
+ removeCSS(id) {
+ const el = document.getElementById('wx11-ui-css-' + id);
+ if (el) el.remove();
+ this._cssIds = this._cssIds.filter(x => x !== id);
+ }
+
+ _cssSweep() { this._cssIds.slice().forEach(id => this.removeCSS(id)); }
+
+ /* ---------- capability API handed to modes ---------- */
+ api() {
+ const self = this;
+ const term = this.terminal;
+ this._snapshotStock();
+ return {
+ root: this._root,
+ terminal: term,
+ stock: {
+ output: document.getElementById('terminal-container'),
+ input: document.getElementById('input-area'),
+ quickActions: document.getElementById('quick-actions'),
+ statusBar: document.getElementById('status-bar')
+ },
+ // user text → executeCommand (security gate applies)
+ exec: (line) => term.executeCommand(String(line)),
+ // shell-authored fixed strings only → capturePipeOutput (no security gate)
+ capture: (line) => {
+ const parts = term._parseArgs(String(line).trim());
+ const cmd = parts[0];
+ if (!cmd) return Promise.resolve('');
+ return term.capturePipeOutput(cmd, parts.slice(1), String(line));
+ },
+ fs: term.fs,
+ el: (tag, props, kids) => {
+ const node = document.createElement(tag);
+ if (props) for (const k of Object.keys(props)) {
+ if (k === 'text') node.textContent = props[k];
+ else if (k === 'html') node.innerHTML = props[k];
+ else if (k === 'class') node.className = props[k];
+ else node.setAttribute(k, props[k]);
+ }
+ (kids || []).forEach(k => node.appendChild(k));
+ return node;
+ },
+ attach: (slot, el) => { if (slot && el) slot.appendChild(el); return el; },
+ injectCSS: (id, css) => self.injectCSS(id, css),
+ removeCSS: (id) => self.removeCSS(id),
+ interval: (fn, ms) => {
+ const mm = term.moduleManager;
+ return mm && mm._setInterval ? mm._setInterval(fn, ms) : setInterval(fn, ms);
+ },
+ clearInterval: (id) => {
+ const mm = term.moduleManager;
+ if (mm && mm._clearTimer) mm._clearTimer(id); else clearInterval(id);
+ },
+ toast: (msg, ms) => self.toast(msg, ms)
+ };
+ }
+
+ toast(msg, ms) {
+ let host = document.getElementById('wx11-ui-toast');
+ if (!host) {
+ host = document.createElement('div');
+ host.id = 'wx11-ui-toast';
+ host.style.cssText = 'position:fixed;left:50%;transform:translateX(-50%);bottom:44px;' +
+ 'background:var(--bg-secondary);border:1px solid var(--accent-color);color:var(--text-color);' +
+ 'padding:10px 14px;border-radius:8px;z-index:9999;font-size:.9em;max-width:90vw;';
+ document.body.appendChild(host);
+ }
+ host.textContent = String(msg);
+ host.style.display = 'block';
+ clearTimeout(this._toastT);
+ this._toastT = setTimeout(() => { host.style.display = 'none'; }, ms || 2600);
+ }
+
+ /* ---------- picker ---------- */
+ showPicker() {
+ if (this._picker) return this._picker;
+ const self = this;
+ const wrap = document.createElement('div');
+ wrap.id = 'wx11-ui-picker';
+ wrap.style.cssText = 'position:fixed;inset:0;z-index:9998;background:rgba(0,0,0,.86);' +
+ 'display:flex;flex-direction:column;justify-content:center;padding:20px;overflow:auto;gap:10px;';
+ wrap.innerHTML = '<div style="max-width:720px;margin:0 auto;width:100%">' +
+ '<h2 style="margin:0 0 4px;font-size:1.15em">How do you want newOS4 to look?</h2>' +
+ '<p class="wx11-dim" style="margin:0 0 14px">Pick one — you can switch any time with <code>ui</code>.</p>' +
+ '<div id="wx11-ui-picker-list" style="display:grid;gap:10px"></div>' +
+ '<button class="wx11-btn" id="wx11-ui-picker-skip" style="margin-top:12px;width:100%;text-align:center">' +
+ 'Keep the classic terminal</button></div>';
+ document.body.appendChild(wrap);
+ const list = wrap.querySelector('#wx11-ui-picker-list');
+ // Fewer than 4 registered means the ext modules are still in flight — use the static list so
+ // the user never sees a one-card chooser (the card handler re-checks and warns if missing).
+ const modes = this.list().length >= 4 ? this.list() : [
+ { id: 'easy', label: 'Easy', level: 1, icon: '🌱', tagline: 'Just ask. No commands.' },
+ { id: 'chat', label: 'Chat', level: 2, icon: '💬', tagline: 'Conversation first.' },
+ { id: 'studio', label: 'Studio', level: 3, icon: '🎨', tagline: 'Chat plus a place for your files.' },
+ { id: 'workspace', label: 'Workspace', level: 4, icon: '🗂', tagline: 'Everything at a glance.' },
+ { id: 'commander', label: 'Commander', level: 5, icon: '⌨️', tagline: 'Terminal with rails.' },
+ { id: 'cockpit', label: 'Cockpit', level: 6, icon: '🛰', tagline: 'Live system telemetry.' }
+ ];
+ modes.forEach(m => {
+ const b = document.createElement('button');
+ b.className = 'wx11-btn';
+ b.style.cssText = 'display:flex;gap:12px;align-items:center;width:100%';
+ const ico = document.createElement('span');
+ ico.style.fontSize = '1.5em';
+ ico.textContent = m.icon || '▫'; // never innerHTML: labels can change
+ const txt = document.createElement('span');
+ const strong = document.createElement('b');
+ strong.textContent = String(m.label);
+ const tag = document.createElement('span');
+ tag.className = 'wx11-dim';
+ tag.textContent = String(m.tagline || '');
+ txt.appendChild(strong); txt.appendChild(document.createElement('br')); txt.appendChild(tag);
+ b.appendChild(ico); b.appendChild(txt);
+ b.addEventListener('click', () => { self._closePicker(); self.apply(m.id); });
+ list.appendChild(b);
+ });
+ wrap.querySelector('#wx11-ui-picker-skip').addEventListener('click', () => {
+ self._closePicker(); self.apply('classic');
+ });
+ const onEsc = (ev) => { if (ev.key === 'Escape') { self._closePicker(); } };
+ document.addEventListener('keydown', onEsc);
+ wrap._onEsc = onEsc;
+ this._picker = wrap;
+ return wrap;
+ }
+
+ _closePicker() {
+ if (this._pickerT) { clearTimeout(this._pickerT); this._pickerT = null; }
+ if (this._picker) {
+ if (this._picker._onEsc) document.removeEventListener('keydown', this._picker._onEsc);
+ this._picker.remove();
+ this._picker = null;
+ }
+ localStorage.setItem(this.seenKey, '1');
+ }
+
+ /* ---------- command ---------- */
+ handle(args) {
+ const sub = String(args[0] || '').toLowerCase();
+ const available = ['classic'].concat(this.list().map(m => m.id));
+ if (!sub || sub === 'list') {
+ this.terminal.print('Interface modes:', 'info');
+ this.terminal.print(' classic (level 0) — the terminal you already know', 'info');
+ this.list().forEach(m => this.terminal.print(
+ ' ' + String(m.id).padEnd(10) + '(level ' + m.level + ') — ' + (m.tagline || m.label), 'info'));
+ this.terminal.print('Usage: ui <mode> | ui next | ui picker | ui classic', 'info');
+ return;
+ }
+ if (sub === 'picker') return void this.showPicker();
+ if (sub === 'next') return void this.next();
+ // A mode that is not registered yet is not an error: it is lazy-loaded on demand. Only an
+ // id that is not one of the six is unknown.
+ const LAZY = ['easy', 'chat', 'studio', 'workspace', 'commander', 'cockpit'];
+ if (available.indexOf(sub) === -1 && LAZY.indexOf(sub) === -1) {
+ this.terminal.print(`Unknown mode '${sub}'. Available: ${available.join(', ')}`, 'warning');
+ return;
+ }
+ if (this.apply(sub)) this.terminal.print('Interface: ' + sub, 'success');
+ }
+}
+
/* === end core module bundle === */
diff --git a/modules/core/module-loader.js b/modules/core/module-loader.js
index 1f931fd..e9d3413 100644
--- a/modules/core/module-loader.js
+++ b/modules/core/module-loader.js
@@ -4,7 +4,7 @@
// Core module bundle loaded synchronously in main.html.
// This loader only handles version cache-busting, splash, terminal init, and optional ext modules.
- const LATEST_VER = 'v4.6.151';
+ const LATEST_VER = 'v4.6.152';
const CACHE = '?v=' + LATEST_VER;
// Exposed so ext modules can register their own URL with the same cache-busting version
// instead of an unversioned path the browser may already have cached (shell.js did).
@@ -58,6 +58,9 @@
setSplash('Initializing terminal (3/3)', total, total, '');
// webx11 already created synchronously above — just init ext modules
if (typeof webx11._loadExtModules === 'function') {
+ // The six ui-* modes are NOT here on purpose: they load on demand via
+ // uiShell._loadMode(). Eager boot issues ~55 requests and trips nginx
+ // limit_req (60r/m, burst 50) — the 11th+ module would 503.
let extKeys = ['calc', 'voice', 'debug-panel', 'cron', 'theme', 'wpm', 'mindmap', 'webdav', 'skill-pack-core', 'cryptogzip', 'tutorial', 'floating', 'console', 'media', 'three', 'kanban', 'course'];
const saved = localStorage.getItem('webx11_startup_modules');
let selected = null;
diff --git a/modules/core/module-uishell.js b/modules/core/module-uishell.js
new file mode 100644
index 0000000..63a1437
--- /dev/null
+++ b/modules/core/module-uishell.js
@@ -0,0 +1,508 @@
+// newOS4 UI shell (v4.6.152) — one output stream, seven chromes.
+//
+// Rules this file exists to enforce:
+// • modes REPARENT the stock nodes (#terminal-container, #input-area, #quick-actions,
+// #status-bar); they never recreate them. terminal.js holds those references from
+// construction, so a fresh node silently orphans the AI output.
+// • teardown restores the stock nodes and clears the mode's DOM; the post-mount check compares
+// node IDENTITY, because a same-id clone passes a presence check.
+// • the shell, not the mode, docks #status-bar (it holds the AI stop button) into #ui-foot.
+// • ext modules are not a sandbox — they already hold window.webx11. The api() below is
+// ergonomics, not a privilege boundary. Do not "harden" it into theatre.
+class UIShellModule {
+ constructor() {
+ this.commands = {};
+ this.terminal = null;
+ this.modes = {}; // id -> { id, label, level, icon, tagline, mount }
+ this.current = 'classic';
+ this._root = null;
+ this._cleanup = null;
+ this._where = null; // [{ el, parent, next }] captured on first mount
+ this._terminalEl = null; // identity of the one real output node
+ this._cssIds = [];
+ this._pending = null; // mode requested before its ext module loaded
+ this._picker = null;
+ this._fromQuery = false;
+ this.key = 'webx11_ui_mode';
+ this.seenKey = 'webx11_ui_seen';
+ }
+
+ requirements() { return { met: true, missing: [] }; }
+
+ getHelpInfo() {
+ return {
+ 'ui': 'ui [list|<mode>|next|picker|classic] — Switch interface: classic | easy | chat | studio | workspace | commander | cockpit'
+ };
+ }
+
+ registerCommands() { this.commands['ui'] = (args) => this.handle(args || []); }
+
+ /* ---------- boot ---------- */
+ init(terminal) {
+ this.terminal = terminal;
+ terminal.uiShell = this; // ext mode modules reach the shell through this
+ this._injectBaseCSS();
+ this._root = this._ensureRoot();
+
+ // ?ui= is a REQUEST, not a preference: honoured only if that mode actually registers, so a
+ // shared link with a typo cannot poison localStorage or suppress onboarding.
+ let wanted = localStorage.getItem(this.key) || 'classic';
+ try {
+ const q = new URLSearchParams(location.search).get('ui');
+ if (q) { wanted = q; this._fromQuery = true; }
+ } catch (e) { /* no URLSearchParams: ignore the request */ }
+ const applied = this.apply(wanted, { silent: true, persist: this._fromQuery });
+ if (!applied) this._pending = wanted; // ext modules register ~800 ms later
+
+ // A requested/stored mode that never registers (404, parse error) is forgotten rather than
+ // kept as a dead preference forever.
+ this._sweepT = setTimeout(() => this._sweepPending(), 6000);
+
+ // First run: show the picker only once the mode modules have actually registered, otherwise
+ // the user can tap a card before its module exists and get "not loaded".
+ if (!localStorage.getItem(this.seenKey)) {
+ let waited = 0;
+ const tryShow = () => {
+ if (localStorage.getItem(this.seenKey)) return;
+ waited += 400;
+ if (Object.keys(this.modes).length >= 4 || waited >= 4000) { this.showPicker(); return; }
+ this._pickerT = setTimeout(tryShow, 400);
+ };
+ this._pickerT = setTimeout(tryShow, 800);
+ }
+ }
+
+ _sweepPending() {
+ if (!this._pending) return;
+ if (this.modes[this._pending]) return; // registered late: apply() already ran
+ if (this.terminal) {
+ this.terminal.print('Interface mode \'' + this._pending + '\' did not load — staying on ' +
+ this.current + '.', 'warning');
+ }
+ this._pending = null;
+ localStorage.setItem(this.key, this.current);
+ }
+
+ /* ---------- registry ---------- */
+ registerMode(spec) {
+ if (!spec || !spec.id || typeof spec.mount !== 'function') {
+ console.warn('[UIShell] ignoring invalid mode spec', spec && spec.id);
+ return false;
+ }
+ this.modes[spec.id] = spec;
+ if (this._pending === spec.id) {
+ this._pending = null;
+ this.apply(spec.id, { silent: true, persist: false });
+ }
+ return true;
+ }
+
+ list() { return Object.keys(this.modes).map(id => this.modes[id]).sort((a, b) => a.level - b.level); }
+
+ /* ---------- switching ---------- */
+ apply(id, opts) {
+ const o = opts || {};
+ const want = id || 'classic';
+ // A user-initiated switch cancels a pending boot restore, or a slow ext module would later
+ // yank the interface back to whatever boot asked for.
+ if (!o.silent) { this._pending = null; this._closePicker(); }
+ if (want !== 'classic' && !this.modes[want]) {
+ // Lazy load. The six mode files are NOT in extKeys: a full eager boot issues ~55
+ // requests and trips nginx `limit_req zone=webx11 (60r/m, burst 50)`, which 503s every
+ // module after the limit — measured 2026-11-07 in the dev copy.
+ if (o.retried) {
+ if (this.terminal) this.terminal.print(`UI mode '${want}' is not available.`, 'warning');
+ return false;
+ }
+ this._pending = want;
+ if (this.terminal) this.toast('Loading ' + want + '…', 1500);
+ this._loadMode(want).then((ok) => {
+ if (ok) this.apply(want, { force: true, retried: true, persist: o.persist });
+ else if (this.terminal) {
+ this.terminal.print(`UI mode '${want}' could not be loaded — available: ` +
+ (['classic'].concat(Object.keys(this.modes)).join(', ')), 'warning');
+ }
+ });
+ return false;
+ }
+ if (want === this.current && !o.force) {
+ // classic is a REAL state, not "attribute absent": the probe, the CSS scoping and
+ // _syncChrome all key off data-ui-mode, so it must be set even when nothing changes.
+ document.body.dataset.uiMode = want;
+ document.body.classList.add('wx11-ui-' + want);
+ if (o.persist !== false) localStorage.setItem(this.key, want);
+ return true;
+ }
+
+ this._teardown();
+ this.current = want;
+ document.body.dataset.uiMode = want;
+ document.body.classList.add('wx11-ui-' + want);
+ if (o.persist !== false) localStorage.setItem(this.key, want);
+
+ if (want === 'classic') { this._hideFooter(); this._syncChrome(); return true; }
+
+ const api = this.api();
+ let cleanup = null;
+ try {
+ cleanup = this.modes[want].mount(api);
+ } catch (e) {
+ console.error('[UIShell] mount failed for ' + want + ':', e);
+ this._abortToClassic(want, 'could not start (' + e.message + ')');
+ return false;
+ }
+ this._cleanup = typeof cleanup === 'function' ? cleanup : null;
+
+ // Identity, not just presence: a mode that appends its own <div id="terminal-container">
+ // would pass a presence check while terminal.js keeps printing into the detached original.
+ const out = document.getElementById('terminal-container');
+ const inDoc = !!out && !!document.body.contains(out);
+ if (!out || !inDoc || (this._terminalEl && out !== this._terminalEl)) {
+ this._abortToClassic(want, 'removed or replaced the terminal pane');
+ return false;
+ }
+ this._dockFooter();
+ document.dispatchEvent(new CustomEvent('wx11:ui-changed', { detail: { mode: want } }));
+ return true;
+ }
+
+ _abortToClassic(id, why) {
+ if (this.terminal) this.terminal.print(`UI mode '${id}' ${why} — reverted to classic.`, 'error');
+ try { if (this._cleanup) this._cleanup(); } catch (e) { /* mode is already broken */ }
+ this._cleanup = null;
+ this._restoreStock();
+ if (this._root) this._root.innerHTML = '';
+ this.current = 'classic';
+ document.body.classList.remove('wx11-ui-' + id);
+ document.body.dataset.uiMode = 'classic';
+ this._hideFooter();
+ this._cssSweep();
+ this._syncChrome();
+ }
+
+ next() {
+ const order = ['classic'].concat(this.list().map(m => m.id));
+ const i = order.indexOf(this.current);
+ return this.apply(order[(i + 1) % order.length]);
+ }
+
+ // Fetch a mode on demand. Uses the same cache-busting version the loader uses, so a mode can
+ // never be served stale after a version bump. Public moduleManager methods only, and no
+ // printing: a mode load must not litter the chat.
+ async _loadMode(id) {
+ const mm = this.terminal && this.terminal.moduleManager;
+ if (!mm) return this._loadFail(id, 'no module manager');
+ const ver = localStorage.getItem('webx11_cache_ver') || mm.LATEST_VER || '0';
+ let pkg = null;
+ try {
+ // no-store: the loader already cached packages.json under the same versioned URL.
+ const idx = await (await fetch('packages.json?v=' + ver, { cache: 'no-store' })).json();
+ // Mode ids are bare (`easy`); package keys are prefixed (`ui-easy`). Looking up only the
+ // bare id reported "no package entry" for every mode — measured 2026-11-07.
+ pkg = idx && idx.packages && (idx.packages['ui-' + id] || idx.packages[id]);
+ } catch (e) {
+ return this._loadFail(id, 'packages.json unreadable: ' + e.message);
+ }
+ if (!pkg || !pkg.url) return this._loadFail(id, 'no package entry (ver=' + ver + ')');
+ const url = pkg.url + (pkg.url.indexOf('?') === -1 ? '?v=' : '&v=') + ver;
+ // Reuse the SAME loader every ext module uses (fetch → createModuleClass → registerModule →
+ // init). A bespoke fetch/instantiate path here drifted from it twice and cost a debug cycle.
+ // Two attempts: a 503 from nginx `limit_req` is transient, and a mode that silently fails to
+ // load looks exactly like a broken button.
+ let ok = await mm.loadModuleFromURL(url);
+ if (!ok) {
+ await new Promise(r => setTimeout(r, 900));
+ ok = await mm.loadModuleFromURL(url);
+ }
+ if (!ok) return this._loadFail(id, 'loader refused ' + url);
+ if (!this.modes[id]) return this._loadFail(id, 'loaded but registered no mode');
+ return true;
+ }
+
+ _loadFail(id, why) {
+ console.warn('[UIShell] could not load mode ' + id + ': ' + why);
+ if (this.terminal && this.terminal.print) {
+ this.terminal.print('Interface mode ' + id + ' could not be loaded (' + why + ').', 'warning');
+ }
+ return false;
+ }
+
+ _teardown() {
+ if (this._cleanup) { try { this._cleanup(); } catch (e) { console.warn('[UIShell] unmount:', e.message); } }
+ this._cleanup = null;
+ // Restore first: the mode's subtree may still hold the stock nodes, and any
+ // getElementById() between these two lines would see nothing. The load-bearing part is that
+ // the restore RUNS — dropping it is what A5 catches (reordering alone still passes).
+ this._restoreStock();
+ if (this._root) this._root.innerHTML = '';
+ this._cssSweep();
+ document.body.classList.remove('wx11-ui-' + this.current);
+ }
+
+ /* ---------- stock DOM bookkeeping ---------- */
+ _stockEls() {
+ return ['terminal-container', 'input-area', 'quick-actions', 'status-bar']
+ .map(id => document.getElementById(id)).filter(Boolean);
+ }
+
+ _snapshotStock() {
+ if (this._where) return;
+ this._terminalEl = document.getElementById('terminal-container');
+ this._where = this._stockEls().map(el => ({ el, parent: el.parentNode, next: el.nextSibling || null }));
+ }
+
+ _restoreStock() {
+ if (!this._where) return;
+ for (const w of this._where) {
+ if (!w.parent) continue;
+ const ref = w.next && w.next.parentNode === w.parent ? w.next : null;
+ try { w.parent.insertBefore(w.el, ref); } catch (e) { /* detached parent */ }
+ }
+ }
+
+ /* ---------- chrome ---------- */
+ _syncChrome() {
+ const qa = document.getElementById('quick-actions');
+ if (qa) qa.style.display = this.current === 'classic' ? '' : 'none';
+ }
+
+ _ensureRoot() {
+ let root = document.getElementById('ui-root');
+ if (!root) {
+ root = document.createElement('div');
+ root.id = 'ui-root';
+ root.appendChild(this._ensureFoot());
+ document.body.appendChild(root);
+ }
+ return root;
+ }
+
+ // Every mode gets the status bar (network state + the AI "stop" button) whether it asks for it
+ // or not. A mode that forgets it would strand a streaming answer with no way to stop.
+ _ensureFoot() {
+ let foot = document.getElementById('ui-foot');
+ if (!foot) {
+ foot = document.createElement('div');
+ foot.id = 'ui-foot';
+ }
+ return foot;
+ }
+
+ _dockFooter() {
+ const foot = this._ensureFoot();
+ const bar = document.getElementById('status-bar');
+ if (bar && bar.parentNode !== foot) foot.appendChild(bar);
+ if (this._root && foot.parentNode !== this._root) this._root.appendChild(foot);
+ foot.style.display = '';
+ }
+
+ _hideFooter() {
+ const foot = document.getElementById('ui-foot');
+ if (foot) foot.style.display = 'none';
+ }
+
+ _injectBaseCSS() {
+ if (document.getElementById('wx11-ui-base-css')) return;
+ const st = document.createElement('style');
+ st.id = 'wx11-ui-base-css';
+ st.textContent = [
+ '#ui-root{position:fixed;inset:0;display:none;overflow:hidden;z-index:40;background:var(--bg-color);color:var(--text-color)}',
+ 'body[data-ui-mode]:not([data-ui-mode="classic"]) #ui-root{display:block}',
+ 'body[data-ui-mode]:not([data-ui-mode="classic"]) #cmd-cards{display:none!important}',
+ 'body[data-ui-mode]:not([data-ui-mode="classic"]) #ui-root{padding-bottom:34px}',
+ // The stock containers are flex children of <body>; inside a pane they must be able to
+ // shrink, or every mode overflows vertically and the input scrolls off-screen.
+ '#ui-root #terminal-container{flex:1 1 auto;min-height:0;height:auto;overflow-y:auto;padding:0}',
+ '#ui-root #input-area{flex:0 0 auto}',
+ '#ui-root #status-bar{flex:0 0 auto}',
+ '#ui-foot{position:absolute;left:0;right:0;bottom:0;height:34px;display:flex;align-items:center;',
+ 'gap:8px;padding:0 10px;background:var(--bg-secondary);border-top:1px solid var(--border-color);',
+ 'font-size:.78em;z-index:5}',
+ '#ui-foot #status-bar{border:0;background:transparent;flex:1 1 auto;display:flex;',
+ 'align-items:center;justify-content:space-between;gap:8px;font-size:1em;padding:0}',
+ '.wx11-pane{display:flex;flex-direction:column;min-height:0;min-width:0;overflow:hidden}',
+ '.wx11-card{background:var(--bg-secondary);border:1px solid var(--border-color);border-radius:10px;padding:12px}',
+ '.wx11-btn{min-height:44px;padding:10px 14px;background:var(--bg-tertiary);color:var(--text-color);',
+ 'border:1px solid var(--border-color);border-radius:8px;font:inherit;cursor:pointer;text-align:left}',
+ '.wx11-btn:active{background:var(--accent-color);color:#000}',
+ '.wx11-chip{display:inline-block;min-height:40px;padding:8px 12px;margin:0 6px 6px 0;border-radius:999px;',
+ 'border:1px solid var(--border-color);background:var(--bg-tertiary);font-size:.85em;cursor:pointer}',
+ '.wx11-dim{color:var(--text-muted);font-size:.85em}',
+ '.wx11-pre{margin:0;white-space:pre-wrap;word-break:break-word;font-size:.8em;line-height:1.35;',
+ 'max-height:100%;overflow:auto;color:var(--text-color)}',
+ '@media (max-width:600px){.wx11-btn{min-height:48px}}'
+ ].join('\n');
+ document.head.appendChild(st);
+ }
+
+ injectCSS(id, css) {
+ this.removeCSS(id);
+ const st = document.createElement('style');
+ st.id = 'wx11-ui-css-' + id;
+ st.textContent = css;
+ document.head.appendChild(st);
+ this._cssIds.push(id);
+ }
+
+ removeCSS(id) {
+ const el = document.getElementById('wx11-ui-css-' + id);
+ if (el) el.remove();
+ this._cssIds = this._cssIds.filter(x => x !== id);
+ }
+
+ _cssSweep() { this._cssIds.slice().forEach(id => this.removeCSS(id)); }
+
+ /* ---------- capability API handed to modes ---------- */
+ api() {
+ const self = this;
+ const term = this.terminal;
+ this._snapshotStock();
+ return {
+ root: this._root,
+ terminal: term,
+ stock: {
+ output: document.getElementById('terminal-container'),
+ input: document.getElementById('input-area'),
+ quickActions: document.getElementById('quick-actions'),
+ statusBar: document.getElementById('status-bar')
+ },
+ // user text → executeCommand (security gate applies)
+ exec: (line) => term.executeCommand(String(line)),
+ // shell-authored fixed strings only → capturePipeOutput (no security gate)
+ capture: (line) => {
+ const parts = term._parseArgs(String(line).trim());
+ const cmd = parts[0];
+ if (!cmd) return Promise.resolve('');
+ return term.capturePipeOutput(cmd, parts.slice(1), String(line));
+ },
+ fs: term.fs,
+ el: (tag, props, kids) => {
+ const node = document.createElement(tag);
+ if (props) for (const k of Object.keys(props)) {
+ if (k === 'text') node.textContent = props[k];
+ else if (k === 'html') node.innerHTML = props[k];
+ else if (k === 'class') node.className = props[k];
+ else node.setAttribute(k, props[k]);
+ }
+ (kids || []).forEach(k => node.appendChild(k));
+ return node;
+ },
+ attach: (slot, el) => { if (slot && el) slot.appendChild(el); return el; },
+ injectCSS: (id, css) => self.injectCSS(id, css),
+ removeCSS: (id) => self.removeCSS(id),
+ interval: (fn, ms) => {
+ const mm = term.moduleManager;
+ return mm && mm._setInterval ? mm._setInterval(fn, ms) : setInterval(fn, ms);
+ },
+ clearInterval: (id) => {
+ const mm = term.moduleManager;
+ if (mm && mm._clearTimer) mm._clearTimer(id); else clearInterval(id);
+ },
+ toast: (msg, ms) => self.toast(msg, ms)
+ };
+ }
+
+ toast(msg, ms) {
+ let host = document.getElementById('wx11-ui-toast');
+ if (!host) {
+ host = document.createElement('div');
+ host.id = 'wx11-ui-toast';
+ host.style.cssText = 'position:fixed;left:50%;transform:translateX(-50%);bottom:44px;' +
+ 'background:var(--bg-secondary);border:1px solid var(--accent-color);color:var(--text-color);' +
+ 'padding:10px 14px;border-radius:8px;z-index:9999;font-size:.9em;max-width:90vw;';
+ document.body.appendChild(host);
+ }
+ host.textContent = String(msg);
+ host.style.display = 'block';
+ clearTimeout(this._toastT);
+ this._toastT = setTimeout(() => { host.style.display = 'none'; }, ms || 2600);
+ }
+
+ /* ---------- picker ---------- */
+ showPicker() {
+ if (this._picker) return this._picker;
+ const self = this;
+ const wrap = document.createElement('div');
+ wrap.id = 'wx11-ui-picker';
+ wrap.style.cssText = 'position:fixed;inset:0;z-index:9998;background:rgba(0,0,0,.86);' +
+ 'display:flex;flex-direction:column;justify-content:center;padding:20px;overflow:auto;gap:10px;';
+ wrap.innerHTML = '<div style="max-width:720px;margin:0 auto;width:100%">' +
+ '<h2 style="margin:0 0 4px;font-size:1.15em">How do you want newOS4 to look?</h2>' +
+ '<p class="wx11-dim" style="margin:0 0 14px">Pick one — you can switch any time with <code>ui</code>.</p>' +
+ '<div id="wx11-ui-picker-list" style="display:grid;gap:10px"></div>' +
+ '<button class="wx11-btn" id="wx11-ui-picker-skip" style="margin-top:12px;width:100%;text-align:center">' +
+ 'Keep the classic terminal</button></div>';
+ document.body.appendChild(wrap);
+ const list = wrap.querySelector('#wx11-ui-picker-list');
+ // Fewer than 4 registered means the ext modules are still in flight — use the static list so
+ // the user never sees a one-card chooser (the card handler re-checks and warns if missing).
+ const modes = this.list().length >= 4 ? this.list() : [
+ { id: 'easy', label: 'Easy', level: 1, icon: '🌱', tagline: 'Just ask. No commands.' },
+ { id: 'chat', label: 'Chat', level: 2, icon: '💬', tagline: 'Conversation first.' },
+ { id: 'studio', label: 'Studio', level: 3, icon: '🎨', tagline: 'Chat plus a place for your files.' },
+ { id: 'workspace', label: 'Workspace', level: 4, icon: '🗂', tagline: 'Everything at a glance.' },
+ { id: 'commander', label: 'Commander', level: 5, icon: '⌨️', tagline: 'Terminal with rails.' },
+ { id: 'cockpit', label: 'Cockpit', level: 6, icon: '🛰', tagline: 'Live system telemetry.' }
+ ];
+ modes.forEach(m => {
+ const b = document.createElement('button');
+ b.className = 'wx11-btn';
+ b.style.cssText = 'display:flex;gap:12px;align-items:center;width:100%';
+ const ico = document.createElement('span');
+ ico.style.fontSize = '1.5em';
+ ico.textContent = m.icon || '▫'; // never innerHTML: labels can change
+ const txt = document.createElement('span');
+ const strong = document.createElement('b');
+ strong.textContent = String(m.label);
+ const tag = document.createElement('span');
+ tag.className = 'wx11-dim';
+ tag.textContent = String(m.tagline || '');
+ txt.appendChild(strong); txt.appendChild(document.createElement('br')); txt.appendChild(tag);
+ b.appendChild(ico); b.appendChild(txt);
+ b.addEventListener('click', () => { self._closePicker(); self.apply(m.id); });
+ list.appendChild(b);
+ });
+ wrap.querySelector('#wx11-ui-picker-skip').addEventListener('click', () => {
+ self._closePicker(); self.apply('classic');
+ });
+ const onEsc = (ev) => { if (ev.key === 'Escape') { self._closePicker(); } };
+ document.addEventListener('keydown', onEsc);
+ wrap._onEsc = onEsc;
+ this._picker = wrap;
+ return wrap;
+ }
+
+ _closePicker() {
+ if (this._pickerT) { clearTimeout(this._pickerT); this._pickerT = null; }
+ if (this._picker) {
+ if (this._picker._onEsc) document.removeEventListener('keydown', this._picker._onEsc);
+ this._picker.remove();
+ this._picker = null;
+ }
+ localStorage.setItem(this.seenKey, '1');
+ }
+
+ /* ---------- command ---------- */
+ handle(args) {
+ const sub = String(args[0] || '').toLowerCase();
+ const available = ['classic'].concat(this.list().map(m => m.id));
+ if (!sub || sub === 'list') {
+ this.terminal.print('Interface modes:', 'info');
+ this.terminal.print(' classic (level 0) — the terminal you already know', 'info');
+ this.list().forEach(m => this.terminal.print(
+ ' ' + String(m.id).padEnd(10) + '(level ' + m.level + ') — ' + (m.tagline || m.label), 'info'));
+ this.terminal.print('Usage: ui <mode> | ui next | ui picker | ui classic', 'info');
+ return;
+ }
+ if (sub === 'picker') return void this.showPicker();
+ if (sub === 'next') return void this.next();
+ // A mode that is not registered yet is not an error: it is lazy-loaded on demand. Only an
+ // id that is not one of the six is unknown.
+ const LAZY = ['easy', 'chat', 'studio', 'workspace', 'commander', 'cockpit'];
+ if (available.indexOf(sub) === -1 && LAZY.indexOf(sub) === -1) {
+ this.terminal.print(`Unknown mode '${sub}'. Available: ${available.join(', ')}`, 'warning');
+ return;
+ }
+ if (this.apply(sub)) this.terminal.print('Interface: ' + sub, 'success');
+ }
+}
diff --git a/modules/core/terminal.js b/modules/core/terminal.js
index ca6bb61..6bc23b4 100644
--- a/modules/core/terminal.js
+++ b/modules/core/terminal.js
@@ -1382,6 +1382,7 @@
this._safeRegister('SmartContextModule', 'smart context');
this._safeRegister('SimpleGUIModule', 'simple GUI builder');
this._safeRegister('DebugSupervisorModule', 'debug supervisor');
+ this._safeRegister('UIShellModule', 'UI shell');
// v4.5.47: Critical ext modules promoted to boot path
this._safeRegister('EditorModule', 'editor');
this._safeRegister('ShellModule', 'shell');
@@ -1430,6 +1431,7 @@
BusyBoxModule: typeof BusyBoxModule !== 'undefined' ? BusyBoxModule : undefined,
SmartContextModule: typeof SmartContextModule !== 'undefined' ? SmartContextModule : undefined,
DebugSupervisorModule: typeof DebugSupervisorModule !== 'undefined' ? DebugSupervisorModule : undefined,
+ UIShellModule: typeof UIShellModule !== 'undefined' ? UIShellModule : undefined,
BillingModule: typeof BillingModule !== 'undefined' ? BillingModule : undefined,
};
const cls = registry[className];
@@ -1519,7 +1521,24 @@
const pkg = index.packages[key];
if (pkg) {
const url = pkg.url + (pkg.url.includes('?') ? '&v=' : '?v=') + cacheVer;
- await this.moduleManager.loadModuleFromURL(url);
+ // One bad module must not abort the rest: this loop is serial and used to
+ // return on the first 404/503/parse error, silently skipping every key
+ // after it (measured 2026-11-07: a 503 on an earlier key meant the six
+ // ui-* modes at the end never loaded).
+ try {
+ await this.moduleManager.loadModuleFromURL(url);
+ } catch (e) {
+ // A 503 here is nginx `limit_req zone=webx11` (60r/m, burst 50): the
+ // bucket is per-IP, so a boot with many modules drains it and every
+ // later module is refused. Back off and retry instead of losing the
+ // module — measured 2026-11-07, six ui-* files 503'd every load.
+ try {
+ await new Promise(r => setTimeout(r, 700));
+ await this.moduleManager.loadModuleFromURL(url);
+ } catch (e2) {
+ console.warn(`[Ext] ${key} failed to load: ${e2.message}`);
+ }
+ }
}
}
}
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); };
+ }
+}
diff --git a/modules/test/test-ui-shell.js b/modules/test/test-ui-shell.js
new file mode 100644
index 0000000..ea4dc84
--- /dev/null
+++ b/modules/test/test-ui-shell.js
@@ -0,0 +1,214 @@
+// node modules/test/test-ui-shell.js
+// test-ui-shell: the UI shell must keep exactly one output stream alive across mode switches.
+//
+// Why this exists: every mode reparents #terminal-container into its own layout. Four ways that goes
+// wrong are invisible in a screenshot — teardown that forgets to restore, a mode that clones the
+// node, a failed mount that leaves the page chrome-less, and a footer that never docks (which strands
+// a streaming AI answer with no stop button). These assertions pin all four.
+const fs = require('fs'), path = require('path');
+const CORE = path.join(__dirname, '..', 'core');
+
+// ── minimal DOM: only what the shell is allowed to use ────────────────────────────
+function makeEl(tag) {
+ const el = {
+ tagName: String(tag || 'div').toUpperCase(), id: '', children: [], parentNode: null,
+ dataset: {}, style: {}, textContent: '', _html: '',
+ get innerHTML() { return this._html; },
+ set innerHTML(v) {
+ this._html = String(v);
+ for (const c of this.children) c.parentNode = null;
+ this.children = [];
+ },
+ appendChild(c) {
+ if (c.parentNode) c.parentNode.removeChild(c);
+ c.parentNode = this; this.children.push(c); return c;
+ },
+ append(...cs) { cs.forEach(c => this.appendChild(c)); },
+ insertBefore(c, ref) {
+ if (c.parentNode) c.parentNode.removeChild(c);
+ const i = ref ? this.children.indexOf(ref) : -1;
+ if (i < 0) this.children.push(c); else this.children.splice(i, 0, c);
+ c.parentNode = this; return c;
+ },
+ removeChild(c) {
+ const i = this.children.indexOf(c);
+ if (i >= 0) { this.children.splice(i, 1); c.parentNode = null; }
+ return c;
+ },
+ remove() { if (this.parentNode) this.parentNode.removeChild(this); },
+ contains(n) {
+ if (!n) return false;
+ if (n === this) return true;
+ return this.children.some(c => c.contains && c.contains(n));
+ },
+ setAttribute(k, v) { this[k] = v; },
+ addEventListener() {}, removeEventListener() {},
+ querySelector(sel) {
+ const want = String(sel).replace(/^#/, '');
+ const find = n => {
+ if (n.id === want) return n;
+ for (const c of n.children) { const r = find(c); if (r) return r; }
+ return null;
+ };
+ return find(this);
+ },
+ classList: {
+ _s: new Set(),
+ add(...c) { c.forEach(x => this._s.add(x)); },
+ remove(...c) { c.forEach(x => this._s.delete(x)); },
+ contains(c) { return this._s.has(c); },
+ toggle(c) { if (this._s.has(c)) this._s.delete(c); else this._s.add(c); }
+ }
+ };
+ return el;
+}
+
+const head = makeEl('head'), body = makeEl('body'), htmlEl = makeEl('html');
+const byId = {};
+global.document = {
+ head, body, documentElement: htmlEl,
+ createElement: tag => makeEl(tag),
+ createTextNode: t => ({ textContent: String(t), children: [], parentNode: null }),
+ getElementById: id => {
+ // walk the tree, not a registry: a node the shell forgot to restore must read as absent
+ const find = n => {
+ if (n.id === id) return n;
+ for (const c of n.children) { const r = find(c); if (r) return r; }
+ return null;
+ };
+ return find(body);
+ },
+ addEventListener() {}, dispatchEvent() { return true; },
+ querySelector: sel => body.querySelector(sel)
+};
+global.CustomEvent = class { constructor(type, opts) { this.type = type; this.detail = (opts || {}).detail; } };
+global.window = global;
+global.performance = { now: () => Date.now() };
+const store = {};
+global.localStorage = {
+ getItem: k => (k in store ? store[k] : null),
+ setItem: (k, v) => { store[k] = String(v); },
+ removeItem: k => { delete store[k]; }
+};
+
+function stock(id) { const e = makeEl('div'); e.id = id; byId[id] = e; body.appendChild(e); return e; }
+// canonical order taken from main.html:882-965
+['terminal-container', 'input-area', 'quick-actions', 'status-bar'].forEach(stock);
+
+// ── load the module the way the bundle does (class declaration, not module.exports) ──
+const src = fs.readFileSync(path.join(CORE, 'module-uishell.js'), 'utf8');
+const UIShellModule = new Function(src + '\nreturn UIShellModule;')();
+
+let pass = 0, fail = 0;
+function ok(name, cond, detail) {
+ if (cond) { pass++; console.log(' PASS ' + name); }
+ else { fail++; console.log(' FAIL ' + name + (detail ? ' — ' + detail : '')); }
+}
+
+const printed = [];
+const fakeTerminal = {
+ print: (m, t) => printed.push({ m, t }),
+ capturePipeOutput: async () => 'captured',
+ _parseArgs: s => String(s).trim().split(/\s+/).filter(Boolean)
+};
+
+const shell = new UIShellModule();
+shell.init(fakeTerminal);
+
+// A1 — command registered
+shell.registerCommands();
+ok('A1 ui command registered', typeof shell.commands['ui'] === 'function');
+
+// A2 — mode registry + apply persists and sets the body attribute
+let mounted = 0, unmounted = 0;
+shell.registerMode({
+ id: 'probe', label: 'Probe', level: 3, icon: '🧪', tagline: 'test mode',
+ mount(api) {
+ mounted++;
+ const slot = api.el('div', { id: 'probe-slot' });
+ api.root.appendChild(slot);
+ api.attach(slot, api.stock.output); // reparent the real terminal
+ return () => { unmounted++; };
+ }
+});
+ok('A2 apply returns true', shell.apply('probe') === true);
+ok('A2 body data-ui-mode', document.body.dataset.uiMode === 'probe', document.body.dataset.uiMode);
+ok('A2 persisted', localStorage.getItem('webx11_ui_mode') === 'probe');
+ok('A3 mount ran once', mounted === 1);
+ok('A4 terminal moved into the mode', byId['terminal-container'].parentNode &&
+ byId['terminal-container'].parentNode.id === 'probe-slot');
+
+// A5 — switching away must restore the stock node, in order, and not destroy it
+shell.apply('classic');
+ok('A5 teardown ran', unmounted === 1);
+ok('A5 terminal survived teardown', !!document.getElementById('terminal-container'));
+// ui-root is the shell's own container and legitimately sits in <body> too — compare stock only
+const stockOrder = () => body.children.map(c => c.id)
+ .filter(id => id && id !== 'ui-root').join(',');
+ok('A5 body order restored',
+ stockOrder() === 'terminal-container,input-area,quick-actions,status-bar', stockOrder());
+ok('A5 root emptied', (shell._root.children || []).length === 0);
+ok('A5 body attribute cleared', document.body.dataset.uiMode === 'classic');
+
+// A6 — unknown mode: no persist, warning, still classic
+store['webx11_ui_mode'] = 'classic';
+ok('A6 unknown returns false', shell.apply('nope') === false);
+ok('A6 unknown does not persist', localStorage.getItem('webx11_ui_mode') === 'classic');
+// The warning is emitted asynchronously now: an unknown id is looked up in packages.json first
+// (lazy load), so the message arrives a tick later — asserted in the async tail below.
+
+// A7 — a mode that throws on mount must not leave the page chrome-less
+shell.registerMode({
+ id: 'boom', label: 'Boom', level: 9, icon: '💥', tagline: 'broken',
+ mount() { throw new Error('mount exploded'); }
+});
+shell.apply('boom');
+ok('A7 fell back to classic', document.body.dataset.uiMode === 'classic', document.body.dataset.uiMode);
+ok('A7 terminal still present', !!document.getElementById('terminal-container'));
+
+// A8 — a mode that deletes the terminal is rejected
+shell.registerMode({
+ id: 'killer', label: 'Killer', level: 9, icon: '☠', tagline: 'deletes the pane',
+ mount() { byId['terminal-container'].remove(); return () => {}; }
+});
+shell.apply('killer');
+ok('A8 destructive mode rejected', document.body.dataset.uiMode === 'classic');
+ok('A8 error reported', printed.some(p => /terminal/i.test(p.m) && p.t === 'error'));
+
+// A11 — a same-id clone passes a presence check; identity must reject it
+shell.registerMode({
+ id: 'clone', label: 'Clone', level: 9, icon: '👥', tagline: 'same id, different node',
+ mount(api) {
+ byId['terminal-container'].remove();
+ const fake = document.createElement('div');
+ fake.id = 'terminal-container';
+ api.root.appendChild(fake);
+ return () => {};
+ }
+});
+ok('A11 clone rejected', shell.apply('clone') === false);
+ok('A11 clone fallback is classic', document.body.dataset.uiMode === 'classic',
+ document.body.dataset.uiMode);
+ok('A11 no stale body class', !document.body.classList.contains('wx11-ui-clone'));
+
+// A10 — the status bar (AI stop + network state) must exist inside every mode
+shell.apply('probe');
+const sb = document.getElementById('status-bar');
+ok('A10 status bar docked in ui-foot',
+ !!sb && sb.parentNode.id === 'ui-foot', sb ? sb.parentNode.id : 'missing');
+shell.apply('classic');
+const sb2 = document.getElementById('status-bar');
+ok('A10 status bar returned to body', !!sb2 && sb2.parentNode === document.body,
+ sb2 ? String(sb2.parentNode.id || 'body') : 'missing');
+const foot = document.getElementById('ui-foot');
+ok('A10 classic leaves no visible footer', !foot || foot.style.display === 'none');
+
+// A9 — capture() routes a shell-authored command through the capture path
+(async () => {
+ const got = await shell.api().capture('ls -la');
+ ok('A9 capture returns text', got === 'captured', String(got));
+ await new Promise(r => setTimeout(r, 100)); // let the failed lazy load report itself
+ ok('A6 warned', printed.some(p => /could not be loaded|not available|not loaded/.test(p.m)));
+ console.log('\n' + pass + '/' + (pass + fail) + ' PASS');
+ process.exit(fail ? 1 : 0);
+})();
diff --git a/packages.json b/packages.json
index 035b0b0..6d27184 100644
--- a/packages.json
+++ b/packages.json
@@ -305,6 +305,36 @@
"course"
],
"category": "learning"
+ },
+ "ui-easy": {
+ "name": "Easy UI", "version": "1.0.0",
+ "desc": "🌱 Level 1: four action cards, bubble chat, nothing to memorise",
+ "url": "modules/ext/ui-easy.js", "commands": [], "category": "ui-mode"
+ },
+ "ui-chat": {
+ "name": "Chat UI", "version": "1.0.0",
+ "desc": "💬 Level 2: single-column conversation with slash chips",
+ "url": "modules/ext/ui-chat.js", "commands": [], "category": "ui-mode"
+ },
+ "ui-studio": {
+ "name": "Studio UI", "version": "1.0.0",
+ "desc": "🎨 Level 3: chat plus files / preview / editor pane",
+ "url": "modules/ext/ui-studio.js", "commands": [], "category": "ui-mode"
+ },
+ "ui-workspace": {
+ "name": "Workspace UI", "version": "1.0.0",
+ "desc": "🗂 Level 4: card grid of live captured command output",
+ "url": "modules/ext/ui-workspace.js", "commands": [], "category": "ui-mode"
+ },
+ "ui-commander": {
+ "name": "Commander UI", "version": "1.0.0",
+ "desc": "⌨️ Level 5: terminal with rails and a Ctrl-K command palette",
+ "url": "modules/ext/ui-commander.js", "commands": [], "category": "ui-mode"
+ },
+ "ui-cockpit": {
+ "name": "Cockpit UI", "version": "1.0.0",
+ "desc": "🛰 Level 6: eight live /sys telemetry panes",
+ "url": "modules/ext/ui-cockpit.js", "commands": [], "category": "ui-mode"
}
}
} \ No newline at end of file
diff --git a/scripts/rebuild-bundles.py b/scripts/rebuild-bundles.py
index 7f82817..fe12825 100644
--- a/scripts/rebuild-bundles.py
+++ b/scripts/rebuild-bundles.py
@@ -38,6 +38,7 @@ CORE_FILES = [
'module-neural-vfs.js',
'module-smartctx.js',
'module-debug-supervisor.js',
+ 'module-uishell.js',
]
CRITICAL_EXT_FILES = [