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.
Files changed (35) hide show
  1. package/dist/api/gemini.d.ts +5 -1
  2. package/dist/api/gemini.js +30 -5
  3. package/dist/api/responses.js +18 -3
  4. package/dist/hooks/useConversation.d.ts +0 -5
  5. package/dist/hooks/useConversation.js +109 -56
  6. package/dist/hooks/useFilePicker.d.ts +1 -1
  7. package/dist/hooks/useFilePicker.js +13 -7
  8. package/dist/hooks/useHistoryNavigation.js +14 -7
  9. package/dist/hooks/useInputBuffer.d.ts +1 -1
  10. package/dist/hooks/useInputBuffer.js +22 -6
  11. package/dist/hooks/useStreamingState.js +2 -2
  12. package/dist/hooks/useVSCodeState.js +23 -6
  13. package/dist/mcp/filesystem.js +1 -1
  14. package/dist/ui/components/ChatInput.js +17 -11
  15. package/dist/ui/components/MessageList.d.ts +0 -1
  16. package/dist/ui/components/MessageList.js +1 -2
  17. package/dist/ui/components/SessionListPanel.js +12 -8
  18. package/dist/ui/components/SessionListScreen.js +2 -1
  19. package/dist/ui/components/ToolConfirmation.d.ts +1 -1
  20. package/dist/ui/components/ToolConfirmation.js +63 -22
  21. package/dist/ui/components/ToolResultPreview.js +33 -6
  22. package/dist/ui/pages/ChatScreen.js +21 -17
  23. package/dist/ui/pages/ConfigScreen.js +167 -16
  24. package/dist/ui/pages/HeadlessModeScreen.js +0 -1
  25. package/dist/ui/pages/ProxyConfigScreen.d.ts +1 -1
  26. package/dist/ui/pages/ProxyConfigScreen.js +6 -6
  27. package/dist/ui/pages/SensitiveCommandConfigScreen.d.ts +7 -0
  28. package/dist/ui/pages/SensitiveCommandConfigScreen.js +262 -0
  29. package/dist/ui/pages/SubAgentConfigScreen.js +1 -1
  30. package/dist/ui/pages/WelcomeScreen.js +14 -3
  31. package/dist/utils/apiConfig.d.ts +10 -0
  32. package/dist/utils/sensitiveCommandManager.d.ts +53 -0
  33. package/dist/utils/sensitiveCommandManager.js +308 -0
  34. package/dist/utils/sessionConverter.js +16 -11
  35. package/package.json +4 -2
@@ -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
  /**
@@ -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 text content
314
- if (part.text) {
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,
@@ -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
- // Only add reasoning if not explicitly disabled (null means don't pass it)
250
- ...(options.reasoning !== null && {
251
- reasoning: options.reasoning || { effort: 'high', summary: 'auto' },
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, setCurrentTodos, requestToolConfirmation, isToolAutoApproved, addMultipleToAlwaysApproved, yoloMode, setContextUsage, setIsReasoning, setRetryStatus, } = options;
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
- if (isToolAutoApproved(toolCall.function.name) ||
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
- approvedTools.push(...toolsNeedingConfirmation);
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]; // Safe: length > 0 guarantees this exists
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 }; // Exit the conversation loop
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.map(m => m.text).join('\n\n');
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: (text: string, cursorPos: number) => void;
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((text, cursorPos) => {
55
- if (!text.includes('@')) {
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 = text.slice(0, cursorPos);
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
- const text = buffer.getFullText();
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 = text.slice(0, state.atSymbolPosition);
147
- const afterCursor = text.slice(cursorPos);
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.getFullText().length) {
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
- label: `${index + 1}. ${msg.content.slice(0, 50)}${msg.content.length > 50 ? '...' : ''}`,
39
- value: msg.originalIndex.toString(),
40
- infoText: msg.content,
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, buffer]);
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, buffer]);
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: import("react").Dispatch<import("react").SetStateAction<{}>>;
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 [, forceUpdate] = useState({});
4
+ const [, setForceUpdateState] = useState({});
5
5
  const lastUpdateTime = useRef(0);
6
- // Force re-render when buffer changes
7
- const triggerUpdate = useCallback(() => {
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
- forceUpdate({});
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
- triggerUpdate();
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 || !retryStatus.isRetrying)
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, retryStatus?.nextDelay]);
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
- if (isConnected && vscodeConnectionStatus !== 'connected') {
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 && vscodeConnectionStatus === 'connected') {
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
- setEditorContext(context);
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 (vscodeConnectionStatus !== 'connected') {
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
- }, [vscodeConnectionStatus]);
48
+ }, []); // Remove vscodeConnectionStatus from dependencies
32
49
  // Separate effect for handling connecting timeout
33
50
  useEffect(() => {
34
51
  if (vscodeConnectionStatus !== 'connecting') {