mirror of
https://github.com/QwenLM/qwen-code.git
synced 2025-12-19 09:33:53 +00:00
Add Interactive Approval Mode Dialog (#1012)
This commit is contained in:
@@ -12,6 +12,7 @@ import type {
|
|||||||
ChatCompressionSettings,
|
ChatCompressionSettings,
|
||||||
} from '@qwen-code/qwen-code-core';
|
} from '@qwen-code/qwen-code-core';
|
||||||
import {
|
import {
|
||||||
|
ApprovalMode,
|
||||||
DEFAULT_TRUNCATE_TOOL_OUTPUT_LINES,
|
DEFAULT_TRUNCATE_TOOL_OUTPUT_LINES,
|
||||||
DEFAULT_TRUNCATE_TOOL_OUTPUT_THRESHOLD,
|
DEFAULT_TRUNCATE_TOOL_OUTPUT_THRESHOLD,
|
||||||
} from '@qwen-code/qwen-code-core';
|
} from '@qwen-code/qwen-code-core';
|
||||||
@@ -830,14 +831,20 @@ const SETTINGS_SCHEMA = {
|
|||||||
mergeStrategy: MergeStrategy.UNION,
|
mergeStrategy: MergeStrategy.UNION,
|
||||||
},
|
},
|
||||||
approvalMode: {
|
approvalMode: {
|
||||||
type: 'string',
|
type: 'enum',
|
||||||
label: 'Default Approval Mode',
|
label: 'Approval Mode',
|
||||||
category: 'Tools',
|
category: 'Tools',
|
||||||
requiresRestart: false,
|
requiresRestart: false,
|
||||||
default: 'default',
|
default: ApprovalMode.DEFAULT,
|
||||||
description:
|
description:
|
||||||
'Default approval mode for tool usage. Valid values: plan, default, auto-edit, yolo.',
|
'Approval mode for tool usage. Controls how tools are approved before execution.',
|
||||||
showInDialog: true,
|
showInDialog: true,
|
||||||
|
options: [
|
||||||
|
{ value: ApprovalMode.PLAN, label: 'Plan' },
|
||||||
|
{ value: ApprovalMode.DEFAULT, label: 'Default' },
|
||||||
|
{ value: ApprovalMode.AUTO_EDIT, label: 'Auto Edit' },
|
||||||
|
{ value: ApprovalMode.YOLO, label: 'YOLO' },
|
||||||
|
],
|
||||||
},
|
},
|
||||||
discoveryCommand: {
|
discoveryCommand: {
|
||||||
type: 'string',
|
type: 'string',
|
||||||
|
|||||||
@@ -53,6 +53,7 @@ import { useQuotaAndFallback } from './hooks/useQuotaAndFallback.js';
|
|||||||
import { useEditorSettings } from './hooks/useEditorSettings.js';
|
import { useEditorSettings } from './hooks/useEditorSettings.js';
|
||||||
import { useSettingsCommand } from './hooks/useSettingsCommand.js';
|
import { useSettingsCommand } from './hooks/useSettingsCommand.js';
|
||||||
import { useModelCommand } from './hooks/useModelCommand.js';
|
import { useModelCommand } from './hooks/useModelCommand.js';
|
||||||
|
import { useApprovalModeCommand } from './hooks/useApprovalModeCommand.js';
|
||||||
import { useSlashCommandProcessor } from './hooks/slashCommandProcessor.js';
|
import { useSlashCommandProcessor } from './hooks/slashCommandProcessor.js';
|
||||||
import { useVimMode } from './contexts/VimModeContext.js';
|
import { useVimMode } from './contexts/VimModeContext.js';
|
||||||
import { useConsoleMessages } from './hooks/useConsoleMessages.js';
|
import { useConsoleMessages } from './hooks/useConsoleMessages.js';
|
||||||
@@ -335,6 +336,12 @@ export const AppContainer = (props: AppContainerProps) => {
|
|||||||
initializationResult.themeError,
|
initializationResult.themeError,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const {
|
||||||
|
isApprovalModeDialogOpen,
|
||||||
|
openApprovalModeDialog,
|
||||||
|
handleApprovalModeSelect,
|
||||||
|
} = useApprovalModeCommand(settings, config);
|
||||||
|
|
||||||
const {
|
const {
|
||||||
setAuthState,
|
setAuthState,
|
||||||
authError,
|
authError,
|
||||||
@@ -470,6 +477,7 @@ export const AppContainer = (props: AppContainerProps) => {
|
|||||||
openSettingsDialog,
|
openSettingsDialog,
|
||||||
openModelDialog,
|
openModelDialog,
|
||||||
openPermissionsDialog,
|
openPermissionsDialog,
|
||||||
|
openApprovalModeDialog,
|
||||||
quit: (messages: HistoryItem[]) => {
|
quit: (messages: HistoryItem[]) => {
|
||||||
setQuittingMessages(messages);
|
setQuittingMessages(messages);
|
||||||
setTimeout(async () => {
|
setTimeout(async () => {
|
||||||
@@ -495,6 +503,7 @@ export const AppContainer = (props: AppContainerProps) => {
|
|||||||
setCorgiMode,
|
setCorgiMode,
|
||||||
dispatchExtensionStateUpdate,
|
dispatchExtensionStateUpdate,
|
||||||
openPermissionsDialog,
|
openPermissionsDialog,
|
||||||
|
openApprovalModeDialog,
|
||||||
addConfirmUpdateExtensionRequest,
|
addConfirmUpdateExtensionRequest,
|
||||||
showQuitConfirmation,
|
showQuitConfirmation,
|
||||||
openSubagentCreateDialog,
|
openSubagentCreateDialog,
|
||||||
@@ -939,6 +948,8 @@ export const AppContainer = (props: AppContainerProps) => {
|
|||||||
const { closeAnyOpenDialog } = useDialogClose({
|
const { closeAnyOpenDialog } = useDialogClose({
|
||||||
isThemeDialogOpen,
|
isThemeDialogOpen,
|
||||||
handleThemeSelect,
|
handleThemeSelect,
|
||||||
|
isApprovalModeDialogOpen,
|
||||||
|
handleApprovalModeSelect,
|
||||||
isAuthDialogOpen,
|
isAuthDialogOpen,
|
||||||
handleAuthSelect,
|
handleAuthSelect,
|
||||||
selectedAuthType: settings.merged.security?.auth?.selectedType,
|
selectedAuthType: settings.merged.security?.auth?.selectedType,
|
||||||
@@ -1188,7 +1199,8 @@ export const AppContainer = (props: AppContainerProps) => {
|
|||||||
showIdeRestartPrompt ||
|
showIdeRestartPrompt ||
|
||||||
!!proQuotaRequest ||
|
!!proQuotaRequest ||
|
||||||
isSubagentCreateDialogOpen ||
|
isSubagentCreateDialogOpen ||
|
||||||
isAgentsManagerDialogOpen;
|
isAgentsManagerDialogOpen ||
|
||||||
|
isApprovalModeDialogOpen;
|
||||||
|
|
||||||
const pendingHistoryItems = useMemo(
|
const pendingHistoryItems = useMemo(
|
||||||
() => [...pendingSlashCommandHistoryItems, ...pendingGeminiHistoryItems],
|
() => [...pendingSlashCommandHistoryItems, ...pendingGeminiHistoryItems],
|
||||||
@@ -1219,6 +1231,7 @@ export const AppContainer = (props: AppContainerProps) => {
|
|||||||
isSettingsDialogOpen,
|
isSettingsDialogOpen,
|
||||||
isModelDialogOpen,
|
isModelDialogOpen,
|
||||||
isPermissionsDialogOpen,
|
isPermissionsDialogOpen,
|
||||||
|
isApprovalModeDialogOpen,
|
||||||
slashCommands,
|
slashCommands,
|
||||||
pendingSlashCommandHistoryItems,
|
pendingSlashCommandHistoryItems,
|
||||||
commandContext,
|
commandContext,
|
||||||
@@ -1313,6 +1326,7 @@ export const AppContainer = (props: AppContainerProps) => {
|
|||||||
isSettingsDialogOpen,
|
isSettingsDialogOpen,
|
||||||
isModelDialogOpen,
|
isModelDialogOpen,
|
||||||
isPermissionsDialogOpen,
|
isPermissionsDialogOpen,
|
||||||
|
isApprovalModeDialogOpen,
|
||||||
slashCommands,
|
slashCommands,
|
||||||
pendingSlashCommandHistoryItems,
|
pendingSlashCommandHistoryItems,
|
||||||
commandContext,
|
commandContext,
|
||||||
@@ -1393,6 +1407,7 @@ export const AppContainer = (props: AppContainerProps) => {
|
|||||||
() => ({
|
() => ({
|
||||||
handleThemeSelect,
|
handleThemeSelect,
|
||||||
handleThemeHighlight,
|
handleThemeHighlight,
|
||||||
|
handleApprovalModeSelect,
|
||||||
handleAuthSelect,
|
handleAuthSelect,
|
||||||
setAuthState,
|
setAuthState,
|
||||||
onAuthError,
|
onAuthError,
|
||||||
@@ -1428,6 +1443,7 @@ export const AppContainer = (props: AppContainerProps) => {
|
|||||||
[
|
[
|
||||||
handleThemeSelect,
|
handleThemeSelect,
|
||||||
handleThemeHighlight,
|
handleThemeHighlight,
|
||||||
|
handleApprovalModeSelect,
|
||||||
handleAuthSelect,
|
handleAuthSelect,
|
||||||
setAuthState,
|
setAuthState,
|
||||||
onAuthError,
|
onAuthError,
|
||||||
|
|||||||
@@ -4,492 +4,68 @@
|
|||||||
* SPDX-License-Identifier: Apache-2.0
|
* SPDX-License-Identifier: Apache-2.0
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
import { describe, it, expect } from 'vitest';
|
||||||
import { approvalModeCommand } from './approvalModeCommand.js';
|
import { approvalModeCommand } from './approvalModeCommand.js';
|
||||||
import {
|
import {
|
||||||
type CommandContext,
|
type CommandContext,
|
||||||
CommandKind,
|
CommandKind,
|
||||||
type MessageActionReturn,
|
type OpenDialogActionReturn,
|
||||||
} from './types.js';
|
} from './types.js';
|
||||||
import { createMockCommandContext } from '../../test-utils/mockCommandContext.js';
|
import { createMockCommandContext } from '../../test-utils/mockCommandContext.js';
|
||||||
import { ApprovalMode } from '@qwen-code/qwen-code-core';
|
import type { LoadedSettings } from '../../config/settings.js';
|
||||||
import { SettingScope, type LoadedSettings } from '../../config/settings.js';
|
|
||||||
|
|
||||||
describe('approvalModeCommand', () => {
|
describe('approvalModeCommand', () => {
|
||||||
let mockContext: CommandContext;
|
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(() => {
|
beforeEach(() => {
|
||||||
setApprovalModeMock = vi.fn();
|
|
||||||
setSettingsValueMock = vi.fn();
|
|
||||||
|
|
||||||
mockContext = createMockCommandContext({
|
mockContext = createMockCommandContext({
|
||||||
services: {
|
services: {
|
||||||
config: {
|
config: {
|
||||||
getApprovalMode: vi.fn().mockReturnValue(ApprovalMode.DEFAULT),
|
getApprovalMode: () => 'default',
|
||||||
setApprovalMode: setApprovalModeMock,
|
setApprovalMode: () => {},
|
||||||
},
|
},
|
||||||
settings: {
|
settings: {
|
||||||
merged: {},
|
merged: {},
|
||||||
setValue: setSettingsValueMock,
|
setValue: () => {},
|
||||||
forScope: vi
|
forScope: () => ({}),
|
||||||
.fn()
|
|
||||||
.mockImplementation((scope: SettingScope) =>
|
|
||||||
scope === SettingScope.User
|
|
||||||
? userSettingsFile
|
|
||||||
: scope === SettingScope.Workspace
|
|
||||||
? projectSettingsFile
|
|
||||||
: { path: '', settings: {} },
|
|
||||||
),
|
|
||||||
} as unknown as LoadedSettings,
|
} as unknown as LoadedSettings,
|
||||||
},
|
},
|
||||||
} as unknown as CommandContext);
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(() => {
|
it('should have correct metadata', () => {
|
||||||
process.env = { ...originalEnv };
|
|
||||||
vi.clearAllMocks();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should have the correct command properties', () => {
|
|
||||||
expect(approvalModeCommand.name).toBe('approval-mode');
|
expect(approvalModeCommand.name).toBe('approval-mode');
|
||||||
expect(approvalModeCommand.kind).toBe(CommandKind.BUILT_IN);
|
|
||||||
expect(approvalModeCommand.description).toBe(
|
expect(approvalModeCommand.description).toBe(
|
||||||
'View or change the approval mode for tool usage',
|
'View or change the approval mode for tool usage',
|
||||||
);
|
);
|
||||||
|
expect(approvalModeCommand.kind).toBe(CommandKind.BUILT_IN);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should show current mode, options, and usage when no arguments provided', async () => {
|
it('should open approval mode dialog when invoked', async () => {
|
||||||
if (!approvalModeCommand.action) {
|
const result = (await approvalModeCommand.action?.(
|
||||||
throw new Error('approvalModeCommand must have an action.');
|
|
||||||
}
|
|
||||||
|
|
||||||
const result = (await approvalModeCommand.action(
|
|
||||||
mockContext,
|
mockContext,
|
||||||
'',
|
'',
|
||||||
)) as MessageActionReturn;
|
)) as OpenDialogActionReturn;
|
||||||
|
|
||||||
expect(result.type).toBe('message');
|
expect(result.type).toBe('dialog');
|
||||||
expect(result.messageType).toBe('info');
|
expect(result.dialog).toBe('approval-mode');
|
||||||
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 () => {
|
it('should open approval mode dialog with arguments (ignored)', async () => {
|
||||||
if (!approvalModeCommand.action) {
|
const result = (await approvalModeCommand.action?.(
|
||||||
throw new Error('approvalModeCommand must have an action.');
|
mockContext,
|
||||||
}
|
'some arguments',
|
||||||
|
)) as OpenDialogActionReturn;
|
||||||
|
|
||||||
const nullConfigContext = createMockCommandContext({
|
expect(result.type).toBe('dialog');
|
||||||
services: {
|
expect(result.dialog).toBe('approval-mode');
|
||||||
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 () => {
|
it('should not have subcommands', () => {
|
||||||
if (!approvalModeCommand.action) {
|
expect(approvalModeCommand.subCommands).toBeUndefined();
|
||||||
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 () => {
|
it('should not have completion function', () => {
|
||||||
if (!approvalModeCommand.action) {
|
expect(approvalModeCommand.completion).toBeUndefined();
|
||||||
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([]);
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -7,428 +7,19 @@
|
|||||||
import type {
|
import type {
|
||||||
SlashCommand,
|
SlashCommand,
|
||||||
CommandContext,
|
CommandContext,
|
||||||
MessageActionReturn,
|
OpenDialogActionReturn,
|
||||||
} from './types.js';
|
} from './types.js';
|
||||||
import { CommandKind } 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 = {
|
export const approvalModeCommand: SlashCommand = {
|
||||||
name: 'approval-mode',
|
name: 'approval-mode',
|
||||||
description: 'View or change the approval mode for tool usage',
|
description: 'View or change the approval mode for tool usage',
|
||||||
kind: CommandKind.BUILT_IN,
|
kind: CommandKind.BUILT_IN,
|
||||||
action: async (
|
action: async (
|
||||||
context: CommandContext,
|
_context: CommandContext,
|
||||||
args: string,
|
_args: string,
|
||||||
): Promise<MessageActionReturn> => {
|
): Promise<OpenDialogActionReturn> => ({
|
||||||
const { config } = context.services;
|
type: 'dialog',
|
||||||
if (!config) {
|
dialog: 'approval-mode',
|
||||||
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 [];
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -129,7 +129,8 @@ export interface OpenDialogActionReturn {
|
|||||||
| 'model'
|
| 'model'
|
||||||
| 'subagent_create'
|
| 'subagent_create'
|
||||||
| 'subagent_list'
|
| 'subagent_list'
|
||||||
| 'permissions';
|
| 'permissions'
|
||||||
|
| 'approval-mode';
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
183
packages/cli/src/ui/components/ApprovalModeDialog.tsx
Normal file
183
packages/cli/src/ui/components/ApprovalModeDialog.tsx
Normal file
@@ -0,0 +1,183 @@
|
|||||||
|
/**
|
||||||
|
* @license
|
||||||
|
* Copyright 2025 Qwen
|
||||||
|
* SPDX-License-Identifier: Apache-2.0
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type React from 'react';
|
||||||
|
import { useCallback, useState } from 'react';
|
||||||
|
import { Box, Text } from 'ink';
|
||||||
|
import { theme } from '../semantic-colors.js';
|
||||||
|
import { ApprovalMode, APPROVAL_MODES } from '@qwen-code/qwen-code-core';
|
||||||
|
import { RadioButtonSelect } from './shared/RadioButtonSelect.js';
|
||||||
|
import type { LoadedSettings } from '../../config/settings.js';
|
||||||
|
import { SettingScope } from '../../config/settings.js';
|
||||||
|
import { getScopeMessageForSetting } from '../../utils/dialogScopeUtils.js';
|
||||||
|
import { useKeypress } from '../hooks/useKeypress.js';
|
||||||
|
import { ScopeSelector } from './shared/ScopeSelector.js';
|
||||||
|
|
||||||
|
interface ApprovalModeDialogProps {
|
||||||
|
/** Callback function when an approval mode is selected */
|
||||||
|
onSelect: (mode: ApprovalMode | undefined, scope: SettingScope) => void;
|
||||||
|
|
||||||
|
/** The settings object */
|
||||||
|
settings: LoadedSettings;
|
||||||
|
|
||||||
|
/** Current approval mode */
|
||||||
|
currentMode: ApprovalMode;
|
||||||
|
|
||||||
|
/** Available terminal height for layout calculations */
|
||||||
|
availableTerminalHeight?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const formatModeDescription = (mode: ApprovalMode): string => {
|
||||||
|
switch (mode) {
|
||||||
|
case ApprovalMode.PLAN:
|
||||||
|
return 'Analyze only, do not modify files or execute commands';
|
||||||
|
case ApprovalMode.DEFAULT:
|
||||||
|
return 'Require approval for file edits or shell commands';
|
||||||
|
case ApprovalMode.AUTO_EDIT:
|
||||||
|
return 'Automatically approve file edits';
|
||||||
|
case ApprovalMode.YOLO:
|
||||||
|
return 'Automatically approve all tools';
|
||||||
|
default:
|
||||||
|
return `${mode} mode`;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export function ApprovalModeDialog({
|
||||||
|
onSelect,
|
||||||
|
settings,
|
||||||
|
currentMode,
|
||||||
|
availableTerminalHeight: _availableTerminalHeight,
|
||||||
|
}: ApprovalModeDialogProps): React.JSX.Element {
|
||||||
|
// Start with User scope by default
|
||||||
|
const [selectedScope, setSelectedScope] = useState<SettingScope>(
|
||||||
|
SettingScope.User,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Track the currently highlighted approval mode
|
||||||
|
const [highlightedMode, setHighlightedMode] = useState<ApprovalMode>(
|
||||||
|
currentMode || ApprovalMode.DEFAULT,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Generate approval mode items with inline descriptions
|
||||||
|
const modeItems = APPROVAL_MODES.map((mode) => ({
|
||||||
|
label: `${mode} - ${formatModeDescription(mode)}`,
|
||||||
|
value: mode,
|
||||||
|
key: mode,
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Find the index of the current mode
|
||||||
|
const initialModeIndex = modeItems.findIndex(
|
||||||
|
(item) => item.value === highlightedMode,
|
||||||
|
);
|
||||||
|
const safeInitialModeIndex = initialModeIndex >= 0 ? initialModeIndex : 0;
|
||||||
|
|
||||||
|
const handleModeSelect = useCallback(
|
||||||
|
(mode: ApprovalMode) => {
|
||||||
|
onSelect(mode, selectedScope);
|
||||||
|
},
|
||||||
|
[onSelect, selectedScope],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleModeHighlight = (mode: ApprovalMode) => {
|
||||||
|
setHighlightedMode(mode);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleScopeHighlight = useCallback((scope: SettingScope) => {
|
||||||
|
setSelectedScope(scope);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleScopeSelect = useCallback(
|
||||||
|
(scope: SettingScope) => {
|
||||||
|
onSelect(highlightedMode, scope);
|
||||||
|
},
|
||||||
|
[onSelect, highlightedMode],
|
||||||
|
);
|
||||||
|
|
||||||
|
const [focusSection, setFocusSection] = useState<'mode' | 'scope'>('mode');
|
||||||
|
|
||||||
|
useKeypress(
|
||||||
|
(key) => {
|
||||||
|
if (key.name === 'tab') {
|
||||||
|
setFocusSection((prev) => (prev === 'mode' ? 'scope' : 'mode'));
|
||||||
|
}
|
||||||
|
if (key.name === 'escape') {
|
||||||
|
onSelect(undefined, selectedScope);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ isActive: true },
|
||||||
|
);
|
||||||
|
|
||||||
|
// Generate scope message for approval mode setting
|
||||||
|
const otherScopeModifiedMessage = getScopeMessageForSetting(
|
||||||
|
'tools.approvalMode',
|
||||||
|
selectedScope,
|
||||||
|
settings,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Check if user scope is selected but workspace has the setting
|
||||||
|
const showWorkspacePriorityWarning =
|
||||||
|
selectedScope === SettingScope.User &&
|
||||||
|
otherScopeModifiedMessage.toLowerCase().includes('workspace');
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box
|
||||||
|
borderStyle="round"
|
||||||
|
borderColor={theme.border.default}
|
||||||
|
flexDirection="row"
|
||||||
|
padding={1}
|
||||||
|
width="100%"
|
||||||
|
height="100%"
|
||||||
|
>
|
||||||
|
<Box flexDirection="column" flexGrow={1}>
|
||||||
|
{/* Approval Mode Selection */}
|
||||||
|
<Text bold={focusSection === 'mode'} wrap="truncate">
|
||||||
|
{focusSection === 'mode' ? '> ' : ' '}Approval Mode{' '}
|
||||||
|
<Text color={theme.text.secondary}>{otherScopeModifiedMessage}</Text>
|
||||||
|
</Text>
|
||||||
|
<Box height={1} />
|
||||||
|
<RadioButtonSelect
|
||||||
|
items={modeItems}
|
||||||
|
initialIndex={safeInitialModeIndex}
|
||||||
|
onSelect={handleModeSelect}
|
||||||
|
onHighlight={handleModeHighlight}
|
||||||
|
isFocused={focusSection === 'mode'}
|
||||||
|
maxItemsToShow={10}
|
||||||
|
showScrollArrows={false}
|
||||||
|
showNumbers={focusSection === 'mode'}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Box height={1} />
|
||||||
|
|
||||||
|
{/* Scope Selection */}
|
||||||
|
<Box marginTop={1}>
|
||||||
|
<ScopeSelector
|
||||||
|
onSelect={handleScopeSelect}
|
||||||
|
onHighlight={handleScopeHighlight}
|
||||||
|
isFocused={focusSection === 'scope'}
|
||||||
|
initialScope={selectedScope}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Box height={1} />
|
||||||
|
|
||||||
|
{/* Warning when workspace setting will override user setting */}
|
||||||
|
{showWorkspacePriorityWarning && (
|
||||||
|
<>
|
||||||
|
<Text color={theme.status.warning} wrap="wrap">
|
||||||
|
⚠ Workspace approval mode exists and takes priority. User-level
|
||||||
|
change will have no effect.
|
||||||
|
</Text>
|
||||||
|
<Box height={1} />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Text color={theme.text.secondary}>
|
||||||
|
(Use Enter to select, Tab to change focus)
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -20,6 +20,7 @@ import { WorkspaceMigrationDialog } from './WorkspaceMigrationDialog.js';
|
|||||||
import { ProQuotaDialog } from './ProQuotaDialog.js';
|
import { ProQuotaDialog } from './ProQuotaDialog.js';
|
||||||
import { PermissionsModifyTrustDialog } from './PermissionsModifyTrustDialog.js';
|
import { PermissionsModifyTrustDialog } from './PermissionsModifyTrustDialog.js';
|
||||||
import { ModelDialog } from './ModelDialog.js';
|
import { ModelDialog } from './ModelDialog.js';
|
||||||
|
import { ApprovalModeDialog } from './ApprovalModeDialog.js';
|
||||||
import { theme } from '../semantic-colors.js';
|
import { theme } from '../semantic-colors.js';
|
||||||
import { useUIState } from '../contexts/UIStateContext.js';
|
import { useUIState } from '../contexts/UIStateContext.js';
|
||||||
import { useUIActions } from '../contexts/UIActionsContext.js';
|
import { useUIActions } from '../contexts/UIActionsContext.js';
|
||||||
@@ -180,6 +181,22 @@ export const DialogManager = ({
|
|||||||
onSelect={() => uiActions.closeSettingsDialog()}
|
onSelect={() => uiActions.closeSettingsDialog()}
|
||||||
onRestartRequest={() => process.exit(0)}
|
onRestartRequest={() => process.exit(0)}
|
||||||
availableTerminalHeight={terminalHeight - staticExtraHeight}
|
availableTerminalHeight={terminalHeight - staticExtraHeight}
|
||||||
|
config={config}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (uiState.isApprovalModeDialogOpen) {
|
||||||
|
const currentMode = config.getApprovalMode();
|
||||||
|
return (
|
||||||
|
<Box flexDirection="column">
|
||||||
|
<ApprovalModeDialog
|
||||||
|
settings={settings}
|
||||||
|
currentMode={currentMode}
|
||||||
|
onSelect={uiActions.handleApprovalModeSelect}
|
||||||
|
availableTerminalHeight={
|
||||||
|
constrainHeight ? terminalHeight - staticExtraHeight : undefined
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
</Box>
|
</Box>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -9,11 +9,8 @@ import { Box, Text } from 'ink';
|
|||||||
import { theme } from '../semantic-colors.js';
|
import { theme } from '../semantic-colors.js';
|
||||||
import type { LoadedSettings, Settings } from '../../config/settings.js';
|
import type { LoadedSettings, Settings } from '../../config/settings.js';
|
||||||
import { SettingScope } from '../../config/settings.js';
|
import { SettingScope } from '../../config/settings.js';
|
||||||
import {
|
import { getScopeMessageForSetting } from '../../utils/dialogScopeUtils.js';
|
||||||
getScopeItems,
|
import { ScopeSelector } from './shared/ScopeSelector.js';
|
||||||
getScopeMessageForSetting,
|
|
||||||
} from '../../utils/dialogScopeUtils.js';
|
|
||||||
import { RadioButtonSelect } from './shared/RadioButtonSelect.js';
|
|
||||||
import {
|
import {
|
||||||
getDialogSettingKeys,
|
getDialogSettingKeys,
|
||||||
setPendingSettingValue,
|
setPendingSettingValue,
|
||||||
@@ -30,6 +27,7 @@ import {
|
|||||||
getEffectiveValue,
|
getEffectiveValue,
|
||||||
} from '../../utils/settingsUtils.js';
|
} from '../../utils/settingsUtils.js';
|
||||||
import { useVimMode } from '../contexts/VimModeContext.js';
|
import { useVimMode } from '../contexts/VimModeContext.js';
|
||||||
|
import { type Config } from '@qwen-code/qwen-code-core';
|
||||||
import { useKeypress } from '../hooks/useKeypress.js';
|
import { useKeypress } from '../hooks/useKeypress.js';
|
||||||
import chalk from 'chalk';
|
import chalk from 'chalk';
|
||||||
import { cpSlice, cpLen, stripUnsafeCharacters } from '../utils/textUtils.js';
|
import { cpSlice, cpLen, stripUnsafeCharacters } from '../utils/textUtils.js';
|
||||||
@@ -43,6 +41,7 @@ interface SettingsDialogProps {
|
|||||||
onSelect: (settingName: string | undefined, scope: SettingScope) => void;
|
onSelect: (settingName: string | undefined, scope: SettingScope) => void;
|
||||||
onRestartRequest?: () => void;
|
onRestartRequest?: () => void;
|
||||||
availableTerminalHeight?: number;
|
availableTerminalHeight?: number;
|
||||||
|
config?: Config;
|
||||||
}
|
}
|
||||||
|
|
||||||
const maxItemsToShow = 8;
|
const maxItemsToShow = 8;
|
||||||
@@ -52,6 +51,7 @@ export function SettingsDialog({
|
|||||||
onSelect,
|
onSelect,
|
||||||
onRestartRequest,
|
onRestartRequest,
|
||||||
availableTerminalHeight,
|
availableTerminalHeight,
|
||||||
|
config,
|
||||||
}: SettingsDialogProps): React.JSX.Element {
|
}: SettingsDialogProps): React.JSX.Element {
|
||||||
// Get vim mode context to sync vim mode changes
|
// Get vim mode context to sync vim mode changes
|
||||||
const { vimEnabled, toggleVimEnabled } = useVimMode();
|
const { vimEnabled, toggleVimEnabled } = useVimMode();
|
||||||
@@ -184,6 +184,21 @@ export function SettingsDialog({
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Special handling for approval mode to apply to current session
|
||||||
|
if (
|
||||||
|
key === 'tools.approvalMode' &&
|
||||||
|
settings.merged.tools?.approvalMode
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
config?.setApprovalMode(settings.merged.tools.approvalMode);
|
||||||
|
} catch (error) {
|
||||||
|
console.error(
|
||||||
|
'Failed to apply approval mode to current session:',
|
||||||
|
error,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Remove from modifiedSettings since it's now saved
|
// Remove from modifiedSettings since it's now saved
|
||||||
setModifiedSettings((prev) => {
|
setModifiedSettings((prev) => {
|
||||||
const updated = new Set(prev);
|
const updated = new Set(prev);
|
||||||
@@ -357,12 +372,6 @@ export function SettingsDialog({
|
|||||||
setEditCursorPos(0);
|
setEditCursorPos(0);
|
||||||
};
|
};
|
||||||
|
|
||||||
// Scope selector items
|
|
||||||
const scopeItems = getScopeItems().map((item) => ({
|
|
||||||
...item,
|
|
||||||
key: item.value,
|
|
||||||
}));
|
|
||||||
|
|
||||||
const handleScopeHighlight = (scope: SettingScope) => {
|
const handleScopeHighlight = (scope: SettingScope) => {
|
||||||
setSelectedScope(scope);
|
setSelectedScope(scope);
|
||||||
};
|
};
|
||||||
@@ -616,7 +625,11 @@ export function SettingsDialog({
|
|||||||
prev,
|
prev,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
} else if (defType === 'number' || defType === 'string') {
|
} else if (
|
||||||
|
defType === 'number' ||
|
||||||
|
defType === 'string' ||
|
||||||
|
defType === 'enum'
|
||||||
|
) {
|
||||||
if (
|
if (
|
||||||
typeof defaultValue === 'number' ||
|
typeof defaultValue === 'number' ||
|
||||||
typeof defaultValue === 'string'
|
typeof defaultValue === 'string'
|
||||||
@@ -673,6 +686,21 @@ export function SettingsDialog({
|
|||||||
selectedScope,
|
selectedScope,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Special handling for approval mode to apply to current session
|
||||||
|
if (
|
||||||
|
currentSetting.value === 'tools.approvalMode' &&
|
||||||
|
settings.merged.tools?.approvalMode
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
config?.setApprovalMode(settings.merged.tools.approvalMode);
|
||||||
|
} catch (error) {
|
||||||
|
console.error(
|
||||||
|
'Failed to apply approval mode to current session:',
|
||||||
|
error,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Remove from global pending changes if present
|
// Remove from global pending changes if present
|
||||||
setGlobalPendingChanges((prev) => {
|
setGlobalPendingChanges((prev) => {
|
||||||
if (!prev.has(currentSetting.value)) return prev;
|
if (!prev.has(currentSetting.value)) return prev;
|
||||||
@@ -876,19 +904,12 @@ export function SettingsDialog({
|
|||||||
|
|
||||||
{/* Scope Selection - conditionally visible based on height constraints */}
|
{/* Scope Selection - conditionally visible based on height constraints */}
|
||||||
{showScopeSelection && (
|
{showScopeSelection && (
|
||||||
<Box marginTop={1} flexDirection="column">
|
<Box marginTop={1}>
|
||||||
<Text bold={focusSection === 'scope'} wrap="truncate">
|
<ScopeSelector
|
||||||
{focusSection === 'scope' ? '> ' : ' '}Apply To
|
|
||||||
</Text>
|
|
||||||
<RadioButtonSelect
|
|
||||||
items={scopeItems}
|
|
||||||
initialIndex={scopeItems.findIndex(
|
|
||||||
(item) => item.value === selectedScope,
|
|
||||||
)}
|
|
||||||
onSelect={handleScopeSelect}
|
onSelect={handleScopeSelect}
|
||||||
onHighlight={handleScopeHighlight}
|
onHighlight={handleScopeHighlight}
|
||||||
isFocused={focusSection === 'scope'}
|
isFocused={focusSection === 'scope'}
|
||||||
showNumbers={focusSection === 'scope'}
|
initialScope={selectedScope}
|
||||||
/>
|
/>
|
||||||
</Box>
|
</Box>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -28,7 +28,6 @@ exports[`SettingsDialog > Snapshot Tests > should render default state correctly
|
|||||||
│ Apply To │
|
│ Apply To │
|
||||||
│ ● User Settings │
|
│ ● User Settings │
|
||||||
│ Workspace Settings │
|
│ Workspace Settings │
|
||||||
│ System Settings │
|
|
||||||
│ │
|
│ │
|
||||||
│ (Use Enter to select, Tab to change focus) │
|
│ (Use Enter to select, Tab to change focus) │
|
||||||
│ │
|
│ │
|
||||||
@@ -63,7 +62,6 @@ exports[`SettingsDialog > Snapshot Tests > should render focused on scope select
|
|||||||
│ Apply To │
|
│ Apply To │
|
||||||
│ ● User Settings │
|
│ ● User Settings │
|
||||||
│ Workspace Settings │
|
│ Workspace Settings │
|
||||||
│ System Settings │
|
|
||||||
│ │
|
│ │
|
||||||
│ (Use Enter to select, Tab to change focus) │
|
│ (Use Enter to select, Tab to change focus) │
|
||||||
│ │
|
│ │
|
||||||
@@ -98,7 +96,6 @@ exports[`SettingsDialog > Snapshot Tests > should render with accessibility sett
|
|||||||
│ Apply To │
|
│ Apply To │
|
||||||
│ ● User Settings │
|
│ ● User Settings │
|
||||||
│ Workspace Settings │
|
│ Workspace Settings │
|
||||||
│ System Settings │
|
|
||||||
│ │
|
│ │
|
||||||
│ (Use Enter to select, Tab to change focus) │
|
│ (Use Enter to select, Tab to change focus) │
|
||||||
│ │
|
│ │
|
||||||
@@ -133,7 +130,6 @@ exports[`SettingsDialog > Snapshot Tests > should render with all boolean settin
|
|||||||
│ Apply To │
|
│ Apply To │
|
||||||
│ ● User Settings │
|
│ ● User Settings │
|
||||||
│ Workspace Settings │
|
│ Workspace Settings │
|
||||||
│ System Settings │
|
|
||||||
│ │
|
│ │
|
||||||
│ (Use Enter to select, Tab to change focus) │
|
│ (Use Enter to select, Tab to change focus) │
|
||||||
│ │
|
│ │
|
||||||
@@ -168,7 +164,6 @@ exports[`SettingsDialog > Snapshot Tests > should render with different scope se
|
|||||||
│ Apply To │
|
│ Apply To │
|
||||||
│ ● User Settings │
|
│ ● User Settings │
|
||||||
│ Workspace Settings │
|
│ Workspace Settings │
|
||||||
│ System Settings │
|
|
||||||
│ │
|
│ │
|
||||||
│ (Use Enter to select, Tab to change focus) │
|
│ (Use Enter to select, Tab to change focus) │
|
||||||
│ │
|
│ │
|
||||||
@@ -203,7 +198,6 @@ exports[`SettingsDialog > Snapshot Tests > should render with different scope se
|
|||||||
│ Apply To │
|
│ Apply To │
|
||||||
│ ● User Settings │
|
│ ● User Settings │
|
||||||
│ Workspace Settings │
|
│ Workspace Settings │
|
||||||
│ System Settings │
|
|
||||||
│ │
|
│ │
|
||||||
│ (Use Enter to select, Tab to change focus) │
|
│ (Use Enter to select, Tab to change focus) │
|
||||||
│ │
|
│ │
|
||||||
@@ -238,7 +232,6 @@ exports[`SettingsDialog > Snapshot Tests > should render with file filtering set
|
|||||||
│ Apply To │
|
│ Apply To │
|
||||||
│ ● User Settings │
|
│ ● User Settings │
|
||||||
│ Workspace Settings │
|
│ Workspace Settings │
|
||||||
│ System Settings │
|
|
||||||
│ │
|
│ │
|
||||||
│ (Use Enter to select, Tab to change focus) │
|
│ (Use Enter to select, Tab to change focus) │
|
||||||
│ │
|
│ │
|
||||||
@@ -273,7 +266,6 @@ exports[`SettingsDialog > Snapshot Tests > should render with mixed boolean and
|
|||||||
│ Apply To │
|
│ Apply To │
|
||||||
│ ● User Settings │
|
│ ● User Settings │
|
||||||
│ Workspace Settings │
|
│ Workspace Settings │
|
||||||
│ System Settings │
|
|
||||||
│ │
|
│ │
|
||||||
│ (Use Enter to select, Tab to change focus) │
|
│ (Use Enter to select, Tab to change focus) │
|
||||||
│ │
|
│ │
|
||||||
@@ -308,7 +300,6 @@ exports[`SettingsDialog > Snapshot Tests > should render with tools and security
|
|||||||
│ Apply To │
|
│ Apply To │
|
||||||
│ ● User Settings │
|
│ ● User Settings │
|
||||||
│ Workspace Settings │
|
│ Workspace Settings │
|
||||||
│ System Settings │
|
|
||||||
│ │
|
│ │
|
||||||
│ (Use Enter to select, Tab to change focus) │
|
│ (Use Enter to select, Tab to change focus) │
|
||||||
│ │
|
│ │
|
||||||
@@ -343,7 +334,6 @@ exports[`SettingsDialog > Snapshot Tests > should render with various boolean se
|
|||||||
│ Apply To │
|
│ Apply To │
|
||||||
│ ● User Settings │
|
│ ● User Settings │
|
||||||
│ Workspace Settings │
|
│ Workspace Settings │
|
||||||
│ System Settings │
|
|
||||||
│ │
|
│ │
|
||||||
│ (Use Enter to select, Tab to change focus) │
|
│ (Use Enter to select, Tab to change focus) │
|
||||||
│ │
|
│ │
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ exports[`ThemeDialog Snapshots > should render correctly in scope selector mode
|
|||||||
│ > Apply To │
|
│ > Apply To │
|
||||||
│ ● 1. User Settings │
|
│ ● 1. User Settings │
|
||||||
│ 2. Workspace Settings │
|
│ 2. Workspace Settings │
|
||||||
│ 3. System Settings │
|
|
||||||
│ │
|
│ │
|
||||||
│ (Use Enter to apply scope, Tab to select theme) │
|
│ (Use Enter to apply scope, Tab to select theme) │
|
||||||
│ │
|
│ │
|
||||||
|
|||||||
@@ -8,7 +8,11 @@ import { createContext, useContext } from 'react';
|
|||||||
import { type Key } from '../hooks/useKeypress.js';
|
import { type Key } from '../hooks/useKeypress.js';
|
||||||
import { type IdeIntegrationNudgeResult } from '../IdeIntegrationNudge.js';
|
import { type IdeIntegrationNudgeResult } from '../IdeIntegrationNudge.js';
|
||||||
import { type FolderTrustChoice } from '../components/FolderTrustDialog.js';
|
import { type FolderTrustChoice } from '../components/FolderTrustDialog.js';
|
||||||
import { type AuthType, type EditorType } from '@qwen-code/qwen-code-core';
|
import {
|
||||||
|
type AuthType,
|
||||||
|
type EditorType,
|
||||||
|
type ApprovalMode,
|
||||||
|
} from '@qwen-code/qwen-code-core';
|
||||||
import { type SettingScope } from '../../config/settings.js';
|
import { type SettingScope } from '../../config/settings.js';
|
||||||
import type { AuthState } from '../types.js';
|
import type { AuthState } from '../types.js';
|
||||||
import { type VisionSwitchOutcome } from '../components/ModelSwitchDialog.js';
|
import { type VisionSwitchOutcome } from '../components/ModelSwitchDialog.js';
|
||||||
@@ -19,6 +23,10 @@ export interface UIActions {
|
|||||||
scope: SettingScope,
|
scope: SettingScope,
|
||||||
) => void;
|
) => void;
|
||||||
handleThemeHighlight: (themeName: string | undefined) => void;
|
handleThemeHighlight: (themeName: string | undefined) => void;
|
||||||
|
handleApprovalModeSelect: (
|
||||||
|
mode: ApprovalMode | undefined,
|
||||||
|
scope: SettingScope,
|
||||||
|
) => void;
|
||||||
handleAuthSelect: (
|
handleAuthSelect: (
|
||||||
authType: AuthType | undefined,
|
authType: AuthType | undefined,
|
||||||
scope: SettingScope,
|
scope: SettingScope,
|
||||||
|
|||||||
@@ -69,6 +69,7 @@ export interface UIState {
|
|||||||
isSettingsDialogOpen: boolean;
|
isSettingsDialogOpen: boolean;
|
||||||
isModelDialogOpen: boolean;
|
isModelDialogOpen: boolean;
|
||||||
isPermissionsDialogOpen: boolean;
|
isPermissionsDialogOpen: boolean;
|
||||||
|
isApprovalModeDialogOpen: boolean;
|
||||||
slashCommands: readonly SlashCommand[];
|
slashCommands: readonly SlashCommand[];
|
||||||
pendingSlashCommandHistoryItems: HistoryItemWithoutId[];
|
pendingSlashCommandHistoryItems: HistoryItemWithoutId[];
|
||||||
commandContext: CommandContext;
|
commandContext: CommandContext;
|
||||||
|
|||||||
@@ -48,6 +48,7 @@ interface SlashCommandProcessorActions {
|
|||||||
openSettingsDialog: () => void;
|
openSettingsDialog: () => void;
|
||||||
openModelDialog: () => void;
|
openModelDialog: () => void;
|
||||||
openPermissionsDialog: () => void;
|
openPermissionsDialog: () => void;
|
||||||
|
openApprovalModeDialog: () => void;
|
||||||
quit: (messages: HistoryItem[]) => void;
|
quit: (messages: HistoryItem[]) => void;
|
||||||
setDebugMessage: (message: string) => void;
|
setDebugMessage: (message: string) => void;
|
||||||
toggleCorgiMode: () => void;
|
toggleCorgiMode: () => void;
|
||||||
@@ -396,6 +397,9 @@ export const useSlashCommandProcessor = (
|
|||||||
case 'subagent_list':
|
case 'subagent_list':
|
||||||
actions.openAgentsManagerDialog();
|
actions.openAgentsManagerDialog();
|
||||||
return { type: 'handled' };
|
return { type: 'handled' };
|
||||||
|
case 'approval-mode':
|
||||||
|
actions.openApprovalModeDialog();
|
||||||
|
return { type: 'handled' };
|
||||||
case 'help':
|
case 'help':
|
||||||
return { type: 'handled' };
|
return { type: 'handled' };
|
||||||
default: {
|
default: {
|
||||||
|
|||||||
57
packages/cli/src/ui/hooks/useApprovalModeCommand.ts
Normal file
57
packages/cli/src/ui/hooks/useApprovalModeCommand.ts
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
/**
|
||||||
|
* @license
|
||||||
|
* Copyright 2025 Qwen
|
||||||
|
* SPDX-License-Identifier: Apache-2.0
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useState, useCallback } from 'react';
|
||||||
|
import type { ApprovalMode, Config } from '@qwen-code/qwen-code-core';
|
||||||
|
import type { LoadedSettings, SettingScope } from '../../config/settings.js';
|
||||||
|
|
||||||
|
interface UseApprovalModeCommandReturn {
|
||||||
|
isApprovalModeDialogOpen: boolean;
|
||||||
|
openApprovalModeDialog: () => void;
|
||||||
|
handleApprovalModeSelect: (
|
||||||
|
mode: ApprovalMode | undefined,
|
||||||
|
scope: SettingScope,
|
||||||
|
) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useApprovalModeCommand = (
|
||||||
|
loadedSettings: LoadedSettings,
|
||||||
|
config: Config,
|
||||||
|
): UseApprovalModeCommandReturn => {
|
||||||
|
const [isApprovalModeDialogOpen, setIsApprovalModeDialogOpen] =
|
||||||
|
useState(false);
|
||||||
|
|
||||||
|
const openApprovalModeDialog = useCallback(() => {
|
||||||
|
setIsApprovalModeDialogOpen(true);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleApprovalModeSelect = useCallback(
|
||||||
|
(mode: ApprovalMode | undefined, scope: SettingScope) => {
|
||||||
|
try {
|
||||||
|
if (!mode) {
|
||||||
|
// User cancelled the dialog
|
||||||
|
setIsApprovalModeDialogOpen(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set the mode in the current session and persist to settings
|
||||||
|
loadedSettings.setValue(scope, 'tools.approvalMode', mode);
|
||||||
|
config.setApprovalMode(
|
||||||
|
loadedSettings.merged.tools?.approvalMode ?? mode,
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
setIsApprovalModeDialogOpen(false);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[config, loadedSettings],
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
isApprovalModeDialogOpen,
|
||||||
|
openApprovalModeDialog,
|
||||||
|
handleApprovalModeSelect,
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -6,13 +6,20 @@
|
|||||||
|
|
||||||
import { useCallback } from 'react';
|
import { useCallback } from 'react';
|
||||||
import { SettingScope } from '../../config/settings.js';
|
import { SettingScope } from '../../config/settings.js';
|
||||||
import type { AuthType } from '@qwen-code/qwen-code-core';
|
import type { AuthType, ApprovalMode } from '@qwen-code/qwen-code-core';
|
||||||
|
|
||||||
export interface DialogCloseOptions {
|
export interface DialogCloseOptions {
|
||||||
// Theme dialog
|
// Theme dialog
|
||||||
isThemeDialogOpen: boolean;
|
isThemeDialogOpen: boolean;
|
||||||
handleThemeSelect: (theme: string | undefined, scope: SettingScope) => void;
|
handleThemeSelect: (theme: string | undefined, scope: SettingScope) => void;
|
||||||
|
|
||||||
|
// Approval mode dialog
|
||||||
|
isApprovalModeDialogOpen: boolean;
|
||||||
|
handleApprovalModeSelect: (
|
||||||
|
mode: ApprovalMode | undefined,
|
||||||
|
scope: SettingScope,
|
||||||
|
) => void;
|
||||||
|
|
||||||
// Auth dialog
|
// Auth dialog
|
||||||
isAuthDialogOpen: boolean;
|
isAuthDialogOpen: boolean;
|
||||||
handleAuthSelect: (
|
handleAuthSelect: (
|
||||||
@@ -57,6 +64,12 @@ export function useDialogClose(options: DialogCloseOptions) {
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (options.isApprovalModeDialogOpen) {
|
||||||
|
// Mimic ESC behavior: onSelect(undefined, selectedScope) - keeps current mode
|
||||||
|
options.handleApprovalModeSelect(undefined, SettingScope.User);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
if (options.isEditorDialogOpen) {
|
if (options.isEditorDialogOpen) {
|
||||||
// Mimic ESC behavior: call onExit() directly
|
// Mimic ESC behavior: call onExit() directly
|
||||||
options.exitEditorDialog();
|
options.exitEditorDialog();
|
||||||
|
|||||||
@@ -14,7 +14,11 @@ import { settingExistsInScope } from './settingsUtils.js';
|
|||||||
export const SCOPE_LABELS = {
|
export const SCOPE_LABELS = {
|
||||||
[SettingScope.User]: 'User Settings',
|
[SettingScope.User]: 'User Settings',
|
||||||
[SettingScope.Workspace]: 'Workspace Settings',
|
[SettingScope.Workspace]: 'Workspace Settings',
|
||||||
[SettingScope.System]: 'System Settings',
|
|
||||||
|
// TODO: migrate system settings to user settings
|
||||||
|
// we don't want to save settings to system scope, it is a troublemaker
|
||||||
|
// comment it out for now.
|
||||||
|
// [SettingScope.System]: 'System Settings',
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -27,7 +31,7 @@ export function getScopeItems() {
|
|||||||
label: SCOPE_LABELS[SettingScope.Workspace],
|
label: SCOPE_LABELS[SettingScope.Workspace],
|
||||||
value: SettingScope.Workspace,
|
value: SettingScope.Workspace,
|
||||||
},
|
},
|
||||||
{ label: SCOPE_LABELS[SettingScope.System], value: SettingScope.System },
|
// { label: SCOPE_LABELS[SettingScope.System], value: SettingScope.System },
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user