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.
Files changed (3) hide show
  1. package/index.js +88 -81
  2. package/lib/ui.js +354 -0
  3. package/package.json +1 -1
package/index.js CHANGED
@@ -1,5 +1,5 @@
1
1
  #!/usr/bin/env node
2
- import readline from 'readline';
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.9.0';
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
- process.stdout.write('\n' + dim(` ${name}(${preview})\n`));
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(rl) {
230
+ function makeConfirmCallback(ui) {
206
231
  const cb = async (cmd) => {
207
- return new Promise((resolve) => {
208
- rl.pause();
209
- process.stdout.write(`\n ${yellow('⚠ Dangerous:')} ${dim(cmd.slice(0, 120))}\n`);
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
- rl.pause();
223
- process.stdout.write(`\n ${cyan('?')} ${question}\n ${dim('> ')}`);
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
- rl.on('close', () => {
502
- if (isProcessing) { pendingClose = true; return; }
503
- gracefulExit(0);
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
- rl.on('line', async (input) => {
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
- rl.setPrompt(dim('··· '));
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
- rl.setPrompt(planMode ? yellow('❯ [plan] ') : cyan('❯ '));
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
- rl.setPrompt(dim('··· '));
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
- rl.setPrompt(planMode ? yellow('❯ [plan] ') : cyan('❯ '));
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) { rl.prompt(); return; }
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
- rl.pause(); isProcessing = true;
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(rl));
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; rl.resume(); rl.prompt();
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
- rl.setPrompt(planMode ? yellow('❯ [plan] ') : cyan('❯ '));
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
- rl.prompt();
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
- rl.pause();
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) { process.stdout.write(' '); firstToken = false; }
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
- printToolCall(toolName, toolArgs);
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(rl)
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\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
- rl.resume();
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
- gracefulExit(0);
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
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tsunami-code",
3
- "version": "3.9.0",
3
+ "version": "3.11.0",
4
4
  "description": "Tsunami Code CLI — AI coding agent by Keystone World Management Navy Seal Unit XI3",
5
5
  "type": "module",
6
6
  "bin": {