@aitherium/shell-cli 1.1.0 → 1.11.1

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.
@@ -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
+ }