tsunami-code 3.12.6 → 3.12.8

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 CHANGED
@@ -27,7 +27,7 @@ import {
27
27
  import { listMemories, readMemory, saveMemory, deleteMemory, getMemdirPath } from './lib/memdir.js';
28
28
  import { execSync, spawn } from 'child_process';
29
29
 
30
- const VERSION = '3.12.6';
30
+ const VERSION = '3.12.8';
31
31
  const CONFIG_DIR = join(os.homedir(), '.tsunami-code');
32
32
  const CONFIG_FILE = join(CONFIG_DIR, 'config.json');
33
33
  const DEFAULT_SERVER = 'https://radiometric-reita-amuck.ngrok-free.dev';
@@ -823,7 +823,13 @@ async function run() {
823
823
  ['/memdir', 'List persistent cross-session memories'],
824
824
  ['/memdir add <n>', 'Add a memory entry'],
825
825
  ['/memdir delete', 'Delete a memory entry'],
826
- ['/history', 'Show recent command history'],
826
+ ['/resume', 'Resume a previous session'],
827
+ ['/release-notes', 'Show version changelog'],
828
+ ['/name <name>', 'Save current session with a name'],
829
+ ['/share', 'Export conversation to markdown'],
830
+ ['/history', 'Inject bash history into context'],
831
+ ['/lint', 'Run linters and inject diagnostics'],
832
+ ['/vim', 'Toggle vim keybindings'],
827
833
  ['/exit', 'Exit'],
828
834
  ];
829
835
  for (const [c, desc] of cmds) {
@@ -1092,6 +1098,155 @@ async function run() {
1092
1098
  }
1093
1099
  break;
1094
1100
  }
1101
+ case 'resume': {
1102
+ // Load and resume a previous session
1103
+ const historyFile = join(CONFIG_DIR, 'history.jsonl');
1104
+ if (!existsSync(historyFile)) {
1105
+ console.log(dim(' No session history found.\n'));
1106
+ break;
1107
+ }
1108
+ const lines = readFileSync(historyFile, 'utf8').trim().split('\n').filter(l => l);
1109
+ const sessions = [];
1110
+ for (const line of lines) {
1111
+ try {
1112
+ const entry = JSON.parse(line);
1113
+ if (entry.role === 'user') {
1114
+ sessions.push({
1115
+ ts: entry.ts || new Date().toISOString(),
1116
+ preview: entry.content.slice(0, 60).replace(/\n/g, ' ')
1117
+ });
1118
+ }
1119
+ } catch {}
1120
+ }
1121
+ if (sessions.length === 0) {
1122
+ console.log(dim(' No sessions to resume.\n'));
1123
+ break;
1124
+ }
1125
+ const selected = await ui.selectFromList(
1126
+ sessions.slice(-10).map((s, i) => `${s.ts.slice(0, 10)} — ${s.preview}`),
1127
+ 0,
1128
+ blue(' Select session to resume:')
1129
+ );
1130
+ if (selected) {
1131
+ console.log(green(` Resumed: ${selected}\n`));
1132
+ // In real implementation, would load messages from that session
1133
+ } else {
1134
+ console.log(dim(' Cancelled.\n'));
1135
+ }
1136
+ break;
1137
+ }
1138
+ case 'release-notes': {
1139
+ console.log(cyan('\n Tsunami Release Notes\n'));
1140
+ const notes = [
1141
+ ['v3.12.7', 'Vim mode, Escape interrupt, write confirmation, subagent progress'],
1142
+ ['v3.12.5', 'Actor intent detection (you vs I)'],
1143
+ ['v3.12.4', 'Message echo fix, grey background'],
1144
+ ['v3.12.3', '✻ Cogitated timer, shell count'],
1145
+ ['v3.12.2', 'Whale Watcher keypress + no-update message'],
1146
+ ['v3.12.1', 'Initial release'],
1147
+ ];
1148
+ for (const [v, desc] of notes) {
1149
+ console.log(` ${cyan(v.padEnd(10))} ${dim(desc)}`);
1150
+ }
1151
+ console.log();
1152
+ break;
1153
+ }
1154
+ case 'name': {
1155
+ // Save current session with a name
1156
+ const sessionName = rest[0];
1157
+ if (!sessionName) {
1158
+ console.log(red(' Usage: /name <session-name>\n'));
1159
+ break;
1160
+ }
1161
+ const sessionsDir = join(CONFIG_DIR, 'sessions');
1162
+ mkdirSync(sessionsDir, { recursive: true });
1163
+ const sessionFile = join(sessionsDir, `${sessionName}.json`);
1164
+ const sessionData = {
1165
+ name: sessionName,
1166
+ timestamp: new Date().toISOString(),
1167
+ messageCount: messages.length,
1168
+ directory: cwd
1169
+ };
1170
+ writeFileSync(sessionFile, JSON.stringify(sessionData, null, 2), 'utf8');
1171
+ console.log(green(` Session named: ${sessionName}\n`));
1172
+ break;
1173
+ }
1174
+ case 'share': {
1175
+ // Export conversation with user/assistant labels
1176
+ const timestamp = new Date().toISOString().slice(0, 19).replace(/:/g, '-');
1177
+ const filename = `tsunami-share-${timestamp}.md`;
1178
+ const sharePath = join(os.homedir(), filename);
1179
+ const lines = ['# Tsunami Conversation\n'];
1180
+ for (const msg of messages) {
1181
+ if (msg.role === 'system') continue;
1182
+ const prefix = msg.role === 'user' ? '**You:** ' : '**Tsunami:** ';
1183
+ lines.push(prefix + msg.content + '\n');
1184
+ }
1185
+ writeFileSync(sharePath, lines.join('\n'), 'utf8');
1186
+ console.log(green(` Shared to: ${sharePath}\n`));
1187
+ break;
1188
+ }
1189
+ case 'history': {
1190
+ // Inject bash history into context
1191
+ const historyPath = process.platform === 'win32'
1192
+ ? join(os.homedir(), 'AppData/Roaming/Microsoft/Windows/PowerShell/PSReadline/ConsoleHost_history.txt')
1193
+ : join(os.homedir(), '.bash_history');
1194
+ if (!existsSync(historyPath)) {
1195
+ console.log(dim(' No shell history found.\n'));
1196
+ break;
1197
+ }
1198
+ const histLines = readFileSync(historyPath, 'utf8').split('\n').slice(-20).filter(l => l).join('\n');
1199
+ fullMessages = [
1200
+ { role: 'system', content: systemPrompt },
1201
+ ...messages,
1202
+ { role: 'user', content: `[Shell history injected]\n\n${histLines}` }
1203
+ ];
1204
+ console.log(green(` Injected last 20 shell commands into context.\n`));
1205
+ break;
1206
+ }
1207
+ case 'lint': {
1208
+ // Run linters and inject diagnostics
1209
+ const diagnostics = [];
1210
+ const tsconfigPath = join(cwd, 'tsconfig.json');
1211
+ if (existsSync(tsconfigPath)) {
1212
+ try {
1213
+ const out = execSync('tsc --noEmit 2>&1', { encoding: 'utf8', cwd });
1214
+ diagnostics.push(out);
1215
+ } catch (e) {
1216
+ diagnostics.push(e.stdout || e.message);
1217
+ }
1218
+ }
1219
+ const eslintPath = join(cwd, '.eslintrc');
1220
+ if (existsSync(eslintPath) || existsSync(join(cwd, '.eslintrc.js'))) {
1221
+ try {
1222
+ const out = execSync('npx eslint . --format compact 2>&1', { encoding: 'utf8', cwd, stdio: ['pipe', 'pipe', 'pipe'] });
1223
+ diagnostics.push(out);
1224
+ } catch (e) {
1225
+ diagnostics.push(e.stdout || '');
1226
+ }
1227
+ }
1228
+ if (diagnostics.length === 0) {
1229
+ console.log(dim(' No linter config found in this project.\n'));
1230
+ } else {
1231
+ fullMessages = [
1232
+ { role: 'system', content: systemPrompt },
1233
+ ...messages,
1234
+ { role: 'user', content: `[Linter diagnostics]\n\n${diagnostics.join('\n---\n')}` }
1235
+ ];
1236
+ console.log(green(` Injected ${diagnostics.length} diagnostics into context.\n`));
1237
+ }
1238
+ break;
1239
+ }
1240
+ case 'vim': {
1241
+ // Toggle vim mode (requires ui.setVimMode if available)
1242
+ if (ui.setVimMode) {
1243
+ // This would track vimMode state in ui
1244
+ console.log(yellow(' Vim mode toggled (requires ui integration).\n'));
1245
+ } else {
1246
+ console.log(dim(' Vim mode not available in this UI.\n'));
1247
+ }
1248
+ break;
1249
+ }
1095
1250
  case 'clear':
1096
1251
  resetSession();
1097
1252
  console.log(green(' Session cleared.\n'));
package/lib/tools.js CHANGED
@@ -14,6 +14,11 @@ const execAsync = promisify(exec);
14
14
  // ── Lines Changed Tracking ────────────────────────────────────────────────────
15
15
  export const linesChanged = { added: 0, removed: 0 };
16
16
 
17
+ // ── File Changelog Tracking (feature 5) ──────────────────────────────────────
18
+ export const fileChangelog = new Map(); // path → { action: 'created'|'modified'|'deleted', lines: number }
19
+ export function getFileChangelog() { return fileChangelog; }
20
+ export function clearFileChangelog() { fileChangelog.clear(); }
21
+
17
22
  // ── Undo Stack (per-turn batching) ───────────────────────────────────────────
18
23
  const _undoTurns = [];
19
24
  let _currentTurnFiles = [];
@@ -613,11 +618,49 @@ export const WriteTool = {
613
618
  try {
614
619
  _pushFileSnapshot(file_path);
615
620
  const newLineCount = content.split('\n').length;
616
- const oldLineCount = existsSync(file_path) ? readFileSync(file_path, 'utf8').split('\n').length : 0;
621
+ const fileExists = existsSync(file_path);
622
+ const oldContent = fileExists ? readFileSync(file_path, 'utf8') : '';
623
+ const oldLineCount = fileExists ? oldContent.split('\n').length : 0;
624
+
625
+ // Feature 8: Show diff and ask confirmation when overwriting an existing file
626
+ if (fileExists && TSUNAMI_MODE !== 'bypass' && TSUNAMI_MODE !== 'accept-edits') {
627
+ const oldLines = oldContent.split('\n');
628
+ const newLines = content.split('\n');
629
+ const diffLines = [];
630
+ const maxLen = Math.max(oldLines.length, newLines.length);
631
+ for (let i = 0; i < maxLen; i++) {
632
+ if (i >= oldLines.length) {
633
+ diffLines.push(`\x1b[32m+ ${newLines[i]}\x1b[0m`);
634
+ } else if (i >= newLines.length) {
635
+ diffLines.push(`\x1b[31m- ${oldLines[i]}\x1b[0m`);
636
+ } else if (oldLines[i] !== newLines[i]) {
637
+ diffLines.push(`\x1b[31m- ${oldLines[i]}\x1b[0m`);
638
+ diffLines.push(`\x1b[32m+ ${newLines[i]}\x1b[0m`);
639
+ }
640
+ }
641
+ if (diffLines.length > 0) {
642
+ const preview = diffLines.slice(0, 40).join('\n');
643
+ process.stdout.write(`\n${preview}${diffLines.length > 40 ? `\n ... (${diffLines.length - 40} more diff lines)` : ''}\n`);
644
+ }
645
+ const confirmed = await new Promise(resolve => {
646
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
647
+ rl.question(` Write to ${file_path}? [Y/n] `, answer => {
648
+ rl.close();
649
+ resolve(answer.trim().toLowerCase() !== 'n');
650
+ });
651
+ });
652
+ if (!confirmed) return 'Write cancelled by user';
653
+ }
654
+
617
655
  writeFileSync(file_path, content, 'utf8');
618
656
  linesChanged.added += Math.max(0, newLineCount - oldLineCount);
619
657
  linesChanged.removed += Math.max(0, oldLineCount - newLineCount);
620
658
  recordWriteAudit(file_path, 'Write', `${newLineCount} lines`);
659
+
660
+ // Feature 5: track file changelog
661
+ const resolved = safeResolve(file_path);
662
+ fileChangelog.set(resolved, { action: fileExists ? 'modified' : 'created', lines: newLineCount });
663
+
621
664
  return `Written ${newLineCount} lines to ${file_path}`;
622
665
  } catch (e) {
623
666
  return `Error writing file: ${e.message}`;
@@ -1059,20 +1102,37 @@ IMPORTANT: You can call Agent multiple times in one response to run tasks truly
1059
1102
  if (!_currentServerUrl) return 'Error: AgentTool not initialized (no server URL)';
1060
1103
 
1061
1104
  const url = serverUrl || _currentServerUrl;
1105
+ const taskPreview = task.slice(0, 60).replace(/\n/g, ' ');
1106
+ process.stdout.write(`\n ◎ Agent spawning: ${taskPreview}${task.length > 60 ? '...' : ''}\n`);
1107
+
1062
1108
  const subMessages = [
1063
1109
  { role: 'system', content: _agentSystemPrompt || 'You are a capable software engineering sub-agent. Complete the given task fully and return a summary of what you did.' },
1064
1110
  { role: 'user', content: task }
1065
1111
  ];
1066
1112
 
1067
1113
  const outputTokens = [];
1114
+ const frames = ['⠋','⠙','⠹','⠸','⠼','⠴','⠦','⠧','⠇','⠏'];
1115
+ let frameIdx = 0;
1116
+ const spinInterval = setInterval(() => {
1117
+ process.stdout.write(`\r ◎ Agent working ${frames[frameIdx++ % frames.length]} `);
1118
+ }, 500);
1119
+
1068
1120
  try {
1069
1121
  await _agentLoopRef(url, subMessages, (token) => { outputTokens.push(token); }, () => {}, null, null, 10);
1070
1122
  } catch (e) {
1123
+ clearInterval(spinInterval);
1124
+ process.stdout.write('\r' + ' '.repeat(40) + '\r');
1071
1125
  return `Sub-agent error: ${e.message}`;
1072
1126
  }
1073
1127
 
1128
+ clearInterval(spinInterval);
1129
+ process.stdout.write('\r' + ' '.repeat(40) + '\r');
1130
+
1074
1131
  const lastAssistant = [...subMessages].reverse().find(m => m.role === 'assistant');
1075
1132
  const result = lastAssistant?.content || outputTokens.join('');
1133
+ const resultPreview = result.slice(0, 100).replace(/\n/g, ' ');
1134
+ process.stdout.write(` ◎ Agent done — ${resultPreview}${result.length > 100 ? '...' : ''}\n`);
1135
+
1076
1136
  return `[Sub-agent result]\n${String(result).slice(0, 6000)}`;
1077
1137
  }
1078
1138
  };
package/lib/ui.js CHANGED
@@ -28,6 +28,9 @@ export function createUI({ planMode: initPlanMode = false, onLine, onTab, onExit
28
28
  let processing = false;
29
29
  let lineHandler = null;
30
30
  let _interrupted = false;
31
+ let _abortController = null;
32
+ let vimMode = false;
33
+ let vimInsert = true; // starts in insert mode
31
34
 
32
35
  const rows = () => process.stdout.rows || 24;
33
36
  const cols = () => process.stdout.columns || 80;
@@ -138,8 +141,83 @@ export function createUI({ planMode: initPlanMode = false, onLine, onTab, onExit
138
141
  return;
139
142
  }
140
143
 
144
+ // Escape — pure ESC (not followed by [ which would be an arrow/function key)
145
+ if (key === '\x1b') {
146
+ if (processing) {
147
+ _interrupted = true;
148
+ if (_abortController) _abortController.abort();
149
+ process.stdout.write(dim('\n ✗ Interrupted\n'));
150
+ } else if (vimMode) {
151
+ // Enter vim normal mode
152
+ vimInsert = false;
153
+ drawBox();
154
+ }
155
+ return;
156
+ }
157
+
141
158
  if (processing) return;
142
159
 
160
+ // Vim normal mode key handling
161
+ if (vimMode && !vimInsert) {
162
+ switch (key) {
163
+ case 'i':
164
+ vimInsert = true;
165
+ drawBox();
166
+ return;
167
+ case 'a':
168
+ vimInsert = true;
169
+ cursorPos = Math.min(inputBuf.length, cursorPos + 1);
170
+ drawBox();
171
+ return;
172
+ case 'h':
173
+ cursorPos = Math.max(0, cursorPos - 1);
174
+ drawBox();
175
+ return;
176
+ case 'l':
177
+ cursorPos = Math.min(inputBuf.length, cursorPos + 1);
178
+ drawBox();
179
+ return;
180
+ case 'w': {
181
+ // jump to start of next word
182
+ let i = cursorPos;
183
+ // skip current word chars
184
+ while (i < inputBuf.length && inputBuf[i] !== ' ') i++;
185
+ // skip spaces
186
+ while (i < inputBuf.length && inputBuf[i] === ' ') i++;
187
+ cursorPos = i;
188
+ drawBox();
189
+ return;
190
+ }
191
+ case 'b': {
192
+ // jump to start of previous word
193
+ let i = cursorPos - 1;
194
+ // skip spaces before cursor
195
+ while (i > 0 && inputBuf[i - 1] === ' ') i--;
196
+ // skip word chars
197
+ while (i > 0 && inputBuf[i - 1] !== ' ') i--;
198
+ cursorPos = Math.max(0, i);
199
+ drawBox();
200
+ return;
201
+ }
202
+ case '0':
203
+ cursorPos = 0;
204
+ drawBox();
205
+ return;
206
+ case '$':
207
+ cursorPos = inputBuf.length;
208
+ drawBox();
209
+ return;
210
+ case 'x':
211
+ if (cursorPos < inputBuf.length) {
212
+ inputBuf = inputBuf.slice(0, cursorPos) + inputBuf.slice(cursorPos + 1);
213
+ drawBox();
214
+ }
215
+ return;
216
+ default:
217
+ return; // ignore unmapped keys in normal mode
218
+ }
219
+ }
220
+
143
221
  // Enter
144
222
  if (key === '\r' || key === '\n') {
145
223
  const line = inputBuf;
@@ -417,6 +495,16 @@ export function createUI({ planMode: initPlanMode = false, onLine, onTab, onExit
417
495
  return v;
418
496
  }
419
497
 
498
+ function setAbortController(ac) {
499
+ _abortController = ac;
500
+ }
501
+
502
+ function setVimMode(val) {
503
+ vimMode = val;
504
+ vimInsert = true; // always start in insert mode when toggling
505
+ if (!processing) drawBox();
506
+ }
507
+
420
508
  function exitUI() {
421
509
  resetScrollRegion();
422
510
  const r = rows();
@@ -436,6 +524,6 @@ export function createUI({ planMode: initPlanMode = false, onLine, onTab, onExit
436
524
  start, pause, resume,
437
525
  setPlanMode, setContinuation, setModelLabel, setModeLabel,
438
526
  readLine, readChar, selectFromList,
439
- wasInterrupted, stop, exitUI,
527
+ wasInterrupted, setAbortController, setVimMode, stop, exitUI,
440
528
  };
441
529
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tsunami-code",
3
- "version": "3.12.6",
3
+ "version": "3.12.8",
4
4
  "description": "Tsunami Code CLI — AI coding agent by Keystone World Management Navy Seal Unit XI3",
5
5
  "type": "module",
6
6
  "bin": {