diff options
Diffstat (limited to 'modules/core/module-uishell.js')
| -rw-r--r-- | modules/core/module-uishell.js | 508 |
1 files changed, 508 insertions, 0 deletions
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'); + } +} |