mirror of
https://github.com/QwenLM/qwen-code.git
synced 2025-12-20 08:47:44 +00:00
feat: Implement Plan Mode for Safe Code Planning (#658)
This commit is contained in:
@@ -269,7 +269,7 @@ describe('Configuration Integration Tests', () => {
|
||||
parseArguments = parseArgs;
|
||||
});
|
||||
|
||||
it('should parse --approval-mode=auto_edit correctly through the full argument parsing flow', async () => {
|
||||
it('should parse --approval-mode=auto-edit correctly through the full argument parsing flow', async () => {
|
||||
const originalArgv = process.argv;
|
||||
|
||||
try {
|
||||
@@ -277,7 +277,7 @@ describe('Configuration Integration Tests', () => {
|
||||
'node',
|
||||
'script.js',
|
||||
'--approval-mode',
|
||||
'auto_edit',
|
||||
'auto-edit',
|
||||
'-p',
|
||||
'test',
|
||||
];
|
||||
@@ -285,7 +285,30 @@ describe('Configuration Integration Tests', () => {
|
||||
const argv = await parseArguments({} as Settings);
|
||||
|
||||
// Verify that the argument was parsed correctly
|
||||
expect(argv.approvalMode).toBe('auto_edit');
|
||||
expect(argv.approvalMode).toBe('auto-edit');
|
||||
expect(argv.prompt).toBe('test');
|
||||
expect(argv.yolo).toBe(false);
|
||||
} finally {
|
||||
process.argv = originalArgv;
|
||||
}
|
||||
});
|
||||
|
||||
it('should parse --approval-mode=plan correctly through the full argument parsing flow', async () => {
|
||||
const originalArgv = process.argv;
|
||||
|
||||
try {
|
||||
process.argv = [
|
||||
'node',
|
||||
'script.js',
|
||||
'--approval-mode',
|
||||
'plan',
|
||||
'-p',
|
||||
'test',
|
||||
];
|
||||
|
||||
const argv = await parseArguments({} as Settings);
|
||||
|
||||
expect(argv.approvalMode).toBe('plan');
|
||||
expect(argv.prompt).toBe('test');
|
||||
expect(argv.yolo).toBe(false);
|
||||
} finally {
|
||||
|
||||
@@ -262,9 +262,9 @@ describe('parseArguments', () => {
|
||||
});
|
||||
|
||||
it('should allow --approval-mode without --yolo', async () => {
|
||||
process.argv = ['node', 'script.js', '--approval-mode', 'auto_edit'];
|
||||
process.argv = ['node', 'script.js', '--approval-mode', 'auto-edit'];
|
||||
const argv = await parseArguments({} as Settings);
|
||||
expect(argv.approvalMode).toBe('auto_edit');
|
||||
expect(argv.approvalMode).toBe('auto-edit');
|
||||
expect(argv.yolo).toBe(false);
|
||||
});
|
||||
|
||||
@@ -1087,6 +1087,32 @@ describe('Approval mode tool exclusion logic', () => {
|
||||
expect(excludedTools).toContain(WriteFileTool.Name);
|
||||
});
|
||||
|
||||
it('should exclude all interactive tools in non-interactive mode with plan approval mode', async () => {
|
||||
process.argv = [
|
||||
'node',
|
||||
'script.js',
|
||||
'--approval-mode',
|
||||
'plan',
|
||||
'-p',
|
||||
'test',
|
||||
];
|
||||
const argv = await parseArguments({} as Settings);
|
||||
const settings: Settings = {};
|
||||
const extensions: Extension[] = [];
|
||||
|
||||
const config = await loadCliConfig(
|
||||
settings,
|
||||
extensions,
|
||||
'test-session',
|
||||
argv,
|
||||
);
|
||||
|
||||
const excludedTools = config.getExcludeTools();
|
||||
expect(excludedTools).toContain(ShellTool.Name);
|
||||
expect(excludedTools).toContain(EditTool.Name);
|
||||
expect(excludedTools).toContain(WriteFileTool.Name);
|
||||
});
|
||||
|
||||
it('should exclude all interactive tools in non-interactive mode with explicit default approval mode', async () => {
|
||||
process.argv = [
|
||||
'node',
|
||||
@@ -1113,12 +1139,12 @@ describe('Approval mode tool exclusion logic', () => {
|
||||
expect(excludedTools).toContain(WriteFileTool.Name);
|
||||
});
|
||||
|
||||
it('should exclude only shell tools in non-interactive mode with auto_edit approval mode', async () => {
|
||||
it('should exclude only shell tools in non-interactive mode with auto-edit approval mode', async () => {
|
||||
process.argv = [
|
||||
'node',
|
||||
'script.js',
|
||||
'--approval-mode',
|
||||
'auto_edit',
|
||||
'auto-edit',
|
||||
'-p',
|
||||
'test',
|
||||
];
|
||||
@@ -1189,8 +1215,9 @@ describe('Approval mode tool exclusion logic', () => {
|
||||
|
||||
const testCases = [
|
||||
{ args: ['node', 'script.js'] }, // default
|
||||
{ args: ['node', 'script.js', '--approval-mode', 'plan'] },
|
||||
{ args: ['node', 'script.js', '--approval-mode', 'default'] },
|
||||
{ args: ['node', 'script.js', '--approval-mode', 'auto_edit'] },
|
||||
{ args: ['node', 'script.js', '--approval-mode', 'auto-edit'] },
|
||||
{ args: ['node', 'script.js', '--approval-mode', 'yolo'] },
|
||||
{ args: ['node', 'script.js', '--yolo'] },
|
||||
];
|
||||
@@ -1215,12 +1242,12 @@ describe('Approval mode tool exclusion logic', () => {
|
||||
}
|
||||
});
|
||||
|
||||
it('should merge approval mode exclusions with settings exclusions in auto_edit mode', async () => {
|
||||
it('should merge approval mode exclusions with settings exclusions in auto-edit mode', async () => {
|
||||
process.argv = [
|
||||
'node',
|
||||
'script.js',
|
||||
'--approval-mode',
|
||||
'auto_edit',
|
||||
'auto-edit',
|
||||
'-p',
|
||||
'test',
|
||||
];
|
||||
@@ -1238,8 +1265,8 @@ describe('Approval mode tool exclusion logic', () => {
|
||||
const excludedTools = config.getExcludeTools();
|
||||
expect(excludedTools).toContain('custom_tool'); // From settings
|
||||
expect(excludedTools).toContain(ShellTool.Name); // From approval mode
|
||||
expect(excludedTools).not.toContain(EditTool.Name); // Should be allowed in auto_edit
|
||||
expect(excludedTools).not.toContain(WriteFileTool.Name); // Should be allowed in auto_edit
|
||||
expect(excludedTools).not.toContain(EditTool.Name); // Should be allowed in auto-edit
|
||||
expect(excludedTools).not.toContain(WriteFileTool.Name); // Should be allowed in auto-edit
|
||||
});
|
||||
|
||||
it('should throw an error for invalid approval mode values in loadCliConfig', async () => {
|
||||
@@ -1262,7 +1289,7 @@ describe('Approval mode tool exclusion logic', () => {
|
||||
invalidArgv as CliArgs,
|
||||
),
|
||||
).rejects.toThrow(
|
||||
'Invalid approval mode: invalid_mode. Valid values are: yolo, auto_edit, default',
|
||||
'Invalid approval mode: invalid_mode. Valid values are: plan, default, auto-edit, yolo',
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1929,6 +1956,13 @@ describe('loadCliConfig approval mode', () => {
|
||||
expect(config.getApprovalMode()).toBe(ServerConfig.ApprovalMode.DEFAULT);
|
||||
});
|
||||
|
||||
it('should set PLAN approval mode when --approval-mode=plan', async () => {
|
||||
process.argv = ['node', 'script.js', '--approval-mode', 'plan'];
|
||||
const argv = await parseArguments({} as Settings);
|
||||
const config = await loadCliConfig({}, [], 'test-session', argv);
|
||||
expect(config.getApprovalMode()).toBe(ServerConfig.ApprovalMode.PLAN);
|
||||
});
|
||||
|
||||
it('should set YOLO approval mode when --yolo flag is used', async () => {
|
||||
process.argv = ['node', 'script.js', '--yolo'];
|
||||
const argv = await parseArguments({} as Settings);
|
||||
@@ -1950,8 +1984,8 @@ describe('loadCliConfig approval mode', () => {
|
||||
expect(config.getApprovalMode()).toBe(ServerConfig.ApprovalMode.DEFAULT);
|
||||
});
|
||||
|
||||
it('should set AUTO_EDIT approval mode when --approval-mode=auto_edit', async () => {
|
||||
process.argv = ['node', 'script.js', '--approval-mode', 'auto_edit'];
|
||||
it('should set AUTO_EDIT approval mode when --approval-mode=auto-edit', async () => {
|
||||
process.argv = ['node', 'script.js', '--approval-mode', 'auto-edit'];
|
||||
const argv = await parseArguments({} as Settings);
|
||||
const config = await loadCliConfig({}, [], 'test-session', argv);
|
||||
expect(config.getApprovalMode()).toBe(ServerConfig.ApprovalMode.AUTO_EDIT);
|
||||
@@ -1964,6 +1998,33 @@ describe('loadCliConfig approval mode', () => {
|
||||
expect(config.getApprovalMode()).toBe(ServerConfig.ApprovalMode.YOLO);
|
||||
});
|
||||
|
||||
it('should use approval mode from settings when CLI flags are not provided', async () => {
|
||||
process.argv = ['node', 'script.js'];
|
||||
const argv = await parseArguments({} as Settings);
|
||||
const settings: Settings = { approvalMode: 'plan' };
|
||||
const config = await loadCliConfig(settings, [], 'test-session', argv);
|
||||
expect(config.getApprovalMode()).toBe(ServerConfig.ApprovalMode.PLAN);
|
||||
});
|
||||
|
||||
it('should normalize approval mode values from settings', async () => {
|
||||
process.argv = ['node', 'script.js'];
|
||||
const argv = await parseArguments({} as Settings);
|
||||
const settings: Settings = { approvalMode: 'AutoEdit' };
|
||||
const config = await loadCliConfig(settings, [], 'test-session', argv);
|
||||
expect(config.getApprovalMode()).toBe(ServerConfig.ApprovalMode.AUTO_EDIT);
|
||||
});
|
||||
|
||||
it('should throw when approval mode in settings is invalid', async () => {
|
||||
process.argv = ['node', 'script.js'];
|
||||
const argv = await parseArguments({} as Settings);
|
||||
const settings: Settings = { approvalMode: 'invalid_mode' };
|
||||
await expect(
|
||||
loadCliConfig(settings, [], 'test-session', argv),
|
||||
).rejects.toThrow(
|
||||
'Invalid approval mode: invalid_mode. Valid values are: plan, default, auto-edit, yolo',
|
||||
);
|
||||
});
|
||||
|
||||
it('should prioritize --approval-mode over --yolo when both would be valid (but validation prevents this)', async () => {
|
||||
// Note: This test documents the intended behavior, but in practice the validation
|
||||
// prevents both flags from being used together
|
||||
@@ -1995,8 +2056,8 @@ describe('loadCliConfig approval mode', () => {
|
||||
expect(config.getApprovalMode()).toBe(ServerConfig.ApprovalMode.DEFAULT);
|
||||
});
|
||||
|
||||
it('should override --approval-mode=auto_edit to DEFAULT', async () => {
|
||||
process.argv = ['node', 'script.js', '--approval-mode', 'auto_edit'];
|
||||
it('should override --approval-mode=auto-edit to DEFAULT', async () => {
|
||||
process.argv = ['node', 'script.js', '--approval-mode', 'auto-edit'];
|
||||
const argv = await parseArguments({} as Settings);
|
||||
const config = await loadCliConfig({}, [], 'test-session', argv);
|
||||
expect(config.getApprovalMode()).toBe(ServerConfig.ApprovalMode.DEFAULT);
|
||||
@@ -2015,6 +2076,13 @@ describe('loadCliConfig approval mode', () => {
|
||||
const config = await loadCliConfig({}, [], 'test-session', argv);
|
||||
expect(config.getApprovalMode()).toBe(ServerConfig.ApprovalMode.DEFAULT);
|
||||
});
|
||||
|
||||
it('should allow PLAN approval mode in untrusted folders', async () => {
|
||||
process.argv = ['node', 'script.js', '--approval-mode', 'plan'];
|
||||
const argv = await parseArguments({} as Settings);
|
||||
const config = await loadCliConfig({}, [], 'test-session', argv);
|
||||
expect(config.getApprovalMode()).toBe(ServerConfig.ApprovalMode.PLAN);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -52,6 +52,39 @@ const logger = {
|
||||
error: (...args: any[]) => console.error('[ERROR]', ...args),
|
||||
};
|
||||
|
||||
const VALID_APPROVAL_MODE_VALUES = [
|
||||
'plan',
|
||||
'default',
|
||||
'auto-edit',
|
||||
'yolo',
|
||||
] as const;
|
||||
|
||||
function formatApprovalModeError(value: string): Error {
|
||||
return new Error(
|
||||
`Invalid approval mode: ${value}. Valid values are: ${VALID_APPROVAL_MODE_VALUES.join(
|
||||
', ',
|
||||
)}`,
|
||||
);
|
||||
}
|
||||
|
||||
function parseApprovalModeValue(value: string): ApprovalMode {
|
||||
const normalized = value.trim().toLowerCase();
|
||||
switch (normalized) {
|
||||
case 'plan':
|
||||
return ApprovalMode.PLAN;
|
||||
case 'default':
|
||||
return ApprovalMode.DEFAULT;
|
||||
case 'yolo':
|
||||
return ApprovalMode.YOLO;
|
||||
case 'auto_edit':
|
||||
case 'autoedit':
|
||||
case 'auto-edit':
|
||||
return ApprovalMode.AUTO_EDIT;
|
||||
default:
|
||||
throw formatApprovalModeError(value);
|
||||
}
|
||||
}
|
||||
|
||||
export interface CliArgs {
|
||||
model: string | undefined;
|
||||
sandbox: boolean | string | undefined;
|
||||
@@ -147,9 +180,9 @@ export async function parseArguments(settings: Settings): Promise<CliArgs> {
|
||||
})
|
||||
.option('approval-mode', {
|
||||
type: 'string',
|
||||
choices: ['default', 'auto_edit', 'yolo'],
|
||||
choices: ['plan', 'default', 'auto-edit', 'yolo'],
|
||||
description:
|
||||
'Set the approval mode: default (prompt for approval), auto_edit (auto-approve edit tools), yolo (auto-approve all tools)',
|
||||
'Set the approval mode: plan (plan only), default (prompt for approval), auto-edit (auto-approve edit tools), yolo (auto-approve all tools)',
|
||||
})
|
||||
.option('telemetry', {
|
||||
type: 'boolean',
|
||||
@@ -438,30 +471,21 @@ export async function loadCliConfig(
|
||||
// Determine approval mode with backward compatibility
|
||||
let approvalMode: ApprovalMode;
|
||||
if (argv.approvalMode) {
|
||||
// New --approval-mode flag takes precedence
|
||||
switch (argv.approvalMode) {
|
||||
case 'yolo':
|
||||
approvalMode = ApprovalMode.YOLO;
|
||||
break;
|
||||
case 'auto_edit':
|
||||
approvalMode = ApprovalMode.AUTO_EDIT;
|
||||
break;
|
||||
case 'default':
|
||||
approvalMode = ApprovalMode.DEFAULT;
|
||||
break;
|
||||
default:
|
||||
throw new Error(
|
||||
`Invalid approval mode: ${argv.approvalMode}. Valid values are: yolo, auto_edit, default`,
|
||||
);
|
||||
}
|
||||
approvalMode = parseApprovalModeValue(argv.approvalMode);
|
||||
} else if (argv.yolo) {
|
||||
approvalMode = ApprovalMode.YOLO;
|
||||
} else if (settings.approvalMode) {
|
||||
approvalMode = parseApprovalModeValue(settings.approvalMode);
|
||||
} else {
|
||||
// Fallback to legacy --yolo flag behavior
|
||||
approvalMode =
|
||||
argv.yolo || false ? ApprovalMode.YOLO : ApprovalMode.DEFAULT;
|
||||
approvalMode = ApprovalMode.DEFAULT;
|
||||
}
|
||||
|
||||
// Force approval mode to default if the folder is not trusted.
|
||||
if (!trustedFolder && approvalMode !== ApprovalMode.DEFAULT) {
|
||||
if (
|
||||
!trustedFolder &&
|
||||
approvalMode !== ApprovalMode.DEFAULT &&
|
||||
approvalMode !== ApprovalMode.PLAN
|
||||
) {
|
||||
logger.warn(
|
||||
`Approval mode overridden to "default" because the current folder is not trusted.`,
|
||||
);
|
||||
@@ -474,6 +498,7 @@ export async function loadCliConfig(
|
||||
const extraExcludes: string[] = [];
|
||||
if (!interactive && !argv.experimentalAcp) {
|
||||
switch (approvalMode) {
|
||||
case ApprovalMode.PLAN:
|
||||
case ApprovalMode.DEFAULT:
|
||||
// In default non-interactive mode, all tools that require approval are excluded.
|
||||
extraExcludes.push(ShellTool.Name, EditTool.Name, WriteFileTool.Name);
|
||||
|
||||
@@ -892,6 +892,16 @@ export const SETTINGS_SCHEMA = {
|
||||
description: 'Disable all loop detection checks (streaming and LLM).',
|
||||
showInDialog: true,
|
||||
},
|
||||
approvalMode: {
|
||||
type: 'string',
|
||||
label: 'Default Approval Mode',
|
||||
category: 'General',
|
||||
requiresRestart: false,
|
||||
default: 'default',
|
||||
description:
|
||||
'Default approval mode for tool usage. Valid values: plan, default, auto-edit, yolo.',
|
||||
showInDialog: true,
|
||||
},
|
||||
enableWelcomeBack: {
|
||||
type: 'boolean',
|
||||
label: 'Enable Welcome Back',
|
||||
|
||||
@@ -15,6 +15,14 @@ vi.mock('../ui/commands/aboutCommand.js', async () => {
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('../ui/commands/approvalModeCommand.js', () => ({
|
||||
approvalModeCommand: {
|
||||
name: 'approval-mode',
|
||||
description: 'Approval mode command',
|
||||
kind: 'built-in',
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('../ui/commands/ideCommand.js', () => ({ ideCommand: vi.fn() }));
|
||||
vi.mock('../ui/commands/restoreCommand.js', () => ({
|
||||
restoreCommand: vi.fn(),
|
||||
@@ -128,6 +136,10 @@ describe('BuiltinCommandLoader', () => {
|
||||
expect(aboutCmd).toBeDefined();
|
||||
expect(aboutCmd?.kind).toBe(CommandKind.BUILT_IN);
|
||||
|
||||
const approvalModeCmd = commands.find((c) => c.name === 'approval-mode');
|
||||
expect(approvalModeCmd).toBeDefined();
|
||||
expect(approvalModeCmd?.kind).toBe(CommandKind.BUILT_IN);
|
||||
|
||||
const ideCmd = commands.find((c) => c.name === 'ide');
|
||||
expect(ideCmd).toBeDefined();
|
||||
|
||||
|
||||
@@ -8,6 +8,8 @@ import type { ICommandLoader } from './types.js';
|
||||
import type { SlashCommand } from '../ui/commands/types.js';
|
||||
import type { Config } from '@qwen-code/qwen-code-core';
|
||||
import { aboutCommand } from '../ui/commands/aboutCommand.js';
|
||||
import { agentsCommand } from '../ui/commands/agentsCommand.js';
|
||||
import { approvalModeCommand } from '../ui/commands/approvalModeCommand.js';
|
||||
import { authCommand } from '../ui/commands/authCommand.js';
|
||||
import { bugCommand } from '../ui/commands/bugCommand.js';
|
||||
import { chatCommand } from '../ui/commands/chatCommand.js';
|
||||
@@ -24,19 +26,18 @@ import { ideCommand } from '../ui/commands/ideCommand.js';
|
||||
import { initCommand } from '../ui/commands/initCommand.js';
|
||||
import { mcpCommand } from '../ui/commands/mcpCommand.js';
|
||||
import { memoryCommand } from '../ui/commands/memoryCommand.js';
|
||||
import { modelCommand } from '../ui/commands/modelCommand.js';
|
||||
import { privacyCommand } from '../ui/commands/privacyCommand.js';
|
||||
import { quitCommand, quitConfirmCommand } from '../ui/commands/quitCommand.js';
|
||||
import { restoreCommand } from '../ui/commands/restoreCommand.js';
|
||||
import { settingsCommand } from '../ui/commands/settingsCommand.js';
|
||||
import { statsCommand } from '../ui/commands/statsCommand.js';
|
||||
import { summaryCommand } from '../ui/commands/summaryCommand.js';
|
||||
import { terminalSetupCommand } from '../ui/commands/terminalSetupCommand.js';
|
||||
import { themeCommand } from '../ui/commands/themeCommand.js';
|
||||
import { toolsCommand } from '../ui/commands/toolsCommand.js';
|
||||
import { settingsCommand } from '../ui/commands/settingsCommand.js';
|
||||
import { vimCommand } from '../ui/commands/vimCommand.js';
|
||||
import { setupGithubCommand } from '../ui/commands/setupGithubCommand.js';
|
||||
import { terminalSetupCommand } from '../ui/commands/terminalSetupCommand.js';
|
||||
import { modelCommand } from '../ui/commands/modelCommand.js';
|
||||
import { agentsCommand } from '../ui/commands/agentsCommand.js';
|
||||
|
||||
/**
|
||||
* Loads the core, hard-coded slash commands that are an integral part
|
||||
@@ -56,6 +57,7 @@ export class BuiltinCommandLoader implements ICommandLoader {
|
||||
const allDefinitions: Array<SlashCommand | null> = [
|
||||
aboutCommand,
|
||||
agentsCommand,
|
||||
approvalModeCommand,
|
||||
authCommand,
|
||||
bugCommand,
|
||||
chatCommand,
|
||||
|
||||
@@ -35,7 +35,10 @@ export const createMockCommandContext = (
|
||||
},
|
||||
services: {
|
||||
config: null,
|
||||
settings: { merged: {} } as LoadedSettings,
|
||||
settings: {
|
||||
merged: {},
|
||||
setValue: vi.fn(),
|
||||
} as unknown as LoadedSettings,
|
||||
git: undefined as GitService | undefined,
|
||||
logger: {
|
||||
log: vi.fn(),
|
||||
|
||||
495
packages/cli/src/ui/commands/approvalModeCommand.test.ts
Normal file
495
packages/cli/src/ui/commands/approvalModeCommand.test.ts
Normal 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([]);
|
||||
});
|
||||
});
|
||||
434
packages/cli/src/ui/commands/approvalModeCommand.ts
Normal file
434
packages/cli/src/ui/commands/approvalModeCommand.ts
Normal 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 [];
|
||||
},
|
||||
};
|
||||
@@ -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:
|
||||
|
||||
@@ -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}>
|
||||
|
||||
41
packages/cli/src/ui/components/PlanSummaryDisplay.tsx
Normal file
41
packages/cli/src/ui/components/PlanSummaryDisplay.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
@@ -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',
|
||||
|
||||
@@ -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 =
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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]);
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -883,6 +883,16 @@ function toToolCallContent(toolResult: ToolResult): acp.ToolCallContent | null {
|
||||
type: 'content',
|
||||
content: { type: 'text', text: todoText },
|
||||
};
|
||||
} else if (
|
||||
'type' in toolResult.returnDisplay &&
|
||||
toolResult.returnDisplay.type === 'plan_summary'
|
||||
) {
|
||||
const planDisplay = toolResult.returnDisplay;
|
||||
const planText = `${planDisplay.message}\n\n${planDisplay.plan}`;
|
||||
return {
|
||||
type: 'content',
|
||||
content: { type: 'text', text: planText },
|
||||
};
|
||||
} else if ('fileDiff' in toolResult.returnDisplay) {
|
||||
// Handle FileDiff
|
||||
return {
|
||||
@@ -954,6 +964,15 @@ function toPermissionOptions(
|
||||
},
|
||||
...basicPermissionOptions,
|
||||
];
|
||||
case 'plan':
|
||||
return [
|
||||
{
|
||||
optionId: ToolConfirmationOutcome.ProceedAlways,
|
||||
name: `Always Allow Plans`,
|
||||
kind: 'allow_always',
|
||||
},
|
||||
...basicPermissionOptions,
|
||||
];
|
||||
default: {
|
||||
const unreachable: never = confirmation;
|
||||
throw new Error(`Unexpected: ${unreachable}`);
|
||||
|
||||
Reference in New Issue
Block a user