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.
Files changed (65) 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/responses.d.ts +5 -5
  8. package/dist/api/responses.js +29 -27
  9. package/dist/app.js +4 -1
  10. package/dist/hooks/useClipboard.d.ts +4 -0
  11. package/dist/hooks/useClipboard.js +120 -0
  12. package/dist/hooks/useCommandHandler.d.ts +26 -0
  13. package/dist/hooks/useCommandHandler.js +158 -0
  14. package/dist/hooks/useCommandPanel.d.ts +16 -0
  15. package/dist/hooks/useCommandPanel.js +53 -0
  16. package/dist/hooks/useConversation.d.ts +9 -1
  17. package/dist/hooks/useConversation.js +152 -58
  18. package/dist/hooks/useFilePicker.d.ts +17 -0
  19. package/dist/hooks/useFilePicker.js +91 -0
  20. package/dist/hooks/useHistoryNavigation.d.ts +21 -0
  21. package/dist/hooks/useHistoryNavigation.js +50 -0
  22. package/dist/hooks/useInputBuffer.d.ts +6 -0
  23. package/dist/hooks/useInputBuffer.js +29 -0
  24. package/dist/hooks/useKeyboardInput.d.ts +51 -0
  25. package/dist/hooks/useKeyboardInput.js +272 -0
  26. package/dist/hooks/useSnapshotState.d.ts +12 -0
  27. package/dist/hooks/useSnapshotState.js +28 -0
  28. package/dist/hooks/useStreamingState.d.ts +24 -0
  29. package/dist/hooks/useStreamingState.js +96 -0
  30. package/dist/hooks/useVSCodeState.d.ts +8 -0
  31. package/dist/hooks/useVSCodeState.js +63 -0
  32. package/dist/mcp/filesystem.d.ts +24 -5
  33. package/dist/mcp/filesystem.js +52 -17
  34. package/dist/mcp/todo.js +4 -8
  35. package/dist/ui/components/ChatInput.js +68 -557
  36. package/dist/ui/components/DiffViewer.js +57 -30
  37. package/dist/ui/components/FileList.js +70 -26
  38. package/dist/ui/components/MessageList.d.ts +6 -0
  39. package/dist/ui/components/MessageList.js +47 -15
  40. package/dist/ui/components/ShimmerText.d.ts +9 -0
  41. package/dist/ui/components/ShimmerText.js +30 -0
  42. package/dist/ui/components/TodoTree.d.ts +1 -1
  43. package/dist/ui/components/TodoTree.js +0 -4
  44. package/dist/ui/components/ToolConfirmation.js +14 -6
  45. package/dist/ui/pages/ChatScreen.js +159 -359
  46. package/dist/ui/pages/CustomHeadersScreen.d.ts +6 -0
  47. package/dist/ui/pages/CustomHeadersScreen.js +104 -0
  48. package/dist/ui/pages/WelcomeScreen.js +5 -0
  49. package/dist/utils/apiConfig.d.ts +10 -0
  50. package/dist/utils/apiConfig.js +51 -0
  51. package/dist/utils/incrementalSnapshot.d.ts +8 -0
  52. package/dist/utils/incrementalSnapshot.js +63 -0
  53. package/dist/utils/mcpToolsManager.js +6 -1
  54. package/dist/utils/retryUtils.d.ts +22 -0
  55. package/dist/utils/retryUtils.js +180 -0
  56. package/dist/utils/sessionConverter.js +80 -17
  57. package/dist/utils/sessionManager.js +35 -4
  58. package/dist/utils/textUtils.d.ts +4 -0
  59. package/dist/utils/textUtils.js +19 -0
  60. package/dist/utils/todoPreprocessor.d.ts +1 -1
  61. package/dist/utils/todoPreprocessor.js +0 -1
  62. package/dist/utils/vscodeConnection.d.ts +8 -0
  63. package/dist/utils/vscodeConnection.js +44 -0
  64. package/package.json +1 -1
  65. package/readme.md +3 -1
@@ -1,30 +1,14 @@
1
- import React, { useState, useEffect, useRef, useCallback } from 'react';
2
- import { Box, Text, useStdout, useInput } from 'ink';
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 { execSync } from 'child_process';
9
- // Command Definition
10
- const commands = [
11
- { name: 'clear', description: 'Clear chat context and conversation history' },
12
- { name: 'resume', description: 'Resume a conversation' },
13
- { name: 'mcp', description: 'Show Model Context Protocol services and tools' },
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
- const [, forceUpdate] = useState({});
37
- const lastUpdateTime = useRef(0);
38
- // Force re-render when buffer changes
39
- const triggerUpdate = useCallback(() => {
40
- const now = Date.now();
41
- lastUpdateTime.current = now;
42
- forceUpdate({});
43
- }, []);
44
- const [buffer] = useState(() => new TextBuffer(viewport, triggerUpdate));
45
- // Cleanup buffer and timers on unmount
46
- useEffect(() => {
47
- return () => {
48
- buffer.destroy();
49
- if (inputTimer.current) {
50
- clearTimeout(inputTimer.current);
51
- }
52
- if (escapeKeyTimer.current) {
53
- clearTimeout(escapeKeyTimer.current);
54
- }
55
- };
56
- }, [buffer]);
57
- // Command panel state
58
- const [showCommands, setShowCommands] = useState(false);
59
- const [commandSelectedIndex, setCommandSelectedIndex] = useState(0);
60
- // File picker state
61
- const [showFilePicker, setShowFilePicker] = useState(false);
62
- const [fileSelectedIndex, setFileSelectedIndex] = useState(0);
63
- const [fileQuery, setFileQuery] = useState('');
64
- const [atSymbolPosition, setAtSymbolPosition] = useState(-1);
65
- const [filteredFileCount, setFilteredFileCount] = useState(0);
66
- // Refs
67
- const fileListRef = useRef(null);
68
- // History navigation state
69
- const [showHistoryMenu, setShowHistoryMenu] = useState(false);
70
- const [historySelectedIndex, setHistorySelectedIndex] = useState(0);
71
- const [escapeKeyCount, setEscapeKeyCount] = useState(0);
72
- const escapeKeyTimer = useRef(null);
73
- // Get user messages from chat history for navigation
74
- const getUserMessages = useCallback(() => {
75
- const userMessages = chatHistory
76
- .map((msg, index) => ({ ...msg, originalIndex: index }))
77
- .filter(msg => msg.role === 'user' && msg.content.trim());
78
- // Keep original order (oldest first, newest last) and map with display numbers
79
- return userMessages.map((msg, index) => ({
80
- label: `${index + 1}. ${msg.content.slice(0, 50)}${msg.content.length > 50 ? '...' : ''}`,
81
- value: msg.originalIndex.toString(),
82
- infoText: msg.content,
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
- const fileCount = snapshotFileCount?.get(messageIndex) || 0;
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, { color: "gray", dimColor: true }, showCommands && getFilteredCommands().length > 0
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'