snow-ai 0.2.15 → 0.2.16
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/api/anthropic.d.ts +1 -1
- package/dist/api/anthropic.js +52 -76
- package/dist/api/chat.d.ts +4 -4
- package/dist/api/chat.js +32 -17
- package/dist/api/gemini.d.ts +1 -1
- package/dist/api/gemini.js +20 -13
- package/dist/api/responses.d.ts +5 -5
- package/dist/api/responses.js +29 -27
- package/dist/app.js +4 -1
- package/dist/hooks/useClipboard.d.ts +4 -0
- package/dist/hooks/useClipboard.js +120 -0
- package/dist/hooks/useCommandHandler.d.ts +26 -0
- package/dist/hooks/useCommandHandler.js +158 -0
- package/dist/hooks/useCommandPanel.d.ts +16 -0
- package/dist/hooks/useCommandPanel.js +53 -0
- package/dist/hooks/useConversation.d.ts +9 -1
- package/dist/hooks/useConversation.js +152 -58
- package/dist/hooks/useFilePicker.d.ts +17 -0
- package/dist/hooks/useFilePicker.js +91 -0
- package/dist/hooks/useHistoryNavigation.d.ts +21 -0
- package/dist/hooks/useHistoryNavigation.js +50 -0
- package/dist/hooks/useInputBuffer.d.ts +6 -0
- package/dist/hooks/useInputBuffer.js +29 -0
- package/dist/hooks/useKeyboardInput.d.ts +51 -0
- package/dist/hooks/useKeyboardInput.js +272 -0
- package/dist/hooks/useSnapshotState.d.ts +12 -0
- package/dist/hooks/useSnapshotState.js +28 -0
- package/dist/hooks/useStreamingState.d.ts +24 -0
- package/dist/hooks/useStreamingState.js +96 -0
- package/dist/hooks/useVSCodeState.d.ts +8 -0
- package/dist/hooks/useVSCodeState.js +63 -0
- package/dist/mcp/filesystem.d.ts +24 -5
- package/dist/mcp/filesystem.js +52 -17
- package/dist/mcp/todo.js +4 -8
- package/dist/ui/components/ChatInput.js +68 -557
- package/dist/ui/components/DiffViewer.js +57 -30
- package/dist/ui/components/FileList.js +70 -26
- package/dist/ui/components/MessageList.d.ts +6 -0
- package/dist/ui/components/MessageList.js +47 -15
- package/dist/ui/components/ShimmerText.d.ts +9 -0
- package/dist/ui/components/ShimmerText.js +30 -0
- package/dist/ui/components/TodoTree.d.ts +1 -1
- package/dist/ui/components/TodoTree.js +0 -4
- package/dist/ui/components/ToolConfirmation.js +14 -6
- package/dist/ui/pages/ChatScreen.js +159 -359
- package/dist/ui/pages/CustomHeadersScreen.d.ts +6 -0
- package/dist/ui/pages/CustomHeadersScreen.js +104 -0
- package/dist/ui/pages/WelcomeScreen.js +5 -0
- package/dist/utils/apiConfig.d.ts +10 -0
- package/dist/utils/apiConfig.js +51 -0
- package/dist/utils/incrementalSnapshot.d.ts +8 -0
- package/dist/utils/incrementalSnapshot.js +63 -0
- package/dist/utils/mcpToolsManager.js +6 -1
- package/dist/utils/retryUtils.d.ts +22 -0
- package/dist/utils/retryUtils.js +180 -0
- package/dist/utils/sessionConverter.js +80 -17
- package/dist/utils/sessionManager.js +35 -4
- package/dist/utils/textUtils.d.ts +4 -0
- package/dist/utils/textUtils.js +19 -0
- package/dist/utils/todoPreprocessor.d.ts +1 -1
- package/dist/utils/todoPreprocessor.js +0 -1
- package/dist/utils/vscodeConnection.d.ts +8 -0
- package/dist/utils/vscodeConnection.js +44 -0
- package/package.json +1 -1
- package/readme.md +3 -1
|
@@ -1,30 +1,14 @@
|
|
|
1
|
-
import React, {
|
|
2
|
-
import { Box, Text, useStdout
|
|
3
|
-
import { TextBuffer } from '../../utils/textBuffer.js';
|
|
1
|
+
import React, { useCallback, useEffect } from 'react';
|
|
2
|
+
import { Box, Text, useStdout } from 'ink';
|
|
4
3
|
import { cpSlice, cpLen } from '../../utils/textUtils.js';
|
|
5
4
|
import CommandPanel from './CommandPanel.js';
|
|
6
|
-
import { executeCommand } from '../../utils/commandExecutor.js';
|
|
7
5
|
import FileList from './FileList.js';
|
|
8
|
-
import {
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
{
|
|
15
|
-
name: 'yolo',
|
|
16
|
-
description: 'Toggle unattended mode (auto-approve all tools)',
|
|
17
|
-
},
|
|
18
|
-
{
|
|
19
|
-
name: 'init',
|
|
20
|
-
description: 'Analyze project and generate/update SNOW.md documentation',
|
|
21
|
-
},
|
|
22
|
-
{ name: 'ide', description: 'Connect to VSCode editor and sync context' },
|
|
23
|
-
{
|
|
24
|
-
name: 'compact',
|
|
25
|
-
description: 'Compress conversation history using compact model',
|
|
26
|
-
},
|
|
27
|
-
];
|
|
6
|
+
import { useInputBuffer } from '../../hooks/useInputBuffer.js';
|
|
7
|
+
import { useCommandPanel } from '../../hooks/useCommandPanel.js';
|
|
8
|
+
import { useFilePicker } from '../../hooks/useFilePicker.js';
|
|
9
|
+
import { useHistoryNavigation } from '../../hooks/useHistoryNavigation.js';
|
|
10
|
+
import { useClipboard } from '../../hooks/useClipboard.js';
|
|
11
|
+
import { useKeyboardInput } from '../../hooks/useKeyboardInput.js';
|
|
28
12
|
export default function ChatInput({ onSubmit, onCommand, placeholder = 'Type your message...', disabled = false, chatHistory = [], onHistorySelect, yoloMode = false, contextUsage, snapshotFileCount, }) {
|
|
29
13
|
const { stdout } = useStdout();
|
|
30
14
|
const terminalWidth = stdout?.columns || 80;
|
|
@@ -33,150 +17,53 @@ export default function ChatInput({ onSubmit, onCommand, placeholder = 'Type you
|
|
|
33
17
|
width: Math.max(40, terminalWidth - uiOverhead),
|
|
34
18
|
height: 1,
|
|
35
19
|
};
|
|
36
|
-
|
|
37
|
-
const
|
|
38
|
-
//
|
|
39
|
-
const
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
},
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
}));
|
|
84
|
-
}, [chatHistory]);
|
|
85
|
-
// Get filtered commands based on current input
|
|
86
|
-
const getFilteredCommands = useCallback(() => {
|
|
87
|
-
const text = buffer.getFullText();
|
|
88
|
-
if (!text.startsWith('/'))
|
|
89
|
-
return [];
|
|
90
|
-
const query = text.slice(1).toLowerCase();
|
|
91
|
-
return commands.filter(command => command.name.toLowerCase().includes(query) ||
|
|
92
|
-
command.description.toLowerCase().includes(query));
|
|
93
|
-
}, [buffer]);
|
|
94
|
-
// Update command panel state
|
|
95
|
-
const updateCommandPanelState = useCallback((text) => {
|
|
96
|
-
if (text.startsWith('/') && text.length > 0) {
|
|
97
|
-
setShowCommands(true);
|
|
98
|
-
setCommandSelectedIndex(0);
|
|
99
|
-
}
|
|
100
|
-
else {
|
|
101
|
-
setShowCommands(false);
|
|
102
|
-
setCommandSelectedIndex(0);
|
|
103
|
-
}
|
|
104
|
-
}, []);
|
|
105
|
-
// Update file picker state
|
|
106
|
-
const updateFilePickerState = useCallback((text, cursorPos) => {
|
|
107
|
-
if (!text.includes('@')) {
|
|
108
|
-
if (showFilePicker) {
|
|
109
|
-
setShowFilePicker(false);
|
|
110
|
-
setFileSelectedIndex(0);
|
|
111
|
-
setFileQuery('');
|
|
112
|
-
setAtSymbolPosition(-1);
|
|
113
|
-
}
|
|
114
|
-
return;
|
|
115
|
-
}
|
|
116
|
-
// Find the last '@' symbol before the cursor
|
|
117
|
-
const beforeCursor = text.slice(0, cursorPos);
|
|
118
|
-
const lastAtIndex = beforeCursor.lastIndexOf('@');
|
|
119
|
-
if (lastAtIndex !== -1) {
|
|
120
|
-
// Check if there's no space between '@' and cursor
|
|
121
|
-
const afterAt = beforeCursor.slice(lastAtIndex + 1);
|
|
122
|
-
if (!afterAt.includes(' ') && !afterAt.includes('\n')) {
|
|
123
|
-
if (!showFilePicker ||
|
|
124
|
-
fileQuery !== afterAt ||
|
|
125
|
-
atSymbolPosition !== lastAtIndex) {
|
|
126
|
-
setShowFilePicker(true);
|
|
127
|
-
setFileSelectedIndex(0);
|
|
128
|
-
setFileQuery(afterAt);
|
|
129
|
-
setAtSymbolPosition(lastAtIndex);
|
|
130
|
-
}
|
|
131
|
-
return;
|
|
132
|
-
}
|
|
133
|
-
}
|
|
134
|
-
// Hide file picker if no valid @ context found
|
|
135
|
-
if (showFilePicker) {
|
|
136
|
-
setShowFilePicker(false);
|
|
137
|
-
setFileSelectedIndex(0);
|
|
138
|
-
setFileQuery('');
|
|
139
|
-
setAtSymbolPosition(-1);
|
|
140
|
-
}
|
|
141
|
-
}, [showFilePicker, fileQuery, atSymbolPosition]);
|
|
142
|
-
// Force immediate state update for critical operations like backspace
|
|
143
|
-
const forceStateUpdate = useCallback(() => {
|
|
144
|
-
const text = buffer.getFullText();
|
|
145
|
-
const cursorPos = buffer.getCursorPosition();
|
|
146
|
-
updateFilePickerState(text, cursorPos);
|
|
147
|
-
updateCommandPanelState(text);
|
|
148
|
-
forceUpdate({});
|
|
149
|
-
}, [buffer, updateFilePickerState, updateCommandPanelState]);
|
|
150
|
-
// Handle file selection
|
|
151
|
-
const handleFileSelect = useCallback(async (filePath) => {
|
|
152
|
-
if (atSymbolPosition !== -1) {
|
|
153
|
-
const text = buffer.getFullText();
|
|
154
|
-
const cursorPos = buffer.getCursorPosition();
|
|
155
|
-
// Replace @query with @filePath + space
|
|
156
|
-
const beforeAt = text.slice(0, atSymbolPosition);
|
|
157
|
-
const afterCursor = text.slice(cursorPos);
|
|
158
|
-
const newText = beforeAt + '@' + filePath + ' ' + afterCursor;
|
|
159
|
-
// Set the new text and position cursor after the inserted file path + space
|
|
160
|
-
buffer.setText(newText);
|
|
161
|
-
// Calculate cursor position after the inserted file path + space
|
|
162
|
-
// Reset cursor to beginning, then move to correct position
|
|
163
|
-
for (let i = 0; i < atSymbolPosition + filePath.length + 2; i++) {
|
|
164
|
-
// +2 for @ and space
|
|
165
|
-
if (i < buffer.getFullText().length) {
|
|
166
|
-
buffer.moveRight();
|
|
167
|
-
}
|
|
168
|
-
}
|
|
169
|
-
setShowFilePicker(false);
|
|
170
|
-
setFileSelectedIndex(0);
|
|
171
|
-
setFileQuery('');
|
|
172
|
-
setAtSymbolPosition(-1);
|
|
173
|
-
triggerUpdate();
|
|
174
|
-
}
|
|
175
|
-
}, [atSymbolPosition, buffer, triggerUpdate]);
|
|
176
|
-
// Handle filtered file count change
|
|
177
|
-
const handleFilteredCountChange = useCallback((count) => {
|
|
178
|
-
setFilteredFileCount(count);
|
|
179
|
-
}, []);
|
|
20
|
+
// Use input buffer hook
|
|
21
|
+
const { buffer, triggerUpdate, forceUpdate } = useInputBuffer(viewport);
|
|
22
|
+
// Use command panel hook
|
|
23
|
+
const { showCommands, setShowCommands, commandSelectedIndex, setCommandSelectedIndex, getFilteredCommands, updateCommandPanelState, } = useCommandPanel(buffer);
|
|
24
|
+
// Use file picker hook
|
|
25
|
+
const { showFilePicker, setShowFilePicker, fileSelectedIndex, setFileSelectedIndex, fileQuery, setFileQuery, atSymbolPosition, setAtSymbolPosition, filteredFileCount, updateFilePickerState, handleFileSelect, handleFilteredCountChange, fileListRef, } = useFilePicker(buffer, triggerUpdate);
|
|
26
|
+
// Use history navigation hook
|
|
27
|
+
const { showHistoryMenu, setShowHistoryMenu, historySelectedIndex, setHistorySelectedIndex, escapeKeyCount, setEscapeKeyCount, escapeKeyTimer, getUserMessages, handleHistorySelect, } = useHistoryNavigation(buffer, triggerUpdate, chatHistory, onHistorySelect);
|
|
28
|
+
// Use clipboard hook
|
|
29
|
+
const { pasteFromClipboard } = useClipboard(buffer, updateCommandPanelState, updateFilePickerState, triggerUpdate);
|
|
30
|
+
// Use keyboard input hook
|
|
31
|
+
useKeyboardInput({
|
|
32
|
+
buffer,
|
|
33
|
+
disabled,
|
|
34
|
+
triggerUpdate,
|
|
35
|
+
forceUpdate,
|
|
36
|
+
showCommands,
|
|
37
|
+
setShowCommands,
|
|
38
|
+
commandSelectedIndex,
|
|
39
|
+
setCommandSelectedIndex,
|
|
40
|
+
getFilteredCommands,
|
|
41
|
+
updateCommandPanelState,
|
|
42
|
+
onCommand,
|
|
43
|
+
showFilePicker,
|
|
44
|
+
setShowFilePicker,
|
|
45
|
+
fileSelectedIndex,
|
|
46
|
+
setFileSelectedIndex,
|
|
47
|
+
fileQuery,
|
|
48
|
+
setFileQuery,
|
|
49
|
+
atSymbolPosition,
|
|
50
|
+
setAtSymbolPosition,
|
|
51
|
+
filteredFileCount,
|
|
52
|
+
updateFilePickerState,
|
|
53
|
+
handleFileSelect,
|
|
54
|
+
fileListRef,
|
|
55
|
+
showHistoryMenu,
|
|
56
|
+
setShowHistoryMenu,
|
|
57
|
+
historySelectedIndex,
|
|
58
|
+
setHistorySelectedIndex,
|
|
59
|
+
escapeKeyCount,
|
|
60
|
+
setEscapeKeyCount,
|
|
61
|
+
escapeKeyTimer,
|
|
62
|
+
getUserMessages,
|
|
63
|
+
handleHistorySelect,
|
|
64
|
+
pasteFromClipboard,
|
|
65
|
+
onSubmit,
|
|
66
|
+
});
|
|
180
67
|
// Force full re-render when file picker visibility changes to prevent artifacts
|
|
181
68
|
useEffect(() => {
|
|
182
69
|
// Use a small delay to ensure the component tree has updated
|
|
@@ -184,393 +71,7 @@ export default function ChatInput({ onSubmit, onCommand, placeholder = 'Type you
|
|
|
184
71
|
forceUpdate({});
|
|
185
72
|
}, 10);
|
|
186
73
|
return () => clearTimeout(timer);
|
|
187
|
-
}, [showFilePicker]);
|
|
188
|
-
// Update buffer viewport when terminal width changes
|
|
189
|
-
useEffect(() => {
|
|
190
|
-
const newViewport = {
|
|
191
|
-
width: Math.max(40, terminalWidth - uiOverhead),
|
|
192
|
-
height: 1,
|
|
193
|
-
};
|
|
194
|
-
buffer.updateViewport(newViewport);
|
|
195
|
-
triggerUpdate();
|
|
196
|
-
}, [terminalWidth, buffer, triggerUpdate]);
|
|
197
|
-
// Track paste detection
|
|
198
|
-
const inputBuffer = useRef('');
|
|
199
|
-
const inputTimer = useRef(null);
|
|
200
|
-
// Handle input using useInput hook instead of raw stdin
|
|
201
|
-
useInput((input, key) => {
|
|
202
|
-
if (disabled)
|
|
203
|
-
return;
|
|
204
|
-
// Handle escape key for double-ESC history navigation
|
|
205
|
-
if (key.escape) {
|
|
206
|
-
// Close file picker if open
|
|
207
|
-
if (showFilePicker) {
|
|
208
|
-
setShowFilePicker(false);
|
|
209
|
-
setFileSelectedIndex(0);
|
|
210
|
-
setFileQuery('');
|
|
211
|
-
setAtSymbolPosition(-1);
|
|
212
|
-
return;
|
|
213
|
-
}
|
|
214
|
-
// Don't interfere with existing ESC behavior if in command panel
|
|
215
|
-
if (showCommands) {
|
|
216
|
-
setShowCommands(false);
|
|
217
|
-
setCommandSelectedIndex(0);
|
|
218
|
-
return;
|
|
219
|
-
}
|
|
220
|
-
// Handle history navigation
|
|
221
|
-
if (showHistoryMenu) {
|
|
222
|
-
setShowHistoryMenu(false);
|
|
223
|
-
return;
|
|
224
|
-
}
|
|
225
|
-
// Count escape key presses for double-ESC detection
|
|
226
|
-
setEscapeKeyCount(prev => prev + 1);
|
|
227
|
-
// Clear any existing timer
|
|
228
|
-
if (escapeKeyTimer.current) {
|
|
229
|
-
clearTimeout(escapeKeyTimer.current);
|
|
230
|
-
}
|
|
231
|
-
// Set timer to reset count after 500ms
|
|
232
|
-
escapeKeyTimer.current = setTimeout(() => {
|
|
233
|
-
setEscapeKeyCount(0);
|
|
234
|
-
}, 500);
|
|
235
|
-
// Check for double escape
|
|
236
|
-
if (escapeKeyCount >= 1) {
|
|
237
|
-
// This will be 2 after increment
|
|
238
|
-
const userMessages = getUserMessages();
|
|
239
|
-
if (userMessages.length > 0) {
|
|
240
|
-
setShowHistoryMenu(true);
|
|
241
|
-
setHistorySelectedIndex(0); // Reset selection to first item
|
|
242
|
-
setEscapeKeyCount(0);
|
|
243
|
-
if (escapeKeyTimer.current) {
|
|
244
|
-
clearTimeout(escapeKeyTimer.current);
|
|
245
|
-
escapeKeyTimer.current = null;
|
|
246
|
-
}
|
|
247
|
-
}
|
|
248
|
-
}
|
|
249
|
-
return;
|
|
250
|
-
}
|
|
251
|
-
// Handle history menu navigation
|
|
252
|
-
if (showHistoryMenu) {
|
|
253
|
-
const userMessages = getUserMessages();
|
|
254
|
-
// Up arrow in history menu
|
|
255
|
-
if (key.upArrow) {
|
|
256
|
-
setHistorySelectedIndex(prev => Math.max(0, prev - 1));
|
|
257
|
-
return;
|
|
258
|
-
}
|
|
259
|
-
// Down arrow in history menu
|
|
260
|
-
if (key.downArrow) {
|
|
261
|
-
const maxIndex = Math.max(0, userMessages.length - 1);
|
|
262
|
-
setHistorySelectedIndex(prev => Math.min(maxIndex, prev + 1));
|
|
263
|
-
return;
|
|
264
|
-
}
|
|
265
|
-
// Enter - select history item
|
|
266
|
-
if (key.return) {
|
|
267
|
-
if (userMessages.length > 0 &&
|
|
268
|
-
historySelectedIndex < userMessages.length) {
|
|
269
|
-
const selectedMessage = userMessages[historySelectedIndex];
|
|
270
|
-
if (selectedMessage) {
|
|
271
|
-
handleHistorySelect(selectedMessage.value);
|
|
272
|
-
}
|
|
273
|
-
}
|
|
274
|
-
return;
|
|
275
|
-
}
|
|
276
|
-
// For any other key in history menu, just return to prevent interference
|
|
277
|
-
return;
|
|
278
|
-
}
|
|
279
|
-
// Ctrl+L - Delete from cursor to beginning
|
|
280
|
-
if (key.ctrl && input === 'l') {
|
|
281
|
-
const fullText = buffer.getFullText();
|
|
282
|
-
const cursorPos = buffer.getCursorPosition();
|
|
283
|
-
const afterCursor = fullText.slice(cursorPos);
|
|
284
|
-
buffer.setText(afterCursor);
|
|
285
|
-
forceStateUpdate();
|
|
286
|
-
return;
|
|
287
|
-
}
|
|
288
|
-
// Ctrl+R - Delete from cursor to end
|
|
289
|
-
if (key.ctrl && input === 'r') {
|
|
290
|
-
const fullText = buffer.getFullText();
|
|
291
|
-
const cursorPos = buffer.getCursorPosition();
|
|
292
|
-
const beforeCursor = fullText.slice(0, cursorPos);
|
|
293
|
-
buffer.setText(beforeCursor);
|
|
294
|
-
forceStateUpdate();
|
|
295
|
-
return;
|
|
296
|
-
}
|
|
297
|
-
// Windows: Alt+V, macOS: Ctrl+V - Paste from clipboard (including images)
|
|
298
|
-
// In Ink, key.meta represents:
|
|
299
|
-
// - On Windows/Linux: Alt key (Meta key)
|
|
300
|
-
// - On macOS: We use Ctrl+V to avoid conflict with VSCode shortcuts
|
|
301
|
-
const isPasteShortcut = process.platform === 'darwin'
|
|
302
|
-
? key.ctrl && input === 'v'
|
|
303
|
-
: key.meta && input === 'v';
|
|
304
|
-
if (isPasteShortcut) {
|
|
305
|
-
try {
|
|
306
|
-
// Try to read image from clipboard
|
|
307
|
-
if (process.platform === 'win32') {
|
|
308
|
-
// Windows: Use PowerShell to read image from clipboard
|
|
309
|
-
try {
|
|
310
|
-
const psScript = `Add-Type -AssemblyName System.Windows.Forms; Add-Type -AssemblyName System.Drawing; $clipboard = [System.Windows.Forms.Clipboard]::GetImage(); if ($clipboard -ne $null) { $ms = New-Object System.IO.MemoryStream; $clipboard.Save($ms, [System.Drawing.Imaging.ImageFormat]::Png); $bytes = $ms.ToArray(); $ms.Close(); [Convert]::ToBase64String($bytes) }`;
|
|
311
|
-
const base64 = execSync(`powershell -Command "${psScript}"`, {
|
|
312
|
-
encoding: 'utf-8',
|
|
313
|
-
timeout: 5000,
|
|
314
|
-
}).trim();
|
|
315
|
-
if (base64 && base64.length > 100) {
|
|
316
|
-
const dataUrl = `data:image/png;base64,${base64}`;
|
|
317
|
-
buffer.insertImage(dataUrl, 'image/png');
|
|
318
|
-
const text = buffer.getFullText();
|
|
319
|
-
const cursorPos = buffer.getCursorPosition();
|
|
320
|
-
updateCommandPanelState(text);
|
|
321
|
-
updateFilePickerState(text, cursorPos);
|
|
322
|
-
triggerUpdate();
|
|
323
|
-
return;
|
|
324
|
-
}
|
|
325
|
-
}
|
|
326
|
-
catch (imgError) {
|
|
327
|
-
// No image in clipboard or error, fall through to text
|
|
328
|
-
}
|
|
329
|
-
}
|
|
330
|
-
else if (process.platform === 'darwin') {
|
|
331
|
-
// macOS: Use osascript to read image from clipboard
|
|
332
|
-
try {
|
|
333
|
-
// First check if there's an image in clipboard
|
|
334
|
-
const checkScript = `osascript -e 'try
|
|
335
|
-
set imgData to the clipboard as «class PNGf»
|
|
336
|
-
return "hasImage"
|
|
337
|
-
on error
|
|
338
|
-
return "noImage"
|
|
339
|
-
end try'`;
|
|
340
|
-
const hasImage = execSync(checkScript, {
|
|
341
|
-
encoding: 'utf-8',
|
|
342
|
-
timeout: 2000,
|
|
343
|
-
}).trim();
|
|
344
|
-
if (hasImage === 'hasImage') {
|
|
345
|
-
// Save clipboard image to temporary file and read it
|
|
346
|
-
const tmpFile = `/tmp/snow_clipboard_${Date.now()}.png`;
|
|
347
|
-
const saveScript = `osascript -e 'set imgData to the clipboard as «class PNGf»' -e 'set fileRef to open for access POSIX file "${tmpFile}" with write permission' -e 'write imgData to fileRef' -e 'close access fileRef'`;
|
|
348
|
-
execSync(saveScript, {
|
|
349
|
-
encoding: 'utf-8',
|
|
350
|
-
timeout: 3000,
|
|
351
|
-
});
|
|
352
|
-
// Read the file as base64
|
|
353
|
-
const base64 = execSync(`base64 -i "${tmpFile}"`, {
|
|
354
|
-
encoding: 'utf-8',
|
|
355
|
-
timeout: 2000,
|
|
356
|
-
}).trim();
|
|
357
|
-
// Clean up temp file
|
|
358
|
-
try {
|
|
359
|
-
execSync(`rm "${tmpFile}"`, { timeout: 1000 });
|
|
360
|
-
}
|
|
361
|
-
catch (e) {
|
|
362
|
-
// Ignore cleanup errors
|
|
363
|
-
}
|
|
364
|
-
if (base64 && base64.length > 100) {
|
|
365
|
-
const dataUrl = `data:image/png;base64,${base64}`;
|
|
366
|
-
buffer.insertImage(dataUrl, 'image/png');
|
|
367
|
-
const text = buffer.getFullText();
|
|
368
|
-
const cursorPos = buffer.getCursorPosition();
|
|
369
|
-
updateCommandPanelState(text);
|
|
370
|
-
updateFilePickerState(text, cursorPos);
|
|
371
|
-
triggerUpdate();
|
|
372
|
-
return;
|
|
373
|
-
}
|
|
374
|
-
}
|
|
375
|
-
}
|
|
376
|
-
catch (imgError) {
|
|
377
|
-
// No image in clipboard or error, fall through to text
|
|
378
|
-
console.error('Failed to read image from macOS clipboard:', imgError);
|
|
379
|
-
}
|
|
380
|
-
}
|
|
381
|
-
// If no image, try to read text from clipboard
|
|
382
|
-
try {
|
|
383
|
-
let clipboardText = '';
|
|
384
|
-
if (process.platform === 'win32') {
|
|
385
|
-
clipboardText = execSync('powershell -Command "Get-Clipboard"', {
|
|
386
|
-
encoding: 'utf-8',
|
|
387
|
-
timeout: 2000,
|
|
388
|
-
}).trim();
|
|
389
|
-
}
|
|
390
|
-
else if (process.platform === 'darwin') {
|
|
391
|
-
clipboardText = execSync('pbpaste', {
|
|
392
|
-
encoding: 'utf-8',
|
|
393
|
-
timeout: 2000,
|
|
394
|
-
}).trim();
|
|
395
|
-
}
|
|
396
|
-
else {
|
|
397
|
-
clipboardText = execSync('xclip -selection clipboard -o', {
|
|
398
|
-
encoding: 'utf-8',
|
|
399
|
-
timeout: 2000,
|
|
400
|
-
}).trim();
|
|
401
|
-
}
|
|
402
|
-
if (clipboardText) {
|
|
403
|
-
buffer.insert(clipboardText);
|
|
404
|
-
const fullText = buffer.getFullText();
|
|
405
|
-
const cursorPos = buffer.getCursorPosition();
|
|
406
|
-
updateCommandPanelState(fullText);
|
|
407
|
-
updateFilePickerState(fullText, cursorPos);
|
|
408
|
-
triggerUpdate();
|
|
409
|
-
}
|
|
410
|
-
}
|
|
411
|
-
catch (textError) {
|
|
412
|
-
console.error('Failed to read text from clipboard:', textError);
|
|
413
|
-
}
|
|
414
|
-
}
|
|
415
|
-
catch (error) {
|
|
416
|
-
console.error('Failed to read from clipboard:', error);
|
|
417
|
-
}
|
|
418
|
-
return;
|
|
419
|
-
}
|
|
420
|
-
// Backspace
|
|
421
|
-
if (key.backspace || key.delete) {
|
|
422
|
-
buffer.backspace();
|
|
423
|
-
forceStateUpdate();
|
|
424
|
-
return;
|
|
425
|
-
}
|
|
426
|
-
// Handle file picker navigation
|
|
427
|
-
if (showFilePicker) {
|
|
428
|
-
// Up arrow in file picker
|
|
429
|
-
if (key.upArrow) {
|
|
430
|
-
setFileSelectedIndex(prev => Math.max(0, prev - 1));
|
|
431
|
-
return;
|
|
432
|
-
}
|
|
433
|
-
// Down arrow in file picker
|
|
434
|
-
if (key.downArrow) {
|
|
435
|
-
const maxIndex = Math.max(0, filteredFileCount - 1);
|
|
436
|
-
setFileSelectedIndex(prev => Math.min(maxIndex, prev + 1));
|
|
437
|
-
return;
|
|
438
|
-
}
|
|
439
|
-
// Tab or Enter - select file
|
|
440
|
-
if (key.tab || key.return) {
|
|
441
|
-
if (filteredFileCount > 0 && fileSelectedIndex < filteredFileCount) {
|
|
442
|
-
const selectedFile = fileListRef.current?.getSelectedFile();
|
|
443
|
-
if (selectedFile) {
|
|
444
|
-
handleFileSelect(selectedFile);
|
|
445
|
-
}
|
|
446
|
-
}
|
|
447
|
-
return;
|
|
448
|
-
}
|
|
449
|
-
}
|
|
450
|
-
// Handle command panel navigation
|
|
451
|
-
if (showCommands) {
|
|
452
|
-
const filteredCommands = getFilteredCommands();
|
|
453
|
-
// Up arrow in command panel
|
|
454
|
-
if (key.upArrow) {
|
|
455
|
-
setCommandSelectedIndex(prev => Math.max(0, prev - 1));
|
|
456
|
-
return;
|
|
457
|
-
}
|
|
458
|
-
// Down arrow in command panel
|
|
459
|
-
if (key.downArrow) {
|
|
460
|
-
const maxIndex = Math.max(0, filteredCommands.length - 1);
|
|
461
|
-
setCommandSelectedIndex(prev => Math.min(maxIndex, prev + 1));
|
|
462
|
-
return;
|
|
463
|
-
}
|
|
464
|
-
// Enter - select command
|
|
465
|
-
if (key.return) {
|
|
466
|
-
if (filteredCommands.length > 0 &&
|
|
467
|
-
commandSelectedIndex < filteredCommands.length) {
|
|
468
|
-
const selectedCommand = filteredCommands[commandSelectedIndex];
|
|
469
|
-
if (selectedCommand) {
|
|
470
|
-
// Execute command instead of inserting text
|
|
471
|
-
executeCommand(selectedCommand.name).then(result => {
|
|
472
|
-
if (onCommand) {
|
|
473
|
-
onCommand(selectedCommand.name, result);
|
|
474
|
-
}
|
|
475
|
-
});
|
|
476
|
-
buffer.setText('');
|
|
477
|
-
setShowCommands(false);
|
|
478
|
-
setCommandSelectedIndex(0);
|
|
479
|
-
triggerUpdate();
|
|
480
|
-
return;
|
|
481
|
-
}
|
|
482
|
-
}
|
|
483
|
-
// If no commands available, fall through to normal Enter handling
|
|
484
|
-
}
|
|
485
|
-
}
|
|
486
|
-
// Enter - submit message
|
|
487
|
-
if (key.return) {
|
|
488
|
-
const message = buffer.getFullText().trim();
|
|
489
|
-
if (message) {
|
|
490
|
-
// 获取图片数据,但只包含占位符仍然存在的图片
|
|
491
|
-
const currentText = buffer.text; // 使用内部文本(包含占位符)
|
|
492
|
-
const allImages = buffer.getImages();
|
|
493
|
-
const validImages = allImages
|
|
494
|
-
.filter(img => currentText.includes(img.placeholder))
|
|
495
|
-
.map(img => ({
|
|
496
|
-
data: img.data,
|
|
497
|
-
mimeType: img.mimeType,
|
|
498
|
-
}));
|
|
499
|
-
buffer.setText('');
|
|
500
|
-
forceUpdate({});
|
|
501
|
-
onSubmit(message, validImages.length > 0 ? validImages : undefined);
|
|
502
|
-
}
|
|
503
|
-
return;
|
|
504
|
-
}
|
|
505
|
-
// Arrow keys for cursor movement
|
|
506
|
-
if (key.leftArrow) {
|
|
507
|
-
buffer.moveLeft();
|
|
508
|
-
const text = buffer.getFullText();
|
|
509
|
-
const cursorPos = buffer.getCursorPosition();
|
|
510
|
-
updateFilePickerState(text, cursorPos);
|
|
511
|
-
triggerUpdate();
|
|
512
|
-
return;
|
|
513
|
-
}
|
|
514
|
-
if (key.rightArrow) {
|
|
515
|
-
buffer.moveRight();
|
|
516
|
-
const text = buffer.getFullText();
|
|
517
|
-
const cursorPos = buffer.getCursorPosition();
|
|
518
|
-
updateFilePickerState(text, cursorPos);
|
|
519
|
-
triggerUpdate();
|
|
520
|
-
return;
|
|
521
|
-
}
|
|
522
|
-
if (key.upArrow && !showCommands && !showFilePicker) {
|
|
523
|
-
buffer.moveUp();
|
|
524
|
-
const text = buffer.getFullText();
|
|
525
|
-
const cursorPos = buffer.getCursorPosition();
|
|
526
|
-
updateFilePickerState(text, cursorPos);
|
|
527
|
-
triggerUpdate();
|
|
528
|
-
return;
|
|
529
|
-
}
|
|
530
|
-
if (key.downArrow && !showCommands && !showFilePicker) {
|
|
531
|
-
buffer.moveDown();
|
|
532
|
-
const text = buffer.getFullText();
|
|
533
|
-
const cursorPos = buffer.getCursorPosition();
|
|
534
|
-
updateFilePickerState(text, cursorPos);
|
|
535
|
-
triggerUpdate();
|
|
536
|
-
return;
|
|
537
|
-
}
|
|
538
|
-
// Regular character input
|
|
539
|
-
if (input && !key.ctrl && !key.meta && !key.escape) {
|
|
540
|
-
// Accumulate input for paste detection
|
|
541
|
-
inputBuffer.current += input;
|
|
542
|
-
// Clear existing timer
|
|
543
|
-
if (inputTimer.current) {
|
|
544
|
-
clearTimeout(inputTimer.current);
|
|
545
|
-
}
|
|
546
|
-
// Set timer to process accumulated input
|
|
547
|
-
inputTimer.current = setTimeout(() => {
|
|
548
|
-
const accumulated = inputBuffer.current;
|
|
549
|
-
inputBuffer.current = '';
|
|
550
|
-
// If we accumulated input, it's likely a paste
|
|
551
|
-
if (accumulated) {
|
|
552
|
-
buffer.insert(accumulated);
|
|
553
|
-
const text = buffer.getFullText();
|
|
554
|
-
const cursorPos = buffer.getCursorPosition();
|
|
555
|
-
updateCommandPanelState(text);
|
|
556
|
-
updateFilePickerState(text, cursorPos);
|
|
557
|
-
triggerUpdate();
|
|
558
|
-
}
|
|
559
|
-
}, 10); // Short delay to accumulate rapid input
|
|
560
|
-
}
|
|
561
|
-
});
|
|
562
|
-
// Handle history selection
|
|
563
|
-
const handleHistorySelect = useCallback((value) => {
|
|
564
|
-
const selectedIndex = parseInt(value, 10);
|
|
565
|
-
const selectedMessage = chatHistory[selectedIndex];
|
|
566
|
-
if (selectedMessage && onHistorySelect) {
|
|
567
|
-
// Put the message content in the input buffer
|
|
568
|
-
buffer.setText(selectedMessage.content);
|
|
569
|
-
setShowHistoryMenu(false);
|
|
570
|
-
triggerUpdate();
|
|
571
|
-
onHistorySelect(selectedIndex, selectedMessage.content);
|
|
572
|
-
}
|
|
573
|
-
}, [chatHistory, onHistorySelect, buffer, triggerUpdate]);
|
|
74
|
+
}, [showFilePicker, forceUpdate]);
|
|
574
75
|
// Render content with cursor and paste placeholders
|
|
575
76
|
const renderContent = useCallback(() => {
|
|
576
77
|
if (buffer.text.length > 0) {
|
|
@@ -646,7 +147,17 @@ end try'`;
|
|
|
646
147
|
const visibleMessages = userMessages.slice(0, maxHeight);
|
|
647
148
|
return visibleMessages.map((message, index) => {
|
|
648
149
|
const messageIndex = parseInt(message.value, 10);
|
|
649
|
-
|
|
150
|
+
// Find snapshot created after this user message
|
|
151
|
+
// Snapshots are created AFTER submitting a message, so we look for
|
|
152
|
+
// the smallest snapshot index that is > messageIndex
|
|
153
|
+
let fileCount = 0;
|
|
154
|
+
if (snapshotFileCount && snapshotFileCount.size > 0) {
|
|
155
|
+
const snapshotIndices = Array.from(snapshotFileCount.keys()).sort((a, b) => a - b);
|
|
156
|
+
const matchingSnapshot = snapshotIndices.find(idx => idx > messageIndex);
|
|
157
|
+
if (matchingSnapshot !== undefined) {
|
|
158
|
+
fileCount = snapshotFileCount.get(matchingSnapshot) || 0;
|
|
159
|
+
}
|
|
160
|
+
}
|
|
650
161
|
return (React.createElement(Box, { key: message.value },
|
|
651
162
|
React.createElement(Text, { color: index === historySelectedIndex ? 'green' : 'white', bold: true },
|
|
652
163
|
index === historySelectedIndex ? '➣ ' : ' ',
|
|
@@ -737,7 +248,7 @@ end try'`;
|
|
|
737
248
|
"cached"))))));
|
|
738
249
|
})()))),
|
|
739
250
|
React.createElement(Box, { marginTop: 1 },
|
|
740
|
-
React.createElement(Text,
|
|
251
|
+
React.createElement(Text, null, showCommands && getFilteredCommands().length > 0
|
|
741
252
|
? 'Type to filter commands'
|
|
742
253
|
: showFilePicker
|
|
743
254
|
? 'Type to filter files • Tab/Enter to select • ESC to cancel'
|