aboutsummaryrefslogtreecommitdiff
path: root/modules/core
diff options
context:
space:
mode:
Diffstat (limited to 'modules/core')
-rw-r--r--modules/core/core-bundle.js531
-rw-r--r--modules/core/module-loader.js5
-rw-r--r--modules/core/module-uishell.js508
-rw-r--r--modules/core/terminal.js21
4 files changed, 1062 insertions, 3 deletions
diff --git a/modules/core/core-bundle.js b/modules/core/core-bundle.js
index 4483709..36f6e7a 100644
--- a/modules/core/core-bundle.js
+++ b/modules/core/core-bundle.js
@@ -4071,6 +4071,7 @@ Only output XML function calls for tool tasks. If no tool is needed, answer norm
this._safeRegister('SmartContextModule', 'smart context');
this._safeRegister('SimpleGUIModule', 'simple GUI builder');
this._safeRegister('DebugSupervisorModule', 'debug supervisor');
+ this._safeRegister('UIShellModule', 'UI shell');
// v4.5.47: Critical ext modules promoted to boot path
this._safeRegister('EditorModule', 'editor');
this._safeRegister('ShellModule', 'shell');
@@ -4119,6 +4120,7 @@ Only output XML function calls for tool tasks. If no tool is needed, answer norm
BusyBoxModule: typeof BusyBoxModule !== 'undefined' ? BusyBoxModule : undefined,
SmartContextModule: typeof SmartContextModule !== 'undefined' ? SmartContextModule : undefined,
DebugSupervisorModule: typeof DebugSupervisorModule !== 'undefined' ? DebugSupervisorModule : undefined,
+ UIShellModule: typeof UIShellModule !== 'undefined' ? UIShellModule : undefined,
BillingModule: typeof BillingModule !== 'undefined' ? BillingModule : undefined,
};
const cls = registry[className];
@@ -4208,7 +4210,24 @@ Only output XML function calls for tool tasks. If no tool is needed, answer norm
const pkg = index.packages[key];
if (pkg) {
const url = pkg.url + (pkg.url.includes('?') ? '&v=' : '?v=') + cacheVer;
- await this.moduleManager.loadModuleFromURL(url);
+ // One bad module must not abort the rest: this loop is serial and used to
+ // return on the first 404/503/parse error, silently skipping every key
+ // after it (measured 2026-11-07: a 503 on an earlier key meant the six
+ // ui-* modes at the end never loaded).
+ try {
+ await this.moduleManager.loadModuleFromURL(url);
+ } catch (e) {
+ // A 503 here is nginx `limit_req zone=webx11` (60r/m, burst 50): the
+ // bucket is per-IP, so a boot with many modules drains it and every
+ // later module is refused. Back off and retry instead of losing the
+ // module — measured 2026-11-07, six ui-* files 503'd every load.
+ try {
+ await new Promise(r => setTimeout(r, 700));
+ await this.moduleManager.loadModuleFromURL(url);
+ } catch (e2) {
+ console.warn(`[Ext] ${key} failed to load: ${e2.message}`);
+ }
+ }
}
}
}
@@ -22939,4 +22958,514 @@ class DebugSupervisorModule {
}
}
+/* === module-uishell.js === */
+// newOS4 UI shell (v4.6.152) — one output stream, seven chromes.
+//
+// Rules this file exists to enforce:
+// • modes REPARENT the stock nodes (#terminal-container, #input-area, #quick-actions,
+// #status-bar); they never recreate them. terminal.js holds those references from
+// construction, so a fresh node silently orphans the AI output.
+// • teardown restores the stock nodes and clears the mode's DOM; the post-mount check compares
+// node IDENTITY, because a same-id clone passes a presence check.
+// • the shell, not the mode, docks #status-bar (it holds the AI stop button) into #ui-foot.
+// • ext modules are not a sandbox — they already hold window.webx11. The api() below is
+// ergonomics, not a privilege boundary. Do not "harden" it into theatre.
+class UIShellModule {
+ constructor() {
+ this.commands = {};
+ this.terminal = null;
+ this.modes = {}; // id -> { id, label, level, icon, tagline, mount }
+ this.current = 'classic';
+ this._root = null;
+ this._cleanup = null;
+ this._where = null; // [{ el, parent, next }] captured on first mount
+ this._terminalEl = null; // identity of the one real output node
+ this._cssIds = [];
+ this._pending = null; // mode requested before its ext module loaded
+ this._picker = null;
+ this._fromQuery = false;
+ this.key = 'webx11_ui_mode';
+ this.seenKey = 'webx11_ui_seen';
+ }
+
+ requirements() { return { met: true, missing: [] }; }
+
+ getHelpInfo() {
+ return {
+ 'ui': 'ui [list|<mode>|next|picker|classic] — Switch interface: classic | easy | chat | studio | workspace | commander | cockpit'
+ };
+ }
+
+ registerCommands() { this.commands['ui'] = (args) => this.handle(args || []); }
+
+ /* ---------- boot ---------- */
+ init(terminal) {
+ this.terminal = terminal;
+ terminal.uiShell = this; // ext mode modules reach the shell through this
+ this._injectBaseCSS();
+ this._root = this._ensureRoot();
+
+ // ?ui= is a REQUEST, not a preference: honoured only if that mode actually registers, so a
+ // shared link with a typo cannot poison localStorage or suppress onboarding.
+ let wanted = localStorage.getItem(this.key) || 'classic';
+ try {
+ const q = new URLSearchParams(location.search).get('ui');
+ if (q) { wanted = q; this._fromQuery = true; }
+ } catch (e) { /* no URLSearchParams: ignore the request */ }
+ const applied = this.apply(wanted, { silent: true, persist: this._fromQuery });
+ if (!applied) this._pending = wanted; // ext modules register ~800 ms later
+
+ // A requested/stored mode that never registers (404, parse error) is forgotten rather than
+ // kept as a dead preference forever.
+ this._sweepT = setTimeout(() => this._sweepPending(), 6000);
+
+ // First run: show the picker only once the mode modules have actually registered, otherwise
+ // the user can tap a card before its module exists and get "not loaded".
+ if (!localStorage.getItem(this.seenKey)) {
+ let waited = 0;
+ const tryShow = () => {
+ if (localStorage.getItem(this.seenKey)) return;
+ waited += 400;
+ if (Object.keys(this.modes).length >= 4 || waited >= 4000) { this.showPicker(); return; }
+ this._pickerT = setTimeout(tryShow, 400);
+ };
+ this._pickerT = setTimeout(tryShow, 800);
+ }
+ }
+
+ _sweepPending() {
+ if (!this._pending) return;
+ if (this.modes[this._pending]) return; // registered late: apply() already ran
+ if (this.terminal) {
+ this.terminal.print('Interface mode \'' + this._pending + '\' did not load — staying on ' +
+ this.current + '.', 'warning');
+ }
+ this._pending = null;
+ localStorage.setItem(this.key, this.current);
+ }
+
+ /* ---------- registry ---------- */
+ registerMode(spec) {
+ if (!spec || !spec.id || typeof spec.mount !== 'function') {
+ console.warn('[UIShell] ignoring invalid mode spec', spec && spec.id);
+ return false;
+ }
+ this.modes[spec.id] = spec;
+ if (this._pending === spec.id) {
+ this._pending = null;
+ this.apply(spec.id, { silent: true, persist: false });
+ }
+ return true;
+ }
+
+ list() { return Object.keys(this.modes).map(id => this.modes[id]).sort((a, b) => a.level - b.level); }
+
+ /* ---------- switching ---------- */
+ apply(id, opts) {
+ const o = opts || {};
+ const want = id || 'classic';
+ // A user-initiated switch cancels a pending boot restore, or a slow ext module would later
+ // yank the interface back to whatever boot asked for.
+ if (!o.silent) { this._pending = null; this._closePicker(); }
+ if (want !== 'classic' && !this.modes[want]) {
+ // Lazy load. The six mode files are NOT in extKeys: a full eager boot issues ~55
+ // requests and trips nginx `limit_req zone=webx11 (60r/m, burst 50)`, which 503s every
+ // module after the limit — measured 2026-11-07 in the dev copy.
+ if (o.retried) {
+ if (this.terminal) this.terminal.print(`UI mode '${want}' is not available.`, 'warning');
+ return false;
+ }
+ this._pending = want;
+ if (this.terminal) this.toast('Loading ' + want + '…', 1500);
+ this._loadMode(want).then((ok) => {
+ if (ok) this.apply(want, { force: true, retried: true, persist: o.persist });
+ else if (this.terminal) {
+ this.terminal.print(`UI mode '${want}' could not be loaded — available: ` +
+ (['classic'].concat(Object.keys(this.modes)).join(', ')), 'warning');
+ }
+ });
+ return false;
+ }
+ if (want === this.current && !o.force) {
+ // classic is a REAL state, not "attribute absent": the probe, the CSS scoping and
+ // _syncChrome all key off data-ui-mode, so it must be set even when nothing changes.
+ document.body.dataset.uiMode = want;
+ document.body.classList.add('wx11-ui-' + want);
+ if (o.persist !== false) localStorage.setItem(this.key, want);
+ return true;
+ }
+
+ this._teardown();
+ this.current = want;
+ document.body.dataset.uiMode = want;
+ document.body.classList.add('wx11-ui-' + want);
+ if (o.persist !== false) localStorage.setItem(this.key, want);
+
+ if (want === 'classic') { this._hideFooter(); this._syncChrome(); return true; }
+
+ const api = this.api();
+ let cleanup = null;
+ try {
+ cleanup = this.modes[want].mount(api);
+ } catch (e) {
+ console.error('[UIShell] mount failed for ' + want + ':', e);
+ this._abortToClassic(want, 'could not start (' + e.message + ')');
+ return false;
+ }
+ this._cleanup = typeof cleanup === 'function' ? cleanup : null;
+
+ // Identity, not just presence: a mode that appends its own <div id="terminal-container">
+ // would pass a presence check while terminal.js keeps printing into the detached original.
+ const out = document.getElementById('terminal-container');
+ const inDoc = !!out && !!document.body.contains(out);
+ if (!out || !inDoc || (this._terminalEl && out !== this._terminalEl)) {
+ this._abortToClassic(want, 'removed or replaced the terminal pane');
+ return false;
+ }
+ this._dockFooter();
+ document.dispatchEvent(new CustomEvent('wx11:ui-changed', { detail: { mode: want } }));
+ return true;
+ }
+
+ _abortToClassic(id, why) {
+ if (this.terminal) this.terminal.print(`UI mode '${id}' ${why} — reverted to classic.`, 'error');
+ try { if (this._cleanup) this._cleanup(); } catch (e) { /* mode is already broken */ }
+ this._cleanup = null;
+ this._restoreStock();
+ if (this._root) this._root.innerHTML = '';
+ this.current = 'classic';
+ document.body.classList.remove('wx11-ui-' + id);
+ document.body.dataset.uiMode = 'classic';
+ this._hideFooter();
+ this._cssSweep();
+ this._syncChrome();
+ }
+
+ next() {
+ const order = ['classic'].concat(this.list().map(m => m.id));
+ const i = order.indexOf(this.current);
+ return this.apply(order[(i + 1) % order.length]);
+ }
+
+ // Fetch a mode on demand. Uses the same cache-busting version the loader uses, so a mode can
+ // never be served stale after a version bump. Public moduleManager methods only, and no
+ // printing: a mode load must not litter the chat.
+ async _loadMode(id) {
+ const mm = this.terminal && this.terminal.moduleManager;
+ if (!mm) return this._loadFail(id, 'no module manager');
+ const ver = localStorage.getItem('webx11_cache_ver') || mm.LATEST_VER || '0';
+ let pkg = null;
+ try {
+ // no-store: the loader already cached packages.json under the same versioned URL.
+ const idx = await (await fetch('packages.json?v=' + ver, { cache: 'no-store' })).json();
+ // Mode ids are bare (`easy`); package keys are prefixed (`ui-easy`). Looking up only the
+ // bare id reported "no package entry" for every mode — measured 2026-11-07.
+ pkg = idx && idx.packages && (idx.packages['ui-' + id] || idx.packages[id]);
+ } catch (e) {
+ return this._loadFail(id, 'packages.json unreadable: ' + e.message);
+ }
+ if (!pkg || !pkg.url) return this._loadFail(id, 'no package entry (ver=' + ver + ')');
+ const url = pkg.url + (pkg.url.indexOf('?') === -1 ? '?v=' : '&v=') + ver;
+ // Reuse the SAME loader every ext module uses (fetch → createModuleClass → registerModule →
+ // init). A bespoke fetch/instantiate path here drifted from it twice and cost a debug cycle.
+ // Two attempts: a 503 from nginx `limit_req` is transient, and a mode that silently fails to
+ // load looks exactly like a broken button.
+ let ok = await mm.loadModuleFromURL(url);
+ if (!ok) {
+ await new Promise(r => setTimeout(r, 900));
+ ok = await mm.loadModuleFromURL(url);
+ }
+ if (!ok) return this._loadFail(id, 'loader refused ' + url);
+ if (!this.modes[id]) return this._loadFail(id, 'loaded but registered no mode');
+ return true;
+ }
+
+ _loadFail(id, why) {
+ console.warn('[UIShell] could not load mode ' + id + ': ' + why);
+ if (this.terminal && this.terminal.print) {
+ this.terminal.print('Interface mode ' + id + ' could not be loaded (' + why + ').', 'warning');
+ }
+ return false;
+ }
+
+ _teardown() {
+ if (this._cleanup) { try { this._cleanup(); } catch (e) { console.warn('[UIShell] unmount:', e.message); } }
+ this._cleanup = null;
+ // Restore first: the mode's subtree may still hold the stock nodes, and any
+ // getElementById() between these two lines would see nothing. The load-bearing part is that
+ // the restore RUNS — dropping it is what A5 catches (reordering alone still passes).
+ this._restoreStock();
+ if (this._root) this._root.innerHTML = '';
+ this._cssSweep();
+ document.body.classList.remove('wx11-ui-' + this.current);
+ }
+
+ /* ---------- stock DOM bookkeeping ---------- */
+ _stockEls() {
+ return ['terminal-container', 'input-area', 'quick-actions', 'status-bar']
+ .map(id => document.getElementById(id)).filter(Boolean);
+ }
+
+ _snapshotStock() {
+ if (this._where) return;
+ this._terminalEl = document.getElementById('terminal-container');
+ this._where = this._stockEls().map(el => ({ el, parent: el.parentNode, next: el.nextSibling || null }));
+ }
+
+ _restoreStock() {
+ if (!this._where) return;
+ for (const w of this._where) {
+ if (!w.parent) continue;
+ const ref = w.next && w.next.parentNode === w.parent ? w.next : null;
+ try { w.parent.insertBefore(w.el, ref); } catch (e) { /* detached parent */ }
+ }
+ }
+
+ /* ---------- chrome ---------- */
+ _syncChrome() {
+ const qa = document.getElementById('quick-actions');
+ if (qa) qa.style.display = this.current === 'classic' ? '' : 'none';
+ }
+
+ _ensureRoot() {
+ let root = document.getElementById('ui-root');
+ if (!root) {
+ root = document.createElement('div');
+ root.id = 'ui-root';
+ root.appendChild(this._ensureFoot());
+ document.body.appendChild(root);
+ }
+ return root;
+ }
+
+ // Every mode gets the status bar (network state + the AI "stop" button) whether it asks for it
+ // or not. A mode that forgets it would strand a streaming answer with no way to stop.
+ _ensureFoot() {
+ let foot = document.getElementById('ui-foot');
+ if (!foot) {
+ foot = document.createElement('div');
+ foot.id = 'ui-foot';
+ }
+ return foot;
+ }
+
+ _dockFooter() {
+ const foot = this._ensureFoot();
+ const bar = document.getElementById('status-bar');
+ if (bar && bar.parentNode !== foot) foot.appendChild(bar);
+ if (this._root && foot.parentNode !== this._root) this._root.appendChild(foot);
+ foot.style.display = '';
+ }
+
+ _hideFooter() {
+ const foot = document.getElementById('ui-foot');
+ if (foot) foot.style.display = 'none';
+ }
+
+ _injectBaseCSS() {
+ if (document.getElementById('wx11-ui-base-css')) return;
+ const st = document.createElement('style');
+ st.id = 'wx11-ui-base-css';
+ st.textContent = [
+ '#ui-root{position:fixed;inset:0;display:none;overflow:hidden;z-index:40;background:var(--bg-color);color:var(--text-color)}',
+ 'body[data-ui-mode]:not([data-ui-mode="classic"]) #ui-root{display:block}',
+ 'body[data-ui-mode]:not([data-ui-mode="classic"]) #cmd-cards{display:none!important}',
+ 'body[data-ui-mode]:not([data-ui-mode="classic"]) #ui-root{padding-bottom:34px}',
+ // The stock containers are flex children of <body>; inside a pane they must be able to
+ // shrink, or every mode overflows vertically and the input scrolls off-screen.
+ '#ui-root #terminal-container{flex:1 1 auto;min-height:0;height:auto;overflow-y:auto;padding:0}',
+ '#ui-root #input-area{flex:0 0 auto}',
+ '#ui-root #status-bar{flex:0 0 auto}',
+ '#ui-foot{position:absolute;left:0;right:0;bottom:0;height:34px;display:flex;align-items:center;',
+ 'gap:8px;padding:0 10px;background:var(--bg-secondary);border-top:1px solid var(--border-color);',
+ 'font-size:.78em;z-index:5}',
+ '#ui-foot #status-bar{border:0;background:transparent;flex:1 1 auto;display:flex;',
+ 'align-items:center;justify-content:space-between;gap:8px;font-size:1em;padding:0}',
+ '.wx11-pane{display:flex;flex-direction:column;min-height:0;min-width:0;overflow:hidden}',
+ '.wx11-card{background:var(--bg-secondary);border:1px solid var(--border-color);border-radius:10px;padding:12px}',
+ '.wx11-btn{min-height:44px;padding:10px 14px;background:var(--bg-tertiary);color:var(--text-color);',
+ 'border:1px solid var(--border-color);border-radius:8px;font:inherit;cursor:pointer;text-align:left}',
+ '.wx11-btn:active{background:var(--accent-color);color:#000}',
+ '.wx11-chip{display:inline-block;min-height:40px;padding:8px 12px;margin:0 6px 6px 0;border-radius:999px;',
+ 'border:1px solid var(--border-color);background:var(--bg-tertiary);font-size:.85em;cursor:pointer}',
+ '.wx11-dim{color:var(--text-muted);font-size:.85em}',
+ '.wx11-pre{margin:0;white-space:pre-wrap;word-break:break-word;font-size:.8em;line-height:1.35;',
+ 'max-height:100%;overflow:auto;color:var(--text-color)}',
+ '@media (max-width:600px){.wx11-btn{min-height:48px}}'
+ ].join('\n');
+ document.head.appendChild(st);
+ }
+
+ injectCSS(id, css) {
+ this.removeCSS(id);
+ const st = document.createElement('style');
+ st.id = 'wx11-ui-css-' + id;
+ st.textContent = css;
+ document.head.appendChild(st);
+ this._cssIds.push(id);
+ }
+
+ removeCSS(id) {
+ const el = document.getElementById('wx11-ui-css-' + id);
+ if (el) el.remove();
+ this._cssIds = this._cssIds.filter(x => x !== id);
+ }
+
+ _cssSweep() { this._cssIds.slice().forEach(id => this.removeCSS(id)); }
+
+ /* ---------- capability API handed to modes ---------- */
+ api() {
+ const self = this;
+ const term = this.terminal;
+ this._snapshotStock();
+ return {
+ root: this._root,
+ terminal: term,
+ stock: {
+ output: document.getElementById('terminal-container'),
+ input: document.getElementById('input-area'),
+ quickActions: document.getElementById('quick-actions'),
+ statusBar: document.getElementById('status-bar')
+ },
+ // user text → executeCommand (security gate applies)
+ exec: (line) => term.executeCommand(String(line)),
+ // shell-authored fixed strings only → capturePipeOutput (no security gate)
+ capture: (line) => {
+ const parts = term._parseArgs(String(line).trim());
+ const cmd = parts[0];
+ if (!cmd) return Promise.resolve('');
+ return term.capturePipeOutput(cmd, parts.slice(1), String(line));
+ },
+ fs: term.fs,
+ el: (tag, props, kids) => {
+ const node = document.createElement(tag);
+ if (props) for (const k of Object.keys(props)) {
+ if (k === 'text') node.textContent = props[k];
+ else if (k === 'html') node.innerHTML = props[k];
+ else if (k === 'class') node.className = props[k];
+ else node.setAttribute(k, props[k]);
+ }
+ (kids || []).forEach(k => node.appendChild(k));
+ return node;
+ },
+ attach: (slot, el) => { if (slot && el) slot.appendChild(el); return el; },
+ injectCSS: (id, css) => self.injectCSS(id, css),
+ removeCSS: (id) => self.removeCSS(id),
+ interval: (fn, ms) => {
+ const mm = term.moduleManager;
+ return mm && mm._setInterval ? mm._setInterval(fn, ms) : setInterval(fn, ms);
+ },
+ clearInterval: (id) => {
+ const mm = term.moduleManager;
+ if (mm && mm._clearTimer) mm._clearTimer(id); else clearInterval(id);
+ },
+ toast: (msg, ms) => self.toast(msg, ms)
+ };
+ }
+
+ toast(msg, ms) {
+ let host = document.getElementById('wx11-ui-toast');
+ if (!host) {
+ host = document.createElement('div');
+ host.id = 'wx11-ui-toast';
+ host.style.cssText = 'position:fixed;left:50%;transform:translateX(-50%);bottom:44px;' +
+ 'background:var(--bg-secondary);border:1px solid var(--accent-color);color:var(--text-color);' +
+ 'padding:10px 14px;border-radius:8px;z-index:9999;font-size:.9em;max-width:90vw;';
+ document.body.appendChild(host);
+ }
+ host.textContent = String(msg);
+ host.style.display = 'block';
+ clearTimeout(this._toastT);
+ this._toastT = setTimeout(() => { host.style.display = 'none'; }, ms || 2600);
+ }
+
+ /* ---------- picker ---------- */
+ showPicker() {
+ if (this._picker) return this._picker;
+ const self = this;
+ const wrap = document.createElement('div');
+ wrap.id = 'wx11-ui-picker';
+ wrap.style.cssText = 'position:fixed;inset:0;z-index:9998;background:rgba(0,0,0,.86);' +
+ 'display:flex;flex-direction:column;justify-content:center;padding:20px;overflow:auto;gap:10px;';
+ wrap.innerHTML = '<div style="max-width:720px;margin:0 auto;width:100%">' +
+ '<h2 style="margin:0 0 4px;font-size:1.15em">How do you want newOS4 to look?</h2>' +
+ '<p class="wx11-dim" style="margin:0 0 14px">Pick one — you can switch any time with <code>ui</code>.</p>' +
+ '<div id="wx11-ui-picker-list" style="display:grid;gap:10px"></div>' +
+ '<button class="wx11-btn" id="wx11-ui-picker-skip" style="margin-top:12px;width:100%;text-align:center">' +
+ 'Keep the classic terminal</button></div>';
+ document.body.appendChild(wrap);
+ const list = wrap.querySelector('#wx11-ui-picker-list');
+ // Fewer than 4 registered means the ext modules are still in flight — use the static list so
+ // the user never sees a one-card chooser (the card handler re-checks and warns if missing).
+ const modes = this.list().length >= 4 ? this.list() : [
+ { id: 'easy', label: 'Easy', level: 1, icon: '🌱', tagline: 'Just ask. No commands.' },
+ { id: 'chat', label: 'Chat', level: 2, icon: '💬', tagline: 'Conversation first.' },
+ { id: 'studio', label: 'Studio', level: 3, icon: '🎨', tagline: 'Chat plus a place for your files.' },
+ { id: 'workspace', label: 'Workspace', level: 4, icon: '🗂', tagline: 'Everything at a glance.' },
+ { id: 'commander', label: 'Commander', level: 5, icon: '⌨️', tagline: 'Terminal with rails.' },
+ { id: 'cockpit', label: 'Cockpit', level: 6, icon: '🛰', tagline: 'Live system telemetry.' }
+ ];
+ modes.forEach(m => {
+ const b = document.createElement('button');
+ b.className = 'wx11-btn';
+ b.style.cssText = 'display:flex;gap:12px;align-items:center;width:100%';
+ const ico = document.createElement('span');
+ ico.style.fontSize = '1.5em';
+ ico.textContent = m.icon || '▫'; // never innerHTML: labels can change
+ const txt = document.createElement('span');
+ const strong = document.createElement('b');
+ strong.textContent = String(m.label);
+ const tag = document.createElement('span');
+ tag.className = 'wx11-dim';
+ tag.textContent = String(m.tagline || '');
+ txt.appendChild(strong); txt.appendChild(document.createElement('br')); txt.appendChild(tag);
+ b.appendChild(ico); b.appendChild(txt);
+ b.addEventListener('click', () => { self._closePicker(); self.apply(m.id); });
+ list.appendChild(b);
+ });
+ wrap.querySelector('#wx11-ui-picker-skip').addEventListener('click', () => {
+ self._closePicker(); self.apply('classic');
+ });
+ const onEsc = (ev) => { if (ev.key === 'Escape') { self._closePicker(); } };
+ document.addEventListener('keydown', onEsc);
+ wrap._onEsc = onEsc;
+ this._picker = wrap;
+ return wrap;
+ }
+
+ _closePicker() {
+ if (this._pickerT) { clearTimeout(this._pickerT); this._pickerT = null; }
+ if (this._picker) {
+ if (this._picker._onEsc) document.removeEventListener('keydown', this._picker._onEsc);
+ this._picker.remove();
+ this._picker = null;
+ }
+ localStorage.setItem(this.seenKey, '1');
+ }
+
+ /* ---------- command ---------- */
+ handle(args) {
+ const sub = String(args[0] || '').toLowerCase();
+ const available = ['classic'].concat(this.list().map(m => m.id));
+ if (!sub || sub === 'list') {
+ this.terminal.print('Interface modes:', 'info');
+ this.terminal.print(' classic (level 0) — the terminal you already know', 'info');
+ this.list().forEach(m => this.terminal.print(
+ ' ' + String(m.id).padEnd(10) + '(level ' + m.level + ') — ' + (m.tagline || m.label), 'info'));
+ this.terminal.print('Usage: ui <mode> | ui next | ui picker | ui classic', 'info');
+ return;
+ }
+ if (sub === 'picker') return void this.showPicker();
+ if (sub === 'next') return void this.next();
+ // A mode that is not registered yet is not an error: it is lazy-loaded on demand. Only an
+ // id that is not one of the six is unknown.
+ const LAZY = ['easy', 'chat', 'studio', 'workspace', 'commander', 'cockpit'];
+ if (available.indexOf(sub) === -1 && LAZY.indexOf(sub) === -1) {
+ this.terminal.print(`Unknown mode '${sub}'. Available: ${available.join(', ')}`, 'warning');
+ return;
+ }
+ if (this.apply(sub)) this.terminal.print('Interface: ' + sub, 'success');
+ }
+}
+
/* === end core module bundle === */
diff --git a/modules/core/module-loader.js b/modules/core/module-loader.js
index 1f931fd..e9d3413 100644
--- a/modules/core/module-loader.js
+++ b/modules/core/module-loader.js
@@ -4,7 +4,7 @@
// Core module bundle loaded synchronously in main.html.
// This loader only handles version cache-busting, splash, terminal init, and optional ext modules.
- const LATEST_VER = 'v4.6.151';
+ const LATEST_VER = 'v4.6.152';
const CACHE = '?v=' + LATEST_VER;
// Exposed so ext modules can register their own URL with the same cache-busting version
// instead of an unversioned path the browser may already have cached (shell.js did).
@@ -58,6 +58,9 @@
setSplash('Initializing terminal (3/3)', total, total, '');
// webx11 already created synchronously above — just init ext modules
if (typeof webx11._loadExtModules === 'function') {
+ // The six ui-* modes are NOT here on purpose: they load on demand via
+ // uiShell._loadMode(). Eager boot issues ~55 requests and trips nginx
+ // limit_req (60r/m, burst 50) — the 11th+ module would 503.
let extKeys = ['calc', 'voice', 'debug-panel', 'cron', 'theme', 'wpm', 'mindmap', 'webdav', 'skill-pack-core', 'cryptogzip', 'tutorial', 'floating', 'console', 'media', 'three', 'kanban', 'course'];
const saved = localStorage.getItem('webx11_startup_modules');
let selected = null;
diff --git a/modules/core/module-uishell.js b/modules/core/module-uishell.js
new file mode 100644
index 0000000..63a1437
--- /dev/null
+++ b/modules/core/module-uishell.js
@@ -0,0 +1,508 @@
+// newOS4 UI shell (v4.6.152) — one output stream, seven chromes.
+//
+// Rules this file exists to enforce:
+// • modes REPARENT the stock nodes (#terminal-container, #input-area, #quick-actions,
+// #status-bar); they never recreate them. terminal.js holds those references from
+// construction, so a fresh node silently orphans the AI output.
+// • teardown restores the stock nodes and clears the mode's DOM; the post-mount check compares
+// node IDENTITY, because a same-id clone passes a presence check.
+// • the shell, not the mode, docks #status-bar (it holds the AI stop button) into #ui-foot.
+// • ext modules are not a sandbox — they already hold window.webx11. The api() below is
+// ergonomics, not a privilege boundary. Do not "harden" it into theatre.
+class UIShellModule {
+ constructor() {
+ this.commands = {};
+ this.terminal = null;
+ this.modes = {}; // id -> { id, label, level, icon, tagline, mount }
+ this.current = 'classic';
+ this._root = null;
+ this._cleanup = null;
+ this._where = null; // [{ el, parent, next }] captured on first mount
+ this._terminalEl = null; // identity of the one real output node
+ this._cssIds = [];
+ this._pending = null; // mode requested before its ext module loaded
+ this._picker = null;
+ this._fromQuery = false;
+ this.key = 'webx11_ui_mode';
+ this.seenKey = 'webx11_ui_seen';
+ }
+
+ requirements() { return { met: true, missing: [] }; }
+
+ getHelpInfo() {
+ return {
+ 'ui': 'ui [list|<mode>|next|picker|classic] — Switch interface: classic | easy | chat | studio | workspace | commander | cockpit'
+ };
+ }
+
+ registerCommands() { this.commands['ui'] = (args) => this.handle(args || []); }
+
+ /* ---------- boot ---------- */
+ init(terminal) {
+ this.terminal = terminal;
+ terminal.uiShell = this; // ext mode modules reach the shell through this
+ this._injectBaseCSS();
+ this._root = this._ensureRoot();
+
+ // ?ui= is a REQUEST, not a preference: honoured only if that mode actually registers, so a
+ // shared link with a typo cannot poison localStorage or suppress onboarding.
+ let wanted = localStorage.getItem(this.key) || 'classic';
+ try {
+ const q = new URLSearchParams(location.search).get('ui');
+ if (q) { wanted = q; this._fromQuery = true; }
+ } catch (e) { /* no URLSearchParams: ignore the request */ }
+ const applied = this.apply(wanted, { silent: true, persist: this._fromQuery });
+ if (!applied) this._pending = wanted; // ext modules register ~800 ms later
+
+ // A requested/stored mode that never registers (404, parse error) is forgotten rather than
+ // kept as a dead preference forever.
+ this._sweepT = setTimeout(() => this._sweepPending(), 6000);
+
+ // First run: show the picker only once the mode modules have actually registered, otherwise
+ // the user can tap a card before its module exists and get "not loaded".
+ if (!localStorage.getItem(this.seenKey)) {
+ let waited = 0;
+ const tryShow = () => {
+ if (localStorage.getItem(this.seenKey)) return;
+ waited += 400;
+ if (Object.keys(this.modes).length >= 4 || waited >= 4000) { this.showPicker(); return; }
+ this._pickerT = setTimeout(tryShow, 400);
+ };
+ this._pickerT = setTimeout(tryShow, 800);
+ }
+ }
+
+ _sweepPending() {
+ if (!this._pending) return;
+ if (this.modes[this._pending]) return; // registered late: apply() already ran
+ if (this.terminal) {
+ this.terminal.print('Interface mode \'' + this._pending + '\' did not load — staying on ' +
+ this.current + '.', 'warning');
+ }
+ this._pending = null;
+ localStorage.setItem(this.key, this.current);
+ }
+
+ /* ---------- registry ---------- */
+ registerMode(spec) {
+ if (!spec || !spec.id || typeof spec.mount !== 'function') {
+ console.warn('[UIShell] ignoring invalid mode spec', spec && spec.id);
+ return false;
+ }
+ this.modes[spec.id] = spec;
+ if (this._pending === spec.id) {
+ this._pending = null;
+ this.apply(spec.id, { silent: true, persist: false });
+ }
+ return true;
+ }
+
+ list() { return Object.keys(this.modes).map(id => this.modes[id]).sort((a, b) => a.level - b.level); }
+
+ /* ---------- switching ---------- */
+ apply(id, opts) {
+ const o = opts || {};
+ const want = id || 'classic';
+ // A user-initiated switch cancels a pending boot restore, or a slow ext module would later
+ // yank the interface back to whatever boot asked for.
+ if (!o.silent) { this._pending = null; this._closePicker(); }
+ if (want !== 'classic' && !this.modes[want]) {
+ // Lazy load. The six mode files are NOT in extKeys: a full eager boot issues ~55
+ // requests and trips nginx `limit_req zone=webx11 (60r/m, burst 50)`, which 503s every
+ // module after the limit — measured 2026-11-07 in the dev copy.
+ if (o.retried) {
+ if (this.terminal) this.terminal.print(`UI mode '${want}' is not available.`, 'warning');
+ return false;
+ }
+ this._pending = want;
+ if (this.terminal) this.toast('Loading ' + want + '…', 1500);
+ this._loadMode(want).then((ok) => {
+ if (ok) this.apply(want, { force: true, retried: true, persist: o.persist });
+ else if (this.terminal) {
+ this.terminal.print(`UI mode '${want}' could not be loaded — available: ` +
+ (['classic'].concat(Object.keys(this.modes)).join(', ')), 'warning');
+ }
+ });
+ return false;
+ }
+ if (want === this.current && !o.force) {
+ // classic is a REAL state, not "attribute absent": the probe, the CSS scoping and
+ // _syncChrome all key off data-ui-mode, so it must be set even when nothing changes.
+ document.body.dataset.uiMode = want;
+ document.body.classList.add('wx11-ui-' + want);
+ if (o.persist !== false) localStorage.setItem(this.key, want);
+ return true;
+ }
+
+ this._teardown();
+ this.current = want;
+ document.body.dataset.uiMode = want;
+ document.body.classList.add('wx11-ui-' + want);
+ if (o.persist !== false) localStorage.setItem(this.key, want);
+
+ if (want === 'classic') { this._hideFooter(); this._syncChrome(); return true; }
+
+ const api = this.api();
+ let cleanup = null;
+ try {
+ cleanup = this.modes[want].mount(api);
+ } catch (e) {
+ console.error('[UIShell] mount failed for ' + want + ':', e);
+ this._abortToClassic(want, 'could not start (' + e.message + ')');
+ return false;
+ }
+ this._cleanup = typeof cleanup === 'function' ? cleanup : null;
+
+ // Identity, not just presence: a mode that appends its own <div id="terminal-container">
+ // would pass a presence check while terminal.js keeps printing into the detached original.
+ const out = document.getElementById('terminal-container');
+ const inDoc = !!out && !!document.body.contains(out);
+ if (!out || !inDoc || (this._terminalEl && out !== this._terminalEl)) {
+ this._abortToClassic(want, 'removed or replaced the terminal pane');
+ return false;
+ }
+ this._dockFooter();
+ document.dispatchEvent(new CustomEvent('wx11:ui-changed', { detail: { mode: want } }));
+ return true;
+ }
+
+ _abortToClassic(id, why) {
+ if (this.terminal) this.terminal.print(`UI mode '${id}' ${why} — reverted to classic.`, 'error');
+ try { if (this._cleanup) this._cleanup(); } catch (e) { /* mode is already broken */ }
+ this._cleanup = null;
+ this._restoreStock();
+ if (this._root) this._root.innerHTML = '';
+ this.current = 'classic';
+ document.body.classList.remove('wx11-ui-' + id);
+ document.body.dataset.uiMode = 'classic';
+ this._hideFooter();
+ this._cssSweep();
+ this._syncChrome();
+ }
+
+ next() {
+ const order = ['classic'].concat(this.list().map(m => m.id));
+ const i = order.indexOf(this.current);
+ return this.apply(order[(i + 1) % order.length]);
+ }
+
+ // Fetch a mode on demand. Uses the same cache-busting version the loader uses, so a mode can
+ // never be served stale after a version bump. Public moduleManager methods only, and no
+ // printing: a mode load must not litter the chat.
+ async _loadMode(id) {
+ const mm = this.terminal && this.terminal.moduleManager;
+ if (!mm) return this._loadFail(id, 'no module manager');
+ const ver = localStorage.getItem('webx11_cache_ver') || mm.LATEST_VER || '0';
+ let pkg = null;
+ try {
+ // no-store: the loader already cached packages.json under the same versioned URL.
+ const idx = await (await fetch('packages.json?v=' + ver, { cache: 'no-store' })).json();
+ // Mode ids are bare (`easy`); package keys are prefixed (`ui-easy`). Looking up only the
+ // bare id reported "no package entry" for every mode — measured 2026-11-07.
+ pkg = idx && idx.packages && (idx.packages['ui-' + id] || idx.packages[id]);
+ } catch (e) {
+ return this._loadFail(id, 'packages.json unreadable: ' + e.message);
+ }
+ if (!pkg || !pkg.url) return this._loadFail(id, 'no package entry (ver=' + ver + ')');
+ const url = pkg.url + (pkg.url.indexOf('?') === -1 ? '?v=' : '&v=') + ver;
+ // Reuse the SAME loader every ext module uses (fetch → createModuleClass → registerModule →
+ // init). A bespoke fetch/instantiate path here drifted from it twice and cost a debug cycle.
+ // Two attempts: a 503 from nginx `limit_req` is transient, and a mode that silently fails to
+ // load looks exactly like a broken button.
+ let ok = await mm.loadModuleFromURL(url);
+ if (!ok) {
+ await new Promise(r => setTimeout(r, 900));
+ ok = await mm.loadModuleFromURL(url);
+ }
+ if (!ok) return this._loadFail(id, 'loader refused ' + url);
+ if (!this.modes[id]) return this._loadFail(id, 'loaded but registered no mode');
+ return true;
+ }
+
+ _loadFail(id, why) {
+ console.warn('[UIShell] could not load mode ' + id + ': ' + why);
+ if (this.terminal && this.terminal.print) {
+ this.terminal.print('Interface mode ' + id + ' could not be loaded (' + why + ').', 'warning');
+ }
+ return false;
+ }
+
+ _teardown() {
+ if (this._cleanup) { try { this._cleanup(); } catch (e) { console.warn('[UIShell] unmount:', e.message); } }
+ this._cleanup = null;
+ // Restore first: the mode's subtree may still hold the stock nodes, and any
+ // getElementById() between these two lines would see nothing. The load-bearing part is that
+ // the restore RUNS — dropping it is what A5 catches (reordering alone still passes).
+ this._restoreStock();
+ if (this._root) this._root.innerHTML = '';
+ this._cssSweep();
+ document.body.classList.remove('wx11-ui-' + this.current);
+ }
+
+ /* ---------- stock DOM bookkeeping ---------- */
+ _stockEls() {
+ return ['terminal-container', 'input-area', 'quick-actions', 'status-bar']
+ .map(id => document.getElementById(id)).filter(Boolean);
+ }
+
+ _snapshotStock() {
+ if (this._where) return;
+ this._terminalEl = document.getElementById('terminal-container');
+ this._where = this._stockEls().map(el => ({ el, parent: el.parentNode, next: el.nextSibling || null }));
+ }
+
+ _restoreStock() {
+ if (!this._where) return;
+ for (const w of this._where) {
+ if (!w.parent) continue;
+ const ref = w.next && w.next.parentNode === w.parent ? w.next : null;
+ try { w.parent.insertBefore(w.el, ref); } catch (e) { /* detached parent */ }
+ }
+ }
+
+ /* ---------- chrome ---------- */
+ _syncChrome() {
+ const qa = document.getElementById('quick-actions');
+ if (qa) qa.style.display = this.current === 'classic' ? '' : 'none';
+ }
+
+ _ensureRoot() {
+ let root = document.getElementById('ui-root');
+ if (!root) {
+ root = document.createElement('div');
+ root.id = 'ui-root';
+ root.appendChild(this._ensureFoot());
+ document.body.appendChild(root);
+ }
+ return root;
+ }
+
+ // Every mode gets the status bar (network state + the AI "stop" button) whether it asks for it
+ // or not. A mode that forgets it would strand a streaming answer with no way to stop.
+ _ensureFoot() {
+ let foot = document.getElementById('ui-foot');
+ if (!foot) {
+ foot = document.createElement('div');
+ foot.id = 'ui-foot';
+ }
+ return foot;
+ }
+
+ _dockFooter() {
+ const foot = this._ensureFoot();
+ const bar = document.getElementById('status-bar');
+ if (bar && bar.parentNode !== foot) foot.appendChild(bar);
+ if (this._root && foot.parentNode !== this._root) this._root.appendChild(foot);
+ foot.style.display = '';
+ }
+
+ _hideFooter() {
+ const foot = document.getElementById('ui-foot');
+ if (foot) foot.style.display = 'none';
+ }
+
+ _injectBaseCSS() {
+ if (document.getElementById('wx11-ui-base-css')) return;
+ const st = document.createElement('style');
+ st.id = 'wx11-ui-base-css';
+ st.textContent = [
+ '#ui-root{position:fixed;inset:0;display:none;overflow:hidden;z-index:40;background:var(--bg-color);color:var(--text-color)}',
+ 'body[data-ui-mode]:not([data-ui-mode="classic"]) #ui-root{display:block}',
+ 'body[data-ui-mode]:not([data-ui-mode="classic"]) #cmd-cards{display:none!important}',
+ 'body[data-ui-mode]:not([data-ui-mode="classic"]) #ui-root{padding-bottom:34px}',
+ // The stock containers are flex children of <body>; inside a pane they must be able to
+ // shrink, or every mode overflows vertically and the input scrolls off-screen.
+ '#ui-root #terminal-container{flex:1 1 auto;min-height:0;height:auto;overflow-y:auto;padding:0}',
+ '#ui-root #input-area{flex:0 0 auto}',
+ '#ui-root #status-bar{flex:0 0 auto}',
+ '#ui-foot{position:absolute;left:0;right:0;bottom:0;height:34px;display:flex;align-items:center;',
+ 'gap:8px;padding:0 10px;background:var(--bg-secondary);border-top:1px solid var(--border-color);',
+ 'font-size:.78em;z-index:5}',
+ '#ui-foot #status-bar{border:0;background:transparent;flex:1 1 auto;display:flex;',
+ 'align-items:center;justify-content:space-between;gap:8px;font-size:1em;padding:0}',
+ '.wx11-pane{display:flex;flex-direction:column;min-height:0;min-width:0;overflow:hidden}',
+ '.wx11-card{background:var(--bg-secondary);border:1px solid var(--border-color);border-radius:10px;padding:12px}',
+ '.wx11-btn{min-height:44px;padding:10px 14px;background:var(--bg-tertiary);color:var(--text-color);',
+ 'border:1px solid var(--border-color);border-radius:8px;font:inherit;cursor:pointer;text-align:left}',
+ '.wx11-btn:active{background:var(--accent-color);color:#000}',
+ '.wx11-chip{display:inline-block;min-height:40px;padding:8px 12px;margin:0 6px 6px 0;border-radius:999px;',
+ 'border:1px solid var(--border-color);background:var(--bg-tertiary);font-size:.85em;cursor:pointer}',
+ '.wx11-dim{color:var(--text-muted);font-size:.85em}',
+ '.wx11-pre{margin:0;white-space:pre-wrap;word-break:break-word;font-size:.8em;line-height:1.35;',
+ 'max-height:100%;overflow:auto;color:var(--text-color)}',
+ '@media (max-width:600px){.wx11-btn{min-height:48px}}'
+ ].join('\n');
+ document.head.appendChild(st);
+ }
+
+ injectCSS(id, css) {
+ this.removeCSS(id);
+ const st = document.createElement('style');
+ st.id = 'wx11-ui-css-' + id;
+ st.textContent = css;
+ document.head.appendChild(st);
+ this._cssIds.push(id);
+ }
+
+ removeCSS(id) {
+ const el = document.getElementById('wx11-ui-css-' + id);
+ if (el) el.remove();
+ this._cssIds = this._cssIds.filter(x => x !== id);
+ }
+
+ _cssSweep() { this._cssIds.slice().forEach(id => this.removeCSS(id)); }
+
+ /* ---------- capability API handed to modes ---------- */
+ api() {
+ const self = this;
+ const term = this.terminal;
+ this._snapshotStock();
+ return {
+ root: this._root,
+ terminal: term,
+ stock: {
+ output: document.getElementById('terminal-container'),
+ input: document.getElementById('input-area'),
+ quickActions: document.getElementById('quick-actions'),
+ statusBar: document.getElementById('status-bar')
+ },
+ // user text → executeCommand (security gate applies)
+ exec: (line) => term.executeCommand(String(line)),
+ // shell-authored fixed strings only → capturePipeOutput (no security gate)
+ capture: (line) => {
+ const parts = term._parseArgs(String(line).trim());
+ const cmd = parts[0];
+ if (!cmd) return Promise.resolve('');
+ return term.capturePipeOutput(cmd, parts.slice(1), String(line));
+ },
+ fs: term.fs,
+ el: (tag, props, kids) => {
+ const node = document.createElement(tag);
+ if (props) for (const k of Object.keys(props)) {
+ if (k === 'text') node.textContent = props[k];
+ else if (k === 'html') node.innerHTML = props[k];
+ else if (k === 'class') node.className = props[k];
+ else node.setAttribute(k, props[k]);
+ }
+ (kids || []).forEach(k => node.appendChild(k));
+ return node;
+ },
+ attach: (slot, el) => { if (slot && el) slot.appendChild(el); return el; },
+ injectCSS: (id, css) => self.injectCSS(id, css),
+ removeCSS: (id) => self.removeCSS(id),
+ interval: (fn, ms) => {
+ const mm = term.moduleManager;
+ return mm && mm._setInterval ? mm._setInterval(fn, ms) : setInterval(fn, ms);
+ },
+ clearInterval: (id) => {
+ const mm = term.moduleManager;
+ if (mm && mm._clearTimer) mm._clearTimer(id); else clearInterval(id);
+ },
+ toast: (msg, ms) => self.toast(msg, ms)
+ };
+ }
+
+ toast(msg, ms) {
+ let host = document.getElementById('wx11-ui-toast');
+ if (!host) {
+ host = document.createElement('div');
+ host.id = 'wx11-ui-toast';
+ host.style.cssText = 'position:fixed;left:50%;transform:translateX(-50%);bottom:44px;' +
+ 'background:var(--bg-secondary);border:1px solid var(--accent-color);color:var(--text-color);' +
+ 'padding:10px 14px;border-radius:8px;z-index:9999;font-size:.9em;max-width:90vw;';
+ document.body.appendChild(host);
+ }
+ host.textContent = String(msg);
+ host.style.display = 'block';
+ clearTimeout(this._toastT);
+ this._toastT = setTimeout(() => { host.style.display = 'none'; }, ms || 2600);
+ }
+
+ /* ---------- picker ---------- */
+ showPicker() {
+ if (this._picker) return this._picker;
+ const self = this;
+ const wrap = document.createElement('div');
+ wrap.id = 'wx11-ui-picker';
+ wrap.style.cssText = 'position:fixed;inset:0;z-index:9998;background:rgba(0,0,0,.86);' +
+ 'display:flex;flex-direction:column;justify-content:center;padding:20px;overflow:auto;gap:10px;';
+ wrap.innerHTML = '<div style="max-width:720px;margin:0 auto;width:100%">' +
+ '<h2 style="margin:0 0 4px;font-size:1.15em">How do you want newOS4 to look?</h2>' +
+ '<p class="wx11-dim" style="margin:0 0 14px">Pick one — you can switch any time with <code>ui</code>.</p>' +
+ '<div id="wx11-ui-picker-list" style="display:grid;gap:10px"></div>' +
+ '<button class="wx11-btn" id="wx11-ui-picker-skip" style="margin-top:12px;width:100%;text-align:center">' +
+ 'Keep the classic terminal</button></div>';
+ document.body.appendChild(wrap);
+ const list = wrap.querySelector('#wx11-ui-picker-list');
+ // Fewer than 4 registered means the ext modules are still in flight — use the static list so
+ // the user never sees a one-card chooser (the card handler re-checks and warns if missing).
+ const modes = this.list().length >= 4 ? this.list() : [
+ { id: 'easy', label: 'Easy', level: 1, icon: '🌱', tagline: 'Just ask. No commands.' },
+ { id: 'chat', label: 'Chat', level: 2, icon: '💬', tagline: 'Conversation first.' },
+ { id: 'studio', label: 'Studio', level: 3, icon: '🎨', tagline: 'Chat plus a place for your files.' },
+ { id: 'workspace', label: 'Workspace', level: 4, icon: '🗂', tagline: 'Everything at a glance.' },
+ { id: 'commander', label: 'Commander', level: 5, icon: '⌨️', tagline: 'Terminal with rails.' },
+ { id: 'cockpit', label: 'Cockpit', level: 6, icon: '🛰', tagline: 'Live system telemetry.' }
+ ];
+ modes.forEach(m => {
+ const b = document.createElement('button');
+ b.className = 'wx11-btn';
+ b.style.cssText = 'display:flex;gap:12px;align-items:center;width:100%';
+ const ico = document.createElement('span');
+ ico.style.fontSize = '1.5em';
+ ico.textContent = m.icon || '▫'; // never innerHTML: labels can change
+ const txt = document.createElement('span');
+ const strong = document.createElement('b');
+ strong.textContent = String(m.label);
+ const tag = document.createElement('span');
+ tag.className = 'wx11-dim';
+ tag.textContent = String(m.tagline || '');
+ txt.appendChild(strong); txt.appendChild(document.createElement('br')); txt.appendChild(tag);
+ b.appendChild(ico); b.appendChild(txt);
+ b.addEventListener('click', () => { self._closePicker(); self.apply(m.id); });
+ list.appendChild(b);
+ });
+ wrap.querySelector('#wx11-ui-picker-skip').addEventListener('click', () => {
+ self._closePicker(); self.apply('classic');
+ });
+ const onEsc = (ev) => { if (ev.key === 'Escape') { self._closePicker(); } };
+ document.addEventListener('keydown', onEsc);
+ wrap._onEsc = onEsc;
+ this._picker = wrap;
+ return wrap;
+ }
+
+ _closePicker() {
+ if (this._pickerT) { clearTimeout(this._pickerT); this._pickerT = null; }
+ if (this._picker) {
+ if (this._picker._onEsc) document.removeEventListener('keydown', this._picker._onEsc);
+ this._picker.remove();
+ this._picker = null;
+ }
+ localStorage.setItem(this.seenKey, '1');
+ }
+
+ /* ---------- command ---------- */
+ handle(args) {
+ const sub = String(args[0] || '').toLowerCase();
+ const available = ['classic'].concat(this.list().map(m => m.id));
+ if (!sub || sub === 'list') {
+ this.terminal.print('Interface modes:', 'info');
+ this.terminal.print(' classic (level 0) — the terminal you already know', 'info');
+ this.list().forEach(m => this.terminal.print(
+ ' ' + String(m.id).padEnd(10) + '(level ' + m.level + ') — ' + (m.tagline || m.label), 'info'));
+ this.terminal.print('Usage: ui <mode> | ui next | ui picker | ui classic', 'info');
+ return;
+ }
+ if (sub === 'picker') return void this.showPicker();
+ if (sub === 'next') return void this.next();
+ // A mode that is not registered yet is not an error: it is lazy-loaded on demand. Only an
+ // id that is not one of the six is unknown.
+ const LAZY = ['easy', 'chat', 'studio', 'workspace', 'commander', 'cockpit'];
+ if (available.indexOf(sub) === -1 && LAZY.indexOf(sub) === -1) {
+ this.terminal.print(`Unknown mode '${sub}'. Available: ${available.join(', ')}`, 'warning');
+ return;
+ }
+ if (this.apply(sub)) this.terminal.print('Interface: ' + sub, 'success');
+ }
+}
diff --git a/modules/core/terminal.js b/modules/core/terminal.js
index ca6bb61..6bc23b4 100644
--- a/modules/core/terminal.js
+++ b/modules/core/terminal.js
@@ -1382,6 +1382,7 @@
this._safeRegister('SmartContextModule', 'smart context');
this._safeRegister('SimpleGUIModule', 'simple GUI builder');
this._safeRegister('DebugSupervisorModule', 'debug supervisor');
+ this._safeRegister('UIShellModule', 'UI shell');
// v4.5.47: Critical ext modules promoted to boot path
this._safeRegister('EditorModule', 'editor');
this._safeRegister('ShellModule', 'shell');
@@ -1430,6 +1431,7 @@
BusyBoxModule: typeof BusyBoxModule !== 'undefined' ? BusyBoxModule : undefined,
SmartContextModule: typeof SmartContextModule !== 'undefined' ? SmartContextModule : undefined,
DebugSupervisorModule: typeof DebugSupervisorModule !== 'undefined' ? DebugSupervisorModule : undefined,
+ UIShellModule: typeof UIShellModule !== 'undefined' ? UIShellModule : undefined,
BillingModule: typeof BillingModule !== 'undefined' ? BillingModule : undefined,
};
const cls = registry[className];
@@ -1519,7 +1521,24 @@
const pkg = index.packages[key];
if (pkg) {
const url = pkg.url + (pkg.url.includes('?') ? '&v=' : '?v=') + cacheVer;
- await this.moduleManager.loadModuleFromURL(url);
+ // One bad module must not abort the rest: this loop is serial and used to
+ // return on the first 404/503/parse error, silently skipping every key
+ // after it (measured 2026-11-07: a 503 on an earlier key meant the six
+ // ui-* modes at the end never loaded).
+ try {
+ await this.moduleManager.loadModuleFromURL(url);
+ } catch (e) {
+ // A 503 here is nginx `limit_req zone=webx11` (60r/m, burst 50): the
+ // bucket is per-IP, so a boot with many modules drains it and every
+ // later module is refused. Back off and retry instead of losing the
+ // module — measured 2026-11-07, six ui-* files 503'd every load.
+ try {
+ await new Promise(r => setTimeout(r, 700));
+ await this.moduleManager.loadModuleFromURL(url);
+ } catch (e2) {
+ console.warn(`[Ext] ${key} failed to load: ${e2.message}`);
+ }
+ }
}
}
}