tsunami-code 3.10.0 → 3.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.
- package/index.js +39 -75
- package/lib/ui.js +365 -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,24 @@ 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
|
+
ui.setModelLabel(getModel());
|
|
512
|
+
|
|
531
513
|
// ── Memory commands ───────────────────────────────────────────────────────────
|
|
532
514
|
async function handleMemoryCommand(args) {
|
|
533
515
|
const sub = args[0]?.toLowerCase();
|
|
@@ -627,36 +609,32 @@ async function run() {
|
|
|
627
609
|
let mlBuffer = [];
|
|
628
610
|
let mlHeredoc = false;
|
|
629
611
|
|
|
630
|
-
|
|
612
|
+
async function handleLine(input) {
|
|
631
613
|
// ── Multiline: heredoc mode (""" toggle) ──────────────────────────────────
|
|
632
614
|
if (input.trimStart() === '"""' || input.trimStart() === "'''") {
|
|
633
615
|
if (!mlHeredoc) {
|
|
634
616
|
mlHeredoc = true;
|
|
635
|
-
|
|
636
|
-
rl.prompt();
|
|
617
|
+
ui.setContinuation(true);
|
|
637
618
|
return;
|
|
638
619
|
} else {
|
|
639
620
|
// Close heredoc — submit accumulated buffer
|
|
640
621
|
mlHeredoc = false;
|
|
641
622
|
const assembled = mlBuffer.join('\n');
|
|
642
623
|
mlBuffer = [];
|
|
643
|
-
|
|
644
|
-
// Fall through with assembled as the input
|
|
624
|
+
ui.setContinuation(false);
|
|
645
625
|
return _handleInput(assembled);
|
|
646
626
|
}
|
|
647
627
|
}
|
|
648
628
|
|
|
649
629
|
if (mlHeredoc) {
|
|
650
630
|
mlBuffer.push(input);
|
|
651
|
-
rl.prompt();
|
|
652
631
|
return;
|
|
653
632
|
}
|
|
654
633
|
|
|
655
634
|
// ── Multiline: backslash continuation ─────────────────────────────────────
|
|
656
635
|
if (input.endsWith('\\')) {
|
|
657
636
|
mlBuffer.push(input.slice(0, -1));
|
|
658
|
-
|
|
659
|
-
rl.prompt();
|
|
637
|
+
ui.setContinuation(true);
|
|
660
638
|
return;
|
|
661
639
|
}
|
|
662
640
|
|
|
@@ -665,21 +643,20 @@ async function run() {
|
|
|
665
643
|
mlBuffer.push(input);
|
|
666
644
|
const assembled = mlBuffer.join('\n');
|
|
667
645
|
mlBuffer = [];
|
|
668
|
-
|
|
646
|
+
ui.setContinuation(false);
|
|
669
647
|
return _handleInput(assembled);
|
|
670
648
|
}
|
|
671
649
|
|
|
672
650
|
return _handleInput(input);
|
|
673
|
-
}
|
|
651
|
+
}
|
|
674
652
|
|
|
675
653
|
async function _handleInput(input) {
|
|
676
654
|
const line = input.trim();
|
|
677
|
-
if (!line) {
|
|
655
|
+
if (!line) { return; }
|
|
678
656
|
|
|
679
657
|
// Append to persistent history
|
|
680
658
|
if (!line.startsWith('/')) {
|
|
681
659
|
appendHistory(line);
|
|
682
|
-
historyIdx = -1;
|
|
683
660
|
}
|
|
684
661
|
|
|
685
662
|
if (line.startsWith('/')) {
|
|
@@ -696,7 +673,7 @@ async function run() {
|
|
|
696
673
|
: skillMatch.prompt;
|
|
697
674
|
messages.push({ role: 'user', content: userContent });
|
|
698
675
|
const fullMessages = [{ role: 'system', content: systemPrompt }, ...messages];
|
|
699
|
-
|
|
676
|
+
ui.pause(); isProcessing = true;
|
|
700
677
|
process.stdout.write('\n' + dim(` ◈ Running skill: ${skillMatch.skill.name}\n\n`));
|
|
701
678
|
let firstToken = true;
|
|
702
679
|
try {
|
|
@@ -704,10 +681,10 @@ async function run() {
|
|
|
704
681
|
if (firstToken) { process.stdout.write(' '); firstToken = false; }
|
|
705
682
|
process.stdout.write(token);
|
|
706
683
|
}, (name, args) => { printToolCall(name, args); firstToken = true; },
|
|
707
|
-
{ sessionDir, cwd, planMode }, makeConfirmCallback(
|
|
684
|
+
{ sessionDir, cwd, planMode }, makeConfirmCallback(ui));
|
|
708
685
|
process.stdout.write('\n\n');
|
|
709
686
|
} catch(e) { console.error(red(` Error: ${e.message}\n`)); }
|
|
710
|
-
isProcessing = false;
|
|
687
|
+
isProcessing = false; ui.resume();
|
|
711
688
|
return;
|
|
712
689
|
}
|
|
713
690
|
|
|
@@ -758,7 +735,7 @@ async function run() {
|
|
|
758
735
|
planMode = !planMode;
|
|
759
736
|
if (planMode) console.log(yellow(' Plan mode ON — read-only, no writes or execution.\n'));
|
|
760
737
|
else console.log(green(' Plan mode OFF — full capabilities restored.\n'));
|
|
761
|
-
|
|
738
|
+
ui.setPlanMode(planMode);
|
|
762
739
|
break;
|
|
763
740
|
case 'undo': {
|
|
764
741
|
const restored = undo();
|
|
@@ -997,6 +974,7 @@ async function run() {
|
|
|
997
974
|
case 'model':
|
|
998
975
|
if (rest[0]) {
|
|
999
976
|
setModel(rest[0]);
|
|
977
|
+
ui.setModelLabel(rest[0]);
|
|
1000
978
|
console.log(green(` Model changed to: ${rest[0]}\n`));
|
|
1001
979
|
} else {
|
|
1002
980
|
console.log(dim(` Current model: ${getModel()}\n`));
|
|
@@ -1174,7 +1152,7 @@ Output ONLY the TSUNAMI.md content, starting with "# Project: <name>"`;
|
|
|
1174
1152
|
default:
|
|
1175
1153
|
console.log(red(` Unknown command: /${cmd}\n`));
|
|
1176
1154
|
}
|
|
1177
|
-
|
|
1155
|
+
ui.resume();
|
|
1178
1156
|
return;
|
|
1179
1157
|
}
|
|
1180
1158
|
|
|
@@ -1206,7 +1184,7 @@ Output ONLY the TSUNAMI.md content, starting with "# Project: <name>"`;
|
|
|
1206
1184
|
];
|
|
1207
1185
|
messages.push({ role: 'user', content: userContent });
|
|
1208
1186
|
|
|
1209
|
-
|
|
1187
|
+
ui.pause();
|
|
1210
1188
|
isProcessing = true;
|
|
1211
1189
|
process.stdout.write('\n');
|
|
1212
1190
|
|
|
@@ -1243,7 +1221,7 @@ Output ONLY the TSUNAMI.md content, starting with "# Project: <name>"`;
|
|
|
1243
1221
|
firstToken = true;
|
|
1244
1222
|
},
|
|
1245
1223
|
{ sessionDir, cwd, planMode },
|
|
1246
|
-
makeConfirmCallback(
|
|
1224
|
+
makeConfirmCallback(ui)
|
|
1247
1225
|
);
|
|
1248
1226
|
spinner.stop();
|
|
1249
1227
|
flushHighlighter(highlight);
|
|
@@ -1286,27 +1264,13 @@ Output ONLY the TSUNAMI.md content, starting with "# Project: <name>"`;
|
|
|
1286
1264
|
gracefulExit(0);
|
|
1287
1265
|
return;
|
|
1288
1266
|
}
|
|
1289
|
-
|
|
1290
|
-
rl.prompt();
|
|
1267
|
+
ui.resume();
|
|
1291
1268
|
}
|
|
1292
1269
|
|
|
1270
|
+
// SIGINT from kill -INT (keyboard Ctrl+C is handled in raw mode inside ui.js)
|
|
1293
1271
|
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
|
-
}
|
|
1272
|
+
if (!isProcessing) gracefulExit(0);
|
|
1273
|
+
else pendingClose = true;
|
|
1310
1274
|
});
|
|
1311
1275
|
}
|
|
1312
1276
|
|
package/lib/ui.js
ADDED
|
@@ -0,0 +1,365 @@
|
|
|
1
|
+
// lib/ui.js — sticky-bottom terminal UI
|
|
2
|
+
// Pins a 4-line area at the bottom of the terminal:
|
|
3
|
+
// ╭─ tsunami ───────────────────────────────────────────────╮
|
|
4
|
+
// │ ❯ input │
|
|
5
|
+
// ╰─────────────────────────────────────────────────────────╯
|
|
6
|
+
// model-name (dim)
|
|
7
|
+
// All output (replies, tool calls, spinner) scrolls above.
|
|
8
|
+
// Uses ANSI scroll region so the bottom box never scrolls away.
|
|
9
|
+
|
|
10
|
+
import chalk from 'chalk';
|
|
11
|
+
|
|
12
|
+
const cyan = (s) => chalk.cyan(s);
|
|
13
|
+
const yellow = (s) => chalk.yellow(s);
|
|
14
|
+
const dim = (s) => chalk.dim(s);
|
|
15
|
+
|
|
16
|
+
const BOX_LINES = 4; // top border + input + bottom border + model label
|
|
17
|
+
|
|
18
|
+
export function createUI({ planMode: initPlanMode = false, onLine, onTab, onExit }) {
|
|
19
|
+
let planMode = initPlanMode;
|
|
20
|
+
let continuation = false;
|
|
21
|
+
let modelLabel = '';
|
|
22
|
+
let inputBuf = '';
|
|
23
|
+
let cursorPos = 0;
|
|
24
|
+
let history = [];
|
|
25
|
+
let histIdx = 0;
|
|
26
|
+
let histDraft = '';
|
|
27
|
+
let processing = false;
|
|
28
|
+
let lineHandler = null;
|
|
29
|
+
let _interrupted = false;
|
|
30
|
+
|
|
31
|
+
const rows = () => process.stdout.rows || 24;
|
|
32
|
+
const cols = () => process.stdout.columns || 80;
|
|
33
|
+
|
|
34
|
+
// ── Scroll region ──────────────────────────────────────────────────────────
|
|
35
|
+
// Rows 1 .. (rows-BOX_LINES) scroll. The bottom BOX_LINES rows stay fixed.
|
|
36
|
+
|
|
37
|
+
function setScrollRegion() {
|
|
38
|
+
process.stdout.write(`\x1b[1;${rows() - BOX_LINES}r`);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function resetScrollRegion() {
|
|
42
|
+
process.stdout.write('\x1b[r');
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function goOutputArea() {
|
|
46
|
+
process.stdout.write(`\x1b[${rows() - BOX_LINES};1H`);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// ── Box drawing ────────────────────────────────────────────────────────────
|
|
50
|
+
// Draws using absolute row numbers from the actual terminal bottom.
|
|
51
|
+
|
|
52
|
+
function drawBox() {
|
|
53
|
+
const w = cols();
|
|
54
|
+
const r = rows(); // last row of terminal
|
|
55
|
+
const rT = r - 3; // top border ╭─────╮
|
|
56
|
+
const rI = r - 2; // input line │ ❯ │
|
|
57
|
+
const rB = r - 1; // bottom border ╰─────╯
|
|
58
|
+
const rM = r; // model label
|
|
59
|
+
|
|
60
|
+
process.stdout.write('\x1b[s'); // save cursor
|
|
61
|
+
|
|
62
|
+
// Top border ─────────────────────────────────────────────────────
|
|
63
|
+
const label = ' tsunami ';
|
|
64
|
+
const rightFill = Math.max(0, w - 2 - 1 - label.length);
|
|
65
|
+
const topLine = `╭─${label}${'─'.repeat(rightFill)}╮`;
|
|
66
|
+
process.stdout.write(`\x1b[${rT};1H\x1b[2K${topLine.slice(0, w)}`);
|
|
67
|
+
|
|
68
|
+
// Input line ──────────────────────────────────────────────────────
|
|
69
|
+
let prefix;
|
|
70
|
+
if (continuation) prefix = ` ${dim('···')} `;
|
|
71
|
+
else if (planMode) prefix = ` ${yellow('❯')} ${dim('[plan]')} `;
|
|
72
|
+
else prefix = ` ${cyan('❯')} `;
|
|
73
|
+
|
|
74
|
+
const prefixVis = prefix.replace(/\x1b\[[0-9;]*m/g, '');
|
|
75
|
+
const innerW = w - 2;
|
|
76
|
+
const maxInput = Math.max(0, innerW - prefixVis.length - 1);
|
|
77
|
+
|
|
78
|
+
let dispInput = inputBuf;
|
|
79
|
+
let dispCursor = cursorPos;
|
|
80
|
+
if (dispInput.length > maxInput) {
|
|
81
|
+
const start = Math.max(0, cursorPos - maxInput + 1);
|
|
82
|
+
dispInput = inputBuf.slice(start);
|
|
83
|
+
dispCursor = cursorPos - start;
|
|
84
|
+
}
|
|
85
|
+
const display = dispInput.slice(0, maxInput);
|
|
86
|
+
const pad = ' '.repeat(Math.max(0, maxInput - display.length));
|
|
87
|
+
process.stdout.write(`\x1b[${rI};1H\x1b[2K│${prefix}${display}${pad} │`);
|
|
88
|
+
|
|
89
|
+
// Bottom border ───────────────────────────────────────────────────
|
|
90
|
+
const botLine = `╰${'─'.repeat(w - 2)}╯`;
|
|
91
|
+
process.stdout.write(`\x1b[${rB};1H\x1b[2K${botLine.slice(0, w)}`);
|
|
92
|
+
|
|
93
|
+
// Model label ─────────────────────────────────────────────────────
|
|
94
|
+
const ml = modelLabel ? ` ${dim(modelLabel)}` : '';
|
|
95
|
+
process.stdout.write(`\x1b[${rM};1H\x1b[2K${ml}`);
|
|
96
|
+
|
|
97
|
+
// Position cursor in input line at correct column ─────────────────
|
|
98
|
+
const curCol = 1 + prefixVis.length + 1 + Math.min(dispCursor, display.length);
|
|
99
|
+
process.stdout.write(`\x1b[${rI};${curCol}H`);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// ── Raw key handler ────────────────────────────────────────────────────────
|
|
103
|
+
|
|
104
|
+
function handleKey(chunk) {
|
|
105
|
+
const key = chunk.toString('utf8');
|
|
106
|
+
|
|
107
|
+
if (lineHandler) { lineHandler(key); return; }
|
|
108
|
+
|
|
109
|
+
// Ctrl+C
|
|
110
|
+
if (key === '\x03') {
|
|
111
|
+
if (processing) {
|
|
112
|
+
_interrupted = true;
|
|
113
|
+
process.stdout.write(dim('\n (interrupted)\n'));
|
|
114
|
+
} else if (inputBuf !== '') {
|
|
115
|
+
inputBuf = '';
|
|
116
|
+
cursorPos = 0;
|
|
117
|
+
drawBox();
|
|
118
|
+
} else {
|
|
119
|
+
exitUI();
|
|
120
|
+
onExit(0);
|
|
121
|
+
}
|
|
122
|
+
return;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// Ctrl+D
|
|
126
|
+
if (key === '\x04') {
|
|
127
|
+
if (!processing && inputBuf === '') { exitUI(); onExit(0); }
|
|
128
|
+
return;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
if (processing) return;
|
|
132
|
+
|
|
133
|
+
// Enter
|
|
134
|
+
if (key === '\r' || key === '\n') {
|
|
135
|
+
const line = inputBuf;
|
|
136
|
+
if (line || continuation) {
|
|
137
|
+
if (line) {
|
|
138
|
+
history.push(line);
|
|
139
|
+
histIdx = history.length;
|
|
140
|
+
histDraft = '';
|
|
141
|
+
}
|
|
142
|
+
inputBuf = '';
|
|
143
|
+
cursorPos = 0;
|
|
144
|
+
drawBox();
|
|
145
|
+
goOutputArea();
|
|
146
|
+
process.stdout.write('\n');
|
|
147
|
+
onLine(line);
|
|
148
|
+
}
|
|
149
|
+
return;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// Backspace
|
|
153
|
+
if (key === '\x7f' || key === '\x08') {
|
|
154
|
+
if (cursorPos > 0) {
|
|
155
|
+
inputBuf = inputBuf.slice(0, cursorPos - 1) + inputBuf.slice(cursorPos);
|
|
156
|
+
cursorPos--;
|
|
157
|
+
drawBox();
|
|
158
|
+
}
|
|
159
|
+
return;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// Tab
|
|
163
|
+
if (key === '\t') {
|
|
164
|
+
if (!onTab) return;
|
|
165
|
+
const [hits] = onTab(inputBuf);
|
|
166
|
+
if (!hits.length) return;
|
|
167
|
+
if (hits.length === 1) {
|
|
168
|
+
inputBuf = hits[0];
|
|
169
|
+
cursorPos = hits[0].length;
|
|
170
|
+
drawBox();
|
|
171
|
+
return;
|
|
172
|
+
}
|
|
173
|
+
const prefix = hits.reduce((a, b) => {
|
|
174
|
+
let i = 0;
|
|
175
|
+
while (i < a.length && i < b.length && a[i] === b[i]) i++;
|
|
176
|
+
return a.slice(0, i);
|
|
177
|
+
});
|
|
178
|
+
goOutputArea();
|
|
179
|
+
const shown = hits.slice(0, 8).join(' ') + (hits.length > 8 ? ` …+${hits.length - 8}` : '');
|
|
180
|
+
process.stdout.write('\n ' + dim(shown) + '\n');
|
|
181
|
+
if (prefix.length > inputBuf.length) {
|
|
182
|
+
inputBuf = prefix;
|
|
183
|
+
cursorPos = prefix.length;
|
|
184
|
+
}
|
|
185
|
+
drawBox();
|
|
186
|
+
return;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// Arrow keys / navigation
|
|
190
|
+
if (key === '\x1b[A') {
|
|
191
|
+
if (histIdx > 0) {
|
|
192
|
+
if (histIdx === history.length) histDraft = inputBuf;
|
|
193
|
+
histIdx--;
|
|
194
|
+
inputBuf = history[histIdx] || '';
|
|
195
|
+
cursorPos = inputBuf.length;
|
|
196
|
+
drawBox();
|
|
197
|
+
}
|
|
198
|
+
return;
|
|
199
|
+
}
|
|
200
|
+
if (key === '\x1b[B') {
|
|
201
|
+
if (histIdx < history.length) {
|
|
202
|
+
histIdx++;
|
|
203
|
+
inputBuf = histIdx === history.length ? histDraft : history[histIdx] || '';
|
|
204
|
+
cursorPos = inputBuf.length;
|
|
205
|
+
drawBox();
|
|
206
|
+
}
|
|
207
|
+
return;
|
|
208
|
+
}
|
|
209
|
+
if (key === '\x1b[D') { cursorPos = Math.max(0, cursorPos - 1); drawBox(); return; }
|
|
210
|
+
if (key === '\x1b[C') { cursorPos = Math.min(inputBuf.length, cursorPos+1); drawBox(); return; }
|
|
211
|
+
if (key === '\x1b[H' || key === '\x01') { cursorPos = 0; drawBox(); return; }
|
|
212
|
+
if (key === '\x1b[F' || key === '\x05') { cursorPos = inputBuf.length; drawBox(); return; }
|
|
213
|
+
if (key === '\x1b[3~') {
|
|
214
|
+
if (cursorPos < inputBuf.length) {
|
|
215
|
+
inputBuf = inputBuf.slice(0, cursorPos) + inputBuf.slice(cursorPos + 1);
|
|
216
|
+
drawBox();
|
|
217
|
+
}
|
|
218
|
+
return;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
// Editing shortcuts
|
|
222
|
+
if (key === '\x0b') { inputBuf = inputBuf.slice(0, cursorPos); drawBox(); return; }
|
|
223
|
+
if (key === '\x15') { inputBuf = inputBuf.slice(cursorPos); cursorPos = 0; drawBox(); return; }
|
|
224
|
+
if (key === '\x17') {
|
|
225
|
+
const before = inputBuf.slice(0, cursorPos).trimEnd();
|
|
226
|
+
const lastSpace = before.lastIndexOf(' ');
|
|
227
|
+
const newBefore = lastSpace < 0 ? '' : before.slice(0, lastSpace + 1);
|
|
228
|
+
inputBuf = newBefore + inputBuf.slice(cursorPos);
|
|
229
|
+
cursorPos = newBefore.length;
|
|
230
|
+
drawBox();
|
|
231
|
+
return;
|
|
232
|
+
}
|
|
233
|
+
if (key === '\x0c') {
|
|
234
|
+
process.stdout.write('\x1b[2J\x1b[1;1H');
|
|
235
|
+
setScrollRegion();
|
|
236
|
+
drawBox();
|
|
237
|
+
return;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
if (key.startsWith('\x1b')) return;
|
|
241
|
+
|
|
242
|
+
if (key >= ' ' || key.charCodeAt(0) > 127) {
|
|
243
|
+
inputBuf = inputBuf.slice(0, cursorPos) + key + inputBuf.slice(cursorPos);
|
|
244
|
+
cursorPos += key.length;
|
|
245
|
+
drawBox();
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
// ── readLine / readChar ────────────────────────────────────────────────────
|
|
250
|
+
|
|
251
|
+
function readLine(promptText) {
|
|
252
|
+
return new Promise((resolve) => {
|
|
253
|
+
let buf = '';
|
|
254
|
+
process.stdout.write(promptText);
|
|
255
|
+
lineHandler = (key) => {
|
|
256
|
+
if (key === '\r' || key === '\n') {
|
|
257
|
+
lineHandler = null;
|
|
258
|
+
process.stdout.write('\n');
|
|
259
|
+
resolve(buf);
|
|
260
|
+
} else if (key === '\x7f' || key === '\x08') {
|
|
261
|
+
if (buf.length > 0) { buf = buf.slice(0, -1); process.stdout.write('\x08 \x08'); }
|
|
262
|
+
} else if (key === '\x03') {
|
|
263
|
+
lineHandler = null;
|
|
264
|
+
process.stdout.write('\n');
|
|
265
|
+
resolve('');
|
|
266
|
+
} else if (key >= ' ') {
|
|
267
|
+
buf += key;
|
|
268
|
+
process.stdout.write(key);
|
|
269
|
+
}
|
|
270
|
+
};
|
|
271
|
+
});
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
function readChar(promptText) {
|
|
275
|
+
return new Promise((resolve) => {
|
|
276
|
+
process.stdout.write(promptText);
|
|
277
|
+
lineHandler = (key) => {
|
|
278
|
+
if (key === '\x03' || key === '\r' || key === '\n' || key >= ' ') {
|
|
279
|
+
lineHandler = null;
|
|
280
|
+
const ch = (key === '\r' || key === '\n' || key === '\x03') ? '' : key;
|
|
281
|
+
process.stdout.write(ch + '\n');
|
|
282
|
+
resolve(ch);
|
|
283
|
+
}
|
|
284
|
+
};
|
|
285
|
+
});
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
// ── Public API ─────────────────────────────────────────────────────────────
|
|
289
|
+
|
|
290
|
+
function start(preloadHistory = []) {
|
|
291
|
+
if (!process.stdin.isTTY) return;
|
|
292
|
+
|
|
293
|
+
history = [...preloadHistory];
|
|
294
|
+
histIdx = history.length;
|
|
295
|
+
|
|
296
|
+
process.stdin.setRawMode(true);
|
|
297
|
+
process.stdin.resume();
|
|
298
|
+
process.stdin.setEncoding('utf8');
|
|
299
|
+
process.stdin.on('data', handleKey);
|
|
300
|
+
|
|
301
|
+
process.stdout.on('resize', () => {
|
|
302
|
+
setScrollRegion();
|
|
303
|
+
drawBox();
|
|
304
|
+
});
|
|
305
|
+
|
|
306
|
+
// Push existing content up to make room for the box.
|
|
307
|
+
process.stdout.write('\n'.repeat(BOX_LINES));
|
|
308
|
+
setScrollRegion();
|
|
309
|
+
goOutputArea();
|
|
310
|
+
drawBox();
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
function pause() {
|
|
314
|
+
processing = true;
|
|
315
|
+
goOutputArea();
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
function resume() {
|
|
319
|
+
processing = false;
|
|
320
|
+
drawBox();
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
function setPlanMode(val) {
|
|
324
|
+
planMode = val;
|
|
325
|
+
if (!processing) drawBox();
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
function setContinuation(val) {
|
|
329
|
+
continuation = val;
|
|
330
|
+
if (!processing) drawBox();
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
function setModelLabel(label) {
|
|
334
|
+
modelLabel = label;
|
|
335
|
+
if (!processing) drawBox();
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
function wasInterrupted() {
|
|
339
|
+
const v = _interrupted;
|
|
340
|
+
_interrupted = false;
|
|
341
|
+
return v;
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
function exitUI() {
|
|
345
|
+
resetScrollRegion();
|
|
346
|
+
const r = rows();
|
|
347
|
+
for (let i = 0; i < BOX_LINES; i++) {
|
|
348
|
+
process.stdout.write(`\x1b[${r - BOX_LINES + 1 + i};1H\x1b[2K`);
|
|
349
|
+
}
|
|
350
|
+
process.stdout.write(`\x1b[${r - BOX_LINES + 1};1H`);
|
|
351
|
+
try { process.stdin.setRawMode(false); } catch {}
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
function stop(code = 0) {
|
|
355
|
+
exitUI();
|
|
356
|
+
process.exit(code);
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
return {
|
|
360
|
+
start, pause, resume,
|
|
361
|
+
setPlanMode, setContinuation, setModelLabel,
|
|
362
|
+
readLine, readChar,
|
|
363
|
+
wasInterrupted, stop, exitUI,
|
|
364
|
+
};
|
|
365
|
+
}
|