snow-ai 0.2.15 → 0.2.17

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 (67) hide show
  1. package/dist/api/anthropic.d.ts +1 -1
  2. package/dist/api/anthropic.js +52 -76
  3. package/dist/api/chat.d.ts +4 -4
  4. package/dist/api/chat.js +32 -17
  5. package/dist/api/gemini.d.ts +1 -1
  6. package/dist/api/gemini.js +20 -13
  7. package/dist/api/models.d.ts +3 -0
  8. package/dist/api/models.js +101 -17
  9. package/dist/api/responses.d.ts +5 -5
  10. package/dist/api/responses.js +29 -27
  11. package/dist/app.js +4 -1
  12. package/dist/hooks/useClipboard.d.ts +4 -0
  13. package/dist/hooks/useClipboard.js +120 -0
  14. package/dist/hooks/useCommandHandler.d.ts +26 -0
  15. package/dist/hooks/useCommandHandler.js +158 -0
  16. package/dist/hooks/useCommandPanel.d.ts +16 -0
  17. package/dist/hooks/useCommandPanel.js +53 -0
  18. package/dist/hooks/useConversation.d.ts +9 -1
  19. package/dist/hooks/useConversation.js +152 -58
  20. package/dist/hooks/useFilePicker.d.ts +17 -0
  21. package/dist/hooks/useFilePicker.js +91 -0
  22. package/dist/hooks/useHistoryNavigation.d.ts +21 -0
  23. package/dist/hooks/useHistoryNavigation.js +50 -0
  24. package/dist/hooks/useInputBuffer.d.ts +6 -0
  25. package/dist/hooks/useInputBuffer.js +29 -0
  26. package/dist/hooks/useKeyboardInput.d.ts +51 -0
  27. package/dist/hooks/useKeyboardInput.js +272 -0
  28. package/dist/hooks/useSnapshotState.d.ts +12 -0
  29. package/dist/hooks/useSnapshotState.js +28 -0
  30. package/dist/hooks/useStreamingState.d.ts +24 -0
  31. package/dist/hooks/useStreamingState.js +96 -0
  32. package/dist/hooks/useVSCodeState.d.ts +8 -0
  33. package/dist/hooks/useVSCodeState.js +63 -0
  34. package/dist/mcp/filesystem.d.ts +24 -5
  35. package/dist/mcp/filesystem.js +52 -17
  36. package/dist/mcp/todo.js +4 -8
  37. package/dist/ui/components/ChatInput.js +71 -560
  38. package/dist/ui/components/DiffViewer.js +57 -30
  39. package/dist/ui/components/FileList.js +70 -26
  40. package/dist/ui/components/MessageList.d.ts +6 -0
  41. package/dist/ui/components/MessageList.js +47 -15
  42. package/dist/ui/components/ShimmerText.d.ts +9 -0
  43. package/dist/ui/components/ShimmerText.js +30 -0
  44. package/dist/ui/components/TodoTree.d.ts +1 -1
  45. package/dist/ui/components/TodoTree.js +0 -4
  46. package/dist/ui/components/ToolConfirmation.js +14 -6
  47. package/dist/ui/pages/ChatScreen.js +174 -373
  48. package/dist/ui/pages/CustomHeadersScreen.d.ts +6 -0
  49. package/dist/ui/pages/CustomHeadersScreen.js +104 -0
  50. package/dist/ui/pages/WelcomeScreen.js +5 -0
  51. package/dist/utils/apiConfig.d.ts +10 -0
  52. package/dist/utils/apiConfig.js +51 -0
  53. package/dist/utils/incrementalSnapshot.d.ts +8 -0
  54. package/dist/utils/incrementalSnapshot.js +63 -0
  55. package/dist/utils/mcpToolsManager.js +6 -1
  56. package/dist/utils/retryUtils.d.ts +22 -0
  57. package/dist/utils/retryUtils.js +180 -0
  58. package/dist/utils/sessionConverter.js +80 -17
  59. package/dist/utils/sessionManager.js +35 -4
  60. package/dist/utils/textUtils.d.ts +4 -0
  61. package/dist/utils/textUtils.js +19 -0
  62. package/dist/utils/todoPreprocessor.d.ts +1 -1
  63. package/dist/utils/todoPreprocessor.js +0 -1
  64. package/dist/utils/vscodeConnection.d.ts +8 -0
  65. package/dist/utils/vscodeConnection.js +44 -0
  66. package/package.json +1 -1
  67. package/readme.md +3 -1
