feat(ui): add /settings command and UI panel (#4738)

Co-authored-by: Jacob Richman <jacob314@gmail.com>
This commit is contained in:
Ali Al Jufairi
2025-08-10 09:04:52 +09:00
committed by GitHub
parent c632ec8b03
commit 8a9a927544
17 changed files with 3521 additions and 109 deletions

View File

@@ -0,0 +1,465 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import React, { useState, useEffect } from 'react';
import { Box, Text, useInput } from 'ink';
import { Colors } from '../colors.js';
import {
LoadedSettings,
SettingScope,
Settings,
} from '../../config/settings.js';
import {
getScopeItems,
getScopeMessageForSetting,
} from '../../utils/dialogScopeUtils.js';
import { RadioButtonSelect } from './shared/RadioButtonSelect.js';
import {
getDialogSettingKeys,
getSettingValue,
setPendingSettingValue,
getDisplayValue,
hasRestartRequiredSettings,
saveModifiedSettings,
getSettingDefinition,
isDefaultValue,
requiresRestart,
getRestartRequiredFromModified,
getDefaultValue,
} from '../../utils/settingsUtils.js';
import { useVimMode } from '../contexts/VimModeContext.js';
interface SettingsDialogProps {
settings: LoadedSettings;
onSelect: (settingName: string | undefined, scope: SettingScope) => void;
onRestartRequest?: () => void;
}
const maxItemsToShow = 8;
export function SettingsDialog({
settings,
onSelect,
onRestartRequest,
}: SettingsDialogProps): React.JSX.Element {
// Get vim mode context to sync vim mode changes
const { vimEnabled, toggleVimEnabled } = useVimMode();
// Focus state: 'settings' or 'scope'
const [focusSection, setFocusSection] = useState<'settings' | 'scope'>(
'settings',
);
// Scope selector state (User by default)
const [selectedScope, setSelectedScope] = useState<SettingScope>(
SettingScope.User,
);
// Active indices
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>(() =>
// Deep clone to avoid mutation
structuredClone(settings.forScope(selectedScope).settings),
);
// Track which settings have been modified by the user
const [modifiedSettings, setModifiedSettings] = useState<Set<string>>(
new Set(),
);
// Track the intended values for modified settings
const [modifiedValues, setModifiedValues] = useState<Map<string, boolean>>(
new Map(),
);
// Track restart-required settings across scope changes
const [restartRequiredSettings, setRestartRequiredSettings] = useState<
Set<string>
>(new Set());
useEffect(() => {
setPendingSettings(
structuredClone(settings.forScope(selectedScope).settings),
);
// Don't reset modifiedSettings when scope changes - preserve user's pending changes
if (restartRequiredSettings.size === 0) {
setShowRestartPrompt(false);
}
}, [selectedScope, settings, restartRequiredSettings]);
// Preserve pending changes when scope changes
useEffect(() => {
if (modifiedSettings.size > 0) {
setPendingSettings((prevPending) => {
let updatedPending = { ...prevPending };
// Reapply all modified settings to the new pending settings using stored values
modifiedSettings.forEach((key) => {
const storedValue = modifiedValues.get(key);
if (storedValue !== undefined) {
updatedPending = setPendingSettingValue(
key,
storedValue,
updatedPending,
);
}
});
return updatedPending;
});
}
}, [selectedScope, modifiedSettings, modifiedValues, settings]);
const generateSettingsItems = () => {
const settingKeys = getDialogSettingKeys();
return settingKeys.map((key: string) => {
const currentValue = getSettingValue(key, pendingSettings, {});
const definition = getSettingDefinition(key);
return {
label: definition?.label || key,
value: key,
checked: currentValue,
toggle: () => {
const newValue = !currentValue;
setPendingSettings((prev) =>
setPendingSettingValue(key, newValue, prev),
);
if (!requiresRestart(key)) {
const immediateSettings = new Set([key]);
const immediateSettingsObject = setPendingSettingValue(
key,
newValue,
{},
);
console.log(
`[DEBUG SettingsDialog] Saving ${key} immediately with value:`,
newValue,
);
saveModifiedSettings(
immediateSettings,
immediateSettingsObject,
settings,
selectedScope,
);
// Special handling for vim mode to sync with VimModeContext
if (key === 'vimMode' && newValue !== vimEnabled) {
// Call toggleVimEnabled to sync the VimModeContext local state
toggleVimEnabled().catch((error) => {
console.error('Failed to toggle vim mode:', error);
});
}
// Capture the current modified settings before updating state
const currentModifiedSettings = new Set(modifiedSettings);
// Remove the saved setting from modifiedSettings since it's now saved
setModifiedSettings((prev) => {
const updated = new Set(prev);
updated.delete(key);
return updated;
});
// Remove from modifiedValues as well
setModifiedValues((prev) => {
const updated = new Map(prev);
updated.delete(key);
return updated;
});
// Also remove from restart-required settings if it was there
setRestartRequiredSettings((prev) => {
const updated = new Set(prev);
updated.delete(key);
return updated;
});
setPendingSettings((_prevPending) => {
let updatedPending = structuredClone(
settings.forScope(selectedScope).settings,
);
currentModifiedSettings.forEach((modifiedKey) => {
if (modifiedKey !== key) {
const modifiedValue = modifiedValues.get(modifiedKey);
if (modifiedValue !== undefined) {
updatedPending = setPendingSettingValue(
modifiedKey,
modifiedValue,
updatedPending,
);
}
}
});
return updatedPending;
});
} else {
// For restart-required settings, store the actual value
setModifiedValues((prev) => {
const updated = new Map(prev);
updated.set(key, newValue);
return updated;
});
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;
});
}
},
};
});
};
const items = generateSettingsItems();
// Scope selector items
const scopeItems = getScopeItems();
const handleScopeHighlight = (scope: SettingScope) => {
setSelectedScope(scope);
};
const handleScopeSelect = (scope: SettingScope) => {
handleScopeHighlight(scope);
setFocusSection('settings');
};
// Scroll logic for settings
const visibleItems = items.slice(scrollOffset, scrollOffset + maxItemsToShow);
// Always show arrows for consistent UI and to indicate circular navigation
const showScrollUp = true;
const showScrollDown = true;
useInput((input, key) => {
if (key.tab) {
setFocusSection((prev) => (prev === 'settings' ? 'scope' : 'settings'));
}
if (focusSection === 'settings') {
if (key.upArrow || input === 'k') {
const newIndex =
activeSettingIndex > 0 ? activeSettingIndex - 1 : items.length - 1;
setActiveSettingIndex(newIndex);
// Adjust scroll offset for wrap-around
if (newIndex === items.length - 1) {
setScrollOffset(Math.max(0, items.length - maxItemsToShow));
} else if (newIndex < scrollOffset) {
setScrollOffset(newIndex);
}
} else if (key.downArrow || input === 'j') {
const newIndex =
activeSettingIndex < items.length - 1 ? activeSettingIndex + 1 : 0;
setActiveSettingIndex(newIndex);
// Adjust scroll offset for wrap-around
if (newIndex === 0) {
setScrollOffset(0);
} else if (newIndex >= scrollOffset + maxItemsToShow) {
setScrollOffset(newIndex - maxItemsToShow + 1);
}
} else if (key.return || input === ' ') {
items[activeSettingIndex]?.toggle();
} else if ((key.ctrl && input === 'c') || (key.ctrl && input === 'l')) {
// Ctrl+C or Ctrl+L: Clear current setting and reset to default
const currentSetting = items[activeSettingIndex];
if (currentSetting) {
const defaultValue = getDefaultValue(currentSetting.value);
// Ensure defaultValue is a boolean for setPendingSettingValue
const booleanDefaultValue =
typeof defaultValue === 'boolean' ? defaultValue : false;
// Update pending settings to default value
setPendingSettings((prev) =>
setPendingSettingValue(
currentSetting.value,
booleanDefaultValue,
prev,
),
);
// Remove from modified settings since it's now at default
setModifiedSettings((prev) => {
const updated = new Set(prev);
updated.delete(currentSetting.value);
return updated;
});
// Remove from restart-required settings if it was there
setRestartRequiredSettings((prev) => {
const updated = new Set(prev);
updated.delete(currentSetting.value);
return updated;
});
// If this setting doesn't require restart, save it immediately
if (!requiresRestart(currentSetting.value)) {
const immediateSettings = new Set([currentSetting.value]);
const immediateSettingsObject = setPendingSettingValue(
currentSetting.value,
booleanDefaultValue,
{},
);
saveModifiedSettings(
immediateSettings,
immediateSettingsObject,
settings,
selectedScope,
);
}
}
}
}
if (showRestartPrompt && input === 'r') {
// Only save settings that require restart (non-restart settings were already saved immediately)
const restartRequiredSettings =
getRestartRequiredFromModified(modifiedSettings);
const restartRequiredSet = new Set(restartRequiredSettings);
if (restartRequiredSet.size > 0) {
saveModifiedSettings(
restartRequiredSet,
pendingSettings,
settings,
selectedScope,
);
}
setShowRestartPrompt(false);
setRestartRequiredSettings(new Set()); // Clear restart-required settings
if (onRestartRequest) onRestartRequest();
}
if (key.escape) {
onSelect(undefined, selectedScope);
}
});
return (
<Box
borderStyle="round"
borderColor={Colors.Gray}
flexDirection="row"
padding={1}
width="100%"
height="100%"
>
<Box flexDirection="column" flexGrow={1}>
<Text bold color={Colors.AccentBlue}>
Settings
</Text>
<Box height={1} />
{showScrollUp && <Text color={Colors.Gray}></Text>}
{visibleItems.map((item, idx) => {
const isActive =
focusSection === 'settings' &&
activeSettingIndex === idx + scrollOffset;
const scopeSettings = settings.forScope(selectedScope).settings;
const mergedSettings = settings.merged;
const displayValue = getDisplayValue(
item.value,
scopeSettings,
mergedSettings,
modifiedSettings,
pendingSettings,
);
const shouldBeGreyedOut = isDefaultValue(item.value, scopeSettings);
// Generate scope message for this setting
const scopeMessage = getScopeMessageForSetting(
item.value,
selectedScope,
settings,
);
return (
<React.Fragment key={item.value}>
<Box flexDirection="row" alignItems="center">
<Box minWidth={2} flexShrink={0}>
<Text color={isActive ? Colors.AccentGreen : Colors.Gray}>
{isActive ? '●' : ''}
</Text>
</Box>
<Box minWidth={50}>
<Text
color={isActive ? Colors.AccentGreen : Colors.Foreground}
>
{item.label}
{scopeMessage && (
<Text color={Colors.Gray}> {scopeMessage}</Text>
)}
</Text>
</Box>
<Box minWidth={3} />
<Text
color={
isActive
? Colors.AccentGreen
: shouldBeGreyedOut
? Colors.Gray
: Colors.Foreground
}
>
{displayValue}
</Text>
</Box>
<Box height={1} />
</React.Fragment>
);
})}
{showScrollDown && <Text color={Colors.Gray}></Text>}
<Box height={1} />
<Box marginTop={1} flexDirection="column">
<Text bold={focusSection === 'scope'} wrap="truncate">
{focusSection === 'scope' ? '> ' : ' '}Apply To
</Text>
<RadioButtonSelect
items={scopeItems}
initialIndex={0}
onSelect={handleScopeSelect}
onHighlight={handleScopeHighlight}
isFocused={focusSection === 'scope'}
showNumbers={focusSection === 'scope'}
/>
</Box>
<Box height={1} />
<Text color={Colors.Gray}>
(Use Enter to select, Tab to change focus)
</Text>
{showRestartPrompt && (
<Text color={Colors.AccentYellow}>
To see changes, Gemini CLI must be restarted. Press r to exit and
apply changes now.
</Text>
)}
</Box>
</Box>
);
}