mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-04-28 03:30:40 +00:00
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:
parent
9de33dded3
commit
528fcfcff8
5 changed files with 193 additions and 23 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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', () => {
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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) => {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue