aboutsummaryrefslogtreecommitdiff
path: root/modules/ext/ui-commander.js
blob: 4ffdc8baccb4ad1587c7b4c67e526b10026221cd (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
// 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) => {
            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);
        };
    }
}