tsunami-code 3.11.7 → 3.11.9

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 +57 -29
  2. package/lib/ui.js +38 -1
  3. package/package.json +1 -1
package/index.js CHANGED
@@ -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.11.7';
29
+ const VERSION = '3.11.9';
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';
@@ -129,6 +129,40 @@ function formatBytes(bytes) {
129
129
  return `${(bytes / 1024 / 1024).toFixed(2)}MB`;
130
130
  }
131
131
 
132
+ // ── Typewriter — drains a char queue at a fixed rate for smooth output ───────
133
+ // charDelay: ms between characters. onFirstChar fires once before first write.
134
+ function createTypewriter(writeFn, charDelay = 12) {
135
+ const queue = [];
136
+ let timer = null;
137
+ let onFirst = null;
138
+ let firstDone = false;
139
+
140
+ function tick() {
141
+ if (queue.length === 0) { timer = null; return; }
142
+ if (!firstDone && onFirst) { onFirst(); firstDone = true; }
143
+ writeFn(queue.shift());
144
+ timer = setTimeout(tick, charDelay);
145
+ }
146
+
147
+ return {
148
+ setOnFirst(fn) { onFirst = fn; },
149
+ push(text) {
150
+ for (const ch of text) queue.push(ch);
151
+ if (!timer) timer = setTimeout(tick, 0); // start immediately
152
+ },
153
+ drainNow() {
154
+ if (timer) { clearTimeout(timer); timer = null; }
155
+ if (!firstDone && queue.length && onFirst) { onFirst(); firstDone = true; }
156
+ while (queue.length) writeFn(queue.shift());
157
+ },
158
+ reset() {
159
+ if (timer) { clearTimeout(timer); timer = null; }
160
+ queue.length = 0;
161
+ firstDone = false;
162
+ },
163
+ };
164
+ }
165
+
132
166
  // ── Syntax highlighter — line-buffered, applied to streaming model output ──────
133
167
  // Buffers tokens until newlines, then applies chalk markup per line.
134
168
  // createHighlighter(write) returns an onToken(token) function.
@@ -186,20 +220,6 @@ function createHighlighter(write) {
186
220
  renderLine(buf.slice(0, nl));
187
221
  buf = buf.slice(nl + 1);
188
222
  }