@@ -0,0 +1,6 @@
1
+ type Props = {
2
+ onBack: () => void;
3
+ onSave: () => void;
4
+ };
5
+ export default function CustomHeadersScreen({ onBack }: Props): null;
6
+ export {};
@@ -0,0 +1,104 @@
1
+ import { useEffect } from 'react';
2
+ import { useApp } from 'ink';
3
+ import { spawn } from 'child_process';
4
+ import { writeFileSync, readFileSync, existsSync, mkdirSync } from 'fs';
5
+ import { join } from 'path';
6
+ import { homedir, platform } from 'os';
7
+ const CONFIG_DIR = join(homedir(), '.snow');
8
+ const CUSTOM_HEADERS_FILE = join(CONFIG_DIR, 'custom-headers.json');
9
+ function getSystemEditor() {
10
+ if (platform() === 'win32') {
11
+ return 'notepad';
12
+ }
13
+ return process.env['EDITOR'] || 'vim';
14
+ }
15
+ function ensureConfigDirectory() {
16
+ if (!existsSync(CONFIG_DIR)) {
17
+ mkdirSync(CONFIG_DIR, { recursive: true });
18
+ }
19
+ }
20
+ const DEFAULT_HEADERS_TEMPLATE = `{
21
+ "X-Custom-Header": "custom-value",
22
+ "User-Agent": "MyApp/1.0"
23
+ }`;
24
+ export default function CustomHeadersScreen({ onBack }) {
25
+ const { exit } = useApp();
26
+ useEffect(() => {
27
+ const openEditor = async () => {
28
+ ensureConfigDirectory();
29
+ // Read existing custom headers, or use template if not exists
30
+ let currentHeaders = DEFAULT_HEADERS_TEMPLATE;
31
+ if (existsSync(CUSTOM_HEADERS_FILE)) {
32
+ try {
33
+ currentHeaders = readFileSync(CUSTOM_HEADERS_FILE, 'utf8');
34
+ }
35
+ catch {
36
+ // Read failed, use template
37
+ currentHeaders = DEFAULT_HEADERS_TEMPLATE;
38
+ }
39
+ }
40
+ // Write to file for editing
41
+ writeFileSync(CUSTOM_HEADERS_FILE, currentHeaders, 'utf8');
42
+ const editor = getSystemEditor();
43
+ exit();
44
+ const child = spawn(editor, [CUSTOM_HEADERS_FILE], {
45
+ stdio: 'inherit'
46
+ });
47
+ child.on('close', () => {
48
+ // Read edited content
49
+ if (existsSync(CUSTOM_HEADERS_FILE)) {
50
+ try {
51
+ const editedContent = readFileSync(CUSTOM_HEADERS_FILE, 'utf8');
52
+ const trimmedContent = editedContent.trim();
53
+ // Validate JSON format
54
+ if (trimmedContent === '' || trimmedContent === '{}') {
55
+ // Empty or empty object, delete file to reset
56
+ try {
57
+ const fs = require('fs');
58
+ fs.unlinkSync(CUSTOM_HEADERS_FILE);
59
+ console.log('Custom headers cleared. Please use `snow` to restart!');
60
+ }
61
+ catch {
62
+ // Delete failed, save empty object
63
+ writeFileSync(CUSTOM_HEADERS_FILE, '{}', 'utf8');
64
+ console.log('Custom headers cleared. Please use `snow` to restart!');
65
+ }
66
+ }
67
+ else {
68
+ // Validate JSON
69
+ try {
70
+ const headers = JSON.parse(trimmedContent);
71
+ if (typeof headers !== 'object' || headers === null || Array.isArray(headers)) {
72
+ throw new Error('Headers must be a JSON object');
73
+ }
74
+ // Validate all values are strings
75
+ for (const [key, value] of Object.entries(headers)) {
76
+ if (typeof value !== 'string') {
77
+ throw new Error(`Header value for "${key}" must be a string`);
78
+ }
79
+ }
80
+ // Save valid headers
81
+ writeFileSync(CUSTOM_HEADERS_FILE, JSON.stringify(headers, null, 2), 'utf8');
82
+ console.log('Custom headers saved successfully! Please use `snow` to restart!');
83
+ }
84
+ catch (error) {
85
+ console.error('Invalid JSON format:', error instanceof Error ? error.message : 'Unknown error');
86
+ console.error('Custom headers were NOT saved. Please fix the JSON format and try again.');
87
+ }
88
+ }
89
+ }
90
+ catch (error) {
91
+ console.error('Failed to save custom headers:', error instanceof Error ? error.message : 'Unknown error');
92
+ }
93
+ }
94
+ process.exit(0);
95
+ });
96
+ child.on('error', (error) => {
97
+ console.error('Failed to open editor:', error.message);
98
+ process.exit(1);
99
+ });
100
+ };
101
+ openEditor();
102
+ }, [exit, onBack]);
103
+ return null;
104
+ }
@@ -27,6 +27,11 @@ export default function WelcomeScreen({ version = '1.0.0', onMenuSelect, }) {
27
27
  value: 'systemprompt',
28
28
  infoText: 'Configure custom system prompt (overrides default)',
29
29
  },
