tsunami-code 3.9.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 +88 -81
- 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';
|
|
@@ -26,7 +26,7 @@ import {
|
|
|
26
26
|
} from './lib/memory.js';
|
|
27
27
|
import { listMemories, readMemory, saveMemory, deleteMemory, getMemdirPath } from './lib/memdir.js';
|
|
28
28
|
|
|
29
|
-
const VERSION = '3.
|
|
29
|
+
const VERSION = '3.10.0';
|
|
30
30
|
const CONFIG_DIR = join(os.homedir(), '.tsunami-code');
|
|
31
31
|
const CONFIG_FILE = join(CONFIG_DIR, 'config.json');
|
|
32
32
|
const DEFAULT_SERVER = 'https://radiometric-reita-amuck.ngrok-free.dev';
|
|
@@ -68,13 +68,38 @@ function printBanner(serverUrl) {
|
|
|
68
68
|
console.log(dim(' International AI Wars\n'));
|
|
69
69
|
}
|
|
70
70
|
|
|
71
|
-
function printToolCall(name, args) {
|
|
71
|
+
function printToolCall(name, args, elapsedMs) {
|
|
72
72
|
let parsed = {};
|
|
73
73
|
try { parsed = JSON.parse(args); } catch {}
|
|
74
74
|
const preview = Object.entries(parsed)
|
|
75
75
|
.map(([k, v]) => `${k}=${JSON.stringify(String(v).slice(0, 60))}`)
|
|
76
76
|
.join(', ');
|
|
77
|
-
|
|
77
|
+
const timing = elapsedMs != null ? chalk.dim(` (${(elapsedMs / 1000).toFixed(1)}s)`) : '';
|
|
78
|
+
process.stdout.write('\n' + dim(` ⚙ ${name}`) + timing + dim(`(${preview})\n`));
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// ── Spinner — shows while waiting for first token ────────────────────────────
|
|
82
|
+
function createSpinner() {
|
|
83
|
+
const frames = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
|
|
84
|
+
const labels = ['thinking', 'pondering', 'reasoning', 'analyzing'];
|
|
85
|
+
let fi = 0, li = 0, ticks = 0, interval = null;
|
|
86
|
+
|
|
87
|
+
const start = () => {
|
|
88
|
+
interval = setInterval(() => {
|
|
89
|
+
ticks++;
|
|
90
|
+
if (ticks % 25 === 0) li = (li + 1) % labels.length; // rotate label every ~2s
|
|
91
|
+
process.stdout.write(`\r ${dim(frames[fi++ % frames.length] + ' ' + labels[li] + '...')} `);
|
|
92
|
+
}, 80);
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
const stop = () => {
|
|
96
|
+
if (!interval) return;
|
|
97
|
+
clearInterval(interval);
|
|
98
|
+
interval = null;
|
|
99
|
+
process.stdout.write('\r' + ' '.repeat(35) + '\r');
|
|
100
|
+
};
|
|
101
|
+
|
|
102
|
+
return { start, stop };
|
|
78
103
|
}
|
|
79
104
|
|
|
80
105
|
function formatBytes(bytes) {
|
|
@@ -202,32 +227,16 @@ if (setServerIdx !== -1 && argv[setServerIdx + 1]) {
|
|
|
202
227
|
}
|
|
203
228
|
|
|
204
229
|
// ── Confirm Callback (dangerous command prompt) ─────────────────────────────
|
|
205
|
-
function makeConfirmCallback(
|
|
230
|
+
function makeConfirmCallback(ui) {
|
|
206
231
|
const cb = async (cmd) => {
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
process.stdout.write(` ${yellow('Proceed?')} ${dim('(y/N) ')}`);
|
|
211
|
-
const handler = (data) => {
|
|
212
|
-
process.stdin.removeListener('data', handler);
|
|
213
|
-
rl.resume();
|
|
214
|
-
process.stdout.write('\n');
|
|
215
|
-
resolve(data.toString().trim().toLowerCase() === 'y');
|
|
216
|
-
};
|
|
217
|
-
process.stdin.once('data', handler);
|
|
218
|
-
});
|
|
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';
|
|
219
235
|
};
|
|
220
236
|
|
|
221
|
-
cb._askUser = (question, resolve) => {
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
const handler = (data) => {
|
|
225
|
-
process.stdin.removeListener('data', handler);
|
|
226
|
-
rl.resume();
|
|
227
|
-
process.stdout.write('\n');
|
|
228
|
-
resolve(data.toString().trim());
|
|
229
|
-
};
|
|
230
|
-
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);
|
|
231
240
|
};
|
|
232
241
|
|
|
233
242
|
return cb;
|
|
@@ -326,7 +335,6 @@ async function run() {
|
|
|
326
335
|
} catch { return []; }
|
|
327
336
|
}
|
|
328
337
|
const historyEntries = loadHistory();
|
|
329
|
-
let historyIdx = -1;
|
|
330
338
|
|
|
331
339
|
// Preflight checks
|
|
332
340
|
process.stdout.write(dim(' Checking server connection...'));
|
|
@@ -477,16 +485,6 @@ async function run() {
|
|
|
477
485
|
return [[], line];
|
|
478
486
|
}
|
|
479
487
|
|
|
480
|
-
const rl = readline.createInterface({
|
|
481
|
-
input: process.stdin,
|
|
482
|
-
output: process.stdout,
|
|
483
|
-
prompt: planMode ? yellow('❯ [plan] ') : cyan('❯ '),
|
|
484
|
-
terminal: process.stdin.isTTY,
|
|
485
|
-
completer: tabCompleter,
|
|
486
|
-
});
|
|
487
|
-
|
|
488
|
-
rl.prompt();
|
|
489
|
-
|
|
490
488
|
let isProcessing = false;
|
|
491
489
|
let pendingClose = false;
|
|
492
490
|
|
|
@@ -494,15 +492,23 @@ async function run() {
|
|
|
494
492
|
function gracefulExit(code = 0) {
|
|
495
493
|
try { endSession(sessionDir); } catch {}
|
|
496
494
|
try { disconnectMcp(); } catch {}
|
|
495
|
+
ui.exitUI();
|
|
497
496
|
console.log(dim('\n Goodbye.\n'));
|
|
498
497
|
process.exit(code);
|
|
499
498
|
}
|
|
500
499
|
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
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
|
+
},
|
|
504
508
|
});
|
|
505
509
|
|
|
510
|
+
ui.start(historyEntries);
|
|
511
|
+
|
|
506
512
|
// ── Memory commands ───────────────────────────────────────────────────────────
|
|
507
513
|
async function handleMemoryCommand(args) {
|
|
508
514
|
const sub = args[0]?.toLowerCase();
|
|
@@ -602,36 +608,32 @@ async function run() {
|
|
|
602
608
|
let mlBuffer = [];
|
|
603
609
|
let mlHeredoc = false;
|
|
604
610
|
|
|
605
|
-
|
|
611
|
+
async function handleLine(input) {
|
|
606
612
|
// ── Multiline: heredoc mode (""" toggle) ──────────────────────────────────
|
|
607
613
|
if (input.trimStart() === '"""' || input.trimStart() === "'''") {
|
|
608
614
|
if (!mlHeredoc) {
|
|
609
615
|
mlHeredoc = true;
|
|
610
|
-
|
|
611
|
-
rl.prompt();
|
|
616
|
+
ui.setContinuation(true);
|
|
612
617
|
return;
|
|
613
618
|
} else {
|
|
614
619
|
// Close heredoc — submit accumulated buffer
|
|
615
620
|
mlHeredoc = false;
|
|
616
621
|
const assembled = mlBuffer.join('\n');
|
|
617
622
|
mlBuffer = [];
|
|
618
|
-
|
|
619
|
-
// Fall through with assembled as the input
|
|
623
|
+
ui.setContinuation(false);
|
|
620
624
|
return _handleInput(assembled);
|
|
621
625
|
}
|
|
622
626
|
}
|
|
623
627
|
|
|
624
628
|
if (mlHeredoc) {
|
|
625
629
|
mlBuffer.push(input);
|
|
626
|
-
rl.prompt();
|
|
627
630
|
return;
|
|
628
631
|
}
|
|
629
632
|
|
|
630
633
|
// ── Multiline: backslash continuation ─────────────────────────────────────
|
|
631
634
|
if (input.endsWith('\\')) {
|
|
632
635
|
mlBuffer.push(input.slice(0, -1));
|
|
633
|
-
|
|
634
|
-
rl.prompt();
|
|
636
|
+
ui.setContinuation(true);
|
|
635
637
|
return;
|
|
636
638
|
}
|
|
637
639
|
|
|
@@ -640,21 +642,20 @@ async function run() {
|
|
|
640
642
|
mlBuffer.push(input);
|
|
641
643
|
const assembled = mlBuffer.join('\n');
|
|
642
644
|
mlBuffer = [];
|
|
643
|
-
|
|
645
|
+
ui.setContinuation(false);
|
|
644
646
|
return _handleInput(assembled);
|
|
645
647
|
}
|
|
646
648
|
|
|
647
649
|
return _handleInput(input);
|
|
648
|
-
}
|
|
650
|
+
}
|
|
649
651
|
|
|
650
652
|
async function _handleInput(input) {
|
|
651
653
|
const line = input.trim();
|
|
652
|
-
if (!line) {
|
|
654
|
+
if (!line) { return; }
|
|
653
655
|
|
|
654
656
|
// Append to persistent history
|
|
655
657
|
if (!line.startsWith('/')) {
|
|
656
658
|
appendHistory(line);
|
|
657
|
-
historyIdx = -1;
|
|
658
659
|
}
|
|
659
660
|
|
|
660
661
|
if (line.startsWith('/')) {
|
|
@@ -671,7 +672,7 @@ async function run() {
|
|
|
671
672
|
: skillMatch.prompt;
|
|
672
673
|
messages.push({ role: 'user', content: userContent });
|
|
673
674
|
const fullMessages = [{ role: 'system', content: systemPrompt }, ...messages];
|
|
674
|
-
|
|
675
|
+
ui.pause(); isProcessing = true;
|
|
675
676
|
process.stdout.write('\n' + dim(` ◈ Running skill: ${skillMatch.skill.name}\n\n`));
|
|
676
677
|
let firstToken = true;
|
|
677
678
|
try {
|
|
@@ -679,10 +680,10 @@ async function run() {
|
|
|
679
680
|
if (firstToken) { process.stdout.write(' '); firstToken = false; }
|
|
680
681
|
process.stdout.write(token);
|
|
681
682
|
}, (name, args) => { printToolCall(name, args); firstToken = true; },
|
|
682
|
-
{ sessionDir, cwd, planMode }, makeConfirmCallback(
|
|
683
|
+
{ sessionDir, cwd, planMode }, makeConfirmCallback(ui));
|
|
683
684
|
process.stdout.write('\n\n');
|
|
684
685
|
} catch(e) { console.error(red(` Error: ${e.message}\n`)); }
|
|
685
|
-
isProcessing = false;
|
|
686
|
+
isProcessing = false; ui.resume();
|
|
686
687
|
return;
|
|
687
688
|
}
|
|
688
689
|
|
|
@@ -733,7 +734,7 @@ async function run() {
|
|
|
733
734
|
planMode = !planMode;
|
|
734
735
|
if (planMode) console.log(yellow(' Plan mode ON — read-only, no writes or execution.\n'));
|
|
735
736
|
else console.log(green(' Plan mode OFF — full capabilities restored.\n'));
|
|
736
|
-
|
|
737
|
+
ui.setPlanMode(planMode);
|
|
737
738
|
break;
|
|
738
739
|
case 'undo': {
|
|
739
740
|
const restored = undo();
|
|
@@ -1149,7 +1150,7 @@ Output ONLY the TSUNAMI.md content, starting with "# Project: <name>"`;
|
|
|
1149
1150
|
default:
|
|
1150
1151
|
console.log(red(` Unknown command: /${cmd}\n`));
|
|
1151
1152
|
}
|
|
1152
|
-
|
|
1153
|
+
ui.resume();
|
|
1153
1154
|
return;
|
|
1154
1155
|
}
|
|
1155
1156
|
|
|
@@ -1181,33 +1182,52 @@ Output ONLY the TSUNAMI.md content, starting with "# Project: <name>"`;
|
|
|
1181
1182
|
];
|
|
1182
1183
|
messages.push({ role: 'user', content: userContent });
|
|
1183
1184
|
|
|
1184
|
-
|
|
1185
|
+
ui.pause();
|
|
1185
1186
|
isProcessing = true;
|
|
1186
1187
|
process.stdout.write('\n');
|
|
1187
1188
|
|
|
1188
1189
|
// Open a new undo turn — all Write/Edit calls during this turn grouped together
|
|
1189
1190
|
beginUndoTurn();
|
|
1190
1191
|
|
|
1192
|
+
const spinner = createSpinner();
|
|
1193
|
+
const turnStart = Date.now();
|
|
1191
1194
|
let firstToken = true;
|
|
1195
|
+
let toolTimers = {}; // track per-tool duration
|
|
1196
|
+
|
|
1197
|
+
spinner.start();
|
|
1192
1198
|
const highlight = createHighlighter((s) => process.stdout.write(s));
|
|
1199
|
+
|
|
1193
1200
|
try {
|
|
1194
1201
|
await agentLoop(
|
|
1195
1202
|
currentServerUrl,
|
|
1196
1203
|
fullMessages,
|
|
1197
1204
|
(token) => {
|
|
1198
|
-
if (firstToken) {
|
|
1205
|
+
if (firstToken) {
|
|
1206
|
+
spinner.stop();
|
|
1207
|
+
process.stdout.write(' ');
|
|
1208
|
+
firstToken = false;
|
|
1209
|
+
}
|
|
1199
1210
|
highlight(token);
|
|
1200
1211
|
},
|
|
1201
1212
|
(toolName, toolArgs) => {
|
|
1202
1213
|
flushHighlighter(highlight);
|
|
1203
|
-
|
|
1214
|
+
spinner.stop();
|
|
1215
|
+
const elapsed = toolTimers[toolName] != null ? Date.now() - toolTimers[toolName] : null;
|
|
1216
|
+
toolTimers[toolName] = Date.now();
|
|
1217
|
+
printToolCall(toolName, toolArgs, elapsed);
|
|
1218
|
+
spinner.start();
|
|
1204
1219
|
firstToken = true;
|
|
1205
1220
|
},
|
|
1206
1221
|
{ sessionDir, cwd, planMode },
|
|
1207
|
-
makeConfirmCallback(
|
|
1222
|
+
makeConfirmCallback(ui)
|
|
1208
1223
|
);
|
|
1224
|
+
spinner.stop();
|
|
1209
1225
|
flushHighlighter(highlight);
|
|
1210
1226
|
|
|
1227
|
+
const elapsed = ((Date.now() - turnStart) / 1000).toFixed(1);
|
|
1228
|
+
const tok = tokenStats.output > 0 ? ` · ${tokenStats.output - (_outputTokens || 0)} tok` : '';
|
|
1229
|
+
process.stdout.write(dim(`\n ↳ ${elapsed}s${tok}\n`));
|
|
1230
|
+
|
|
1211
1231
|
// Token estimation
|
|
1212
1232
|
const inputChars = fullMessages.reduce((s, m) => s + (typeof m.content === 'string' ? m.content.length : 0), 0);
|
|
1213
1233
|
_inputTokens += Math.round(inputChars / 4);
|
|
@@ -1230,8 +1250,9 @@ Output ONLY the TSUNAMI.md content, starting with "# Project: <name>"`;
|
|
|
1230
1250
|
const newSkills = loadSkills(cwd);
|
|
1231
1251
|
if (newSkills.length !== skills.length) skills = newSkills;
|
|
1232
1252
|
|
|
1233
|
-
process.stdout.write('\n
|
|
1253
|
+
process.stdout.write('\n');
|
|
1234
1254
|
} catch (e) {
|
|
1255
|
+
spinner.stop();
|
|
1235
1256
|
process.stdout.write('\n');
|
|
1236
1257
|
console.error(red(` Error: ${e.message}\n`));
|
|
1237
1258
|
}
|
|
@@ -1241,27 +1262,13 @@ Output ONLY the TSUNAMI.md content, starting with "# Project: <name>"`;
|
|
|
1241
1262
|
gracefulExit(0);
|
|
1242
1263
|
return;
|
|
1243
1264
|
}
|
|
1244
|
-
|
|
1245
|
-
rl.prompt();
|
|
1265
|
+
ui.resume();
|
|
1246
1266
|
}
|
|
1247
1267
|
|
|
1268
|
+
// SIGINT from kill -INT (keyboard Ctrl+C is handled in raw mode inside ui.js)
|
|
1248
1269
|
process.on('SIGINT', () => {
|
|
1249
|
-
if (!isProcessing)
|
|
1250
|
-
|
|
1251
|
-
} else {
|
|
1252
|
-
// Remove interrupted command from history (from history.ts removeLastFromHistory)
|
|
1253
|
-
try {
|
|
1254
|
-
if (existsSync(HISTORY_FILE)) {
|
|
1255
|
-
const lines = readFileSync(HISTORY_FILE, 'utf8').trimEnd().split('\n').filter(Boolean);
|
|
1256
|
-
if (lines.length > 0) lines.pop();
|
|
1257
|
-
import('fs').then(({ writeFileSync: wfs }) => {
|
|
1258
|
-
try { wfs(HISTORY_FILE, lines.join('\n') + (lines.length ? '\n' : ''), 'utf8'); } catch {}
|
|
1259
|
-
});
|
|
1260
|
-
}
|
|
1261
|
-
} catch {}
|
|
1262
|
-
pendingClose = true;
|
|
1263
|
-
console.log(dim('\n (interrupted)\n'));
|
|
1264
|
-
}
|
|
1270
|
+
if (!isProcessing) gracefulExit(0);
|
|
1271
|
+
else pendingClose = true;
|
|
1265
1272
|
});
|
|
1266
1273
|
}
|
|
1267
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
|
+
}
|