189
- // Flush partial line at word boundaries so text streams word-by-word
190
- // (skip inside code fences where exact formatting matters)
191
- if (!inFence && buf.length > 0) {
192
- const lastSpace = buf.lastIndexOf(' ');
193
- if (lastSpace > 0) {
194
- const partial = buf.slice(0, lastSpace + 1);
195
- const styled = partial
196
- .replace(/\*\*([^*\n]+)\*\*/g, (_, t) => chalk.bold(t))
197
- .replace(/__([^_\n]+)__/g, (_, t) => chalk.bold(t))
198
- .replace(/`([^`\n]+)`/g, (_, t) => chalk.yellow('`' + t + '`'));
199
- write(styled);
200
- buf = buf.slice(lastSpace + 1);
201
- }
202
- }
203
223
  };
204
224
  }
205
225
 
@@ -810,12 +830,19 @@ async function run() {
810
830
  case 'mode': {
811
831
  const m = rest[0]?.toLowerCase();
812
832
  if (!m) {
813
- console.log(blue('\n Permission modes:\n'));
814
- for (const pm of PERM_MODES) {
815
- const active = pm === permMode ? green(' ← active') : '';
816
- console.log(` ${cyan(pm.padEnd(16))}${active}`);
833
+ // Interactive arrow-key picker
834
+ process.stdout.write(blue('\n Select permission mode:\n\n'));
835
+ const currentIdx = PERM_MODES.indexOf(permMode);
836
+ const selected = await ui.selectFromList(PERM_MODES, currentIdx >= 0 ? currentIdx : 0);
837
+ if (selected) {
838
+ permMode = selected;
839
+ planMode = selected === 'readonly';
840
+ ui.setPlanMode(planMode);
841
+ updateModeLabel();
842
+ console.log(green(`\n Mode: ${selected}\n`));
843
+ } else {
844
+ console.log(dim(' Cancelled.\n'));
817
845
  }
818
- console.log(dim('\n Usage: /mode <name>\n'));
819
846
  break;
820
847
  }
821
848
  if (!PERM_MODES.includes(m)) {
@@ -1290,16 +1317,13 @@ Output ONLY the TSUNAMI.md content, starting with "# Project: <name>"`;
1290
1317
  let toolTimers = {}; // track per-tool duration
1291
1318
 
1292
1319
  spinner.start();
1293
- // Spinner stops the moment first text actually reaches the screen,
1294
- // not on first raw token (highlighter buffers until word boundary).
1295
- const highlight = createHighlighter((s) => {
1296
- if (firstToken) {
1297
- spinner.stop();
1298
- process.stdout.write(' ');
1299
- firstToken = false;
1300
- }
1301
- process.stdout.write(s);
1320
+ const tw = createTypewriter((ch) => process.stdout.write(ch), 12);
1321
+ tw.setOnFirst(() => {
1322
+ spinner.stop();
1323
+ process.stdout.write(' ');
1324
+ firstToken = false;
1302
1325
  });
1326
+ const highlight = createHighlighter((s) => tw.push(s));
1303
1327
 
1304
1328
  try {
1305
1329
  await agentLoop(
@@ -1310,18 +1334,22 @@ Output ONLY the TSUNAMI.md content, starting with "# Project: <name>"`;
1310
1334
  },
1311
1335
  (toolName, toolArgs) => {
1312
1336
  flushHighlighter(highlight);
1337
+ tw.drainNow();
1338
+ tw.reset();
1313
1339
  spinner.stop();
1314
1340
  const elapsed = toolTimers[toolName] != null ? Date.now() - toolTimers[toolName] : null;
1315
1341
  toolTimers[toolName] = Date.now();
1316
1342
  printToolCall(toolName, toolArgs, elapsed);
1317
1343
  spinner.start();
1318
1344
  firstToken = true;
1345
+ tw.setOnFirst(() => { spinner.stop(); process.stdout.write(' '); firstToken = false; });
1319
1346
  },
1320
1347
  { sessionDir, cwd, planMode, permMode },
1321
1348
  makeConfirmCallback(ui)
1322
1349
  );
1323
1350
  spinner.stop();
1324
1351
  flushHighlighter(highlight);
1352
+ tw.drainNow();
1325
1353
 
1326
1354
  const elapsed = ((Date.now() - turnStart) / 1000).toFixed(1);
1327
1355
  const tok = tokenStats.output > 0 ? ` · ${tokenStats.output - (_outputTokens || 0)} tok` : '';
package/lib/ui.js CHANGED
@@ -281,6 +281,43 @@ export function createUI({ planMode: initPlanMode = false, onLine, onTab, onExit
281
281
  });
282
282
  }
283
283
 
284
+ // ── selectFromList — arrow-key interactive picker ─────────────────────────
285
+ function selectFromList(items, initialIdx = 0) {
286
+ return new Promise((resolve) => {
287
+ let idx = initialIdx;
288
+ const n = items.length;
289
+
290
+ function render(isFirst) {
291
+ if (!isFirst) process.stdout.write(`\x1b[${n}A`); // move back up
292
+ for (let i = 0; i < n; i++) {
293
+ const active = i === idx;
294
+ const label = active ? ` ${cyan('❯')} ${items[i]}` : ` ${dim(items[i])}`;
295
+ process.stdout.write(`\x1b[2K${label}\n`);
296
+ }
297
+ }
298
+
299
+ render(true);
300
+
301
+ lineHandler = (key) => {
302
+ if (key === '\x1b[A') { // up
303
+ idx = (idx - 1 + n) % n;
304
+ render(false);
305
+ } else if (key === '\x1b[B') { // down
306
+ idx = (idx + 1) % n;
307
+ render(false);
308
+ } else if (key === '\r' || key === '\n') { // confirm
309
+ lineHandler = null;
310
+ process.stdout.write('\n');
311
+ resolve(items[idx]);
312
+ } else if (key === '\x1b' || key === '\x03') { // cancel
313
+ lineHandler = null;
314
+ process.stdout.write('\n');
315
+ resolve(null);
316
+ }
317
+ };
318
+ });
319
+ }
320
+
284
321
  function readChar(promptText) {
285
322
  return new Promise((resolve) => {
286
323
  process.stdout.write(promptText);
@@ -374,7 +411,7 @@ export function createUI({ planMode: initPlanMode = false, onLine, onTab, onExit
374
411
  return {
375
412
  start, pause, resume,
376
413
  setPlanMode, setContinuation, setModelLabel, setModeLabel,
377
- readLine, readChar,
414
+ readLine, readChar, selectFromList,
378
415
  wasInterrupted, stop, exitUI,
379
416
  };
380
417
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tsunami-code",
3
- "version": "3.11.7",
3
+ "version": "3.11.9",
4
4
  "description": "Tsunami Code CLI — AI coding agent by Keystone World Management Navy Seal Unit XI3",
5
5
  "type": "module",
6
6
  "bin": {