feat(cli): support LLM output language configuration in Settings Dialog

This commit is contained in:
tanzhenxin 2026-01-20 17:00:19 +08:00
parent 0c960add8d
commit 4ae8584c81
15 changed files with 1122 additions and 474 deletions

View file

@ -34,6 +34,7 @@ import {
saveModifiedSettings,
TEST_ONLY,
} from '../../utils/settingsUtils.js';
import { OUTPUT_LANGUAGE_AUTO } from '../../utils/languageUtils.js';
// Mock the VimModeContext
const mockToggleVimEnabled = vi.fn();
@ -282,7 +283,12 @@ describe('SettingsDialog', () => {
stdin.write(TerminalKeys.DOWN_ARROW as string); // Down arrow
});
expect(lastFrame()).toContain('● Language');
const secondKey = getDialogSettingKeys()[1];
expect(secondKey).toBeDefined();
const secondLabel = secondKey
? (getSettingDefinition(secondKey)?.label ?? secondKey)
: '';
expect(lastFrame()).toContain(`${secondLabel}`);
// The active index should have changed (tested indirectly through behavior)
unmount();
@ -375,14 +381,17 @@ describe('SettingsDialog', () => {
expect(lastFrame()).toContain('● Tool Approval Mode');
});
// Navigate to Vim Mode setting (third setting - a boolean) and verify we're there
act(() => {
stdin.write(TerminalKeys.DOWN_ARROW as string); // -> Language
});
await wait();
act(() => {
stdin.write(TerminalKeys.DOWN_ARROW as string); // -> Vim Mode
});
const dialogKeys = getDialogSettingKeys();
const targetIndex = dialogKeys.indexOf('general.vimMode');
expect(targetIndex).toBeGreaterThan(0);
// Navigate to Vim Mode setting and verify we're there
for (let i = 0; i < targetIndex; i++) {
act(() => {
stdin.write(TerminalKeys.DOWN_ARROW as string);
});
await wait();
}
await waitFor(() => {
expect(lastFrame()).toContain('● Vim Mode');
});
@ -579,7 +588,7 @@ describe('SettingsDialog', () => {
// Wait for initial render
await waitFor(() => {
expect(lastFrame()).toContain('Vim Mode');
expect(lastFrame()).toContain('Tool Approval Mode');
});
// The UI should show settings mode is active (scope is in separate view)
@ -651,7 +660,7 @@ describe('SettingsDialog', () => {
// Wait for initial render
await waitFor(() => {
expect(lastFrame()).toContain('Vim Mode');
expect(lastFrame()).toContain('Tool Approval Mode');
});
// Verify the dialog is rendered properly (scope is in separate view)
@ -857,17 +866,40 @@ describe('SettingsDialog', () => {
unmount();
});
it('should clear restart prompt when switching scopes', async () => {
it('should keep restart prompt when switching scopes', async () => {
const settings = createMockSettings();
const onSelect = vi.fn();
const { unmount } = render(
const { stdin, lastFrame, unmount } = render(
<KeypressProvider kittyProtocolEnabled={false}>
<SettingsDialog settings={settings} onSelect={onSelect} />
</KeypressProvider>,
);
// Restart prompt should be cleared when switching scopes
// Trigger a restart-required setting change: navigate to "Language: UI" (2nd item) and toggle it.
stdin.write(TerminalKeys.DOWN_ARROW as string);
await wait();
stdin.write(TerminalKeys.ENTER as string);
await wait();
await waitFor(() => {
expect(lastFrame()).toContain(
'To see changes, Qwen Code must be restarted',
);
});
// Switch scopes; restart prompt should remain visible.
stdin.write(TerminalKeys.TAB as string);
await wait();
stdin.write('2');
await wait();
await waitFor(() => {
expect(lastFrame()).toContain(
'To see changes, Qwen Code must be restarted',
);
});
unmount();
});
});
@ -912,6 +944,44 @@ describe('SettingsDialog', () => {
});
});
describe('Output Language', () => {
it('treats empty output language as auto', async () => {
const settings = createMockSettings({
general: { outputLanguage: 'en' },
});
const { stdin, unmount } = render(
<KeypressProvider kittyProtocolEnabled={false}>
<SettingsDialog settings={settings} onSelect={() => {}} />
</KeypressProvider>,
);
// Navigate to "Language: Model" (3rd item), start editing, then commit empty.
stdin.write(TerminalKeys.DOWN_ARROW as string);
await wait();
stdin.write(TerminalKeys.DOWN_ARROW as string);
await wait();
stdin.write(TerminalKeys.ENTER as string);
await wait();
stdin.write(TerminalKeys.ENTER as string);
await wait();
// Empty input should set 'auto' in settings (rule file is updated on restart)
const outputLanguageCall = vi
.mocked(saveModifiedSettings)
.mock.calls.find((call) =>
(call[0] as Set<string>).has('general.outputLanguage'),
);
expect(outputLanguageCall).toBeTruthy();
// Should save 'auto' to settings
expect(outputLanguageCall?.[1]).toMatchObject({
general: { outputLanguage: OUTPUT_LANGUAGE_AUTO },
});
unmount();
});
});
describe('Keyboard Shortcuts Edge Cases', () => {
it('should handle rapid key presses gracefully', async () => {
const settings = createMockSettings();
@ -1001,7 +1071,7 @@ describe('SettingsDialog', () => {
// Wait for initial render
await waitFor(() => {
expect(lastFrame()).toContain('Vim Mode');
expect(lastFrame()).toContain('Tool Approval Mode');
});
// Verify initial state: settings mode active (scope is in separate view)
@ -1063,7 +1133,7 @@ describe('SettingsDialog', () => {
// Wait for initial render
await waitFor(() => {
expect(lastFrame()).toContain('Vim Mode');
expect(lastFrame()).toContain('Tool Approval Mode');
});
// Verify the complete UI is rendered (scope is in separate view)

View file

@ -17,7 +17,6 @@ import {
getDialogSettingKeys,
setPendingSettingValue,
getDisplayValue,
hasRestartRequiredSettings,
saveModifiedSettings,
getSettingDefinition,
isDefaultValue,
@ -28,6 +27,7 @@ import {
getNestedValue,
getEffectiveValue,
} from '../../utils/settingsUtils.js';
import { updateOutputLanguageFile } from '../../utils/languageUtils.js';
import { useVimMode } from '../contexts/VimModeContext.js';
import { type Config } from '@qwen-code/qwen-code-core';
import { useKeypress } from '../hooks/useKeypress.js';
@ -68,7 +68,6 @@ export function SettingsDialog({
const [activeSettingIndex, setActiveSettingIndex] = useState(0);
// Scroll offset for settings
const [scrollOffset, setScrollOffset] = useState(0);
const [showRestartPrompt, setShowRestartPrompt] = useState(false);
// Local pending settings state for the selected scope
const [pendingSettings, setPendingSettings] = useState<Settings>(() =>
@ -88,16 +87,17 @@ export function SettingsDialog({
>(new Map());
// Track restart-required settings across scope changes
const [_restartRequiredSettings, setRestartRequiredSettings] = useState<
const [restartRequiredSettings, setRestartRequiredSettings] = useState<
Set<string>
>(new Set());
const showRestartPrompt = restartRequiredSettings.size > 0;
useEffect(() => {
// Base settings for selected scope
let updated = structuredClone(settings.forScope(selectedScope).settings);
// Overlay globally pending (unsaved) changes so user sees their modifications in any scope
const newModified = new Set<string>();
const newRestartRequired = new Set<string>();
for (const [key, value] of globalPendingChanges.entries()) {
const def = getSettingDefinition(key);
if (def?.type === 'boolean' && typeof value === 'boolean') {
@ -111,12 +111,9 @@ export function SettingsDialog({
updated = setPendingSettingValueAny(key, value, updated);
}
newModified.add(key);
if (requiresRestart(key)) newRestartRequired.add(key);
}
setPendingSettings(updated);
setModifiedSettings(newModified);
setRestartRequiredSettings(newRestartRequired);
setShowRestartPrompt(newRestartRequired.size > 0);
}, [selectedScope, settings, globalPendingChanges]);
const generateSettingsItems = () => {
@ -226,31 +223,22 @@ export function SettingsDialog({
structuredClone(settings.forScope(selectedScope).settings),
);
} else {
// For restart-required settings, track as modified
setModifiedSettings((prev) => {
const updated = new Set(prev).add(key);
const needsRestart = hasRestartRequiredSettings(updated);
console.log(
`[DEBUG SettingsDialog] Modified settings:`,
Array.from(updated),
'Needs restart:',
needsRestart,
);
if (needsRestart) {
setShowRestartPrompt(true);
setRestartRequiredSettings((prevRestart) =>
new Set(prevRestart).add(key),
);
}
return updated;
});
// For restart-required settings, save immediately but show restart prompt
const immediateSettings = new Set([key]);
const immediateSettingsObject = setPendingSettingValueAny(
key,
newValue,
{} as Settings,
);
saveModifiedSettings(
immediateSettings,
immediateSettingsObject,
settings,
selectedScope,
);
// Add/update pending change globally so it persists across scopes
setGlobalPendingChanges((prev) => {
const next = new Map(prev);
next.set(key, newValue as PendingValue);
return next;
});
// Mark as needing restart and show prompt
setRestartRequiredSettings((prev) => new Set(prev).add(key));
}
},
};
@ -293,7 +281,7 @@ export function SettingsDialog({
return;
}
let parsed: string | number;
let parsed: string | number | undefined;
if (type === 'number') {
const numParsed = Number(editBuffer.trim());
if (Number.isNaN(numParsed)) {
@ -306,19 +294,32 @@ export function SettingsDialog({
parsed = numParsed;
} else {
// For strings, use the buffer as is.
parsed = editBuffer;
// Special handling for outputLanguage: empty input means 'auto'
if (key === 'general.outputLanguage') {
const trimmed = editBuffer.trim();
parsed = trimmed === '' ? 'auto' : trimmed;
} else {
parsed = editBuffer;
}
}
// Update pending
setPendingSettings((prev) => setPendingSettingValueAny(key, parsed, prev));
setPendingSettings((prev) =>
parsed === undefined
? setPendingSettingValueAny(
key,
undefined as unknown as SettingsValue,
prev,
)
: setPendingSettingValueAny(key, parsed, prev),
);
if (!requiresRestart(key)) {
const immediateSettings = new Set([key]);
const immediateSettingsObject = setPendingSettingValueAny(
key,
parsed,
{} as Settings,
);
const immediateSettingsObject =
parsed === undefined
? ({} as Settings)
: setPendingSettingValueAny(key, parsed, {} as Settings);
saveModifiedSettings(
immediateSettings,
immediateSettingsObject,
@ -346,25 +347,26 @@ export function SettingsDialog({
return next;
});
} else {
// Mark as modified and needing restart
setModifiedSettings((prev) => {
const updated = new Set(prev).add(key);
const needsRestart = hasRestartRequiredSettings(updated);
if (needsRestart) {
setShowRestartPrompt(true);
setRestartRequiredSettings((prevRestart) =>
new Set(prevRestart).add(key),
);
}
return updated;
});
// For restart-required settings, save immediately but show restart prompt
const immediateSettings = new Set([key]);
const immediateSettingsObject =
parsed === undefined
? ({} as Settings)
: setPendingSettingValueAny(key, parsed, {} as Settings);
saveModifiedSettings(
immediateSettings,
immediateSettingsObject,
settings,
selectedScope,
);
// Record pending change globally for persistence across scopes
setGlobalPendingChanges((prev) => {
const next = new Map(prev);
next.set(key, parsed as PendingValue);
return next;
});
// Update output language rule file immediately (no restart needed for LLM effect)
if (key === 'general.outputLanguage' && typeof parsed === 'string') {
updateOutputLanguageFile(parsed);
}
// Mark as needing restart and show prompt
setRestartRequiredSettings((prev) => new Set(prev).add(key));
}
setEditingKey(null);
@ -691,6 +693,9 @@ export function SettingsDialog({
return next;
});
}
setRestartRequiredSettings((prev) =>
new Set(prev).add(currentSetting.value),
);
}
}
}
@ -720,7 +725,6 @@ export function SettingsDialog({
});
}
setShowRestartPrompt(false);
setRestartRequiredSettings(new Set()); // Clear restart-required settings
if (onRestartRequest) onRestartRequest();
}
@ -837,9 +841,10 @@ export function SettingsDialog({
{isActive ? '●' : ''}
</Text>
</Box>
<Box minWidth={50}>
<Box flexGrow={1} flexShrink={1}>
<Text
color={isActive ? theme.status.success : theme.text.primary}
wrap="truncate"
>
{item.label}
{scopeMessage && (
@ -847,18 +852,20 @@ export function SettingsDialog({
)}
</Text>
</Box>
<Box minWidth={3} />
<Text
color={
isActive
? theme.status.success
: shouldBeGreyedOut
? theme.text.secondary
: theme.text.primary
}
>
{displayValue}
</Text>
<Box marginLeft={1} flexShrink={0}>
<Text
color={
isActive
? theme.status.success
: shouldBeGreyedOut
? theme.text.secondary
: theme.text.primary
}
wrap="truncate"
>
{displayValue}
</Text>
</Box>
</Box>
);
})}

View file

@ -6,14 +6,14 @@ exports[`SettingsDialog > Snapshot Tests > should render default state correctly
│ > Settings │
│ │
│ ▲ │
│ ● Tool Approval Mode Default │
│ Language Auto (detect from system) │
│ Vim Mode false │
│ Interactive Shell (PTY) false │
│ Theme Qwen Dark │
│ ● Tool Approval Mode Default │
│ Language: UI Auto (detect from system) │
│ Language: Model auto │
│ Theme Qwen Dark │
│ Vim Mode false │
│ Interactive Shell (PTY) false │
│ Preferred Editor │
│ Auto-connect to IDE false │
│ Show Line Numbers in Code false │
│ Auto-connect to IDE false │
│ ▼ │
│ │
│ (Use Enter to select, Tab to configure scope) │
@ -27,14 +27,14 @@ exports[`SettingsDialog > Snapshot Tests > should render focused on scope select
│ > Settings │
│ │
│ ▲ │
│ ● Tool Approval Mode Default │
│ Language Auto (detect from system) │
│ Vim Mode false │
│ Interactive Shell (PTY) false │
│ Theme Qwen Dark │
│ ● Tool Approval Mode Default │
│ Language: UI Auto (detect from system) │
│ Language: Model auto │
│ Theme Qwen Dark │
│ Vim Mode false │
│ Interactive Shell (PTY) false │
│ Preferred Editor │
│ Auto-connect to IDE false │
│ Show Line Numbers in Code false │
│ Auto-connect to IDE false │
│ ▼ │
│ │
│ (Use Enter to select, Tab to configure scope) │
@ -48,14 +48,14 @@ exports[`SettingsDialog > Snapshot Tests > should render with accessibility sett
│ > Settings │
│ │
│ ▲ │
│ ● Tool Approval Mode Default │
│ Language Auto (detect from system) │
│ Vim Mode true* │
│ Interactive Shell (PTY) false │
│ Theme Qwen Dark │
│ ● Tool Approval Mode Default │
│ Language: UI Auto (detect from system) │
│ Language: Model auto │
│ Theme Qwen Dark │
│ Vim Mode true* │
│ Interactive Shell (PTY) false │
│ Preferred Editor │
│ Auto-connect to IDE false │
│ Show Line Numbers in Code true* │
│ Auto-connect to IDE false │
│ ▼ │
│ │
│ (Use Enter to select, Tab to configure scope) │
@ -69,14 +69,14 @@ exports[`SettingsDialog > Snapshot Tests > should render with all boolean settin
│ > Settings │
│ │
│ ▲ │
│ ● Tool Approval Mode Default │
│ Language Auto (detect from system) │
│ Vim Mode false* │
│ Interactive Shell (PTY) false │
│ Theme Qwen Dark │
│ ● Tool Approval Mode Default │
│ Language: UI Auto (detect from system) │
│ Language: Model auto │
│ Theme Qwen Dark │
│ Vim Mode false* │
│ Interactive Shell (PTY) false │
│ Preferred Editor │
│ Auto-connect to IDE false* │
│ Show Line Numbers in Code false* │
│ Auto-connect to IDE false* │
│ ▼ │
│ │
│ (Use Enter to select, Tab to configure scope) │
@ -90,14 +90,14 @@ exports[`SettingsDialog > Snapshot Tests > should render with different scope se
│ > Settings │
│ │
│ ▲ │
│ ● Tool Approval Mode Default │
│ Language Auto (detect from system) │
│ Vim Mode (Modified in System) false │
│ Interactive Shell (PTY) false │
│ Theme Qwen Dark │
│ ● Tool Approval Mode Default │
│ Language: UI Auto (detect from system) │
│ Language: Model auto │
│ Theme Qwen Dark │
│ Vim Mode (Modified in System) false │
│ Interactive Shell (PTY) false │
│ Preferred Editor │
│ Auto-connect to IDE false │
│ Show Line Numbers in Code false │
│ Auto-connect to IDE false │
│ ▼ │
│ │
│ (Use Enter to select, Tab to configure scope) │
@ -111,14 +111,14 @@ exports[`SettingsDialog > Snapshot Tests > should render with different scope se
│ > Settings │
│ │
│ ▲ │
│ ● Tool Approval Mode Default │
│ Language Auto (detect from system) │
│ Vim Mode (Modified in Workspace) false │
│ Interactive Shell (PTY) false │
│ Theme Qwen Dark │
│ ● Tool Approval Mode Default │
│ Language: UI Auto (detect from system) │
│ Language: Model auto │
│ Theme Qwen Dark │
│ Vim Mode (Modified in Workspace) false │
│ Interactive Shell (PTY) false │
│ Preferred Editor │
│ Auto-connect to IDE false │
│ Show Line Numbers in Code false │
│ Auto-connect to IDE false │
│ ▼ │
│ │
│ (Use Enter to select, Tab to configure scope) │
@ -132,14 +132,14 @@ exports[`SettingsDialog > Snapshot Tests > should render with file filtering set
│ > Settings │
│ │
│ ▲ │
│ ● Tool Approval Mode Default │
│ Language Auto (detect from system) │
│ Vim Mode false │
│ Interactive Shell (PTY) false │
│ Theme Qwen Dark │
│ ● Tool Approval Mode Default │
│ Language: UI Auto (detect from system) │
│ Language: Model auto │
│ Theme Qwen Dark │
│ Vim Mode false │
│ Interactive Shell (PTY) false │
│ Preferred Editor │
│ Auto-connect to IDE false │
│ Show Line Numbers in Code false │
│ Auto-connect to IDE false │
│ ▼ │
│ │
│ (Use Enter to select, Tab to configure scope) │
@ -153,14 +153,14 @@ exports[`SettingsDialog > Snapshot Tests > should render with mixed boolean and
│ > Settings │
│ │
│ ▲ │
│ ● Tool Approval Mode Default │
│ Language Auto (detect from system) │
│ Vim Mode false* │
│ Interactive Shell (PTY) false │
│ Theme Qwen Dark │
│ ● Tool Approval Mode Default │
│ Language: UI Auto (detect from system) │
│ Language: Model auto │
│ Theme Qwen Dark │
│ Vim Mode false* │
│ Interactive Shell (PTY) false │
│ Preferred Editor │
│ Auto-connect to IDE false │
│ Show Line Numbers in Code false │
│ Auto-connect to IDE false │
│ ▼ │
│ │
│ (Use Enter to select, Tab to configure scope) │
@ -174,14 +174,14 @@ exports[`SettingsDialog > Snapshot Tests > should render with tools and security
│ > Settings │
│ │
│ ▲ │
│ ● Tool Approval Mode Default │
│ Language Auto (detect from system) │
│ Vim Mode false │
│ Interactive Shell (PTY) false │
│ Theme Qwen Dark │
│ ● Tool Approval Mode Default │
│ Language: UI Auto (detect from system) │
│ Language: Model auto │
│ Theme Qwen Dark │
│ Vim Mode false │
│ Interactive Shell (PTY) false │
│ Preferred Editor │
│ Auto-connect to IDE false │
│ Show Line Numbers in Code false │
│ Auto-connect to IDE false │
│ ▼ │
│ │
│ (Use Enter to select, Tab to configure scope) │
@ -195,14 +195,14 @@ exports[`SettingsDialog > Snapshot Tests > should render with various boolean se
│ > Settings │
│ │
│ ▲ │
│ ● Tool Approval Mode Default │
│ Language Auto (detect from system) │
│ Vim Mode true* │
│ Interactive Shell (PTY) false │
│ Theme Qwen Dark │
│ ● Tool Approval Mode Default │
│ Language: UI Auto (detect from system) │
│ Language: Model auto │
│ Theme Qwen Dark │
│ Vim Mode true* │
│ Interactive Shell (PTY) false │
│ Preferred Editor │
│ Auto-connect to IDE true* │
│ Show Line Numbers in Code true* │
│ Auto-connect to IDE true* │
│ ▼ │
│ │
│ (Use Enter to select, Tab to configure scope) │