aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--CHANGELOG.md73
-rw-r--r--README.md7
-rw-r--r--help.html16
-rw-r--r--main.html9
-rw-r--r--modules-index.json3
-rw-r--r--modules/core/core-bundle.js709
-rw-r--r--modules/core/module-aiassist.js62
-rw-r--r--modules/core/module-fetcher.js17
-rw-r--r--modules/core/module-loader.js5
-rw-r--r--modules/core/module-uishell.js607
-rw-r--r--modules/core/terminal.js21
-rw-r--r--modules/ext/cryptogzip.js67
-rw-r--r--modules/ext/ui-chat.js99
-rw-r--r--modules/ext/ui-cockpit.js132
-rw-r--r--modules/ext/ui-commander.js203
-rw-r--r--modules/ext/ui-easy.js85
-rw-r--r--modules/ext/ui-studio.js147
-rw-r--r--modules/ext/ui-workspace.js139
-rw-r--r--modules/test/test-ui-shell.js221
-rw-r--r--packages.json30
-rw-r--r--scripts/rebuild-bundles.py1
21 files changed, 2607 insertions, 46 deletions
diff --git a/CHANGELOG.md b/CHANGELOG.md
index b5dbd5f..090694e 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -4,13 +4,84 @@ Generated from git history by `scripts/build-changelog.py` — do not edit by ha
<!-- generated:start -->
+## v4.6.155 — 2026-11-07
+
+_Touches modules (10), CHANGELOG.md (1), main.html (1) · fix 1_
+
+- **Second review round — identity for every stock node, load dedup, sweep race, a11y** `0acee7d`
+ - Four models (kimi-k2.7-code, deepseek-v4-pro, minimax-m3, glm-5.3) reviewed the shipped change set. Review log with every accepted and rejected finding: .hermes/plans/2026-11-07_163420-newos4-six-ui-modes.review-log.md
+ - identity guard now covers every snapshot node, not just #terminal-container: terminal.js also
+ - binds #output, #command-input, #input-area and #status-bar, so a cloned one leaves the terminal writing into a detached node while the page looks perfect
+ - _restoreStock anchors on the next stock node already home, then on the recorded trailing
+ - sibling; a raw child index was measured wrong (terminal,input,status-bar,quick-actions)
+ - _loading map: a double-clicked card or a repeated 'ui easy' loads the mode once
+ - _sweepPending returns while that id is in flight instead of declaring failure at 6 s and
+ - overwriting the stored preference
+ - … run `git show <sha>` for the rest
+
+## v4.6.153 — 2026-11-07
+
+_Touches CHANGELOG.md (1), main.html (1), modules (1) · project 1_
+
+- **V4.6.153 — cache-bust the review fixes** `13271e5`
+
+## v4.6.152 — 2026-11-07
+
+_Touches modules (18), CHANGELOG.md (2), README.md (1), help.html (1) · docs 2, fix 2, release 1_
+
+- **Six interface modes (easy -> cockpit) over one terminal** `5dafaa4`
+ - 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.
+ - … run `git show <sha>` for the rest
+- **V4.6.152 changelog + change-history row for the six modes** `38e75c5`
+- **Mode layout constraints, capture() arg split, suppress classic welcome modal** `61ba8b5`
+ - #ui-root is a flex column (it was display:block, so every mode's flex:1 was inert and the
+ - terminal collapsed to 0px while the composer sat at the bottom of a 4600px flow)
+ - overflow:clip on #ui-root and body: overflow:hidden is still a scroll container, and the
+ - terminal's scrollIntoView() on new output scrolled the mode's own chrome off-screen
+ - terminal min-height 100px: in the tabbed/panel modes its grid parent is not a flex parent
+ - api.capture() split arguments itself; term._parseArgs does not exist, so every panel showed
+ - 'unavailable: term._parseArgs is not a function'
+ - the classic Let's Get Started modal is suppressed in non-classic modes: it covered easy's cards
+- **Rebuild changelog for v4.6.152** `5d9e037`
+- **Four-model review — lazy-load race, restore order, a11y, key handling** `fcd58a4`
+ - Reviewed the shipped change set with kimi-k2.7-code, deepseek-v4-pro, glm-5.3 and minimax-m3. Findings below are the ones that survived verification against file:line; the rest are in .hermes/plans/2026-11-07_163420-newos4-six-ui-modes.review-log.md
+ - Accepted and fixed:
+ - apply() had no generation token: a lazy mode load that finished after the user switched to
+ - another mode force-applied the abandoned one (`apply(want,{force:true})` in the .then). Every apply() now bumps `_loadSeq` and the callback acts only while its token is newest. Same token also removes a double mount: registerMode() already applies the mode as it registers, so the callback was mounting every lazy mode twice (teardown+cleanup twice).
+ - _restoreStock() used the recorded `nextSibling` as insert anchor. Every real mode moves
+ - … run `git show <sha>` for the rest
+
+## v4.6.151 — 2026-11-07
+
+_Touches modules (5), main.html (1), help.html (1) · project 1, fix 1_
+
+- **V4.6.151 — wget that actually saves, web_fetch that actually pages** `f84a82d`
+ - S1 (reported by agent-os4-maint-01, wrongly refuted by the chat auto-reply): wget existed but was unusable and dishonest.
+ - Cause 1: _cmdWget used the page's own fetch(url), so every cross-origin URL
+ - died on CORS ("Failed to fetch"). Measured in the served v4.6.150 page: fetch(cgit README) threw, /api/fetch returned 200/1954 bytes.
+ - Cause 2: _vfsWriteBytes ignored writeFile()'s boolean, so the command printed
+ - a green tick when the target directory did not exist. Measured: fs.writeFile('nosuchdir/x.txt') === false.
+ - Cause 3: bytes were round-tripped through String.fromCharCode, mangling UTF-8.
+ - Fix: fetch through /api/fetch (raw source), mkdir -p, writeFile + read-back
+ - verification, error instead of a tick on failure. Register curl with -s/-o parsing delegating to the same writer so the two cannot drift.
+ - … run `git show <sha>` for the rest
+- **Document curl in help command table (auto)** `f8e4c51`
+ - Plan: .hermes/plans/2026-11-07_061825-newos4-curl-help-row.md (T1) Root cause: curl shipped in v4.6.151 (cryptogzip.js:19 getHelpInfo, :612 commands['curl']) but help.html's command table was never updated, so check_help_coverage() reported 365/366.
+ - Change: one <tr> row added after the wget row in help.html's command table. No JS/PHP touched; cryptogzip.js, modules-index.json and atlas unchanged as specified in T2.
+ - Verification: grep -c '<code>curl ' help.html -> 1 wget row at 1086, curl row at 1087 -> adjacent grep -c "_isBase64" modules/ext/cryptogzip.js -> 0 (unchanged) git diff --stat -> help.html | 1 + python3 scripts/newos4-maintain.py -> PASS: help coverage 366/366, maintenance result: OK, needs OK: none HTTP help.html -> 200
+
## v4.6.150 — 2026-11-06
-_Touches modules (7), main.html (1) · project 1_
+_Touches modules (7), main.html (1), CHANGELOG.md (1) · project 2_
- **Commit the module work that has been live but uncommitted** `6bea062`
- Operator commit-approve, 2026-11-06. These changes have been served since 2026-11-02/03 while git HEAD still stamped v4.6.146, so the mirror published a tree four builds behind the running instance and the release tag named the wrong build.
- … run `git show <sha>` for the rest
+- **Regenerate for v4.6.150** `10e2ac2`
## v4.6.146 — 2026-11-06
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 faf7af6..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>
@@ -1084,6 +1099,7 @@ ollamaconnect</pre>
<tr><td><code>gui-clear</code></td><td>Clear the GUI panel</td></tr>
<tr><td><code>gzip &lt;path&gt;</code></td><td>Compress a VFS file to .gz</td></tr>
<tr><td><code>wget &lt;url&gt; [filename]</code></td><td>Download a URL and save it to the VFS</td></tr>
+ <tr><td><code>curl [-s] [-o FILE] &lt;url&gt;</code></td><td>Fetch a URL through the proxy and save it to the VFS (same writer as wget)</td></tr>
</table></div>
diff --git a/main.html b/main.html
index 7c5eb85..a6e17e9 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.150"></script>
+<script src="modules/core/core-bundle.js?v=v4.6.156"></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.150"></script>
+<script src="modules/core/module-loader.js?v=v4.6.156"></script>
<!-- Critical ext bundle loads AFTER webx11 exists so registerModule calls work -->
-<script src="modules/ext/critical-ext-bundle.js?v=v4.6.150"></script>
+<script src="modules/ext/critical-ext-bundle.js?v=v4.6.156"></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 c37d13d..c2b52d0 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, 1200));
+ await this.moduleManager.loadModuleFromURL(url);
+ } catch (e2) {
+ console.warn(`[Ext] ${key} failed to load: ${e2.message}`);
+ }
+ }
}
}
}
@@ -9144,7 +9163,8 @@ buildSystemPrompt() {
method: { type: 'string', enum: ['GET', 'POST'], description: 'HTTP method (default GET)' },
body: { type: 'string', description: 'Request body for POST (JSON string, max 10KB)' },
content_type: { type: 'string', description: 'Content-Type header (default application/json for POST)' },
-max_chars: { type: 'integer', description: 'Max characters of page content to return (default from the model profile; max 50000)' },
+max_chars: { type: 'integer', description: 'Max characters of page content to return (default from the model profile; a page is capped by the tool-result budget, so page with offset/next_offset for longer pages)' },
+ offset: { type: 'integer', description: 'Start character offset into the extracted text (default 0). Use the next_offset from the previous reply to continue.' },
},
required: ['url']
}
@@ -10373,6 +10393,11 @@ max_chars: { type: 'integer', description: 'Max characters of page content to re
if (keep < obj.content.length) {
const c = obj.content.charCodeAt(keep - 1);
if (c >= 0xD800 && c <= 0xDBFF) keep -= 1;
+ } else if (obj.content.length <= keep) {
+ // The page fits; only the envelope is fat. Do not claim a loss that
+ // did not happen (measured 2026-11-06: "clipped: 0 of 11800 withheld"
+ // appended while every character was returned).
+ return JSON.stringify(obj);
}
const hadMore = (obj.next_offset !== null && obj.next_offset !== undefined);
const full = obj.content.length;
@@ -11624,9 +11649,14 @@ max_chars: { type: 'integer', description: 'Max characters of page content to re
const contentType = a.content_type || (method === 'POST' ? 'application/json' : '');
try {
// Use /api/fetch for POST, /api/extract for GET (extract is cleaner for readable content)
- const maxChars = Math.max(500, Math.min(parseInt(a.max_chars, 10) || this._toolLimit('fetchChars'), 50000));
+ // A page larger than toolResultChars-200 is a page the model never sees
+ // (measured 2026-11-06: asked 50 000, received 11 800, no next_offset).
+ const wanted = parseInt(a.max_chars, 10) || this._toolLimit('fetchChars');
+ const clipMax = this._toolLimit('toolResultChars');
+ const limit = Math.max(500, Math.min(wanted, clipMax - 200)); // request window
+ const offset = Math.max(0, parseInt(a.offset, 10) || 0);
const endpoint = method === 'POST' ? '/api/fetch' : '/api/extract';
- const params = new URLSearchParams({ url, max_chars: String(maxChars) });
+ const params = new URLSearchParams({ url, max_chars: String(Math.min(100000, offset + limit)) });
if (method === 'POST') { params.set('method', 'POST'); params.set('body', body); if (contentType) params.set('content_type', contentType); }
const resp = await fetch(endpoint + '?' + params.toString());
if (resp.status === 403) return JSON.stringify({ error: 'URL blocked — internal network' });
@@ -11634,18 +11664,47 @@ max_chars: { type: 'integer', description: 'Max characters of page content to re
const err = await resp.json().catch(() => ({}));
return JSON.stringify({ error: 'Fetch failed: ' + (err.error || `HTTP ${resp.status}`), status: resp.status, details: err.details || '' });
}
- // For POST/route, the response is raw text; for GET/extract it's JSON
+ // POST: /api/fetch returns raw text, so page it by characters.
if (method === 'POST') {
const text = await resp.text();
- return JSON.stringify({ method, content: text.substring(0, maxChars), content_chars: text.length,
- returned_chars: Math.min(text.length, maxChars), truncated: text.length > maxChars });
+ const start = Math.min(offset, text.length);
+ let lim = Math.max(500, Math.min(limit, clipMax - 200));
+ let env;
+ do {
+ const slice = text.slice(start, start + lim);
+ const nextOffset = start + slice.length < text.length ? start + slice.length : null;
+ env = { method, offset: start, content: slice, returned_chars: slice.length,
+ content_chars: text.length, total_chars: text.length, source_bytes: text.length,
+ truncated: nextOffset !== null, next_offset: nextOffset };
+ if (JSON.stringify(env).length <= clipMax || lim <= 500) break;
+ lim -= Math.max(1, JSON.stringify(env).length - clipMax);
+ } while (true);
+ return JSON.stringify(env);
}
+ // GET: /api/extract carries content_chars = the true length of the extracted
+ // text, so offset/next_offset are exact rather than hopeful.
const data = await resp.json();
- return JSON.stringify({ method, title: data.title || '', content: (data.content || '').substring(0, maxChars),
- content_chars: data.content_chars || (data.content || '').length,
- returned_chars: Math.min((data.content || '').length, maxChars),
- truncated: !!(data.truncated || (data.content_chars > maxChars)),
- word_count: data.word_count || 0, method_proxy: data.method || 'extract' });
+ const full = data.content || '';
+ // UTF-16 length when the proxy supplies it: Python len() counts code points,
+ // JS counts UTF-16 units, and 7 astral chars were enough to leave a 7-char
+ // gap at the end of a 90 KB page (measured 2026-11-06).
+ const totalChars = Number(data.content_chars_utf16) || Number(data.content_chars) || full.length;
+ let start = Math.min(offset, totalChars);
+ if (start > 0) { const prev = full.charCodeAt(start - 1); if (prev >= 0xD800 && prev <= 0xDBFF) start += 1; }
+ let lim = Math.max(500, Math.min(limit, clipMax - 200));
+ let env;
+ do {
+ const slice = full.slice(start, start + lim);
+ const nextOffset = start + slice.length < totalChars ? start + slice.length : null;
+ env = { method, title: data.title || '', offset: start, content: slice, returned_chars: slice.length,
+ content_chars: totalChars, total_chars: totalChars,
+ source_bytes: (data.source_bytes === undefined ? null : data.source_bytes),
+ truncated: nextOffset !== null, next_offset: nextOffset,
+ word_count: data.word_count || 0, method_proxy: data.method || 'extract' };
+ if (JSON.stringify(env).length <= clipMax || lim <= 500) break;
+ lim -= Math.max(1, JSON.stringify(env).length - clipMax);
+ } while (true);
+ return JSON.stringify(env);
} catch (e) { return JSON.stringify({ error: 'Fetch failed: ' + e.message }); }
}
case 'wpm_install': {
@@ -14062,7 +14121,7 @@ class WebFetcherModule {
}
getHelpInfo() {
return {
- 'fetch': 'fetch <url> [--post=BODY] [--type=CT] [--raw] — Fetch URL (raw HTML/text); --post enables POST with body, --type sets Content-Type, --raw shows untruncated content',
+ 'fetch': 'fetch <url> [--post=BODY] [--type=CT] [--raw] [--save=FILE] — Fetch URL (raw HTML/text); --post enables POST with body, --type sets Content-Type, --raw shows untruncated content, --save writes it to the VFS',
'extract': 'extract <url> — Extract readable article content (title + body text) from a URL',
};
}
@@ -14126,6 +14185,21 @@ class WebFetcherModule {
return;
}
const text = await resp.text();
+ // --save: the only terminal path from a remote URL to the VFS. Without it a
+ // 100 KB source page can only be read through 3 000-char scrollback, which is
+ // what sent the in-OS agent looking for a shell redirect (reported 2026-11-06).
+ if (flags.save) {
+ const path = String(flags.save === true ? (url.split('/').pop().split('?')[0] || 'fetch.txt') : flags.save);
+ const dir = path.includes('/') ? path.slice(0, path.lastIndexOf('/')) : '';
+ if (dir) { try { this.terminal.fs.mkdir(dir); } catch (e) {} }
+ const wrote = this.terminal.fs.writeFile(path, text);
+ if (!wrote || this.terminal.fs.readFile(path) !== text) {
+ this.terminal.print(`Save failed: could not write ${path}`, 'error');
+ return;
+ }
+ this.terminal.print(`💾 Saved ${text.length} chars to ${path}`, 'success');
+ return;
+ }
const limit = showRaw ? text.length : 3000;
this.terminal.print(`\n=== Content from ${url} (${method}) ===\n`, 'success');
this.terminal.print(text.substring(0, limit) + (text.length > limit && !showRaw ? '...' : ''));
@@ -22884,4 +22958,613 @@ 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._loadSeq = 0; // bumps on every apply(): a stale lazy load must never win
+ this._loading = {}; // id -> in-flight promise, so a double click loads a mode once
+ 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');
+ // Validate before it reaches classList.add('wx11-ui-' + id): a space or a colon in the
+ // query throws a DOMException there and boot dies with no interface at all.
+ if (q && this.lazyIds().indexOf(q) !== -1) { wanted = q; this._fromQuery = true; }
+ } catch (e) { /* no URLSearchParams: ignore the request */ }
+ // persist:false — a shared link requests a view, it must not rewrite the user's own default
+ // (the code comment already claimed this; the flag did the opposite).
+ const applied = this.apply(wanted, { silent: true, persist: false });
+ 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 at once. Waiting for four registered modes was a dead condition —
+ // the six mode files are lazy now, so nothing registers before the user picks one, and every
+ // first-run visitor paid a 4.4 s delay for a list that was static anyway.
+ if (!localStorage.getItem(this.seenKey) && this.current === 'classic' && !this._pending) {
+ this._pickerT = setTimeout(() => this.showPicker(), 600);
+ }
+ }
+
+ _sweepPending() {
+ if (!this._pending) return;
+ if (this._loading[this._pending]) return; // still in flight: the load's own callback decides
+ 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); }
+
+ // The six modes exist even before their files are fetched, so `ui list` / `ui next` / the picker
+ // must not be limited to what happens to be registered. One list, not four copies.
+ lazyIds() { return ['easy', 'chat', 'studio', 'workspace', 'commander', 'cockpit']; }
+
+ /* ---------- switching ---------- */
+ apply(id, opts) {
+ const o = opts || {};
+ const want = id || 'classic';
+ // Every apply supersedes an in-flight lazy load. Without this token a load that finishes
+ // after the user moved on force-applies the abandoned mode (two reviewers, same finding).
+ const token = ++this._loadSeq;
+ // 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(); }
+ // hasOwnProperty: `this.modes` is a plain object, so ?ui=constructor / __proto__ passed the
+ // truthiness check and reached the mount path without any mode registering.
+ if (want !== 'classic' && !Object.prototype.hasOwnProperty.call(this.modes, want)) {
+ // Lazy load. The six mode files are NOT in extKeys: a full eager boot issues ~40-55
+ // requests and the per-IP `limit_req` bucket (then burst=50, now 200) 503'd everything
+ // after it — measured 2026-11-07.
+ 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) => {
+ // registerMode() applies the mode the moment it registers — which already bumped this
+ // token — and so does a user switch. Only act while this load is still the newest.
+ if (this._loadSeq !== token) return;
+ 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');
+ }
+ }).catch((e) => this._loadFail(want, 'load rejected: ' + (e && e.message)));
+ 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;
+ // Drop the previous mode's class: two wx11-ui-* classes at once keep any rule keyed on the
+ // old one matching, and the next switch then has to clean up after two modes.
+ Array.from(document.body.classList)
+ .filter((c) => c.indexOf('wx11-ui-') === 0 && c !== 'wx11-ui-' + want)
+ .forEach((c) => document.body.classList.remove(c));
+ document.body.classList.add('wx11-ui-' + want);
+ if (o.persist !== false) localStorage.setItem(this.key, want);
+
+ // _syncChrome on EVERY switch: it hides #quick-actions, which otherwise stays in the tab order
+ // and the accessibility tree behind the opaque mode root (found by review).
+ 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.
+ // Identity for EVERY stock node the terminal bound, not just the output pane: terminal.js also
+ // holds #output, #command-input, #input-area and #status-bar, so a mode that clones one of those
+ // leaves the terminal writing into a detached node while the page looks fine.
+ const lost = (this._where || []).filter((w) => {
+ const live = document.getElementById(w.el.id);
+ return !live || live !== w.el || !document.body.contains(w.el);
+ });
+ if (lost.length) {
+ this._abortToClassic(want, 'removed or replaced ' + lost.map((w) => '#' + w.el.id).join(', '));
+ return false;
+ }
+ this._dockFooter();
+ this._syncChrome();
+ // Every mode hides .prompt-label, so without this the real input has no accessible name.
+ const inp = document.getElementById('command-input');
+ if (inp && !inp.getAttribute('aria-label')) inp.setAttribute('aria-label', 'Terminal command input');
+ 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._pending = null; // a sweep or an in-flight load must not re-apply it
+ // Roll the stored preference back: leaving the broken mode persisted means every reload fails
+ // the same way, with no way out except devtools.
+ try { localStorage.setItem(this.key, 'classic'); } catch (e) { /* private mode */ }
+ 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.lazyIds());
+ 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) {
+ if (this._loading[id]) return this._loading[id]; // one load per id, however many taps
+ const p = this._loadModeOnce(id).finally(() => { delete this._loading[id]; });
+ this._loading[id] = p;
+ return p;
+ }
+
+ async _loadModeOnce(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.
+ // Only `ui-<id>`: without this a crafted ?ui=calc fetches and initialises an unrelated ext
+ // module through the mode loader (same-origin, full page access).
+ pkg = idx && idx.packages && idx.packages['ui-' + 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, 1200)); // >= 1 token at 60 r/m before retrying
+ 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,
+ index: Array.prototype.indexOf.call(el.parentNode.children, el),
+ next: el.nextSibling || null, // trailing anchor: what followed the last stock node
+ }));
+ }
+
+ _restoreStock() {
+ if (!this._where) return;
+ // Ascending original index, anchored on the next stock node that is ALREADY home. Neither
+ // `nextSibling` (a mode moves several nodes, so the recorded sibling lives inside the mode)
+ // nor a raw child index (removing nodes shifts every later index) reproduces the order —
+ // measured: `body.children[idx]` gave terminal,input,status-bar,quick-actions.
+ const list = this._where.slice().sort((a, b) => a.index - b.index);
+ list.forEach((w) => {
+ if (!w.parent || w.el.parentNode === w.parent) return; // already in place
+ // Anchor on the next stock node already home; for the LAST stock node fall back to the
+ // sibling that followed it originally, or the group lands after every trailing body child.
+ const anchor = list.find((c) => c.index > w.index && c.el.parentNode === w.parent)
+ || (w.next && w.next.parentNode === w.parent ? w.next : null);
+ try { w.parent.insertBefore(w.el, anchor ? anchor.el : null); } catch (e) { /* detached */ }
+ });
+ }
+
+ /* ---------- 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 = [
+ // clip, not hidden: `overflow:hidden` still creates a scroll container, and the terminal's
+ // scrollIntoView() on new output scrolled #ui-root by 464px — the title, the action cards
+ // and the composer all left the screen (measured 2026-11-07). `clip` cannot be scrolled.
+ '#ui-root{position:fixed;inset:0;display:none;overflow:hidden;overflow:clip;z-index:40;background:var(--bg-color);color:var(--text-color)}',
+ // flex column, not block: a block container makes each child's `flex:1 1 auto` inert and the
+ // terminal collapsed to 0px. Specificity is deliberately (1,0,0) so a mode that wants a GRID
+ // wins — with the old `body[data-ui-mode]:not(...) #ui-root` (1,2,1) every mode's
+ // `display:grid` was beaten and commander/studio/cockpit laid out as a plain stack
+ // (commander on a phone: two rails hidden, 253px of content in an 844px black page).
+ '#ui-root{display:flex;flex-direction:column;height:100%}',
+ 'body:not([data-ui-mode]) #ui-root,body[data-ui-mode="classic"] #ui-root{display:none!important}',
+ '#ui-root > *{min-width:0;min-height:0}',
+ // A non-classic mode owns the viewport: the page itself must not scroll, or terminal
+ // output drags the action cards off-screen and the composer never comes back.
+ 'body[data-ui-mode]:not([data-ui-mode="classic"]){overflow:hidden;overflow:clip}',
+ 'body[data-ui-mode]:not([data-ui-mode="classic"]) #ui-root{overflow:clip}',
+ 'body[data-ui-mode]:not([data-ui-mode="classic"]) #cmd-cards{display:none!important}',
+ // A mode IS the onboarding: six cards behind the classic "Let's Get Started" modal is two
+ // competing first-run prompts, and the modal covered the easy mode's action cards.
+ 'body[data-ui-mode]:not([data-ui-mode="classic"]) #welcome-modal{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.
+ // basis 0, not auto: with `auto` the terminal takes its 4 600px content height and pushes
+ // every sibling out of the viewport instead of scrolling inside its own pane.
+ // min-height 100px, not 0: in the tabbed/panel modes the terminal sits inside a grid cell
+ // that is not a flex parent, so `flex:1 1 0` resolves to 0 and the output strip vanished.
+ '#ui-root #terminal-container{flex:1 1 0;min-height:100px;height:0;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) => {
+ // Do NOT use terminal._parseArgs — it does not exist on the terminal instance; every
+ // panel rendered "unavailable: term._parseArgs is not a function" (measured 2026-11-07).
+ const raw = String(line).trim();
+ // Strip the quoting characters: capturePipeOutput receives argv, not shell words.
+ const parts = (raw.match(/(?:[^\s"']+|"[^"]*"|'[^']*')+/g) || [])
+ .map((a) => a.replace(/^["']/, '').replace(/["']$/, ''));
+ const cmd = parts[0];
+ if (!cmd) return Promise.resolve('');
+ // Commands with UI side effects are not capturable: dispatchCommand() runs them for
+ // real, so capturing `modules` re-opened the module-manager panel on every refresh
+ // tick ("the modules tab keeps opening", reported 2026-11-07).
+ const UI_ONLY = ['modules', 'config', 'edit', 'course', 'dashboard', 'ui', 'clear',
+ 'expand', 'sysmsg', 'font-size', 'setfont', 'startup-modules', 'load-local'];
+ if (UI_ONLY.indexOf(cmd) !== -1) {
+ return Promise.resolve('(' + cmd + ' opens a panel — not capturable)');
+ }
+ return term.capturePipeOutput(cmd, parts.slice(1), raw);
+ },
+ 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];
+ // No `html` sink: zero modes needed it, and it was the only innerHTML entry point in
+ // the capability API. Dynamic text goes through textContent.
+ 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';
+ // A full-screen overlay that is not announced as a dialog is a keyboard trap in reverse: Tab
+ // walks straight into the page behind it. (Found by review.)
+ wrap.setAttribute('role', 'dialog');
+ wrap.setAttribute('aria-modal', 'true');
+ wrap.setAttribute('aria-labelledby', 'wx11-ui-picker-title');
+ 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 id="wx11-ui-picker-title" 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);
+ // Focus the first card so Escape/Tab/Enter work without a pointer.
+ const firstCard = wrap.querySelector('#wx11-ui-picker-list button');
+ if (firstCard && firstCard.focus) firstCard.focus();
+ const list = wrap.querySelector('#wx11-ui-picker-list');
+ // ALWAYS the six, in level order. `this.list()` only holds modes that have already been
+ // fetched, so switching on it showed 4 cards once the user had visited 4 modes and silently
+ // dropped commander/cockpit (reported 2026-11-07). Registered specs win for label/tagline.
+ const modes = [
+ { 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.' }
+ ].map((m) => this.modes[m.id] || m);
+ 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 = this.lazyIds();
+ 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-aiassist.js b/modules/core/module-aiassist.js
index 0a0ea71..a3ffb6f 100644
--- a/modules/core/module-aiassist.js
+++ b/modules/core/module-aiassist.js
@@ -226,7 +226,8 @@ buildSystemPrompt() {
method: { type: 'string', enum: ['GET', 'POST'], description: 'HTTP method (default GET)' },
body: { type: 'string', description: 'Request body for POST (JSON string, max 10KB)' },
content_type: { type: 'string', description: 'Content-Type header (default application/json for POST)' },
-max_chars: { type: 'integer', description: 'Max characters of page content to return (default from the model profile; max 50000)' },
+max_chars: { type: 'integer', description: 'Max characters of page content to return (default from the model profile; a page is capped by the tool-result budget, so page with offset/next_offset for longer pages)' },
+ offset: { type: 'integer', description: 'Start character offset into the extracted text (default 0). Use the next_offset from the previous reply to continue.' },
},
required: ['url']
}
@@ -1455,6 +1456,11 @@ max_chars: { type: 'integer', description: 'Max characters of page content to re
if (keep < obj.content.length) {
const c = obj.content.charCodeAt(keep - 1);
if (c >= 0xD800 && c <= 0xDBFF) keep -= 1;
+ } else if (obj.content.length <= keep) {
+ // The page fits; only the envelope is fat. Do not claim a loss that
+ // did not happen (measured 2026-11-06: "clipped: 0 of 11800 withheld"
+ // appended while every character was returned).
+ return JSON.stringify(obj);
}
const hadMore = (obj.next_offset !== null && obj.next_offset !== undefined);
const full = obj.content.length;
@@ -2706,9 +2712,14 @@ max_chars: { type: 'integer', description: 'Max characters of page content to re
const contentType = a.content_type || (method === 'POST' ? 'application/json' : '');
try {
// Use /api/fetch for POST, /api/extract for GET (extract is cleaner for readable content)
- const maxChars = Math.max(500, Math.min(parseInt(a.max_chars, 10) || this._toolLimit('fetchChars'), 50000));
+ // A page larger than toolResultChars-200 is a page the model never sees
+ // (measured 2026-11-06: asked 50 000, received 11 800, no next_offset).
+ const wanted = parseInt(a.max_chars, 10) || this._toolLimit('fetchChars');
+ const clipMax = this._toolLimit('toolResultChars');
+ const limit = Math.max(500, Math.min(wanted, clipMax - 200)); // request window
+ const offset = Math.max(0, parseInt(a.offset, 10) || 0);
const endpoint = method === 'POST' ? '/api/fetch' : '/api/extract';
- const params = new URLSearchParams({ url, max_chars: String(maxChars) });
+ const params = new URLSearchParams({ url, max_chars: String(Math.min(100000, offset + limit)) });
if (method === 'POST') { params.set('method', 'POST'); params.set('body', body); if (contentType) params.set('content_type', contentType); }
const resp = await fetch(endpoint + '?' + params.toString());
if (resp.status === 403) return JSON.stringify({ error: 'URL blocked — internal network' });
@@ -2716,18 +2727,47 @@ max_chars: { type: 'integer', description: 'Max characters of page content to re
const err = await resp.json().catch(() => ({}));
return JSON.stringify({ error: 'Fetch failed: ' + (err.error || `HTTP ${resp.status}`), status: resp.status, details: err.details || '' });
}
- // For POST/route, the response is raw text; for GET/extract it's JSON
+ // POST: /api/fetch returns raw text, so page it by characters.
if (method === 'POST') {
const text = await resp.text();
- return JSON.stringify({ method, content: text.substring(0, maxChars), content_chars: text.length,
- returned_chars: Math.min(text.length, maxChars), truncated: text.length > maxChars });
+ const start = Math.min(offset, text.length);
+ let lim = Math.max(500, Math.min(limit, clipMax - 200));
+ let env;
+ do {
+ const slice = text.slice(start, start + lim);
+ const nextOffset = start + slice.length < text.length ? start + slice.length : null;
+ env = { method, offset: start, content: slice, returned_chars: slice.length,
+ content_chars: text.length, total_chars: text.length, source_bytes: text.length,
+ truncated: nextOffset !== null, next_offset: nextOffset };
+ if (JSON.stringify(env).length <= clipMax || lim <= 500) break;
+ lim -= Math.max(1, JSON.stringify(env).length - clipMax);
+ } while (true);
+ return JSON.stringify(env);
}
+ // GET: /api/extract carries content_chars = the true length of the extracted
+ // text, so offset/next_offset are exact rather than hopeful.
const data = await resp.json();
- return JSON.stringify({ method, title: data.title || '', content: (data.content || '').substring(0, maxChars),
- content_chars: data.content_chars || (data.content || '').length,
- returned_chars: Math.min((data.content || '').length, maxChars),
- truncated: !!(data.truncated || (data.content_chars > maxChars)),
- word_count: data.word_count || 0, method_proxy: data.method || 'extract' });
+ const full = data.content || '';
+ // UTF-16 length when the proxy supplies it: Python len() counts code points,
+ // JS counts UTF-16 units, and 7 astral chars were enough to leave a 7-char
+ // gap at the end of a 90 KB page (measured 2026-11-06).
+ const totalChars = Number(data.content_chars_utf16) || Number(data.content_chars) || full.length;
+ let start = Math.min(offset, totalChars);
+ if (start > 0) { const prev = full.charCodeAt(start - 1); if (prev >= 0xD800 && prev <= 0xDBFF) start += 1; }
+ let lim = Math.max(500, Math.min(limit, clipMax - 200));
+ let env;
+ do {
+ const slice = full.slice(start, start + lim);
+ const nextOffset = start + slice.length < totalChars ? start + slice.length : null;
+ env = { method, title: data.title || '', offset: start, content: slice, returned_chars: slice.length,
+ content_chars: totalChars, total_chars: totalChars,
+ source_bytes: (data.source_bytes === undefined ? null : data.source_bytes),
+ truncated: nextOffset !== null, next_offset: nextOffset,
+ word_count: data.word_count || 0, method_proxy: data.method || 'extract' };
+ if (JSON.stringify(env).length <= clipMax || lim <= 500) break;
+ lim -= Math.max(1, JSON.stringify(env).length - clipMax);
+ } while (true);
+ return JSON.stringify(env);
} catch (e) { return JSON.stringify({ error: 'Fetch failed: ' + e.message }); }
}
case 'wpm_install': {
diff --git a/modules/core/module-fetcher.js b/modules/core/module-fetcher.js
index 45505a7..d1b7bcc 100644
--- a/modules/core/module-fetcher.js
+++ b/modules/core/module-fetcher.js
@@ -5,7 +5,7 @@ class WebFetcherModule {
}
getHelpInfo() {
return {
- 'fetch': 'fetch <url> [--post=BODY] [--type=CT] [--raw] — Fetch URL (raw HTML/text); --post enables POST with body, --type sets Content-Type, --raw shows untruncated content',
+ 'fetch': 'fetch <url> [--post=BODY] [--type=CT] [--raw] [--save=FILE] — Fetch URL (raw HTML/text); --post enables POST with body, --type sets Content-Type, --raw shows untruncated content, --save writes it to the VFS',
'extract': 'extract <url> — Extract readable article content (title + body text) from a URL',
};
}
@@ -69,6 +69,21 @@ class WebFetcherModule {
return;
}
const text = await resp.text();
+ // --save: the only terminal path from a remote URL to the VFS. Without it a
+ // 100 KB source page can only be read through 3 000-char scrollback, which is
+ // what sent the in-OS agent looking for a shell redirect (reported 2026-11-06).
+ if (flags.save) {
+ const path = String(flags.save === true ? (url.split('/').pop().split('?')[0] || 'fetch.txt') : flags.save);
+ const dir = path.includes('/') ? path.slice(0, path.lastIndexOf('/')) : '';
+ if (dir) { try { this.terminal.fs.mkdir(dir); } catch (e) {} }
+ const wrote = this.terminal.fs.writeFile(path, text);
+ if (!wrote || this.terminal.fs.readFile(path) !== text) {
+ this.terminal.print(`Save failed: could not write ${path}`, 'error');
+ return;
+ }
+ this.terminal.print(`💾 Saved ${text.length} chars to ${path}`, 'success');
+ return;
+ }
const limit = showRaw ? text.length : 3000;
this.terminal.print(`\n=== Content from ${url} (${method}) ===\n`, 'success');
this.terminal.print(text.substring(0, limit) + (text.length > limit && !showRaw ? '...' : ''));
diff --git a/modules/core/module-loader.js b/modules/core/module-loader.js
index 1005463..67be01b 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.150';
+ const LATEST_VER = 'v4.6.156';
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..94e9bd1
--- /dev/null
+++ b/modules/core/module-uishell.js
@@ -0,0 +1,607 @@
+// 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._loadSeq = 0; // bumps on every apply(): a stale lazy load must never win
+ this._loading = {}; // id -> in-flight promise, so a double click loads a mode once
+ 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');
+ // Validate before it reaches classList.add('wx11-ui-' + id): a space or a colon in the
+ // query throws a DOMException there and boot dies with no interface at all.
+ if (q && this.lazyIds().indexOf(q) !== -1) { wanted = q; this._fromQuery = true; }
+ } catch (e) { /* no URLSearchParams: ignore the request */ }
+ // persist:false — a shared link requests a view, it must not rewrite the user's own default
+ // (the code comment already claimed this; the flag did the opposite).
+ const applied = this.apply(wanted, { silent: true, persist: false });
+ 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 at once. Waiting for four registered modes was a dead condition —
+ // the six mode files are lazy now, so nothing registers before the user picks one, and every
+ // first-run visitor paid a 4.4 s delay for a list that was static anyway.
+ if (!localStorage.getItem(this.seenKey) && this.current === 'classic' && !this._pending) {
+ this._pickerT = setTimeout(() => this.showPicker(), 600);
+ }
+ }
+
+ _sweepPending() {
+ if (!this._pending) return;
+ if (this._loading[this._pending]) return; // still in flight: the load's own callback decides
+ 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); }
+
+ // The six modes exist even before their files are fetched, so `ui list` / `ui next` / the picker
+ // must not be limited to what happens to be registered. One list, not four copies.
+ lazyIds() { return ['easy', 'chat', 'studio', 'workspace', 'commander', 'cockpit']; }
+
+ /* ---------- switching ---------- */
+ apply(id, opts) {
+ const o = opts || {};
+ const want = id || 'classic';
+ // Every apply supersedes an in-flight lazy load. Without this token a load that finishes
+ // after the user moved on force-applies the abandoned mode (two reviewers, same finding).
+ const token = ++this._loadSeq;
+ // 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(); }
+ // hasOwnProperty: `this.modes` is a plain object, so ?ui=constructor / __proto__ passed the
+ // truthiness check and reached the mount path without any mode registering.
+ if (want !== 'classic' && !Object.prototype.hasOwnProperty.call(this.modes, want)) {
+ // Lazy load. The six mode files are NOT in extKeys: a full eager boot issues ~40-55
+ // requests and the per-IP `limit_req` bucket (then burst=50, now 200) 503'd everything
+ // after it — measured 2026-11-07.
+ 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) => {
+ // registerMode() applies the mode the moment it registers — which already bumped this
+ // token — and so does a user switch. Only act while this load is still the newest.
+ if (this._loadSeq !== token) return;
+ 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');
+ }
+ }).catch((e) => this._loadFail(want, 'load rejected: ' + (e && e.message)));
+ 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;
+ // Drop the previous mode's class: two wx11-ui-* classes at once keep any rule keyed on the
+ // old one matching, and the next switch then has to clean up after two modes.
+ Array.from(document.body.classList)
+ .filter((c) => c.indexOf('wx11-ui-') === 0 && c !== 'wx11-ui-' + want)
+ .forEach((c) => document.body.classList.remove(c));
+ document.body.classList.add('wx11-ui-' + want);
+ if (o.persist !== false) localStorage.setItem(this.key, want);
+
+ // _syncChrome on EVERY switch: it hides #quick-actions, which otherwise stays in the tab order
+ // and the accessibility tree behind the opaque mode root (found by review).
+ 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.
+ // Identity for EVERY stock node the terminal bound, not just the output pane: terminal.js also
+ // holds #output, #command-input, #input-area and #status-bar, so a mode that clones one of those
+ // leaves the terminal writing into a detached node while the page looks fine.
+ const lost = (this._where || []).filter((w) => {
+ const live = document.getElementById(w.el.id);
+ return !live || live !== w.el || !document.body.contains(w.el);
+ });
+ if (lost.length) {
+ this._abortToClassic(want, 'removed or replaced ' + lost.map((w) => '#' + w.el.id).join(', '));
+ return false;
+ }
+ this._dockFooter();
+ this._syncChrome();
+ // Every mode hides .prompt-label, so without this the real input has no accessible name.
+ const inp = document.getElementById('command-input');
+ if (inp && !inp.getAttribute('aria-label')) inp.setAttribute('aria-label', 'Terminal command input');
+ 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._pending = null; // a sweep or an in-flight load must not re-apply it
+ // Roll the stored preference back: leaving the broken mode persisted means every reload fails
+ // the same way, with no way out except devtools.
+ try { localStorage.setItem(this.key, 'classic'); } catch (e) { /* private mode */ }
+ 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.lazyIds());
+ 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) {
+ if (this._loading[id]) return this._loading[id]; // one load per id, however many taps
+ const p = this._loadModeOnce(id).finally(() => { delete this._loading[id]; });
+ this._loading[id] = p;
+ return p;
+ }
+
+ async _loadModeOnce(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.
+ // Only `ui-<id>`: without this a crafted ?ui=calc fetches and initialises an unrelated ext
+ // module through the mode loader (same-origin, full page access).
+ pkg = idx && idx.packages && idx.packages['ui-' + 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, 1200)); // >= 1 token at 60 r/m before retrying
+ 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,
+ index: Array.prototype.indexOf.call(el.parentNode.children, el),
+ next: el.nextSibling || null, // trailing anchor: what followed the last stock node
+ }));
+ }
+
+ _restoreStock() {
+ if (!this._where) return;
+ // Ascending original index, anchored on the next stock node that is ALREADY home. Neither
+ // `nextSibling` (a mode moves several nodes, so the recorded sibling lives inside the mode)
+ // nor a raw child index (removing nodes shifts every later index) reproduces the order —
+ // measured: `body.children[idx]` gave terminal,input,status-bar,quick-actions.
+ const list = this._where.slice().sort((a, b) => a.index - b.index);
+ list.forEach((w) => {
+ if (!w.parent || w.el.parentNode === w.parent) return; // already in place
+ // Anchor on the next stock node already home; for the LAST stock node fall back to the
+ // sibling that followed it originally, or the group lands after every trailing body child.
+ const anchor = list.find((c) => c.index > w.index && c.el.parentNode === w.parent)
+ || (w.next && w.next.parentNode === w.parent ? w.next : null);
+ try { w.parent.insertBefore(w.el, anchor ? anchor.el : null); } catch (e) { /* detached */ }
+ });
+ }
+
+ /* ---------- 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 = [
+ // clip, not hidden: `overflow:hidden` still creates a scroll container, and the terminal's
+ // scrollIntoView() on new output scrolled #ui-root by 464px — the title, the action cards
+ // and the composer all left the screen (measured 2026-11-07). `clip` cannot be scrolled.
+ '#ui-root{position:fixed;inset:0;display:none;overflow:hidden;overflow:clip;z-index:40;background:var(--bg-color);color:var(--text-color)}',
+ // flex column, not block: a block container makes each child's `flex:1 1 auto` inert and the
+ // terminal collapsed to 0px. Specificity is deliberately (1,0,0) so a mode that wants a GRID
+ // wins — with the old `body[data-ui-mode]:not(...) #ui-root` (1,2,1) every mode's
+ // `display:grid` was beaten and commander/studio/cockpit laid out as a plain stack
+ // (commander on a phone: two rails hidden, 253px of content in an 844px black page).
+ '#ui-root{display:flex;flex-direction:column;height:100%}',
+ 'body:not([data-ui-mode]) #ui-root,body[data-ui-mode="classic"] #ui-root{display:none!important}',
+ '#ui-root > *{min-width:0;min-height:0}',
+ // A non-classic mode owns the viewport: the page itself must not scroll, or terminal
+ // output drags the action cards off-screen and the composer never comes back.
+ 'body[data-ui-mode]:not([data-ui-mode="classic"]){overflow:hidden;overflow:clip}',
+ 'body[data-ui-mode]:not([data-ui-mode="classic"]) #ui-root{overflow:clip}',
+ 'body[data-ui-mode]:not([data-ui-mode="classic"]) #cmd-cards{display:none!important}',
+ // A mode IS the onboarding: six cards behind the classic "Let's Get Started" modal is two
+ // competing first-run prompts, and the modal covered the easy mode's action cards.
+ 'body[data-ui-mode]:not([data-ui-mode="classic"]) #welcome-modal{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.
+ // basis 0, not auto: with `auto` the terminal takes its 4 600px content height and pushes
+ // every sibling out of the viewport instead of scrolling inside its own pane.
+ // min-height 100px, not 0: in the tabbed/panel modes the terminal sits inside a grid cell
+ // that is not a flex parent, so `flex:1 1 0` resolves to 0 and the output strip vanished.
+ '#ui-root #terminal-container{flex:1 1 0;min-height:100px;height:0;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) => {
+ // Do NOT use terminal._parseArgs — it does not exist on the terminal instance; every
+ // panel rendered "unavailable: term._parseArgs is not a function" (measured 2026-11-07).
+ const raw = String(line).trim();
+ // Strip the quoting characters: capturePipeOutput receives argv, not shell words.
+ const parts = (raw.match(/(?:[^\s"']+|"[^"]*"|'[^']*')+/g) || [])
+ .map((a) => a.replace(/^["']/, '').replace(/["']$/, ''));
+ const cmd = parts[0];
+ if (!cmd) return Promise.resolve('');
+ // Commands with UI side effects are not capturable: dispatchCommand() runs them for
+ // real, so capturing `modules` re-opened the module-manager panel on every refresh
+ // tick ("the modules tab keeps opening", reported 2026-11-07).
+ const UI_ONLY = ['modules', 'config', 'edit', 'course', 'dashboard', 'ui', 'clear',
+ 'expand', 'sysmsg', 'font-size', 'setfont', 'startup-modules', 'load-local'];
+ if (UI_ONLY.indexOf(cmd) !== -1) {
+ return Promise.resolve('(' + cmd + ' opens a panel — not capturable)');
+ }
+ return term.capturePipeOutput(cmd, parts.slice(1), raw);
+ },
+ 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];
+ // No `html` sink: zero modes needed it, and it was the only innerHTML entry point in
+ // the capability API. Dynamic text goes through textContent.
+ 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';
+ // A full-screen overlay that is not announced as a dialog is a keyboard trap in reverse: Tab
+ // walks straight into the page behind it. (Found by review.)
+ wrap.setAttribute('role', 'dialog');
+ wrap.setAttribute('aria-modal', 'true');
+ wrap.setAttribute('aria-labelledby', 'wx11-ui-picker-title');
+ 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 id="wx11-ui-picker-title" 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);
+ // Focus the first card so Escape/Tab/Enter work without a pointer.
+ const firstCard = wrap.querySelector('#wx11-ui-picker-list button');
+ if (firstCard && firstCard.focus) firstCard.focus();
+ const list = wrap.querySelector('#wx11-ui-picker-list');
+ // ALWAYS the six, in level order. `this.list()` only holds modes that have already been
+ // fetched, so switching on it showed 4 cards once the user had visited 4 modes and silently
+ // dropped commander/cockpit (reported 2026-11-07). Registered specs win for label/tagline.
+ const modes = [
+ { 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.' }
+ ].map((m) => this.modes[m.id] || m);
+ 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 = this.lazyIds();
+ 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..f0259fc 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, 1200));
+ await this.moduleManager.loadModuleFromURL(url);
+ } catch (e2) {
+ console.warn(`[Ext] ${key} failed to load: ${e2.message}`);
+ }
+ }
}
}
}
diff --git a/modules/ext/cryptogzip.js b/modules/ext/cryptogzip.js
index 200abdd..cc275df 100644
--- a/modules/ext/cryptogzip.js
+++ b/modules/ext/cryptogzip.js
@@ -15,7 +15,8 @@ class CryptoGzipModule {
'decrypt': 'decrypt <path.enc> <passphrase> — Decrypt a .enc file',
'tar': 'tar <src-dir> <out.tar[.gz]> — Bundle a VFS directory into a tar archive (gzip if .gz)',
'untar': 'untar <path.tar[.gz]> [dest-dir] — Extract a tar archive into VFS (auto-detects gzip)',
- 'wget': 'wget <url> [filename] — Download a URL and save to VFS'
+ 'wget': 'wget <url> [filename] — Download a URL (via /api/fetch) and save it to VFS',
+ 'curl': 'curl [-s] [-o FILE] <url> — Same as wget: proxy fetch + verified VFS write'
};
}
@@ -342,7 +343,10 @@ class CryptoGzipModule {
}
// ============================================================
- // WGET — fetch URL and save to VFS (binary-safe)
+ // WGET — fetch a URL through the /api/fetch proxy and save to VFS
+ // A direct browser fetch() only works same-origin: cross-origin URLs
+ // throw "Failed to fetch" (CORS), measured 2026-11-06 on the served
+ // v4.6.150 page. /api/fetch returns the raw source as UTF-8 text.
// ============================================================
async _cmdWget(args) {
@@ -350,20 +354,40 @@ class CryptoGzipModule {
this.terminal.print('Usage: wget <url> [filename] — Download a URL and save to VFS', 'warning');
return;
}
- const url = args[0];
- const filename = args[1] || url.split('/').pop() || 'download.bin';
+ const url = /^https?:\/\//i.test(args[0]) ? args[0] : 'https://' + args[0];
+ const filename = args[1] || url.split('/').pop().split('?')[0] || 'download.bin';
this.terminal.print(`Downloading ${url}...`, 'info');
+ let text;
try {
- const resp = await fetch(url);
- if (!resp.ok) throw new Error(`HTTP ${resp.status}: ${resp.statusText}`);
- const buf = await resp.arrayBuffer();
- const bytes = new Uint8Array(buf);
- this._vfsWriteBytes(filename, bytes);
- const sizeKb = (bytes.length / 1024).toFixed(1);
- this.terminal.print(`✅ wget: ${filename} (${sizeKb} KB, ${bytes.length} bytes)`, 'success');
+ const resp = await fetch('/api/fetch?url=' + encodeURIComponent(url));
+ if (resp.status === 403) {
+ this.terminal.print('wget error: URL blocked — internal network', 'error');
+ return;
+ }
+ if (!resp.ok) {
+ let detail = 'HTTP ' + resp.status;
+ try { const j = await resp.json(); detail = j.error || detail; } catch (e) {}
+ this.terminal.print('wget error: ' + detail, 'error');
+ return;
+ }
+ text = await resp.text();
} catch (e) {
- this.terminal.print(`wget error: ${e.message}`, 'error');
+ this.terminal.print('wget error: ' + e.message, 'error');
+ return;
+ }
+ // writeFile() returns false rather than throwing when the parent directory is
+ // missing — the old code printed ✅ on that path (measured: writeFile('nosuchdir/x.txt') === false).
+ const dir = filename.includes('/') ? filename.slice(0, filename.lastIndexOf('/')) : '';
+ if (dir) { try { this.terminal.fs.mkdir(dir); } catch (e) {} }
+ if (!this.terminal.fs.writeFile(filename, text)) {
+ this.terminal.print(`wget error: could not write ${filename} (missing directory, or path is a directory)`, 'error');
+ return;
}
+ if (this.terminal.fs.readFile(filename) !== text) {
+ this.terminal.print(`wget error: write verification failed for ${filename}`, 'error');
+ return;
+ }
+ this.terminal.print(`✅ wget: ${filename} (${(text.length / 1024).toFixed(1)} KB, ${text.length} chars)`, 'success');
}
// ============================================================
@@ -582,6 +606,22 @@ class CryptoGzipModule {
this.commands['tar'] = async (args) => self._cmdTar(args);
this.commands['untar'] = async (args) => self._cmdUntar(args);
this.commands['wget'] = async (args) => self._cmdWget(args);
+ // curl — the flags an agent actually types. No shell semantics: -s/-L/-f are
+ // accepted and ignored, -o/--output selects the VFS target. Delegates to the
+ // single verified writer above so the two cannot drift.
+ this.commands['curl'] = async (args) => {
+ const positional = [];
+ let out = null;
+ for (let i = 0; i < args.length; i++) {
+ const a = String(args[i]);
+ if (a === '-o' || a === '-O' || a === '--output') { out = args[++i]; }
+ else if (a.startsWith('-')) { /* accepted, ignored */ }
+ else positional.push(a);
+ }
+ if (!positional[0]) { this.terminal.print('Usage: curl [-s] [-o FILE] <url>', 'warning'); return; }
+ const target = out || (positional[0].split('/').pop().split('?')[0] || 'download.bin');
+ return self._cmdWget([positional[0], target]);
+ };
this.commandsMeta = {
gzip: { argType: 'file', description: 'Compress a VFS file to .gz' },
gunzip: { argType: 'file', description: 'Decompress a .gz file (binary-safe)' },
@@ -589,7 +629,8 @@ class CryptoGzipModule {
decrypt: { argType: 'file', description: 'Decrypt a .enc file' },
tar: { argType: 'file', description: 'Bundle a VFS directory into a tar archive' },
untar: { argType: 'file', description: 'Extract a tar archive into VFS' },
- wget: { argType: 'url', description: 'Download a URL and save to VFS' }
+ wget: { argType: 'url', description: 'Download a URL and save to VFS' },
+ curl: { argType: 'url', description: 'curl [-s] [-o FILE] <url> — same as wget' }
};
}
}
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..73fbe9f
--- /dev/null
+++ b/modules/ext/ui-cockpit.js
@@ -0,0 +1,132 @@
+// 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);
+ pane.tabIndex = -1; // so F1-F8 can actually move focus, not just scroll
+ 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 () => {
+ // Same as workspace: bumping per tick cancelled a slow run before the later panes were
+ // written, leaving them at "…" indefinitely.
+ 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;
+ const t = ev.target;
+ // Never swallow keys aimed at a text field, and leave F1/F12 to the browser: the handler
+ // used to preventDefault on all eight, so help, the address bar and devtools were dead
+ // while the cockpit was open (found by review).
+ if (t && (t.tagName === 'INPUT' || t.tagName === 'TEXTAREA' || t.isContentEditable)) return;
+ if (ev.key === 'F1' || ev.key === 'F12') 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;
+ if (pane.focus) pane.focus();
+ 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..873d7cc
--- /dev/null
+++ b/modules/ext/ui-commander.js
@@ -0,0 +1,203 @@
+// 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) {
+ /* one row that FILLS the viewport: without an explicit 1fr row the single grid row is auto, so
+ the centre column stopped at its content height and the rest of the screen was black. */
+ body[data-ui-mode="commander"] #ui-root { grid-template-columns: 1fr; grid-template-rows: minmax(0, 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 });
+ // Quote the path: a VFS name with a space split into two arguments, and a leading `-`
+ // became an option flag (found by review).
+ const quoted = "'" + String(name).replace(/'/g, "'\\''") + "'";
+ const run = () => api.exec((isDir ? 'ls ' : 'cat ') + quoted);
+ it.addEventListener('click', run);
+ it.tabIndex = 0;
+ it.setAttribute('role', 'button');
+ it.addEventListener('keydown', (ev) => {
+ if (ev.key === 'Enter' || ev.key === ' ') { ev.preventDefault(); run(); }
+ });
+ 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) => {
+ const t = ev.target;
+ // Ctrl/Cmd+K belongs to the focused field (kill-line in a terminal input); only intercept
+ // it from the page body, or typing in the composer opens the palette instead.
+ if (t && (t.tagName === 'INPUT' || t.tagName === 'TEXTAREA' || t.isContentEditable)) return;
+ 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..cd1388a
--- /dev/null
+++ b/modules/ext/ui-easy.js
@@ -0,0 +1,85 @@
+// 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; }
+/* padding-bottom 34px, not 0: #ui-foot is absolutely positioned and 34px tall, and the shorthand
+ reset the bottom padding the base sheet reserves, so the footer sat on top of the composer. */
+body[data-ui-mode="easy"] #ui-root { display: flex; flex-direction: column; padding: 14px 14px 34px; 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..e8341f3
--- /dev/null
+++ b/modules/ext/ui-studio.js
@@ -0,0 +1,147 @@
+// 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', () => {
+ // Quoted: a filename with a space would arrive as two arguments (found by review).
+ api.exec("edit '" + String(target).replace(/'/g, "'\\''") + "'");
+ 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..d660283
--- /dev/null
+++ b/modules/ext/ui-workspace.js
@@ -0,0 +1,139 @@
+// 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' },
+ // `modules` OPENS the module-manager panel — capturing it re-opened the panel on every
+ // 15 s refresh ("the modules tab keeps opening", reported 2026-11-07). `listmods` prints
+ // the same inventory as text.
+ { id: 'modules', ico: '🧩', title: 'Modules', cmd: 'listmods' },
+ { 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 () => {
+ // Do NOT bump the generation on every tick: a capture slower than the 15 s interval
+ // invalidated the in-flight run before it reached the later cards, so those stayed at
+ // "…" forever under slow storage (found by review). Teardown still bumps gen to stop it.
+ 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..530e2e4
--- /dev/null
+++ b/modules/test/test-ui-shell.js
@@ -0,0 +1,221 @@
+// 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',
+ // Deliberately absent: the production terminal has NO _parseArgs, and api.capture() must never
+ // call one (that regression blanked every telemetry panel). A stub here made A9 green anyway.
+};
+
+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
+ // Move a SECOND stock node: every real mode moves the input and the status bar too, and with
+ // only one node moved the `next`-sibling restore heuristic looked correct while being wrong.
+ const slot2 = api.el('div', { id: 'probe-slot2' });
+ api.root.appendChild(slot2);
+ const input = document.getElementById('input-area');
+ if (input) slot2.appendChild(input);
+ 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 = [