// 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||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
// 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-`: 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: every mode is a vertical stack (chrome / content / composer / // footer) and a block container makes each child's `flex:1 1 auto` inert — the terminal // collapsed to 0px and the composer sat at the very bottom of a 4 600px flow. 'body[data-ui-mode]:not([data-ui-mode="classic"]) #ui-root{display:flex;flex-direction:column;height:100%}', '#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 ; 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(''); 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 = '
' + '

How do you want newOS4 to look?

' + '

Pick one — you can switch any time with ui.

' + '
' + '
'; 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'); // 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 | 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'); } }