diff --git a/packages/cli/src/config/settingsSchema.ts b/packages/cli/src/config/settingsSchema.ts index b36ee397..70037dfd 100644 --- a/packages/cli/src/config/settingsSchema.ts +++ b/packages/cli/src/config/settingsSchema.ts @@ -12,6 +12,7 @@ import type { ChatCompressionSettings, } from '@qwen-code/qwen-code-core'; import { + ApprovalMode, DEFAULT_TRUNCATE_TOOL_OUTPUT_LINES, DEFAULT_TRUNCATE_TOOL_OUTPUT_THRESHOLD, } from '@qwen-code/qwen-code-core'; @@ -830,14 +831,20 @@ const SETTINGS_SCHEMA = { mergeStrategy: MergeStrategy.UNION, }, approvalMode: { - type: 'string', - label: 'Default Approval Mode', + type: 'enum', + label: 'Approval Mode', category: 'Tools', requiresRestart: false, - default: 'default', + default: ApprovalMode.DEFAULT, 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, + options: [ + { value: ApprovalMode.PLAN, label: 'Plan' }, + { value: ApprovalMode.DEFAULT, label: 'Default' }, + { value: ApprovalMode.AUTO_EDIT, label: 'Auto Edit' }, + { value: ApprovalMode.YOLO, label: 'YOLO' }, + ], }, discoveryCommand: { type: 'string', diff --git a/packages/cli/src/ui/AppContainer.tsx b/packages/cli/src/ui/AppContainer.tsx index 2e66610a..fbfc732b 100644 --- a/packages/cli/src/ui/AppContainer.tsx +++ b/packages/cli/src/ui/AppContainer.tsx @@ -53,6 +53,7 @@ import { useQuotaAndFallback } from './hooks/useQuotaAndFallback.js'; import { useEditorSettings } from './hooks/useEditorSettings.js'; import { useSettingsCommand } from './hooks/useSettingsCommand.js'; import { useModelCommand } from './hooks/useModelCommand.js'; +import { useApprovalModeCommand } from './hooks/useApprovalModeCommand.js'; import { useSlashCommandProcessor } from './hooks/slashCommandProcessor.js'; import { useVimMode } from './contexts/VimModeContext.js'; import { useConsoleMessages } from './hooks/useConsoleMessages.js'; @@ -335,6 +336,12 @@ export const AppContainer = (props: AppContainerProps) => { initializationResult.themeError, ); + const { + isApprovalModeDialogOpen, + openApprovalModeDialog, + handleApprovalModeSelect, + } = useApprovalModeCommand(settings, config); + const { setAuthState, authError, @@ -470,6 +477,7 @@ export const AppContainer = (props: AppContainerProps) => { openSettingsDialog, openModelDialog, openPermissionsDialog, + openApprovalModeDialog, quit: (messages: HistoryItem[]) => { setQuittingMessages(messages); setTimeout(async () => { @@ -495,6 +503,7 @@ export const AppContainer = (props: AppContainerProps) => { setCorgiMode, dispatchExtensionStateUpdate, openPermissionsDialog, + openApprovalModeDialog, addConfirmUpdateExtensionRequest, showQuitConfirmation, openSubagentCreateDialog, @@ -939,6 +948,8 @@ export const AppContainer = (props: AppContainerProps) => { const { closeAnyOpenDialog } = useDialogClose({ isThemeDialogOpen, handleThemeSelect, + isApprovalModeDialogOpen, + handleApprovalModeSelect, isAuthDialogOpen, handleAuthSelect, selectedAuthType: settings.merged.security?.auth?.selectedType, @@ -1188,7 +1199,8 @@ export const AppContainer = (props: AppContainerProps) => { showIdeRestartPrompt || !!proQuotaRequest || isSubagentCreateDialogOpen || - isAgentsManagerDialogOpen; + isAgentsManagerDialogOpen || + isApprovalModeDialogOpen; const pendingHistoryItems = useMemo( () => [...pendingSlashCommandHistoryItems, ...pendingGeminiHistoryItems], @@ -1219,6 +1231,7 @@ export const AppContainer = (props: AppContainerProps) => { isSettingsDialogOpen, isModelDialogOpen, isPermissionsDialogOpen, + isApprovalModeDialogOpen, slashCommands, pendingSlashCommandHistoryItems, commandContext, @@ -1313,6 +1326,7 @@ export const AppContainer = (props: AppContainerProps) => { isSettingsDialogOpen, isModelDialogOpen, isPermissionsDialogOpen, + isApprovalModeDialogOpen, slashCommands, pendingSlashCommandHistoryItems, commandContext, @@ -1393,6 +1407,7 @@ export const AppContainer = (props: AppContainerProps) => { () => ({ handleThemeSelect, handleThemeHighlight, + handleApprovalModeSelect, handleAuthSelect, setAuthState, onAuthError, @@ -1428,6 +1443,7 @@ export const AppContainer = (props: AppContainerProps) => { [ handleThemeSelect, handleThemeHighlight, + handleApprovalModeSelect, handleAuthSelect, setAuthState, onAuthError, diff --git a/packages/cli/src/ui/commands/approvalModeCommand.test.ts b/packages/cli/src/ui/commands/approvalModeCommand.test.ts index c52c84fc..f915a63c 100644 --- a/packages/cli/src/ui/commands/approvalModeCommand.test.ts +++ b/packages/cli/src/ui/commands/approvalModeCommand.test.ts @@ -4,492 +4,68 @@ * 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 { type CommandContext, CommandKind, - type MessageActionReturn, + type OpenDialogActionReturn, } 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'; +import type { LoadedSettings } from '../../config/settings.js'; describe('approvalModeCommand', () => { let mockContext: CommandContext; - let setApprovalModeMock: ReturnType; - let setSettingsValueMock: ReturnType; - 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, + getApprovalMode: () => 'default', + setApprovalMode: () => {}, }, settings: { merged: {}, - setValue: setSettingsValueMock, - forScope: vi - .fn() - .mockImplementation((scope: SettingScope) => - scope === SettingScope.User - ? userSettingsFile - : scope === SettingScope.Workspace - ? projectSettingsFile - : { path: '', settings: {} }, - ), + setValue: () => {}, + forScope: () => ({}), } as unknown as LoadedSettings, }, - } as unknown as CommandContext); + }); }); - afterEach(() => { - process.env = { ...originalEnv }; - vi.clearAllMocks(); - }); - - it('should have the correct command properties', () => { + it('should have correct metadata', () => { 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', ); + expect(approvalModeCommand.kind).toBe(CommandKind.BUILT_IN); }); - 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( + it('should open approval mode dialog when invoked', async () => { + const result = (await approvalModeCommand.action?.( mockContext, '', - )) as MessageActionReturn; + )) as OpenDialogActionReturn; - 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 [--session|--user|--project]', - ].join('\n'); - expect(result.content).toBe(expectedMessage); + expect(result.type).toBe('dialog'); + expect(result.dialog).toBe('approval-mode'); }); - it('should display error when config is not available', async () => { - if (!approvalModeCommand.action) { - throw new Error('approvalModeCommand must have an action.'); - } + it('should open approval mode dialog with arguments (ignored)', async () => { + const result = (await approvalModeCommand.action?.( + mockContext, + 'some arguments', + )) as OpenDialogActionReturn; - 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.'); + expect(result.type).toBe('dialog'); + expect(result.dialog).toBe('approval-mode'); }); - 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 not have subcommands', () => { + expect(approvalModeCommand.subCommands).toBeUndefined(); }); - 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 [--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([]); + it('should not have completion function', () => { + expect(approvalModeCommand.completion).toBeUndefined(); }); }); diff --git a/packages/cli/src/ui/commands/approvalModeCommand.ts b/packages/cli/src/ui/commands/approvalModeCommand.ts index 6cef96c2..5528d86f 100644 --- a/packages/cli/src/ui/commands/approvalModeCommand.ts +++ b/packages/cli/src/ui/commands/approvalModeCommand.ts @@ -7,428 +7,19 @@ import type { SlashCommand, CommandContext, - MessageActionReturn, + OpenDialogActionReturn, } 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 [--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 => { - 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 => { - 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 => { - 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 => { - 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 => { - 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 => { - 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 []; - }, + _context: CommandContext, + _args: string, + ): Promise => ({ + type: 'dialog', + dialog: 'approval-mode', + }), }; diff --git a/packages/cli/src/ui/commands/types.ts b/packages/cli/src/ui/commands/types.ts index e9ee4677..e865c07e 100644 --- a/packages/cli/src/ui/commands/types.ts +++ b/packages/cli/src/ui/commands/types.ts @@ -129,7 +129,8 @@ export interface OpenDialogActionReturn { | 'model' | 'subagent_create' | 'subagent_list' - | 'permissions'; + | 'permissions' + | 'approval-mode'; } /** diff --git a/packages/cli/src/ui/components/ApprovalModeDialog.tsx b/packages/cli/src/ui/components/ApprovalModeDialog.tsx new file mode 100644 index 00000000..eb6441ec --- /dev/null +++ b/packages/cli/src/ui/components/ApprovalModeDialog.tsx @@ -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.User, + ); + + // Track the currently highlighted approval mode + const [highlightedMode, setHighlightedMode] = useState( + 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 ( + + + {/* Approval Mode Selection */} + + {focusSection === 'mode' ? '> ' : ' '}Approval Mode{' '} + {otherScopeModifiedMessage} + + + + + + + {/* Scope Selection */} + + + + + + + {/* Warning when workspace setting will override user setting */} + {showWorkspacePriorityWarning && ( + <> + + ⚠ Workspace approval mode exists and takes priority. User-level + change will have no effect. + + + + )} + + + (Use Enter to select, Tab to change focus) + + + + ); +} diff --git a/packages/cli/src/ui/components/DialogManager.tsx b/packages/cli/src/ui/components/DialogManager.tsx index f174a8d0..01d95392 100644 --- a/packages/cli/src/ui/components/DialogManager.tsx +++ b/packages/cli/src/ui/components/DialogManager.tsx @@ -20,6 +20,7 @@ import { WorkspaceMigrationDialog } from './WorkspaceMigrationDialog.js'; import { ProQuotaDialog } from './ProQuotaDialog.js'; import { PermissionsModifyTrustDialog } from './PermissionsModifyTrustDialog.js'; import { ModelDialog } from './ModelDialog.js'; +import { ApprovalModeDialog } from './ApprovalModeDialog.js'; import { theme } from '../semantic-colors.js'; import { useUIState } from '../contexts/UIStateContext.js'; import { useUIActions } from '../contexts/UIActionsContext.js'; @@ -180,6 +181,22 @@ export const DialogManager = ({ onSelect={() => uiActions.closeSettingsDialog()} onRestartRequest={() => process.exit(0)} availableTerminalHeight={terminalHeight - staticExtraHeight} + config={config} + /> + + ); + } + if (uiState.isApprovalModeDialogOpen) { + const currentMode = config.getApprovalMode(); + return ( + + ); diff --git a/packages/cli/src/ui/components/SettingsDialog.tsx b/packages/cli/src/ui/components/SettingsDialog.tsx index b9e1559d..210672bb 100644 --- a/packages/cli/src/ui/components/SettingsDialog.tsx +++ b/packages/cli/src/ui/components/SettingsDialog.tsx @@ -9,11 +9,8 @@ import { Box, Text } from 'ink'; import { theme } from '../semantic-colors.js'; import type { LoadedSettings, Settings } from '../../config/settings.js'; import { SettingScope } from '../../config/settings.js'; -import { - getScopeItems, - getScopeMessageForSetting, -} from '../../utils/dialogScopeUtils.js'; -import { RadioButtonSelect } from './shared/RadioButtonSelect.js'; +import { getScopeMessageForSetting } from '../../utils/dialogScopeUtils.js'; +import { ScopeSelector } from './shared/ScopeSelector.js'; import { getDialogSettingKeys, setPendingSettingValue, @@ -30,6 +27,7 @@ import { getEffectiveValue, } from '../../utils/settingsUtils.js'; import { useVimMode } from '../contexts/VimModeContext.js'; +import { type Config } from '@qwen-code/qwen-code-core'; import { useKeypress } from '../hooks/useKeypress.js'; import chalk from 'chalk'; import { cpSlice, cpLen, stripUnsafeCharacters } from '../utils/textUtils.js'; @@ -43,6 +41,7 @@ interface SettingsDialogProps { onSelect: (settingName: string | undefined, scope: SettingScope) => void; onRestartRequest?: () => void; availableTerminalHeight?: number; + config?: Config; } const maxItemsToShow = 8; @@ -52,6 +51,7 @@ export function SettingsDialog({ onSelect, onRestartRequest, availableTerminalHeight, + config, }: SettingsDialogProps): React.JSX.Element { // Get vim mode context to sync vim mode changes 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 setModifiedSettings((prev) => { const updated = new Set(prev); @@ -357,12 +372,6 @@ export function SettingsDialog({ setEditCursorPos(0); }; - // Scope selector items - const scopeItems = getScopeItems().map((item) => ({ - ...item, - key: item.value, - })); - const handleScopeHighlight = (scope: SettingScope) => { setSelectedScope(scope); }; @@ -616,7 +625,11 @@ export function SettingsDialog({ prev, ), ); - } else if (defType === 'number' || defType === 'string') { + } else if ( + defType === 'number' || + defType === 'string' || + defType === 'enum' + ) { if ( typeof defaultValue === 'number' || typeof defaultValue === 'string' @@ -673,6 +686,21 @@ export function SettingsDialog({ 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 setGlobalPendingChanges((prev) => { if (!prev.has(currentSetting.value)) return prev; @@ -876,19 +904,12 @@ export function SettingsDialog({ {/* Scope Selection - conditionally visible based on height constraints */} {showScopeSelection && ( - - - {focusSection === 'scope' ? '> ' : ' '}Apply To - - item.value === selectedScope, - )} + + )} diff --git a/packages/cli/src/ui/components/__snapshots__/SettingsDialog.test.tsx.snap b/packages/cli/src/ui/components/__snapshots__/SettingsDialog.test.tsx.snap index 5e528375..b63948e1 100644 --- a/packages/cli/src/ui/components/__snapshots__/SettingsDialog.test.tsx.snap +++ b/packages/cli/src/ui/components/__snapshots__/SettingsDialog.test.tsx.snap @@ -28,7 +28,6 @@ exports[`SettingsDialog > Snapshot Tests > should render default state correctly │ Apply To │ │ ● User Settings │ │ Workspace Settings │ -│ System Settings │ │ │ │ (Use Enter to select, Tab to change focus) │ │ │ @@ -63,7 +62,6 @@ exports[`SettingsDialog > Snapshot Tests > should render focused on scope select │ Apply To │ │ ● User Settings │ │ Workspace Settings │ -│ System Settings │ │ │ │ (Use Enter to select, Tab to change focus) │ │ │ @@ -98,7 +96,6 @@ exports[`SettingsDialog > Snapshot Tests > should render with accessibility sett │ Apply To │ │ ● User Settings │ │ Workspace Settings │ -│ System Settings │ │ │ │ (Use Enter to select, Tab to change focus) │ │ │ @@ -133,7 +130,6 @@ exports[`SettingsDialog > Snapshot Tests > should render with all boolean settin │ Apply To │ │ ● User Settings │ │ Workspace Settings │ -│ System Settings │ │ │ │ (Use Enter to select, Tab to change focus) │ │ │ @@ -168,7 +164,6 @@ exports[`SettingsDialog > Snapshot Tests > should render with different scope se │ Apply To │ │ ● User Settings │ │ Workspace Settings │ -│ System Settings │ │ │ │ (Use Enter to select, Tab to change focus) │ │ │ @@ -203,7 +198,6 @@ exports[`SettingsDialog > Snapshot Tests > should render with different scope se │ Apply To │ │ ● User Settings │ │ Workspace Settings │ -│ System Settings │ │ │ │ (Use Enter to select, Tab to change focus) │ │ │ @@ -238,7 +232,6 @@ exports[`SettingsDialog > Snapshot Tests > should render with file filtering set │ Apply To │ │ ● User Settings │ │ Workspace Settings │ -│ System Settings │ │ │ │ (Use Enter to select, Tab to change focus) │ │ │ @@ -273,7 +266,6 @@ exports[`SettingsDialog > Snapshot Tests > should render with mixed boolean and │ Apply To │ │ ● User Settings │ │ Workspace Settings │ -│ System Settings │ │ │ │ (Use Enter to select, Tab to change focus) │ │ │ @@ -308,7 +300,6 @@ exports[`SettingsDialog > Snapshot Tests > should render with tools and security │ Apply To │ │ ● User Settings │ │ Workspace Settings │ -│ System Settings │ │ │ │ (Use Enter to select, Tab to change focus) │ │ │ @@ -343,7 +334,6 @@ exports[`SettingsDialog > Snapshot Tests > should render with various boolean se │ Apply To │ │ ● User Settings │ │ Workspace Settings │ -│ System Settings │ │ │ │ (Use Enter to select, Tab to change focus) │ │ │ diff --git a/packages/cli/src/ui/components/__snapshots__/ThemeDialog.test.tsx.snap b/packages/cli/src/ui/components/__snapshots__/ThemeDialog.test.tsx.snap index 084525d5..09787eca 100644 --- a/packages/cli/src/ui/components/__snapshots__/ThemeDialog.test.tsx.snap +++ b/packages/cli/src/ui/components/__snapshots__/ThemeDialog.test.tsx.snap @@ -6,7 +6,6 @@ exports[`ThemeDialog Snapshots > should render correctly in scope selector mode │ > Apply To │ │ ● 1. User Settings │ │ 2. Workspace Settings │ -│ 3. System Settings │ │ │ │ (Use Enter to apply scope, Tab to select theme) │ │ │ diff --git a/packages/cli/src/ui/contexts/UIActionsContext.tsx b/packages/cli/src/ui/contexts/UIActionsContext.tsx index e6802965..409b4c4c 100644 --- a/packages/cli/src/ui/contexts/UIActionsContext.tsx +++ b/packages/cli/src/ui/contexts/UIActionsContext.tsx @@ -8,7 +8,11 @@ import { createContext, useContext } from 'react'; import { type Key } from '../hooks/useKeypress.js'; import { type IdeIntegrationNudgeResult } from '../IdeIntegrationNudge.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 { AuthState } from '../types.js'; import { type VisionSwitchOutcome } from '../components/ModelSwitchDialog.js'; @@ -19,6 +23,10 @@ export interface UIActions { scope: SettingScope, ) => void; handleThemeHighlight: (themeName: string | undefined) => void; + handleApprovalModeSelect: ( + mode: ApprovalMode | undefined, + scope: SettingScope, + ) => void; handleAuthSelect: ( authType: AuthType | undefined, scope: SettingScope, diff --git a/packages/cli/src/ui/contexts/UIStateContext.tsx b/packages/cli/src/ui/contexts/UIStateContext.tsx index e2fd4cf5..fae2db66 100644 --- a/packages/cli/src/ui/contexts/UIStateContext.tsx +++ b/packages/cli/src/ui/contexts/UIStateContext.tsx @@ -69,6 +69,7 @@ export interface UIState { isSettingsDialogOpen: boolean; isModelDialogOpen: boolean; isPermissionsDialogOpen: boolean; + isApprovalModeDialogOpen: boolean; slashCommands: readonly SlashCommand[]; pendingSlashCommandHistoryItems: HistoryItemWithoutId[]; commandContext: CommandContext; diff --git a/packages/cli/src/ui/hooks/slashCommandProcessor.ts b/packages/cli/src/ui/hooks/slashCommandProcessor.ts index cba3bf7a..45411f94 100644 --- a/packages/cli/src/ui/hooks/slashCommandProcessor.ts +++ b/packages/cli/src/ui/hooks/slashCommandProcessor.ts @@ -48,6 +48,7 @@ interface SlashCommandProcessorActions { openSettingsDialog: () => void; openModelDialog: () => void; openPermissionsDialog: () => void; + openApprovalModeDialog: () => void; quit: (messages: HistoryItem[]) => void; setDebugMessage: (message: string) => void; toggleCorgiMode: () => void; @@ -396,6 +397,9 @@ export const useSlashCommandProcessor = ( case 'subagent_list': actions.openAgentsManagerDialog(); return { type: 'handled' }; + case 'approval-mode': + actions.openApprovalModeDialog(); + return { type: 'handled' }; case 'help': return { type: 'handled' }; default: { diff --git a/packages/cli/src/ui/hooks/useApprovalModeCommand.ts b/packages/cli/src/ui/hooks/useApprovalModeCommand.ts new file mode 100644 index 00000000..f328ded9 --- /dev/null +++ b/packages/cli/src/ui/hooks/useApprovalModeCommand.ts @@ -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, + }; +}; diff --git a/packages/cli/src/ui/hooks/useDialogClose.ts b/packages/cli/src/ui/hooks/useDialogClose.ts index 8c944996..06e221ac 100644 --- a/packages/cli/src/ui/hooks/useDialogClose.ts +++ b/packages/cli/src/ui/hooks/useDialogClose.ts @@ -6,13 +6,20 @@ import { useCallback } from 'react'; 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 { // Theme dialog isThemeDialogOpen: boolean; handleThemeSelect: (theme: string | undefined, scope: SettingScope) => void; + // Approval mode dialog + isApprovalModeDialogOpen: boolean; + handleApprovalModeSelect: ( + mode: ApprovalMode | undefined, + scope: SettingScope, + ) => void; + // Auth dialog isAuthDialogOpen: boolean; handleAuthSelect: ( @@ -57,6 +64,12 @@ export function useDialogClose(options: DialogCloseOptions) { return true; } + if (options.isApprovalModeDialogOpen) { + // Mimic ESC behavior: onSelect(undefined, selectedScope) - keeps current mode + options.handleApprovalModeSelect(undefined, SettingScope.User); + return true; + } + if (options.isEditorDialogOpen) { // Mimic ESC behavior: call onExit() directly options.exitEditorDialog(); diff --git a/packages/cli/src/utils/dialogScopeUtils.ts b/packages/cli/src/utils/dialogScopeUtils.ts index fd4cbbd4..027928ab 100644 --- a/packages/cli/src/utils/dialogScopeUtils.ts +++ b/packages/cli/src/utils/dialogScopeUtils.ts @@ -14,7 +14,11 @@ import { settingExistsInScope } from './settingsUtils.js'; export const SCOPE_LABELS = { [SettingScope.User]: 'User 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; /** @@ -27,7 +31,7 @@ export function getScopeItems() { label: SCOPE_LABELS[SettingScope.Workspace], value: SettingScope.Workspace, }, - { label: SCOPE_LABELS[SettingScope.System], value: SettingScope.System }, + // { label: SCOPE_LABELS[SettingScope.System], value: SettingScope.System }, ]; }