30
+ {
31
+ label: 'Custom Headers Settings',
32
+ value: 'customheaders',
33
+ infoText: 'Configure custom HTTP headers for API requests',
34
+ },
30
35
  {
31
36
  label: 'MCP Settings',
32
37
  value: 'mcp',
@@ -42,3 +42,13 @@ export declare function validateMCPConfig(config: Partial<MCPConfig>): string[];
42
42
  * 否则返回 undefined (使用默认系统提示词)
43
43
  */
44
44
  export declare function getCustomSystemPrompt(): string | undefined;
45
+ /**
46
+ * 读取自定义请求头配置
47
+ * 如果 custom-headers.json 文件存在且有效,返回其内容
48
+ * 否则返回空对象
49
+ */
50
+ export declare function getCustomHeaders(): Record<string, string>;
51
+ /**
52
+ * 保存自定义请求头配置
53
+ */
54
+ export declare function saveCustomHeaders(headers: Record<string, string>): void;
@@ -18,6 +18,7 @@ const DEFAULT_MCP_CONFIG = {
18
18
  };
19
19
  const CONFIG_DIR = join(homedir(), '.snow');
20
20
  const SYSTEM_PROMPT_FILE = join(CONFIG_DIR, 'system-prompt.txt');
21
+ const CUSTOM_HEADERS_FILE = join(CONFIG_DIR, 'custom-headers.json');
21
22
  function normalizeRequestMethod(method) {
22
23
  if (method === 'chat' || method === 'responses' || method === 'gemini' || method === 'anthropic') {
23
24
  return method;
@@ -216,3 +217,53 @@ export function getCustomSystemPrompt() {
216
217
  return undefined;
217
218
  }
218
219
  }
220
+ /**
221
+ * 读取自定义请求头配置
222
+ * 如果 custom-headers.json 文件存在且有效,返回其内容
223
+ * 否则返回空对象
224
+ */
225
+ export function getCustomHeaders() {
226
+ ensureConfigDirectory();
227
+ if (!existsSync(CUSTOM_HEADERS_FILE)) {
228
+ return {};
229
+ }
230
+ try {
231
+ const content = readFileSync(CUSTOM_HEADERS_FILE, 'utf8');
232
+ const headers = JSON.parse(content);
233
+ // 验证格式:必须是对象,且所有值都是字符串
234
+ if (typeof headers !== 'object' || headers === null || Array.isArray(headers)) {
235
+ return {};
236
+ }
237
+ // 过滤掉非字符串的值
238
+ const validHeaders = {};
239
+ for (const [key, value] of Object.entries(headers)) {
240
+ if (typeof value === 'string') {
241
+ validHeaders[key] = value;
242
+ }
243
+ }
244
+ return validHeaders;
245
+ }
246
+ catch {
247
+ return {};
248
+ }
249
+ }
250
+ /**
251
+ * 保存自定义请求头配置
252
+ */
253
+ export function saveCustomHeaders(headers) {
254
+ ensureConfigDirectory();
255
+ try {
256
+ // 过滤掉空键值对
257
+ const filteredHeaders = {};
258
+ for (const [key, value] of Object.entries(headers)) {
259
+ if (key.trim() && value.trim()) {
260
+ filteredHeaders[key.trim()] = value.trim();
261
+ }
262
+ }
263
+ const content = JSON.stringify(filteredHeaders, null, 2);
264
+ writeFileSync(CUSTOM_HEADERS_FILE, content, 'utf8');
265
+ }
266
+ catch (error) {
267
+ throw new Error(`Failed to save custom headers: ${error}`);
268
+ }
269
+ }
@@ -60,6 +60,14 @@ declare class IncrementalSnapshotManager {
60
60
  * Rollback to a specific snapshot
61
61
  */
62
62
  rollbackToSnapshot(sessionId: string, messageIndex: number): Promise<boolean>;
63
+ /**
64
+ * Rollback all snapshots after a specific message index
65
+ * This is used when user selects to rollback to a specific message
66
+ * @param sessionId Session ID
67
+ * @param targetMessageIndex The message index to rollback to (inclusive)
68
+ * @returns Number of files rolled back
69
+ */
70
+ rollbackToMessageIndex(sessionId: string, targetMessageIndex: number): Promise<number>;
63
71
  /**
64
72
  * Clear all snapshots for a session
65
73
  */
@@ -157,6 +157,69 @@ class IncrementalSnapshotManager {
157
157
  return false;
158
158
  }
159
159
  }
160
+ /**
161
+ * Rollback all snapshots after a specific message index
162
+ * This is used when user selects to rollback to a specific message
163
+ * @param sessionId Session ID
164
+ * @param targetMessageIndex The message index to rollback to (inclusive)
165
+ * @returns Number of files rolled back
166
+ */
167
+ async rollbackToMessageIndex(sessionId, targetMessageIndex) {
168
+ await this.ensureSnapshotsDir();
169
+ try {
170
+ const files = await fs.readdir(this.snapshotsDir);
171
+ const snapshots = [];
172
+ // Load all snapshots for this session
173
+ for (const file of files) {
174
+ if (file.startsWith(sessionId) && file.endsWith('.json')) {
175
+ const snapshotPath = path.join(this.snapshotsDir, file);
176
+ const content = await fs.readFile(snapshotPath, 'utf-8');
177
+ const metadata = JSON.parse(content);
178
+ snapshots.push({
179
+ messageIndex: metadata.messageIndex,
180
+ path: snapshotPath,
181
+ metadata
182
+ });
183
+ }
184
+ }
185
+ // Filter snapshots that are >= targetMessageIndex and sort in descending order
186
+ // We rollback from newest to oldest to ensure correct restoration
187
+ const snapshotsToRollback = snapshots
188
+ .filter(s => s.messageIndex >= targetMessageIndex)
189
+ .sort((a, b) => b.messageIndex - a.messageIndex);
190
+ let totalFilesRolledBack = 0;
191
+ // Rollback each snapshot in reverse chronological order
192
+ for (const snapshot of snapshotsToRollback) {
193
+ for (const backup of snapshot.metadata.backups) {
194
+ try {
195
+ if (backup.existed && backup.content !== null) {
196
+ // Restore original file content
197
+ await fs.writeFile(backup.path, backup.content, 'utf-8');
198
+ totalFilesRolledBack++;
199
+ }
200
+ else if (!backup.existed) {
201
+ // Delete file that was created
202
+ try {
203
+ await fs.unlink(backup.path);
204
+ totalFilesRolledBack++;
205
+ }
206
+ catch (error) {
207
+ // File may not exist, ignore
208
+ }
209
+ }
210
+ }
211
+ catch (error) {
212
+ console.error(`Failed to restore file ${backup.path}:`, error);
213
+ }
214
+ }
215
+ }
216
+ return totalFilesRolledBack;
217
+ }
218
+ catch (error) {
219
+ console.error('Failed to rollback to message index:', error);
220
+ return 0;
221
+ }
222
+ }
160
223
  /**
161
224
  * Clear all snapshots for a session
162
225
  */
@@ -382,7 +382,12 @@ export async function executeMCPTool(toolName, args) {
382
382
  case 'create':
383
383
  return await filesystemService.createFile(args.filePath, args.content, args.createDirectories);
384
384
  case 'delete':
385
- return await filesystemService.deleteFile(args.filePath);
385
+ // Support both filePath (legacy) and filePaths (new) parameters
386
+ const pathsToDelete = args.filePaths || args.filePath;
387
+ if (!pathsToDelete) {
388
+ throw new Error('Missing required parameter: filePath or filePaths');
389
+ }
390
+ return await filesystemService.deleteFile(pathsToDelete);
386
391
  case 'list':
387
392
  return await filesystemService.listFiles(args.dirPath);
388
393
  case 'exists':
@@ -0,0 +1,22 @@
1
+ /**
2
+ * 重试工具函数
3
+ * 提供统一的重试机制用于所有 AI 请求
4
+ * - 支持5次重试
5
+ * - 延时递增策略 (1s, 2s, 4s, 8s, 16s)
6
+ * - 支持 AbortSignal 中断
7
+ */
8
+ export interface RetryOptions {
9
+ maxRetries?: number;
10
+ baseDelay?: number;
11
+ onRetry?: (error: Error, attempt: number, nextDelay: number) => void;
12
+ abortSignal?: AbortSignal;
13
+ }
14
+ /**
15
+ * 包装异步函数,提供重试机制
16
+ */
17
+ export declare function withRetry<T>(fn: () => Promise<T>, options?: RetryOptions): Promise<T>;
18
+ /**
19
+ * 包装异步生成器函数,提供重试机制
20
+ * 注意:如果生成器已经开始产生数据,则不会重试
21
+ */
22
+ export declare function withRetryGenerator<T>(fn: () => AsyncGenerator<T, void, unknown>, options?: RetryOptions): AsyncGenerator<T, void, unknown>;
@@ -0,0 +1,180 @@
1
+ /**
2
+ * 重试工具函数
3
+ * 提供统一的重试机制用于所有 AI 请求
4
+ * - 支持5次重试
5
+ * - 延时递增策略 (1s, 2s, 4s, 8s, 16s)
6
+ * - 支持 AbortSignal 中断
7
+ */
8
+ /**
9
+ * 延时函数,支持 AbortSignal 中断
10
+ */
11
+ async function delay(ms, abortSignal) {
12
+ return new Promise((resolve, reject) => {
13
+ if (abortSignal?.aborted) {
14
+ reject(new Error('Aborted'));
15
+ return;
16
+ }
17
+ const timer = setTimeout(() => {
18
+ cleanup();
19
+ resolve();
20
+ }, ms);
21
+ const abortHandler = () => {
22
+ cleanup();
23
+ reject(new Error('Aborted'));
24
+ };
25
+ const cleanup = () => {
26
+ clearTimeout(timer);
27
+ abortSignal?.removeEventListener('abort', abortHandler);
28
+ };
29
+ abortSignal?.addEventListener('abort', abortHandler);
30
+ });
31
+ }
32
+ /**
33
+ * 判断错误是否可重试
34
+ */
35
+ function isRetriableError(error) {
36
+ const errorMessage = error.message.toLowerCase();
37
+ // 网络错误
38
+ if (errorMessage.includes('network') ||
39
+ errorMessage.includes('econnrefused') ||
40
+ errorMessage.includes('econnreset') ||
41
+ errorMessage.includes('etimedout') ||
42
+ errorMessage.includes('timeout')) {
43
+ return true;
44
+ }
45
+ // Rate limit errors
46
+ if (errorMessage.includes('rate limit') ||
47
+ errorMessage.includes('too many requests') ||
48
+ errorMessage.includes('429')) {
49
+ return true;
50
+ }
51
+ // Server errors (5xx)
52
+ if (errorMessage.includes('500') ||
53
+ errorMessage.includes('502') ||
54
+ errorMessage.includes('503') ||
55
+ errorMessage.includes('504') ||
56
+ errorMessage.includes('internal server error') ||
57
+ errorMessage.includes('bad gateway') ||
58
+ errorMessage.includes('service unavailable') ||
59
+ errorMessage.includes('gateway timeout')) {
60
+ return true;
61
+ }
62
+ // Temporary service unavailable
63
+ if (errorMessage.includes('overloaded') ||
64
+ errorMessage.includes('unavailable')) {
65
+ return true;
66
+ }
67
+ return false;
68
+ }
69
+ /**
70
+ * 包装异步函数,提供重试机制
71
+ */
72
+ export async function withRetry(fn, options = {}) {
73
+ const { maxRetries = 5, baseDelay = 1000, onRetry, abortSignal } = options;
74
+ let lastError = null;
75
+ let attempt = 0;
76
+ while (attempt <= maxRetries) {
77
+ // 检查是否已中断
78
+ if (abortSignal?.aborted) {
79
+ throw new Error('Request aborted');
80
+ }
81
+ try {
82
+ // 尝试执行函数
83
+ return await fn();
84
+ }
85
+ catch (error) {
86
+ lastError = error;
87
+ // 如果是 AbortError,立即退出
88
+ if (lastError.name === 'AbortError' || lastError.message === 'Aborted') {
89
+ throw lastError;
90
+ }
91
+ // 如果已达到最大重试次数,抛出错误
92
+ if (attempt >= maxRetries) {
93
+ throw lastError;
94
+ }
95
+ // 检查错误是否可重试
96
+ if (!isRetriableError(lastError)) {
97
+ throw lastError;
98
+ }
99
+ // 计算下次重试的延时(指数退避:1s, 2s, 4s, 8s, 16s)
100
+ const nextDelay = baseDelay * Math.pow(2, attempt);
101
+ // 调用重试回调
102
+ if (onRetry) {
103
+ onRetry(lastError, attempt + 1, nextDelay);
104
+ }
105
+ // 等待后重试
106
+ try {
107
+ await delay(nextDelay, abortSignal);
108
+ }
109
+ catch (delayError) {
110
+ // 延时过程中被中断
111
+ throw new Error('Request aborted');
112
+ }
113
+ attempt++;
114
+ }
115
+ }
116
+ // 不应该到达这里
117
+ throw lastError || new Error('Retry failed');
118
+ }
119
+ /**
120
+ * 包装异步生成器函数,提供重试机制
121
+ * 注意:如果生成器已经开始产生数据,则不会重试
122
+ */
123
+ export async function* withRetryGenerator(fn, options = {}) {
124
+ const { maxRetries = 5, baseDelay = 1000, onRetry, abortSignal } = options;
125
+ let lastError = null;
126
+ let attempt = 0;
127
+ let hasYielded = false; // 标记是否已经产生过数据
128
+ while (attempt <= maxRetries) {
129
+ // 检查是否已中断
130
+ if (abortSignal?.aborted) {
131
+ throw new Error('Request aborted');
132
+ }
133
+ try {
134
+ // 尝试执行生成器
135
+ const generator = fn();
136
+ for await (const chunk of generator) {
137
+ hasYielded = true; // 标记已产生数据
138
+ yield chunk;
139
+ }
140
+ // 成功完成
141
+ return;
142
+ }
143
+ catch (error) {
144
+ lastError = error;
145
+ // 如果是 AbortError,立即退出
146
+ if (lastError.name === 'AbortError' || lastError.message === 'Aborted') {
147
+ throw lastError;
148
+ }
149
+ // 如果已经产生过数据,不再重试(避免重复数据)
150
+ if (hasYielded) {
151
+ throw lastError;
152
+ }
153
+ // 如果已达到最大重试次数,抛出错误
154
+ if (attempt >= maxRetries) {
155
+ throw lastError;
156
+ }
157
+ // 检查错误是否可重试
158
+ if (!isRetriableError(lastError)) {
159
+ throw lastError;
160
+ }
161
+ // 计算下次重试的延时(指数退避:1s, 2s, 4s, 8s, 16s)
162
+ const nextDelay = baseDelay * Math.pow(2, attempt);
163
+ // 调用重试回调
164
+ if (onRetry) {
165
+ onRetry(lastError, attempt + 1, nextDelay);
166
+ }
167
+ // 等待后重试
168
+ try {
169
+ await delay(nextDelay, abortSignal);
170
+ }
171
+ catch (delayError) {
172
+ // 延时过程中被中断
173
+ throw new Error('Request aborted');
174
+ }
175
+ attempt++;
176
+ }
177
+ }
178
+ // 不应该到达这里
179
+ throw lastError || new Error('Retry failed');
180
+ }
@@ -4,33 +4,31 @@ import { formatToolCallMessage } from './messageFormatter.js';
4
4
  */
5
5
  export function convertSessionMessagesToUI(sessionMessages) {
6
6
  const uiMessages = [];
7
+ // First pass: build a map of tool_call_id to tool results
8
+ const toolResultsMap = new Map();
9
+ for (const msg of sessionMessages) {
10
+ if (msg.role === 'tool' && msg.tool_call_id) {
11
+ toolResultsMap.set(msg.tool_call_id, msg.content);
12
+ }
13
+ }
7
14
  for (const msg of sessionMessages) {
8
15
  // Skip system messages
9
16
  if (msg.role === 'system')
10
17
  continue;
11
- // Handle tool role messages (tool execution results)
12
- if (msg.role === 'tool') {
13
- const isError = msg.content.startsWith('Error:');
14
- const statusIcon = isError ? '✗' : '✓';
15
- const statusText = isError ? `\n └─ ${msg.content}` : '';
16
- const toolName = msg.tool_call_id || 'unknown-tool';
17
- uiMessages.push({
18
- role: 'assistant',
19
- content: `${statusIcon} ${toolName}${statusText}`,
20
- streaming: false,
21
- toolResult: !isError ? msg.content : undefined
22
- });
18
+ // Skip tool role messages (we'll attach them to tool calls)
19
+ if (msg.role === 'tool')
23
20
  continue;
24
- }
25
21
  // Handle user and assistant messages
26
22
  const uiMessage = {
27
23
  role: msg.role,
28
24
  content: msg.content,
29
25
  streaming: false,
30
- images: msg.images
26
+ images: msg.images,
31
27
  };
32
28
  // If assistant message has tool_calls, expand to show each tool call
33
- if (msg.role === 'assistant' && msg.tool_calls && msg.tool_calls.length > 0) {
29
+ if (msg.role === 'assistant' &&
30
+ msg.tool_calls &&
31
+ msg.tool_calls.length > 0) {
34
32
  for (const toolCall of msg.tool_calls) {
35
33
  const toolDisplay = formatToolCallMessage(toolCall);
36
34
  let toolArgs;
@@ -40,16 +38,81 @@ export function convertSessionMessagesToUI(sessionMessages) {
40
38
  catch (e) {
41
39
  toolArgs = {};
42
40
  }
41
+ // Get the tool result for this tool call
42
+ const toolResult = toolResultsMap.get(toolCall.id);
43
+ const isError = toolResult?.startsWith('Error:') || false;
44
+ // For filesystem-edit, try to extract diff data from result
45
+ let editDiffData;
46
+ if (toolCall.function.name === 'filesystem-edit' &&
47
+ toolResult &&
48
+ !isError) {
49
+ try {
50
+ const resultData = JSON.parse(toolResult);
51
+ if (resultData.oldContent && resultData.newContent) {
52
+ editDiffData = {
53
+ oldContent: resultData.oldContent,
54
+ newContent: resultData.newContent,
55
+ filename: toolArgs.filePath,
56
+ };
57
+ // Merge diff data into toolArgs for DiffViewer
58
+ toolArgs.oldContent = resultData.oldContent;
59
+ toolArgs.newContent = resultData.newContent;
60
+ }
61
+ }
62
+ catch (e) {
63
+ // If parsing fails, just show regular result
64
+ }
65
+ }
66
+ // For terminal-execute, try to extract terminal result data
67
+ let terminalResultData;
68
+ if (toolCall.function.name === 'terminal-execute' &&
69
+ toolResult &&
70
+ !isError) {
71
+ try {
72
+ const resultData = JSON.parse(toolResult);
73
+ if (resultData.stdout !== undefined ||
74
+ resultData.stderr !== undefined) {
75
+ terminalResultData = {
76
+ stdout: resultData.stdout,
77
+ stderr: resultData.stderr,
78
+ exitCode: resultData.exitCode,
79
+ command: toolArgs.command,
80
+ };
81
+ }
82
+ }
83
+ catch (e) {
84
+ // If parsing fails, just show regular result
85
+ }
86
+ }
87
+ // Create tool call message
43
88
  uiMessages.push({
44
89
  role: 'assistant',
45
90
  content: `⚡ ${toolDisplay.toolName}`,
46
91
  streaming: false,
47
92
  toolCall: {
48
93
  name: toolCall.function.name,
49
- arguments: toolArgs
94
+ arguments: toolArgs,
50
95
  },
51
- toolDisplay
96
+ toolDisplay,
52
97
  });
98
+ // Create tool result message
99
+ if (toolResult) {
100
+ const statusIcon = isError ? '✗' : '✓';
101
+ const statusText = isError ? `\n └─ ${toolResult}` : '';
102
+ uiMessages.push({
103
+ role: 'assistant',
104
+ content: `${statusIcon} ${toolCall.function.name}${statusText}`,
105
+ streaming: false,
106
+ toolResult: !isError ? toolResult : undefined,
107
+ toolCall: editDiffData || terminalResultData
108
+ ? {
109
+ name: toolCall.function.name,
110
+ arguments: toolArgs,
111
+ }
112
+ : undefined,
113
+ terminalResult: terminalResultData,
114
+ });
115
+ }
53
116
  }
54
117
  }
55
118
  else {