aboutsummaryrefslogtreecommitdiff
path: root/modules/core/module-uishell.js
diff options
context:
space:
mode:
Diffstat (limited to 'modules/core/module-uishell.js')
-rw-r--r--modules/core/module-uishell.js607
1 files changed, 607 insertions, 0 deletions
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');
+ }
+}