diff --git a/packages/cli/src/ui/AppContainer.tsx b/packages/cli/src/ui/AppContainer.tsx index 1373d29d4f..20e5b71329 100644 --- a/packages/cli/src/ui/AppContainer.tsx +++ b/packages/cli/src/ui/AppContainer.tsx @@ -163,12 +163,6 @@ export const AppContainer = (props: AppContainerProps) => { const [isConfigInitialized, setConfigInitialized] = useState(false); - // Auto-accept indicator - const showAutoAcceptIndicator = useAutoAcceptIndicator({ - config, - addItem: historyManager.addItem, - }); - const logger = useLogger(config.storage); const [userMessages, setUserMessages] = useState([]); @@ -536,6 +530,7 @@ Logging in with Google... Please restart Gemini CLI to continue. pendingHistoryItems: pendingGeminiHistoryItems, thought, cancelOngoingRequest, + handleApprovalModeChange, activePtyId, loopDetectionConfirmationRequest, } = useGeminiStream( @@ -560,6 +555,13 @@ Logging in with Google... Please restart Gemini CLI to continue. shellFocused, ); + // Auto-accept indicator + const showAutoAcceptIndicator = useAutoAcceptIndicator({ + config, + addItem: historyManager.addItem, + onApprovalModeChange: handleApprovalModeChange, + }); + const { messageQueue, addMessage, clearQueue, getQueuedMessagesText } = useMessageQueue({ isConfigInitialized, diff --git a/packages/cli/src/ui/hooks/useAutoAcceptIndicator.test.ts b/packages/cli/src/ui/hooks/useAutoAcceptIndicator.test.ts index 9db68f1bfb..dffc7c023e 100644 --- a/packages/cli/src/ui/hooks/useAutoAcceptIndicator.test.ts +++ b/packages/cli/src/ui/hooks/useAutoAcceptIndicator.test.ts @@ -16,8 +16,8 @@ import { import { renderHook, act } from '@testing-library/react'; import { useAutoAcceptIndicator } from './useAutoAcceptIndicator.js'; -import type { Config as ActualConfigType } from '@google/gemini-cli-core'; import { Config, ApprovalMode } from '@google/gemini-cli-core'; +import type { Config as ActualConfigType } from '@google/gemini-cli-core'; import type { Key } from './useKeypress.js'; import { useKeypress } from './useKeypress.js'; import { MessageType } from '../types.js'; @@ -470,4 +470,124 @@ describe('useAutoAcceptIndicator', () => { expect(mockAddItem).toHaveBeenCalledTimes(2); }); }); + + it('should call onApprovalModeChange when switching to YOLO mode', () => { + mockConfigInstance.getApprovalMode.mockReturnValue(ApprovalMode.DEFAULT); + + const mockOnApprovalModeChange = vi.fn(); + + renderHook(() => + useAutoAcceptIndicator({ + config: mockConfigInstance as unknown as ActualConfigType, + onApprovalModeChange: mockOnApprovalModeChange, + }), + ); + + act(() => { + capturedUseKeypressHandler({ name: 'y', ctrl: true } as Key); + }); + + expect(mockConfigInstance.setApprovalMode).toHaveBeenCalledWith( + ApprovalMode.YOLO, + ); + expect(mockOnApprovalModeChange).toHaveBeenCalledWith(ApprovalMode.YOLO); + }); + + it('should call onApprovalModeChange when switching to AUTO_EDIT mode', () => { + mockConfigInstance.getApprovalMode.mockReturnValue(ApprovalMode.DEFAULT); + + const mockOnApprovalModeChange = vi.fn(); + + renderHook(() => + useAutoAcceptIndicator({ + config: mockConfigInstance as unknown as ActualConfigType, + onApprovalModeChange: mockOnApprovalModeChange, + }), + ); + + act(() => { + capturedUseKeypressHandler({ name: 'tab', shift: true } as Key); + }); + + expect(mockConfigInstance.setApprovalMode).toHaveBeenCalledWith( + ApprovalMode.AUTO_EDIT, + ); + expect(mockOnApprovalModeChange).toHaveBeenCalledWith( + ApprovalMode.AUTO_EDIT, + ); + }); + + it('should call onApprovalModeChange when switching to DEFAULT mode', () => { + mockConfigInstance.getApprovalMode.mockReturnValue(ApprovalMode.YOLO); + + const mockOnApprovalModeChange = vi.fn(); + + renderHook(() => + useAutoAcceptIndicator({ + config: mockConfigInstance as unknown as ActualConfigType, + onApprovalModeChange: mockOnApprovalModeChange, + }), + ); + + act(() => { + capturedUseKeypressHandler({ name: 'y', ctrl: true } as Key); // This should toggle from YOLO to DEFAULT + }); + + expect(mockConfigInstance.setApprovalMode).toHaveBeenCalledWith( + ApprovalMode.DEFAULT, + ); + expect(mockOnApprovalModeChange).toHaveBeenCalledWith(ApprovalMode.DEFAULT); + }); + + it('should not call onApprovalModeChange when callback is not provided', () => { + mockConfigInstance.getApprovalMode.mockReturnValue(ApprovalMode.DEFAULT); + + renderHook(() => + useAutoAcceptIndicator({ + config: mockConfigInstance as unknown as ActualConfigType, + }), + ); + + act(() => { + capturedUseKeypressHandler({ name: 'y', ctrl: true } as Key); + }); + + expect(mockConfigInstance.setApprovalMode).toHaveBeenCalledWith( + ApprovalMode.YOLO, + ); + // Should not throw an error when callback is not provided + }); + + it('should handle multiple mode changes correctly', () => { + mockConfigInstance.getApprovalMode.mockReturnValue(ApprovalMode.DEFAULT); + + const mockOnApprovalModeChange = vi.fn(); + + renderHook(() => + useAutoAcceptIndicator({ + config: mockConfigInstance as unknown as ActualConfigType, + onApprovalModeChange: mockOnApprovalModeChange, + }), + ); + + // Switch to YOLO + act(() => { + capturedUseKeypressHandler({ name: 'y', ctrl: true } as Key); + }); + + // Switch to AUTO_EDIT + act(() => { + capturedUseKeypressHandler({ name: 'tab', shift: true } as Key); + }); + + expect(mockOnApprovalModeChange).toHaveBeenCalledTimes(2); + expect(mockOnApprovalModeChange).toHaveBeenNthCalledWith( + 1, + ApprovalMode.YOLO, + ); + expect(mockOnApprovalModeChange).toHaveBeenNthCalledWith( + 2, + ApprovalMode.AUTO_EDIT, + ); + }); }); diff --git a/packages/cli/src/ui/hooks/useAutoAcceptIndicator.ts b/packages/cli/src/ui/hooks/useAutoAcceptIndicator.ts index 8766a2db5d..ae749a4648 100644 --- a/packages/cli/src/ui/hooks/useAutoAcceptIndicator.ts +++ b/packages/cli/src/ui/hooks/useAutoAcceptIndicator.ts @@ -12,12 +12,14 @@ import { MessageType } from '../types.js'; export interface UseAutoAcceptIndicatorArgs { config: Config; - addItem: (item: HistoryItemWithoutId, timestamp: number) => void; + addItem?: (item: HistoryItemWithoutId, timestamp: number) => void; + onApprovalModeChange?: (mode: ApprovalMode) => void; } export function useAutoAcceptIndicator({ config, addItem, + onApprovalModeChange, }: UseAutoAcceptIndicatorArgs): ApprovalMode { const currentConfigValue = config.getApprovalMode(); const [showAutoAcceptIndicator, setShowAutoAcceptIndicator] = @@ -48,14 +50,19 @@ export function useAutoAcceptIndicator({ config.setApprovalMode(nextApprovalMode); // Update local state immediately for responsiveness setShowAutoAcceptIndicator(nextApprovalMode); + + // Notify the central handler about the approval mode change + onApprovalModeChange?.(nextApprovalMode); } catch (e) { - addItem( - { - type: MessageType.INFO, - text: (e as Error).message, - }, - Date.now(), - ); + if (addItem) { + addItem( + { + type: MessageType.INFO, + text: (e as Error).message, + }, + Date.now(), + ); + } } } }, diff --git a/packages/cli/src/ui/hooks/useGeminiStream.test.tsx b/packages/cli/src/ui/hooks/useGeminiStream.test.tsx index b63af14846..37e044fd2f 100644 --- a/packages/cli/src/ui/hooks/useGeminiStream.test.tsx +++ b/packages/cli/src/ui/hooks/useGeminiStream.test.tsx @@ -16,6 +16,7 @@ import type { TrackedCompletedToolCall, TrackedExecutingToolCall, TrackedCancelledToolCall, + TrackedWaitingToolCall, } from './useReactToolScheduler.js'; import { useReactToolScheduler } from './useReactToolScheduler.js'; import type { @@ -29,6 +30,7 @@ import { AuthType, GeminiEventType as ServerGeminiEventType, ToolErrorType, + ToolConfirmationOutcome, } from '@google/gemini-cli-core'; import type { Part, PartListUnion } from '@google/genai'; import type { UseHistoryManagerReturn } from './useHistoryManager.js'; @@ -1340,6 +1342,458 @@ describe('useGeminiStream', () => { }); }); + describe('handleApprovalModeChange', () => { + it('should auto-approve all pending tool calls when switching to YOLO mode', async () => { + const mockOnConfirm = vi.fn().mockResolvedValue(undefined); + const awaitingApprovalToolCalls: TrackedToolCall[] = [ + { + request: { + callId: 'call1', + name: 'replace', + args: { old_string: 'old', new_string: 'new' }, + isClientInitiated: false, + prompt_id: 'prompt-id-1', + }, + status: 'awaiting_approval', + responseSubmittedToGemini: false, + confirmationDetails: { + onConfirm: mockOnConfirm, + onCancel: vi.fn(), + message: 'Replace text?', + displayedText: 'Replace old with new', + }, + tool: { + name: 'replace', + displayName: 'replace', + description: 'Replace text', + build: vi.fn(), + } as any, + invocation: { + getDescription: () => 'Mock description', + } as unknown as AnyToolInvocation, + } as TrackedWaitingToolCall, + { + request: { + callId: 'call2', + name: 'read_file', + args: { path: '/test/file.txt' }, + isClientInitiated: false, + prompt_id: 'prompt-id-1', + }, + status: 'awaiting_approval', + responseSubmittedToGemini: false, + confirmationDetails: { + onConfirm: mockOnConfirm, + onCancel: vi.fn(), + message: 'Read file?', + displayedText: 'Read /test/file.txt', + }, + tool: { + name: 'read_file', + displayName: 'read_file', + description: 'Read file', + build: vi.fn(), + } as any, + invocation: { + getDescription: () => 'Mock description', + } as unknown as AnyToolInvocation, + } as TrackedWaitingToolCall, + ]; + + const { result } = renderTestHook(awaitingApprovalToolCalls); + + await act(async () => { + await result.current.handleApprovalModeChange(ApprovalMode.YOLO); + }); + + // Both tool calls should be auto-approved + expect(mockOnConfirm).toHaveBeenCalledTimes(2); + expect(mockOnConfirm).toHaveBeenNthCalledWith( + 1, + ToolConfirmationOutcome.ProceedOnce, + ); + expect(mockOnConfirm).toHaveBeenNthCalledWith( + 2, + ToolConfirmationOutcome.ProceedOnce, + ); + }); + + it('should only auto-approve edit tools when switching to AUTO_EDIT mode', async () => { + const mockOnConfirmReplace = vi.fn().mockResolvedValue(undefined); + const mockOnConfirmWrite = vi.fn().mockResolvedValue(undefined); + const mockOnConfirmRead = vi.fn().mockResolvedValue(undefined); + + const awaitingApprovalToolCalls: TrackedToolCall[] = [ + { + request: { + callId: 'call1', + name: 'replace', + args: { old_string: 'old', new_string: 'new' }, + isClientInitiated: false, + prompt_id: 'prompt-id-1', + }, + status: 'awaiting_approval', + responseSubmittedToGemini: false, + confirmationDetails: { + onConfirm: mockOnConfirmReplace, + onCancel: vi.fn(), + message: 'Replace text?', + displayedText: 'Replace old with new', + }, + tool: { + name: 'replace', + displayName: 'replace', + description: 'Replace text', + build: vi.fn(), + } as any, + invocation: { + getDescription: () => 'Mock description', + } as unknown as AnyToolInvocation, + } as TrackedWaitingToolCall, + { + request: { + callId: 'call2', + name: 'write_file', + args: { path: '/test/new.txt', content: 'content' }, + isClientInitiated: false, + prompt_id: 'prompt-id-1', + }, + status: 'awaiting_approval', + responseSubmittedToGemini: false, + confirmationDetails: { + onConfirm: mockOnConfirmWrite, + onCancel: vi.fn(), + message: 'Write file?', + displayedText: 'Write to /test/new.txt', + }, + tool: { + name: 'write_file', + displayName: 'write_file', + description: 'Write file', + build: vi.fn(), + } as any, + invocation: { + getDescription: () => 'Mock description', + } as unknown as AnyToolInvocation, + } as TrackedWaitingToolCall, + { + request: { + callId: 'call3', + name: 'read_file', + args: { path: '/test/file.txt' }, + isClientInitiated: false, + prompt_id: 'prompt-id-1', + }, + status: 'awaiting_approval', + responseSubmittedToGemini: false, + confirmationDetails: { + onConfirm: mockOnConfirmRead, + onCancel: vi.fn(), + message: 'Read file?', + displayedText: 'Read /test/file.txt', + }, + tool: { + name: 'read_file', + displayName: 'read_file', + description: 'Read file', + build: vi.fn(), + } as any, + invocation: { + getDescription: () => 'Mock description', + } as unknown as AnyToolInvocation, + } as TrackedWaitingToolCall, + ]; + + const { result } = renderTestHook(awaitingApprovalToolCalls); + + await act(async () => { + await result.current.handleApprovalModeChange(ApprovalMode.AUTO_EDIT); + }); + + // Only replace and write_file should be auto-approved + expect(mockOnConfirmReplace).toHaveBeenCalledTimes(1); + expect(mockOnConfirmReplace).toHaveBeenCalledWith( + ToolConfirmationOutcome.ProceedOnce, + ); + expect(mockOnConfirmWrite).toHaveBeenCalledTimes(1); + expect(mockOnConfirmWrite).toHaveBeenCalledWith( + ToolConfirmationOutcome.ProceedOnce, + ); + + // read_file should not be auto-approved + expect(mockOnConfirmRead).not.toHaveBeenCalled(); + }); + + it('should not auto-approve any tools when switching to REQUIRE_CONFIRMATION mode', async () => { + const mockOnConfirm = vi.fn().mockResolvedValue(undefined); + const awaitingApprovalToolCalls: TrackedToolCall[] = [ + { + request: { + callId: 'call1', + name: 'replace', + args: { old_string: 'old', new_string: 'new' }, + isClientInitiated: false, + prompt_id: 'prompt-id-1', + }, + status: 'awaiting_approval', + responseSubmittedToGemini: false, + confirmationDetails: { + onConfirm: mockOnConfirm, + onCancel: vi.fn(), + message: 'Replace text?', + displayedText: 'Replace old with new', + }, + tool: { + name: 'replace', + displayName: 'replace', + description: 'Replace text', + build: vi.fn(), + } as any, + invocation: { + getDescription: () => 'Mock description', + } as unknown as AnyToolInvocation, + } as TrackedWaitingToolCall, + ]; + + const { result } = renderTestHook(awaitingApprovalToolCalls); + + await act(async () => { + await result.current.handleApprovalModeChange( + ApprovalMode.REQUIRE_CONFIRMATION, + ); + }); + + // No tools should be auto-approved + expect(mockOnConfirm).not.toHaveBeenCalled(); + }); + + it('should handle errors gracefully when auto-approving tool calls', async () => { + const consoleSpy = vi + .spyOn(console, 'error') + .mockImplementation(() => {}); + const mockOnConfirmSuccess = vi.fn().mockResolvedValue(undefined); + const mockOnConfirmError = vi + .fn() + .mockRejectedValue(new Error('Approval failed')); + + const awaitingApprovalToolCalls: TrackedToolCall[] = [ + { + request: { + callId: 'call1', + name: 'replace', + args: { old_string: 'old', new_string: 'new' }, + isClientInitiated: false, + prompt_id: 'prompt-id-1', + }, + status: 'awaiting_approval', + responseSubmittedToGemini: false, + confirmationDetails: { + onConfirm: mockOnConfirmSuccess, + onCancel: vi.fn(), + message: 'Replace text?', + displayedText: 'Replace old with new', + }, + tool: { + name: 'replace', + displayName: 'replace', + description: 'Replace text', + build: vi.fn(), + } as any, + invocation: { + getDescription: () => 'Mock description', + } as unknown as AnyToolInvocation, + } as TrackedWaitingToolCall, + { + request: { + callId: 'call2', + name: 'write_file', + args: { path: '/test/file.txt', content: 'content' }, + isClientInitiated: false, + prompt_id: 'prompt-id-1', + }, + status: 'awaiting_approval', + responseSubmittedToGemini: false, + confirmationDetails: { + onConfirm: mockOnConfirmError, + onCancel: vi.fn(), + message: 'Write file?', + displayedText: 'Write to /test/file.txt', + }, + tool: { + name: 'write_file', + displayName: 'write_file', + description: 'Write file', + build: vi.fn(), + } as any, + invocation: { + getDescription: () => 'Mock description', + } as unknown as AnyToolInvocation, + } as TrackedWaitingToolCall, + ]; + + const { result } = renderTestHook(awaitingApprovalToolCalls); + + await act(async () => { + await result.current.handleApprovalModeChange(ApprovalMode.YOLO); + }); + + // Both confirmation methods should be called + expect(mockOnConfirmSuccess).toHaveBeenCalledTimes(1); + expect(mockOnConfirmError).toHaveBeenCalledTimes(1); + + // Error should be logged + expect(consoleSpy).toHaveBeenCalledWith( + 'Failed to auto-approve tool call call2:', + expect.any(Error), + ); + + consoleSpy.mockRestore(); + }); + + it('should skip tool calls without confirmationDetails', async () => { + const awaitingApprovalToolCalls: TrackedToolCall[] = [ + { + request: { + callId: 'call1', + name: 'replace', + args: { old_string: 'old', new_string: 'new' }, + isClientInitiated: false, + prompt_id: 'prompt-id-1', + }, + status: 'awaiting_approval', + responseSubmittedToGemini: false, + // No confirmationDetails + tool: { + name: 'replace', + displayName: 'replace', + description: 'Replace text', + build: vi.fn(), + } as any, + invocation: { + getDescription: () => 'Mock description', + } as unknown as AnyToolInvocation, + } as TrackedWaitingToolCall, + ]; + + const { result } = renderTestHook(awaitingApprovalToolCalls); + + // Should not throw an error + await act(async () => { + await result.current.handleApprovalModeChange(ApprovalMode.YOLO); + }); + }); + + it('should skip tool calls without onConfirm method in confirmationDetails', async () => { + const awaitingApprovalToolCalls: TrackedToolCall[] = [ + { + request: { + callId: 'call1', + name: 'replace', + args: { old_string: 'old', new_string: 'new' }, + isClientInitiated: false, + prompt_id: 'prompt-id-1', + }, + status: 'awaiting_approval', + responseSubmittedToGemini: false, + confirmationDetails: { + onCancel: vi.fn(), + message: 'Replace text?', + displayedText: 'Replace old with new', + // No onConfirm method + } as any, + tool: { + name: 'replace', + displayName: 'replace', + description: 'Replace text', + build: vi.fn(), + } as any, + invocation: { + getDescription: () => 'Mock description', + } as unknown as AnyToolInvocation, + } as TrackedWaitingToolCall, + ]; + + const { result } = renderTestHook(awaitingApprovalToolCalls); + + // Should not throw an error + await act(async () => { + await result.current.handleApprovalModeChange(ApprovalMode.YOLO); + }); + }); + + it('should only process tool calls with awaiting_approval status', async () => { + const mockOnConfirmAwaiting = vi.fn().mockResolvedValue(undefined); + const mockOnConfirmExecuting = vi.fn().mockResolvedValue(undefined); + + const mixedStatusToolCalls: TrackedToolCall[] = [ + { + request: { + callId: 'call1', + name: 'replace', + args: { old_string: 'old', new_string: 'new' }, + isClientInitiated: false, + prompt_id: 'prompt-id-1', + }, + status: 'awaiting_approval', + responseSubmittedToGemini: false, + confirmationDetails: { + onConfirm: mockOnConfirmAwaiting, + onCancel: vi.fn(), + message: 'Replace text?', + displayedText: 'Replace old with new', + }, + tool: { + name: 'replace', + displayName: 'replace', + description: 'Replace text', + build: vi.fn(), + } as any, + invocation: { + getDescription: () => 'Mock description', + } as unknown as AnyToolInvocation, + } as TrackedWaitingToolCall, + { + request: { + callId: 'call2', + name: 'write_file', + args: { path: '/test/file.txt', content: 'content' }, + isClientInitiated: false, + prompt_id: 'prompt-id-1', + }, + status: 'executing', + responseSubmittedToGemini: false, + confirmationDetails: { + onConfirm: mockOnConfirmExecuting, + onCancel: vi.fn(), + message: 'Write file?', + displayedText: 'Write to /test/file.txt', + }, + tool: { + name: 'write_file', + displayName: 'write_file', + description: 'Write file', + build: vi.fn(), + } as any, + invocation: { + getDescription: () => 'Mock description', + } as unknown as AnyToolInvocation, + startTime: Date.now(), + liveOutput: 'Writing...', + } as TrackedExecutingToolCall, + ]; + + const { result } = renderTestHook(mixedStatusToolCalls); + + await act(async () => { + await result.current.handleApprovalModeChange(ApprovalMode.YOLO); + }); + + // Only the awaiting_approval tool should be processed + expect(mockOnConfirmAwaiting).toHaveBeenCalledTimes(1); + expect(mockOnConfirmExecuting).not.toHaveBeenCalled(); + }); + }); + describe('handleFinishedEvent', () => { it('should add info message for MAX_TOKENS finish reason', async () => { // Setup mock to return a stream with MAX_TOKENS finish reason diff --git a/packages/cli/src/ui/hooks/useGeminiStream.ts b/packages/cli/src/ui/hooks/useGeminiStream.ts index 2d49aa2c7e..19c9be1aaf 100644 --- a/packages/cli/src/ui/hooks/useGeminiStream.ts +++ b/packages/cli/src/ui/hooks/useGeminiStream.ts @@ -31,6 +31,7 @@ import { ConversationFinishedEvent, ApprovalMode, parseAndFormatApiError, + ToolConfirmationOutcome, getCodeAssistServer, UserTierId, promptIdContext, @@ -50,17 +51,16 @@ import { findLastSafeSplitPoint } from '../utils/markdownUtilities.js'; import { useStateAndRef } from './useStateAndRef.js'; import type { UseHistoryManagerReturn } from './useHistoryManager.js'; import { useLogger } from './useLogger.js'; -import type { - TrackedToolCall, - TrackedCompletedToolCall, - TrackedCancelledToolCall, -} from './useReactToolScheduler.js'; -import { promises as fs } from 'node:fs'; -import path from 'node:path'; import { useReactToolScheduler, mapToDisplay as mapTrackedToolCallsToDisplay, + type TrackedToolCall, + type TrackedCompletedToolCall, + type TrackedCancelledToolCall, + type TrackedWaitingToolCall, } from './useReactToolScheduler.js'; +import { promises as fs } from 'node:fs'; +import path from 'node:path'; import { useSessionStats } from '../contexts/SessionContext.js'; import { useKeypress } from './useKeypress.js'; import type { LoadedSettings } from '../../config/settings.js'; @@ -71,6 +71,8 @@ enum StreamProcessingStatus { Error, } +const EDIT_TOOL_NAMES = new Set(['replace', 'write_file']); + function showCitations(settings: LoadedSettings, config: Config): boolean { const enabled = settings?.merged?.ui?.showCitations; if (enabled !== undefined) { @@ -847,6 +849,45 @@ export const useGeminiStream = ( ], ); + const handleApprovalModeChange = useCallback( + async (newApprovalMode: ApprovalMode) => { + // Auto-approve pending tool calls when switching to auto-approval modes + if ( + newApprovalMode === ApprovalMode.YOLO || + newApprovalMode === ApprovalMode.AUTO_EDIT + ) { + let awaitingApprovalCalls = toolCalls.filter( + (call): call is TrackedWaitingToolCall => + call.status === 'awaiting_approval', + ); + + // For AUTO_EDIT mode, only approve edit tools (replace, write_file) + if (newApprovalMode === ApprovalMode.AUTO_EDIT) { + awaitingApprovalCalls = awaitingApprovalCalls.filter((call) => + EDIT_TOOL_NAMES.has(call.request.name), + ); + } + + // Process pending tool calls sequentially to reduce UI chaos + for (const call of awaitingApprovalCalls) { + if (call.confirmationDetails?.onConfirm) { + try { + await call.confirmationDetails.onConfirm( + ToolConfirmationOutcome.ProceedOnce, + ); + } catch (error) { + console.error( + `Failed to auto-approve tool call ${call.request.callId}:`, + error, + ); + } + } + } + } + }, + [toolCalls], + ); + const handleCompletedTools = useCallback( async (completedToolCallsFromScheduler: TrackedToolCall[]) => { if (isResponding) { @@ -981,8 +1022,7 @@ export const useGeminiStream = ( } const restorableToolCalls = toolCalls.filter( (toolCall) => - (toolCall.request.name === 'replace' || - toolCall.request.name === 'write_file') && + EDIT_TOOL_NAMES.has(toolCall.request.name) && toolCall.status === 'awaiting_approval', ); @@ -1101,6 +1141,8 @@ export const useGeminiStream = ( pendingHistoryItems, thought, cancelOngoingRequest, + pendingToolCalls: toolCalls, + handleApprovalModeChange, activePtyId, loopDetectionConfirmationRequest, };