snow-ai 0.2.6 → 0.2.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/dist/app.js +3 -3
- package/dist/hooks/useConversation.d.ts +1 -0
- package/dist/hooks/useConversation.js +9 -4
- package/dist/mcp/bash.js +2 -2
- package/dist/mcp/filesystem.d.ts +11 -6
- package/dist/mcp/filesystem.js +74 -22
- package/dist/mcp/todo.js +116 -14
- package/dist/ui/components/ChatInput.d.ts +2 -1
- package/dist/ui/components/ChatInput.js +16 -5
- package/dist/ui/components/FileRollbackConfirmation.d.ts +7 -0
- package/dist/ui/components/FileRollbackConfirmation.js +49 -0
- package/dist/ui/components/MessageList.js +1 -1
- package/dist/ui/components/SessionListPanel.d.ts +7 -0
- package/dist/ui/components/SessionListPanel.js +171 -0
- package/dist/ui/pages/ChatScreen.js +125 -17
- package/dist/utils/checkpointManager.d.ts +74 -0
- package/dist/utils/checkpointManager.js +181 -0
- package/dist/utils/commandExecutor.d.ts +1 -1
- package/dist/utils/commands/mcp.js +3 -3
- package/dist/utils/commands/resume.js +3 -3
- package/dist/utils/incrementalSnapshot.d.ts +87 -0
- package/dist/utils/incrementalSnapshot.js +250 -0
- package/dist/utils/workspaceSnapshot.d.ts +63 -0
- package/dist/utils/workspaceSnapshot.js +299 -0
- package/package.json +2 -1
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
import React, { useState, useEffect, useCallback } from 'react';
|
|
2
|
+
import { Box, Text, useInput } from 'ink';
|
|
3
|
+
import { sessionManager } from '../../utils/sessionManager.js';
|
|
4
|
+
export default function SessionListPanel({ onSelectSession, onClose }) {
|
|
5
|
+
const [sessions, setSessions] = useState([]);
|
|
6
|
+
const [loading, setLoading] = useState(true);
|
|
7
|
+
const [selectedIndex, setSelectedIndex] = useState(0);
|
|
8
|
+
const [scrollOffset, setScrollOffset] = useState(0);
|
|
9
|
+
const [markedSessions, setMarkedSessions] = useState(new Set());
|
|
10
|
+
const VISIBLE_ITEMS = 5; // Number of items to show at once
|
|
11
|
+
// Load sessions on mount
|
|
12
|
+
useEffect(() => {
|
|
13
|
+
const loadSessions = async () => {
|
|
14
|
+
setLoading(true);
|
|
15
|
+
try {
|
|
16
|
+
const sessionList = await sessionManager.listSessions();
|
|
17
|
+
setSessions(sessionList);
|
|
18
|
+
}
|
|
19
|
+
catch (error) {
|
|
20
|
+
console.error('Failed to load sessions:', error);
|
|
21
|
+
setSessions([]);
|
|
22
|
+
}
|
|
23
|
+
finally {
|
|
24
|
+
setLoading(false);
|
|
25
|
+
}
|
|
26
|
+
};
|
|
27
|
+
void loadSessions();
|
|
28
|
+
}, []);
|
|
29
|
+
// Format date to relative time
|
|
30
|
+
const formatDate = useCallback((timestamp) => {
|
|
31
|
+
const date = new Date(timestamp);
|
|
32
|
+
const now = new Date();
|
|
33
|
+
const diffMs = now.getTime() - date.getTime();
|
|
34
|
+
const diffMinutes = Math.floor(diffMs / (1000 * 60));
|
|
35
|
+
const diffHours = Math.floor(diffMinutes / 60);
|
|
36
|
+
const diffDays = Math.floor(diffHours / 24);
|
|
37
|
+
if (diffMinutes < 1)
|
|
38
|
+
return 'now';
|
|
39
|
+
if (diffMinutes < 60)
|
|
40
|
+
return `${diffMinutes}m`;
|
|
41
|
+
if (diffHours < 24)
|
|
42
|
+
return `${diffHours}h`;
|
|
43
|
+
if (diffDays < 7)
|
|
44
|
+
return `${diffDays}d`;
|
|
45
|
+
return date.toLocaleDateString('en-US', { month: 'short', day: 'numeric' });
|
|
46
|
+
}, []);
|
|
47
|
+
// Handle keyboard input
|
|
48
|
+
useInput((input, key) => {
|
|
49
|
+
if (loading)
|
|
50
|
+
return;
|
|
51
|
+
if (key.escape) {
|
|
52
|
+
onClose();
|
|
53
|
+
return;
|
|
54
|
+
}
|
|
55
|
+
if (key.upArrow) {
|
|
56
|
+
setSelectedIndex(prev => {
|
|
57
|
+
const newIndex = Math.max(0, prev - 1);
|
|
58
|
+
// Adjust scroll offset if needed
|
|
59
|
+
if (newIndex < scrollOffset) {
|
|
60
|
+
setScrollOffset(newIndex);
|
|
61
|
+
}
|
|
62
|
+
return newIndex;
|
|
63
|
+
});
|
|
64
|
+
return;
|
|
65
|
+
}
|
|
66
|
+
if (key.downArrow) {
|
|
67
|
+
setSelectedIndex(prev => {
|
|
68
|
+
const newIndex = Math.min(sessions.length - 1, prev + 1);
|
|
69
|
+
// Adjust scroll offset if needed
|
|
70
|
+
if (newIndex >= scrollOffset + VISIBLE_ITEMS) {
|
|
71
|
+
setScrollOffset(newIndex - VISIBLE_ITEMS + 1);
|
|
72
|
+
}
|
|
73
|
+
return newIndex;
|
|
74
|
+
});
|
|
75
|
+
return;
|
|
76
|
+
}
|
|
77
|
+
// Space to toggle mark
|
|
78
|
+
if (input === ' ') {
|
|
79
|
+
const currentSession = sessions[selectedIndex];
|
|
80
|
+
if (currentSession) {
|
|
81
|
+
setMarkedSessions(prev => {
|
|
82
|
+
const next = new Set(prev);
|
|
83
|
+
if (next.has(currentSession.id)) {
|
|
84
|
+
next.delete(currentSession.id);
|
|
85
|
+
}
|
|
86
|
+
else {
|
|
87
|
+
next.add(currentSession.id);
|
|
88
|
+
}
|
|
89
|
+
return next;
|
|
90
|
+
});
|
|
91
|
+
}
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
94
|
+
// D to delete marked sessions
|
|
95
|
+
if (input === 'd' || input === 'D') {
|
|
96
|
+
if (markedSessions.size > 0) {
|
|
97
|
+
const deleteMarked = async () => {
|
|
98
|
+
const ids = Array.from(markedSessions);
|
|
99
|
+
await Promise.all(ids.map(id => sessionManager.deleteSession(id)));
|
|
100
|
+
// Reload sessions
|
|
101
|
+
const sessionList = await sessionManager.listSessions();
|
|
102
|
+
setSessions(sessionList);
|
|
103
|
+
setMarkedSessions(new Set());
|
|
104
|
+
// Reset selection if needed
|
|
105
|
+
if (selectedIndex >= sessionList.length && sessionList.length > 0) {
|
|
106
|
+
setSelectedIndex(sessionList.length - 1);
|
|
107
|
+
}
|
|
108
|
+
};
|
|
109
|
+
void deleteMarked();
|
|
110
|
+
}
|
|
111
|
+
return;
|
|
112
|
+
}
|
|
113
|
+
if (key.return && sessions.length > 0) {
|
|
114
|
+
const selectedSession = sessions[selectedIndex];
|
|
115
|
+
if (selectedSession) {
|
|
116
|
+
onSelectSession(selectedSession.id);
|
|
117
|
+
}
|
|
118
|
+
return;
|
|
119
|
+
}
|
|
120
|
+
});
|
|
121
|
+
if (loading) {
|
|
122
|
+
return (React.createElement(Box, { borderStyle: "round", borderColor: "cyan", paddingX: 1 },
|
|
123
|
+
React.createElement(Text, { color: "gray", dimColor: true }, "Loading sessions...")));
|
|
124
|
+
}
|
|
125
|
+
if (sessions.length === 0) {
|
|
126
|
+
return (React.createElement(Box, { borderStyle: "round", borderColor: "yellow", paddingX: 1 },
|
|
127
|
+
React.createElement(Text, { color: "gray", dimColor: true }, "No conversations found \u2022 Press ESC to close")));
|
|
128
|
+
}
|
|
129
|
+
// Calculate visible sessions based on scroll offset
|
|
130
|
+
const visibleSessions = sessions.slice(scrollOffset, scrollOffset + VISIBLE_ITEMS);
|
|
131
|
+
const hasMore = sessions.length > scrollOffset + VISIBLE_ITEMS;
|
|
132
|
+
const hasPrevious = scrollOffset > 0;
|
|
133
|
+
const currentSession = sessions[selectedIndex];
|
|
134
|
+
return (React.createElement(Box, { borderStyle: "round", borderColor: "cyan", paddingX: 1, flexDirection: "column" },
|
|
135
|
+
React.createElement(Box, { flexDirection: "column" },
|
|
136
|
+
React.createElement(Text, { color: "cyan", dimColor: true },
|
|
137
|
+
"Resume (",
|
|
138
|
+
selectedIndex + 1,
|
|
139
|
+
"/",
|
|
140
|
+
sessions.length,
|
|
141
|
+
")",
|
|
142
|
+
currentSession && ` • ${currentSession.messageCount} msgs`,
|
|
143
|
+
markedSessions.size > 0 && React.createElement(Text, { color: "yellow" },
|
|
144
|
+
" \u2022 ",
|
|
145
|
+
markedSessions.size,
|
|
146
|
+
" marked")),
|
|
147
|
+
React.createElement(Text, { color: "gray", dimColor: true }, "\u2191\u2193 navigate \u2022 Space mark \u2022 D delete \u2022 Enter select \u2022 ESC close")),
|
|
148
|
+
hasPrevious && (React.createElement(Text, { color: "gray", dimColor: true },
|
|
149
|
+
" \u2191 ",
|
|
150
|
+
scrollOffset,
|
|
151
|
+
" more above")),
|
|
152
|
+
visibleSessions.map((session, index) => {
|
|
153
|
+
const actualIndex = scrollOffset + index;
|
|
154
|
+
const isSelected = actualIndex === selectedIndex;
|
|
155
|
+
const isMarked = markedSessions.has(session.id);
|
|
156
|
+
const title = session.title || 'Untitled';
|
|
157
|
+
const timeStr = formatDate(session.updatedAt);
|
|
158
|
+
const truncatedLabel = title.length > 50 ? title.slice(0, 47) + '...' : title;
|
|
159
|
+
return (React.createElement(Box, { key: session.id },
|
|
160
|
+
React.createElement(Text, { color: isMarked ? 'green' : 'gray' }, isMarked ? '✔ ' : ' '),
|
|
161
|
+
React.createElement(Text, { color: isSelected ? 'green' : 'gray' }, isSelected ? '❯ ' : ' '),
|
|
162
|
+
React.createElement(Text, { color: isSelected ? 'cyan' : isMarked ? 'green' : 'white' }, truncatedLabel),
|
|
163
|
+
React.createElement(Text, { color: "gray", dimColor: true },
|
|
164
|
+
" \u2022 ",
|
|
165
|
+
timeStr)));
|
|
166
|
+
}),
|
|
167
|
+
hasMore && (React.createElement(Text, { color: "gray", dimColor: true },
|
|
168
|
+
" \u2193 ",
|
|
169
|
+
sessions.length - scrollOffset - VISIBLE_ITEMS,
|
|
170
|
+
" more below"))));
|
|
171
|
+
}
|
|
@@ -5,20 +5,22 @@ import Gradient from 'ink-gradient';
|
|
|
5
5
|
import ChatInput from '../components/ChatInput.js';
|
|
6
6
|
import PendingMessages from '../components/PendingMessages.js';
|
|
7
7
|
import MCPInfoScreen from '../components/MCPInfoScreen.js';
|
|
8
|
-
import
|
|
8
|
+
import MCPInfoPanel from '../components/MCPInfoPanel.js';
|
|
9
|
+
import SessionListPanel from '../components/SessionListPanel.js';
|
|
9
10
|
import MarkdownRenderer from '../components/MarkdownRenderer.js';
|
|
10
11
|
import ToolConfirmation from '../components/ToolConfirmation.js';
|
|
11
12
|
import DiffViewer from '../components/DiffViewer.js';
|
|
12
13
|
import ToolResultPreview from '../components/ToolResultPreview.js';
|
|
13
14
|
import TodoTree from '../components/TodoTree.js';
|
|
15
|
+
import FileRollbackConfirmation from '../components/FileRollbackConfirmation.js';
|
|
14
16
|
import { getOpenAiConfig } from '../../utils/apiConfig.js';
|
|
15
17
|
import { sessionManager } from '../../utils/sessionManager.js';
|
|
16
18
|
import { useSessionSave } from '../../hooks/useSessionSave.js';
|
|
17
|
-
import { useSessionManagement } from '../../hooks/useSessionManagement.js';
|
|
18
19
|
import { useToolConfirmation } from '../../hooks/useToolConfirmation.js';
|
|
19
20
|
import { handleConversationWithTools } from '../../hooks/useConversation.js';
|
|
20
21
|
import { parseAndValidateFileReferences, createMessageWithFileInstructions, getSystemInfo } from '../../utils/fileUtils.js';
|
|
21
22
|
import { compressContext } from '../../utils/contextCompressor.js';
|
|
23
|
+
import { incrementalSnapshotManager } from '../../utils/incrementalSnapshot.js';
|
|
22
24
|
// Import commands to register them
|
|
23
25
|
import '../../utils/commands/clear.js';
|
|
24
26
|
import '../../utils/commands/resume.js';
|
|
@@ -68,6 +70,10 @@ export default function ChatScreen({}) {
|
|
|
68
70
|
const [editorContext, setEditorContext] = useState({});
|
|
69
71
|
const [isCompressing, setIsCompressing] = useState(false);
|
|
70
72
|
const [compressionError, setCompressionError] = useState(null);
|
|
73
|
+
const [showSessionPanel, setShowSessionPanel] = useState(false);
|
|
74
|
+
const [showMcpPanel, setShowMcpPanel] = useState(false);
|
|
75
|
+
const [snapshotFileCount, setSnapshotFileCount] = useState(new Map());
|
|
76
|
+
const [pendingRollback, setPendingRollback] = useState(null);
|
|
71
77
|
const { stdout } = useStdout();
|
|
72
78
|
const terminalHeight = stdout?.rows || 24;
|
|
73
79
|
const workingDirectory = process.cwd();
|
|
@@ -81,8 +87,6 @@ export default function ChatScreen({}) {
|
|
|
81
87
|
}, [pendingMessages]);
|
|
82
88
|
// Use tool confirmation hook
|
|
83
89
|
const { pendingToolConfirmation, requestToolConfirmation, isToolAutoApproved, addMultipleToAlwaysApproved } = useToolConfirmation();
|
|
84
|
-
// Use session management hook
|
|
85
|
-
const { showSessionList, setShowSessionList, handleSessionSelect, handleBackFromSessionList } = useSessionManagement(setMessages, setPendingMessages, setIsStreaming, setRemountKey, initializeFromSession);
|
|
86
90
|
// Animation for streaming/saving indicator
|
|
87
91
|
useEffect(() => {
|
|
88
92
|
if (!isStreaming && !isSaving)
|
|
@@ -171,6 +175,21 @@ export default function ChatScreen({}) {
|
|
|
171
175
|
unsubscribe();
|
|
172
176
|
};
|
|
173
177
|
}, [vscodeConnectionStatus]);
|
|
178
|
+
// Load snapshot file counts when session changes
|
|
179
|
+
useEffect(() => {
|
|
180
|
+
const loadSnapshotFileCounts = async () => {
|
|
181
|
+
const currentSession = sessionManager.getCurrentSession();
|
|
182
|
+
if (!currentSession)
|
|
183
|
+
return;
|
|
184
|
+
const snapshots = await incrementalSnapshotManager.listSnapshots(currentSession.id);
|
|
185
|
+
const counts = new Map();
|
|
186
|
+
for (const snapshot of snapshots) {
|
|
187
|
+
counts.set(snapshot.messageIndex, snapshot.fileCount);
|
|
188
|
+
}
|
|
189
|
+
setSnapshotFileCount(counts);
|
|
190
|
+
};
|
|
191
|
+
loadSnapshotFileCounts();
|
|
192
|
+
}, [messages.length]); // Reload when messages change
|
|
174
193
|
// Pending messages are now handled inline during tool execution in useConversation
|
|
175
194
|
// Auto-send pending messages when streaming completely stops (as fallback)
|
|
176
195
|
useEffect(() => {
|
|
@@ -184,6 +203,24 @@ export default function ChatScreen({}) {
|
|
|
184
203
|
}, [isStreaming, pendingMessages.length]);
|
|
185
204
|
// ESC key handler to interrupt streaming or close overlays
|
|
186
205
|
useInput((_, key) => {
|
|
206
|
+
if (pendingRollback) {
|
|
207
|
+
if (key.escape) {
|
|
208
|
+
setPendingRollback(null);
|
|
209
|
+
}
|
|
210
|
+
return;
|
|
211
|
+
}
|
|
212
|
+
if (showSessionPanel) {
|
|
213
|
+
if (key.escape) {
|
|
214
|
+
setShowSessionPanel(false);
|
|
215
|
+
}
|
|
216
|
+
return;
|
|
217
|
+
}
|
|
218
|
+
if (showMcpPanel) {
|
|
219
|
+
if (key.escape) {
|
|
220
|
+
setShowMcpPanel(false);
|
|
221
|
+
}
|
|
222
|
+
return;
|
|
223
|
+
}
|
|
187
224
|
if (showMcpInfo) {
|
|
188
225
|
if (key.escape) {
|
|
189
226
|
setShowMcpInfo(false);
|
|
@@ -193,7 +230,7 @@ export default function ChatScreen({}) {
|
|
|
193
230
|
if (key.escape && isStreaming && abortController) {
|
|
194
231
|
// Abort the controller
|
|
195
232
|
abortController.abort();
|
|
196
|
-
//
|
|
233
|
+
// Add discontinued message
|
|
197
234
|
setMessages(prev => [...prev, {
|
|
198
235
|
role: 'assistant',
|
|
199
236
|
content: '',
|
|
@@ -293,8 +330,14 @@ export default function ChatScreen({}) {
|
|
|
293
330
|
};
|
|
294
331
|
setMessages([commandMessage]);
|
|
295
332
|
}
|
|
296
|
-
else if (result.success && result.action === '
|
|
297
|
-
|
|
333
|
+
else if (result.success && result.action === 'showSessionPanel') {
|
|
334
|
+
setShowSessionPanel(true);
|
|
335
|
+
const commandMessage = {
|
|
336
|
+
role: 'command',
|
|
337
|
+
content: '',
|
|
338
|
+
commandName: commandName
|
|
339
|
+
};
|
|
340
|
+
setMessages(prev => [...prev, commandMessage]);
|
|
298
341
|
}
|
|
299
342
|
else if (result.success && result.action === 'showMcpInfo') {
|
|
300
343
|
setShowMcpInfo(true);
|
|
@@ -306,6 +349,15 @@ export default function ChatScreen({}) {
|
|
|
306
349
|
};
|
|
307
350
|
setMessages(prev => [...prev, commandMessage]);
|
|
308
351
|
}
|
|
352
|
+
else if (result.success && result.action === 'showMcpPanel') {
|
|
353
|
+
setShowMcpPanel(true);
|
|
354
|
+
const commandMessage = {
|
|
355
|
+
role: 'command',
|
|
356
|
+
content: '',
|
|
357
|
+
commandName: commandName
|
|
358
|
+
};
|
|
359
|
+
setMessages(prev => [...prev, commandMessage]);
|
|
360
|
+
}
|
|
309
361
|
else if (result.success && result.action === 'goHome') {
|
|
310
362
|
navigateTo('welcome');
|
|
311
363
|
}
|
|
@@ -330,11 +382,53 @@ export default function ChatScreen({}) {
|
|
|
330
382
|
processMessage(result.prompt, undefined, true, true);
|
|
331
383
|
}
|
|
332
384
|
};
|
|
333
|
-
const handleHistorySelect = (selectedIndex, _message) => {
|
|
385
|
+
const handleHistorySelect = async (selectedIndex, _message) => {
|
|
386
|
+
// Check if there are files to rollback
|
|
387
|
+
const fileCount = snapshotFileCount.get(selectedIndex) || 0;
|
|
388
|
+
if (fileCount > 0) {
|
|
389
|
+
// Show confirmation dialog
|
|
390
|
+
setPendingRollback({ messageIndex: selectedIndex, fileCount });
|
|
391
|
+
}
|
|
392
|
+
else {
|
|
393
|
+
// No files to rollback, just rollback conversation
|
|
394
|
+
performRollback(selectedIndex, false);
|
|
395
|
+
}
|
|
396
|
+
};
|
|
397
|
+
const performRollback = async (selectedIndex, rollbackFiles) => {
|
|
398
|
+
// Rollback workspace to checkpoint if requested
|
|
399
|
+
if (rollbackFiles) {
|
|
400
|
+
const currentSession = sessionManager.getCurrentSession();
|
|
401
|
+
if (currentSession) {
|
|
402
|
+
await incrementalSnapshotManager.rollbackToSnapshot(currentSession.id, selectedIndex);
|
|
403
|
+
}
|
|
404
|
+
}
|
|
334
405
|
// Truncate messages array to remove the selected user message and everything after it
|
|
335
406
|
setMessages(prev => prev.slice(0, selectedIndex));
|
|
336
407
|
clearSavedMessages();
|
|
337
408
|
setRemountKey(prev => prev + 1);
|
|
409
|
+
// Clear pending rollback dialog
|
|
410
|
+
setPendingRollback(null);
|
|
411
|
+
};
|
|
412
|
+
const handleRollbackConfirm = (rollbackFiles) => {
|
|
413
|
+
if (pendingRollback) {
|
|
414
|
+
performRollback(pendingRollback.messageIndex, rollbackFiles);
|
|
415
|
+
}
|
|
416
|
+
};
|
|
417
|
+
const handleSessionPanelSelect = async (sessionId) => {
|
|
418
|
+
setShowSessionPanel(false);
|
|
419
|
+
try {
|
|
420
|
+
const session = await sessionManager.loadSession(sessionId);
|
|
421
|
+
if (session) {
|
|
422
|
+
initializeFromSession(session.messages);
|
|
423
|
+
setMessages(session.messages);
|
|
424
|
+
setPendingMessages([]);
|
|
425
|
+
setIsStreaming(false);
|
|
426
|
+
setRemountKey(prev => prev + 1);
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
catch (error) {
|
|
430
|
+
console.error('Failed to load session:', error);
|
|
431
|
+
}
|
|
338
432
|
};
|
|
339
433
|
const handleMessageSubmit = async (message, images) => {
|
|
340
434
|
// If streaming, add to pending messages instead of sending immediately
|
|
@@ -342,6 +436,15 @@ export default function ChatScreen({}) {
|
|
|
342
436
|
setPendingMessages(prev => [...prev, message]);
|
|
343
437
|
return;
|
|
344
438
|
}
|
|
439
|
+
// Create checkpoint (lightweight, only tracks modifications)
|
|
440
|
+
const currentSession = sessionManager.getCurrentSession();
|
|
441
|
+
if (!currentSession) {
|
|
442
|
+
await sessionManager.createNewSession();
|
|
443
|
+
}
|
|
444
|
+
const session = sessionManager.getCurrentSession();
|
|
445
|
+
if (session) {
|
|
446
|
+
await incrementalSnapshotManager.createSnapshot(session.id, messages.length);
|
|
447
|
+
}
|
|
345
448
|
// Process the message normally
|
|
346
449
|
await processMessage(message, images);
|
|
347
450
|
};
|
|
@@ -393,7 +496,8 @@ export default function ChatScreen({}) {
|
|
|
393
496
|
setContextUsage,
|
|
394
497
|
useBasicModel,
|
|
395
498
|
getPendingMessages: () => pendingMessagesRef.current,
|
|
396
|
-
clearPendingMessages: () => setPendingMessages([])
|
|
499
|
+
clearPendingMessages: () => setPendingMessages([]),
|
|
500
|
+
setIsStreaming
|
|
397
501
|
});
|
|
398
502
|
}
|
|
399
503
|
catch (error) {
|
|
@@ -455,7 +559,8 @@ export default function ChatScreen({}) {
|
|
|
455
559
|
yoloMode,
|
|
456
560
|
setContextUsage,
|
|
457
561
|
getPendingMessages: () => pendingMessagesRef.current,
|
|
458
|
-
clearPendingMessages: () => setPendingMessages([])
|
|
562
|
+
clearPendingMessages: () => setPendingMessages([]),
|
|
563
|
+
setIsStreaming
|
|
459
564
|
});
|
|
460
565
|
}
|
|
461
566
|
catch (error) {
|
|
@@ -477,10 +582,6 @@ export default function ChatScreen({}) {
|
|
|
477
582
|
setStreamTokenCount(0);
|
|
478
583
|
}
|
|
479
584
|
};
|
|
480
|
-
// If showing session list, only render that
|
|
481
|
-
if (showSessionList) {
|
|
482
|
-
return (React.createElement(SessionListScreenWrapper, { onBack: handleBackFromSessionList, onSelectSession: handleSessionSelect }));
|
|
483
|
-
}
|
|
484
585
|
if (showMcpInfo) {
|
|
485
586
|
return (React.createElement(MCPInfoScreen, { onClose: () => setShowMcpInfo(false), panelKey: mcpPanelKey }));
|
|
486
587
|
}
|
|
@@ -499,7 +600,7 @@ export default function ChatScreen({}) {
|
|
|
499
600
|
React.createElement(Box, { marginTop: 1 },
|
|
500
601
|
React.createElement(Text, { color: "gray", dimColor: true }, "Please resize your terminal window to continue."))));
|
|
501
602
|
}
|
|
502
|
-
return (React.createElement(Box, { flexDirection: "column" },
|
|
603
|
+
return (React.createElement(Box, { flexDirection: "column", height: "100%" },
|
|
503
604
|
React.createElement(Static, { key: remountKey, items: [
|
|
504
605
|
React.createElement(Box, { key: "header", marginX: 1, borderColor: 'cyan', borderStyle: "round", paddingX: 2, paddingY: 1 },
|
|
505
606
|
React.createElement(Box, { flexDirection: "column" },
|
|
@@ -617,11 +718,18 @@ export default function ChatScreen({}) {
|
|
|
617
718
|
React.createElement(Box, { marginX: 1 },
|
|
618
719
|
React.createElement(PendingMessages, { pendingMessages: pendingMessages })),
|
|
619
720
|
pendingToolConfirmation && (React.createElement(ToolConfirmation, { toolName: pendingToolConfirmation.batchToolNames || pendingToolConfirmation.tool.function.name, onConfirm: pendingToolConfirmation.resolve })),
|
|
620
|
-
|
|
721
|
+
showSessionPanel && (React.createElement(Box, { marginX: 1 },
|
|
722
|
+
React.createElement(SessionListPanel, { onSelectSession: handleSessionPanelSelect, onClose: () => setShowSessionPanel(false) }))),
|
|
723
|
+
showMcpPanel && (React.createElement(Box, { marginX: 1, flexDirection: "column" },
|
|
724
|
+
React.createElement(MCPInfoPanel, null),
|
|
725
|
+
React.createElement(Box, { marginTop: 1 },
|
|
726
|
+
React.createElement(Text, { color: "gray", dimColor: true }, "Press ESC to close")))),
|
|
727
|
+
pendingRollback && (React.createElement(FileRollbackConfirmation, { fileCount: pendingRollback.fileCount, onConfirm: handleRollbackConfirm })),
|
|
728
|
+
!pendingToolConfirmation && !isCompressing && !showSessionPanel && !showMcpPanel && !pendingRollback && (React.createElement(React.Fragment, null,
|
|
621
729
|
React.createElement(ChatInput, { onSubmit: handleMessageSubmit, onCommand: handleCommandExecution, placeholder: "Ask me anything about coding...", disabled: !!pendingToolConfirmation, chatHistory: messages, onHistorySelect: handleHistorySelect, yoloMode: yoloMode, contextUsage: contextUsage ? {
|
|
622
730
|
inputTokens: contextUsage.prompt_tokens,
|
|
623
731
|
maxContextTokens: getOpenAiConfig().maxContextTokens || 4000
|
|
624
|
-
} : undefined }),
|
|
732
|
+
} : undefined, snapshotFileCount: snapshotFileCount }),
|
|
625
733
|
vscodeConnectionStatus !== 'disconnected' && (React.createElement(Box, { marginTop: 1 },
|
|
626
734
|
React.createElement(Text, { color: vscodeConnectionStatus === 'connecting' ? 'yellow' :
|
|
627
735
|
vscodeConnectionStatus === 'connected' ? 'green' :
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* File checkpoint data structure
|
|
3
|
+
*/
|
|
4
|
+
export interface FileCheckpoint {
|
|
5
|
+
path: string;
|
|
6
|
+
content: string;
|
|
7
|
+
timestamp: number;
|
|
8
|
+
exists: boolean;
|
|
9
|
+
}
|
|
10
|
+
/**
|
|
11
|
+
* Conversation checkpoint data structure
|
|
12
|
+
*/
|
|
13
|
+
export interface ConversationCheckpoint {
|
|
14
|
+
sessionId: string;
|
|
15
|
+
messageCount: number;
|
|
16
|
+
fileSnapshots: FileCheckpoint[];
|
|
17
|
+
timestamp: number;
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* Checkpoint Manager
|
|
21
|
+
* Manages file snapshots for rollback on ESC interrupt
|
|
22
|
+
*/
|
|
23
|
+
declare class CheckpointManager {
|
|
24
|
+
private readonly checkpointsDir;
|
|
25
|
+
private activeCheckpoint;
|
|
26
|
+
constructor();
|
|
27
|
+
/**
|
|
28
|
+
* Ensure checkpoints directory exists
|
|
29
|
+
*/
|
|
30
|
+
private ensureCheckpointsDir;
|
|
31
|
+
/**
|
|
32
|
+
* Get checkpoint file path for a session
|
|
33
|
+
*/
|
|
34
|
+
private getCheckpointPath;
|
|
35
|
+
/**
|
|
36
|
+
* Create a new checkpoint before AI response
|
|
37
|
+
* @param sessionId - Current session ID
|
|
38
|
+
* @param messageCount - Number of messages before AI response
|
|
39
|
+
*/
|
|
40
|
+
createCheckpoint(sessionId: string, messageCount: number): Promise<void>;
|
|
41
|
+
/**
|
|
42
|
+
* Record a file snapshot before modification
|
|
43
|
+
* @param filePath - Absolute path to the file
|
|
44
|
+
*/
|
|
45
|
+
recordFileSnapshot(filePath: string): Promise<void>;
|
|
46
|
+
/**
|
|
47
|
+
* Save current checkpoint to disk
|
|
48
|
+
*/
|
|
49
|
+
private saveCheckpoint;
|
|
50
|
+
/**
|
|
51
|
+
* Load checkpoint from disk
|
|
52
|
+
*/
|
|
53
|
+
loadCheckpoint(sessionId: string): Promise<ConversationCheckpoint | null>;
|
|
54
|
+
/**
|
|
55
|
+
* Rollback files to checkpoint state
|
|
56
|
+
* @param sessionId - Session ID to rollback
|
|
57
|
+
* @returns Number of messages to rollback to, or null if no checkpoint
|
|
58
|
+
*/
|
|
59
|
+
rollback(sessionId: string): Promise<number | null>;
|
|
60
|
+
/**
|
|
61
|
+
* Clear checkpoint for a session
|
|
62
|
+
*/
|
|
63
|
+
clearCheckpoint(sessionId: string): Promise<void>;
|
|
64
|
+
/**
|
|
65
|
+
* Get active checkpoint
|
|
66
|
+
*/
|
|
67
|
+
getActiveCheckpoint(): ConversationCheckpoint | null;
|
|
68
|
+
/**
|
|
69
|
+
* Clear active checkpoint (used when conversation completes successfully)
|
|
70
|
+
*/
|
|
71
|
+
commitCheckpoint(): Promise<void>;
|
|
72
|
+
}
|
|
73
|
+
export declare const checkpointManager: CheckpointManager;
|
|
74
|
+
export {};
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
import fs from 'fs/promises';
|
|
2
|
+
import path from 'path';
|
|
3
|
+
import os from 'os';
|
|
4
|
+
/**
|
|
5
|
+
* Checkpoint Manager
|
|
6
|
+
* Manages file snapshots for rollback on ESC interrupt
|
|
7
|
+
*/
|
|
8
|
+
class CheckpointManager {
|
|
9
|
+
constructor() {
|
|
10
|
+
Object.defineProperty(this, "checkpointsDir", {
|
|
11
|
+
enumerable: true,
|
|
12
|
+
configurable: true,
|
|
13
|
+
writable: true,
|
|
14
|
+
value: void 0
|
|
15
|
+
});
|
|
16
|
+
Object.defineProperty(this, "activeCheckpoint", {
|
|
17
|
+
enumerable: true,
|
|
18
|
+
configurable: true,
|
|
19
|
+
writable: true,
|
|
20
|
+
value: null
|
|
21
|
+
});
|
|
22
|
+
this.checkpointsDir = path.join(os.homedir(), '.snow', 'checkpoints');
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* Ensure checkpoints directory exists
|
|
26
|
+
*/
|
|
27
|
+
async ensureCheckpointsDir() {
|
|
28
|
+
try {
|
|
29
|
+
await fs.mkdir(this.checkpointsDir, { recursive: true });
|
|
30
|
+
}
|
|
31
|
+
catch (error) {
|
|
32
|
+
// Directory already exists or other error
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* Get checkpoint file path for a session
|
|
37
|
+
*/
|
|
38
|
+
getCheckpointPath(sessionId) {
|
|
39
|
+
return path.join(this.checkpointsDir, `${sessionId}.json`);
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* Create a new checkpoint before AI response
|
|
43
|
+
* @param sessionId - Current session ID
|
|
44
|
+
* @param messageCount - Number of messages before AI response
|
|
45
|
+
*/
|
|
46
|
+
async createCheckpoint(sessionId, messageCount) {
|
|
47
|
+
await this.ensureCheckpointsDir();
|
|
48
|
+
this.activeCheckpoint = {
|
|
49
|
+
sessionId,
|
|
50
|
+
messageCount,
|
|
51
|
+
fileSnapshots: [],
|
|
52
|
+
timestamp: Date.now()
|
|
53
|
+
};
|
|
54
|
+
// Save checkpoint immediately (will be updated as files are modified)
|
|
55
|
+
await this.saveCheckpoint();
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* Record a file snapshot before modification
|
|
59
|
+
* @param filePath - Absolute path to the file
|
|
60
|
+
*/
|
|
61
|
+
async recordFileSnapshot(filePath) {
|
|
62
|
+
if (!this.activeCheckpoint) {
|
|
63
|
+
return; // No active checkpoint, skip
|
|
64
|
+
}
|
|
65
|
+
// Check if this file already has a snapshot
|
|
66
|
+
const existingSnapshot = this.activeCheckpoint.fileSnapshots.find(snapshot => snapshot.path === filePath);
|
|
67
|
+
if (existingSnapshot) {
|
|
68
|
+
return; // Already recorded, skip
|
|
69
|
+
}
|
|
70
|
+
try {
|
|
71
|
+
// Try to read existing file content
|
|
72
|
+
const content = await fs.readFile(filePath, 'utf-8');
|
|
73
|
+
this.activeCheckpoint.fileSnapshots.push({
|
|
74
|
+
path: filePath,
|
|
75
|
+
content,
|
|
76
|
+
timestamp: Date.now(),
|
|
77
|
+
exists: true
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
catch (error) {
|
|
81
|
+
// File doesn't exist, record as non-existent
|
|
82
|
+
this.activeCheckpoint.fileSnapshots.push({
|
|
83
|
+
path: filePath,
|
|
84
|
+
content: '',
|
|
85
|
+
timestamp: Date.now(),
|
|
86
|
+
exists: false
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
// Update checkpoint file
|
|
90
|
+
await this.saveCheckpoint();
|
|
91
|
+
}
|
|
92
|
+
/**
|
|
93
|
+
* Save current checkpoint to disk
|
|
94
|
+
*/
|
|
95
|
+
async saveCheckpoint() {
|
|
96
|
+
if (!this.activeCheckpoint) {
|
|
97
|
+
return;
|
|
98
|
+
}
|
|
99
|
+
await this.ensureCheckpointsDir();
|
|
100
|
+
const checkpointPath = this.getCheckpointPath(this.activeCheckpoint.sessionId);
|
|
101
|
+
await fs.writeFile(checkpointPath, JSON.stringify(this.activeCheckpoint, null, 2));
|
|
102
|
+
}
|
|
103
|
+
/**
|
|
104
|
+
* Load checkpoint from disk
|
|
105
|
+
*/
|
|
106
|
+
async loadCheckpoint(sessionId) {
|
|
107
|
+
try {
|
|
108
|
+
const checkpointPath = this.getCheckpointPath(sessionId);
|
|
109
|
+
const data = await fs.readFile(checkpointPath, 'utf-8');
|
|
110
|
+
return JSON.parse(data);
|
|
111
|
+
}
|
|
112
|
+
catch (error) {
|
|
113
|
+
return null;
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
/**
|
|
117
|
+
* Rollback files to checkpoint state
|
|
118
|
+
* @param sessionId - Session ID to rollback
|
|
119
|
+
* @returns Number of messages to rollback to, or null if no checkpoint
|
|
120
|
+
*/
|
|
121
|
+
async rollback(sessionId) {
|
|
122
|
+
const checkpoint = await this.loadCheckpoint(sessionId);
|
|
123
|
+
if (!checkpoint) {
|
|
124
|
+
return null;
|
|
125
|
+
}
|
|
126
|
+
// Rollback all file snapshots
|
|
127
|
+
for (const snapshot of checkpoint.fileSnapshots) {
|
|
128
|
+
try {
|
|
129
|
+
if (snapshot.exists) {
|
|
130
|
+
// Restore original file content
|
|
131
|
+
await fs.writeFile(snapshot.path, snapshot.content, 'utf-8');
|
|
132
|
+
}
|
|
133
|
+
else {
|
|
134
|
+
// Delete file that was created
|
|
135
|
+
try {
|
|
136
|
+
await fs.unlink(snapshot.path);
|
|
137
|
+
}
|
|
138
|
+
catch (error) {
|
|
139
|
+
// File may already be deleted, ignore
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
catch (error) {
|
|
144
|
+
console.error(`Failed to rollback file ${snapshot.path}:`, error);
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
// Clear checkpoint after rollback
|
|
148
|
+
await this.clearCheckpoint(sessionId);
|
|
149
|
+
return checkpoint.messageCount;
|
|
150
|
+
}
|
|
151
|
+
/**
|
|
152
|
+
* Clear checkpoint for a session
|
|
153
|
+
*/
|
|
154
|
+
async clearCheckpoint(sessionId) {
|
|
155
|
+
try {
|
|
156
|
+
const checkpointPath = this.getCheckpointPath(sessionId);
|
|
157
|
+
await fs.unlink(checkpointPath);
|
|
158
|
+
}
|
|
159
|
+
catch (error) {
|
|
160
|
+
// Checkpoint may not exist, ignore
|
|
161
|
+
}
|
|
162
|
+
if (this.activeCheckpoint?.sessionId === sessionId) {
|
|
163
|
+
this.activeCheckpoint = null;
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
/**
|
|
167
|
+
* Get active checkpoint
|
|
168
|
+
*/
|
|
169
|
+
getActiveCheckpoint() {
|
|
170
|
+
return this.activeCheckpoint;
|
|
171
|
+
}
|
|
172
|
+
/**
|
|
173
|
+
* Clear active checkpoint (used when conversation completes successfully)
|
|
174
|
+
*/
|
|
175
|
+
async commitCheckpoint() {
|
|
176
|
+
if (this.activeCheckpoint) {
|
|
177
|
+
await this.clearCheckpoint(this.activeCheckpoint.sessionId);
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
export const checkpointManager = new CheckpointManager();
|