snow-ai 0.3.11 → 0.3.12

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.
@@ -2,13 +2,13 @@ import { TextBuffer } from '../utils/textBuffer.js';
2
2
  import { FileListRef } from '../ui/components/FileList.js';
3
3
  export declare function useFilePicker(buffer: TextBuffer, triggerUpdate: () => void): {
4
4
  showFilePicker: boolean;
5
- setShowFilePicker: import("react").Dispatch<import("react").SetStateAction<boolean>>;
5
+ setShowFilePicker: (show: boolean) => void;
6
6
  fileSelectedIndex: number;
7
- setFileSelectedIndex: import("react").Dispatch<import("react").SetStateAction<number>>;
7
+ setFileSelectedIndex: (index: number | ((prev: number) => number)) => void;
8
8
  fileQuery: string;
9
- setFileQuery: import("react").Dispatch<import("react").SetStateAction<string>>;
9
+ setFileQuery: (_query: string) => void;
10
10
  atSymbolPosition: number;
11
- setAtSymbolPosition: import("react").Dispatch<import("react").SetStateAction<number>>;
11
+ setAtSymbolPosition: (_pos: number) => void;
12
12
  filteredFileCount: number;
13
13
  updateFilePickerState: (text: string, cursorPos: number) => void;
14
14
  handleFileSelect: (filePath: string) => Promise<void>;
@@ -1,19 +1,58 @@
1
- import { useState, useCallback, useRef } from 'react';
1
+ import { useReducer, useCallback, useRef } from 'react';
2
+ function filePickerReducer(state, action) {
3
+ switch (action.type) {
4
+ case 'SHOW':
5
+ return {
6
+ ...state,
7
+ showFilePicker: true,
8
+ fileSelectedIndex: 0,
9
+ fileQuery: action.query,
10
+ atSymbolPosition: action.position,
11
+ };
12
+ case 'HIDE':
13
+ return {
14
+ ...state,
15
+ showFilePicker: false,
16
+ fileSelectedIndex: 0,
17
+ fileQuery: '',
18
+ atSymbolPosition: -1,
19
+ };
20
+ case 'SELECT_FILE':
21
+ return {
22
+ ...state,
23
+ showFilePicker: false,
24
+ fileSelectedIndex: 0,
25
+ fileQuery: '',
26
+ atSymbolPosition: -1,
27
+ };
28
+ case 'SET_SELECTED_INDEX':
29
+ return {
30
+ ...state,
31
+ fileSelectedIndex: action.index,
32
+ };
33
+ case 'SET_FILTERED_COUNT':
34
+ return {
35
+ ...state,
36
+ filteredFileCount: action.count,
37
+ };
38
+ default:
39
+ return state;
40
+ }
41
+ }
2
42
  export function useFilePicker(buffer, triggerUpdate) {
3
- const [showFilePicker, setShowFilePicker] = useState(false);
4
- const [fileSelectedIndex, setFileSelectedIndex] = useState(0);
5
- const [fileQuery, setFileQuery] = useState('');
6
- const [atSymbolPosition, setAtSymbolPosition] = useState(-1);
7
- const [filteredFileCount, setFilteredFileCount] = useState(0);
43
+ const [state, dispatch] = useReducer(filePickerReducer, {
44
+ showFilePicker: false,
45
+ fileSelectedIndex: 0,
46
+ fileQuery: '',
47
+ atSymbolPosition: -1,
48
+ filteredFileCount: 0,
49
+ });
8
50
  const fileListRef = useRef(null);
9
51
  // Update file picker state
10
52
  const updateFilePickerState = useCallback((text, cursorPos) => {
11
53
  if (!text.includes('@')) {
12
- if (showFilePicker) {
13
- setShowFilePicker(false);
14
- setFileSelectedIndex(0);
15
- setFileQuery('');
16
- setAtSymbolPosition(-1);
54
+ if (state.showFilePicker) {
55
+ dispatch({ type: 'HIDE' });
17
56
  }
18
57
  return;
19
58
  }
@@ -24,65 +63,74 @@ export function useFilePicker(buffer, triggerUpdate) {
24
63
  // Check if there's no space between '@' and cursor
25
64
  const afterAt = beforeCursor.slice(lastAtIndex + 1);
26
65
  if (!afterAt.includes(' ') && !afterAt.includes('\n')) {
27
- if (!showFilePicker ||
28
- fileQuery !== afterAt ||
29
- atSymbolPosition !== lastAtIndex) {
30
- setShowFilePicker(true);
31
- setFileSelectedIndex(0);
32
- setFileQuery(afterAt);
33
- setAtSymbolPosition(lastAtIndex);
66
+ if (!state.showFilePicker ||
67
+ state.fileQuery !== afterAt ||
68
+ state.atSymbolPosition !== lastAtIndex) {
69
+ dispatch({ type: 'SHOW', query: afterAt, position: lastAtIndex });
34
70
  }
35
71
  return;
36
72
  }
37
73
  }
38
74
  // Hide file picker if no valid @ context found
39
- if (showFilePicker) {
40
- setShowFilePicker(false);
41
- setFileSelectedIndex(0);
42
- setFileQuery('');
43
- setAtSymbolPosition(-1);
75
+ if (state.showFilePicker) {
76
+ dispatch({ type: 'HIDE' });
44
77
  }
45
- }, [showFilePicker, fileQuery, atSymbolPosition]);
78
+ }, [state.showFilePicker, state.fileQuery, state.atSymbolPosition]);
46
79
  // Handle file selection
47
80
  const handleFileSelect = useCallback(async (filePath) => {
48
- if (atSymbolPosition !== -1) {
81
+ if (state.atSymbolPosition !== -1) {
49
82
  const text = buffer.getFullText();
50
83
  const cursorPos = buffer.getCursorPosition();
51
84
  // Replace @query with @filePath + space
52
- const beforeAt = text.slice(0, atSymbolPosition);
85
+ const beforeAt = text.slice(0, state.atSymbolPosition);
53
86
  const afterCursor = text.slice(cursorPos);
54
87
  const newText = beforeAt + '@' + filePath + ' ' + afterCursor;
55
88
  // Set the new text and position cursor after the inserted file path + space
56
89
  buffer.setText(newText);
57
90
  // Calculate cursor position after the inserted file path + space
58
91
  // Reset cursor to beginning, then move to correct position
59
- for (let i = 0; i < atSymbolPosition + filePath.length + 2; i++) {
92
+ for (let i = 0; i < state.atSymbolPosition + filePath.length + 2; i++) {
60
93
  // +2 for @ and space
61
94
  if (i < buffer.getFullText().length) {
62
95
  buffer.moveRight();
63
96
  }
64
97
  }
65
- setShowFilePicker(false);
66
- setFileSelectedIndex(0);
67
- setFileQuery('');
68
- setAtSymbolPosition(-1);
98
+ dispatch({ type: 'SELECT_FILE' });
69
99
  triggerUpdate();
70
100
  }
71
- }, [atSymbolPosition, buffer, triggerUpdate]);
101
+ }, [state.atSymbolPosition, buffer]);
72
102
  // Handle filtered file count change
73
103
  const handleFilteredCountChange = useCallback((count) => {
74
- setFilteredFileCount(count);
104
+ dispatch({ type: 'SET_FILTERED_COUNT', count });
105
+ }, []);
106
+ // Wrapper setters for backwards compatibility
107
+ const setShowFilePicker = useCallback((show) => {
108
+ dispatch({ type: show ? 'SHOW' : 'HIDE', query: '', position: -1 });
75
109
  }, []);
110
+ const setFileSelectedIndex = useCallback((index) => {
111
+ if (typeof index === 'function') {
112
+ // For functional updates, we need to get current state first
113
+ // This is a simplified version - in production you might want to use a ref
114
+ dispatch({ type: 'SET_SELECTED_INDEX', index: index(state.fileSelectedIndex) });
115
+ }
116
+ else {
117
+ dispatch({ type: 'SET_SELECTED_INDEX', index });
118
+ }
119
+ }, [state.fileSelectedIndex]);
76
120
  return {
77
- showFilePicker,
121
+ showFilePicker: state.showFilePicker,
78
122
  setShowFilePicker,
79
- fileSelectedIndex,
123
+ fileSelectedIndex: state.fileSelectedIndex,
80
124
  setFileSelectedIndex,
81
- fileQuery,
82
- setFileQuery,
83
- atSymbolPosition,
84
- setAtSymbolPosition,
85
- filteredFileCount,
125
+ fileQuery: state.fileQuery,
126
+ setFileQuery: (_query) => {
127
+ // Not used, but kept for compatibility
128
+ },
129
+ atSymbolPosition: state.atSymbolPosition,
130
+ setAtSymbolPosition: (_pos) => {
131
+ // Not used, but kept for compatibility
132
+ },
133
+ filteredFileCount: state.filteredFileCount,
86
134
  updateFilePickerState,
87
135
  handleFileSelect,
88
136
  handleFilteredCountChange,
@@ -17,5 +17,10 @@ export declare function useHistoryNavigation(buffer: TextBuffer, triggerUpdate:
17
17
  infoText: string;
18
18
  }[];
19
19
  handleHistorySelect: (value: string) => void;
20
+ currentHistoryIndex: number;
21
+ navigateHistoryUp: () => boolean;
22
+ navigateHistoryDown: () => boolean;
23
+ resetHistoryNavigation: () => void;
24
+ saveToHistory: (content: string) => Promise<void>;
20
25
  };
21
26
  export {};
@@ -1,9 +1,25 @@
1
1
  import { useState, useCallback, useRef, useEffect } from 'react';
2
+ import { historyManager } from '../utils/historyManager.js';
2
3
  export function useHistoryNavigation(buffer, triggerUpdate, chatHistory, onHistorySelect) {
3
4
  const [showHistoryMenu, setShowHistoryMenu] = useState(false);
4
5
  const [historySelectedIndex, setHistorySelectedIndex] = useState(0);
5
6
  const [escapeKeyCount, setEscapeKeyCount] = useState(0);
6
7
  const escapeKeyTimer = useRef(null);
8
+ // Terminal-style history navigation state
9
+ const [currentHistoryIndex, setCurrentHistoryIndex] = useState(-1); // -1 means not in history mode
10
+ const savedInput = useRef(''); // Save current input when entering history mode
11
+ const [persistentHistory, setPersistentHistory] = useState([]);
12
+ const persistentHistoryRef = useRef([]);
13
+ // Keep ref in sync with state
14
+ useEffect(() => {
15
+ persistentHistoryRef.current = persistentHistory;
16
+ }, [persistentHistory]);
17
+ // Load persistent history on mount
18
+ useEffect(() => {
19
+ historyManager.loadHistory().then(entries => {
20
+ setPersistentHistory(entries);
21
+ });
22
+ }, []);
7
23
  // Cleanup timer on unmount
8
24
  useEffect(() => {
9
25
  return () => {
@@ -35,7 +51,62 @@ export function useHistoryNavigation(buffer, triggerUpdate, chatHistory, onHisto
35
51
  triggerUpdate();
36
52
  onHistorySelect(selectedIndex, selectedMessage.content);
37
53
  }
38
- }, [chatHistory, onHistorySelect, buffer, triggerUpdate]);
54
+ }, [chatHistory, onHistorySelect, buffer]);
55
+ // Terminal-style history navigation: navigate up (older)
56
+ const navigateHistoryUp = useCallback(() => {
57
+ const history = persistentHistoryRef.current;
58
+ if (history.length === 0)
59
+ return false;
60
+ // Save current input when first entering history mode
61
+ if (currentHistoryIndex === -1) {
62
+ savedInput.current = buffer.getFullText();
63
+ }
64
+ // Navigate to older message (persistentHistory is already newest first)
65
+ const newIndex = currentHistoryIndex === -1
66
+ ? 0
67
+ : Math.min(history.length - 1, currentHistoryIndex + 1);
68
+ setCurrentHistoryIndex(newIndex);
69
+ const entry = history[newIndex];
70
+ if (entry) {
71
+ buffer.setText(entry.content);
72
+ triggerUpdate();
73
+ }
74
+ return true;
75
+ }, [currentHistoryIndex, buffer]);
76
+ // Terminal-style history navigation: navigate down (newer)
77
+ const navigateHistoryDown = useCallback(() => {
78
+ if (currentHistoryIndex === -1)
79
+ return false;
80
+ const newIndex = currentHistoryIndex - 1;
81
+ const history = persistentHistoryRef.current;
82
+ if (newIndex < 0) {
83
+ // Restore original input
84
+ buffer.setText(savedInput.current);
85
+ setCurrentHistoryIndex(-1);
86
+ savedInput.current = '';
87
+ }
88
+ else {
89
+ setCurrentHistoryIndex(newIndex);
90
+ const entry = history[newIndex];
91
+ if (entry) {
92
+ buffer.setText(entry.content);
93
+ }
94
+ }
95
+ triggerUpdate();
96
+ return true;
97
+ }, [currentHistoryIndex, buffer]);
98
+ // Reset history navigation state
99
+ const resetHistoryNavigation = useCallback(() => {
100
+ setCurrentHistoryIndex(-1);
101
+ savedInput.current = '';
102
+ }, []);
103
+ // Save message to persistent history
104
+ const saveToHistory = useCallback(async (content) => {
105
+ await historyManager.addEntry(content);
106
+ // Reload history to update the list
107
+ const entries = await historyManager.getEntries();
108
+ setPersistentHistory(entries);
109
+ }, []);
39
110
  return {
40
111
  showHistoryMenu,
41
112
  setShowHistoryMenu,
@@ -46,5 +117,11 @@ export function useHistoryNavigation(buffer, triggerUpdate, chatHistory, onHisto
46
117
  escapeKeyTimer,
47
118
  getUserMessages,
48
119
  handleHistorySelect,
120
+ // Terminal-style history navigation
121
+ currentHistoryIndex,
122
+ navigateHistoryUp,
123
+ navigateHistoryDown,
124
+ resetHistoryNavigation,
125
+ saveToHistory,
49
126
  };
50
127
  }
@@ -14,7 +14,7 @@ export function useInputBuffer(viewport) {
14
14
  useEffect(() => {
15
15
  buffer.updateViewport(viewport);
16
16
  triggerUpdate();
17
- }, [viewport.width, viewport.height, buffer, triggerUpdate]);
17
+ }, [viewport.width, viewport.height, buffer]);
18
18
  // Cleanup buffer on unmount
19
19
  useEffect(() => {
20
20
  return () => {
@@ -41,6 +41,11 @@ type KeyboardInputOptions = {
41
41
  infoText: string;
42
42
  }>;
43
43
  handleHistorySelect: (value: string) => void;
44
+ currentHistoryIndex: number;
45
+ navigateHistoryUp: () => boolean;
46
+ navigateHistoryDown: () => boolean;
47
+ resetHistoryNavigation: () => void;
48
+ saveToHistory: (content: string) => Promise<void>;
44
49
  pasteFromClipboard: () => Promise<void>;
45
50
  onSubmit: (message: string, images?: Array<{
46
51
  data: string;
@@ -2,7 +2,7 @@ import { useRef, useEffect } from 'react';
2
2
  import { useInput } from 'ink';
3
3
  import { executeCommand } from '../utils/commandExecutor.js';
4
4
  export function useKeyboardInput(options) {
5
- const { buffer, disabled, triggerUpdate, forceUpdate, showCommands, setShowCommands, commandSelectedIndex, setCommandSelectedIndex, getFilteredCommands, updateCommandPanelState, onCommand, showFilePicker, setShowFilePicker, fileSelectedIndex, setFileSelectedIndex, setFileQuery, setAtSymbolPosition, filteredFileCount, updateFilePickerState, handleFileSelect, fileListRef, showHistoryMenu, setShowHistoryMenu, historySelectedIndex, setHistorySelectedIndex, escapeKeyCount, setEscapeKeyCount, escapeKeyTimer, getUserMessages, handleHistorySelect, pasteFromClipboard, onSubmit, ensureFocus, } = options;
5
+ const { buffer, disabled, triggerUpdate, forceUpdate, showCommands, setShowCommands, commandSelectedIndex, setCommandSelectedIndex, getFilteredCommands, updateCommandPanelState, onCommand, showFilePicker, setShowFilePicker, fileSelectedIndex, setFileSelectedIndex, setFileQuery, setAtSymbolPosition, filteredFileCount, updateFilePickerState, handleFileSelect, fileListRef, showHistoryMenu, setShowHistoryMenu, historySelectedIndex, setHistorySelectedIndex, escapeKeyCount, setEscapeKeyCount, escapeKeyTimer, getUserMessages, handleHistorySelect, currentHistoryIndex, navigateHistoryUp, navigateHistoryDown, resetHistoryNavigation, saveToHistory, pasteFromClipboard, onSubmit, ensureFocus, } = options;
6
6
  // Track paste detection
7
7
  const inputBuffer = useRef('');
8
8
  const inputTimer = useRef(null);
@@ -129,18 +129,18 @@ export function useKeyboardInput(options) {
129
129
  }
130
130
  // Ctrl+L - Delete from cursor to beginning
131
131
  if (key.ctrl && input === 'l') {
132
- const fullText = buffer.getFullText();
132
+ const displayText = buffer.text;
133
133
  const cursorPos = buffer.getCursorPosition();
134
- const afterCursor = fullText.slice(cursorPos);
134
+ const afterCursor = displayText.slice(cursorPos);
135
135
  buffer.setText(afterCursor);
136
136
  forceStateUpdate();
137
137
  return;
138
138
  }
139
139
  // Ctrl+R - Delete from cursor to end
140
140
  if (key.ctrl && input === 'r') {
141
- const fullText = buffer.getFullText();
141
+ const displayText = buffer.text;
142
142
  const cursorPos = buffer.getCursorPosition();
143
- const beforeCursor = fullText.slice(0, cursorPos);
143
+ const beforeCursor = displayText.slice(0, cursorPos);
144
144
  buffer.setText(beforeCursor);
145
145
  forceStateUpdate();
146
146
  return;
@@ -221,6 +221,10 @@ export function useKeyboardInput(options) {
221
221
  }
222
222
  // Enter - submit message
223
223
  if (key.return) {
224
+ // Reset history navigation on submit
225
+ if (currentHistoryIndex !== -1) {
226
+ resetHistoryNavigation();
227
+ }
224
228
  const message = buffer.getFullText().trim();
225
229
  if (message) {
226
230
  // Check if message is a command with arguments (e.g., /review [note])
@@ -253,6 +257,8 @@ export function useKeyboardInput(options) {
253
257
  }));
254
258
  buffer.setText('');
255
259
  forceUpdate({});
260
+ // Save to persistent history
261
+ saveToHistory(message);
256
262
  onSubmit(message, validImages.length > 0 ? validImages : undefined);
257
263
  }
258
264
  return;
@@ -275,23 +281,59 @@ export function useKeyboardInput(options) {
275
281
  return;
276
282
  }
277
283
  if (key.upArrow && !showCommands && !showFilePicker) {
278
- buffer.moveUp();
279
284
  const text = buffer.getFullText();
280
285
  const cursorPos = buffer.getCursorPosition();
281
- updateFilePickerState(text, cursorPos);
286
+ const isEmpty = text.trim() === '';
287
+ const isAtStart = cursorPos === 0;
288
+ const hasNewline = text.includes('\n');
289
+ // Terminal-style history navigation:
290
+ // 1. Empty input box -> navigate history
291
+ // 2. Cursor at start of single line -> navigate history
292
+ // 3. Otherwise -> normal cursor movement
293
+ if (isEmpty || (!hasNewline && isAtStart)) {
294
+ const navigated = navigateHistoryUp();
295
+ if (navigated) {
296
+ updateFilePickerState(buffer.getFullText(), buffer.getCursorPosition());
297
+ triggerUpdate();
298
+ return;
299
+ }
300
+ }
301
+ // Normal cursor movement
302
+ buffer.moveUp();
303
+ updateFilePickerState(buffer.getFullText(), buffer.getCursorPosition());
282
304
  triggerUpdate();
283
305
  return;
284
306
  }
285
307
  if (key.downArrow && !showCommands && !showFilePicker) {
286
- buffer.moveDown();
287
308
  const text = buffer.getFullText();
288
309
  const cursorPos = buffer.getCursorPosition();
289
- updateFilePickerState(text, cursorPos);
310
+ const isEmpty = text.trim() === '';
311
+ const isAtEnd = cursorPos === text.length;
312
+ const hasNewline = text.includes('\n');
313
+ // Terminal-style history navigation:
314
+ // 1. Empty input box -> navigate history (if in history mode)
315
+ // 2. Cursor at end of single line -> navigate history (if in history mode)
316
+ // 3. Otherwise -> normal cursor movement
317
+ if ((isEmpty || (!hasNewline && isAtEnd)) && currentHistoryIndex !== -1) {
318
+ const navigated = navigateHistoryDown();
319
+ if (navigated) {
320
+ updateFilePickerState(buffer.getFullText(), buffer.getCursorPosition());
321
+ triggerUpdate();
322
+ return;
323
+ }
324
+ }
325
+ // Normal cursor movement
326
+ buffer.moveDown();
327
+ updateFilePickerState(buffer.getFullText(), buffer.getCursorPosition());
290
328
  triggerUpdate();
291
329
  return;
292
330
  }
293
331
  // Regular character input
294
332
  if (input && !key.ctrl && !key.meta && !key.escape) {
333
+ // Reset history navigation when user starts typing
334
+ if (currentHistoryIndex !== -1) {
335
+ resetHistoryNavigation();
336
+ }
295
337
  // Ensure focus is active when user is typing (handles delayed focus events)
296
338
  // This is especially important for drag-and-drop operations where focus
297
339
  // events may arrive out of order or be filtered by sanitizeInput
@@ -76,7 +76,7 @@ export function useStreamingState() {
76
76
  });
77
77
  }, 1000);
78
78
  return () => clearInterval(interval);
79
- }, [retryStatus]);
79
+ }, [retryStatus?.isRetrying, retryStatus?.remainingSeconds]);
80
80
  return {
81
81
  isStreaming,
82
82
  setIsStreaming,
@@ -44,6 +44,10 @@ export declare class ACECodeSearchService {
44
44
  * Find symbol definition (go to definition)
45
45
  */
46
46
  findDefinition(symbolName: string, contextFile?: string): Promise<CodeSymbol | null>;
47
+ /**
48
+ * Expand glob patterns with braces like "*.{ts,tsx}" into multiple patterns
49
+ */
50
+ private expandGlobBraces;
47
51
  /**
48
52
  * Strategy 1: Use git grep for fast searching in Git repositories
49
53
  */
@@ -470,6 +470,20 @@ export class ACECodeSearchService {
470
470
  }
471
471
  return null;
472
472
  }
473
+ /**
474
+ * Expand glob patterns with braces like "*.{ts,tsx}" into multiple patterns
475
+ */
476
+ expandGlobBraces(glob) {
477
+ // Match {a,b,c} pattern
478
+ const braceMatch = glob.match(/^(.+)\{([^}]+)\}(.*)$/);
479
+ if (!braceMatch || !braceMatch[1] || !braceMatch[2] || braceMatch[3] === undefined) {
480
+ return [glob];
481
+ }
482
+ const prefix = braceMatch[1];
483
+ const alternatives = braceMatch[2].split(',');
484
+ const suffix = braceMatch[3];
485
+ return alternatives.map(alt => `${prefix}${alt}${suffix}`);
486
+ }
473
487
  /**
474
488
  * Strategy 1: Use git grep for fast searching in Git repositories
475
489
  */
@@ -484,7 +498,9 @@ export class ACECodeSearchService {
484
498
  pattern,
485
499
  ];
486
500
  if (fileGlob) {
487
- args.push('--', fileGlob);
501
+ // Expand glob patterns with braces (e.g., "source/**/*.{ts,tsx}" -> ["source/**/*.ts", "source/**/*.tsx"])
502
+ const expandedGlobs = this.expandGlobBraces(fileGlob);
503
+ args.push('--', ...expandedGlobs);
488
504
  }
489
505
  const child = spawn('git', args, {
490
506
  cwd: this.basePath,
@@ -411,7 +411,7 @@ export class FilesystemMCPService {
411
411
  for (let i = 0; i <= contentLines.length - searchLines.length; i++) {
412
412
  // Yield control periodically to prevent UI freeze
413
413
  if (i % YIELD_INTERVAL === 0) {
414
- await new Promise(resolve => setImmediate(resolve));
414
+ await new Promise(resolve => setTimeout(resolve, 0));
415
415
  }
416
416
  // Quick pre-filter: check first line similarity (only for multi-line searches)
417
417
  if (usePreFilter) {
@@ -456,7 +456,7 @@ export class FilesystemMCPService {
456
456
  for (let i = 0; i <= contentLines.length - correctedSearchLines.length; i++) {
457
457
  // Yield control periodically to prevent UI freeze
458
458
  if (i % YIELD_INTERVAL === 0) {
459
- await new Promise(resolve => setImmediate(resolve));
459
+ await new Promise(resolve => setTimeout(resolve, 0));
460
460
  }
461
461
  const candidateLines = contentLines.slice(i, i + correctedSearchLines.length);
462
462
  const candidateContent = candidateLines.join('\n');
@@ -22,7 +22,7 @@ export async function findClosestMatches(searchContent, fileLines, topN = 3) {
22
22
  for (let i = 0; i <= fileLines.length - searchLines.length; i++) {
23
23
  // Yield control periodically to prevent UI freeze
24
24
  if (i % YIELD_INTERVAL === 0) {
25
- await new Promise(resolve => setImmediate(resolve));
25
+ await new Promise(resolve => setTimeout(resolve, 0));
26
26
  }
27
27
  // Quick pre-filter: check first line similarity (only for multi-line)
28
28
  if (usePreFilter) {
@@ -48,7 +48,7 @@ export default function ChatInput({ onSubmit, onCommand, placeholder = 'Type you
48
48
  // Use file picker hook
49
49
  const { showFilePicker, setShowFilePicker, fileSelectedIndex, setFileSelectedIndex, fileQuery, setFileQuery, atSymbolPosition, setAtSymbolPosition, filteredFileCount, updateFilePickerState, handleFileSelect, handleFilteredCountChange, fileListRef, } = useFilePicker(buffer, triggerUpdate);
50
50
  // Use history navigation hook
51
- const { showHistoryMenu, setShowHistoryMenu, historySelectedIndex, setHistorySelectedIndex, escapeKeyCount, setEscapeKeyCount, escapeKeyTimer, getUserMessages, handleHistorySelect, } = useHistoryNavigation(buffer, triggerUpdate, chatHistory, onHistorySelect);
51
+ const { showHistoryMenu, setShowHistoryMenu, historySelectedIndex, setHistorySelectedIndex, escapeKeyCount, setEscapeKeyCount, escapeKeyTimer, getUserMessages, handleHistorySelect, currentHistoryIndex, navigateHistoryUp, navigateHistoryDown, resetHistoryNavigation, saveToHistory, } = useHistoryNavigation(buffer, triggerUpdate, chatHistory, onHistorySelect);
52
52
  // Use clipboard hook
53
53
  const { pasteFromClipboard } = useClipboard(buffer, updateCommandPanelState, updateFilePickerState, triggerUpdate);
54
54
  // Use keyboard input hook
@@ -85,6 +85,11 @@ export default function ChatInput({ onSubmit, onCommand, placeholder = 'Type you
85
85
  escapeKeyTimer,
86
86
  getUserMessages,
87
87
  handleHistorySelect,
88
+ currentHistoryIndex,
89
+ navigateHistoryUp,
90
+ navigateHistoryDown,
91
+ resetHistoryNavigation,
92
+ saveToHistory,
88
93
  pasteFromClipboard,
89
94
  onSubmit,
90
95
  ensureFocus,
@@ -95,7 +100,9 @@ export default function ChatInput({ onSubmit, onCommand, placeholder = 'Type you
95
100
  buffer.setText(initialContent);
96
101
  triggerUpdate();
97
102
  }
98
- }, [initialContent, buffer, triggerUpdate]);
103
+ // Only run when initialContent changes
104
+ // eslint-disable-next-line react-hooks/exhaustive-deps
105
+ }, [initialContent]);
99
106
  // Force full re-render when file picker visibility changes to prevent artifacts
100
107
  useEffect(() => {
101
108
  // Use a small delay to ensure the component tree has updated
@@ -103,7 +110,7 @@ export default function ChatInput({ onSubmit, onCommand, placeholder = 'Type you
103
110
  forceUpdate({});
104
111
  }, 10);
105
112
  return () => clearTimeout(timer);
106
- }, [showFilePicker, forceUpdate]);
113
+ }, [showFilePicker]);
107
114
  // Handle terminal width changes with debounce (like gemini-cli)
108
115
  useEffect(() => {
109
116
  // Skip on initial mount
@@ -117,7 +124,7 @@ export default function ChatInput({ onSubmit, onCommand, placeholder = 'Type you
117
124
  forceUpdate({});
118
125
  }, 100);
119
126
  return () => clearTimeout(timer);
120
- }, [terminalWidth, forceUpdate]);
127
+ }, [terminalWidth]);
121
128
  // Notify parent of context percentage changes
122
129
  useEffect(() => {
123
130
  if (contextUsage && onContextPercentageChange) {
@@ -0,0 +1,45 @@
1
+ export interface HistoryEntry {
2
+ content: string;
3
+ timestamp: number;
4
+ }
5
+ export interface HistoryData {
6
+ entries: HistoryEntry[];
7
+ lastCleanup: number;
8
+ }
9
+ declare class HistoryManager {
10
+ private readonly historyFile;
11
+ private readonly maxAge;
12
+ private readonly maxEntries;
13
+ private historyData;
14
+ constructor();
15
+ /**
16
+ * Ensure the .snow directory exists
17
+ */
18
+ private ensureSnowDir;
19
+ /**
20
+ * Load history from file
21
+ */
22
+ loadHistory(): Promise<HistoryEntry[]>;
23
+ /**
24
+ * Add a new entry to history
25
+ */
26
+ addEntry(content: string): Promise<void>;
27
+ /**
28
+ * Get all history entries (newest first)
29
+ */
30
+ getEntries(): Promise<HistoryEntry[]>;
31
+ /**
32
+ * Clean up entries older than maxAge
33
+ */
34
+ private cleanupOldEntries;
35
+ /**
36
+ * Save history to file
37
+ */
38
+ private saveHistory;
39
+ /**
40
+ * Clear all history
41
+ */
42
+ clearHistory(): Promise<void>;
43
+ }
44
+ export declare const historyManager: HistoryManager;
45
+ export {};
@@ -0,0 +1,159 @@
1
+ import fs from 'fs/promises';
2
+ import path from 'path';
3
+ import os from 'os';
4
+ import { logger } from './logger.js';
5
+ class HistoryManager {
6
+ constructor() {
7
+ Object.defineProperty(this, "historyFile", {
8
+ enumerable: true,
9
+ configurable: true,
10
+ writable: true,
11
+ value: void 0
12
+ });
13
+ Object.defineProperty(this, "maxAge", {
14
+ enumerable: true,
15
+ configurable: true,
16
+ writable: true,
17
+ value: 24 * 60 * 60 * 1000
18
+ }); // 1 day in milliseconds
19
+ Object.defineProperty(this, "maxEntries", {
20
+ enumerable: true,
21
+ configurable: true,
22
+ writable: true,
23
+ value: 1000
24
+ }); // Maximum number of entries to keep
25
+ Object.defineProperty(this, "historyData", {
26
+ enumerable: true,
27
+ configurable: true,
28
+ writable: true,
29
+ value: null
30
+ });
31
+ const snowDir = path.join(os.homedir(), '.snow');
32
+ this.historyFile = path.join(snowDir, 'history.json');
33
+ }
34
+ /**
35
+ * Ensure the .snow directory exists
36
+ */
37
+ async ensureSnowDir() {
38
+ try {
39
+ const snowDir = path.dirname(this.historyFile);
40
+ await fs.mkdir(snowDir, { recursive: true });
41
+ }
42
+ catch (error) {
43
+ // Directory already exists or other error
44
+ }
45
+ }
46
+ /**
47
+ * Load history from file
48
+ */
49
+ async loadHistory() {
50
+ try {
51
+ await this.ensureSnowDir();
52
+ // Try to read existing history file
53
+ const data = await fs.readFile(this.historyFile, 'utf-8');
54
+ this.historyData = JSON.parse(data);
55
+ // Clean up old entries if needed
56
+ await this.cleanupOldEntries();
57
+ return this.historyData.entries;
58
+ }
59
+ catch (error) {
60
+ // File doesn't exist or is corrupted, start fresh
61
+ this.historyData = {
62
+ entries: [],
63
+ lastCleanup: Date.now(),
64
+ };
65
+ return [];
66
+ }
67
+ }
68
+ /**
69
+ * Add a new entry to history
70
+ */
71
+ async addEntry(content) {
72
+ // Don't add empty or whitespace-only entries
73
+ if (!content || !content.trim()) {
74
+ return;
75
+ }
76
+ // Load history if not already loaded
77
+ if (!this.historyData) {
78
+ await this.loadHistory();
79
+ }
80
+ // Don't add duplicate of the last entry
81
+ const lastEntry = this.historyData.entries[this.historyData.entries.length - 1];
82
+ if (lastEntry && lastEntry.content === content) {
83
+ return;
84
+ }
85
+ // Add new entry
86
+ const newEntry = {
87
+ content,
88
+ timestamp: Date.now(),
89
+ };
90
+ this.historyData.entries.push(newEntry);
91
+ // Limit the number of entries
92
+ if (this.historyData.entries.length > this.maxEntries) {
93
+ this.historyData.entries = this.historyData.entries.slice(-this.maxEntries);
94
+ }
95
+ // Save to file
96
+ await this.saveHistory();
97
+ }
98
+ /**
99
+ * Get all history entries (newest first)
100
+ */
101
+ async getEntries() {
102
+ if (!this.historyData) {
103
+ await this.loadHistory();
104
+ }
105
+ // Return a copy in reverse order (newest first)
106
+ return [...this.historyData.entries].reverse();
107
+ }
108
+ /**
109
+ * Clean up entries older than maxAge
110
+ */
111
+ async cleanupOldEntries() {
112
+ if (!this.historyData) {
113
+ return;
114
+ }
115
+ const now = Date.now();
116
+ const cutoffTime = now - this.maxAge;
117
+ // Only cleanup once per hour to avoid excessive file writes
118
+ if (now - this.historyData.lastCleanup < 60 * 60 * 1000) {
119
+ return;
120
+ }
121
+ // Filter out old entries
122
+ const originalLength = this.historyData.entries.length;
123
+ this.historyData.entries = this.historyData.entries.filter(entry => entry.timestamp > cutoffTime);
124
+ // Update last cleanup time
125
+ this.historyData.lastCleanup = now;
126
+ // Save if we removed any entries
127
+ if (this.historyData.entries.length < originalLength) {
128
+ await this.saveHistory();
129
+ logger.debug(`Cleaned up ${originalLength - this.historyData.entries.length} old history entries`);
130
+ }
131
+ }
132
+ /**
133
+ * Save history to file
134
+ */
135
+ async saveHistory() {
136
+ if (!this.historyData) {
137
+ return;
138
+ }
139
+ try {
140
+ await this.ensureSnowDir();
141
+ await fs.writeFile(this.historyFile, JSON.stringify(this.historyData, null, 2), 'utf-8');
142
+ }
143
+ catch (error) {
144
+ logger.error('Failed to save history:', error);
145
+ }
146
+ }
147
+ /**
148
+ * Clear all history
149
+ */
150
+ async clearHistory() {
151
+ this.historyData = {
152
+ entries: [],
153
+ lastCleanup: Date.now(),
154
+ };
155
+ await this.saveHistory();
156
+ }
157
+ }
158
+ // Export singleton instance
159
+ export const historyManager = new HistoryManager();
@@ -196,16 +196,19 @@ export class TextBuffer {
196
196
  if (this.pasteTimer) {
197
197
  clearTimeout(this.pasteTimer);
198
198
  }
199
- // 如果是第一批数据,记录插入位置
199
+ // 如果是第一批数据,记录插入位置并清空内容
200
200
  const isFirstBatch = !this.pasteAccumulator;
201
201
  if (isFirstBatch) {
202
202
  this.pastePlaceholderPosition = this.cursorIndex;
203
+ // 保存粘贴位置前后的内容,避免后续计算错误
204
+ this.content = cpSlice(this.content, 0, this.pastePlaceholderPosition) +
205
+ cpSlice(this.content, this.pastePlaceholderPosition);
203
206
  }
204
207
  // 累积数据
205
208
  this.pasteAccumulator += sanitized;
206
- // 移除旧的临时占位符(如果存在)
209
+ // 移除所有旧的临时占位符(使用全局替换)
207
210
  if (!isFirstBatch) {
208
- const tempPlaceholderPattern = /\[Pasting\.\.\. \d+ chars\]/;
211
+ const tempPlaceholderPattern = /\[Pasting\.\.\. \d+ chars\]/g;
209
212
  this.content = this.content.replace(tempPlaceholderPattern, '');
210
213
  }
211
214
  // 显示更新后的临时占位符
@@ -241,9 +244,9 @@ export class TextBuffer {
241
244
  return;
242
245
  }
243
246
  const totalChars = this.pasteAccumulator.length;
244
- // 移除临时占位符(如果存在)
247
+ // 移除所有临时占位符(使用全局替换)
245
248
  // 临时占位符格式: [Pasting... XXX chars]
246
- const tempPlaceholderPattern = /\[Pasting\.\.\. \d+ chars\]/;
249
+ const tempPlaceholderPattern = /\[Pasting\.\.\. \d+ chars\]/g;
247
250
  this.content = this.content.replace(tempPlaceholderPattern, '');
248
251
  // 只有当累积的字符数超过300时才创建占位符
249
252
  if (totalChars > 300) {
@@ -331,6 +334,14 @@ export class TextBuffer {
331
334
  if (this.visualLines.length === 0) {
332
335
  return;
333
336
  }
337
+ // 检查是否只有单行(没有换行符)
338
+ const hasNewline = this.content.includes('\n');
339
+ if (!hasNewline && this.visualLines.length === 1) {
340
+ // 单行模式:移动到行首
341
+ this.cursorIndex = 0;
342
+ this.recomputeVisualCursorOnly();
343
+ return;
344
+ }
334
345
  const currentRow = this.visualCursorPos[0];
335
346
  if (currentRow <= 0) {
336
347
  return;
@@ -341,6 +352,14 @@ export class TextBuffer {
341
352
  if (this.visualLines.length === 0) {
342
353
  return;
343
354
  }
355
+ // 检查是否只有单行(没有换行符)
356
+ const hasNewline = this.content.includes('\n');
357
+ if (!hasNewline && this.visualLines.length === 1) {
358
+ // 单行模式:移动到行尾
359
+ this.cursorIndex = cpLen(this.content);
360
+ this.recomputeVisualCursorOnly();
361
+ return;
362
+ }
344
363
  const currentRow = this.visualCursorPos[0];
345
364
  if (currentRow >= this.visualLines.length - 1) {
346
365
  return;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "snow-ai",
3
- "version": "0.3.11",
3
+ "version": "0.3.12",
4
4
  "description": "Intelligent Command Line Assistant powered by AI",
5
5
  "license": "MIT",
6
6
  "bin": {