tsunami-code 3.10.0 → 3.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/index.js +37 -75
- package/lib/ui.js +354 -0
- package/package.json +1 -1
package/index.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
import
|
|
2
|
+
import { createUI } from './lib/ui.js';
|
|
3
3
|
import chalk from 'chalk';
|
|
4
4
|
import { existsSync, readFileSync, writeFileSync, mkdirSync, readdirSync } from 'fs';
|
|
5
5
|
import { join } from 'path';
|
|
@@ -227,32 +227,16 @@ if (setServerIdx !== -1 && argv[setServerIdx + 1]) {
|
|
|
227
227
|
}
|
|
228
228
|
|
|
229
229
|
// ── Confirm Callback (dangerous command prompt) ─────────────────────────────
|
|
230
|
-
function makeConfirmCallback(
|
|
230
|
+
function makeConfirmCallback(ui) {
|
|
231
231
|
const cb = async (cmd) => {
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
process.stdout.write(` ${yellow('Proceed?')} ${dim('(y/N) ')}`);
|
|
236
|
-
const handler = (data) => {
|
|
237
|
-
process.stdin.removeListener('data', handler);
|
|
238
|
-
rl.resume();
|
|
239
|
-
process.stdout.write('\n');
|
|
240
|
-
resolve(data.toString().trim().toLowerCase() === 'y');
|
|
241
|
-
};
|
|
242
|
-
process.stdin.once('data', handler);
|
|
243
|
-
});
|
|
232
|
+
process.stdout.write(`\n ${yellow('⚠ Dangerous:')} ${dim(cmd.slice(0, 120))}\n`);
|
|
233
|
+
const ch = await ui.readChar(` ${yellow('Proceed?')} ${dim('(y/N) ')}`);
|
|
234
|
+
return ch.toLowerCase() === 'y';
|
|
244
235
|
};
|
|
245
236
|
|
|
246
|
-
cb._askUser = (question, resolve) => {
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
const handler = (data) => {
|
|
250
|
-
process.stdin.removeListener('data', handler);
|
|
251
|
-
rl.resume();
|
|
252
|
-
process.stdout.write('\n');
|
|
253
|
-
resolve(data.toString().trim());
|
|
254
|
-
};
|
|
255
|
-
process.stdin.once('data', handler);
|
|
237
|
+
cb._askUser = async (question, resolve) => {
|
|
238
|
+
const answer = await ui.readLine(`\n ${cyan('?')} ${question}\n ${dim('> ')}`);
|
|
239
|
+
resolve(answer);
|
|
256
240
|
};
|
|
257
241
|
|
|
258
242
|
return cb;
|
|
@@ -351,7 +335,6 @@ async function run() {
|
|
|
351
335
|
} catch { return []; }
|
|
352
336
|
}
|
|
353
337
|
const historyEntries = loadHistory();
|
|
354
|
-
let historyIdx = -1;
|
|
355
338
|
|
|
356
339
|
// Preflight checks
|
|
357
340
|
process.stdout.write(dim(' Checking server connection...'));
|
|
@@ -502,16 +485,6 @@ async function run() {
|
|
|
502
485
|
return [[], line];
|
|
503
486
|
}
|
|
504
487
|
|
|
505
|
-
const rl = readline.createInterface({
|
|
506
|
-
input: process.stdin,
|
|
507
|
-
output: process.stdout,
|
|
508
|
-
prompt: planMode ? yellow('❯ [plan] ') : cyan('❯ '),
|
|
509
|
-
terminal: process.stdin.isTTY,
|
|
510
|
-
completer: tabCompleter,
|
|
511
|
-
});
|
|
512
|
-
|
|
513
|
-
rl.prompt();
|
|
514
|
-
|
|
515
488
|
let isProcessing = false;
|
|
516
489
|
let pendingClose = false;
|
|
517
490
|
|
|
@@ -519,15 +492,23 @@ async function run() {
|
|
|
519
492
|
function gracefulExit(code = 0) {
|
|
520
493
|
try { endSession(sessionDir); } catch {}
|
|
521
494
|
try { disconnectMcp(); } catch {}
|
|
495
|
+
ui.exitUI();
|
|
522
496
|
console.log(dim('\n Goodbye.\n'));
|
|
523
497
|
process.exit(code);
|
|
524
498
|
}
|
|
525
499
|
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
500
|
+
const ui = createUI({
|
|
501
|
+
planMode,
|
|
502
|
+
onLine: handleLine,
|
|
503
|
+
onTab: tabCompleter,
|
|
504
|
+
onExit: (code) => {
|
|
505
|
+
if (isProcessing) { pendingClose = true; return; }
|
|
506
|
+
gracefulExit(code);
|
|
507
|
+
},
|
|
529
508
|
});
|
|
530
509
|
|
|
510
|
+
ui.start(historyEntries);
|
|
511
|
+
|
|
531
512
|
// ── Memory commands ───────────────────────────────────────────────────────────
|
|
532
513
|
async function handleMemoryCommand(args) {
|
|
533
514
|
const sub = args[0]?.toLowerCase();
|
|
@@ -627,36 +608,32 @@ async function run() {
|
|
|
627
608
|
let mlBuffer = [];
|
|
628
609
|
let mlHeredoc = false;
|
|
629
610
|
|
|
630
|
-
|
|
611
|
+
async function handleLine(input) {
|
|
631
612
|
// ── Multiline: heredoc mode (""" toggle) ──────────────────────────────────
|
|
632
613
|
if (input.trimStart() === '"""' || input.trimStart() === "'''") {
|
|
633
614
|
if (!mlHeredoc) {
|
|
634
615
|
mlHeredoc = true;
|
|
635
|
-
|
|
636
|
-
rl.prompt();
|
|
616
|
+
ui.setContinuation(true);
|
|
637
617
|
return;
|
|
638
618
|
} else {
|
|
639
619
|
// Close heredoc — submit accumulated buffer
|
|
640
620
|
mlHeredoc = false;
|
|
641
621
|
const assembled = mlBuffer.join('\n');
|
|
642
622
|
mlBuffer = [];
|
|
643
|
-
|
|
644
|
-
// Fall through with assembled as the input
|
|
623
|
+
ui.setContinuation(false);
|
|
645
624
|
return _handleInput(assembled);
|
|
646
625
|
}
|
|
647
626
|
}
|
|
648
627
|
|
|
649
628
|
if (mlHeredoc) {
|
|
650
629
|
mlBuffer.push(input);
|
|
651
|
-
rl.prompt();
|
|
652
630
|
return;
|
|
653
631
|
}
|
|
654
632
|
|
|
655
633
|
// ── Multiline: backslash continuation ─────────────────────────────────────
|
|
656
634
|
if (input.endsWith('\\')) {
|
|
657
635
|
mlBuffer.push(input.slice(0, -1));
|
|
658
|
-
|
|
659
|
-
rl.prompt();
|
|
636
|
+
ui.setContinuation(true);
|
|
660
637
|
return;
|
|
661
638
|
}
|
|
662
639
|
|
|
@@ -665,21 +642,20 @@ async function run() {
|
|
|
665
642
|
mlBuffer.push(input);
|
|
666
643
|
const assembled = mlBuffer.join('\n');
|
|
667
644
|
mlBuffer = [];
|
|
668
|
-
|
|
645
|
+
ui.setContinuation(false);
|
|
669
646
|
return _handleInput(assembled);
|
|
670
647
|
}
|
|
671
648
|
|
|
672
649
|
return _handleInput(input);
|
|
673
|
-
}
|
|
650
|
+
}
|
|
674
651
|
|
|
675
652
|
async function _handleInput(input) {
|
|
676
653
|
const line = input.trim();
|
|
677
|
-
if (!line) {
|
|
654
|
+
if (!line) { return; }
|
|
678
655
|
|
|
679
656
|
// Append to persistent history
|
|
680
657
|
if (!line.startsWith('/')) {
|
|
681
658
|
appendHistory(line);
|
|
682
|
-
historyIdx = -1;
|
|
683
659
|
}
|
|
684
660
|
|
|
685
661
|
if (line.startsWith('/')) {
|
|
@@ -696,7 +672,7 @@ async function run() {
|
|
|
696
672
|
: skillMatch.prompt;
|
|
697
673
|
messages.push({ role: 'user', content: userContent });
|
|
698
674
|
const fullMessages = [{ role: 'system', content: systemPrompt }, ...messages];
|
|
699
|
-
|
|
675
|
+
ui.pause(); isProcessing = true;
|
|
700
676
|
process.stdout.write('\n' + dim(` ◈ Running skill: ${skillMatch.skill.name}\n\n`));
|
|
701
677
|
let firstToken = true;
|
|
702
678
|
try {
|
|
@@ -704,10 +680,10 @@ async function run() {
|
|
|
704
680
|
if (firstToken) { process.stdout.write(' '); firstToken = false; }
|
|
705
681
|
process.stdout.write(token);
|
|
706
682
|
}, (name, args) => { printToolCall(name, args); firstToken = true; },
|
|
707
|
-
{ sessionDir, cwd, planMode }, makeConfirmCallback(
|
|
683
|
+
{ sessionDir, cwd, planMode }, makeConfirmCallback(ui));
|
|
708
684
|
process.stdout.write('\n\n');
|
|
709
685
|
} catch(e) { console.error(red(` Error: ${e.message}\n`)); }
|
|
710
|
-
isProcessing = false;
|
|
686
|
+
isProcessing = false; ui.resume();
|
|
711
687
|
return;
|
|
712
688
|
}
|
|
713
689
|
|
|
@@ -758,7 +734,7 @@ async function run() {
|
|
|
758
734
|
planMode = !planMode;
|
|
759
735
|
if (planMode) console.log(yellow(' Plan mode ON — read-only, no writes or execution.\n'));
|
|
760
736
|
else console.log(green(' Plan mode OFF — full capabilities restored.\n'));
|
|
761
|
-
|
|
737
|
+
ui.setPlanMode(planMode);
|
|
762
738
|
break;
|
|
763
739
|
case 'undo': {
|
|
764
740
|
const restored = undo();
|
|
@@ -1174,7 +1150,7 @@ Output ONLY the TSUNAMI.md content, starting with "# Project: <name>"`;
|
|
|
1174
1150
|
default:
|
|
1175
1151
|
console.log(red(` Unknown command: /${cmd}\n`));
|
|
1176
1152
|
}
|
|
1177
|
-
|
|
1153
|
+
ui.resume();
|
|
1178
1154
|
return;
|
|
1179
1155
|
}
|
|
1180
1156
|
|
|
@@ -1206,7 +1182,7 @@ Output ONLY the TSUNAMI.md content, starting with "# Project: <name>"`;
|
|
|
1206
1182
|
];
|
|
1207
1183
|
messages.push({ role: 'user', content: userContent });
|
|
1208
1184
|
|
|
1209
|
-
|
|
1185
|
+
ui.pause();
|
|
1210
1186
|
isProcessing = true;
|
|
1211
1187
|
process.stdout.write('\n');
|
|
1212
1188
|
|
|
@@ -1243,7 +1219,7 @@ Output ONLY the TSUNAMI.md content, starting with "# Project: <name>"`;
|
|
|
1243
1219
|
firstToken = true;
|
|
1244
1220
|
},
|
|
1245
1221
|
{ sessionDir, cwd, planMode },
|
|
1246
|
-
makeConfirmCallback(
|
|
1222
|
+
makeConfirmCallback(ui)
|
|
1247
1223
|
);
|
|
1248
1224
|
spinner.stop();
|
|
1249
1225
|
flushHighlighter(highlight);
|
|
@@ -1286,27 +1262,13 @@ Output ONLY the TSUNAMI.md content, starting with "# Project: <name>"`;
|
|
|
1286
1262
|
gracefulExit(0);
|
|
1287
1263
|
return;
|
|
1288
1264
|
}
|
|
1289
|
-
|
|
1290
|
-
rl.prompt();
|
|
1265
|
+
ui.resume();
|
|
1291
1266
|
}
|
|
1292
1267
|
|
|
1268
|
+
// SIGINT from kill -INT (keyboard Ctrl+C is handled in raw mode inside ui.js)
|
|
1293
1269
|
process.on('SIGINT', () => {
|
|
1294
|
-
if (!isProcessing)
|
|
1295
|
-
|
|
1296
|
-
} else {
|
|
1297
|
-
// Remove interrupted command from history (from history.ts removeLastFromHistory)
|
|
1298
|
-
try {
|
|
1299
|
-
if (existsSync(HISTORY_FILE)) {
|
|
1300
|
-
const lines = readFileSync(HISTORY_FILE, 'utf8').trimEnd().split('\n').filter(Boolean);
|
|
1301
|
-
if (lines.length > 0) lines.pop();
|
|
1302
|
-
import('fs').then(({ writeFileSync: wfs }) => {
|
|
1303
|
-
try { wfs(HISTORY_FILE, lines.join('\n') + (lines.length ? '\n' : ''), 'utf8'); } catch {}
|
|
1304
|
-
});
|
|
1305
|
-
}
|
|
1306
|
-
} catch {}
|
|
1307
|
-
pendingClose = true;
|
|
1308
|
-
console.log(dim('\n (interrupted)\n'));
|
|
1309
|
-
}
|
|
1270
|
+
if (!isProcessing) gracefulExit(0);
|
|
1271
|
+
else pendingClose = true;
|
|
1310
1272
|
});
|
|
1311
1273
|
}
|
|
1312
1274
|
|
package/lib/ui.js
ADDED
|
@@ -0,0 +1,354 @@
|
|
|
1
|
+
// lib/ui.js — sticky-bottom terminal UI
|
|
2
|
+
// Pins a 3-line input box at the bottom of the terminal.
|
|
3
|
+
// All output (model replies, tool calls, spinner) scrolls above it.
|
|
4
|
+
// Uses ANSI scroll region (\x1b[top;botr) so the bottom box never scrolls.
|
|
5
|
+
|
|
6
|
+
import chalk from 'chalk';
|
|
7
|
+
|
|
8
|
+
const cyan = (s) => chalk.cyan(s);
|
|
9
|
+
const yellow = (s) => chalk.yellow(s);
|
|
10
|
+
const dim = (s) => chalk.dim(s);
|
|
11
|
+
|
|
12
|
+
const BOX_LINES = 3; // ╭─ header ─╮ / │ ❯ input │ / ╰──────────╯
|
|
13
|
+
|
|
14
|
+
export function createUI({ planMode: initPlanMode = false, onLine, onTab, onExit }) {
|
|
15
|
+
let planMode = initPlanMode;
|
|
16
|
+
let continuation = false; // true → show ··· prompt
|
|
17
|
+
let inputBuf = '';
|
|
18
|
+
let cursorPos = 0;
|
|
19
|
+
let history = []; // strings, oldest→newest
|
|
20
|
+
let histIdx = 0; // points past end when not navigating
|
|
21
|
+
let histDraft = ''; // saved current input while browsing history
|
|
22
|
+
let processing = false;
|
|
23
|
+
let lineHandler = null; // set during readLine / readChar
|
|
24
|
+
let _interrupted = false;
|
|
25
|
+
|
|
26
|
+
const rows = () => process.stdout.rows || 24;
|
|
27
|
+
const cols = () => process.stdout.columns || 80;
|
|
28
|
+
|
|
29
|
+
// ── Scroll region ──────────────────────────────────────────────────────────
|
|
30
|
+
// Confines terminal scroll to rows 1..(rows-BOX_LINES).
|
|
31
|
+
// The bottom BOX_LINES rows sit outside the scroll region and stay fixed.
|
|
32
|
+
|
|
33
|
+
function setScrollRegion() {
|
|
34
|
+
process.stdout.write(`\x1b[1;${rows() - BOX_LINES}r`);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function resetScrollRegion() {
|
|
38
|
+
process.stdout.write('\x1b[r');
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function goOutputArea() {
|
|
42
|
+
// Place cursor at the last line of the scroll region so next write lands there.
|
|
43
|
+
process.stdout.write(`\x1b[${rows() - BOX_LINES};1H`);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// ── Box drawing ────────────────────────────────────────────────────────────
|
|
47
|
+
|
|
48
|
+
function drawBox() {
|
|
49
|
+
const w = cols();
|
|
50
|
+
const r = rows();
|
|
51
|
+
const r1 = r - BOX_LINES + 1; // top border row
|
|
52
|
+
const r2 = r - BOX_LINES + 2; // input row
|
|
53
|
+
const r3 = r - BOX_LINES + 3; // bottom border row
|
|
54
|
+
|
|
55
|
+
// Save cursor so we can restore it after drawing.
|
|
56
|
+
process.stdout.write('\x1b[s');
|
|
57
|
+
|
|
58
|
+
// Top border ─────────────────────────────────────────
|
|
59
|
+
const label = ' tsunami ';
|
|
60
|
+
const rightFill = Math.max(0, w - 2 - 1 - label.length);
|
|
61
|
+
const topLine = `╭─${label}${'─'.repeat(rightFill)}╮`;
|
|
62
|
+
process.stdout.write(`\x1b[${r1};1H\x1b[2K${topLine.slice(0, w)}`);
|
|
63
|
+
|
|
64
|
+
// Input line ──────────────────────────────────────────
|
|
65
|
+
let prefix;
|
|
66
|
+
if (continuation) prefix = ` ${dim('···')} `;
|
|
67
|
+
else if (planMode) prefix = ` ${yellow('❯')} ${dim('[plan]')} `;
|
|
68
|
+
else prefix = ` ${cyan('❯')} `;
|
|
69
|
+
|
|
70
|
+
// Strip ANSI codes to measure visible width of prefix.
|
|
71
|
+
const prefixVis = prefix.replace(/\x1b\[[0-9;]*m/g, '');
|
|
72
|
+
const innerW = w - 2; // space between │ │
|
|
73
|
+
const maxInput = Math.max(0, innerW - prefixVis.length - 1); // trailing space
|
|
74
|
+
|
|
75
|
+
// Slide window so cursor stays visible.
|
|
76
|
+
let dispInput = inputBuf;
|
|
77
|
+
let dispCursor = cursorPos;
|
|
78
|
+
if (dispInput.length > maxInput) {
|
|
79
|
+
const start = Math.max(0, cursorPos - maxInput + 1);
|
|
80
|
+
dispInput = inputBuf.slice(start);
|
|
81
|
+
dispCursor = cursorPos - start;
|
|
82
|
+
}
|
|
83
|
+
const display = dispInput.slice(0, maxInput);
|
|
84
|
+
const pad = ' '.repeat(Math.max(0, maxInput - display.length));
|
|
85
|
+
process.stdout.write(`\x1b[${r2};1H\x1b[2K│${prefix}${display}${pad} │`);
|
|
86
|
+
|
|
87
|
+
// Bottom border ───────────────────────────────────────
|
|
88
|
+
const botLine = `╰${'─'.repeat(w - 2)}╯`;
|
|
89
|
+
process.stdout.write(`\x1b[${r3};1H\x1b[2K${botLine.slice(0, w)}`);
|
|
90
|
+
|
|
91
|
+
// Restore cursor to correct column in the input row.
|
|
92
|
+
const curCol = 1 + prefixVis.length + 1 + Math.min(dispCursor, display.length);
|
|
93
|
+
process.stdout.write(`\x1b[${r2};${curCol}H`);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// ── Raw key handler ────────────────────────────────────────────────────────
|
|
97
|
+
|
|
98
|
+
function handleKey(chunk) {
|
|
99
|
+
const key = chunk.toString('utf8');
|
|
100
|
+
|
|
101
|
+
// Delegate to temporary line handler (readLine / readChar in progress).
|
|
102
|
+
if (lineHandler) { lineHandler(key); return; }
|
|
103
|
+
|
|
104
|
+
// Ctrl+C ──────────────────────────────────────────────
|
|
105
|
+
if (key === '\x03') {
|
|
106
|
+
if (processing) {
|
|
107
|
+
_interrupted = true;
|
|
108
|
+
process.stdout.write(dim('\n (interrupted)\n'));
|
|
109
|
+
} else if (inputBuf !== '') {
|
|
110
|
+
inputBuf = '';
|
|
111
|
+
cursorPos = 0;
|
|
112
|
+
drawBox();
|
|
113
|
+
} else {
|
|
114
|
+
exitUI();
|
|
115
|
+
onExit(0);
|
|
116
|
+
}
|
|
117
|
+
return;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// Ctrl+D ──────────────────────────────────────────────
|
|
121
|
+
if (key === '\x04') {
|
|
122
|
+
if (!processing && inputBuf === '') { exitUI(); onExit(0); }
|
|
123
|
+
return;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
if (processing) return; // swallow all other keys while model is running
|
|
127
|
+
|
|
128
|
+
// Enter ───────────────────────────────────────────────
|
|
129
|
+
if (key === '\r' || key === '\n') {
|
|
130
|
+
const line = inputBuf;
|
|
131
|
+
if (line || continuation) {
|
|
132
|
+
if (line) {
|
|
133
|
+
history.push(line);
|
|
134
|
+
histIdx = history.length;
|
|
135
|
+
histDraft = '';
|
|
136
|
+
}
|
|
137
|
+
inputBuf = '';
|
|
138
|
+
cursorPos = 0;
|
|
139
|
+
drawBox();
|
|
140
|
+
goOutputArea();
|
|
141
|
+
process.stdout.write('\n');
|
|
142
|
+
onLine(line);
|
|
143
|
+
}
|
|
144
|
+
return;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// Backspace ───────────────────────────────────────────
|
|
148
|
+
if (key === '\x7f' || key === '\x08') {
|
|
149
|
+
if (cursorPos > 0) {
|
|
150
|
+
inputBuf = inputBuf.slice(0, cursorPos - 1) + inputBuf.slice(cursorPos);
|
|
151
|
+
cursorPos--;
|
|
152
|
+
drawBox();
|
|
153
|
+
}
|
|
154
|
+
return;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// Tab ─────────────────────────────────────────────────
|
|
158
|
+
if (key === '\t') {
|
|
159
|
+
if (!onTab) return;
|
|
160
|
+
const [hits] = onTab(inputBuf);
|
|
161
|
+
if (!hits.length) return;
|
|
162
|
+
if (hits.length === 1) {
|
|
163
|
+
inputBuf = hits[0];
|
|
164
|
+
cursorPos = hits[0].length;
|
|
165
|
+
drawBox();
|
|
166
|
+
return;
|
|
167
|
+
}
|
|
168
|
+
// Multiple hits — show list and fill common prefix.
|
|
169
|
+
const prefix = hits.reduce((a, b) => {
|
|
170
|
+
let i = 0;
|
|
171
|
+
while (i < a.length && i < b.length && a[i] === b[i]) i++;
|
|
172
|
+
return a.slice(0, i);
|
|
173
|
+
});
|
|
174
|
+
goOutputArea();
|
|
175
|
+
const shown = hits.slice(0, 8).join(' ') + (hits.length > 8 ? ` …+${hits.length - 8}` : '');
|
|
176
|
+
process.stdout.write('\n ' + dim(shown) + '\n');
|
|
177
|
+
if (prefix.length > inputBuf.length) {
|
|
178
|
+
inputBuf = prefix;
|
|
179
|
+
cursorPos = prefix.length;
|
|
180
|
+
}
|
|
181
|
+
drawBox();
|
|
182
|
+
return;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
// Arrow / navigation escape sequences ─────────────────
|
|
186
|
+
if (key === '\x1b[A') { // up — history back
|
|
187
|
+
if (histIdx > 0) {
|
|
188
|
+
if (histIdx === history.length) histDraft = inputBuf;
|
|
189
|
+
histIdx--;
|
|
190
|
+
inputBuf = history[histIdx] || '';
|
|
191
|
+
cursorPos = inputBuf.length;
|
|
192
|
+
drawBox();
|
|
193
|
+
}
|
|
194
|
+
return;
|
|
195
|
+
}
|
|
196
|
+
if (key === '\x1b[B') { // down — history forward
|
|
197
|
+
if (histIdx < history.length) {
|
|
198
|
+
histIdx++;
|
|
199
|
+
inputBuf = histIdx === history.length ? histDraft : history[histIdx] || '';
|
|
200
|
+
cursorPos = inputBuf.length;
|
|
201
|
+
drawBox();
|
|
202
|
+
}
|
|
203
|
+
return;
|
|
204
|
+
}
|
|
205
|
+
if (key === '\x1b[D') { cursorPos = Math.max(0, cursorPos - 1); drawBox(); return; } // ←
|
|
206
|
+
if (key === '\x1b[C') { cursorPos = Math.min(inputBuf.length, cursorPos+1); drawBox(); return; } // →
|
|
207
|
+
if (key === '\x1b[H' || key === '\x01') { cursorPos = 0; drawBox(); return; } // Home / Ctrl+A
|
|
208
|
+
if (key === '\x1b[F' || key === '\x05') { cursorPos = inputBuf.length; drawBox(); return; } // End / Ctrl+E
|
|
209
|
+
if (key === '\x1b[3~') { // Delete
|
|
210
|
+
if (cursorPos < inputBuf.length) {
|
|
211
|
+
inputBuf = inputBuf.slice(0, cursorPos) + inputBuf.slice(cursorPos + 1);
|
|
212
|
+
drawBox();
|
|
213
|
+
}
|
|
214
|
+
return;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
// Editing shortcuts ────────────────────────────────────
|
|
218
|
+
if (key === '\x0b') { inputBuf = inputBuf.slice(0, cursorPos); drawBox(); return; } // Ctrl+K
|
|
219
|
+
if (key === '\x15') { inputBuf = inputBuf.slice(cursorPos); cursorPos = 0; drawBox(); return; } // Ctrl+U
|
|
220
|
+
if (key === '\x17') { // Ctrl+W — kill word backwards
|
|
221
|
+
const before = inputBuf.slice(0, cursorPos).trimEnd();
|
|
222
|
+
const lastSpace = before.lastIndexOf(' ');
|
|
223
|
+
const newBefore = lastSpace < 0 ? '' : before.slice(0, lastSpace + 1);
|
|
224
|
+
inputBuf = newBefore + inputBuf.slice(cursorPos);
|
|
225
|
+
cursorPos = newBefore.length;
|
|
226
|
+
drawBox();
|
|
227
|
+
return;
|
|
228
|
+
}
|
|
229
|
+
if (key === '\x0c') { // Ctrl+L — clear screen
|
|
230
|
+
process.stdout.write('\x1b[2J\x1b[1;1H');
|
|
231
|
+
setScrollRegion();
|
|
232
|
+
drawBox();
|
|
233
|
+
return;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
// Ignore unhandled escape sequences.
|
|
237
|
+
if (key.startsWith('\x1b')) return;
|
|
238
|
+
|
|
239
|
+
// Printable chars (including multi-byte UTF-8).
|
|
240
|
+
if (key >= ' ' || key.charCodeAt(0) > 127) {
|
|
241
|
+
inputBuf = inputBuf.slice(0, cursorPos) + key + inputBuf.slice(cursorPos);
|
|
242
|
+
cursorPos += key.length;
|
|
243
|
+
drawBox();
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
// ── readLine — full line read during processing (AskUser tool) ────────────
|
|
248
|
+
function readLine(promptText) {
|
|
249
|
+
return new Promise((resolve) => {
|
|
250
|
+
let buf = '';
|
|
251
|
+
process.stdout.write(promptText);
|
|
252
|
+
lineHandler = (key) => {
|
|
253
|
+
if (key === '\r' || key === '\n') {
|
|
254
|
+
lineHandler = null;
|
|
255
|
+
process.stdout.write('\n');
|
|
256
|
+
resolve(buf);
|
|
257
|
+
} else if (key === '\x7f' || key === '\x08') {
|
|
258
|
+
if (buf.length > 0) { buf = buf.slice(0, -1); process.stdout.write('\x08 \x08'); }
|
|
259
|
+
} else if (key === '\x03') {
|
|
260
|
+
lineHandler = null;
|
|
261
|
+
process.stdout.write('\n');
|
|
262
|
+
resolve('');
|
|
263
|
+
} else if (key >= ' ') {
|
|
264
|
+
buf += key;
|
|
265
|
+
process.stdout.write(key);
|
|
266
|
+
}
|
|
267
|
+
};
|
|
268
|
+
});
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
// ── readChar — single-key confirm prompt ──────────────────────────────────
|
|
272
|
+
function readChar(promptText) {
|
|
273
|
+
return new Promise((resolve) => {
|
|
274
|
+
process.stdout.write(promptText);
|
|
275
|
+
lineHandler = (key) => {
|
|
276
|
+
if (key === '\x03' || key === '\r' || key === '\n' || key >= ' ') {
|
|
277
|
+
lineHandler = null;
|
|
278
|
+
const ch = (key === '\r' || key === '\n' || key === '\x03') ? '' : key;
|
|
279
|
+
process.stdout.write(ch + '\n');
|
|
280
|
+
resolve(ch);
|
|
281
|
+
}
|
|
282
|
+
};
|
|
283
|
+
});
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
// ── Public API ─────────────────────────────────────────────────────────────
|
|
287
|
+
|
|
288
|
+
function start(preloadHistory = []) {
|
|
289
|
+
if (!process.stdin.isTTY) return;
|
|
290
|
+
|
|
291
|
+
history = [...preloadHistory]; // oldest→newest
|
|
292
|
+
histIdx = history.length;
|
|
293
|
+
|
|
294
|
+
process.stdin.setRawMode(true);
|
|
295
|
+
process.stdin.resume();
|
|
296
|
+
process.stdin.setEncoding('utf8');
|
|
297
|
+
process.stdin.on('data', handleKey);
|
|
298
|
+
|
|
299
|
+
process.stdout.on('resize', () => {
|
|
300
|
+
setScrollRegion();
|
|
301
|
+
drawBox();
|
|
302
|
+
});
|
|
303
|
+
|
|
304
|
+
// Push content up to make room for the box at the bottom.
|
|
305
|
+
process.stdout.write('\n'.repeat(BOX_LINES));
|
|
306
|
+
setScrollRegion();
|
|
307
|
+
goOutputArea();
|
|
308
|
+
drawBox();
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
function pause() {
|
|
312
|
+
processing = true;
|
|
313
|
+
goOutputArea(); // cursor into scroll region, ready for output
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
function resume() {
|
|
317
|
+
processing = false;
|
|
318
|
+
drawBox(); // cursor back into input box
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
function setPlanMode(val) {
|
|
322
|
+
planMode = val;
|
|
323
|
+
if (!processing) drawBox();
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
function setContinuation(val) {
|
|
327
|
+
continuation = val;
|
|
328
|
+
if (!processing) drawBox();
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
function wasInterrupted() {
|
|
332
|
+
const v = _interrupted;
|
|
333
|
+
_interrupted = false;
|
|
334
|
+
return v;
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
function exitUI() {
|
|
338
|
+
resetScrollRegion();
|
|
339
|
+
// Clear the box lines so the terminal is clean on exit.
|
|
340
|
+
const r = rows();
|
|
341
|
+
for (let i = 0; i < BOX_LINES; i++) {
|
|
342
|
+
process.stdout.write(`\x1b[${r - BOX_LINES + 1 + i};1H\x1b[2K`);
|
|
343
|
+
}
|
|
344
|
+
process.stdout.write(`\x1b[${r - BOX_LINES + 1};1H`);
|
|
345
|
+
try { process.stdin.setRawMode(false); } catch {}
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
function stop(code = 0) {
|
|
349
|
+
exitUI();
|
|
350
|
+
process.exit(code);
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
return { start, pause, resume, setPlanMode, setContinuation, readLine, readChar, wasInterrupted, stop, exitUI };
|
|
354
|
+
}
|