aboutsummaryrefslogtreecommitdiff
path: root/modules/ext
diff options
context:
space:
mode:
Diffstat (limited to 'modules/ext')
-rw-r--r--modules/ext/ui-chat.js99
-rw-r--r--modules/ext/ui-cockpit.js122
-rw-r--r--modules/ext/ui-commander.js188
-rw-r--r--modules/ext/ui-easy.js83
-rw-r--r--modules/ext/ui-studio.js146
-rw-r--r--modules/ext/ui-workspace.js133
6 files changed, 771 insertions, 0 deletions
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); };
+ }
+}