feat: Implement Plan Mode for Safe Code Planning (#658)

This commit is contained in:
tanzhenxin
2025-09-24 14:26:17 +08:00
committed by GitHub
parent 8379bc4d81
commit 4e7a7e2656
43 changed files with 2895 additions and 281 deletions

View File

@@ -0,0 +1,495 @@
/**
* @license
* Copyright 2025 Qwen
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { approvalModeCommand } from './approvalModeCommand.js';
import {
type CommandContext,
CommandKind,
type MessageActionReturn,
} from './types.js';
import { createMockCommandContext } from '../../test-utils/mockCommandContext.js';
import { ApprovalMode } from '@qwen-code/qwen-code-core';
import { SettingScope, type LoadedSettings } from '../../config/settings.js';
describe('approvalModeCommand', () => {
let mockContext: CommandContext;
let setApprovalModeMock: ReturnType<typeof vi.fn>;
let setSettingsValueMock: ReturnType<typeof vi.fn>;
const originalEnv = { ...process.env };
const userSettingsPath = '/mock/user/settings.json';
const projectSettingsPath = '/mock/project/settings.json';
const userSettingsFile = { path: userSettingsPath, settings: {} };
const projectSettingsFile = { path: projectSettingsPath, settings: {} };
const getModeSubCommand = (mode: ApprovalMode) =>
approvalModeCommand.subCommands?.find((cmd) => cmd.name === mode);
const getScopeSubCommand = (
mode: ApprovalMode,
scope: '--session' | '--user' | '--project',
) => getModeSubCommand(mode)?.subCommands?.find((cmd) => cmd.name === scope);
beforeEach(() => {
setApprovalModeMock = vi.fn();
setSettingsValueMock = vi.fn();
mockContext = createMockCommandContext({
services: {
config: {
getApprovalMode: vi.fn().mockReturnValue(ApprovalMode.DEFAULT),
setApprovalMode: setApprovalModeMock,
},
settings: {
merged: {},
setValue: setSettingsValueMock,
forScope: vi
.fn()
.mockImplementation((scope: SettingScope) =>
scope === SettingScope.User
? userSettingsFile
: scope === SettingScope.Workspace
? projectSettingsFile
: { path: '', settings: {} },
),
} as unknown as LoadedSettings,
},
} as unknown as CommandContext);
});
afterEach(() => {
process.env = { ...originalEnv };
vi.clearAllMocks();
});
it('should have the correct command properties', () => {
expect(approvalModeCommand.name).toBe('approval-mode');
expect(approvalModeCommand.kind).toBe(CommandKind.BUILT_IN);
expect(approvalModeCommand.description).toBe(
'View or change the approval mode for tool usage',
);
});
it('should show current mode, options, and usage when no arguments provided', async () => {
if (!approvalModeCommand.action) {
throw new Error('approvalModeCommand must have an action.');
}
const result = (await approvalModeCommand.action(
mockContext,
'',
)) as MessageActionReturn;
expect(result.type).toBe('message');
expect(result.messageType).toBe('info');
const expectedMessage = [
'Current approval mode: default',
'',
'Available approval modes:',
' - plan: Plan mode - Analyze only, do not modify files or execute commands',
' - default: Default mode - Require approval for file edits or shell commands',
' - auto-edit: Auto-edit mode - Automatically approve file edits',
' - yolo: YOLO mode - Automatically approve all tools',
'',
'Usage: /approval-mode <mode> [--session|--user|--project]',
].join('\n');
expect(result.content).toBe(expectedMessage);
});
it('should display error when config is not available', async () => {
if (!approvalModeCommand.action) {
throw new Error('approvalModeCommand must have an action.');
}
const nullConfigContext = createMockCommandContext({
services: {
config: null,
},
} as unknown as CommandContext);
const result = (await approvalModeCommand.action(
nullConfigContext,
'',
)) as MessageActionReturn;
expect(result.type).toBe('message');
expect(result.messageType).toBe('error');
expect(result.content).toBe('Configuration not available.');
});
it('should change approval mode when valid mode is provided', async () => {
if (!approvalModeCommand.action) {
throw new Error('approvalModeCommand must have an action.');
}
const result = (await approvalModeCommand.action(
mockContext,
'plan',
)) as MessageActionReturn;
expect(setApprovalModeMock).toHaveBeenCalledWith(ApprovalMode.PLAN);
expect(setSettingsValueMock).not.toHaveBeenCalled();
expect(result.type).toBe('message');
expect(result.messageType).toBe('info');
expect(result.content).toBe('Approval mode changed to: plan');
});
it('should accept canonical auto-edit mode value', async () => {
if (!approvalModeCommand.action) {
throw new Error('approvalModeCommand must have an action.');
}
const result = (await approvalModeCommand.action(
mockContext,
'auto-edit',
)) as MessageActionReturn;
expect(setApprovalModeMock).toHaveBeenCalledWith(ApprovalMode.AUTO_EDIT);
expect(setSettingsValueMock).not.toHaveBeenCalled();
expect(result.type).toBe('message');
expect(result.messageType).toBe('info');
expect(result.content).toBe('Approval mode changed to: auto-edit');
});
it('should accept auto-edit alias for compatibility', async () => {
if (!approvalModeCommand.action) {
throw new Error('approvalModeCommand must have an action.');
}
const result = (await approvalModeCommand.action(
mockContext,
'auto-edit',
)) as MessageActionReturn;
expect(setApprovalModeMock).toHaveBeenCalledWith(ApprovalMode.AUTO_EDIT);
expect(setSettingsValueMock).not.toHaveBeenCalled();
expect(result.content).toBe('Approval mode changed to: auto-edit');
});
it('should display error when invalid mode is provided', async () => {
if (!approvalModeCommand.action) {
throw new Error('approvalModeCommand must have an action.');
}
const result = (await approvalModeCommand.action(
mockContext,
'invalid',
)) as MessageActionReturn;
expect(result.type).toBe('message');
expect(result.messageType).toBe('error');
expect(result.content).toContain('Invalid approval mode: invalid');
expect(result.content).toContain('Available approval modes:');
expect(result.content).toContain(
'Usage: /approval-mode <mode> [--session|--user|--project]',
);
});
it('should display error when setApprovalMode throws an error', async () => {
if (!approvalModeCommand.action) {
throw new Error('approvalModeCommand must have an action.');
}
const errorMessage = 'Failed to set approval mode';
mockContext.services.config!.setApprovalMode = vi
.fn()
.mockImplementation(() => {
throw new Error(errorMessage);
});
const result = (await approvalModeCommand.action(
mockContext,
'plan',
)) as MessageActionReturn;
expect(result.type).toBe('message');
expect(result.messageType).toBe('error');
expect(result.content).toBe(
`Failed to change approval mode: ${errorMessage}`,
);
});
it('should allow selecting auto-edit with user scope via nested subcommands', async () => {
if (!approvalModeCommand.subCommands) {
throw new Error('approvalModeCommand must have subCommands.');
}
const userSubCommand = getScopeSubCommand(ApprovalMode.AUTO_EDIT, '--user');
if (!userSubCommand?.action) {
throw new Error('--user scope subcommand must have an action.');
}
const result = (await userSubCommand.action(
mockContext,
'',
)) as MessageActionReturn;
expect(setApprovalModeMock).toHaveBeenCalledWith(ApprovalMode.AUTO_EDIT);
expect(setSettingsValueMock).toHaveBeenCalledWith(
SettingScope.User,
'approvalMode',
'auto-edit',
);
expect(result.content).toBe(
`Approval mode changed to: auto-edit (saved to user settings at ${userSettingsPath})`,
);
});
it('should allow selecting plan with project scope via nested subcommands', async () => {
if (!approvalModeCommand.subCommands) {
throw new Error('approvalModeCommand must have subCommands.');
}
const projectSubCommand = getScopeSubCommand(
ApprovalMode.PLAN,
'--project',
);
if (!projectSubCommand?.action) {
throw new Error('--project scope subcommand must have an action.');
}
const result = (await projectSubCommand.action(
mockContext,
'',
)) as MessageActionReturn;
expect(setApprovalModeMock).toHaveBeenCalledWith(ApprovalMode.PLAN);
expect(setSettingsValueMock).toHaveBeenCalledWith(
SettingScope.Workspace,
'approvalMode',
'plan',
);
expect(result.content).toBe(
`Approval mode changed to: plan (saved to project settings at ${projectSettingsPath})`,
);
});
it('should allow selecting plan with session scope via nested subcommands', async () => {
if (!approvalModeCommand.subCommands) {
throw new Error('approvalModeCommand must have subCommands.');
}
const sessionSubCommand = getScopeSubCommand(
ApprovalMode.PLAN,
'--session',
);
if (!sessionSubCommand?.action) {
throw new Error('--session scope subcommand must have an action.');
}
const result = (await sessionSubCommand.action(
mockContext,
'',
)) as MessageActionReturn;
expect(setApprovalModeMock).toHaveBeenCalledWith(ApprovalMode.PLAN);
expect(setSettingsValueMock).not.toHaveBeenCalled();
expect(result.content).toBe('Approval mode changed to: plan');
});
it('should allow providing a scope argument after selecting a mode subcommand', async () => {
if (!approvalModeCommand.subCommands) {
throw new Error('approvalModeCommand must have subCommands.');
}
const planSubCommand = getModeSubCommand(ApprovalMode.PLAN);
if (!planSubCommand?.action) {
throw new Error('plan subcommand must have an action.');
}
const result = (await planSubCommand.action(
mockContext,
'--user',
)) as MessageActionReturn;
expect(setApprovalModeMock).toHaveBeenCalledWith(ApprovalMode.PLAN);
expect(setSettingsValueMock).toHaveBeenCalledWith(
SettingScope.User,
'approvalMode',
'plan',
);
expect(result.content).toBe(
`Approval mode changed to: plan (saved to user settings at ${userSettingsPath})`,
);
});
it('should support --user plan pattern (scope first)', async () => {
if (!approvalModeCommand.action) {
throw new Error('approvalModeCommand must have an action.');
}
const result = (await approvalModeCommand.action(
mockContext,
'--user plan',
)) as MessageActionReturn;
expect(setApprovalModeMock).toHaveBeenCalledWith(ApprovalMode.PLAN);
expect(setSettingsValueMock).toHaveBeenCalledWith(
SettingScope.User,
'approvalMode',
'plan',
);
expect(result.content).toBe(
`Approval mode changed to: plan (saved to user settings at ${userSettingsPath})`,
);
});
it('should support plan --user pattern (mode first)', async () => {
if (!approvalModeCommand.action) {
throw new Error('approvalModeCommand must have an action.');
}
const result = (await approvalModeCommand.action(
mockContext,
'plan --user',
)) as MessageActionReturn;
expect(setApprovalModeMock).toHaveBeenCalledWith(ApprovalMode.PLAN);
expect(setSettingsValueMock).toHaveBeenCalledWith(
SettingScope.User,
'approvalMode',
'plan',
);
expect(result.content).toBe(
`Approval mode changed to: plan (saved to user settings at ${userSettingsPath})`,
);
});
it('should support --project auto-edit pattern', async () => {
if (!approvalModeCommand.action) {
throw new Error('approvalModeCommand must have an action.');
}
const result = (await approvalModeCommand.action(
mockContext,
'--project auto-edit',
)) as MessageActionReturn;
expect(setApprovalModeMock).toHaveBeenCalledWith(ApprovalMode.AUTO_EDIT);
expect(setSettingsValueMock).toHaveBeenCalledWith(
SettingScope.Workspace,
'approvalMode',
'auto-edit',
);
expect(result.content).toBe(
`Approval mode changed to: auto-edit (saved to project settings at ${projectSettingsPath})`,
);
});
it('should display error when only scope flag is provided', async () => {
if (!approvalModeCommand.action) {
throw new Error('approvalModeCommand must have an action.');
}
const result = (await approvalModeCommand.action(
mockContext,
'--user',
)) as MessageActionReturn;
expect(result.type).toBe('message');
expect(result.messageType).toBe('error');
expect(result.content).toContain('Missing approval mode');
expect(setApprovalModeMock).not.toHaveBeenCalled();
expect(setSettingsValueMock).not.toHaveBeenCalled();
});
it('should display error when multiple scope flags are provided', async () => {
if (!approvalModeCommand.action) {
throw new Error('approvalModeCommand must have an action.');
}
const result = (await approvalModeCommand.action(
mockContext,
'--user --project plan',
)) as MessageActionReturn;
expect(result.type).toBe('message');
expect(result.messageType).toBe('error');
expect(result.content).toContain('Multiple scope flags provided');
expect(setApprovalModeMock).not.toHaveBeenCalled();
expect(setSettingsValueMock).not.toHaveBeenCalled();
});
it('should surface a helpful error when scope subcommands receive extra arguments', async () => {
if (!approvalModeCommand.subCommands) {
throw new Error('approvalModeCommand must have subCommands.');
}
const userSubCommand = getScopeSubCommand(ApprovalMode.DEFAULT, '--user');
if (!userSubCommand?.action) {
throw new Error('--user scope subcommand must have an action.');
}
const result = (await userSubCommand.action(
mockContext,
'extra',
)) as MessageActionReturn;
expect(result.type).toBe('message');
expect(result.messageType).toBe('error');
expect(result.content).toBe(
'Scope subcommands do not accept additional arguments.',
);
expect(setApprovalModeMock).not.toHaveBeenCalled();
expect(setSettingsValueMock).not.toHaveBeenCalled();
});
it('should provide completion for approval modes', async () => {
if (!approvalModeCommand.completion) {
throw new Error('approvalModeCommand must have a completion function.');
}
// Test partial mode completion
const result = await approvalModeCommand.completion(mockContext, 'p');
expect(result).toEqual(['plan']);
const result2 = await approvalModeCommand.completion(mockContext, 'a');
expect(result2).toEqual(['auto-edit']);
// Test empty completion - should suggest available modes first
const result3 = await approvalModeCommand.completion(mockContext, '');
expect(result3).toEqual(['plan', 'default', 'auto-edit', 'yolo']);
const result4 = await approvalModeCommand.completion(mockContext, 'AUTO');
expect(result4).toEqual(['auto-edit']);
// Test mode first pattern: 'plan ' should suggest scope flags
const result5 = await approvalModeCommand.completion(mockContext, 'plan ');
expect(result5).toEqual(['--session', '--project', '--user']);
const result6 = await approvalModeCommand.completion(
mockContext,
'plan --u',
);
expect(result6).toEqual(['--user']);
// Test scope first pattern: '--user ' should suggest modes
const result7 = await approvalModeCommand.completion(
mockContext,
'--user ',
);
expect(result7).toEqual(['plan', 'default', 'auto-edit', 'yolo']);
const result8 = await approvalModeCommand.completion(
mockContext,
'--user p',
);
expect(result8).toEqual(['plan']);
// Test completed patterns should return empty
const result9 = await approvalModeCommand.completion(
mockContext,
'plan --user ',
);
expect(result9).toEqual([]);
const result10 = await approvalModeCommand.completion(
mockContext,
'--user plan ',
);
expect(result10).toEqual([]);
});
});

View File

@@ -0,0 +1,434 @@
/**
* @license
* Copyright 2025 Qwen
* SPDX-License-Identifier: Apache-2.0
*/
import type {
SlashCommand,
CommandContext,
MessageActionReturn,
} from './types.js';
import { CommandKind } from './types.js';
import { ApprovalMode, APPROVAL_MODES } from '@qwen-code/qwen-code-core';
import { SettingScope } from '../../config/settings.js';
const USAGE_MESSAGE =
'Usage: /approval-mode <mode> [--session|--user|--project]';
const normalizeInputMode = (value: string): string =>
value.trim().toLowerCase();
const tokenizeArgs = (args: string): string[] => {
const matches = args.match(/(?:"[^"]*"|'[^']*'|[^\s"']+)/g);
if (!matches) {
return [];
}
return matches.map((token) => {
if (
(token.startsWith('"') && token.endsWith('"')) ||
(token.startsWith("'") && token.endsWith("'"))
) {
return token.slice(1, -1);
}
return token;
});
};
const parseApprovalMode = (value: string | null): ApprovalMode | null => {
if (!value) {
return null;
}
const normalized = normalizeInputMode(value).replace(/_/g, '-');
const matchIndex = APPROVAL_MODES.findIndex(
(candidate) => candidate === normalized,
);
return matchIndex === -1 ? null : APPROVAL_MODES[matchIndex];
};
const formatModeDescription = (mode: ApprovalMode): string => {
switch (mode) {
case ApprovalMode.PLAN:
return 'Plan mode - Analyze only, do not modify files or execute commands';
case ApprovalMode.DEFAULT:
return 'Default mode - Require approval for file edits or shell commands';
case ApprovalMode.AUTO_EDIT:
return 'Auto-edit mode - Automatically approve file edits';
case ApprovalMode.YOLO:
return 'YOLO mode - Automatically approve all tools';
default:
return `${mode} mode`;
}
};
const parseApprovalArgs = (
args: string,
): {
mode: string | null;
scope: 'session' | 'user' | 'project';
error?: string;
} => {
const trimmedArgs = args.trim();
if (!trimmedArgs) {
return { mode: null, scope: 'session' };
}
const tokens = tokenizeArgs(trimmedArgs);
let mode: string | null = null;
let scope: 'session' | 'user' | 'project' = 'session';
let scopeFlag: string | null = null;
// Find scope flag and mode
for (const token of tokens) {
if (token === '--session' || token === '--user' || token === '--project') {
if (scopeFlag) {
return {
mode: null,
scope: 'session',
error: 'Multiple scope flags provided',
};
}
scopeFlag = token;
scope = token.substring(2) as 'session' | 'user' | 'project';
} else if (!mode) {
mode = token;
} else {
return {
mode: null,
scope: 'session',
error: 'Invalid arguments provided',
};
}
}
if (!mode) {
return { mode: null, scope: 'session', error: 'Missing approval mode' };
}
return { mode, scope };
};
const setApprovalModeWithScope = async (
context: CommandContext,
mode: ApprovalMode,
scope: 'session' | 'user' | 'project',
): Promise<MessageActionReturn> => {
const { services } = context;
const { config } = services;
if (!config) {
return {
type: 'message',
messageType: 'error',
content: 'Configuration not available.',
};
}
try {
// Always set the mode in the current session
config.setApprovalMode(mode);
// If scope is not session, also persist to settings
if (scope !== 'session') {
const { settings } = context.services;
if (!settings || typeof settings.setValue !== 'function') {
return {
type: 'message',
messageType: 'error',
content:
'Settings service is not available; unable to persist the approval mode.',
};
}
const settingScope =
scope === 'user' ? SettingScope.User : SettingScope.Workspace;
const scopeLabel = scope === 'user' ? 'user' : 'project';
let settingsPath: string | undefined;
try {
if (typeof settings.forScope === 'function') {
settingsPath = settings.forScope(settingScope)?.path;
}
} catch (_error) {
settingsPath = undefined;
}
try {
settings.setValue(settingScope, 'approvalMode', mode);
} catch (error) {
return {
type: 'message',
messageType: 'error',
content: `Failed to save approval mode: ${(error as Error).message}`,
};
}
const locationSuffix = settingsPath ? ` at ${settingsPath}` : '';
const scopeSuffix = ` (saved to ${scopeLabel} settings${locationSuffix})`;
return {
type: 'message',
messageType: 'info',
content: `Approval mode changed to: ${mode}${scopeSuffix}`,
};
}
return {
type: 'message',
messageType: 'info',
content: `Approval mode changed to: ${mode}`,
};
} catch (error) {
return {
type: 'message',
messageType: 'error',
content: `Failed to change approval mode: ${(error as Error).message}`,
};
}
};
export const approvalModeCommand: SlashCommand = {
name: 'approval-mode',
description: 'View or change the approval mode for tool usage',
kind: CommandKind.BUILT_IN,
action: async (
context: CommandContext,
args: string,
): Promise<MessageActionReturn> => {
const { config } = context.services;
if (!config) {
return {
type: 'message',
messageType: 'error',
content: 'Configuration not available.',
};
}
// If no arguments provided, show current mode and available options
if (!args || args.trim() === '') {
const currentMode =
typeof config.getApprovalMode === 'function'
? config.getApprovalMode()
: null;
const messageLines: string[] = [];
if (currentMode) {
messageLines.push(`Current approval mode: ${currentMode}`);
messageLines.push('');
}
messageLines.push('Available approval modes:');
for (const mode of APPROVAL_MODES) {
messageLines.push(` - ${mode}: ${formatModeDescription(mode)}`);
}
messageLines.push('');
messageLines.push(USAGE_MESSAGE);
return {
type: 'message',
messageType: 'info',
content: messageLines.join('\n'),
};
}
// Parse arguments flexibly
const parsed = parseApprovalArgs(args);
if (parsed.error) {
return {
type: 'message',
messageType: 'error',
content: `${parsed.error}. ${USAGE_MESSAGE}`,
};
}
if (!parsed.mode) {
return {
type: 'message',
messageType: 'info',
content: USAGE_MESSAGE,
};
}
const requestedMode = parseApprovalMode(parsed.mode);
if (!requestedMode) {
let message = `Invalid approval mode: ${parsed.mode}\n\n`;
message += 'Available approval modes:\n';
for (const mode of APPROVAL_MODES) {
message += ` - ${mode}: ${formatModeDescription(mode)}\n`;
}
message += `\n${USAGE_MESSAGE}`;
return {
type: 'message',
messageType: 'error',
content: message,
};
}
return setApprovalModeWithScope(context, requestedMode, parsed.scope);
},
subCommands: APPROVAL_MODES.map((mode) => ({
name: mode,
description: formatModeDescription(mode),
kind: CommandKind.BUILT_IN,
subCommands: [
{
name: '--session',
description: 'Apply to current session only (temporary)',
kind: CommandKind.BUILT_IN,
action: async (
context: CommandContext,
args: string,
): Promise<MessageActionReturn> => {
if (args.trim().length > 0) {
return {
type: 'message',
messageType: 'error',
content: 'Scope subcommands do not accept additional arguments.',
};
}
return setApprovalModeWithScope(context, mode, 'session');
},
},
{
name: '--project',
description: 'Persist for this project/workspace',
kind: CommandKind.BUILT_IN,
action: async (
context: CommandContext,
args: string,
): Promise<MessageActionReturn> => {
if (args.trim().length > 0) {
return {
type: 'message',
messageType: 'error',
content: 'Scope subcommands do not accept additional arguments.',
};
}
return setApprovalModeWithScope(context, mode, 'project');
},
},
{
name: '--user',
description: 'Persist for this user on this machine',
kind: CommandKind.BUILT_IN,
action: async (
context: CommandContext,
args: string,
): Promise<MessageActionReturn> => {
if (args.trim().length > 0) {
return {
type: 'message',
messageType: 'error',
content: 'Scope subcommands do not accept additional arguments.',
};
}
return setApprovalModeWithScope(context, mode, 'user');
},
},
],
action: async (
context: CommandContext,
args: string,
): Promise<MessageActionReturn> => {
if (args.trim().length > 0) {
// Allow users who type `/approval-mode plan --user` via the subcommand path
const parsed = parseApprovalArgs(`${mode} ${args}`);
if (parsed.error) {
return {
type: 'message',
messageType: 'error',
content: `${parsed.error}. ${USAGE_MESSAGE}`,
};
}
const normalizedMode = parseApprovalMode(parsed.mode);
if (!normalizedMode) {
return {
type: 'message',
messageType: 'error',
content: `Invalid approval mode: ${parsed.mode}. ${USAGE_MESSAGE}`,
};
}
return setApprovalModeWithScope(context, normalizedMode, parsed.scope);
}
return setApprovalModeWithScope(context, mode, 'session');
},
})),
completion: async (_context: CommandContext, partialArg: string) => {
const tokens = tokenizeArgs(partialArg);
const hasTrailingSpace = /\s$/.test(partialArg);
const currentSegment = hasTrailingSpace
? ''
: tokens.length > 0
? tokens[tokens.length - 1]
: '';
const normalizedCurrent = normalizeInputMode(currentSegment).replace(
/_/g,
'-',
);
const scopeValues = ['--session', '--project', '--user'];
const normalizeToken = (token: string) =>
normalizeInputMode(token).replace(/_/g, '-');
const normalizedTokens = tokens.map(normalizeToken);
if (tokens.length === 0) {
if (currentSegment.startsWith('-')) {
return scopeValues.filter((scope) => scope.startsWith(currentSegment));
}
return APPROVAL_MODES;
}
if (tokens.length === 1 && !hasTrailingSpace) {
const originalToken = tokens[0];
if (originalToken.startsWith('-')) {
return scopeValues.filter((scope) =>
scope.startsWith(normalizedCurrent),
);
}
return APPROVAL_MODES.filter((mode) =>
mode.startsWith(normalizedCurrent),
);
}
if (tokens.length === 1 && hasTrailingSpace) {
const normalizedFirst = normalizedTokens[0];
if (scopeValues.includes(tokens[0])) {
return APPROVAL_MODES;
}
if (APPROVAL_MODES.includes(normalizedFirst as ApprovalMode)) {
return scopeValues;
}
return APPROVAL_MODES;
}
if (tokens.length === 2 && !hasTrailingSpace) {
const normalizedFirst = normalizedTokens[0];
if (scopeValues.includes(tokens[0])) {
return APPROVAL_MODES.filter((mode) =>
mode.startsWith(normalizedCurrent),
);
}
if (APPROVAL_MODES.includes(normalizedFirst as ApprovalMode)) {
return scopeValues.filter((scope) =>
scope.startsWith(normalizedCurrent),
);
}
return [];
}
return [];
},
};

View File

@@ -21,15 +21,20 @@ export const AutoAcceptIndicator: React.FC<AutoAcceptIndicatorProps> = ({
let subText = '';
switch (approvalMode) {
case ApprovalMode.PLAN:
textColor = Colors.AccentBlue;
textContent = 'plan mode';
subText = ' (shift + tab to cycle)';
break;
case ApprovalMode.AUTO_EDIT:
textColor = Colors.AccentGreen;
textContent = 'accepting edits';
subText = ' (shift + tab to toggle)';
textContent = 'auto-accept edits';
subText = ' (shift + tab to cycle)';
break;
case ApprovalMode.YOLO:
textColor = Colors.AccentRed;
textContent = 'YOLO mode';
subText = ' (ctrl + y to toggle)';
subText = ' (shift + tab to cycle)';
break;
case ApprovalMode.DEFAULT:
default:

View File

@@ -133,12 +133,6 @@ export const Help: React.FC<Help> = ({ commands }) => (
</Text>{' '}
- Open input in external editor
</Text>
<Text color={Colors.Foreground}>
<Text bold color={Colors.AccentPurple}>
Ctrl+Y
</Text>{' '}
- Toggle YOLO mode
</Text>
<Text color={Colors.Foreground}>
<Text bold color={Colors.AccentPurple}>
Enter
@@ -155,7 +149,7 @@ export const Help: React.FC<Help> = ({ commands }) => (
<Text bold color={Colors.AccentPurple}>
Shift+Tab
</Text>{' '}
- Toggle auto-accepting edits
- Cycle approval modes
</Text>
<Text color={Colors.Foreground}>
<Text bold color={Colors.AccentPurple}>

View File

@@ -0,0 +1,41 @@
/**
* @license
* Copyright 2025 Qwen
* SPDX-License-Identifier: Apache-2.0
*/
import type React from 'react';
import { Box, Text } from 'ink';
import { MarkdownDisplay } from '../utils/MarkdownDisplay.js';
import { Colors } from '../colors.js';
import type { PlanResultDisplay } from '@qwen-code/qwen-code-core';
interface PlanSummaryDisplayProps {
data: PlanResultDisplay;
availableHeight?: number;
childWidth: number;
}
export const PlanSummaryDisplay: React.FC<PlanSummaryDisplayProps> = ({
data,
availableHeight,
childWidth,
}) => {
const { message, plan } = data;
return (
<Box flexDirection="column">
<Box marginBottom={1}>
<Text color={Colors.AccentGreen} wrap="wrap">
{message}
</Text>
</Box>
<MarkdownDisplay
text={plan}
isPending={false}
availableTerminalHeight={availableHeight}
terminalWidth={childWidth}
/>
</Box>
);
};

View File

@@ -5,6 +5,7 @@
*/
import { describe, it, expect, vi } from 'vitest';
import { EOL } from 'node:os';
import { ToolConfirmationMessage } from './ToolConfirmationMessage.js';
import type {
ToolCallConfirmationDetails,
@@ -66,6 +67,30 @@ describe('ToolConfirmationMessage', () => {
);
});
it('should render plan confirmation with markdown plan content', () => {
const confirmationDetails: ToolCallConfirmationDetails = {
type: 'plan',
title: 'Would you like to proceed?',
plan: '# Implementation Plan\n- Step one\n- Step two'.replace(/\n/g, EOL),
onConfirm: vi.fn(),
};
const { lastFrame } = renderWithProviders(
<ToolConfirmationMessage
confirmationDetails={confirmationDetails}
config={mockConfig}
availableTerminalHeight={30}
terminalWidth={80}
/>,
);
expect(lastFrame()).toContain('Yes, and auto-accept edits');
expect(lastFrame()).toContain('Yes, and manually approve edits');
expect(lastFrame()).toContain('No, keep planning');
expect(lastFrame()).toContain('Implementation Plan');
expect(lastFrame()).toContain('Step one');
});
describe('with folder trust', () => {
const editConfirmationDetails: ToolCallConfirmationDetails = {
type: 'edit',

View File

@@ -9,6 +9,7 @@ import { Box, Text } from 'ink';
import { DiffRenderer } from './DiffRenderer.js';
import { Colors } from '../../colors.js';
import { RenderInline } from '../../utils/InlineMarkdownRenderer.js';
import { MarkdownDisplay } from '../../utils/MarkdownDisplay.js';
import type {
ToolCallConfirmationDetails,
ToolExecuteConfirmationDetails,
@@ -235,6 +236,33 @@ export const ToolConfirmationMessage: React.FC<
</Box>
</Box>
);
} else if (confirmationDetails.type === 'plan') {
const planProps = confirmationDetails;
question = planProps.title;
options.push({
label: 'Yes, and auto-accept edits',
value: ToolConfirmationOutcome.ProceedAlways,
});
options.push({
label: 'Yes, and manually approve edits',
value: ToolConfirmationOutcome.ProceedOnce,
});
options.push({
label: 'No, keep planning (esc)',
value: ToolConfirmationOutcome.Cancel,
});
bodyContent = (
<Box flexDirection="column" paddingX={1} marginLeft={1}>
<MarkdownDisplay
text={planProps.plan}
isPending={false}
availableTerminalHeight={availableBodyContentHeight()}
terminalWidth={childWidth}
/>
</Box>
);
} else if (confirmationDetails.type === 'info') {
const infoProps = confirmationDetails;
const displayUrls =

View File

@@ -18,9 +18,11 @@ import { TOOL_STATUS } from '../../constants.js';
import type {
TodoResultDisplay,
TaskResultDisplay,
PlanResultDisplay,
Config,
} from '@qwen-code/qwen-code-core';
import { AgentExecutionDisplay } from '../subagents/index.js';
import { PlanSummaryDisplay } from '../PlanSummaryDisplay.js';
const STATIC_HEIGHT = 1;
const RESERVED_LINE_COUNT = 5; // for tool name, status, padding etc.
@@ -35,6 +37,7 @@ export type TextEmphasis = 'high' | 'medium' | 'low';
type DisplayRendererResult =
| { type: 'none' }
| { type: 'todo'; data: TodoResultDisplay }
| { type: 'plan'; data: PlanResultDisplay }
| { type: 'string'; data: string }
| { type: 'diff'; data: { fileDiff: string; fileName: string } }
| { type: 'task'; data: TaskResultDisplay };
@@ -63,6 +66,18 @@ const useResultDisplayRenderer = (
};
}
if (
typeof resultDisplay === 'object' &&
resultDisplay !== null &&
'type' in resultDisplay &&
resultDisplay.type === 'plan_summary'
) {
return {
type: 'plan',
data: resultDisplay as PlanResultDisplay,
};
}
// Check for SubagentExecutionResultDisplay (for non-task tools)
if (
typeof resultDisplay === 'object' &&
@@ -102,6 +117,18 @@ const TodoResultRenderer: React.FC<{ data: TodoResultDisplay }> = ({
data,
}) => <TodoDisplay todos={data.todos} />;
const PlanResultRenderer: React.FC<{
data: PlanResultDisplay;
availableHeight?: number;
childWidth: number;
}> = ({ data, availableHeight, childWidth }) => (
<PlanSummaryDisplay
data={data}
availableHeight={availableHeight}
childWidth={childWidth}
/>
);
/**
* Component to render subagent execution results
*/
@@ -229,6 +256,13 @@ export const ToolMessage: React.FC<ToolMessageProps> = ({
{displayRenderer.type === 'todo' && (
<TodoResultRenderer data={displayRenderer.data} />
)}
{displayRenderer.type === 'plan' && (
<PlanResultRenderer
data={displayRenderer.data}
availableHeight={availableHeight}
childWidth={childWidth}
/>
)}
{displayRenderer.type === 'task' && (
<SubagentExecutionRenderer
data={displayRenderer.data}

View File

@@ -53,7 +53,7 @@ export function AgentsManagerDialog({
const manager = config.getSubagentManager();
// Load agents from all levels separately to show all agents including conflicts
const allAgents = await manager.listSubagents();
const allAgents = await manager.listSubagents({ force: true });
setAvailableAgents(allAgents);
}, [config]);

View File

@@ -158,7 +158,19 @@ describe('useAutoAcceptIndicator', () => {
expect(mockConfigInstance.getApprovalMode).toHaveBeenCalledTimes(1);
});
it('should toggle the indicator and update config when Shift+Tab or Ctrl+Y is pressed', () => {
it('should initialize with ApprovalMode.PLAN if config.getApprovalMode returns ApprovalMode.PLAN', () => {
mockConfigInstance.getApprovalMode.mockReturnValue(ApprovalMode.PLAN);
const { result } = renderHook(() =>
useAutoAcceptIndicator({
config: mockConfigInstance as unknown as ActualConfigType,
addItem: vi.fn(),
}),
);
expect(result.current).toBe(ApprovalMode.PLAN);
expect(mockConfigInstance.getApprovalMode).toHaveBeenCalledTimes(1);
});
it('should cycle approval modes when Shift+Tab is pressed', () => {
mockConfigInstance.getApprovalMode.mockReturnValue(ApprovalMode.DEFAULT);
const { result } = renderHook(() =>
useAutoAcceptIndicator({
@@ -180,23 +192,10 @@ describe('useAutoAcceptIndicator', () => {
expect(result.current).toBe(ApprovalMode.AUTO_EDIT);
act(() => {
capturedUseKeypressHandler({ name: 'y', ctrl: true } as Key);
});
expect(mockConfigInstance.setApprovalMode).toHaveBeenCalledWith(
ApprovalMode.YOLO,
);
expect(result.current).toBe(ApprovalMode.YOLO);
act(() => {
capturedUseKeypressHandler({ name: 'y', ctrl: true } as Key);
});
expect(mockConfigInstance.setApprovalMode).toHaveBeenCalledWith(
ApprovalMode.DEFAULT,
);
expect(result.current).toBe(ApprovalMode.DEFAULT);
act(() => {
capturedUseKeypressHandler({ name: 'y', ctrl: true } as Key);
capturedUseKeypressHandler({
name: 'tab',
shift: true,
} as Key);
});
expect(mockConfigInstance.setApprovalMode).toHaveBeenCalledWith(
ApprovalMode.YOLO,
@@ -210,9 +209,9 @@ describe('useAutoAcceptIndicator', () => {
} as Key);
});
expect(mockConfigInstance.setApprovalMode).toHaveBeenCalledWith(
ApprovalMode.AUTO_EDIT,
ApprovalMode.PLAN,
);
expect(result.current).toBe(ApprovalMode.AUTO_EDIT);
expect(result.current).toBe(ApprovalMode.PLAN);
act(() => {
capturedUseKeypressHandler({
@@ -314,118 +313,10 @@ describe('useAutoAcceptIndicator', () => {
mockConfigInstance.isTrustedFolder.mockReturnValue(false);
});
it('should not enable YOLO mode when Ctrl+Y is pressed', () => {
mockConfigInstance.getApprovalMode.mockReturnValue(ApprovalMode.DEFAULT);
mockConfigInstance.setApprovalMode.mockImplementation(() => {
throw new Error(
'Cannot enable privileged approval modes in an untrusted folder.',
);
});
const mockAddItem = vi.fn();
const { result } = renderHook(() =>
useAutoAcceptIndicator({
config: mockConfigInstance as unknown as ActualConfigType,
addItem: mockAddItem,
}),
);
expect(result.current).toBe(ApprovalMode.DEFAULT);
act(() => {
capturedUseKeypressHandler({ name: 'y', ctrl: true } as Key);
});
// We expect setApprovalMode to be called, and the error to be caught.
expect(mockConfigInstance.setApprovalMode).toHaveBeenCalledWith(
ApprovalMode.YOLO,
);
expect(mockAddItem).toHaveBeenCalled();
// Verify the underlying config value was not changed
expect(mockConfigInstance.getApprovalMode()).toBe(ApprovalMode.DEFAULT);
});
it('should not enable AUTO_EDIT mode when Shift+Tab is pressed', () => {
mockConfigInstance.getApprovalMode.mockReturnValue(ApprovalMode.DEFAULT);
mockConfigInstance.setApprovalMode.mockImplementation(() => {
throw new Error(
'Cannot enable privileged approval modes in an untrusted folder.',
);
});
const mockAddItem = vi.fn();
const { result } = renderHook(() =>
useAutoAcceptIndicator({
config: mockConfigInstance as unknown as ActualConfigType,
addItem: mockAddItem,
}),
);
expect(result.current).toBe(ApprovalMode.DEFAULT);
act(() => {
capturedUseKeypressHandler({
name: 'tab',
shift: true,
} as Key);
});
// We expect setApprovalMode to be called, and the error to be caught.
expect(mockConfigInstance.setApprovalMode).toHaveBeenCalledWith(
ApprovalMode.AUTO_EDIT,
);
expect(mockAddItem).toHaveBeenCalled();
// Verify the underlying config value was not changed
expect(mockConfigInstance.getApprovalMode()).toBe(ApprovalMode.DEFAULT);
});
it('should disable YOLO mode when Ctrl+Y is pressed', () => {
mockConfigInstance.getApprovalMode.mockReturnValue(ApprovalMode.YOLO);
const mockAddItem = vi.fn();
renderHook(() =>
useAutoAcceptIndicator({
config: mockConfigInstance as unknown as ActualConfigType,
addItem: mockAddItem,
}),
);
act(() => {
capturedUseKeypressHandler({ name: 'y', ctrl: true } as Key);
});
expect(mockConfigInstance.setApprovalMode).toHaveBeenCalledWith(
ApprovalMode.DEFAULT,
);
expect(mockConfigInstance.getApprovalMode()).toBe(ApprovalMode.DEFAULT);
});
it('should disable AUTO_EDIT mode when Shift+Tab is pressed', () => {
mockConfigInstance.getApprovalMode.mockReturnValue(
ApprovalMode.AUTO_EDIT,
);
const mockAddItem = vi.fn();
renderHook(() =>
useAutoAcceptIndicator({
config: mockConfigInstance as unknown as ActualConfigType,
addItem: mockAddItem,
}),
);
act(() => {
capturedUseKeypressHandler({
name: 'tab',
shift: true,
} as Key);
});
expect(mockConfigInstance.setApprovalMode).toHaveBeenCalledWith(
ApprovalMode.DEFAULT,
);
expect(mockConfigInstance.getApprovalMode()).toBe(ApprovalMode.DEFAULT);
});
it('should show a warning when trying to enable privileged modes', () => {
// Mock the error thrown by setApprovalMode
it('should show a warning when cycling from DEFAULT to AUTO_EDIT', () => {
const errorMessage =
'Cannot enable privileged approval modes in an untrusted folder.';
mockConfigInstance.getApprovalMode.mockReturnValue(ApprovalMode.DEFAULT);
mockConfigInstance.setApprovalMode.mockImplementation(() => {
throw new Error(errorMessage);
});
@@ -438,11 +329,13 @@ describe('useAutoAcceptIndicator', () => {
}),
);
// Try to enable YOLO mode
act(() => {
capturedUseKeypressHandler({ name: 'y', ctrl: true } as Key);
capturedUseKeypressHandler({ name: 'tab', shift: true } as Key);
});
expect(mockConfigInstance.setApprovalMode).toHaveBeenCalledWith(
ApprovalMode.AUTO_EDIT,
);
expect(mockAddItem).toHaveBeenCalledWith(
{
type: MessageType.INFO,
@@ -450,15 +343,33 @@ describe('useAutoAcceptIndicator', () => {
},
expect.any(Number),
);
});
// Try to enable AUTO_EDIT mode
act(() => {
capturedUseKeypressHandler({
name: 'tab',
shift: true,
} as Key);
it('should show a warning when cycling from AUTO_EDIT to YOLO', () => {
const errorMessage =
'Cannot enable privileged approval modes in an untrusted folder.';
mockConfigInstance.getApprovalMode.mockReturnValue(
ApprovalMode.AUTO_EDIT,
);
mockConfigInstance.setApprovalMode.mockImplementation(() => {
throw new Error(errorMessage);
});
const mockAddItem = vi.fn();
renderHook(() =>
useAutoAcceptIndicator({
config: mockConfigInstance as unknown as ActualConfigType,
addItem: mockAddItem,
}),
);
act(() => {
capturedUseKeypressHandler({ name: 'tab', shift: true } as Key);
});
expect(mockConfigInstance.setApprovalMode).toHaveBeenCalledWith(
ApprovalMode.YOLO,
);
expect(mockAddItem).toHaveBeenCalledWith(
{
type: MessageType.INFO,
@@ -466,8 +377,27 @@ describe('useAutoAcceptIndicator', () => {
},
expect.any(Number),
);
});
expect(mockAddItem).toHaveBeenCalledTimes(2);
it('should cycle from YOLO to PLAN when Shift+Tab is pressed', () => {
mockConfigInstance.getApprovalMode.mockReturnValue(ApprovalMode.YOLO);
const mockAddItem = vi.fn();
renderHook(() =>
useAutoAcceptIndicator({
config: mockConfigInstance as unknown as ActualConfigType,
addItem: mockAddItem,
}),
);
act(() => {
capturedUseKeypressHandler({ name: 'tab', shift: true } as Key);
});
expect(mockConfigInstance.setApprovalMode).toHaveBeenCalledWith(
ApprovalMode.PLAN,
);
expect(mockConfigInstance.getApprovalMode()).toBe(ApprovalMode.PLAN);
expect(mockAddItem).not.toHaveBeenCalled();
});
});
});

View File

@@ -4,7 +4,11 @@
* SPDX-License-Identifier: Apache-2.0
*/
import { ApprovalMode, type Config } from '@qwen-code/qwen-code-core';
import {
type ApprovalMode,
APPROVAL_MODES,
type Config,
} from '@qwen-code/qwen-code-core';
import { useEffect, useState } from 'react';
import { useKeypress } from './useKeypress.js';
import type { HistoryItemWithoutId } from '../types.js';
@@ -29,34 +33,28 @@ export function useAutoAcceptIndicator({
useKeypress(
(key) => {
let nextApprovalMode: ApprovalMode | undefined;
if (key.ctrl && key.name === 'y') {
nextApprovalMode =
config.getApprovalMode() === ApprovalMode.YOLO
? ApprovalMode.DEFAULT
: ApprovalMode.YOLO;
} else if (key.shift && key.name === 'tab') {
nextApprovalMode =
config.getApprovalMode() === ApprovalMode.AUTO_EDIT
? ApprovalMode.DEFAULT
: ApprovalMode.AUTO_EDIT;
if (!(key.shift && key.name === 'tab')) {
return;
}
if (nextApprovalMode) {
try {
config.setApprovalMode(nextApprovalMode);
// Update local state immediately for responsiveness
setShowAutoAcceptIndicator(nextApprovalMode);
} catch (e) {
addItem(
{
type: MessageType.INFO,
text: (e as Error).message,
},
Date.now(),
);
}
const currentMode = config.getApprovalMode();
const currentIndex = APPROVAL_MODES.indexOf(currentMode);
const nextIndex =
currentIndex === -1 ? 0 : (currentIndex + 1) % APPROVAL_MODES.length;
const nextApprovalMode = APPROVAL_MODES[nextIndex];
try {
config.setApprovalMode(nextApprovalMode);
// Update local state immediately for responsiveness
setShowAutoAcceptIndicator(nextApprovalMode);
} catch (e) {
addItem(
{
type: MessageType.INFO,
text: (e as Error).message,
},
Date.now(),
);
}
},
{ isActive: true },

View File

@@ -9,6 +9,27 @@ import { describe, it, expect, vi, beforeEach } from 'vitest';
import { renderHook, act } from '@testing-library/react';
import type { Part, PartListUnion } from '@google/genai';
import { AuthType, type Config, ApprovalMode } from '@qwen-code/qwen-code-core';
// Mock the image format functions from core package
vi.mock('@qwen-code/qwen-code-core', async (importOriginal) => {
const actual = (await importOriginal()) as Record<string, unknown>;
return {
...actual,
isSupportedImageMimeType: vi.fn((mimeType: string) =>
[
'image/png',
'image/jpeg',
'image/jpg',
'image/gif',
'image/webp',
].includes(mimeType),
),
getUnsupportedImageFormatWarning: vi.fn(
() =>
'Only the following image formats are supported: BMP, JPEG, JPG, PNG, TIFF, WEBP, HEIC. Other formats may not work as expected.',
),
};
});
import {
shouldOfferVisionSwitch,
processVisionSwitchOutcome,