snow-ai 0.3.22 → 0.3.24
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/gemini.d.ts +5 -1
- package/dist/api/gemini.js +30 -5
- package/dist/api/responses.js +18 -3
- package/dist/hooks/useConversation.d.ts +0 -5
- package/dist/hooks/useConversation.js +109 -56
- package/dist/hooks/useFilePicker.d.ts +1 -1
- package/dist/hooks/useFilePicker.js +13 -7
- package/dist/hooks/useHistoryNavigation.js +14 -7
- package/dist/hooks/useInputBuffer.d.ts +1 -1
- package/dist/hooks/useInputBuffer.js +22 -6
- package/dist/hooks/useStreamingState.js +2 -2
- package/dist/hooks/useVSCodeState.js +23 -6
- package/dist/mcp/filesystem.js +1 -1
- package/dist/ui/components/ChatInput.js +17 -11
- package/dist/ui/components/MessageList.d.ts +0 -1
- package/dist/ui/components/MessageList.js +1 -2
- package/dist/ui/components/SessionListPanel.js +12 -8
- package/dist/ui/components/SessionListScreen.js +2 -1
- package/dist/ui/components/ToolConfirmation.d.ts +1 -1
- package/dist/ui/components/ToolConfirmation.js +63 -22
- package/dist/ui/components/ToolResultPreview.js +33 -6
- package/dist/ui/pages/ChatScreen.js +21 -17
- package/dist/ui/pages/ConfigScreen.js +167 -16
- package/dist/ui/pages/HeadlessModeScreen.js +0 -1
- package/dist/ui/pages/ProxyConfigScreen.d.ts +1 -1
- package/dist/ui/pages/ProxyConfigScreen.js +6 -6
- package/dist/ui/pages/SensitiveCommandConfigScreen.d.ts +7 -0
- package/dist/ui/pages/SensitiveCommandConfigScreen.js +262 -0
- package/dist/ui/pages/SubAgentConfigScreen.js +1 -1
- package/dist/ui/pages/WelcomeScreen.js +14 -3
- package/dist/utils/apiConfig.d.ts +10 -0
- package/dist/utils/sensitiveCommandManager.d.ts +53 -0
- package/dist/utils/sensitiveCommandManager.js +308 -0
- package/dist/utils/sessionConverter.js +16 -11
- package/package.json +4 -2
package/dist/api/gemini.d.ts
CHANGED
|
@@ -7,7 +7,7 @@ export interface GeminiOptions {
|
|
|
7
7
|
includeBuiltinSystemPrompt?: boolean;
|
|
8
8
|
}
|
|
9
9
|
export interface GeminiStreamChunk {
|
|
10
|
-
type: 'content' | 'tool_calls' | 'tool_call_delta' | 'done' | 'usage';
|
|
10
|
+
type: 'content' | 'tool_calls' | 'tool_call_delta' | 'done' | 'usage' | 'reasoning_started' | 'reasoning_delta';
|
|
11
11
|
content?: string;
|
|
12
12
|
tool_calls?: Array<{
|
|
13
13
|
id: string;
|
|
@@ -19,6 +19,10 @@ export interface GeminiStreamChunk {
|
|
|
19
19
|
}>;
|
|
20
20
|
delta?: string;
|
|
21
21
|
usage?: UsageInfo;
|
|
22
|
+
thinking?: {
|
|
23
|
+
type: 'thinking';
|
|
24
|
+
thinking: string;
|
|
25
|
+
};
|
|
22
26
|
}
|
|
23
27
|
export declare function resetGeminiClient(): void;
|
|
24
28
|
/**
|
package/dist/api/gemini.js
CHANGED
|
@@ -17,6 +17,7 @@ function getGeminiConfig() {
|
|
|
17
17
|
? config.baseUrl
|
|
18
18
|
: 'https://generativelanguage.googleapis.com/v1beta',
|
|
19
19
|
customHeaders,
|
|
20
|
+
geminiThinking: config.geminiThinking,
|
|
20
21
|
};
|
|
21
22
|
}
|
|
22
23
|
return geminiConfig;
|
|
@@ -230,10 +231,16 @@ export async function* createStreamingGeminiCompletion(options, abortSignal, onR
|
|
|
230
231
|
systemInstruction: systemInstruction
|
|
231
232
|
? { parts: [{ text: systemInstruction }] }
|
|
232
233
|
: undefined,
|
|
233
|
-
generationConfig: {
|
|
234
|
-
temperature: options.temperature ?? 0.7,
|
|
235
|
-
},
|
|
236
234
|
};
|
|
235
|
+
// Add thinking configuration if enabled
|
|
236
|
+
// Only include generationConfig when thinking is enabled
|
|
237
|
+
if (config.geminiThinking?.enabled) {
|
|
238
|
+
requestBody.generationConfig = {
|
|
239
|
+
thinkingConfig: {
|
|
240
|
+
thinkingBudget: config.geminiThinking.budget,
|
|
241
|
+
},
|
|
242
|
+
};
|
|
243
|
+
}
|
|
237
244
|
// Add tools if provided
|
|
238
245
|
const geminiTools = convertToolsToGemini(options.tools);
|
|
239
246
|
if (geminiTools) {
|
|
@@ -263,6 +270,7 @@ export async function* createStreamingGeminiCompletion(options, abortSignal, onR
|
|
|
263
270
|
throw new Error('No response body from Gemini API');
|
|
264
271
|
}
|
|
265
272
|
let contentBuffer = '';
|
|
273
|
+
let thinkingTextBuffer = ''; // Accumulate thinking text content
|
|
266
274
|
let toolCallsBuffer = [];
|
|
267
275
|
let hasToolCalls = false;
|
|
268
276
|
let toolCallIndex = 0;
|
|
@@ -310,8 +318,17 @@ export async function* createStreamingGeminiCompletion(options, abortSignal, onR
|
|
|
310
318
|
const candidate = chunk.candidates[0];
|
|
311
319
|
if (candidate.content && candidate.content.parts) {
|
|
312
320
|
for (const part of candidate.content.parts) {
|
|
313
|
-
// Process
|
|
314
|
-
|
|
321
|
+
// Process thought content (Gemini thinking)
|
|
322
|
+
// When part.thought === true, the text field contains thinking content
|
|
323
|
+
if (part.thought === true && part.text) {
|
|
324
|
+
thinkingTextBuffer += part.text;
|
|
325
|
+
yield {
|
|
326
|
+
type: 'reasoning_delta',
|
|
327
|
+
delta: part.text,
|
|
328
|
+
};
|
|
329
|
+
}
|
|
330
|
+
// Process regular text content (when thought is not true)
|
|
331
|
+
else if (part.text) {
|
|
315
332
|
contentBuffer += part.text;
|
|
316
333
|
yield {
|
|
317
334
|
type: 'content',
|
|
@@ -374,9 +391,17 @@ export async function* createStreamingGeminiCompletion(options, abortSignal, onR
|
|
|
374
391
|
usage: usageData,
|
|
375
392
|
};
|
|
376
393
|
}
|
|
394
|
+
// Return complete thinking block if thinking content exists
|
|
395
|
+
const thinkingBlock = thinkingTextBuffer
|
|
396
|
+
? {
|
|
397
|
+
type: 'thinking',
|
|
398
|
+
thinking: thinkingTextBuffer,
|
|
399
|
+
}
|
|
400
|
+
: undefined;
|
|
377
401
|
// Signal completion
|
|
378
402
|
yield {
|
|
379
403
|
type: 'done',
|
|
404
|
+
thinking: thinkingBlock,
|
|
380
405
|
};
|
|
381
406
|
}, {
|
|
382
407
|
abortSignal,
|
package/dist/api/responses.js
CHANGED
|
@@ -79,6 +79,19 @@ function getOpenAIConfig() {
|
|
|
79
79
|
}
|
|
80
80
|
return openaiConfig;
|
|
81
81
|
}
|
|
82
|
+
function getResponsesReasoningConfig() {
|
|
83
|
+
const config = getOpenAiConfig();
|
|
84
|
+
const reasoningConfig = config.responsesReasoning;
|
|
85
|
+
// 如果 reasoning 未启用,返回 null
|
|
86
|
+
if (!reasoningConfig?.enabled) {
|
|
87
|
+
return null;
|
|
88
|
+
}
|
|
89
|
+
// 返回配置,summary 永远默认为 'auto'
|
|
90
|
+
return {
|
|
91
|
+
effort: reasoningConfig.effort || 'high',
|
|
92
|
+
summary: 'auto',
|
|
93
|
+
};
|
|
94
|
+
}
|
|
82
95
|
export function resetOpenAIClient() {
|
|
83
96
|
openaiConfig = null;
|
|
84
97
|
}
|
|
@@ -237,6 +250,8 @@ export async function* createStreamingResponse(options, abortSignal, onRetry) {
|
|
|
237
250
|
const config = getOpenAIConfig();
|
|
238
251
|
// 提取系统提示词和转换后的消息
|
|
239
252
|
const { input: requestInput, systemInstructions } = convertToResponseInput(options.messages, options.includeBuiltinSystemPrompt !== false);
|
|
253
|
+
// 获取配置的 reasoning 设置
|
|
254
|
+
const configuredReasoning = getResponsesReasoningConfig();
|
|
240
255
|
// 使用重试包装生成器
|
|
241
256
|
yield* withRetryGenerator(async function* () {
|
|
242
257
|
const requestPayload = {
|
|
@@ -246,9 +261,9 @@ export async function* createStreamingResponse(options, abortSignal, onRetry) {
|
|
|
246
261
|
tools: convertToolsForResponses(options.tools),
|
|
247
262
|
tool_choice: options.tool_choice,
|
|
248
263
|
parallel_tool_calls: false,
|
|
249
|
-
//
|
|
250
|
-
...(
|
|
251
|
-
reasoning:
|
|
264
|
+
// 只有当 reasoning 启用时才添加 reasoning 字段
|
|
265
|
+
...(configuredReasoning && {
|
|
266
|
+
reasoning: configuredReasoning,
|
|
252
267
|
}),
|
|
253
268
|
store: false,
|
|
254
269
|
stream: true,
|
|
@@ -12,11 +12,6 @@ export type ConversationHandlerOptions = {
|
|
|
12
12
|
saveMessage: (message: any) => Promise<void>;
|
|
13
13
|
setMessages: React.Dispatch<React.SetStateAction<Message[]>>;
|
|
14
14
|
setStreamTokenCount: React.Dispatch<React.SetStateAction<number>>;
|
|
15
|
-
setCurrentTodos: React.Dispatch<React.SetStateAction<Array<{
|
|
16
|
-
id: string;
|
|
17
|
-
content: string;
|
|
18
|
-
status: 'pending' | 'completed';
|
|
19
|
-
}>>>;
|
|
20
15
|
requestToolConfirmation: (toolCall: ToolCall, batchToolNames?: string, allTools?: ToolCall[]) => Promise<string>;
|
|
21
16
|
isToolAutoApproved: (toolName: string) => boolean;
|
|
22
17
|
addMultipleToAlwaysApproved: (toolNames: string[]) => void;
|
|
@@ -20,7 +20,7 @@ import { shouldAutoCompress, performAutoCompression, } from '../utils/autoCompre
|
|
|
20
20
|
export async function handleConversationWithTools(options) {
|
|
21
21
|
const { userContent, imageContents, controller,
|
|
22
22
|
// messages, // No longer used - we load from session instead to get complete history with tool calls
|
|
23
|
-
saveMessage, setMessages, setStreamTokenCount,
|
|
23
|
+
saveMessage, setMessages, setStreamTokenCount, requestToolConfirmation, isToolAutoApproved, addMultipleToAlwaysApproved, yoloMode, setContextUsage, setIsReasoning, setRetryStatus, } = options;
|
|
24
24
|
// Create a wrapper function for adding single tool to always-approved list
|
|
25
25
|
const addToAlwaysApproved = (toolName) => {
|
|
26
26
|
addMultipleToAlwaysApproved([toolName]);
|
|
@@ -33,10 +33,6 @@ export async function handleConversationWithTools(options) {
|
|
|
33
33
|
const todoService = getTodoService();
|
|
34
34
|
// Get existing TODO list
|
|
35
35
|
const existingTodoList = await todoService.getTodoList(currentSession.id);
|
|
36
|
-
// Update UI state
|
|
37
|
-
if (existingTodoList) {
|
|
38
|
-
setCurrentTodos(existingTodoList.todos);
|
|
39
|
-
}
|
|
40
36
|
// Collect all MCP tools
|
|
41
37
|
const mcpTools = await collectAllMCPTools();
|
|
42
38
|
// Build conversation history with TODO context as pinned user message
|
|
@@ -124,6 +120,7 @@ export async function handleConversationWithTools(options) {
|
|
|
124
120
|
let receivedToolCalls;
|
|
125
121
|
let receivedReasoning;
|
|
126
122
|
let receivedThinking; // Accumulate thinking content from all platforms
|
|
123
|
+
let hasStartedReasoning = false; // Track if reasoning has started (for Gemini thinking)
|
|
127
124
|
// Stream AI response - choose API based on config
|
|
128
125
|
let toolCallAccumulator = ''; // Accumulate tool call deltas for token counting
|
|
129
126
|
let reasoningAccumulator = ''; // Accumulate reasoning summary deltas for token counting (Responses API only)
|
|
@@ -194,6 +191,23 @@ export async function handleConversationWithTools(options) {
|
|
|
194
191
|
// Reasoning started (Responses API only) - set reasoning state
|
|
195
192
|
setIsReasoning?.(true);
|
|
196
193
|
}
|
|
194
|
+
else if (chunk.type === 'reasoning_delta' && chunk.delta) {
|
|
195
|
+
// Handle reasoning delta from Gemini thinking
|
|
196
|
+
// When reasoning_delta is received, set reasoning state if not already set
|
|
197
|
+
if (!hasStartedReasoning) {
|
|
198
|
+
setIsReasoning?.(true);
|
|
199
|
+
hasStartedReasoning = true;
|
|
200
|
+
}
|
|
201
|
+
// Note: reasoning content is NOT sent back to AI, only counted for display
|
|
202
|
+
reasoningAccumulator += chunk.delta;
|
|
203
|
+
try {
|
|
204
|
+
const tokens = encoder.encode(streamedContent + toolCallAccumulator + reasoningAccumulator);
|
|
205
|
+
setStreamTokenCount(tokens.length);
|
|
206
|
+
}
|
|
207
|
+
catch (e) {
|
|
208
|
+
// Ignore encoding errors
|
|
209
|
+
}
|
|
210
|
+
}
|
|
197
211
|
else if (chunk.type === 'content' && chunk.content) {
|
|
198
212
|
// Accumulate content and update token count
|
|
199
213
|
// When content starts, reasoning is done
|
|
@@ -220,18 +234,6 @@ export async function handleConversationWithTools(options) {
|
|
|
220
234
|
// Ignore encoding errors
|
|
221
235
|
}
|
|
222
236
|
}
|
|
223
|
-
else if (chunk.type === 'reasoning_delta' && chunk.delta) {
|
|
224
|
-
// Accumulate reasoning summary deltas for token counting (Responses API only)
|
|
225
|
-
// Note: reasoning content is NOT sent back to AI, only counted for display
|
|
226
|
-
reasoningAccumulator += chunk.delta;
|
|
227
|
-
try {
|
|
228
|
-
const tokens = encoder.encode(streamedContent + toolCallAccumulator + reasoningAccumulator);
|
|
229
|
-
setStreamTokenCount(tokens.length);
|
|
230
|
-
}
|
|
231
|
-
catch (e) {
|
|
232
|
-
// Ignore encoding errors
|
|
233
|
-
}
|
|
234
|
-
}
|
|
235
237
|
else if (chunk.type === 'tool_calls' && chunk.tool_calls) {
|
|
236
238
|
receivedToolCalls = chunk.tool_calls;
|
|
237
239
|
}
|
|
@@ -358,8 +360,28 @@ export async function handleConversationWithTools(options) {
|
|
|
358
360
|
const autoApprovedTools = [];
|
|
359
361
|
for (const toolCall of receivedToolCalls) {
|
|
360
362
|
// Check both global approved list and session-approved list
|
|
361
|
-
|
|
362
|
-
sessionApprovedTools.has(toolCall.function.name)
|
|
363
|
+
const isApproved = isToolAutoApproved(toolCall.function.name) ||
|
|
364
|
+
sessionApprovedTools.has(toolCall.function.name);
|
|
365
|
+
// Check if this is a sensitive command (terminal-execute with sensitive pattern)
|
|
366
|
+
let isSensitiveCommand = false;
|
|
367
|
+
if (toolCall.function.name === 'terminal-execute') {
|
|
368
|
+
try {
|
|
369
|
+
const args = JSON.parse(toolCall.function.arguments);
|
|
370
|
+
const { isSensitiveCommand: checkSensitiveCommand } = await import('../utils/sensitiveCommandManager.js').then(m => ({
|
|
371
|
+
isSensitiveCommand: m.isSensitiveCommand,
|
|
372
|
+
}));
|
|
373
|
+
const sensitiveCheck = checkSensitiveCommand(args.command);
|
|
374
|
+
isSensitiveCommand = sensitiveCheck.isSensitive;
|
|
375
|
+
}
|
|
376
|
+
catch {
|
|
377
|
+
// If parsing fails, treat as normal command
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
// If sensitive command, always require confirmation regardless of approval status
|
|
381
|
+
if (isSensitiveCommand) {
|
|
382
|
+
toolsNeedingConfirmation.push(toolCall);
|
|
383
|
+
}
|
|
384
|
+
else if (isApproved) {
|
|
363
385
|
autoApprovedTools.push(toolCall);
|
|
364
386
|
}
|
|
365
387
|
else {
|
|
@@ -368,24 +390,79 @@ export async function handleConversationWithTools(options) {
|
|
|
368
390
|
}
|
|
369
391
|
// Request confirmation only once for all tools needing confirmation
|
|
370
392
|
let approvedTools = [...autoApprovedTools];
|
|
371
|
-
// In YOLO mode, auto-approve all tools
|
|
393
|
+
// In YOLO mode, auto-approve all tools EXCEPT sensitive commands
|
|
372
394
|
if (yoloMode) {
|
|
373
|
-
|
|
395
|
+
// Filter out sensitive commands from auto-approval
|
|
396
|
+
const nonSensitiveTools = [];
|
|
397
|
+
const sensitiveTools = [];
|
|
398
|
+
for (const toolCall of toolsNeedingConfirmation) {
|
|
399
|
+
if (toolCall.function.name === 'terminal-execute') {
|
|
400
|
+
try {
|
|
401
|
+
const args = JSON.parse(toolCall.function.arguments);
|
|
402
|
+
const { isSensitiveCommand: checkSensitiveCommand } = await import('../utils/sensitiveCommandManager.js').then(m => ({
|
|
403
|
+
isSensitiveCommand: m.isSensitiveCommand,
|
|
404
|
+
}));
|
|
405
|
+
const sensitiveCheck = checkSensitiveCommand(args.command);
|
|
406
|
+
if (sensitiveCheck.isSensitive) {
|
|
407
|
+
sensitiveTools.push(toolCall);
|
|
408
|
+
}
|
|
409
|
+
else {
|
|
410
|
+
nonSensitiveTools.push(toolCall);
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
catch {
|
|
414
|
+
nonSensitiveTools.push(toolCall);
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
else {
|
|
418
|
+
nonSensitiveTools.push(toolCall);
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
approvedTools.push(...nonSensitiveTools);
|
|
422
|
+
// If there are sensitive tools, still need confirmation even in YOLO mode
|
|
423
|
+
if (sensitiveTools.length > 0) {
|
|
424
|
+
const firstTool = sensitiveTools[0];
|
|
425
|
+
const allTools = sensitiveTools.length > 1 ? sensitiveTools : undefined;
|
|
426
|
+
const confirmation = await requestToolConfirmation(firstTool, undefined, allTools);
|
|
427
|
+
if (confirmation === 'reject') {
|
|
428
|
+
setMessages(prev => prev.filter(msg => !msg.toolPending));
|
|
429
|
+
for (const toolCall of sensitiveTools) {
|
|
430
|
+
const rejectionMessage = {
|
|
431
|
+
role: 'tool',
|
|
432
|
+
tool_call_id: toolCall.id,
|
|
433
|
+
content: 'Error: Tool execution rejected by user',
|
|
434
|
+
};
|
|
435
|
+
conversationMessages.push(rejectionMessage);
|
|
436
|
+
saveMessage(rejectionMessage).catch(error => {
|
|
437
|
+
console.error('Failed to save tool rejection message:', error);
|
|
438
|
+
});
|
|
439
|
+
}
|
|
440
|
+
setMessages(prev => [
|
|
441
|
+
...prev,
|
|
442
|
+
{
|
|
443
|
+
role: 'assistant',
|
|
444
|
+
content: 'Tool call rejected, session ended',
|
|
445
|
+
streaming: false,
|
|
446
|
+
},
|
|
447
|
+
]);
|
|
448
|
+
if (options.setIsStreaming) {
|
|
449
|
+
options.setIsStreaming(false);
|
|
450
|
+
}
|
|
451
|
+
freeEncoder();
|
|
452
|
+
return { usage: accumulatedUsage };
|
|
453
|
+
}
|
|
454
|
+
// Approved, add sensitive tools to approved list
|
|
455
|
+
approvedTools.push(...sensitiveTools);
|
|
456
|
+
}
|
|
374
457
|
}
|
|
375
458
|
else if (toolsNeedingConfirmation.length > 0) {
|
|
376
|
-
const firstTool = toolsNeedingConfirmation[0];
|
|
377
|
-
// Use regular CLI confirmation
|
|
378
|
-
// Pass all tools for proper display in confirmation UI
|
|
459
|
+
const firstTool = toolsNeedingConfirmation[0];
|
|
379
460
|
const allTools = toolsNeedingConfirmation.length > 1
|
|
380
461
|
? toolsNeedingConfirmation
|
|
381
462
|
: undefined;
|
|
382
|
-
// Use first tool for confirmation UI, but apply result to all
|
|
383
463
|
const confirmation = await requestToolConfirmation(firstTool, undefined, allTools);
|
|
384
464
|
if (confirmation === 'reject') {
|
|
385
|
-
// Remove pending tool messages
|
|
386
465
|
setMessages(prev => prev.filter(msg => !msg.toolPending));
|
|
387
|
-
// User rejected - need to save tool rejection messages to maintain conversation structure
|
|
388
|
-
// Add tool rejection responses for ALL tools that were rejected
|
|
389
466
|
for (const toolCall of toolsNeedingConfirmation) {
|
|
390
467
|
const rejectionMessage = {
|
|
391
468
|
role: 'tool',
|
|
@@ -397,7 +474,6 @@ export async function handleConversationWithTools(options) {
|
|
|
397
474
|
console.error('Failed to save tool rejection message:', error);
|
|
398
475
|
});
|
|
399
476
|
}
|
|
400
|
-
// User rejected - end conversation
|
|
401
477
|
setMessages(prev => [
|
|
402
478
|
...prev,
|
|
403
479
|
{
|
|
@@ -406,12 +482,11 @@ export async function handleConversationWithTools(options) {
|
|
|
406
482
|
streaming: false,
|
|
407
483
|
},
|
|
408
484
|
]);
|
|
409
|
-
// End streaming immediately
|
|
410
485
|
if (options.setIsStreaming) {
|
|
411
486
|
options.setIsStreaming(false);
|
|
412
487
|
}
|
|
413
488
|
freeEncoder();
|
|
414
|
-
return { usage: accumulatedUsage };
|
|
489
|
+
return { usage: accumulatedUsage };
|
|
415
490
|
}
|
|
416
491
|
// If approved_always, add ALL these tools to both global and session-approved sets
|
|
417
492
|
if (confirmation === 'approve_always') {
|
|
@@ -642,18 +717,6 @@ export async function handleConversationWithTools(options) {
|
|
|
642
717
|
// 即使压缩失败也继续处理工具结果
|
|
643
718
|
}
|
|
644
719
|
}
|
|
645
|
-
// Check if there are TODO related tool calls, if yes refresh TODO list
|
|
646
|
-
const hasTodoTools = approvedTools.some(t => t.function.name.startsWith('todo-'));
|
|
647
|
-
const hasTodoUpdateTools = approvedTools.some(t => t.function.name === 'todo-update');
|
|
648
|
-
if (hasTodoTools) {
|
|
649
|
-
const session = sessionManager.getCurrentSession();
|
|
650
|
-
if (session) {
|
|
651
|
-
const updatedTodoList = await todoService.getTodoList(session.id);
|
|
652
|
-
if (updatedTodoList) {
|
|
653
|
-
setCurrentTodos(updatedTodoList.todos);
|
|
654
|
-
}
|
|
655
|
-
}
|
|
656
|
-
}
|
|
657
720
|
// Remove only streaming sub-agent content messages (not tool-related messages)
|
|
658
721
|
// Keep sub-agent tool call and tool result messages for display
|
|
659
722
|
setMessages(prev => prev.filter(m => m.role !== 'subagent' ||
|
|
@@ -757,18 +820,6 @@ export async function handleConversationWithTools(options) {
|
|
|
757
820
|
});
|
|
758
821
|
}
|
|
759
822
|
}
|
|
760
|
-
// After all tool results are processed, show TODO panel if there were todo-update calls
|
|
761
|
-
if (hasTodoUpdateTools) {
|
|
762
|
-
setMessages(prev => [
|
|
763
|
-
...prev,
|
|
764
|
-
{
|
|
765
|
-
role: 'assistant',
|
|
766
|
-
content: '',
|
|
767
|
-
streaming: false,
|
|
768
|
-
showTodoTree: true,
|
|
769
|
-
},
|
|
770
|
-
]);
|
|
771
|
-
}
|
|
772
823
|
// Check if there are pending user messages to insert
|
|
773
824
|
if (options.getPendingMessages && options.clearPendingMessages) {
|
|
774
825
|
const pendingMessages = options.getPendingMessages();
|
|
@@ -811,7 +862,9 @@ export async function handleConversationWithTools(options) {
|
|
|
811
862
|
// Clear pending messages
|
|
812
863
|
options.clearPendingMessages();
|
|
813
864
|
// Combine multiple pending messages into one
|
|
814
|
-
const combinedMessage = pendingMessages
|
|
865
|
+
const combinedMessage = pendingMessages
|
|
866
|
+
.map(m => m.text)
|
|
867
|
+
.join('\n\n');
|
|
815
868
|
// Collect all images from pending messages
|
|
816
869
|
const allPendingImages = pendingMessages
|
|
817
870
|
.flatMap(m => m.images || [])
|
|
@@ -11,7 +11,7 @@ export declare function useFilePicker(buffer: TextBuffer, triggerUpdate: () => v
|
|
|
11
11
|
setAtSymbolPosition: (_pos: number) => void;
|
|
12
12
|
filteredFileCount: number;
|
|
13
13
|
searchMode: "content" | "file";
|
|
14
|
-
updateFilePickerState: (
|
|
14
|
+
updateFilePickerState: (_text: string, cursorPos: number) => void;
|
|
15
15
|
handleFileSelect: (filePath: string) => Promise<void>;
|
|
16
16
|
handleFilteredCountChange: (count: number) => void;
|
|
17
17
|
fileListRef: import("react").RefObject<FileListRef>;
|
|
@@ -51,15 +51,19 @@ export function useFilePicker(buffer, triggerUpdate) {
|
|
|
51
51
|
});
|
|
52
52
|
const fileListRef = useRef(null);
|
|
53
53
|
// Update file picker state
|
|
54
|
-
const updateFilePickerState = useCallback((
|
|
55
|
-
|
|
54
|
+
const updateFilePickerState = useCallback((_text, cursorPos) => {
|
|
55
|
+
// Use display text (with placeholders) instead of full text (expanded)
|
|
56
|
+
// to ensure cursor position matches text content
|
|
57
|
+
// Note: _text parameter is ignored, we use buffer.text instead
|
|
58
|
+
const displayText = buffer.text;
|
|
59
|
+
if (!displayText.includes('@')) {
|
|
56
60
|
if (state.showFilePicker) {
|
|
57
61
|
dispatch({ type: 'HIDE' });
|
|
58
62
|
}
|
|
59
63
|
return;
|
|
60
64
|
}
|
|
61
65
|
// Find the last '@' or '@@' symbol before the cursor
|
|
62
|
-
const beforeCursor =
|
|
66
|
+
const beforeCursor = displayText.slice(0, cursorPos);
|
|
63
67
|
// Look for @@ first (content search), then @ (file search)
|
|
64
68
|
let searchMode = 'file';
|
|
65
69
|
let position = -1;
|
|
@@ -130,6 +134,7 @@ export function useFilePicker(buffer, triggerUpdate) {
|
|
|
130
134
|
}
|
|
131
135
|
}
|
|
132
136
|
}, [
|
|
137
|
+
buffer,
|
|
133
138
|
state.showFilePicker,
|
|
134
139
|
state.fileQuery,
|
|
135
140
|
state.atSymbolPosition,
|
|
@@ -138,13 +143,14 @@ export function useFilePicker(buffer, triggerUpdate) {
|
|
|
138
143
|
// Handle file selection
|
|
139
144
|
const handleFileSelect = useCallback(async (filePath) => {
|
|
140
145
|
if (state.atSymbolPosition !== -1) {
|
|
141
|
-
|
|
146
|
+
// Use display text (with placeholders) for position calculations
|
|
147
|
+
const displayText = buffer.text;
|
|
142
148
|
const cursorPos = buffer.getCursorPosition();
|
|
143
149
|
// Replace query with selected file path
|
|
144
150
|
// For content search (@@), the filePath already includes line number
|
|
145
151
|
// For file search (@), just the file path
|
|
146
|
-
const beforeAt =
|
|
147
|
-
const afterCursor =
|
|
152
|
+
const beforeAt = displayText.slice(0, state.atSymbolPosition);
|
|
153
|
+
const afterCursor = displayText.slice(cursorPos);
|
|
148
154
|
// Construct the replacement based on search mode
|
|
149
155
|
const prefix = state.searchMode === 'content' ? '@@' : '@';
|
|
150
156
|
const newText = beforeAt + prefix + filePath + ' ' + afterCursor;
|
|
@@ -156,7 +162,7 @@ export function useFilePicker(buffer, triggerUpdate) {
|
|
|
156
162
|
const targetPos = state.atSymbolPosition + insertedLength;
|
|
157
163
|
// Reset cursor to beginning, then move to correct position
|
|
158
164
|
for (let i = 0; i < targetPos; i++) {
|
|
159
|
-
if (i < buffer.
|
|
165
|
+
if (i < buffer.text.length) {
|
|
160
166
|
buffer.moveRight();
|
|
161
167
|
}
|
|
162
168
|
}
|
|
@@ -34,11 +34,18 @@ export function useHistoryNavigation(buffer, triggerUpdate, chatHistory, onHisto
|
|
|
34
34
|
.map((msg, index) => ({ ...msg, originalIndex: index }))
|
|
35
35
|
.filter(msg => msg.role === 'user' && msg.content.trim());
|
|
36
36
|
// Keep original order (oldest first, newest last) and map with display numbers
|
|
37
|
-
return userMessages.map((msg, index) =>
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
37
|
+
return userMessages.map((msg, index) => {
|
|
38
|
+
// Remove all newlines, control characters and extra whitespace to ensure single line display
|
|
39
|
+
const cleanContent = msg.content
|
|
40
|
+
.replace(/[\r\n\t\v\f\u0000-\u001F\u007F-\u009F]+/g, ' ')
|
|
41
|
+
.replace(/\s+/g, ' ')
|
|
42
|
+
.trim();
|
|
43
|
+
return {
|
|
44
|
+
label: `${index + 1}. ${cleanContent.slice(0, 50)}${cleanContent.length > 50 ? '...' : ''}`,
|
|
45
|
+
value: msg.originalIndex.toString(),
|
|
46
|
+
infoText: msg.content,
|
|
47
|
+
};
|
|
48
|
+
});
|
|
42
49
|
}, [chatHistory]);
|
|
43
50
|
// Handle history selection
|
|
44
51
|
const handleHistorySelect = useCallback((value) => {
|
|
@@ -71,7 +78,7 @@ export function useHistoryNavigation(buffer, triggerUpdate, chatHistory, onHisto
|
|
|
71
78
|
triggerUpdate();
|
|
72
79
|
}
|
|
73
80
|
return true;
|
|
74
|
-
}, [currentHistoryIndex
|
|
81
|
+
}, [currentHistoryIndex]); // 移除 buffer 避免循环依赖
|
|
75
82
|
// Terminal-style history navigation: navigate down (newer)
|
|
76
83
|
const navigateHistoryDown = useCallback(() => {
|
|
77
84
|
if (currentHistoryIndex === -1)
|
|
@@ -93,7 +100,7 @@ export function useHistoryNavigation(buffer, triggerUpdate, chatHistory, onHisto
|
|
|
93
100
|
}
|
|
94
101
|
triggerUpdate();
|
|
95
102
|
return true;
|
|
96
|
-
}, [currentHistoryIndex
|
|
103
|
+
}, [currentHistoryIndex]); // 移除 buffer 避免循环依赖
|
|
97
104
|
// Reset history navigation state
|
|
98
105
|
const resetHistoryNavigation = useCallback(() => {
|
|
99
106
|
setCurrentHistoryIndex(-1);
|
|
@@ -2,5 +2,5 @@ import { TextBuffer, Viewport } from '../utils/textBuffer.js';
|
|
|
2
2
|
export declare function useInputBuffer(viewport: Viewport): {
|
|
3
3
|
buffer: TextBuffer;
|
|
4
4
|
triggerUpdate: () => void;
|
|
5
|
-
forceUpdate:
|
|
5
|
+
forceUpdate: () => void;
|
|
6
6
|
};
|
|
@@ -1,19 +1,35 @@
|
|
|
1
1
|
import { useState, useCallback, useEffect, useRef } from 'react';
|
|
2
2
|
import { TextBuffer } from '../utils/textBuffer.js';
|
|
3
3
|
export function useInputBuffer(viewport) {
|
|
4
|
-
const [,
|
|
4
|
+
const [, setForceUpdateState] = useState({});
|
|
5
5
|
const lastUpdateTime = useRef(0);
|
|
6
|
-
|
|
7
|
-
|
|
6
|
+
const bufferRef = useRef(null);
|
|
7
|
+
// Stable forceUpdate function using useRef
|
|
8
|
+
const forceUpdateRef = useRef(() => {
|
|
9
|
+
setForceUpdateState({});
|
|
10
|
+
});
|
|
11
|
+
// Stable triggerUpdate function using useRef
|
|
12
|
+
const triggerUpdateRef = useRef(() => {
|
|
8
13
|
const now = Date.now();
|
|
9
14
|
lastUpdateTime.current = now;
|
|
10
|
-
|
|
15
|
+
forceUpdateRef.current();
|
|
16
|
+
});
|
|
17
|
+
// Initialize buffer once
|
|
18
|
+
if (!bufferRef.current) {
|
|
19
|
+
bufferRef.current = new TextBuffer(viewport, triggerUpdateRef.current);
|
|
20
|
+
}
|
|
21
|
+
const buffer = bufferRef.current;
|
|
22
|
+
// Expose stable callback functions
|
|
23
|
+
const forceUpdate = useCallback(() => {
|
|
24
|
+
forceUpdateRef.current();
|
|
25
|
+
}, []);
|
|
26
|
+
const triggerUpdate = useCallback(() => {
|
|
27
|
+
triggerUpdateRef.current();
|
|
11
28
|
}, []);
|
|
12
|
-
const [buffer] = useState(() => new TextBuffer(viewport, triggerUpdate));
|
|
13
29
|
// Update buffer viewport when viewport changes
|
|
14
30
|
useEffect(() => {
|
|
15
31
|
buffer.updateViewport(viewport);
|
|
16
|
-
|
|
32
|
+
forceUpdateRef.current();
|
|
17
33
|
}, [viewport.width, viewport.height, buffer]);
|
|
18
34
|
// Cleanup buffer on unmount
|
|
19
35
|
useEffect(() => {
|
|
@@ -45,7 +45,7 @@ export function useStreamingState() {
|
|
|
45
45
|
}, [timerStartTime]);
|
|
46
46
|
// Initialize remaining seconds when retry starts
|
|
47
47
|
useEffect(() => {
|
|
48
|
-
if (!retryStatus
|
|
48
|
+
if (!retryStatus?.isRetrying)
|
|
49
49
|
return;
|
|
50
50
|
if (retryStatus.remainingSeconds !== undefined)
|
|
51
51
|
return;
|
|
@@ -56,7 +56,7 @@ export function useStreamingState() {
|
|
|
56
56
|
remainingSeconds: Math.ceil(prev.nextDelay / 1000),
|
|
57
57
|
}
|
|
58
58
|
: null);
|
|
59
|
-
}, [retryStatus?.isRetrying
|
|
59
|
+
}, [retryStatus?.isRetrying]); // Only depend on isRetrying flag
|
|
60
60
|
// Countdown timer for retry delays
|
|
61
61
|
useEffect(() => {
|
|
62
62
|
if (!retryStatus || !retryStatus.isRetrying)
|
|
@@ -1,26 +1,43 @@
|
|
|
1
|
-
import { useState, useEffect } from 'react';
|
|
1
|
+
import { useState, useEffect, useRef } from 'react';
|
|
2
2
|
import { vscodeConnection } from '../utils/vscodeConnection.js';
|
|
3
3
|
export function useVSCodeState() {
|
|
4
4
|
const [vscodeConnected, setVscodeConnected] = useState(false);
|
|
5
5
|
const [vscodeConnectionStatus, setVscodeConnectionStatus] = useState('disconnected');
|
|
6
6
|
const [editorContext, setEditorContext] = useState({});
|
|
7
|
+
// Use ref to track last status without causing re-renders
|
|
8
|
+
const lastStatusRef = useRef('disconnected');
|
|
9
|
+
// Use ref to track last editor context to avoid unnecessary updates
|
|
10
|
+
const lastEditorContextRef = useRef({});
|
|
7
11
|
// Monitor VSCode connection status and editor context
|
|
8
12
|
useEffect(() => {
|
|
9
13
|
const checkConnectionInterval = setInterval(() => {
|
|
10
14
|
const isConnected = vscodeConnection.isConnected();
|
|
11
15
|
setVscodeConnected(isConnected);
|
|
12
16
|
// Update connection status based on actual connection state
|
|
13
|
-
|
|
17
|
+
// Use ref to avoid reading from state
|
|
18
|
+
if (isConnected && lastStatusRef.current !== 'connected') {
|
|
19
|
+
lastStatusRef.current = 'connected';
|
|
14
20
|
setVscodeConnectionStatus('connected');
|
|
15
21
|
}
|
|
16
|
-
else if (!isConnected &&
|
|
22
|
+
else if (!isConnected && lastStatusRef.current === 'connected') {
|
|
23
|
+
lastStatusRef.current = 'disconnected';
|
|
17
24
|
setVscodeConnectionStatus('disconnected');
|
|
18
25
|
}
|
|
19
26
|
}, 1000);
|
|
20
27
|
const unsubscribe = vscodeConnection.onContextUpdate(context => {
|
|
21
|
-
|
|
28
|
+
// Only update state if context has actually changed
|
|
29
|
+
const hasChanged = context.activeFile !== lastEditorContextRef.current.activeFile ||
|
|
30
|
+
context.selectedText !== lastEditorContextRef.current.selectedText ||
|
|
31
|
+
context.cursorPosition?.line !== lastEditorContextRef.current.cursorPosition?.line ||
|
|
32
|
+
context.cursorPosition?.character !== lastEditorContextRef.current.cursorPosition?.character ||
|
|
33
|
+
context.workspaceFolder !== lastEditorContextRef.current.workspaceFolder;
|
|
34
|
+
if (hasChanged) {
|
|
35
|
+
lastEditorContextRef.current = context;
|
|
36
|
+
setEditorContext(context);
|
|
37
|
+
}
|
|
22
38
|
// When we receive context, it means connection is successful
|
|
23
|
-
if (
|
|
39
|
+
if (lastStatusRef.current !== 'connected') {
|
|
40
|
+
lastStatusRef.current = 'connected';
|
|
24
41
|
setVscodeConnectionStatus('connected');
|
|
25
42
|
}
|
|
26
43
|
});
|
|
@@ -28,7 +45,7 @@ export function useVSCodeState() {
|
|
|
28
45
|
clearInterval(checkConnectionInterval);
|
|
29
46
|
unsubscribe();
|
|
30
47
|
};
|
|
31
|
-
}, [
|
|
48
|
+
}, []); // Remove vscodeConnectionStatus from dependencies
|
|
32
49
|
// Separate effect for handling connecting timeout
|
|
33
50
|
useEffect(() => {
|
|
34
51
|
if (vscodeConnectionStatus !== 'connecting') {
|