snow-ai 0.2.15 → 0.2.16
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/api/anthropic.d.ts +1 -1
- package/dist/api/anthropic.js +52 -76
- package/dist/api/chat.d.ts +4 -4
- package/dist/api/chat.js +32 -17
- package/dist/api/gemini.d.ts +1 -1
- package/dist/api/gemini.js +20 -13
- package/dist/api/responses.d.ts +5 -5
- package/dist/api/responses.js +29 -27
- package/dist/app.js +4 -1
- package/dist/hooks/useClipboard.d.ts +4 -0
- package/dist/hooks/useClipboard.js +120 -0
- package/dist/hooks/useCommandHandler.d.ts +26 -0
- package/dist/hooks/useCommandHandler.js +158 -0
- package/dist/hooks/useCommandPanel.d.ts +16 -0
- package/dist/hooks/useCommandPanel.js +53 -0
- package/dist/hooks/useConversation.d.ts +9 -1
- package/dist/hooks/useConversation.js +152 -58
- package/dist/hooks/useFilePicker.d.ts +17 -0
- package/dist/hooks/useFilePicker.js +91 -0
- package/dist/hooks/useHistoryNavigation.d.ts +21 -0
- package/dist/hooks/useHistoryNavigation.js +50 -0
- package/dist/hooks/useInputBuffer.d.ts +6 -0
- package/dist/hooks/useInputBuffer.js +29 -0
- package/dist/hooks/useKeyboardInput.d.ts +51 -0
- package/dist/hooks/useKeyboardInput.js +272 -0
- package/dist/hooks/useSnapshotState.d.ts +12 -0
- package/dist/hooks/useSnapshotState.js +28 -0
- package/dist/hooks/useStreamingState.d.ts +24 -0
- package/dist/hooks/useStreamingState.js +96 -0
- package/dist/hooks/useVSCodeState.d.ts +8 -0
- package/dist/hooks/useVSCodeState.js +63 -0
- package/dist/mcp/filesystem.d.ts +24 -5
- package/dist/mcp/filesystem.js +52 -17
- package/dist/mcp/todo.js +4 -8
- package/dist/ui/components/ChatInput.js +68 -557
- package/dist/ui/components/DiffViewer.js +57 -30
- package/dist/ui/components/FileList.js +70 -26
- package/dist/ui/components/MessageList.d.ts +6 -0
- package/dist/ui/components/MessageList.js +47 -15
- package/dist/ui/components/ShimmerText.d.ts +9 -0
- package/dist/ui/components/ShimmerText.js +30 -0
- package/dist/ui/components/TodoTree.d.ts +1 -1
- package/dist/ui/components/TodoTree.js +0 -4
- package/dist/ui/components/ToolConfirmation.js +14 -6
- package/dist/ui/pages/ChatScreen.js +159 -359
- package/dist/ui/pages/CustomHeadersScreen.d.ts +6 -0
- package/dist/ui/pages/CustomHeadersScreen.js +104 -0
- package/dist/ui/pages/WelcomeScreen.js +5 -0
- package/dist/utils/apiConfig.d.ts +10 -0
- package/dist/utils/apiConfig.js +51 -0
- package/dist/utils/incrementalSnapshot.d.ts +8 -0
- package/dist/utils/incrementalSnapshot.js +63 -0
- package/dist/utils/mcpToolsManager.js +6 -1
- package/dist/utils/retryUtils.d.ts +22 -0
- package/dist/utils/retryUtils.js +180 -0
- package/dist/utils/sessionConverter.js +80 -17
- package/dist/utils/sessionManager.js +35 -4
- package/dist/utils/textUtils.d.ts +4 -0
- package/dist/utils/textUtils.js +19 -0
- package/dist/utils/todoPreprocessor.d.ts +1 -1
- package/dist/utils/todoPreprocessor.js +0 -1
- package/dist/utils/vscodeConnection.d.ts +8 -0
- package/dist/utils/vscodeConnection.js +44 -0
- package/package.json +1 -1
- package/readme.md +3 -1
|
@@ -10,11 +10,15 @@ import { getOpenAiConfig } from '../utils/apiConfig.js';
|
|
|
10
10
|
import { sessionManager } from '../utils/sessionManager.js';
|
|
11
11
|
import { formatTodoContext } from '../utils/todoPreprocessor.js';
|
|
12
12
|
import { formatToolCallMessage } from '../utils/messageFormatter.js';
|
|
13
|
+
import { vscodeConnection } from '../utils/vscodeConnection.js';
|
|
14
|
+
import { filesystemService } from '../mcp/filesystem.js';
|
|
13
15
|
/**
|
|
14
16
|
* Handle conversation with streaming and tool calls
|
|
15
17
|
*/
|
|
16
18
|
export async function handleConversationWithTools(options) {
|
|
17
|
-
const { userContent, imageContents, controller,
|
|
19
|
+
const { userContent, imageContents, controller,
|
|
20
|
+
// messages, // No longer used - we load from session instead to get complete history with tool calls
|
|
21
|
+
saveMessage, setMessages, setStreamTokenCount, setCurrentTodos, requestToolConfirmation, isToolAutoApproved, addMultipleToAlwaysApproved, yoloMode, setContextUsage, setIsReasoning, setRetryStatus } = options;
|
|
18
22
|
// Step 1: Ensure session exists and get existing TODOs
|
|
19
23
|
let currentSession = sessionManager.getCurrentSession();
|
|
20
24
|
if (!currentSession) {
|
|
@@ -41,12 +45,13 @@ export async function handleConversationWithTools(options) {
|
|
|
41
45
|
content: todoContext
|
|
42
46
|
});
|
|
43
47
|
}
|
|
44
|
-
// Add history messages
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
48
|
+
// Add history messages from session (includes tool_calls and tool results)
|
|
49
|
+
// Load from session to get complete conversation history with tool interactions
|
|
50
|
+
const session = sessionManager.getCurrentSession();
|
|
51
|
+
if (session && session.messages.length > 0) {
|
|
52
|
+
// Use session messages directly (they are already in API format)
|
|
53
|
+
conversationMessages.push(...session.messages);
|
|
54
|
+
}
|
|
50
55
|
// Add current user message
|
|
51
56
|
conversationMessages.push({
|
|
52
57
|
role: 'user',
|
|
@@ -87,10 +92,22 @@ export async function handleConversationWithTools(options) {
|
|
|
87
92
|
// Stream AI response - choose API based on config
|
|
88
93
|
let toolCallAccumulator = ''; // Accumulate tool call deltas for token counting
|
|
89
94
|
let reasoningAccumulator = ''; // Accumulate reasoning summary deltas for token counting (Responses API only)
|
|
95
|
+
let chunkCount = 0; // Track number of chunks received (to delay clearing retry status)
|
|
90
96
|
// Get or create session for cache key
|
|
91
97
|
const currentSession = sessionManager.getCurrentSession();
|
|
92
98
|
// Use session ID as cache key to ensure same session requests share cache
|
|
93
99
|
const cacheKey = currentSession?.id;
|
|
100
|
+
// 重试回调函数
|
|
101
|
+
const onRetry = (error, attempt, nextDelay) => {
|
|
102
|
+
if (setRetryStatus) {
|
|
103
|
+
setRetryStatus({
|
|
104
|
+
isRetrying: true,
|
|
105
|
+
attempt,
|
|
106
|
+
nextDelay,
|
|
107
|
+
errorMessage: error.message
|
|
108
|
+
});
|
|
109
|
+
}
|
|
110
|
+
};
|
|
94
111
|
const streamGenerator = config.requestMethod === 'anthropic'
|
|
95
112
|
? createStreamingAnthropicCompletion({
|
|
96
113
|
model,
|
|
@@ -99,14 +116,14 @@ export async function handleConversationWithTools(options) {
|
|
|
99
116
|
max_tokens: config.maxTokens || 4096,
|
|
100
117
|
tools: mcpTools.length > 0 ? mcpTools : undefined,
|
|
101
118
|
sessionId: currentSession?.id
|
|
102
|
-
}, controller.signal)
|
|
119
|
+
}, controller.signal, onRetry)
|
|
103
120
|
: config.requestMethod === 'gemini'
|
|
104
121
|
? createStreamingGeminiCompletion({
|
|
105
122
|
model,
|
|
106
123
|
messages: conversationMessages,
|
|
107
124
|
temperature: 0,
|
|
108
125
|
tools: mcpTools.length > 0 ? mcpTools : undefined
|
|
109
|
-
}, controller.signal)
|
|
126
|
+
}, controller.signal, onRetry)
|
|
110
127
|
: config.requestMethod === 'responses'
|
|
111
128
|
? createStreamingResponse({
|
|
112
129
|
model,
|
|
@@ -114,18 +131,32 @@ export async function handleConversationWithTools(options) {
|
|
|
114
131
|
temperature: 0,
|
|
115
132
|
tools: mcpTools.length > 0 ? mcpTools : undefined,
|
|
116
133
|
prompt_cache_key: cacheKey // Use session ID as cache key
|
|
117
|
-
}, controller.signal)
|
|
134
|
+
}, controller.signal, onRetry)
|
|
118
135
|
: createStreamingChatCompletion({
|
|
119
136
|
model,
|
|
120
137
|
messages: conversationMessages,
|
|
121
138
|
temperature: 0,
|
|
122
139
|
tools: mcpTools.length > 0 ? mcpTools : undefined
|
|
123
|
-
}, controller.signal);
|
|
140
|
+
}, controller.signal, onRetry);
|
|
124
141
|
for await (const chunk of streamGenerator) {
|
|
125
142
|
if (controller.signal.aborted)
|
|
126
143
|
break;
|
|
127
|
-
|
|
144
|
+
// Clear retry status after a delay when first chunk arrives
|
|
145
|
+
// This gives users time to see the retry message (500ms delay)
|
|
146
|
+
chunkCount++;
|
|
147
|
+
if (setRetryStatus && chunkCount === 1) {
|
|
148
|
+
setTimeout(() => {
|
|
149
|
+
setRetryStatus(null);
|
|
150
|
+
}, 500);
|
|
151
|
+
}
|
|
152
|
+
if (chunk.type === 'reasoning_started') {
|
|
153
|
+
// Reasoning started (Responses API only) - set reasoning state
|
|
154
|
+
setIsReasoning?.(true);
|
|
155
|
+
}
|
|
156
|
+
else if (chunk.type === 'content' && chunk.content) {
|
|
128
157
|
// Accumulate content and update token count
|
|
158
|
+
// When content starts, reasoning is done
|
|
159
|
+
setIsReasoning?.(false);
|
|
129
160
|
streamedContent += chunk.content;
|
|
130
161
|
try {
|
|
131
162
|
const tokens = encoder.encode(streamedContent + toolCallAccumulator + reasoningAccumulator);
|
|
@@ -236,66 +267,117 @@ export async function handleConversationWithTools(options) {
|
|
|
236
267
|
}
|
|
237
268
|
else if (toolsNeedingConfirmation.length > 0) {
|
|
238
269
|
const firstTool = toolsNeedingConfirmation[0]; // Safe: length > 0 guarantees this exists
|
|
239
|
-
//
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
270
|
+
// Check if we should use DIFF+APPLY for filesystem tools
|
|
271
|
+
let usedDiffApply = false;
|
|
272
|
+
if (vscodeConnection.isConnected()) {
|
|
273
|
+
// Try to use DIFF+APPLY for filesystem_create or filesystem_edit
|
|
274
|
+
if (firstTool.function.name === 'filesystem-create' || firstTool.function.name === 'filesystem-edit') {
|
|
275
|
+
try {
|
|
276
|
+
const args = JSON.parse(firstTool.function.arguments);
|
|
277
|
+
if (firstTool.function.name === 'filesystem-create') {
|
|
278
|
+
// For create, show diff with empty old content
|
|
279
|
+
const confirmation = await vscodeConnection.requestDiffApply(args.filePath, '', // Empty old content for new file
|
|
280
|
+
args.content);
|
|
281
|
+
if (confirmation === 'reject') {
|
|
282
|
+
// Remove pending tool messages
|
|
283
|
+
setMessages(prev => prev.filter(msg => !msg.toolPending));
|
|
284
|
+
setMessages(prev => [...prev, {
|
|
285
|
+
role: 'assistant',
|
|
286
|
+
content: 'Tool call rejected, session ended',
|
|
287
|
+
streaming: false
|
|
288
|
+
}]);
|
|
289
|
+
if (options.setIsStreaming) {
|
|
290
|
+
options.setIsStreaming(false);
|
|
291
|
+
}
|
|
292
|
+
encoder.free();
|
|
293
|
+
return;
|
|
294
|
+
}
|
|
295
|
+
if (confirmation === 'approve_always') {
|
|
296
|
+
addMultipleToAlwaysApproved([firstTool.function.name]);
|
|
297
|
+
sessionApprovedTools.add(firstTool.function.name);
|
|
298
|
+
}
|
|
299
|
+
approvedTools.push(...toolsNeedingConfirmation);
|
|
300
|
+
usedDiffApply = true;
|
|
301
|
+
}
|
|
302
|
+
else if (firstTool.function.name === 'filesystem-edit') {
|
|
303
|
+
// For edit, read the file first to get old content
|
|
304
|
+
const fileContent = await filesystemService.getFileContent(args.filePath, args.startLine, args.endLine);
|
|
305
|
+
const confirmation = await vscodeConnection.requestDiffApply(args.filePath, fileContent.content, args.newContent);
|
|
306
|
+
if (confirmation === 'reject') {
|
|
307
|
+
// Remove pending tool messages
|
|
308
|
+
setMessages(prev => prev.filter(msg => !msg.toolPending));
|
|
309
|
+
setMessages(prev => [...prev, {
|
|
310
|
+
role: 'assistant',
|
|
311
|
+
content: 'Tool call rejected, session ended',
|
|
312
|
+
streaming: false
|
|
313
|
+
}]);
|
|
314
|
+
if (options.setIsStreaming) {
|
|
315
|
+
options.setIsStreaming(false);
|
|
316
|
+
}
|
|
317
|
+
encoder.free();
|
|
318
|
+
return;
|
|
319
|
+
}
|
|
320
|
+
if (confirmation === 'approve_always') {
|
|
321
|
+
addMultipleToAlwaysApproved([firstTool.function.name]);
|
|
322
|
+
sessionApprovedTools.add(firstTool.function.name);
|
|
323
|
+
}
|
|
324
|
+
approvedTools.push(...toolsNeedingConfirmation);
|
|
325
|
+
usedDiffApply = true;
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
catch (error) {
|
|
329
|
+
// If DIFF+APPLY fails, fall back to regular confirmation
|
|
330
|
+
console.error('Failed to use DIFF+APPLY:', error);
|
|
331
|
+
}
|
|
257
332
|
}
|
|
258
|
-
encoder.free();
|
|
259
|
-
return; // Exit the conversation loop
|
|
260
333
|
}
|
|
261
|
-
// If
|
|
262
|
-
if (
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
334
|
+
// If we didn't use DIFF+APPLY, fall back to regular CLI confirmation
|
|
335
|
+
if (!usedDiffApply) {
|
|
336
|
+
// Pass all tools for proper display in confirmation UI
|
|
337
|
+
const allTools = toolsNeedingConfirmation.length > 1
|
|
338
|
+
? toolsNeedingConfirmation
|
|
339
|
+
: undefined;
|
|
340
|
+
// Use first tool for confirmation UI, but apply result to all
|
|
341
|
+
const confirmation = await requestToolConfirmation(firstTool, undefined, allTools);
|
|
342
|
+
if (confirmation === 'reject') {
|
|
343
|
+
// Remove pending tool messages
|
|
344
|
+
setMessages(prev => prev.filter(msg => !msg.toolPending));
|
|
345
|
+
// User rejected - end conversation
|
|
346
|
+
setMessages(prev => [...prev, {
|
|
347
|
+
role: 'assistant',
|
|
348
|
+
content: 'Tool call rejected, session ended',
|
|
349
|
+
streaming: false
|
|
350
|
+
}]);
|
|
351
|
+
// End streaming immediately
|
|
352
|
+
if (options.setIsStreaming) {
|
|
353
|
+
options.setIsStreaming(false);
|
|
354
|
+
}
|
|
355
|
+
encoder.free();
|
|
356
|
+
return; // Exit the conversation loop
|
|
357
|
+
}
|
|
358
|
+
// If approved_always, add ALL these tools to both global and session-approved sets
|
|
359
|
+
if (confirmation === 'approve_always') {
|
|
360
|
+
const toolNamesToAdd = toolsNeedingConfirmation.map(t => t.function.name);
|
|
361
|
+
// Add to global state (async, for future sessions)
|
|
362
|
+
addMultipleToAlwaysApproved(toolNamesToAdd);
|
|
363
|
+
// Add to local session set (sync, for this conversation)
|
|
364
|
+
toolNamesToAdd.forEach(name => sessionApprovedTools.add(name));
|
|
365
|
+
}
|
|
366
|
+
// Add all tools to approved list
|
|
367
|
+
approvedTools.push(...toolsNeedingConfirmation);
|
|
268
368
|
}
|
|
269
|
-
// Add all tools to approved list
|
|
270
|
-
approvedTools.push(...toolsNeedingConfirmation);
|
|
271
369
|
}
|
|
272
370
|
// Execute approved tools
|
|
273
371
|
const toolResults = await executeToolCalls(approvedTools);
|
|
274
372
|
// Check if there are TODO related tool calls, if yes refresh TODO list
|
|
275
|
-
// Only show TODO panel for todo-update, not for other todo operations
|
|
276
|
-
const shouldShowTodoPanel = approvedTools.some(t => t.function.name === 'todo-update');
|
|
277
373
|
const hasTodoTools = approvedTools.some(t => t.function.name.startsWith('todo-'));
|
|
374
|
+
const hasTodoUpdateTools = approvedTools.some(t => t.function.name === 'todo-update');
|
|
278
375
|
if (hasTodoTools) {
|
|
279
376
|
const session = sessionManager.getCurrentSession();
|
|
280
377
|
if (session) {
|
|
281
378
|
const updatedTodoList = await todoService.getTodoList(session.id);
|
|
282
379
|
if (updatedTodoList) {
|
|
283
380
|
setCurrentTodos(updatedTodoList.todos);
|
|
284
|
-
// Only show TODO panel for update operations
|
|
285
|
-
if (shouldShowTodoPanel) {
|
|
286
|
-
// Remove any existing TODO tree messages and add a new one
|
|
287
|
-
setMessages(prev => {
|
|
288
|
-
// Filter out previous TODO tree messages
|
|
289
|
-
const withoutTodoTree = prev.filter(m => !m.showTodoTree);
|
|
290
|
-
// Add new TODO tree message
|
|
291
|
-
return [...withoutTodoTree, {
|
|
292
|
-
role: 'assistant',
|
|
293
|
-
content: '[TODO List Updated]',
|
|
294
|
-
streaming: false,
|
|
295
|
-
showTodoTree: true
|
|
296
|
-
}];
|
|
297
|
-
});
|
|
298
|
-
}
|
|
299
381
|
}
|
|
300
382
|
}
|
|
301
383
|
}
|
|
@@ -368,6 +450,18 @@ export async function handleConversationWithTools(options) {
|
|
|
368
450
|
console.error('Failed to save tool result:', error);
|
|
369
451
|
});
|
|
370
452
|
}
|
|
453
|
+
// After all tool results are processed, show TODO panel if there were todo-update calls
|
|
454
|
+
if (hasTodoUpdateTools) {
|
|
455
|
+
setMessages(prev => [
|
|
456
|
+
...prev,
|
|
457
|
+
{
|
|
458
|
+
role: 'assistant',
|
|
459
|
+
content: '',
|
|
460
|
+
streaming: false,
|
|
461
|
+
showTodoTree: true
|
|
462
|
+
}
|
|
463
|
+
]);
|
|
464
|
+
}
|
|
371
465
|
// Check if there are pending user messages to insert
|
|
372
466
|
if (options.getPendingMessages && options.clearPendingMessages) {
|
|
373
467
|
const pendingMessages = options.getPendingMessages();
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { TextBuffer } from '../utils/textBuffer.js';
|
|
2
|
+
import { FileListRef } from '../ui/components/FileList.js';
|
|
3
|
+
export declare function useFilePicker(buffer: TextBuffer, triggerUpdate: () => void): {
|
|
4
|
+
showFilePicker: boolean;
|
|
5
|
+
setShowFilePicker: import("react").Dispatch<import("react").SetStateAction<boolean>>;
|
|
6
|
+
fileSelectedIndex: number;
|
|
7
|
+
setFileSelectedIndex: import("react").Dispatch<import("react").SetStateAction<number>>;
|
|
8
|
+
fileQuery: string;
|
|
9
|
+
setFileQuery: import("react").Dispatch<import("react").SetStateAction<string>>;
|
|
10
|
+
atSymbolPosition: number;
|
|
11
|
+
setAtSymbolPosition: import("react").Dispatch<import("react").SetStateAction<number>>;
|
|
12
|
+
filteredFileCount: number;
|
|
13
|
+
updateFilePickerState: (text: string, cursorPos: number) => void;
|
|
14
|
+
handleFileSelect: (filePath: string) => Promise<void>;
|
|
15
|
+
handleFilteredCountChange: (count: number) => void;
|
|
16
|
+
fileListRef: import("react").RefObject<FileListRef>;
|
|
17
|
+
};
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import { useState, useCallback, useRef } from 'react';
|
|
2
|
+
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);
|
|
8
|
+
const fileListRef = useRef(null);
|
|
9
|
+
// Update file picker state
|
|
10
|
+
const updateFilePickerState = useCallback((text, cursorPos) => {
|
|
11
|
+
if (!text.includes('@')) {
|
|
12
|
+
if (showFilePicker) {
|
|
13
|
+
setShowFilePicker(false);
|
|
14
|
+
setFileSelectedIndex(0);
|
|
15
|
+
setFileQuery('');
|
|
16
|
+
setAtSymbolPosition(-1);
|
|
17
|
+
}
|
|
18
|
+
return;
|
|
19
|
+
}
|
|
20
|
+
// Find the last '@' symbol before the cursor
|
|
21
|
+
const beforeCursor = text.slice(0, cursorPos);
|
|
22
|
+
const lastAtIndex = beforeCursor.lastIndexOf('@');
|
|
23
|
+
if (lastAtIndex !== -1) {
|
|
24
|
+
// Check if there's no space between '@' and cursor
|
|
25
|
+
const afterAt = beforeCursor.slice(lastAtIndex + 1);
|
|
26
|
+
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);
|
|
34
|
+
}
|
|
35
|
+
return;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
// Hide file picker if no valid @ context found
|
|
39
|
+
if (showFilePicker) {
|
|
40
|
+
setShowFilePicker(false);
|
|
41
|
+
setFileSelectedIndex(0);
|
|
42
|
+
setFileQuery('');
|
|
43
|
+
setAtSymbolPosition(-1);
|
|
44
|
+
}
|
|
45
|
+
}, [showFilePicker, fileQuery, atSymbolPosition]);
|
|
46
|
+
// Handle file selection
|
|
47
|
+
const handleFileSelect = useCallback(async (filePath) => {
|
|
48
|
+
if (atSymbolPosition !== -1) {
|
|
49
|
+
const text = buffer.getFullText();
|
|
50
|
+
const cursorPos = buffer.getCursorPosition();
|
|
51
|
+
// Replace @query with @filePath + space
|
|
52
|
+
const beforeAt = text.slice(0, atSymbolPosition);
|
|
53
|
+
const afterCursor = text.slice(cursorPos);
|
|
54
|
+
const newText = beforeAt + '@' + filePath + ' ' + afterCursor;
|
|
55
|
+
// Set the new text and position cursor after the inserted file path + space
|
|
56
|
+
buffer.setText(newText);
|
|
57
|
+
// Calculate cursor position after the inserted file path + space
|
|
58
|
+
// Reset cursor to beginning, then move to correct position
|
|
59
|
+
for (let i = 0; i < atSymbolPosition + filePath.length + 2; i++) {
|
|
60
|
+
// +2 for @ and space
|
|
61
|
+
if (i < buffer.getFullText().length) {
|
|
62
|
+
buffer.moveRight();
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
setShowFilePicker(false);
|
|
66
|
+
setFileSelectedIndex(0);
|
|
67
|
+
setFileQuery('');
|
|
68
|
+
setAtSymbolPosition(-1);
|
|
69
|
+
triggerUpdate();
|
|
70
|
+
}
|
|
71
|
+
}, [atSymbolPosition, buffer, triggerUpdate]);
|
|
72
|
+
// Handle filtered file count change
|
|
73
|
+
const handleFilteredCountChange = useCallback((count) => {
|
|
74
|
+
setFilteredFileCount(count);
|
|
75
|
+
}, []);
|
|
76
|
+
return {
|
|
77
|
+
showFilePicker,
|
|
78
|
+
setShowFilePicker,
|
|
79
|
+
fileSelectedIndex,
|
|
80
|
+
setFileSelectedIndex,
|
|
81
|
+
fileQuery,
|
|
82
|
+
setFileQuery,
|
|
83
|
+
atSymbolPosition,
|
|
84
|
+
setAtSymbolPosition,
|
|
85
|
+
filteredFileCount,
|
|
86
|
+
updateFilePickerState,
|
|
87
|
+
handleFileSelect,
|
|
88
|
+
handleFilteredCountChange,
|
|
89
|
+
fileListRef,
|
|
90
|
+
};
|
|
91
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { TextBuffer } from '../utils/textBuffer.js';
|
|
2
|
+
type ChatMessage = {
|
|
3
|
+
role: string;
|
|
4
|
+
content: string;
|
|
5
|
+
};
|
|
6
|
+
export declare function useHistoryNavigation(buffer: TextBuffer, triggerUpdate: () => void, chatHistory: ChatMessage[], onHistorySelect?: (selectedIndex: number, message: string) => void): {
|
|
7
|
+
showHistoryMenu: boolean;
|
|
8
|
+
setShowHistoryMenu: import("react").Dispatch<import("react").SetStateAction<boolean>>;
|
|
9
|
+
historySelectedIndex: number;
|
|
10
|
+
setHistorySelectedIndex: import("react").Dispatch<import("react").SetStateAction<number>>;
|
|
11
|
+
escapeKeyCount: number;
|
|
12
|
+
setEscapeKeyCount: import("react").Dispatch<import("react").SetStateAction<number>>;
|
|
13
|
+
escapeKeyTimer: import("react").MutableRefObject<NodeJS.Timeout | null>;
|
|
14
|
+
getUserMessages: () => {
|
|
15
|
+
label: string;
|
|
16
|
+
value: string;
|
|
17
|
+
infoText: string;
|
|
18
|
+
}[];
|
|
19
|
+
handleHistorySelect: (value: string) => void;
|
|
20
|
+
};
|
|
21
|
+
export {};
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { useState, useCallback, useRef, useEffect } from 'react';
|
|
2
|
+
export function useHistoryNavigation(buffer, triggerUpdate, chatHistory, onHistorySelect) {
|
|
3
|
+
const [showHistoryMenu, setShowHistoryMenu] = useState(false);
|
|
4
|
+
const [historySelectedIndex, setHistorySelectedIndex] = useState(0);
|
|
5
|
+
const [escapeKeyCount, setEscapeKeyCount] = useState(0);
|
|
6
|
+
const escapeKeyTimer = useRef(null);
|
|
7
|
+
// Cleanup timer on unmount
|
|
8
|
+
useEffect(() => {
|
|
9
|
+
return () => {
|
|
10
|
+
if (escapeKeyTimer.current) {
|
|
11
|
+
clearTimeout(escapeKeyTimer.current);
|
|
12
|
+
}
|
|
13
|
+
};
|
|
14
|
+
}, []);
|
|
15
|
+
// Get user messages from chat history for navigation
|
|
16
|
+
const getUserMessages = useCallback(() => {
|
|
17
|
+
const userMessages = chatHistory
|
|
18
|
+
.map((msg, index) => ({ ...msg, originalIndex: index }))
|
|
19
|
+
.filter(msg => msg.role === 'user' && msg.content.trim());
|
|
20
|
+
// Keep original order (oldest first, newest last) and map with display numbers
|
|
21
|
+
return userMessages.map((msg, index) => ({
|
|
22
|
+
label: `${index + 1}. ${msg.content.slice(0, 50)}${msg.content.length > 50 ? '...' : ''}`,
|
|
23
|
+
value: msg.originalIndex.toString(),
|
|
24
|
+
infoText: msg.content,
|
|
25
|
+
}));
|
|
26
|
+
}, [chatHistory]);
|
|
27
|
+
// Handle history selection
|
|
28
|
+
const handleHistorySelect = useCallback((value) => {
|
|
29
|
+
const selectedIndex = parseInt(value, 10);
|
|
30
|
+
const selectedMessage = chatHistory[selectedIndex];
|
|
31
|
+
if (selectedMessage && onHistorySelect) {
|
|
32
|
+
// Put the message content in the input buffer
|
|
33
|
+
buffer.setText(selectedMessage.content);
|
|
34
|
+
setShowHistoryMenu(false);
|
|
35
|
+
triggerUpdate();
|
|
36
|
+
onHistorySelect(selectedIndex, selectedMessage.content);
|
|
37
|
+
}
|
|
38
|
+
}, [chatHistory, onHistorySelect, buffer, triggerUpdate]);
|
|
39
|
+
return {
|
|
40
|
+
showHistoryMenu,
|
|
41
|
+
setShowHistoryMenu,
|
|
42
|
+
historySelectedIndex,
|
|
43
|
+
setHistorySelectedIndex,
|
|
44
|
+
escapeKeyCount,
|
|
45
|
+
setEscapeKeyCount,
|
|
46
|
+
escapeKeyTimer,
|
|
47
|
+
getUserMessages,
|
|
48
|
+
handleHistorySelect,
|
|
49
|
+
};
|
|
50
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { useState, useCallback, useEffect, useRef } from 'react';
|
|
2
|
+
import { TextBuffer } from '../utils/textBuffer.js';
|
|
3
|
+
export function useInputBuffer(viewport) {
|
|
4
|
+
const [, forceUpdate] = useState({});
|
|
5
|
+
const lastUpdateTime = useRef(0);
|
|
6
|
+
// Force re-render when buffer changes
|
|
7
|
+
const triggerUpdate = useCallback(() => {
|
|
8
|
+
const now = Date.now();
|
|
9
|
+
lastUpdateTime.current = now;
|
|
10
|
+
forceUpdate({});
|
|
11
|
+
}, []);
|
|
12
|
+
const [buffer] = useState(() => new TextBuffer(viewport, triggerUpdate));
|
|
13
|
+
// Update buffer viewport when viewport changes
|
|
14
|
+
useEffect(() => {
|
|
15
|
+
buffer.updateViewport(viewport);
|
|
16
|
+
triggerUpdate();
|
|
17
|
+
}, [viewport.width, viewport.height, buffer, triggerUpdate]);
|
|
18
|
+
// Cleanup buffer on unmount
|
|
19
|
+
useEffect(() => {
|
|
20
|
+
return () => {
|
|
21
|
+
buffer.destroy();
|
|
22
|
+
};
|
|
23
|
+
}, [buffer]);
|
|
24
|
+
return {
|
|
25
|
+
buffer,
|
|
26
|
+
triggerUpdate,
|
|
27
|
+
forceUpdate,
|
|
28
|
+
};
|
|
29
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { TextBuffer } from '../utils/textBuffer.js';
|
|
2
|
+
type KeyboardInputOptions = {
|
|
3
|
+
buffer: TextBuffer;
|
|
4
|
+
disabled: boolean;
|
|
5
|
+
triggerUpdate: () => void;
|
|
6
|
+
forceUpdate: React.Dispatch<React.SetStateAction<{}>>;
|
|
7
|
+
showCommands: boolean;
|
|
8
|
+
setShowCommands: (show: boolean) => void;
|
|
9
|
+
commandSelectedIndex: number;
|
|
10
|
+
setCommandSelectedIndex: (index: number | ((prev: number) => number)) => void;
|
|
11
|
+
getFilteredCommands: () => Array<{
|
|
12
|
+
name: string;
|
|
13
|
+
description: string;
|
|
14
|
+
}>;
|
|
15
|
+
updateCommandPanelState: (text: string) => void;
|
|
16
|
+
onCommand?: (commandName: string, result: any) => void;
|
|
17
|
+
showFilePicker: boolean;
|
|
18
|
+
setShowFilePicker: (show: boolean) => void;
|
|
19
|
+
fileSelectedIndex: number;
|
|
20
|
+
setFileSelectedIndex: (index: number | ((prev: number) => number)) => void;
|
|
21
|
+
fileQuery: string;
|
|
22
|
+
setFileQuery: (query: string) => void;
|
|
23
|
+
atSymbolPosition: number;
|
|
24
|
+
setAtSymbolPosition: (pos: number) => void;
|
|
25
|
+
filteredFileCount: number;
|
|
26
|
+
updateFilePickerState: (text: string, cursorPos: number) => void;
|
|
27
|
+
handleFileSelect: (filePath: string) => Promise<void>;
|
|
28
|
+
fileListRef: React.RefObject<{
|
|
29
|
+
getSelectedFile: () => string | null;
|
|
30
|
+
}>;
|
|
31
|
+
showHistoryMenu: boolean;
|
|
32
|
+
setShowHistoryMenu: (show: boolean) => void;
|
|
33
|
+
historySelectedIndex: number;
|
|
34
|
+
setHistorySelectedIndex: (index: number | ((prev: number) => number)) => void;
|
|
35
|
+
escapeKeyCount: number;
|
|
36
|
+
setEscapeKeyCount: (count: number | ((prev: number) => number)) => void;
|
|
37
|
+
escapeKeyTimer: React.MutableRefObject<NodeJS.Timeout | null>;
|
|
38
|
+
getUserMessages: () => Array<{
|
|
39
|
+
label: string;
|
|
40
|
+
value: string;
|
|
41
|
+
infoText: string;
|
|
42
|
+
}>;
|
|
43
|
+
handleHistorySelect: (value: string) => void;
|
|
44
|
+
pasteFromClipboard: () => Promise<void>;
|
|
45
|
+
onSubmit: (message: string, images?: Array<{
|
|
46
|
+
data: string;
|
|
47
|
+
mimeType: string;
|
|
48
|
+
}>) => void;
|
|
49
|
+
};
|
|
50
|
+
export declare function useKeyboardInput(options: KeyboardInputOptions): void;
|
|
51
|
+
export {};
|