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