diff --git a/packages/vscode-ide-companion/src/types/acpTypes.ts b/packages/vscode-ide-companion/src/types/acpTypes.ts index 9a6495237..c1e20b5f9 100644 --- a/packages/vscode-ide-companion/src/types/acpTypes.ts +++ b/packages/vscode-ide-companion/src/types/acpTypes.ts @@ -40,10 +40,10 @@ export { export const NEXT_APPROVAL_MODE: { [k in ApprovalModeValue]: ApprovalModeValue; } = { + plan: 'default', default: 'auto-edit', 'auto-edit': 'yolo', - plan: 'yolo', - yolo: 'default', + yolo: 'plan', }; // Ask User Question types diff --git a/packages/vscode-ide-companion/src/webview/App.tsx b/packages/vscode-ide-companion/src/webview/App.tsx index 2124f2e09..e6e11945b 100644 --- a/packages/vscode-ide-companion/src/webview/App.tsx +++ b/packages/vscode-ide-companion/src/webview/App.tsx @@ -838,12 +838,10 @@ export const App: React.FC = () => { }); }, [vscode]); - // Handle toggle edit mode (Default -> Auto-edit -> YOLO -> Default) const handleToggleEditMode = useCallback(() => { setEditMode((prev) => { const next: ApprovalModeValue = NEXT_APPROVAL_MODE[prev]; - // Notify extension to set approval mode via ACP try { vscode.postMessage({ type: 'setApprovalMode', @@ -856,6 +854,22 @@ export const App: React.FC = () => { }); }, [vscode]); + // Handle Tab key to cycle approval modes when input is focused + const handleInputKeyDown = useCallback( + (e: React.KeyboardEvent) => { + if ( + e.key === 'Tab' && + !e.shiftKey && + !isComposing && + !completion.isOpen + ) { + e.preventDefault(); + handleToggleEditMode(); + } + }, + [completion.isOpen, handleToggleEditMode, isComposing], + ); + const handleToggleThinking = useCallback(() => { setThinkingEnabled((prev) => !prev); }, []); @@ -1026,7 +1040,7 @@ export const App: React.FC = () => { onInputChange={setInputText} onCompositionStart={() => setIsComposing(true)} onCompositionEnd={() => setIsComposing(false)} - onKeyDown={() => {}} + onKeyDown={handleInputKeyDown} onSubmit={handleSubmitWithScroll} onCancel={handleCancel} onToggleEditMode={handleToggleEditMode} diff --git a/packages/vscode-ide-companion/src/webview/providers/WebViewProvider.test.ts b/packages/vscode-ide-companion/src/webview/providers/WebViewProvider.test.ts index 3f3bf8158..3c87e078c 100644 --- a/packages/vscode-ide-companion/src/webview/providers/WebViewProvider.test.ts +++ b/packages/vscode-ide-companion/src/webview/providers/WebViewProvider.test.ts @@ -10,16 +10,28 @@ const { mockCreateImagePathResolver, mockGetGlobalTempDir, mockGetPanel, + mockMessageHandlerInstances, mockOnDidChangeActiveTextEditor, mockOnDidChangeTextEditorSelection, + mockQwenAgentManagerInstances, } = vi.hoisted(() => ({ mockCreateImagePathResolver: vi.fn(), mockGetGlobalTempDir: vi.fn(() => '/global-temp'), mockGetPanel: vi.fn<() => { webview: { postMessage: unknown } } | null>( () => null, ), + mockMessageHandlerInstances: [] as Array<{ + permissionHandler?: (message: { + type: string; + data: { optionId?: string }; + }) => void; + }>, mockOnDidChangeActiveTextEditor: vi.fn(() => ({ dispose: vi.fn() })), mockOnDidChangeTextEditorSelection: vi.fn(() => ({ dispose: vi.fn() })), + mockQwenAgentManagerInstances: [] as Array<{ + permissionRequestCallback?: (request: unknown) => Promise; + cancelCurrentPrompt: ReturnType; + }>, })); vi.mock('@qwen-code/qwen-code-core', () => ({ @@ -68,10 +80,19 @@ vi.mock('../../services/qwenAgentManager.js', () => ({ onEndTurn = vi.fn(); onToolCall = vi.fn(); onPlan = vi.fn(); - onPermissionRequest = vi.fn(); + onPermissionRequest = vi.fn( + (callback: (request: unknown) => Promise) => { + this.permissionRequestCallback = callback; + }, + ); onAskUserQuestion = vi.fn(); onDisconnected = vi.fn(); + permissionRequestCallback?: (request: unknown) => Promise; + cancelCurrentPrompt = vi.fn(); disconnect = vi.fn(); + constructor() { + mockQwenAgentManagerInstances.push(this); + } }, })); @@ -107,9 +128,24 @@ vi.mock('./MessageHandler.js', () => ({ _conversationStore: unknown, _currentConversationId: string | null, _sendToWebView: (message: unknown) => void, - ) {} + ) { + mockMessageHandlerInstances.push(this); + } setLoginHandler = vi.fn(); - setPermissionHandler = vi.fn(); + permissionHandler?: (message: { + type: string; + data: { optionId?: string }; + }) => void; + setPermissionHandler = vi.fn( + ( + handler: (message: { + type: string; + data: { optionId?: string }; + }) => void, + ) => { + this.permissionHandler = handler; + }, + ); setAskUserQuestionHandler = vi.fn(); setCurrentConversationId = vi.fn(); getCurrentConversationId = vi.fn(() => null); @@ -146,6 +182,8 @@ import { describe('WebViewProvider.attachToView', () => { beforeEach(() => { vi.clearAllMocks(); + mockMessageHandlerInstances.length = 0; + mockQwenAgentManagerInstances.length = 0; mockGetPanel.mockReturnValue(null); mockCreateImagePathResolver.mockReturnValue((paths: string[]) => paths.map((entry) => ({ @@ -302,6 +340,84 @@ describe('WebViewProvider.attachToView', () => { }); expect(panelPostMessage).not.toHaveBeenCalled(); }); + + it('marks rejected switch_mode permission requests as failed without cancelling the session', async () => { + const postMessage = vi.fn(); + const webview = { + options: undefined as unknown, + html: '', + postMessage, + asWebviewUri: vi.fn((uri: { fsPath: string }) => ({ + toString: () => `webview:${uri.fsPath}`, + })), + onDidReceiveMessage: vi.fn(() => ({ dispose: vi.fn() })), + }; + + const provider = new WebViewProvider( + { subscriptions: [] } as never, + { fsPath: '/extension-root' } as never, + ); + + await provider.attachToView( + { + webview, + visible: true, + onDidChangeVisibility: vi.fn(() => ({ dispose: vi.fn() })), + onDidDispose: vi.fn(() => ({ dispose: vi.fn() })), + } as never, + 'qwen-code.chatView.sidebar', + ); + + const agentManager = mockQwenAgentManagerInstances.at(-1); + const messageHandler = mockMessageHandlerInstances.at(-1); + + expect(agentManager?.permissionRequestCallback).toBeTypeOf('function'); + + const permissionPromise = agentManager?.permissionRequestCallback?.({ + options: [ + { + optionId: 'proceed_once', + name: 'Yes', + kind: 'allow_once', + }, + { + optionId: 'cancel', + name: 'No, keep planning (esc)', + kind: 'reject_once', + }, + ], + toolCall: { + toolCallId: 'tool-call-1', + title: 'Would you like to proceed?', + kind: 'switch_mode', + status: 'pending', + }, + }); + + expect(messageHandler?.permissionHandler).toBeTypeOf('function'); + + messageHandler?.permissionHandler?.({ + type: 'permissionResponse', + data: { optionId: 'cancel' }, + }); + + await expect(permissionPromise).resolves.toBe('cancel'); + expect(agentManager?.cancelCurrentPrompt).not.toHaveBeenCalled(); + expect(postMessage).not.toHaveBeenCalledWith( + expect.objectContaining({ + type: 'streamEnd', + }), + ); + expect(postMessage).toHaveBeenCalledWith({ + type: 'toolCall', + data: expect.objectContaining({ + type: 'tool_call_update', + toolCallId: 'tool-call-1', + kind: 'switch_mode', + status: 'failed', + }), + }); + }); }); describe('WebViewProvider.createNewSession', () => { diff --git a/packages/vscode-ide-companion/src/webview/providers/WebViewProvider.ts b/packages/vscode-ide-companion/src/webview/providers/WebViewProvider.ts index 60f8a25d9..2f84c89dc 100644 --- a/packages/vscode-ide-companion/src/webview/providers/WebViewProvider.ts +++ b/packages/vscode-ide-companion/src/webview/providers/WebViewProvider.ts @@ -308,25 +308,35 @@ export class WebViewProvider { optionId === 'cancel' || optionId.toLowerCase().includes('reject'); + // For switch_mode (exit_plan_mode), cancel means "reject + // the plan and stay in plan mode" — the agent keeps running. + const isSwitchMode = + (request.toolCall as { kind?: string } | undefined)?.kind === + 'switch_mode'; + // Always close open qwen-diff editors after any permission decision void vscode.commands.executeCommand('qwen.diff.closeAll'); if (isCancel) { - // Fire and forget — cancel generation and update UI + // Fire and forget — for normal tool calls, cancel generation and + // end the stream; for switch_mode, keep the session alive but + // still mark the permission tool call as failed in the UI. void (async () => { - try { - await this.agentManager.cancelCurrentPrompt(); - } catch (err) { - console.warn( - '[WebViewProvider] cancelCurrentPrompt error:', - err, - ); - } + if (!isSwitchMode) { + try { + await this.agentManager.cancelCurrentPrompt(); + } catch (err) { + console.warn( + '[WebViewProvider] cancelCurrentPrompt error:', + err, + ); + } - this.sendMessageToWebView({ - type: 'streamEnd', - data: { timestamp: Date.now(), reason: 'user_cancelled' }, - }); + this.sendMessageToWebView({ + type: 'streamEnd', + data: { timestamp: Date.now(), reason: 'user_cancelled' }, + }); + } // Synthesize a failed tool_call_update to match CLI UX try { diff --git a/packages/webui/src/components/PermissionDrawer.tsx b/packages/webui/src/components/PermissionDrawer.tsx index 1f8c12432..d2eee02d6 100644 --- a/packages/webui/src/components/PermissionDrawer.tsx +++ b/packages/webui/src/components/PermissionDrawer.tsx @@ -4,8 +4,9 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { useEffect, useRef, useState } from 'react'; +import { useEffect, useMemo, useRef, useState } from 'react'; import type { FC } from 'react'; +import { MarkdownRenderer } from './messages/MarkdownRenderer/MarkdownRenderer.js'; export interface PermissionOption { name: string; @@ -103,6 +104,9 @@ export const PermissionDrawer: FC = ({ ); } + if (toolCall.kind === 'switch_mode') { + return 'Would you like to proceed?'; + } return toolCall.title || 'Permission Required'; }; @@ -178,6 +182,25 @@ export const PermissionDrawer: FC = ({ } }, [isOpen, options.length]); + const planText = useMemo(() => { + if (toolCall.kind !== 'switch_mode' || !Array.isArray(toolCall.content)) { + return null; + } + for (const item of toolCall.content) { + if ( + item.type === 'content' && + typeof item.content === 'object' && + item.content !== null + ) { + const inner = item.content as { type?: string; text?: string }; + if (inner.type === 'text' && typeof inner.text === 'string') { + return inner.text; + } + } + } + return null; + }, [toolCall.kind, toolCall.content]); + if (!isOpen) { return null; } @@ -187,7 +210,7 @@ export const PermissionDrawer: FC = ({ {/* Main container */}
= ({ )}
+ {/* Plan content for switch_mode (exit_plan_mode) */} + {planText && ( +
+ +
+ )} + {/* Options */}
{options.map((option, index) => {