Add Interactive Approval Mode Dialog (#1012)

This commit is contained in:
tanzhenxin
2025-11-13 19:02:53 +08:00
committed by GitHub
parent 0752a31e1e
commit 160b64523e
16 changed files with 397 additions and 909 deletions

View 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>
);
}

View File

@@ -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}
/>
</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>
);

View File

@@ -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 && (
<Box marginTop={1} flexDirection="column">
<Text bold={focusSection === 'scope'} wrap="truncate">
{focusSection === 'scope' ? '> ' : ' '}Apply To
</Text>
<RadioButtonSelect
items={scopeItems}
initialIndex={scopeItems.findIndex(
(item) => item.value === selectedScope,
)}
<Box marginTop={1}>
<ScopeSelector
onSelect={handleScopeSelect}
onHighlight={handleScopeHighlight}
isFocused={focusSection === 'scope'}
showNumbers={focusSection === 'scope'}
initialScope={selectedScope}
/>
</Box>
)}

View File

@@ -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) │
│ │

View File

@@ -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) │
│ │