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.
- package/dist/hooks/useFilePicker.d.ts +4 -4
- package/dist/hooks/useFilePicker.js +88 -40
- package/dist/hooks/useHistoryNavigation.d.ts +5 -0
- package/dist/hooks/useHistoryNavigation.js +78 -1
- package/dist/hooks/useInputBuffer.js +1 -1
- package/dist/hooks/useKeyboardInput.d.ts +5 -0
- package/dist/hooks/useKeyboardInput.js +51 -9
- package/dist/hooks/useStreamingState.js +1 -1
- package/dist/mcp/aceCodeSearch.d.ts +4 -0
- package/dist/mcp/aceCodeSearch.js +17 -1
- package/dist/mcp/filesystem.js +2 -2
- package/dist/mcp/utils/filesystem/match-finder.utils.js +1 -1
- package/dist/ui/components/ChatInput.js +11 -4
- package/dist/utils/historyManager.d.ts +45 -0
- package/dist/utils/historyManager.js +159 -0
- package/dist/utils/textBuffer.js +24 -5
- package/package.json +1 -1
|
@@ -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:
|
|
5
|
+
setShowFilePicker: (show: boolean) => void;
|
|
6
6
|
fileSelectedIndex: number;
|
|
7
|
-
setFileSelectedIndex:
|
|
7
|
+
setFileSelectedIndex: (index: number | ((prev: number) => number)) => void;
|
|
8
8
|
fileQuery: string;
|
|
9
|
-
setFileQuery:
|
|
9
|
+
setFileQuery: (_query: string) => void;
|
|
10
10
|
atSymbolPosition: number;
|
|
11
|
-
setAtSymbolPosition:
|
|
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 {
|
|
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 [
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
66
|
-
setFileSelectedIndex(0);
|
|
67
|
-
setFileQuery('');
|
|
68
|
-
setAtSymbolPosition(-1);
|
|
98
|
+
dispatch({ type: 'SELECT_FILE' });
|
|
69
99
|
triggerUpdate();
|
|
70
100
|
}
|
|
71
|
-
}, [atSymbolPosition, buffer
|
|
101
|
+
}, [state.atSymbolPosition, buffer]);
|
|
72
102
|
// Handle filtered file count change
|
|
73
103
|
const handleFilteredCountChange = useCallback((count) => {
|
|
74
|
-
|
|
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
|
-
|
|
84
|
-
|
|
85
|
-
|
|
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
|
|
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
|
|
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
|
|
132
|
+
const displayText = buffer.text;
|
|
133
133
|
const cursorPos = buffer.getCursorPosition();
|
|
134
|
-
const afterCursor =
|
|
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
|
|
141
|
+
const displayText = buffer.text;
|
|
142
142
|
const cursorPos = buffer.getCursorPosition();
|
|
143
|
-
const beforeCursor =
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
@@ -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
|
-
|
|
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,
|
package/dist/mcp/filesystem.js
CHANGED
|
@@ -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 =>
|
|
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 =>
|
|
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 =>
|
|
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
|
-
|
|
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
|
|
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
|
|
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();
|
package/dist/utils/textBuffer.js
CHANGED
|
@@ -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;
|