feat(vscode-companion): enable Plan Mode toggle and approval UI (#2551)

* feat(vscode-companion): enable Plan Mode toggle and approval UI

- Add Plan Mode to the approval mode cycle (plan → default → auto-edit → yolo → plan)
- Add Tab key shortcut to cycle approval modes in the input field
- Fix cancel handling for exit_plan_mode: reject plan without aborting agent session
- Add plan approval UI in PermissionDrawer with markdown content rendering

Closes #1985

Made-with: Cursor

* fix(vscode-ide-companion/webview): finalize rejected plan prompts
This commit is contained in:
易良 2026-04-19 20:45:09 +08:00 committed by GitHub
parent 9de33dded3
commit 528fcfcff8
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 193 additions and 23 deletions

View file

@ -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

View file

@ -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}

View file

@ -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<string>;
cancelCurrentPrompt: ReturnType<typeof vi.fn>;
}>,
}));
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<string>) => {
this.permissionRequestCallback = callback;
},
);
onAskUserQuestion = vi.fn();
onDisconnected = vi.fn();
permissionRequestCallback?: (request: unknown) => Promise<string>;
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', () => {

View file

@ -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 {

View file

@ -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<PermissionDrawerProps> = ({
</>
);
}
if (toolCall.kind === 'switch_mode') {
return 'Would you like to proceed?';
}
return toolCall.title || 'Permission Required';
};
@ -178,6 +182,25 @@ export const PermissionDrawer: FC<PermissionDrawerProps> = ({
}
}, [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<PermissionDrawerProps> = ({
{/* Main container */}
<div
ref={containerRef}
className="relative flex flex-col rounded-large border p-2 outline-none animate-slide-up"
className={`relative flex flex-col rounded-large border p-2 outline-none animate-slide-up${planText ? ' max-h-[60vh]' : ''}`}
style={{
backgroundColor: 'var(--app-input-secondary-background)',
borderColor: 'var(--app-input-border)',
@ -227,6 +250,13 @@ export const PermissionDrawer: FC<PermissionDrawerProps> = ({
)}
</div>
{/* Plan content for switch_mode (exit_plan_mode) */}
{planText && (
<div className="relative z-[1] overflow-y-auto mb-2 rounded-[4px] max-h-[40vh] py-2 px-3 text-[13px] leading-normal bg-[var(--app-primary-background)] border border-[var(--app-input-border)] [scrollbar-width:thin] [&::-webkit-scrollbar]:w-1.5 [&::-webkit-scrollbar-track]:bg-transparent [&::-webkit-scrollbar-thumb]:rounded-full [&::-webkit-scrollbar-thumb]:bg-[var(--app-foreground-muted)]/30">
<MarkdownRenderer content={planText} />
</div>
)}
{/* Options */}
<div className="relative z-[1] flex flex-col gap-1 pb-1">
{options.map((option, index) => {