@aitherium/shell-cli 1.1.0 → 1.11.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/auth.d.ts +13 -4
- package/dist/auth.js +18 -3
- package/dist/client.d.ts +21 -1
- package/dist/client.js +213 -9
- package/dist/command-registry.d.ts +1 -1
- package/dist/command-registry.js +28 -2
- package/dist/commands.d.ts +8 -0
- package/dist/commands.js +2068 -48
- package/dist/completions.js +236 -2
- package/dist/config.d.ts +30 -0
- package/dist/config.js +37 -4
- package/dist/crash-reporter.d.ts +22 -0
- package/dist/crash-reporter.js +275 -0
- package/dist/formatters.d.ts +16 -0
- package/dist/formatters.js +280 -0
- package/dist/interactive.d.ts +45 -0
- package/dist/interactive.js +232 -0
- package/dist/main.js +439 -41
- package/dist/mcp-client.d.ts +58 -0
- package/dist/mcp-client.js +202 -0
- package/dist/relay.d.ts +96 -0
- package/dist/relay.js +234 -0
- package/dist/renderer.d.ts +47 -17
- package/dist/renderer.js +506 -164
- package/dist/repl.js +205 -23
- package/dist/session-store.d.ts +30 -5
- package/dist/session-store.js +56 -0
- package/dist/status-banner.d.ts +59 -0
- package/dist/status-banner.js +239 -0
- package/dist/tui/controller.d.ts +3 -0
- package/dist/tui/controller.js +310 -0
- package/dist/tui/repl-tui.d.ts +3 -0
- package/dist/tui/repl-tui.js +898 -0
- package/dist/tui/screen.d.ts +68 -0
- package/dist/tui/screen.js +736 -0
- package/package.json +9 -2
|
@@ -0,0 +1,736 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* neo-blessed 3-pane surface for AitherShell.
|
|
3
|
+
*
|
|
4
|
+
* ┌─ output (≈60%) ───────┬─ trace (≈40%) ─┐
|
|
5
|
+
* │ answer streams here │ live pipeline │
|
|
6
|
+
* │ (independently scroll) │ telemetry │
|
|
7
|
+
* ├────────────────────────┴────────────────┤
|
|
8
|
+
* │ status line │
|
|
9
|
+
* ├─────────────────────────────────────────┤
|
|
10
|
+
* │ message > ▌ │
|
|
11
|
+
* └─────────────────────────────────────────┘
|
|
12
|
+
*
|
|
13
|
+
* OUTPUT and TRACE each own a real scroll buffer (mouse wheel + PageUp/Down),
|
|
14
|
+
* so long output is never "eaten" — the bug the old DECSTBM SteeringBar caused.
|
|
15
|
+
*/
|
|
16
|
+
import { createRequire } from 'node:module';
|
|
17
|
+
import chalk from 'chalk';
|
|
18
|
+
const nodeRequire = createRequire(import.meta.url);
|
|
19
|
+
const blessed = nodeRequire('neo-blessed');
|
|
20
|
+
export function createTuiScreen(opts) {
|
|
21
|
+
// Force SGR (1006) mouse reporting BEFORE the screen (and its lazy
|
|
22
|
+
// program.enableMouse()) initialises. neo-blessed picks the mouse mode purely
|
|
23
|
+
// from $TERM: for xterm-256color it enables vt200Mouse + utfMouse (1005) but
|
|
24
|
+
// NOT SGR. Windows Terminal doesn't implement UTF-8 mouse (1005), so the raw
|
|
25
|
+
// high bytes get UTF-8-decoded and the X10 coordinate parse corrupts/drops the
|
|
26
|
+
// event — which is why mouse-wheel scroll and click-to-toggle-thread did
|
|
27
|
+
// nothing. SGR is pure ASCII (no encoding ambiguity), universally supported,
|
|
28
|
+
// and the input parser already decodes it. BLESSED_FORCE_MODES is blessed's
|
|
29
|
+
// own escape hatch and is read at enableMouse() time. vt200Mouse(1000) reports
|
|
30
|
+
// button press/release + wheel — all we need; we skip cell/all-motion to avoid
|
|
31
|
+
// a mousemove event storm.
|
|
32
|
+
if (!process.env.BLESSED_FORCE_MODES) {
|
|
33
|
+
process.env.BLESSED_FORCE_MODES = 'SGRMOUSE=1,VT200MOUSE=1';
|
|
34
|
+
}
|
|
35
|
+
const screen = blessed.screen({
|
|
36
|
+
smartCSR: true, fullUnicode: true, mouse: true,
|
|
37
|
+
title: opts.title || 'AitherShell', autoPadding: true, warnings: false,
|
|
38
|
+
});
|
|
39
|
+
// Belt-and-suspenders: guarantee mouse reporting is on even if no element's
|
|
40
|
+
// newListener happened to trigger it first. Idempotent; re-reads the env modes.
|
|
41
|
+
try {
|
|
42
|
+
screen.program.enableMouse();
|
|
43
|
+
}
|
|
44
|
+
catch { /* terminal may not support it */ }
|
|
45
|
+
const paneBottom = 4;
|
|
46
|
+
const output = blessed.box({
|
|
47
|
+
parent: screen, top: 0, left: 0, width: '60%', bottom: paneBottom,
|
|
48
|
+
label: ' output ', border: 'line', tags: false,
|
|
49
|
+
scrollable: true, alwaysScroll: true, keys: true, mouse: true,
|
|
50
|
+
scrollbar: { ch: ' ', inverse: true }, wrap: true,
|
|
51
|
+
style: { border: { fg: 'cyan' }, label: { fg: 'cyan' } },
|
|
52
|
+
});
|
|
53
|
+
const traceTurns = [];
|
|
54
|
+
// Rendered-line-index → turn-index, for click-to-toggle on header rows.
|
|
55
|
+
let traceHeaderRows = new Map();
|
|
56
|
+
let userPinnedTrace = false;
|
|
57
|
+
const trace = blessed.box({
|
|
58
|
+
parent: screen, top: 0, left: '60%', right: 0, bottom: paneBottom,
|
|
59
|
+
label: ' trace ', border: 'line', tags: false,
|
|
60
|
+
scrollable: true, alwaysScroll: true, wrap: true, // wrap long trace lines (were truncated with …)
|
|
61
|
+
keys: true, mouse: true, scrollbar: { ch: ' ', inverse: true },
|
|
62
|
+
style: { border: { fg: 'gray' }, label: { fg: 'gray' } },
|
|
63
|
+
});
|
|
64
|
+
const status = blessed.box({
|
|
65
|
+
parent: screen, bottom: 3, left: 0, right: 0, height: 1, tags: false,
|
|
66
|
+
// A solid inverse bar so the status line is always clearly visible (the dim
|
|
67
|
+
// gray-on-black was nearly invisible except when the terminal selection hit it).
|
|
68
|
+
style: { fg: 'black', bg: 'cyan' },
|
|
69
|
+
});
|
|
70
|
+
// Display-only box — input is driven manually (see onKey below), NOT via
|
|
71
|
+
// blessed's readInput, which double-delivers keys to the element on this terminal.
|
|
72
|
+
const input = blessed.box({
|
|
73
|
+
parent: screen, bottom: 0, left: 0, right: 0, height: 3,
|
|
74
|
+
label: ' message ', border: 'line', tags: false,
|
|
75
|
+
style: { border: { fg: 'yellow' }, label: { fg: 'yellow' } },
|
|
76
|
+
});
|
|
77
|
+
// ── OUTPUT streaming buffer + scroll pinning ───────────────────
|
|
78
|
+
let outputBuf = '';
|
|
79
|
+
let userPinnedOutput = false;
|
|
80
|
+
let dirty = false;
|
|
81
|
+
let renderTimer = null;
|
|
82
|
+
function atBottom(el) {
|
|
83
|
+
try {
|
|
84
|
+
return el.getScrollPerc() >= 99;
|
|
85
|
+
}
|
|
86
|
+
catch {
|
|
87
|
+
return true;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
function atTop(el) {
|
|
91
|
+
try {
|
|
92
|
+
return el.getScrollPerc() <= 1;
|
|
93
|
+
}
|
|
94
|
+
catch {
|
|
95
|
+
return false;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
function flush() {
|
|
99
|
+
renderTimer = null;
|
|
100
|
+
// NOTE: flush() runs in a setTimeout (timer) context — it is NOT inside the
|
|
101
|
+
// stream for-await try/catch. A throw from any blessed call here would escape
|
|
102
|
+
// as an uncaughtException → the global crash reporter calls process.exit(1) →
|
|
103
|
+
// the whole TUI dies mid-turn (observed: long grounded follow-up renders
|
|
104
|
+
// intermittently throw under fullUnicode). Swallow render faults so a single
|
|
105
|
+
// bad repaint never tears the session down; the next scheduleRender recovers.
|
|
106
|
+
try {
|
|
107
|
+
if (dirty) {
|
|
108
|
+
// Preserve the user's scroll position across streamed appends: setContent
|
|
109
|
+
// resets the view, so when the user has scrolled up (pinned) we restore
|
|
110
|
+
// their top line instead of yanking them to the bottom on every token.
|
|
111
|
+
const base = output.childBase || 0;
|
|
112
|
+
output.setContent(outputBuf);
|
|
113
|
+
if (!userPinnedOutput)
|
|
114
|
+
output.setScrollPerc(100);
|
|
115
|
+
else {
|
|
116
|
+
try {
|
|
117
|
+
output.scrollTo(base);
|
|
118
|
+
}
|
|
119
|
+
catch { /* */ }
|
|
120
|
+
}
|
|
121
|
+
dirty = false;
|
|
122
|
+
}
|
|
123
|
+
screen.render();
|
|
124
|
+
}
|
|
125
|
+
catch { /* transient blessed repaint fault — recovered on next render */ }
|
|
126
|
+
}
|
|
127
|
+
function scheduleRender() { if (!renderTimer)
|
|
128
|
+
renderTimer = setTimeout(flush, 1000 / 30); }
|
|
129
|
+
function appendOutput(text) {
|
|
130
|
+
if (!text)
|
|
131
|
+
return;
|
|
132
|
+
outputBuf += text;
|
|
133
|
+
dirty = true;
|
|
134
|
+
scheduleRender();
|
|
135
|
+
}
|
|
136
|
+
function outputLine(line) {
|
|
137
|
+
appendOutput((outputBuf && !outputBuf.endsWith('\n') ? '\n' : '') + line + '\n');
|
|
138
|
+
}
|
|
139
|
+
/** Prepend text to the TOP of the output (relay scrollback). Keeps the viewport
|
|
140
|
+
* anchored by shifting scroll down by the number of added lines, so older
|
|
141
|
+
* history loading-in doesn't make the visible content jump. */
|
|
142
|
+
function prependOutput(text) {
|
|
143
|
+
if (!text)
|
|
144
|
+
return;
|
|
145
|
+
const t = text.endsWith('\n') ? text : text + '\n';
|
|
146
|
+
const addedLines = (t.match(/\n/g) || []).length;
|
|
147
|
+
outputBuf = t + outputBuf;
|
|
148
|
+
dirty = true;
|
|
149
|
+
try {
|
|
150
|
+
output.setContent(outputBuf);
|
|
151
|
+
output.scrollTo((output.childBase || 0) + addedLines);
|
|
152
|
+
userPinnedOutput = !atBottom(output);
|
|
153
|
+
}
|
|
154
|
+
catch { /* */ }
|
|
155
|
+
scheduleRender();
|
|
156
|
+
}
|
|
157
|
+
function markCheckpoint() { return outputBuf.length; }
|
|
158
|
+
function replaceOutputFrom(offset, text) {
|
|
159
|
+
if (offset < 0 || offset > outputBuf.length)
|
|
160
|
+
return;
|
|
161
|
+
outputBuf = outputBuf.slice(0, offset) + text;
|
|
162
|
+
dirty = true;
|
|
163
|
+
clearRegion(output); // markdown render may be shorter than the streamed text — blank stale tail
|
|
164
|
+
scheduleRender();
|
|
165
|
+
}
|
|
166
|
+
function _turnHeader(t) {
|
|
167
|
+
const arrow = t.collapsed ? '▸' : '▾';
|
|
168
|
+
const icon = t.status === 'done' ? chalk.green('✓')
|
|
169
|
+
: t.status === 'error' ? chalk.red('✗') : chalk.yellow('⚡');
|
|
170
|
+
const dur = t.durationMs != null ? chalk.dim(` ${(t.durationMs / 1000).toFixed(1)}s`)
|
|
171
|
+
: t.collapsed && t.lines.length ? chalk.dim(` (${t.lines.length})`) : '';
|
|
172
|
+
return `${chalk.cyan(arrow)} ${icon} ${chalk.cyan(t.label)}${dur}`;
|
|
173
|
+
}
|
|
174
|
+
// Blank a pane's inner content rectangle in the screen buffer before a repaint.
|
|
175
|
+
// Under fullUnicode, blessed's box render miscounts wide chars (emoji) in
|
|
176
|
+
// wrapped lines and skips some cells, so when content SHRINKS (a turn collapses,
|
|
177
|
+
// markdown replaces a longer stream) the old glyphs are left behind as
|
|
178
|
+
// "character bleed". clearRegion writes spaces + marks those cells dirty so the
|
|
179
|
+
// next render redraws them clean. Cheap (buffer-only, no terminal clear → no
|
|
180
|
+
// flicker); the box border is preserved by insetting 1 cell. lpos exists only
|
|
181
|
+
// after the first render — the very first paint can't ghost, so skipping is safe.
|
|
182
|
+
function clearRegion(el) {
|
|
183
|
+
const p = el.lpos;
|
|
184
|
+
if (!p)
|
|
185
|
+
return;
|
|
186
|
+
try {
|
|
187
|
+
screen.clearRegion(p.xi + 1, p.xl - 1, p.yi + 1, p.yl - 1);
|
|
188
|
+
}
|
|
189
|
+
catch { /* */ }
|
|
190
|
+
}
|
|
191
|
+
function renderTrace() {
|
|
192
|
+
clearRegion(trace);
|
|
193
|
+
const out = [];
|
|
194
|
+
traceHeaderRows = new Map();
|
|
195
|
+
traceTurns.forEach((t, ti) => {
|
|
196
|
+
traceHeaderRows.set(out.length, ti);
|
|
197
|
+
out.push(_turnHeader(t));
|
|
198
|
+
if (!t.collapsed)
|
|
199
|
+
for (const l of t.lines)
|
|
200
|
+
out.push(l);
|
|
201
|
+
});
|
|
202
|
+
const base = trace.childBase || 0;
|
|
203
|
+
try {
|
|
204
|
+
trace.setContent(out.join('\n'));
|
|
205
|
+
if (!userPinnedTrace)
|
|
206
|
+
trace.setScrollPerc(100);
|
|
207
|
+
else {
|
|
208
|
+
try {
|
|
209
|
+
trace.scrollTo(base);
|
|
210
|
+
}
|
|
211
|
+
catch { /* */ }
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
catch { /* transient blessed fault — recovered on next render */ }
|
|
215
|
+
scheduleRender();
|
|
216
|
+
}
|
|
217
|
+
/** Begin a new trace thread; collapse prior threads into the running history. */
|
|
218
|
+
function startTraceTurn(label) {
|
|
219
|
+
for (const t of traceTurns)
|
|
220
|
+
t.collapsed = true;
|
|
221
|
+
traceTurns.push({ label, lines: [], collapsed: false, status: 'running', startedAt: Date.now() });
|
|
222
|
+
renderTrace();
|
|
223
|
+
}
|
|
224
|
+
/** Mark the current thread done/error + stamp its duration (shown in header). */
|
|
225
|
+
function finishTraceTurn(status) {
|
|
226
|
+
const t = traceTurns[traceTurns.length - 1];
|
|
227
|
+
if (!t || t.status !== 'running')
|
|
228
|
+
return;
|
|
229
|
+
t.status = status;
|
|
230
|
+
t.durationMs = Date.now() - t.startedAt;
|
|
231
|
+
renderTrace();
|
|
232
|
+
}
|
|
233
|
+
function traceLine(line) {
|
|
234
|
+
if (!traceTurns.length)
|
|
235
|
+
traceTurns.push({ label: 'session', lines: [], collapsed: false, status: 'running', startedAt: Date.now() });
|
|
236
|
+
traceTurns[traceTurns.length - 1].lines.push(line);
|
|
237
|
+
renderTrace();
|
|
238
|
+
}
|
|
239
|
+
/** Toggle collapse on the turn whose header is at rendered content row `row`. */
|
|
240
|
+
function toggleTurnAtRow(row) {
|
|
241
|
+
const ti = traceHeaderRows.get(row);
|
|
242
|
+
if (ti == null)
|
|
243
|
+
return;
|
|
244
|
+
traceTurns[ti].collapsed = !traceTurns[ti].collapsed;
|
|
245
|
+
renderTrace();
|
|
246
|
+
}
|
|
247
|
+
/** Collapse-all ⇄ expand-all (Ctrl+E). */
|
|
248
|
+
function toggleAllTurns() {
|
|
249
|
+
const anyOpen = traceTurns.some(t => !t.collapsed);
|
|
250
|
+
for (const t of traceTurns)
|
|
251
|
+
t.collapsed = anyOpen;
|
|
252
|
+
renderTrace();
|
|
253
|
+
}
|
|
254
|
+
// Click a turn header to expand/collapse it.
|
|
255
|
+
trace.on('click', (data) => {
|
|
256
|
+
try {
|
|
257
|
+
const row = (data.y - trace.atop - trace.itop) + (trace.childBase || 0);
|
|
258
|
+
toggleTurnAtRow(row);
|
|
259
|
+
}
|
|
260
|
+
catch { /* */ }
|
|
261
|
+
});
|
|
262
|
+
function setStatus(text) { status.setContent(' ' + (text || '')); scheduleRender(); }
|
|
263
|
+
function setTracePanel(label, lines) {
|
|
264
|
+
clearRegion(trace);
|
|
265
|
+
try {
|
|
266
|
+
trace.setLabel(` ${label} `);
|
|
267
|
+
}
|
|
268
|
+
catch { /* */ }
|
|
269
|
+
const base = trace.childBase || 0;
|
|
270
|
+
trace.setContent(lines.join('\n'));
|
|
271
|
+
if (!userPinnedTrace)
|
|
272
|
+
trace.setScrollPerc(100);
|
|
273
|
+
else {
|
|
274
|
+
try {
|
|
275
|
+
trace.scrollTo(base);
|
|
276
|
+
}
|
|
277
|
+
catch { /* */ }
|
|
278
|
+
}
|
|
279
|
+
scheduleRender();
|
|
280
|
+
}
|
|
281
|
+
function setOutputLabel(label) {
|
|
282
|
+
try {
|
|
283
|
+
output.setLabel(` ${label} `);
|
|
284
|
+
}
|
|
285
|
+
catch { /* */ }
|
|
286
|
+
scheduleRender();
|
|
287
|
+
}
|
|
288
|
+
function clearPanes() {
|
|
289
|
+
outputBuf = '';
|
|
290
|
+
dirty = true;
|
|
291
|
+
output.setContent('');
|
|
292
|
+
traceTurns.length = 0;
|
|
293
|
+
traceHeaderRows = new Map();
|
|
294
|
+
trace.setContent('');
|
|
295
|
+
scheduleRender();
|
|
296
|
+
}
|
|
297
|
+
let traceVisible = true;
|
|
298
|
+
let splitPct = 60; // OUTPUT pane width %; the rest is the trace pane.
|
|
299
|
+
function applySplit() {
|
|
300
|
+
if (traceVisible) {
|
|
301
|
+
output.width = `${splitPct}%`;
|
|
302
|
+
trace.left = `${splitPct}%`;
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
function toggleTrace() {
|
|
306
|
+
traceVisible = !traceVisible;
|
|
307
|
+
if (traceVisible) {
|
|
308
|
+
trace.show();
|
|
309
|
+
applySplit();
|
|
310
|
+
}
|
|
311
|
+
else {
|
|
312
|
+
trace.hide();
|
|
313
|
+
output.width = '100%';
|
|
314
|
+
}
|
|
315
|
+
screen.render();
|
|
316
|
+
}
|
|
317
|
+
// Mouse capture vs terminal text selection. While blessed owns the mouse (the
|
|
318
|
+
// default — needed for wheel scroll + click-to-toggle-thread) the terminal's
|
|
319
|
+
// own click-drag selection is intercepted, so you can't highlight to copy.
|
|
320
|
+
// Ctrl+G releases the mouse back to the terminal (select/copy freely); Ctrl+G
|
|
321
|
+
// again re-grabs it for scroll. (Tip: most terminals also do Shift+drag while
|
|
322
|
+
// captured.)
|
|
323
|
+
let mouseCaptured = true;
|
|
324
|
+
function toggleMouse() {
|
|
325
|
+
mouseCaptured = !mouseCaptured;
|
|
326
|
+
try {
|
|
327
|
+
if (mouseCaptured)
|
|
328
|
+
screen.program.enableMouse();
|
|
329
|
+
else
|
|
330
|
+
screen.program.disableMouse();
|
|
331
|
+
}
|
|
332
|
+
catch { /* terminal may not support it */ }
|
|
333
|
+
setStatus(mouseCaptured
|
|
334
|
+
? 'mouse: captured (wheel scroll on) — Ctrl+G to release for copy/paste'
|
|
335
|
+
: 'mouse: released — drag to select/copy · Ctrl+G to re-enable scroll');
|
|
336
|
+
screen.render();
|
|
337
|
+
}
|
|
338
|
+
/** Adjust the output/trace split (Ctrl+←/→). +delta widens output. */
|
|
339
|
+
function resizeSplit(delta) {
|
|
340
|
+
if (!traceVisible)
|
|
341
|
+
return;
|
|
342
|
+
splitPct = Math.max(30, Math.min(85, splitPct + delta));
|
|
343
|
+
applySplit();
|
|
344
|
+
screen.render();
|
|
345
|
+
}
|
|
346
|
+
function scrollOutput(amount) {
|
|
347
|
+
try {
|
|
348
|
+
output.scroll(amount);
|
|
349
|
+
userPinnedOutput = !atBottom(output);
|
|
350
|
+
screen.render();
|
|
351
|
+
}
|
|
352
|
+
catch { /* */ }
|
|
353
|
+
}
|
|
354
|
+
function scrollTrace(amount) {
|
|
355
|
+
try {
|
|
356
|
+
trace.scroll(amount);
|
|
357
|
+
userPinnedTrace = !atBottom(trace);
|
|
358
|
+
screen.render();
|
|
359
|
+
}
|
|
360
|
+
catch { /* */ }
|
|
361
|
+
}
|
|
362
|
+
output.on('scroll', () => {
|
|
363
|
+
userPinnedOutput = !atBottom(output);
|
|
364
|
+
if (atTop(output))
|
|
365
|
+
opts.onScrollTop?.(); // relay scrollback (caller guards re-entrancy)
|
|
366
|
+
});
|
|
367
|
+
trace.on('scroll', () => { userPinnedTrace = !atBottom(trace); });
|
|
368
|
+
// Mouse wheel scrolls whichever pane is under the cursor — independently, and
|
|
369
|
+
// without needing focus. (blessed's built-in wheel scroll doesn't update our
|
|
370
|
+
// pin state, so route through scrollOutput/scrollTrace which do.)
|
|
371
|
+
output.on('wheelup', () => scrollOutput(-3));
|
|
372
|
+
output.on('wheeldown', () => scrollOutput(3));
|
|
373
|
+
trace.on('wheelup', () => scrollTrace(-3));
|
|
374
|
+
trace.on('wheeldown', () => scrollTrace(3));
|
|
375
|
+
/** Snap the OUTPUT pane back to the live bottom (un-pin). */
|
|
376
|
+
function outputToBottom() {
|
|
377
|
+
userPinnedOutput = false;
|
|
378
|
+
try {
|
|
379
|
+
output.setScrollPerc(100);
|
|
380
|
+
screen.render();
|
|
381
|
+
}
|
|
382
|
+
catch { /* */ }
|
|
383
|
+
}
|
|
384
|
+
// ── Manual input handling (do NOT use blessed's textbox readInput) ──
|
|
385
|
+
// Verified on this terminal: stdin emits EXACTLY ONE keypress per physical key,
|
|
386
|
+
// but blessed's textbox element-keypress path delivers each key to the textbox
|
|
387
|
+
// TWICE ("hheeyy"). So we drive the input value ourselves from the PROGRAM-level
|
|
388
|
+
// keypress stream — `program.emit('keypress')` fires once per physical key
|
|
389
|
+
// (Program.instances == 1) — and use the `input` box purely as a display.
|
|
390
|
+
// Input is an array of CODE POINTS (emoji-safe caret math), not a JS string.
|
|
391
|
+
let inputChars = [];
|
|
392
|
+
let _pickerOpen = false;
|
|
393
|
+
let inputCursor = 0; // caret index into inputChars
|
|
394
|
+
function inputStr() { return inputChars.join(''); }
|
|
395
|
+
function setInput(val, cur) {
|
|
396
|
+
inputChars = Array.from(val ?? '');
|
|
397
|
+
inputCursor = cur == null ? inputChars.length : Math.max(0, Math.min(cur, inputChars.length));
|
|
398
|
+
renderInput();
|
|
399
|
+
}
|
|
400
|
+
function renderInput() {
|
|
401
|
+
// Horizontally WINDOW long lines so the caret stays visible inside the
|
|
402
|
+
// single-line box (was overflowing before). '‹' marks a scrolled-off head.
|
|
403
|
+
const innerW = Math.max(8, input.width - 3);
|
|
404
|
+
let start = 0;
|
|
405
|
+
if (inputCursor > innerW - 2)
|
|
406
|
+
start = inputCursor - (innerW - 2);
|
|
407
|
+
const win = inputChars.slice(start, start + innerW);
|
|
408
|
+
const rel = inputCursor - start;
|
|
409
|
+
const before = win.slice(0, rel).join('');
|
|
410
|
+
const at = win[rel] ?? ' ';
|
|
411
|
+
const after = win.slice(rel + 1).join('');
|
|
412
|
+
const lead = start > 0 ? chalk.dim('‹') : ' ';
|
|
413
|
+
input.setContent(lead + before + chalk.inverse(at) + after);
|
|
414
|
+
scheduleRender();
|
|
415
|
+
}
|
|
416
|
+
const page = () => Math.max(1, output.height - 4);
|
|
417
|
+
function onKey(ch, key) {
|
|
418
|
+
if (_pickerOpen)
|
|
419
|
+
return; // the picker (blessed.list) handles its own keys
|
|
420
|
+
const name = (key && key.name) || '';
|
|
421
|
+
const ctrl = !!(key && key.ctrl);
|
|
422
|
+
if (ctrl && name === 'c') {
|
|
423
|
+
opts.onInterrupt();
|
|
424
|
+
return;
|
|
425
|
+
}
|
|
426
|
+
if (ctrl && name === 't') {
|
|
427
|
+
toggleTrace();
|
|
428
|
+
return;
|
|
429
|
+
}
|
|
430
|
+
if (ctrl && name === 'g') {
|
|
431
|
+
toggleMouse();
|
|
432
|
+
return;
|
|
433
|
+
} // release/grab mouse for copy-paste
|
|
434
|
+
if (ctrl && name === 'e' && !inputChars.length) {
|
|
435
|
+
toggleAllTurns();
|
|
436
|
+
return;
|
|
437
|
+
} // expand threads only on empty line
|
|
438
|
+
// ── Pane controls ──
|
|
439
|
+
if (name === 'pageup') {
|
|
440
|
+
(key && key.shift) ? scrollTrace(-8) : scrollOutput(-page());
|
|
441
|
+
return;
|
|
442
|
+
}
|
|
443
|
+
if (name === 'pagedown') {
|
|
444
|
+
(key && key.shift) ? scrollTrace(8) : scrollOutput(page());
|
|
445
|
+
return;
|
|
446
|
+
}
|
|
447
|
+
if (ctrl && name === 'up') {
|
|
448
|
+
scrollOutput(-1);
|
|
449
|
+
return;
|
|
450
|
+
}
|
|
451
|
+
if (ctrl && name === 'down') {
|
|
452
|
+
scrollOutput(1);
|
|
453
|
+
return;
|
|
454
|
+
}
|
|
455
|
+
if (ctrl && name === 'left') {
|
|
456
|
+
resizeSplit(-5);
|
|
457
|
+
return;
|
|
458
|
+
} // give the trace pane more room
|
|
459
|
+
if (ctrl && name === 'right') {
|
|
460
|
+
resizeSplit(5);
|
|
461
|
+
return;
|
|
462
|
+
} // give the output pane more room
|
|
463
|
+
// Home/End scroll output (Shift = trace) ONLY on an empty line; otherwise
|
|
464
|
+
// they move the input caret (so editing a typed line works as expected).
|
|
465
|
+
if (name === 'home') {
|
|
466
|
+
if (inputChars.length) {
|
|
467
|
+
inputCursor = 0;
|
|
468
|
+
renderInput();
|
|
469
|
+
}
|
|
470
|
+
else
|
|
471
|
+
(key && key.shift) ? scrollTrace(-99999) : scrollOutput(-99999);
|
|
472
|
+
return;
|
|
473
|
+
}
|
|
474
|
+
if (name === 'end') {
|
|
475
|
+
if (inputChars.length) {
|
|
476
|
+
inputCursor = inputChars.length;
|
|
477
|
+
renderInput();
|
|
478
|
+
}
|
|
479
|
+
else
|
|
480
|
+
(key && key.shift) ? scrollTrace(99999) : outputToBottom();
|
|
481
|
+
return;
|
|
482
|
+
}
|
|
483
|
+
// ── Input line editing (operates on code points) ──
|
|
484
|
+
if (name === 'enter' || name === 'return' || name === 'linefeed') {
|
|
485
|
+
const line = inputStr().trim();
|
|
486
|
+
setInput('');
|
|
487
|
+
if (line === '/' && opts.onSlash) {
|
|
488
|
+
opts.onSlash();
|
|
489
|
+
return;
|
|
490
|
+
}
|
|
491
|
+
if (line)
|
|
492
|
+
opts.onSubmit(line);
|
|
493
|
+
return;
|
|
494
|
+
}
|
|
495
|
+
if (name === 'left') {
|
|
496
|
+
if (inputCursor > 0) {
|
|
497
|
+
inputCursor--;
|
|
498
|
+
renderInput();
|
|
499
|
+
}
|
|
500
|
+
return;
|
|
501
|
+
}
|
|
502
|
+
if (name === 'right') {
|
|
503
|
+
if (inputCursor < inputChars.length) {
|
|
504
|
+
inputCursor++;
|
|
505
|
+
renderInput();
|
|
506
|
+
}
|
|
507
|
+
return;
|
|
508
|
+
}
|
|
509
|
+
if (ctrl && name === 'a') {
|
|
510
|
+
inputCursor = 0;
|
|
511
|
+
renderInput();
|
|
512
|
+
return;
|
|
513
|
+
} // line start (readline)
|
|
514
|
+
if (ctrl && name === 'e') {
|
|
515
|
+
inputCursor = inputChars.length;
|
|
516
|
+
renderInput();
|
|
517
|
+
return;
|
|
518
|
+
} // line end
|
|
519
|
+
if (ctrl && name === 'u') {
|
|
520
|
+
setInput(inputChars.slice(inputCursor).join(''), 0);
|
|
521
|
+
return;
|
|
522
|
+
} // kill to start
|
|
523
|
+
if (ctrl && name === 'k') {
|
|
524
|
+
setInput(inputChars.slice(0, inputCursor).join(''), inputCursor);
|
|
525
|
+
return;
|
|
526
|
+
} // kill to end
|
|
527
|
+
if (ctrl && name === 'w') { // delete word before caret
|
|
528
|
+
const left = Array.from(inputChars.slice(0, inputCursor).join('').replace(/\s*\S+\s*$/, ''));
|
|
529
|
+
setInput(left.join('') + inputChars.slice(inputCursor).join(''), left.length);
|
|
530
|
+
return;
|
|
531
|
+
}
|
|
532
|
+
if (name === 'backspace') {
|
|
533
|
+
if (inputCursor > 0)
|
|
534
|
+
setInput(inputChars.slice(0, inputCursor - 1).concat(inputChars.slice(inputCursor)).join(''), inputCursor - 1);
|
|
535
|
+
return;
|
|
536
|
+
}
|
|
537
|
+
if (name === 'delete') {
|
|
538
|
+
if (inputCursor < inputChars.length)
|
|
539
|
+
setInput(inputChars.slice(0, inputCursor).concat(inputChars.slice(inputCursor + 1)).join(''), inputCursor);
|
|
540
|
+
return;
|
|
541
|
+
}
|
|
542
|
+
if (name === 'up' && opts.onHistory) {
|
|
543
|
+
const v = opts.onHistory('up');
|
|
544
|
+
if (v != null)
|
|
545
|
+
setInput(v);
|
|
546
|
+
return;
|
|
547
|
+
}
|
|
548
|
+
if (name === 'down' && opts.onHistory) {
|
|
549
|
+
const v = opts.onHistory('down');
|
|
550
|
+
if (v != null)
|
|
551
|
+
setInput(v);
|
|
552
|
+
return;
|
|
553
|
+
}
|
|
554
|
+
if (name === 'tab' && opts.onTab) {
|
|
555
|
+
const v = opts.onTab(inputStr());
|
|
556
|
+
if (v != null)
|
|
557
|
+
setInput(v);
|
|
558
|
+
return;
|
|
559
|
+
}
|
|
560
|
+
// Printable character — insert at the caret. Ignore control/meta combos.
|
|
561
|
+
if (ch && typeof ch === 'string' && ch.length >= 1 && ch >= ' ' && !(key && (key.ctrl || key.meta))) {
|
|
562
|
+
const ins = Array.from(ch); // paste may deliver multiple chars at once
|
|
563
|
+
inputChars = inputChars.slice(0, inputCursor).concat(ins, inputChars.slice(inputCursor));
|
|
564
|
+
inputCursor += ins.length;
|
|
565
|
+
renderInput();
|
|
566
|
+
opts.onType?.();
|
|
567
|
+
}
|
|
568
|
+
}
|
|
569
|
+
screen.program.on('keypress', onKey);
|
|
570
|
+
function render() { scheduleRender(); }
|
|
571
|
+
function focusInput() { renderInput(); }
|
|
572
|
+
function destroy() {
|
|
573
|
+
if (renderTimer) {
|
|
574
|
+
clearTimeout(renderTimer);
|
|
575
|
+
renderTimer = null;
|
|
576
|
+
}
|
|
577
|
+
try {
|
|
578
|
+
screen.destroy();
|
|
579
|
+
}
|
|
580
|
+
catch { /* */ }
|
|
581
|
+
}
|
|
582
|
+
// Suspend blessed → run on the real terminal → restore. Mirrors the readline
|
|
583
|
+
// REPL's stdin-listener save/restore so handler ora/inquirer/console all work.
|
|
584
|
+
async function runDetached(label, fn) {
|
|
585
|
+
const program = screen.program;
|
|
586
|
+
const stdin = process.stdin;
|
|
587
|
+
const dataL = stdin.rawListeners('data').slice();
|
|
588
|
+
const keyL = stdin.rawListeners('keypress').slice();
|
|
589
|
+
stdin.removeAllListeners('data');
|
|
590
|
+
stdin.removeAllListeners('keypress');
|
|
591
|
+
try {
|
|
592
|
+
program.disableMouse();
|
|
593
|
+
}
|
|
594
|
+
catch { /* */ }
|
|
595
|
+
program.normalBuffer(); // leave the alt screen → normal terminal (output visible)
|
|
596
|
+
program.showCursor();
|
|
597
|
+
if (stdin.setRawMode)
|
|
598
|
+
try {
|
|
599
|
+
stdin.setRawMode(false);
|
|
600
|
+
}
|
|
601
|
+
catch { /* */ }
|
|
602
|
+
stdin.resume();
|
|
603
|
+
process.stdout.write('\n' + chalk.cyan(`── /${label} ──`) + '\n\n');
|
|
604
|
+
try {
|
|
605
|
+
await fn();
|
|
606
|
+
}
|
|
607
|
+
catch (e) {
|
|
608
|
+
process.stdout.write('\n' + chalk.red(` Error: ${e?.message || e}`) + '\n');
|
|
609
|
+
}
|
|
610
|
+
process.stdout.write(chalk.dim('\n ── press Enter to return to AitherShell ──'));
|
|
611
|
+
// fn() may have been an @inquirer prompt flow (interactive commands), which on
|
|
612
|
+
// completion can leave stdin in raw mode AND paused with its own listeners
|
|
613
|
+
// detached — so a plain Enter never produces a 'data' event and the wait hangs
|
|
614
|
+
// (and Ctrl+C, being a raw byte rather than a signal, can't break out either).
|
|
615
|
+
// Re-establish flowing, non-raw, listener-free stdin before waiting so Enter
|
|
616
|
+
// delivers a newline and Ctrl+C raises SIGINT normally.
|
|
617
|
+
stdin.removeAllListeners('data');
|
|
618
|
+
stdin.removeAllListeners('keypress');
|
|
619
|
+
if (stdin.setRawMode)
|
|
620
|
+
try {
|
|
621
|
+
stdin.setRawMode(false);
|
|
622
|
+
}
|
|
623
|
+
catch { /* */ }
|
|
624
|
+
stdin.resume();
|
|
625
|
+
await new Promise((resolve) => {
|
|
626
|
+
const done = () => {
|
|
627
|
+
stdin.removeListener('data', onData);
|
|
628
|
+
process.removeListener('SIGINT', onSig);
|
|
629
|
+
resolve();
|
|
630
|
+
};
|
|
631
|
+
const onData = () => done();
|
|
632
|
+
const onSig = () => done(); // Ctrl+C returns to the shell instead of killing it
|
|
633
|
+
stdin.on('data', onData);
|
|
634
|
+
process.once('SIGINT', onSig);
|
|
635
|
+
});
|
|
636
|
+
// Restore blessed ownership of the terminal.
|
|
637
|
+
stdin.removeAllListeners('data');
|
|
638
|
+
stdin.removeAllListeners('keypress');
|
|
639
|
+
for (const l of dataL)
|
|
640
|
+
stdin.on('data', l);
|
|
641
|
+
for (const l of keyL)
|
|
642
|
+
stdin.on('keypress', l);
|
|
643
|
+
if (stdin.setRawMode)
|
|
644
|
+
try {
|
|
645
|
+
stdin.setRawMode(true);
|
|
646
|
+
}
|
|
647
|
+
catch { /* */ }
|
|
648
|
+
program.alternateBuffer();
|
|
649
|
+
try {
|
|
650
|
+
program.enableMouse();
|
|
651
|
+
}
|
|
652
|
+
catch { /* */ }
|
|
653
|
+
try {
|
|
654
|
+
screen.alloc();
|
|
655
|
+
}
|
|
656
|
+
catch { /* */ }
|
|
657
|
+
screen.render();
|
|
658
|
+
focusInput();
|
|
659
|
+
}
|
|
660
|
+
// ── Command picker overlay (native blessed.list — no inquirer) ──
|
|
661
|
+
function pickerOpen() { return _pickerOpen; }
|
|
662
|
+
function showPicker(title, items, initialFilter = '') {
|
|
663
|
+
return new Promise((resolve) => {
|
|
664
|
+
_pickerOpen = true;
|
|
665
|
+
let filter = initialFilter;
|
|
666
|
+
const box = blessed.list({
|
|
667
|
+
parent: screen, label: ` ${title} `, border: 'line',
|
|
668
|
+
top: 'center', left: 'center', width: '70%', height: '80%',
|
|
669
|
+
keys: true, mouse: true, tags: false, // no vi: letters are for type-to-filter
|
|
670
|
+
scrollbar: { ch: ' ', inverse: true },
|
|
671
|
+
style: { selected: { bg: 'cyan', fg: 'black' }, border: { fg: 'cyan' }, label: { fg: 'cyan' }, item: { fg: 'white' } },
|
|
672
|
+
});
|
|
673
|
+
let view = [];
|
|
674
|
+
function refresh() {
|
|
675
|
+
const term = filter.trim().toLowerCase();
|
|
676
|
+
view = items.filter(it => {
|
|
677
|
+
if (it.separator)
|
|
678
|
+
return !term; // hide separators while filtering
|
|
679
|
+
if (!term)
|
|
680
|
+
return true;
|
|
681
|
+
return it.value.toLowerCase().includes(term)
|
|
682
|
+
|| it.label.toLowerCase().includes(term)
|
|
683
|
+
|| (it.description || '').toLowerCase().includes(term);
|
|
684
|
+
});
|
|
685
|
+
box.setItems(view.map(it => it.separator
|
|
686
|
+
? ` ${it.label}`
|
|
687
|
+
: ` ${it.label}${it.description ? ' — ' + it.description : ''}`));
|
|
688
|
+
box.setLabel(` ${title}${filter ? ' /' + filter : ''} `);
|
|
689
|
+
// Select the first non-separator row.
|
|
690
|
+
const firstReal = view.findIndex(it => !it.separator);
|
|
691
|
+
if (firstReal >= 0)
|
|
692
|
+
box.select(firstReal);
|
|
693
|
+
screen.render();
|
|
694
|
+
}
|
|
695
|
+
function close(result) {
|
|
696
|
+
_pickerOpen = false;
|
|
697
|
+
try {
|
|
698
|
+
box.destroy();
|
|
699
|
+
}
|
|
700
|
+
catch { /* */ }
|
|
701
|
+
screen.render();
|
|
702
|
+
focusInput();
|
|
703
|
+
resolve(result);
|
|
704
|
+
}
|
|
705
|
+
box.on('select', (_item, idx) => {
|
|
706
|
+
const it = view[idx];
|
|
707
|
+
if (it && !it.separator)
|
|
708
|
+
close(it.value);
|
|
709
|
+
});
|
|
710
|
+
box.key(['escape', 'C-c'], () => close(null));
|
|
711
|
+
box.key(['backspace'], () => { filter = filter.slice(0, -1); refresh(); });
|
|
712
|
+
box.on('keypress', (ch, key) => {
|
|
713
|
+
if (!key)
|
|
714
|
+
return;
|
|
715
|
+
if (key.name === 'enter' || key.name === 'return' || key.name === 'up' || key.name === 'down'
|
|
716
|
+
|| key.name === 'pageup' || key.name === 'pagedown' || key.name === 'escape'
|
|
717
|
+
|| key.name === 'backspace' || key.name === 'tab')
|
|
718
|
+
return;
|
|
719
|
+
if (ch && ch.length === 1 && ch >= ' ') {
|
|
720
|
+
filter += ch;
|
|
721
|
+
refresh();
|
|
722
|
+
}
|
|
723
|
+
});
|
|
724
|
+
refresh();
|
|
725
|
+
box.focus();
|
|
726
|
+
screen.render();
|
|
727
|
+
});
|
|
728
|
+
}
|
|
729
|
+
renderInput();
|
|
730
|
+
screen.render();
|
|
731
|
+
return {
|
|
732
|
+
screen, input, appendOutput, outputLine, markCheckpoint, replaceOutputFrom, prependOutput,
|
|
733
|
+
traceLine, startTraceTurn, finishTraceTurn, setStatus, setTracePanel, setOutputLabel, clearPanes, toggleTrace, render, focusInput, destroy,
|
|
734
|
+
pickerOpen, showPicker, runDetached,
|
|
735
|
+
};
|
|
736
|
+
}
|