diff options
Diffstat (limited to 'modules')
| -rw-r--r-- | modules/core/core-bundle.js | 531 | ||||
| -rw-r--r-- | modules/core/module-loader.js | 5 | ||||
| -rw-r--r-- | modules/core/module-uishell.js | 508 | ||||
| -rw-r--r-- | modules/core/terminal.js | 21 | ||||
| -rw-r--r-- | modules/ext/ui-chat.js | 99 | ||||
| -rw-r--r-- | modules/ext/ui-cockpit.js | 122 | ||||
| -rw-r--r-- | modules/ext/ui-commander.js | 188 | ||||
| -rw-r--r-- | modules/ext/ui-easy.js | 83 | ||||
| -rw-r--r-- | modules/ext/ui-studio.js | 146 | ||||
| -rw-r--r-- | modules/ext/ui-workspace.js | 133 | ||||
| -rw-r--r-- | modules/test/test-ui-shell.js | 214 |
11 files changed, 2047 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}`); + } + } } } } diff --git a/modules/ext/ui-chat.js b/modules/ext/ui-chat.js new file mode 100644 index 0000000..32c67e7 --- /dev/null +++ b/modules/ext/ui-chat.js @@ -0,0 +1,99 @@ +// UI mode: chat (level 2). Single column, sticky composer, slash chips, raw-terminal drawer. +class UIChatMode { + constructor() { this.commands = {}; this.terminal = null; } + requirements() { return { met: true, missing: [] }; } + getHelpInfo() { return {}; } + registerCommands() {} + init(terminal) { + this.terminal = terminal; + if (!terminal.uiShell) return; + terminal.uiShell.registerMode({ + id: 'chat', label: 'Chat', level: 2, icon: '💬', + tagline: 'Conversation first.', + mount: (api) => this.mount(api) + }); + } + mount(api) { + api.injectCSS('chat', ` +body[data-ui-mode="chat"] #ui-root { display: flex; flex-direction: column; } +#chat-head { flex: 0 0 auto; display: flex; align-items: center; gap: 8px; padding: 10px 14px; + border-bottom: 1px solid var(--border-color); background: var(--bg-secondary); } +#chat-head .title { font-weight: 600; flex: 1 1 auto; } +#chat-head button { min-height: 44px; } +#chat-stream { flex: 1 1 auto; min-height: 0; overflow-y: auto; padding: 12px 14px; + display: flex; flex-direction: column; gap: 6px; max-width: 860px; width: 100%; margin: 0 auto; } +#chat-stream .output-line { font-family: inherit; font-size: .95em; line-height: 1.5; + white-space: pre-wrap; word-break: break-word; padding: 10px 12px; border-radius: 14px; + background: var(--bg-secondary); border: 1px solid var(--border-color); max-width: 88%; } +#chat-stream .output-line.info { background: var(--ai-bg-color); border-color: var(--border-color); color: var(--ai-text-color); } +#chat-stream .output-line.error { border-color: var(--error-color); } +#chat-chips { flex: 0 0 auto; padding: 8px 14px 0; overflow-x: auto; white-space: nowrap; + max-width: 860px; margin: 0 auto; width: 100%; } +#chat-composer { flex: 0 0 auto; padding: 8px 14px 14px; max-width: 860px; margin: 0 auto; width: 100%; } +#chat-composer #input-area { display: flex; gap: 8px; align-items: flex-end; background: var(--bg-secondary); + border: 1px solid var(--border-color); border-radius: 16px; padding: 8px; } +#chat-composer .prompt-label { display: none; } +#chat-composer #command-input { width: 100%; font-family: inherit; font-size: 1em; min-height: 44px; background: transparent; border: 0; } +#chat-composer #autocomplete-dropdown { display: none; } +body[data-ui-mode="chat"] #raw-drawer { position: absolute; inset: 0 0 34px 0; z-index: 60; + background: var(--bg-color); display: none; flex-direction: column; padding: 10px; } +body[data-ui-mode="chat"] #raw-drawer.open { display: flex; } +body[data-ui-mode="chat"] #raw-drawer #terminal-container { flex: 1 1 auto; min-height: 0; overflow: auto; + font-family: var(--font-primary); font-size: .9em; } +`); + + const root = api.root; + root.innerHTML = ''; + const head = api.el('div', { id: 'chat-head' }); + head.appendChild(api.el('span', { class: 'title', text: 'newOS4' })); + const rawBtn = api.el('button', { class: 'wx11-btn', text: '⌨ Plain terminal' }); + const uiBtn = api.el('button', { class: 'wx11-btn', text: '⇄ Style' }); + uiBtn.addEventListener('click', () => api.exec('ui picker')); + head.appendChild(uiBtn); + head.appendChild(rawBtn); + + const stream = api.el('div', { id: 'chat-stream' }); + const chips = api.el('div', { id: 'chat-chips' }); + const composer = api.el('div', { id: 'chat-composer' }); + const drawer = api.el('div', { id: 'raw-drawer' }); + + const chipMap = { '/help': 'help', '/files': 'ls', '/modules': 'modules', '/sys': 'cat /sys/info' }; + ['/help', '/files', '/modules', '/sys', '/style'].forEach(label => { + const chip = api.el('span', { class: 'wx11-chip', text: label }); + chip.addEventListener('click', () => { + if (label === '/style') return void api.exec('ui picker'); + api.exec(chipMap[label]); + }); + chips.appendChild(chip); + }); + + root.appendChild(head); + root.appendChild(stream); + root.appendChild(chips); + root.appendChild(composer); + root.appendChild(drawer); + + api.attach(stream, api.stock.output); // visible stream first + api.attach(composer, api.stock.input); + // Do NOT attach the output to the drawer here: it is one node, the last attach wins, and + // the drawer starts hidden — the stream would render empty. The toggle moves it. + + const field = document.getElementById('command-input'); + if (field) field.placeholder = 'Message newOS4…'; + + let open = false; + rawBtn.addEventListener('click', () => { + open = !open; + if (open) { api.attach(drawer, api.stock.output); drawer.classList.add('open'); } + else { api.attach(stream, api.stock.output); drawer.classList.remove('open'); } + rawBtn.textContent = open ? '💬 Chat view' : '⌨ Plain terminal'; + }); + + return () => { + open = false; + drawer.classList.remove('open'); + const f = document.getElementById('command-input'); + if (f) f.placeholder = 'Type a command...'; // leave the next mode a clean prompt + }; + } +} diff --git a/modules/ext/ui-cockpit.js b/modules/ext/ui-cockpit.js new file mode 100644 index 0000000..ffb6c2a --- /dev/null +++ b/modules/ext/ui-cockpit.js @@ -0,0 +1,122 @@ +// UI mode: cockpit (level 6). Eight live panes straight from /sys/ + real commands. +class UICockpitMode { + constructor() { this.commands = {}; this.terminal = null; } + requirements() { return { met: true, missing: [] }; } + getHelpInfo() { return {}; } + registerCommands() {} + init(terminal) { + this.terminal = terminal; + if (!terminal.uiShell) return; + terminal.uiShell.registerMode({ + id: 'cockpit', label: 'Cockpit', level: 6, icon: '🛰', + tagline: 'Live system telemetry.', + mount: (api) => this.mount(api) + }); + } + mount(api) { + api.injectCSS('cockpit', ` +body[data-ui-mode="cockpit"] { font-size: 13px; } +body[data-ui-mode="cockpit"] #ui-root { display: grid; grid-template-rows: 1fr 190px; min-height: 0; } +#ck-panes { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 1px; + background: var(--border-color); min-height: 0; overflow-y: auto; } +#ck-panes .pane { background: var(--bg-color); display: flex; flex-direction: column; + min-height: 130px; min-width: 0; transition: outline-color .2s; } +#ck-panes .pane h5 { margin: 0; padding: 5px 8px; font-size: .72em; letter-spacing: .1em; + text-transform: uppercase; color: var(--text-muted); border-bottom: 1px solid var(--border-color); + display: flex; gap: 8px; align-items: center; } +#ck-panes .pane h5 .n { margin-left: auto; color: var(--accent-color); } +#ck-panes pre { flex: 1 1 auto; margin: 0; padding: 6px 8px; overflow: auto; white-space: pre-wrap; + word-break: break-word; font-family: var(--font-primary); font-size: .72em; line-height: 1.35; } +#ck-term { display: flex; flex-direction: column; min-height: 0; border-top: 1px solid var(--border-color); } +#ck-term-bar { flex: 0 0 auto; display: flex; gap: 6px; align-items: center; padding: 4px 8px; + background: var(--bg-secondary); font-size: .78em; } +#ck-term-bar .title { flex: 1 1 auto; color: var(--text-muted); } +#ck-out { flex: 1 1 auto; min-height: 0; overflow: auto; padding: 4px 8px; font-size: .82em; } +#ck-in { flex: 0 0 auto; padding: 4px 8px 8px; } +#ck-in #input-area { display: flex; gap: 6px; align-items: flex-end; } +#ck-in .prompt-label, #ck-in #autocomplete-dropdown { display: none; } +#ck-in #command-input { width: 100%; font-family: var(--font-primary); font-size: .85em; } +@media (max-width: 900px) { + body[data-ui-mode="cockpit"] #ui-root { grid-template-rows: 1fr 44vh; } + #ck-panes { grid-template-columns: 1fr 1fr; } +} +`); + + const root = api.root; + root.innerHTML = ''; + const panes = api.el('div', { id: 'ck-panes' }); + const termWrap = api.el('div', { id: 'ck-term' }); + root.appendChild(panes); root.appendChild(termWrap); + + const defs = [ + { id: 'heap', label: 'heap', cmd: 'cat /sys/heap/stats' }, + { id: 'breakdown', label: 'breakdown', cmd: 'cat /sys/heap/breakdown' }, + { id: 'vfs', label: 'vfs', cmd: 'cat /sys/vfs/stats' }, + { id: 'tools', label: 'tool calls', cmd: 'cat /sys/logs/tool-calls' }, + { id: 'errors', label: 'errors', cmd: 'cat /sys/logs/errors' }, + { id: 'dom', label: 'dom', cmd: 'cat /sys/dom/node-count' }, + { id: 'agents', label: 'agents', cmd: 'agent-list' }, + { id: 'leases', label: 'leases', cmd: 'lease-list' } + ]; + const slots = {}; + defs.forEach((d, i) => { + const pane = api.el('div', { class: 'pane' }); + pane.dataset.pane = String(i + 1); + const h = api.el('h5'); + h.appendChild(api.el('span', { text: 'F' + (i + 1) + ' ' + d.label })); + h.appendChild(api.el('span', { class: 'n' })); + const pre = api.el('pre', { text: '…' }); + pane.appendChild(h); pane.appendChild(pre); + slots[d.id] = { pre, n: h.querySelector('.n') }; + panes.appendChild(pane); + }); + + const bar = api.el('div', { id: 'ck-term-bar' }); + bar.appendChild(api.el('span', { class: 'title', text: 'terminal · F1-F8 focus · F5 refresh' })); + const styleBtn = api.el('button', { class: 'wx11-btn', text: '⇄' }); + styleBtn.addEventListener('click', () => api.exec('ui picker')); + bar.appendChild(styleBtn); + const out = api.el('div', { id: 'ck-out' }); + const inp = api.el('div', { id: 'ck-in' }); + termWrap.appendChild(bar); termWrap.appendChild(out); termWrap.appendChild(inp); + api.attach(out, api.stock.output); + api.attach(inp, api.stock.input); + + // Generation guard: eight async captures per tick at 5s — a tick can outlive teardown. + let gen = 0; + const refresh = async () => { + const my = ++gen; + for (const d of defs) { + try { + const text = await api.capture(d.cmd); + if (my !== gen) return; + const lines = (text || '').trim() ? String(text).trim().split('\n') : ['(empty)']; + slots[d.id].pre.textContent = lines.join('\n'); + slots[d.id].n.textContent = lines.length + 'L'; + } catch (e) { + if (my !== gen) return; + slots[d.id].pre.textContent = 'unavailable: ' + e.message; + slots[d.id].n.textContent = 'ERR'; + } + } + }; + refresh(); + const timer = api.interval(refresh, 5000); + + const onKey = (ev) => { + const m = /^F([1-8])$/.exec(String(ev.key)); + if (!m) return; + ev.preventDefault(); + const idx = parseInt(m[1], 10); + if (idx === 5) { refresh(); return; } // F5 = refresh, as labelled + const pane = panes.querySelector('[data-pane="' + idx + '"]'); + if (!pane) return; + pane.scrollIntoView({ block: 'nearest' }); + pane.style.outline = '1px solid var(--accent-color)'; + setTimeout(() => { pane.style.outline = ''; }, 900); + }; + document.addEventListener('keydown', onKey); + + return () => { gen++; document.removeEventListener('keydown', onKey); api.clearInterval(timer); }; + } +} diff --git a/modules/ext/ui-commander.js b/modules/ext/ui-commander.js new file mode 100644 index 0000000..7f980ea --- /dev/null +++ b/modules/ext/ui-commander.js @@ -0,0 +1,188 @@ +// UI mode: commander (level 5). Left rail + dominant terminal + telemetry rail + Ctrl-K palette. +class UICommanderMode { + constructor() { this.commands = {}; this.terminal = null; } + requirements() { return { met: true, missing: [] }; } + getHelpInfo() { return {}; } + registerCommands() {} + init(terminal) { + this.terminal = terminal; + if (!terminal.uiShell) return; + terminal.uiShell.registerMode({ + id: 'commander', label: 'Commander', level: 5, icon: '⌨️', + tagline: 'Terminal with rails.', + mount: (api) => this.mount(api) + }); + } + /* every command the running system knows, from the modules that own them */ + _commands(api) { + const out = []; + const mods = (api.terminal.moduleManager && api.terminal.moduleManager.modules) || {}; + for (const name of Object.keys(mods)) { + const m = mods[name]; + if (!m || typeof m.getHelpInfo !== 'function') continue; + let info = {}; + try { info = m.getHelpInfo() || {}; } catch (e) { continue; } + for (const cmd of Object.keys(info)) { + out.push({ cmd, desc: typeof info[cmd] === 'string' ? info[cmd] : '', mod: name }); + } + } + return out.sort((a, b) => a.cmd.localeCompare(b.cmd)); + } + mount(api) { + api.injectCSS('commander', ` +body[data-ui-mode="commander"] #ui-root { display: grid; grid-template-columns: 232px 1fr 260px; min-height: 0; } +#cm-left, #cm-right { display: flex; flex-direction: column; min-height: 0; overflow-y: auto; + background: var(--bg-secondary); font-size: .85em; } +#cm-left { border-right: 1px solid var(--border-color); } +#cm-right { border-left: 1px solid var(--border-color); } +#cm-left h4, #cm-right h4 { margin: 0; padding: 9px 10px; font-size: .72em; letter-spacing: .08em; + text-transform: uppercase; color: var(--text-muted); border-bottom: 1px solid var(--border-color); } +#cm-left .item { padding: 9px 10px; cursor: pointer; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } +#cm-left .item:hover { background: var(--bg-tertiary); } +#cm-center { display: flex; flex-direction: column; min-width: 0; min-height: 0; } +#cm-center-bar { flex: 0 0 auto; display: flex; align-items: center; gap: 8px; padding: 6px 10px; + border-bottom: 1px solid var(--border-color); } +#cm-center-bar .title { flex: 1 1 auto; font-size: .85em; color: var(--text-muted); } +#cm-term { flex: 1 1 auto; min-height: 0; overflow: auto; padding: 8px 10px; } +#cm-composer { flex: 0 0 auto; border-top: 1px solid var(--border-color); padding: 6px 8px; } +#cm-composer #input-area { display: flex; gap: 6px; align-items: flex-end; } +#cm-composer .prompt-label, #cm-composer #autocomplete-dropdown { display: none; } +#cm-composer #command-input { width: 100%; font-family: var(--font-primary); font-size: .9em; } +#cm-right pre { margin: 0; padding: 8px 10px; white-space: pre-wrap; word-break: break-word; font-size: .74em; line-height: 1.4; } +#cm-palette { position: fixed; inset: 0; z-index: 80; background: rgba(0,0,0,.6); display: none; + padding: 12vh 16px 16px; } +#cm-palette.open { display: block; } +#cm-palette .box { max-width: 640px; margin: 0 auto; background: var(--bg-secondary); + border: 1px solid var(--accent-color); border-radius: 10px; overflow: hidden; } +#cm-palette input { width: 100%; padding: 14px; font: inherit; font-size: 1em; background: var(--bg-tertiary); + color: var(--text-color); border: 0; outline: 0; } +#cm-palette ul { list-style: none; margin: 0; padding: 0; max-height: 46vh; overflow-y: auto; } +#cm-palette li { padding: 11px 14px; cursor: pointer; border-top: 1px solid var(--border-color); font-size: .9em; } +#cm-palette li.sel { background: var(--bg-tertiary); border-left: 3px solid var(--accent-color); } +#cm-palette li .d { color: var(--text-muted); font-size: .82em; display: block; margin-top: 2px; } +@media (max-width: 900px) { + body[data-ui-mode="commander"] #ui-root { grid-template-columns: 1fr; } + #cm-left, #cm-right { display: none; } +} +`); + + const root = api.root; + root.innerHTML = ''; + const left = api.el('div', { id: 'cm-left' }); + const center = api.el('div', { id: 'cm-center' }); + const right = api.el('div', { id: 'cm-right' }); + root.appendChild(left); root.appendChild(center); root.appendChild(right); + + // left rail: the real VFS root listing + left.appendChild(api.el('h4', { text: 'Files' })); + try { + // readDir('/') returns [{name,type,...}] and always includes the virtual `sys` dir + const entries = api.fs.readDir('/') || []; + entries.slice(0, 40).forEach(entry => { + const name = String(entry.name); + const isDir = entry.type === 'dir' || entry._virtual; + const it = api.el('div', { class: 'item', text: (isDir ? '📁 ' : '📄 ') + name }); + it.addEventListener('click', () => api.exec((isDir ? 'ls ' : 'cat ') + name)); + left.appendChild(it); + }); + } catch (e) { left.appendChild(api.el('div', { class: 'item', text: '(' + e.message + ')' })); } + + const bar = api.el('div', { id: 'cm-center-bar' }); + bar.appendChild(api.el('span', { class: 'title', text: 'terminal' })); + const palBtn = api.el('button', { class: 'wx11-btn', text: '⌘K' }); + const styleBtn = api.el('button', { class: 'wx11-btn', text: '⇄' }); + styleBtn.addEventListener('click', () => api.exec('ui picker')); + bar.appendChild(palBtn); bar.appendChild(styleBtn); + + const term = api.el('div', { id: 'cm-term' }); + const composer = api.el('div', { id: 'cm-composer' }); + center.appendChild(bar); center.appendChild(term); center.appendChild(composer); + api.attach(term, api.stock.output); + api.attach(composer, api.stock.input); + + // right rail: live /sys telemetry + right.appendChild(api.el('h4', { text: 'sys' })); + const pre = api.el('pre', { text: '…' }); + right.appendChild(pre); + let gen = 0; + const refresh = async () => { + const my = ++gen; + try { + const text = await api.capture('cat /sys/heap/stats'); + if (my !== gen) return; + pre.textContent = text || '(empty)'; + } catch (e) { + if (my !== gen) return; + pre.textContent = 'unavailable: ' + e.message; + } + }; + refresh(); + const timer = api.interval(refresh, 20000); + + // palette + const all = this._commands(api); + let filtered = all.slice(); + let sel = 0; + const pal = api.el('div', { id: 'cm-palette' }); + pal.innerHTML = '<div class="box"><input id="cm-pal-input" placeholder="Run a command…" ' + + 'autocomplete="off" spellcheck="false"><ul id="cm-pal-list"></ul></div>'; + document.body.appendChild(pal); + const input = pal.querySelector('#cm-pal-input'); + const listEl = pal.querySelector('#cm-pal-list'); + + const draw = () => { + listEl.innerHTML = ''; + filtered.slice(0, 40).forEach((c, i) => { + const li = api.el('li', { class: i === sel ? 'sel' : '' }); + // textContent, not innerHTML: help strings are code-authored but not markup + li.appendChild(api.el('b', { text: String(c.cmd) })); + li.appendChild(api.el('span', { class: 'd', text: String(c.desc || c.mod).slice(0, 90) })); + li.addEventListener('click', () => pick(c)); + listEl.appendChild(li); + }); + }; + const filter = () => { + const q = input.value.trim().toLowerCase(); + filtered = q ? all.filter(c => c.cmd.toLowerCase().indexOf(q) !== -1 || + String(c.desc).toLowerCase().indexOf(q) !== -1) : all.slice(); + sel = 0; draw(); + }; + const pick = (entry) => { + closePalette(); + const f = document.getElementById('command-input'); + // A usage like "ai <prompt>" or "cron-every 60 cmd" must be EDITABLE, not executed + // half-written — running it would fire a command with a missing argument. + const needsArg = /[<\[]/.test(String(entry.desc || '')); + if (needsArg) { + if (f) { f.value = entry.cmd + ' '; f.focus(); } + api.toast('Edit the command, then send'); + return; + } + api.exec(entry.cmd); + }; + const openPalette = () => { input.value = ''; filter(); pal.classList.add('open'); input.focus(); }; + const closePalette = () => pal.classList.remove('open'); + + palBtn.addEventListener('click', () => openPalette()); + input.addEventListener('input', filter); + input.addEventListener('keydown', (ev) => { + if (ev.key === 'Escape') return closePalette(); + if (ev.key === 'ArrowDown') { sel = Math.min(sel + 1, filtered.length - 1); draw(); ev.preventDefault(); return; } + if (ev.key === 'ArrowUp') { sel = Math.max(sel - 1, 0); draw(); ev.preventDefault(); return; } + if (ev.key === 'Enter' && filtered[sel]) { pick(filtered[sel]); ev.preventDefault(); } + }); + const onKey = (ev) => { + if ((ev.ctrlKey || ev.metaKey) && String(ev.key).toLowerCase() === 'k') { + ev.preventDefault(); openPalette(); + } + }; + document.addEventListener('keydown', onKey); + + return () => { + gen++; + document.removeEventListener('keydown', onKey); + pal.remove(); + api.clearInterval(timer); + }; + } +} diff --git a/modules/ext/ui-easy.js b/modules/ext/ui-easy.js new file mode 100644 index 0000000..eddf11d --- /dev/null +++ b/modules/ext/ui-easy.js @@ -0,0 +1,83 @@ +// UI mode: easy (level 1). Nothing here parses commands; every button calls executeCommand. +class UIEasyMode { + constructor() { this.commands = {}; this.terminal = null; } + requirements() { return { met: true, missing: [] }; } + getHelpInfo() { return {}; } + registerCommands() {} + init(terminal) { + this.terminal = terminal; + if (!terminal.uiShell) return; + terminal.uiShell.registerMode({ + id: 'easy', label: 'Easy', level: 1, icon: '🌱', + tagline: 'Just ask. No commands.', + mount: (api) => this.mount(api) + }); + } + mount(api) { + api.injectCSS('easy', ` +body[data-ui-mode="easy"] { font-family: system-ui, -apple-system, "Segoe UI", sans-serif; } +body[data-ui-mode="easy"] #ui-root { display: flex; flex-direction: column; padding: 14px 14px 0; gap: 12px; } +#easy-top { flex: 0 0 auto; } +#easy-top h1 { font-size: 1.25em; margin: 0 0 4px; font-weight: 600; } +#easy-actions { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; margin-top: 12px; } +#easy-actions .wx11-btn { display: flex; align-items: center; gap: 10px; font-size: 1em; min-height: 62px; } +#easy-actions .ico { font-size: 1.5em; line-height: 1; } +#easy-chat { flex: 1 1 auto; min-height: 0; overflow-y: auto; display: flex; flex-direction: column; gap: 8px; } +#easy-chat .output-line { background: var(--bg-secondary); border: 1px solid var(--border-color); + border-radius: 12px; padding: 10px 12px; font-family: inherit; font-size: .95em; line-height: 1.45; + white-space: pre-wrap; word-break: break-word; } +#easy-chat .output-line.error { border-color: var(--error-color); } +#easy-chat .output-line.success { border-color: var(--accent-color); } +#easy-composer { flex: 0 0 auto; display: flex; gap: 8px; align-items: flex-end; } +#easy-composer #input-area { flex: 1 1 auto; display: flex; gap: 6px; align-items: flex-end; } +#easy-composer .prompt-label { display: none; } +#easy-composer #command-input { font-size: 1em; min-height: 48px; font-family: inherit; width: 100%; } +#easy-composer #autocomplete-dropdown { display: none; } +@media (max-width: 600px) { #easy-actions { grid-template-columns: 1fr; } } +`); + + const root = api.root; + root.innerHTML = ''; + const top = api.el('div', { id: 'easy-top' }); + top.appendChild(api.el('h1', { text: 'Hi — what do you want to do?' })); + top.appendChild(api.el('div', { class: 'wx11-dim', text: 'Tap a card, or just type below in plain language.' })); + + const styleBtn = api.el('button', { class: 'wx11-btn', text: '⇄ Change view' }); + styleBtn.style.cssText = 'margin-top:10px;width:100%;text-align:center'; + styleBtn.addEventListener('click', () => api.exec('ui picker')); + top.appendChild(styleBtn); + + const actions = api.el('div', { id: 'easy-actions' }); + const cards = [ + { ico: '🤖', label: 'Ask the AI', run: () => api.exec('ai "hello — what can you help me with?"') }, + { ico: '📁', label: 'My files', run: () => api.exec('ls') }, + { ico: '🎓', label: 'Learn', run: () => api.exec('course') }, + { ico: '⚙️', label: 'Settings', run: () => api.exec('config') } + ]; + cards.forEach(c => { + const b = api.el('button', { class: 'wx11-btn' }); + b.appendChild(api.el('span', { class: 'ico', text: c.ico })); + b.appendChild(api.el('span', { text: c.label })); + b.addEventListener('click', c.run); + actions.appendChild(b); + }); + top.appendChild(actions); + + const chat = api.el('div', { id: 'easy-chat' }); + const composer = api.el('div', { id: 'easy-composer' }); + root.appendChild(top); + root.appendChild(chat); + root.appendChild(composer); + + // one output stream: the real terminal is restyled into bubbles inside #easy-chat + api.attach(chat, api.stock.output); + api.attach(composer, api.stock.input); + const field = document.getElementById('command-input'); + if (field) field.placeholder = 'Ask anything…'; + + return () => { + const f = document.getElementById('command-input'); + if (f) f.placeholder = 'Type a command...'; + }; + } +} diff --git a/modules/ext/ui-studio.js b/modules/ext/ui-studio.js new file mode 100644 index 0000000..adda6a1 --- /dev/null +++ b/modules/ext/ui-studio.js @@ -0,0 +1,146 @@ +// UI mode: studio (level 3). Chat + artifact pane fed by the real VFS. +class UIStudioMode { + constructor() { this.commands = {}; this.terminal = null; } + requirements() { return { met: true, missing: [] }; } + getHelpInfo() { return {}; } + registerCommands() {} + init(terminal) { + this.terminal = terminal; + if (!terminal.uiShell) return; + terminal.uiShell.registerMode({ + id: 'studio', label: 'Studio', level: 3, icon: '🎨', + tagline: 'Chat plus a place for your files.', + mount: (api) => this.mount(api) + }); + } + mount(api) { + api.injectCSS('studio', ` +body[data-ui-mode="studio"] #ui-root { display: grid; grid-template-columns: 1.15fr .85fr; min-height: 0; } +#st-left { display: flex; flex-direction: column; min-width: 0; min-height: 0; + border-right: 1px solid var(--border-color); } +#st-left-bar { flex: 0 0 auto; padding: 8px 10px; border-bottom: 1px solid var(--border-color); + display: flex; gap: 6px; align-items: center; background: var(--bg-secondary); } +#st-left-bar .title { flex: 1 1 auto; font-weight: 600; font-size: .95em; } +#st-chat { flex: 1 1 auto; min-height: 0; overflow-y: auto; padding: 10px; } +#st-composer { flex: 0 0 auto; padding: 8px; border-top: 1px solid var(--border-color); } +#st-composer #input-area { display: flex; gap: 6px; align-items: flex-end; } +#st-composer .prompt-label, #st-composer #autocomplete-dropdown { display: none; } +#st-composer #command-input { width: 100%; font-family: inherit; font-size: .95em; } +#st-right { display: flex; flex-direction: column; min-width: 0; min-height: 0; } +#st-tabs { flex: 0 0 auto; display: flex; gap: 2px; padding: 6px 8px 0; background: var(--bg-secondary); + border-bottom: 1px solid var(--border-color); overflow-x: auto; } +#st-tabs button { min-height: 40px; padding: 6px 12px; background: transparent; border: 1px solid transparent; + border-bottom: 0; color: var(--text-muted); font: inherit; font-size: .85em; cursor: pointer; border-radius: 8px 8px 0 0; } +#st-tabs button.active { background: var(--bg-tertiary); color: var(--text-color); border-color: var(--border-color); } +#st-body { flex: 1 1 auto; min-height: 0; overflow: auto; padding: 10px; } +#st-body ul { list-style: none; margin: 0; padding: 0; } +#st-body li { padding: 9px 10px; border: 1px solid var(--border-color); border-radius: 8px; + margin-bottom: 6px; cursor: pointer; display: flex; gap: 8px; align-items: center; font-size: .9em; } +#st-body li:active { background: var(--bg-tertiary); } +#st-body .dir { color: var(--info-color); } +#st-body .size { margin-left: auto; color: var(--text-muted); font-size: .8em; } +#st-preview { white-space: pre-wrap; word-break: break-word; font-size: .85em; line-height: 1.5; } +@media (max-width: 820px) { + body[data-ui-mode="studio"] #ui-root { grid-template-columns: 1fr; grid-template-rows: 1fr auto; } + #st-left { border-right: 0; } + #st-right { border-top: 1px solid var(--border-color); max-height: 42vh; } +} +`); + + const root = api.root; + root.innerHTML = ''; + const left = api.el('div', { id: 'st-left' }); + const leftBar = api.el('div', { id: 'st-left-bar' }); + leftBar.appendChild(api.el('span', { class: 'title', text: 'Chat' })); + const styleBtn = api.el('button', { class: 'wx11-btn', text: '⇄' }); + styleBtn.addEventListener('click', () => api.exec('ui picker')); + leftBar.appendChild(styleBtn); + const chat = api.el('div', { id: 'st-chat' }); + const composer = api.el('div', { id: 'st-composer' }); + left.appendChild(leftBar); left.appendChild(chat); left.appendChild(composer); + + const right = api.el('div', { id: 'st-right' }); + const tabs = api.el('div', { id: 'st-tabs' }); + const bodyEl = api.el('div', { id: 'st-body' }); + right.appendChild(tabs); right.appendChild(bodyEl); + + root.appendChild(left); root.appendChild(right); + api.attach(chat, api.stock.output); + api.attach(composer, api.stock.input); + + const state = { tab: 'files', path: '/' }; + + const renderFiles = () => { + bodyEl.innerHTML = ''; + const ul = api.el('ul'); + let entries = []; + try { entries = api.fs.readDir(state.path) || []; } catch (e) { entries = []; } + // MEASURED SHAPE (virtual-fs.js:771-784): [{ name, type, size, _virtual }]. + // type is 'dir' | 'file' | '?'; /sys/ is injected into the ROOT listing only. + if (!Array.isArray(entries)) entries = []; + if (state.path !== '/') { + const up = api.el('li', { text: '⬅ ..' }); + up.addEventListener('click', () => { + state.path = state.path.replace(/\/[^/]+$/, '') || '/'; + renderFiles(); + }); + ul.appendChild(up); + } + entries.slice(0, 200).forEach(entry => { + const name = String(entry.name); + const isDir = entry.type === 'dir' || entry._virtual; + const full = (state.path === '/' ? '' : state.path) + '/' + name; + const li = api.el('li'); + li.appendChild(api.el('span', { class: isDir ? 'dir' : '', + text: (isDir ? '📁 ' : '📄 ') + name })); + if (!isDir && entry.size) li.appendChild(api.el('span', { class: 'size', text: entry.size + 'B' })); + li.addEventListener('click', () => { + if (isDir) { state.path = full; renderFiles(); } + else { setTab('preview', full); } + }); + ul.appendChild(li); + }); + if (!entries.length) bodyEl.appendChild(api.el('div', { class: 'wx11-dim', text: 'Empty folder.' })); + bodyEl.appendChild(ul); + }; + + const setTab = (which, filePath) => { + state.tab = which; + Array.prototype.forEach.call(tabs.children, b => { + b.classList.toggle('active', b.dataset.tab === which); + }); + if (which === 'files') return renderFiles(); + if (which === 'preview') { + bodyEl.innerHTML = ''; + const pre = api.el('pre', { id: 'st-preview' }); + try { + pre.textContent = api.fs.readFile(filePath || '/README.md') || '(empty file)'; + } catch (e) { pre.textContent = 'Cannot read ' + (filePath || '/README.md') + ': ' + e.message; } + bodyEl.appendChild(pre); + return; + } + if (which === 'editor') { + bodyEl.innerHTML = ''; + const target = filePath || '/notes.md'; + const btn = api.el('button', { class: 'wx11-btn', text: '📝 Open the editor for ' + target }); + btn.addEventListener('click', () => { + api.exec('edit ' + target); + api.toast('Editor takes over the chat pane — close it to return'); + }); + bodyEl.appendChild(btn); + bodyEl.appendChild(api.el('div', { class: 'wx11-dim', style: 'margin-top:8px', + text: 'The editor module takes over the terminal pane; close it to come back.' })); + } + }; + + [['files', '📁 Files'], ['preview', '👁 Preview'], ['editor', '📝 Editor']].forEach(([id, label]) => { + const b = api.el('button', { text: label }); + b.dataset.tab = id; + b.addEventListener('click', () => setTab(id)); + tabs.appendChild(b); + }); + setTab('files'); + + return () => { /* buttons die with the root */ }; + } +} diff --git a/modules/ext/ui-workspace.js b/modules/ext/ui-workspace.js new file mode 100644 index 0000000..be7a5e0 --- /dev/null +++ b/modules/ext/ui-workspace.js @@ -0,0 +1,133 @@ +// UI mode: workspace (level 4). Card grid; each card is the captured text of a real command. +// api.capture() is for these fixed strings only — user input must use api.exec(). +class UIWorkspaceMode { + constructor() { this.commands = {}; this.terminal = null; } + requirements() { return { met: true, missing: [] }; } + getHelpInfo() { return {}; } + registerCommands() {} + init(terminal) { + this.terminal = terminal; + if (!terminal.uiShell) return; + terminal.uiShell.registerMode({ + id: 'workspace', label: 'Workspace', level: 4, icon: '🗂', + tagline: 'Everything at a glance.', + mount: (api) => this.mount(api) + }); + } + mount(api) { + api.injectCSS('workspace', ` +body[data-ui-mode="workspace"] #ui-root { display: flex; flex-direction: column; } +#ws-bar { flex: 0 0 auto; display: flex; align-items: center; gap: 8px; padding: 8px 12px; + border-bottom: 1px solid var(--border-color); background: var(--bg-secondary); } +#ws-bar .title { flex: 1 1 auto; font-weight: 600; font-size: .95em; } +#ws-bar .stamp { color: var(--text-muted); font-size: .75em; } +#ws-grid { flex: 1 1 auto; min-height: 0; overflow-y: auto; padding: 12px; + display: grid; grid-template-columns: repeat(auto-fill, minmax(270px, 1fr)); gap: 10px; align-content: start; } +#ws-grid .wx11-card { display: flex; flex-direction: column; min-height: 150px; max-height: 260px; } +#ws-grid h3 { margin: 0 0 8px; font-size: .85em; text-transform: uppercase; letter-spacing: .06em; + color: var(--text-muted); display: flex; gap: 6px; align-items: center; } +#ws-grid h3 .run { margin-left: auto; font-size: 1.1em; cursor: pointer; padding: 4px 8px; } +#ws-out { display: none; flex: 0 0 auto; height: 38vh; border-top: 1px solid var(--border-color); + background: var(--bg-color); } +#ws-out.open { display: flex; flex-direction: column; } +#ws-out #terminal-container { flex: 1 1 auto; min-height: 0; overflow: auto; padding: 6px 10px; font-size: .85em; } +#ws-composer { flex: 0 0 auto; border-top: 1px solid var(--border-color); padding: 8px 12px; } +#ws-composer #input-area { display: flex; gap: 6px; align-items: flex-end; } +#ws-composer .prompt-label, #ws-composer #autocomplete-dropdown { display: none; } +#ws-composer #command-input { width: 100%; font-family: inherit; } +@media (max-width: 600px) { #ws-grid { grid-template-columns: 1fr; } #ws-grid .wx11-card { max-height: none; } } +`); + + const root = api.root; + root.innerHTML = ''; + const bar = api.el('div', { id: 'ws-bar' }); + bar.appendChild(api.el('span', { class: 'title', text: 'Workspace' })); + bar.appendChild(api.el('span', { class: 'stamp', id: 'ws-stamp' })); + + const styleBtn = api.el('button', { class: 'wx11-btn', text: '⇄ Style' }); + styleBtn.addEventListener('click', () => api.exec('ui picker')); + const refreshBtn = api.el('button', { class: 'wx11-btn', text: '⟳ Refresh' }); + const outBtn = api.el('button', { class: 'wx11-btn', text: '⌨ Output' }); + let drawerOpen = false; + outBtn.addEventListener('click', () => { + drawerOpen = !drawerOpen; + const d = document.getElementById('ws-out'); + if (d) d.classList.toggle('open', drawerOpen); + outBtn.textContent = drawerOpen ? '⌨ Hide' : '⌨ Output'; + }); + bar.appendChild(refreshBtn); + bar.appendChild(outBtn); + bar.appendChild(styleBtn); + + const grid = api.el('div', { id: 'ws-grid' }); + const drawer = api.el('div', { id: 'ws-out' }); + // Output visible by default: a card grid with the terminal collapsed reads as "where did my + // answer go?" — and the probe (correctly) measures a 0x0 pane as invisible. + drawer.classList.add('open'); + drawerOpen = true; + outBtn.textContent = '⌨ Hide'; + const composer = api.el('div', { id: 'ws-composer' }); + + const cards = [ + { id: 'files', ico: '📁', title: 'Files', cmd: 'ls -la' }, + { id: 'sys', ico: '🖥', title: 'Heap', cmd: 'cat /sys/heap/stats' }, + { id: 'modules', ico: '🧩', title: 'Modules', cmd: 'modules' }, + { id: 'agents', ico: '🤖', title: 'Agents', cmd: 'agent-list' }, + { id: 'cron', ico: '⏱', title: 'Scheduled', cmd: 'cron-list' }, + { id: 'errors', ico: '🐞', title: 'Recent errors', cmd: 'cat /sys/logs/errors' } + ]; + const pres = {}; + cards.forEach(c => { + const card = api.el('div', { class: 'wx11-card' }); + const h = api.el('h3'); + h.appendChild(api.el('span', { text: c.ico })); + h.appendChild(api.el('span', { text: c.title })); + const run = api.el('span', { class: 'run', text: '▸' }); + run.addEventListener('click', () => { + const d = document.getElementById('ws-out'); + if (d && !d.classList.contains('open')) d.classList.add('open'); + api.exec(c.cmd); + api.toast('Running: ' + c.cmd); + }); + h.appendChild(run); + const pre = api.el('pre', { class: 'wx11-pre', text: '…' }); + pres[c.id] = pre; + card.appendChild(h); card.appendChild(pre); + grid.appendChild(card); + }); + + root.appendChild(bar); + root.appendChild(grid); + root.appendChild(drawer); + root.appendChild(composer); + api.attach(composer, api.stock.input); + api.attach(drawer, api.stock.output); // the run-in-terminal output needs somewhere to land + + // Generation guard: capture() is async, so a 15s tick can land AFTER teardown and write into + // a detached tree (invisible work, retained DOM). Every refresh claims a generation. + let gen = 0; + const refresh = async () => { + const my = ++gen; + let changed = 0; + for (const c of cards) { + try { + const out = await api.capture(c.cmd); + if (my !== gen) return; + pres[c.id].textContent = (out && out.trim()) ? out : '(empty)'; + changed++; + } catch (e) { + if (my !== gen) return; + pres[c.id].textContent = 'unavailable: ' + e.message; + } + } + if (my !== gen) return; + const stamp = document.getElementById('ws-stamp'); + if (stamp) stamp.textContent = changed + '/' + cards.length + ' · ' + + new Date().toLocaleTimeString(); + }; + refreshBtn.addEventListener('click', refresh); + refresh(); + const timer = api.interval(refresh, 15000); + return () => { gen++; api.clearInterval(timer); }; + } +} diff --git a/modules/test/test-ui-shell.js b/modules/test/test-ui-shell.js new file mode 100644 index 0000000..ea4dc84 --- /dev/null +++ b/modules/test/test-ui-shell.js @@ -0,0 +1,214 @@ +// node modules/test/test-ui-shell.js +// test-ui-shell: the UI shell must keep exactly one output stream alive across mode switches. +// +// Why this exists: every mode reparents #terminal-container into its own layout. Four ways that goes +// wrong are invisible in a screenshot — teardown that forgets to restore, a mode that clones the +// node, a failed mount that leaves the page chrome-less, and a footer that never docks (which strands +// a streaming AI answer with no stop button). These assertions pin all four. +const fs = require('fs'), path = require('path'); +const CORE = path.join(__dirname, '..', 'core'); + +// ── minimal DOM: only what the shell is allowed to use ──────────────────────────── +function makeEl(tag) { + const el = { + tagName: String(tag || 'div').toUpperCase(), id: '', children: [], parentNode: null, + dataset: {}, style: {}, textContent: '', _html: '', + get innerHTML() { return this._html; }, + set innerHTML(v) { + this._html = String(v); + for (const c of this.children) c.parentNode = null; + this.children = []; + }, + appendChild(c) { + if (c.parentNode) c.parentNode.removeChild(c); + c.parentNode = this; this.children.push(c); return c; + }, + append(...cs) { cs.forEach(c => this.appendChild(c)); }, + insertBefore(c, ref) { + if (c.parentNode) c.parentNode.removeChild(c); + const i = ref ? this.children.indexOf(ref) : -1; + if (i < 0) this.children.push(c); else this.children.splice(i, 0, c); + c.parentNode = this; return c; + }, + removeChild(c) { + const i = this.children.indexOf(c); + if (i >= 0) { this.children.splice(i, 1); c.parentNode = null; } + return c; + }, + remove() { if (this.parentNode) this.parentNode.removeChild(this); }, + contains(n) { + if (!n) return false; + if (n === this) return true; + return this.children.some(c => c.contains && c.contains(n)); + }, + setAttribute(k, v) { this[k] = v; }, + addEventListener() {}, removeEventListener() {}, + querySelector(sel) { + const want = String(sel).replace(/^#/, ''); + const find = n => { + if (n.id === want) return n; + for (const c of n.children) { const r = find(c); if (r) return r; } + return null; + }; + return find(this); + }, + classList: { + _s: new Set(), + add(...c) { c.forEach(x => this._s.add(x)); }, + remove(...c) { c.forEach(x => this._s.delete(x)); }, + contains(c) { return this._s.has(c); }, + toggle(c) { if (this._s.has(c)) this._s.delete(c); else this._s.add(c); } + } + }; + return el; +} + +const head = makeEl('head'), body = makeEl('body'), htmlEl = makeEl('html'); +const byId = {}; +global.document = { + head, body, documentElement: htmlEl, + createElement: tag => makeEl(tag), + createTextNode: t => ({ textContent: String(t), children: [], parentNode: null }), + getElementById: id => { + // walk the tree, not a registry: a node the shell forgot to restore must read as absent + const find = n => { + if (n.id === id) return n; + for (const c of n.children) { const r = find(c); if (r) return r; } + return null; + }; + return find(body); + }, + addEventListener() {}, dispatchEvent() { return true; }, + querySelector: sel => body.querySelector(sel) +}; +global.CustomEvent = class { constructor(type, opts) { this.type = type; this.detail = (opts || {}).detail; } }; +global.window = global; +global.performance = { now: () => Date.now() }; +const store = {}; +global.localStorage = { + getItem: k => (k in store ? store[k] : null), + setItem: (k, v) => { store[k] = String(v); }, + removeItem: k => { delete store[k]; } +}; + +function stock(id) { const e = makeEl('div'); e.id = id; byId[id] = e; body.appendChild(e); return e; } +// canonical order taken from main.html:882-965 +['terminal-container', 'input-area', 'quick-actions', 'status-bar'].forEach(stock); + +// ── load the module the way the bundle does (class declaration, not module.exports) ── +const src = fs.readFileSync(path.join(CORE, 'module-uishell.js'), 'utf8'); +const UIShellModule = new Function(src + '\nreturn UIShellModule;')(); + +let pass = 0, fail = 0; +function ok(name, cond, detail) { + if (cond) { pass++; console.log(' PASS ' + name); } + else { fail++; console.log(' FAIL ' + name + (detail ? ' — ' + detail : '')); } +} + +const printed = []; +const fakeTerminal = { + print: (m, t) => printed.push({ m, t }), + capturePipeOutput: async () => 'captured', + _parseArgs: s => String(s).trim().split(/\s+/).filter(Boolean) +}; + +const shell = new UIShellModule(); +shell.init(fakeTerminal); + +// A1 — command registered +shell.registerCommands(); +ok('A1 ui command registered', typeof shell.commands['ui'] === 'function'); + +// A2 — mode registry + apply persists and sets the body attribute +let mounted = 0, unmounted = 0; +shell.registerMode({ + id: 'probe', label: 'Probe', level: 3, icon: '🧪', tagline: 'test mode', + mount(api) { + mounted++; + const slot = api.el('div', { id: 'probe-slot' }); + api.root.appendChild(slot); + api.attach(slot, api.stock.output); // reparent the real terminal + return () => { unmounted++; }; + } +}); +ok('A2 apply returns true', shell.apply('probe') === true); +ok('A2 body data-ui-mode', document.body.dataset.uiMode === 'probe', document.body.dataset.uiMode); +ok('A2 persisted', localStorage.getItem('webx11_ui_mode') === 'probe'); +ok('A3 mount ran once', mounted === 1); +ok('A4 terminal moved into the mode', byId['terminal-container'].parentNode && + byId['terminal-container'].parentNode.id === 'probe-slot'); + +// A5 — switching away must restore the stock node, in order, and not destroy it +shell.apply('classic'); +ok('A5 teardown ran', unmounted === 1); +ok('A5 terminal survived teardown', !!document.getElementById('terminal-container')); +// ui-root is the shell's own container and legitimately sits in <body> too — compare stock only +const stockOrder = () => body.children.map(c => c.id) + .filter(id => id && id !== 'ui-root').join(','); +ok('A5 body order restored', + stockOrder() === 'terminal-container,input-area,quick-actions,status-bar', stockOrder()); +ok('A5 root emptied', (shell._root.children || []).length === 0); +ok('A5 body attribute cleared', document.body.dataset.uiMode === 'classic'); + +// A6 — unknown mode: no persist, warning, still classic +store['webx11_ui_mode'] = 'classic'; +ok('A6 unknown returns false', shell.apply('nope') === false); +ok('A6 unknown does not persist', localStorage.getItem('webx11_ui_mode') === 'classic'); +// The warning is emitted asynchronously now: an unknown id is looked up in packages.json first +// (lazy load), so the message arrives a tick later — asserted in the async tail below. + +// A7 — a mode that throws on mount must not leave the page chrome-less +shell.registerMode({ + id: 'boom', label: 'Boom', level: 9, icon: '💥', tagline: 'broken', + mount() { throw new Error('mount exploded'); } +}); +shell.apply('boom'); +ok('A7 fell back to classic', document.body.dataset.uiMode === 'classic', document.body.dataset.uiMode); +ok('A7 terminal still present', !!document.getElementById('terminal-container')); + +// A8 — a mode that deletes the terminal is rejected +shell.registerMode({ + id: 'killer', label: 'Killer', level: 9, icon: '☠', tagline: 'deletes the pane', + mount() { byId['terminal-container'].remove(); return () => {}; } +}); +shell.apply('killer'); +ok('A8 destructive mode rejected', document.body.dataset.uiMode === 'classic'); +ok('A8 error reported', printed.some(p => /terminal/i.test(p.m) && p.t === 'error')); + +// A11 — a same-id clone passes a presence check; identity must reject it +shell.registerMode({ + id: 'clone', label: 'Clone', level: 9, icon: '👥', tagline: 'same id, different node', + mount(api) { + byId['terminal-container'].remove(); + const fake = document.createElement('div'); + fake.id = 'terminal-container'; + api.root.appendChild(fake); + return () => {}; + } +}); +ok('A11 clone rejected', shell.apply('clone') === false); +ok('A11 clone fallback is classic', document.body.dataset.uiMode === 'classic', + document.body.dataset.uiMode); +ok('A11 no stale body class', !document.body.classList.contains('wx11-ui-clone')); + +// A10 — the status bar (AI stop + network state) must exist inside every mode +shell.apply('probe'); +const sb = document.getElementById('status-bar'); +ok('A10 status bar docked in ui-foot', + !!sb && sb.parentNode.id === 'ui-foot', sb ? sb.parentNode.id : 'missing'); +shell.apply('classic'); +const sb2 = document.getElementById('status-bar'); +ok('A10 status bar returned to body', !!sb2 && sb2.parentNode === document.body, + sb2 ? String(sb2.parentNode.id || 'body') : 'missing'); +const foot = document.getElementById('ui-foot'); +ok('A10 classic leaves no visible footer', !foot || foot.style.display === 'none'); + +// A9 — capture() routes a shell-authored command through the capture path +(async () => { + const got = await shell.api().capture('ls -la'); + ok('A9 capture returns text', got === 'captured', String(got)); + await new Promise(r => setTimeout(r, 100)); // let the failed lazy load report itself + ok('A6 warned', printed.some(p => /could not be loaded|not available|not loaded/.test(p.m))); + console.log('\n' + pass + '/' + (pass + fail) + ' PASS'); + process.exit(fail ? 1 : 0); +})(); |