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 +157 -2
- package/lib/tools.js +61 -1
- package/lib/ui.js +89 -1
- package/package.json +1 -1
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.
|
|
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
|
-
['/
|
|
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
|
|
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
|
}
|