// 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 = '
'; 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 " 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) => { const t = ev.target; // Ctrl/Cmd+K belongs to the focused field (kill-line in a terminal input); only intercept // it from the page body, or typing in the composer opens the palette instead. if (t && (t.tagName === 'INPUT' || t.tagName === 'TEXTAREA' || t.isContentEditable)) return; 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); }; } }