aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--CHANGELOG.md6
-rw-r--r--main.html6
-rw-r--r--modules/core/core-bundle.js82
-rw-r--r--modules/core/module-loader.js2
-rw-r--r--modules/core/module-uishell.js80
-rw-r--r--modules/core/terminal.js2
-rw-r--r--modules/ext/ui-cockpit.js6
-rw-r--r--modules/ext/ui-commander.js11
-rw-r--r--modules/ext/ui-easy.js4
-rw-r--r--modules/ext/ui-studio.js3
-rw-r--r--modules/ext/ui-workspace.js5
-rw-r--r--modules/test/test-ui-shell.js3
12 files changed, 142 insertions, 68 deletions
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 3d142ba..3f0a9d6 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -4,6 +4,12 @@ Generated from git history by `scripts/build-changelog.py` — do not edit by ha
<!-- generated:start -->
+## v4.6.153 — 2026-11-07
+
+_Touches CHANGELOG.md (1), main.html (1), modules (1) · project 1_
+
+- **V4.6.153 — cache-bust the review fixes** `13271e5`
+
## v4.6.152 — 2026-11-07
_Touches modules (18), CHANGELOG.md (2), README.md (1), help.html (1) · docs 2, fix 2, release 1_
diff --git a/main.html b/main.html
index 875ff38..eb0fe9a 100644
--- a/main.html
+++ b/main.html
@@ -1350,13 +1350,13 @@
</script>
<!-- v4.5.63: A2A scratchpad fixes + agent-register arg fix -->
-<script src="modules/core/core-bundle.js?v=v4.6.153"></script>
+<script src="modules/core/core-bundle.js?v=v4.6.155"></script>
<!-- Async dependency-ordered module loader now only initializes terminal and loads optional ext modules -->
-<script src="modules/core/module-loader.js?v=v4.6.153"></script>
+<script src="modules/core/module-loader.js?v=v4.6.155"></script>
<!-- Critical ext bundle loads AFTER webx11 exists so registerModule calls work -->
-<script src="modules/ext/critical-ext-bundle.js?v=v4.6.153"></script>
+<script src="modules/ext/critical-ext-bundle.js?v=v4.6.155"></script>
<script>
// ═══ ONE TRUE VERSION SOURCE ═══
diff --git a/modules/core/core-bundle.js b/modules/core/core-bundle.js
index 07be2ce..623bce5 100644
--- a/modules/core/core-bundle.js
+++ b/modules/core/core-bundle.js
@@ -4222,7 +4222,7 @@ Only output XML function calls for tool tasks. If no tool is needed, answer norm
// 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 new Promise(r => setTimeout(r, 1200));
await this.moduleManager.loadModuleFromURL(url);
} catch (e2) {
console.warn(`[Ext] ${key} failed to load: ${e2.message}`);
@@ -22980,6 +22980,7 @@ class UIShellModule {
this._cleanup = null;
this._where = null; // [{ el, parent, next }] captured on first mount
this._loadSeq = 0; // bumps on every apply(): a stale lazy load must never win
+ this._loading = {}; // id -> in-flight promise, so a double click loads a mode once
this._terminalEl = null; // identity of the one real output node
this._cssIds = [];
this._pending = null; // mode requested before its ext module loaded
@@ -23013,7 +23014,7 @@ class UIShellModule {
const q = new URLSearchParams(location.search).get('ui');
// Validate before it reaches classList.add('wx11-ui-' + id): a space or a colon in the
// query throws a DOMException there and boot dies with no interface at all.
- if (q && /^[a-z][a-z0-9-]{0,15}$/.test(q)) { wanted = q; this._fromQuery = true; }
+ if (q && this.lazyIds().indexOf(q) !== -1) { wanted = q; this._fromQuery = true; }
} catch (e) { /* no URLSearchParams: ignore the request */ }
// persist:false — a shared link requests a view, it must not rewrite the user's own default
// (the code comment already claimed this; the flag did the opposite).
@@ -23024,22 +23025,17 @@ class UIShellModule {
// 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);
+ // First run: show the picker at once. Waiting for four registered modes was a dead condition —
+ // the six mode files are lazy now, so nothing registers before the user picks one, and every
+ // first-run visitor paid a 4.4 s delay for a list that was static anyway.
+ if (!localStorage.getItem(this.seenKey) && this.current === 'classic' && !this._pending) {
+ this._pickerT = setTimeout(() => this.showPicker(), 600);
}
}
_sweepPending() {
if (!this._pending) return;
+ if (this._loading[this._pending]) return; // still in flight: the load's own callback decides
if (this.modes[this._pending]) return; // registered late: apply() already ran
if (this.terminal) {
this.terminal.print('Interface mode \'' + this._pending + '\' did not load — staying on ' +
@@ -23065,6 +23061,10 @@ class UIShellModule {
list() { return Object.keys(this.modes).map(id => this.modes[id]).sort((a, b) => a.level - b.level); }
+ // The six modes exist even before their files are fetched, so `ui list` / `ui next` / the picker
+ // must not be limited to what happens to be registered. One list, not four copies.
+ lazyIds() { return ['easy', 'chat', 'studio', 'workspace', 'commander', 'cockpit']; }
+
/* ---------- switching ---------- */
apply(id, opts) {
const o = opts || {};
@@ -23075,10 +23075,12 @@ class UIShellModule {
// 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.
+ // hasOwnProperty: `this.modes` is a plain object, so ?ui=constructor / __proto__ passed the
+ // truthiness check and reached the mount path without any mode registering.
+ if (want !== 'classic' && !Object.prototype.hasOwnProperty.call(this.modes, want)) {
+ // Lazy load. The six mode files are NOT in extKeys: a full eager boot issues ~40-55
+ // requests and the per-IP `limit_req` bucket (then burst=50, now 200) 503'd everything
+ // after it — measured 2026-11-07.
if (o.retried) {
if (this.terminal) this.terminal.print(`UI mode '${want}' is not available.`, 'warning');
return false;
@@ -23117,6 +23119,8 @@ class UIShellModule {
document.body.classList.add('wx11-ui-' + want);
if (o.persist !== false) localStorage.setItem(this.key, want);
+ // _syncChrome on EVERY switch: it hides #quick-actions, which otherwise stays in the tab order
+ // and the accessibility tree behind the opaque mode root (found by review).
if (want === 'classic') { this._hideFooter(); this._syncChrome(); return true; }
const api = this.api();
@@ -23132,13 +23136,19 @@ class UIShellModule {
// 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');
+ // Identity for EVERY stock node the terminal bound, not just the output pane: terminal.js also
+ // holds #output, #command-input, #input-area and #status-bar, so a mode that clones one of those
+ // leaves the terminal writing into a detached node while the page looks fine.
+ const lost = (this._where || []).filter((w) => {
+ const live = document.getElementById(w.el.id);
+ return !live || live !== w.el || !document.body.contains(w.el);
+ });
+ if (lost.length) {
+ this._abortToClassic(want, 'removed or replaced ' + lost.map((w) => '#' + w.el.id).join(', '));
return false;
}
this._dockFooter();
+ this._syncChrome();
// Every mode hides .prompt-label, so without this the real input has no accessible name.
const inp = document.getElementById('command-input');
if (inp && !inp.getAttribute('aria-label')) inp.setAttribute('aria-label', 'Terminal command input');
@@ -23165,7 +23175,7 @@ class UIShellModule {
}
next() {
- const order = ['classic'].concat(this.list().map(m => m.id));
+ const order = ['classic'].concat(this.lazyIds());
const i = order.indexOf(this.current);
return this.apply(order[(i + 1) % order.length]);
}
@@ -23174,6 +23184,13 @@ class UIShellModule {
// never be served stale after a version bump. Public moduleManager methods only, and no
// printing: a mode load must not litter the chat.
async _loadMode(id) {
+ if (this._loading[id]) return this._loading[id]; // one load per id, however many taps
+ const p = this._loadModeOnce(id).finally(() => { delete this._loading[id]; });
+ this._loading[id] = p;
+ return p;
+ }
+
+ async _loadModeOnce(id) {
const mm = this.terminal && this.terminal.moduleManager;
if (!mm) return this._loadFail(id, 'no module manager');
const ver = localStorage.getItem('webx11_cache_ver') || mm.LATEST_VER || '0';
@@ -23183,7 +23200,9 @@ class UIShellModule {
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]);
+ // Only `ui-<id>`: without this a crafted ?ui=calc fetches and initialises an unrelated ext
+ // module through the mode loader (same-origin, full page access).
+ pkg = idx && idx.packages && idx.packages['ui-' + id];
} catch (e) {
return this._loadFail(id, 'packages.json unreadable: ' + e.message);
}
@@ -23195,7 +23214,7 @@ class UIShellModule {
// load looks exactly like a broken button.
let ok = await mm.loadModuleFromURL(url);
if (!ok) {
- await new Promise(r => setTimeout(r, 900));
+ await new Promise(r => setTimeout(r, 1200)); // >= 1 token at 60 r/m before retrying
ok = await mm.loadModuleFromURL(url);
}
if (!ok) return this._loadFail(id, 'loader refused ' + url);
@@ -23236,6 +23255,7 @@ class UIShellModule {
el,
parent: el.parentNode,
index: Array.prototype.indexOf.call(el.parentNode.children, el),
+ next: el.nextSibling || null, // trailing anchor: what followed the last stock node
}));
}
@@ -23248,7 +23268,10 @@ class UIShellModule {
const list = this._where.slice().sort((a, b) => a.index - b.index);
list.forEach((w) => {
if (!w.parent || w.el.parentNode === w.parent) return; // already in place
- const anchor = list.find((c) => c.index > w.index && c.el.parentNode === w.parent);
+ // Anchor on the next stock node already home; for the LAST stock node fall back to the
+ // sibling that followed it originally, or the group lands after every trailing body child.
+ const anchor = list.find((c) => c.index > w.index && c.el.parentNode === w.parent)
+ || (w.next && w.next.parentNode === w.parent ? w.next : null);
try { w.parent.insertBefore(w.el, anchor ? anchor.el : null); } catch (e) { /* detached */ }
});
}
@@ -23302,7 +23325,7 @@ class UIShellModule {
// clip, not hidden: `overflow:hidden` still creates a scroll container, and the terminal's
// scrollIntoView() on new output scrolled #ui-root by 464px — the title, the action cards
// and the composer all left the screen (measured 2026-11-07). `clip` cannot be scrolled.
- '#ui-root{position:fixed;inset:0;display:none;overflow:clip;z-index:40;background:var(--bg-color);color:var(--text-color)}',
+ '#ui-root{position:fixed;inset:0;display:none;overflow:hidden;overflow:clip;z-index:40;background:var(--bg-color);color:var(--text-color)}',
// flex column, not block: every mode is a vertical stack (chrome / content / composer /
// footer) and a block container makes each child's `flex:1 1 auto` inert — the terminal
// collapsed to 0px and the composer sat at the very bottom of a 4 600px flow.
@@ -23310,7 +23333,7 @@ class UIShellModule {
'#ui-root > *{min-width:0;min-height:0}',
// A non-classic mode owns the viewport: the page itself must not scroll, or terminal
// output drags the action cards off-screen and the composer never comes back.
- 'body[data-ui-mode]:not([data-ui-mode="classic"]){overflow:clip}',
+ 'body[data-ui-mode]:not([data-ui-mode="classic"]){overflow:hidden;overflow:clip}',
'body[data-ui-mode]:not([data-ui-mode="classic"]) #ui-root{overflow:clip}',
'body[data-ui-mode]:not([data-ui-mode="classic"]) #cmd-cards{display:none!important}',
// A mode IS the onboarding: six cards behind the classic "Let's Get Started" modal is two
@@ -23396,7 +23419,8 @@ class UIShellModule {
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];
+ // No `html` sink: zero modes needed it, and it was the only innerHTML entry point in
+ // the capability API. Dynamic text goes through textContent.
else if (k === 'class') node.className = props[k];
else node.setAttribute(k, props[k]);
}
@@ -23522,7 +23546,7 @@ class UIShellModule {
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'];
+ const LAZY = this.lazyIds();
if (available.indexOf(sub) === -1 && LAZY.indexOf(sub) === -1) {
this.terminal.print(`Unknown mode '${sub}'. Available: ${available.join(', ')}`, 'warning');
return;
diff --git a/modules/core/module-loader.js b/modules/core/module-loader.js
index 7caa874..7f9ffe8 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.153';
+ const LATEST_VER = 'v4.6.155';
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).
diff --git a/modules/core/module-uishell.js b/modules/core/module-uishell.js
index 9914ec0..11bad38 100644
--- a/modules/core/module-uishell.js
+++ b/modules/core/module-uishell.js
@@ -19,6 +19,7 @@ class UIShellModule {
this._cleanup = null;
this._where = null; // [{ el, parent, next }] captured on first mount
this._loadSeq = 0; // bumps on every apply(): a stale lazy load must never win
+ this._loading = {}; // id -> in-flight promise, so a double click loads a mode once
this._terminalEl = null; // identity of the one real output node
this._cssIds = [];
this._pending = null; // mode requested before its ext module loaded
@@ -52,7 +53,7 @@ class UIShellModule {
const q = new URLSearchParams(location.search).get('ui');
// Validate before it reaches classList.add('wx11-ui-' + id): a space or a colon in the
// query throws a DOMException there and boot dies with no interface at all.
- if (q && /^[a-z][a-z0-9-]{0,15}$/.test(q)) { wanted = q; this._fromQuery = true; }
+ if (q && this.lazyIds().indexOf(q) !== -1) { wanted = q; this._fromQuery = true; }
} catch (e) { /* no URLSearchParams: ignore the request */ }
// persist:false — a shared link requests a view, it must not rewrite the user's own default
// (the code comment already claimed this; the flag did the opposite).
@@ -63,22 +64,17 @@ class UIShellModule {
// 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);
+ // First run: show the picker at once. Waiting for four registered modes was a dead condition —
+ // the six mode files are lazy now, so nothing registers before the user picks one, and every
+ // first-run visitor paid a 4.4 s delay for a list that was static anyway.
+ if (!localStorage.getItem(this.seenKey) && this.current === 'classic' && !this._pending) {
+ this._pickerT = setTimeout(() => this.showPicker(), 600);
}
}
_sweepPending() {
if (!this._pending) return;
+ if (this._loading[this._pending]) return; // still in flight: the load's own callback decides
if (this.modes[this._pending]) return; // registered late: apply() already ran
if (this.terminal) {
this.terminal.print('Interface mode \'' + this._pending + '\' did not load — staying on ' +
@@ -104,6 +100,10 @@ class UIShellModule {
list() { return Object.keys(this.modes).map(id => this.modes[id]).sort((a, b) => a.level - b.level); }
+ // The six modes exist even before their files are fetched, so `ui list` / `ui next` / the picker
+ // must not be limited to what happens to be registered. One list, not four copies.
+ lazyIds() { return ['easy', 'chat', 'studio', 'workspace', 'commander', 'cockpit']; }
+
/* ---------- switching ---------- */
apply(id, opts) {
const o = opts || {};
@@ -114,10 +114,12 @@ class UIShellModule {
// 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.
+ // hasOwnProperty: `this.modes` is a plain object, so ?ui=constructor / __proto__ passed the
+ // truthiness check and reached the mount path without any mode registering.
+ if (want !== 'classic' && !Object.prototype.hasOwnProperty.call(this.modes, want)) {
+ // Lazy load. The six mode files are NOT in extKeys: a full eager boot issues ~40-55
+ // requests and the per-IP `limit_req` bucket (then burst=50, now 200) 503'd everything
+ // after it — measured 2026-11-07.
if (o.retried) {
if (this.terminal) this.terminal.print(`UI mode '${want}' is not available.`, 'warning');
return false;
@@ -156,6 +158,8 @@ class UIShellModule {
document.body.classList.add('wx11-ui-' + want);
if (o.persist !== false) localStorage.setItem(this.key, want);
+ // _syncChrome on EVERY switch: it hides #quick-actions, which otherwise stays in the tab order
+ // and the accessibility tree behind the opaque mode root (found by review).
if (want === 'classic') { this._hideFooter(); this._syncChrome(); return true; }
const api = this.api();
@@ -171,13 +175,19 @@ class UIShellModule {
// 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');
+ // Identity for EVERY stock node the terminal bound, not just the output pane: terminal.js also
+ // holds #output, #command-input, #input-area and #status-bar, so a mode that clones one of those
+ // leaves the terminal writing into a detached node while the page looks fine.
+ const lost = (this._where || []).filter((w) => {
+ const live = document.getElementById(w.el.id);
+ return !live || live !== w.el || !document.body.contains(w.el);
+ });
+ if (lost.length) {
+ this._abortToClassic(want, 'removed or replaced ' + lost.map((w) => '#' + w.el.id).join(', '));
return false;
}
this._dockFooter();
+ this._syncChrome();
// Every mode hides .prompt-label, so without this the real input has no accessible name.
const inp = document.getElementById('command-input');
if (inp && !inp.getAttribute('aria-label')) inp.setAttribute('aria-label', 'Terminal command input');
@@ -204,7 +214,7 @@ class UIShellModule {
}
next() {
- const order = ['classic'].concat(this.list().map(m => m.id));
+ const order = ['classic'].concat(this.lazyIds());
const i = order.indexOf(this.current);
return this.apply(order[(i + 1) % order.length]);
}
@@ -213,6 +223,13 @@ class UIShellModule {
// never be served stale after a version bump. Public moduleManager methods only, and no
// printing: a mode load must not litter the chat.
async _loadMode(id) {
+ if (this._loading[id]) return this._loading[id]; // one load per id, however many taps
+ const p = this._loadModeOnce(id).finally(() => { delete this._loading[id]; });
+ this._loading[id] = p;
+ return p;
+ }
+
+ async _loadModeOnce(id) {
const mm = this.terminal && this.terminal.moduleManager;
if (!mm) return this._loadFail(id, 'no module manager');
const ver = localStorage.getItem('webx11_cache_ver') || mm.LATEST_VER || '0';
@@ -222,7 +239,9 @@ class UIShellModule {
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]);
+ // Only `ui-<id>`: without this a crafted ?ui=calc fetches and initialises an unrelated ext
+ // module through the mode loader (same-origin, full page access).
+ pkg = idx && idx.packages && idx.packages['ui-' + id];
} catch (e) {
return this._loadFail(id, 'packages.json unreadable: ' + e.message);
}
@@ -234,7 +253,7 @@ class UIShellModule {
// load looks exactly like a broken button.
let ok = await mm.loadModuleFromURL(url);
if (!ok) {
- await new Promise(r => setTimeout(r, 900));
+ await new Promise(r => setTimeout(r, 1200)); // >= 1 token at 60 r/m before retrying
ok = await mm.loadModuleFromURL(url);
}
if (!ok) return this._loadFail(id, 'loader refused ' + url);
@@ -275,6 +294,7 @@ class UIShellModule {
el,
parent: el.parentNode,
index: Array.prototype.indexOf.call(el.parentNode.children, el),
+ next: el.nextSibling || null, // trailing anchor: what followed the last stock node
}));
}
@@ -287,7 +307,10 @@ class UIShellModule {
const list = this._where.slice().sort((a, b) => a.index - b.index);
list.forEach((w) => {
if (!w.parent || w.el.parentNode === w.parent) return; // already in place
- const anchor = list.find((c) => c.index > w.index && c.el.parentNode === w.parent);
+ // Anchor on the next stock node already home; for the LAST stock node fall back to the
+ // sibling that followed it originally, or the group lands after every trailing body child.
+ const anchor = list.find((c) => c.index > w.index && c.el.parentNode === w.parent)
+ || (w.next && w.next.parentNode === w.parent ? w.next : null);
try { w.parent.insertBefore(w.el, anchor ? anchor.el : null); } catch (e) { /* detached */ }
});
}
@@ -341,7 +364,7 @@ class UIShellModule {
// clip, not hidden: `overflow:hidden` still creates a scroll container, and the terminal's
// scrollIntoView() on new output scrolled #ui-root by 464px — the title, the action cards
// and the composer all left the screen (measured 2026-11-07). `clip` cannot be scrolled.
- '#ui-root{position:fixed;inset:0;display:none;overflow:clip;z-index:40;background:var(--bg-color);color:var(--text-color)}',
+ '#ui-root{position:fixed;inset:0;display:none;overflow:hidden;overflow:clip;z-index:40;background:var(--bg-color);color:var(--text-color)}',
// flex column, not block: every mode is a vertical stack (chrome / content / composer /
// footer) and a block container makes each child's `flex:1 1 auto` inert — the terminal
// collapsed to 0px and the composer sat at the very bottom of a 4 600px flow.
@@ -349,7 +372,7 @@ class UIShellModule {
'#ui-root > *{min-width:0;min-height:0}',
// A non-classic mode owns the viewport: the page itself must not scroll, or terminal
// output drags the action cards off-screen and the composer never comes back.
- 'body[data-ui-mode]:not([data-ui-mode="classic"]){overflow:clip}',
+ 'body[data-ui-mode]:not([data-ui-mode="classic"]){overflow:hidden;overflow:clip}',
'body[data-ui-mode]:not([data-ui-mode="classic"]) #ui-root{overflow:clip}',
'body[data-ui-mode]:not([data-ui-mode="classic"]) #cmd-cards{display:none!important}',
// A mode IS the onboarding: six cards behind the classic "Let's Get Started" modal is two
@@ -435,7 +458,8 @@ class UIShellModule {
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];
+ // No `html` sink: zero modes needed it, and it was the only innerHTML entry point in
+ // the capability API. Dynamic text goes through textContent.
else if (k === 'class') node.className = props[k];
else node.setAttribute(k, props[k]);
}
@@ -561,7 +585,7 @@ class UIShellModule {
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'];
+ const LAZY = this.lazyIds();
if (available.indexOf(sub) === -1 && LAZY.indexOf(sub) === -1) {
this.terminal.print(`Unknown mode '${sub}'. Available: ${available.join(', ')}`, 'warning');
return;
diff --git a/modules/core/terminal.js b/modules/core/terminal.js
index 6bc23b4..f0259fc 100644
--- a/modules/core/terminal.js
+++ b/modules/core/terminal.js
@@ -1533,7 +1533,7 @@
// 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 new Promise(r => setTimeout(r, 1200));
await this.moduleManager.loadModuleFromURL(url);
} catch (e2) {
console.warn(`[Ext] ${key} failed to load: ${e2.message}`);
diff --git a/modules/ext/ui-cockpit.js b/modules/ext/ui-cockpit.js
index 92dd60b..73fbe9f 100644
--- a/modules/ext/ui-cockpit.js
+++ b/modules/ext/ui-cockpit.js
@@ -62,6 +62,7 @@ body[data-ui-mode="cockpit"] #ui-root { display: grid; grid-template-rows: 1fr 1
defs.forEach((d, i) => {
const pane = api.el('div', { class: 'pane' });
pane.dataset.pane = String(i + 1);
+ pane.tabIndex = -1; // so F1-F8 can actually move focus, not just scroll
const h = api.el('h5');
h.appendChild(api.el('span', { text: 'F' + (i + 1) + ' ' + d.label }));
h.appendChild(api.el('span', { class: 'n' }));
@@ -85,7 +86,9 @@ body[data-ui-mode="cockpit"] #ui-root { display: grid; grid-template-rows: 1fr 1
// Generation guard: eight async captures per tick at 5s — a tick can outlive teardown.
let gen = 0;
const refresh = async () => {
- const my = ++gen;
+ // Same as workspace: bumping per tick cancelled a slow run before the later panes were
+ // written, leaving them at "…" indefinitely.
+ const my = gen;
for (const d of defs) {
try {
const text = await api.capture(d.cmd);
@@ -117,6 +120,7 @@ body[data-ui-mode="cockpit"] #ui-root { display: grid; grid-template-rows: 1fr 1
if (idx === 5) { refresh(); return; } // F5 = refresh, as labelled
const pane = panes.querySelector('[data-pane="' + idx + '"]');
if (!pane) return;
+ if (pane.focus) pane.focus();
pane.scrollIntoView({ block: 'nearest' });
pane.style.outline = '1px solid var(--accent-color)';
setTimeout(() => { pane.style.outline = ''; }, 900);
diff --git a/modules/ext/ui-commander.js b/modules/ext/ui-commander.js
index 4ffdc8b..03af5be 100644
--- a/modules/ext/ui-commander.js
+++ b/modules/ext/ui-commander.js
@@ -82,7 +82,16 @@ body[data-ui-mode="commander"] #ui-root { display: grid; grid-template-columns:
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));
+ // Quote the path: a VFS name with a space split into two arguments, and a leading `-`
+ // became an option flag (found by review).
+ const quoted = "'" + String(name).replace(/'/g, "'\\''") + "'";
+ const run = () => api.exec((isDir ? 'ls ' : 'cat ') + quoted);
+ it.addEventListener('click', run);
+ it.tabIndex = 0;
+ it.setAttribute('role', 'button');
+ it.addEventListener('keydown', (ev) => {
+ if (ev.key === 'Enter' || ev.key === ' ') { ev.preventDefault(); run(); }
+ });
left.appendChild(it);
});
} catch (e) { left.appendChild(api.el('div', { class: 'item', text: '(' + e.message + ')' })); }
diff --git a/modules/ext/ui-easy.js b/modules/ext/ui-easy.js
index eddf11d..cd1388a 100644
--- a/modules/ext/ui-easy.js
+++ b/modules/ext/ui-easy.js
@@ -16,7 +16,9 @@ class UIEasyMode {
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; }
+/* padding-bottom 34px, not 0: #ui-foot is absolutely positioned and 34px tall, and the shorthand
+ reset the bottom padding the base sheet reserves, so the footer sat on top of the composer. */
+body[data-ui-mode="easy"] #ui-root { display: flex; flex-direction: column; padding: 14px 14px 34px; 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; }
diff --git a/modules/ext/ui-studio.js b/modules/ext/ui-studio.js
index adda6a1..e8341f3 100644
--- a/modules/ext/ui-studio.js
+++ b/modules/ext/ui-studio.js
@@ -124,7 +124,8 @@ body[data-ui-mode="studio"] #ui-root { display: grid; grid-template-columns: 1.1
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);
+ // Quoted: a filename with a space would arrive as two arguments (found by review).
+ api.exec("edit '" + String(target).replace(/'/g, "'\\''") + "'");
api.toast('Editor takes over the chat pane — close it to return');
});
bodyEl.appendChild(btn);
diff --git a/modules/ext/ui-workspace.js b/modules/ext/ui-workspace.js
index be7a5e0..ee20aac 100644
--- a/modules/ext/ui-workspace.js
+++ b/modules/ext/ui-workspace.js
@@ -107,7 +107,10 @@ body[data-ui-mode="workspace"] #ui-root { display: flex; flex-direction: column;
// a detached tree (invisible work, retained DOM). Every refresh claims a generation.
let gen = 0;
const refresh = async () => {
- const my = ++gen;
+ // Do NOT bump the generation on every tick: a capture slower than the 15 s interval
+ // invalidated the in-flight run before it reached the later cards, so those stayed at
+ // "…" forever under slow storage (found by review). Teardown still bumps gen to stop it.
+ const my = gen;
let changed = 0;
for (const c of cards) {
try {
diff --git a/modules/test/test-ui-shell.js b/modules/test/test-ui-shell.js
index aced2e0..530e2e4 100644
--- a/modules/test/test-ui-shell.js
+++ b/modules/test/test-ui-shell.js
@@ -109,7 +109,8 @@ const printed = [];
const fakeTerminal = {
print: (m, t) => printed.push({ m, t }),
capturePipeOutput: async () => 'captured',
- _parseArgs: s => String(s).trim().split(/\s+/).filter(Boolean)
+ // Deliberately absent: the production terminal has NO _parseArgs, and api.capture() must never
+ // call one (that regression blanked every telemetry panel). A stub here made A9 green anyway.
};
const shell = new UIShellModule();