summaryrefslogtreecommitdiff
path: root/packages/cli/src/ui/components/SettingsDialog.tsx
diff options
context:
space:
mode:
Diffstat (limited to 'packages/cli/src/ui/components/SettingsDialog.tsx')
-rw-r--r--packages/cli/src/ui/components/SettingsDialog.tsx465
1 files changed, 465 insertions, 0 deletions
diff --git a/packages/cli/src/ui/components/SettingsDialog.tsx b/packages/cli/src/ui/components/SettingsDialog.tsx
new file mode 100644
index 00000000..80e2339f
--- /dev/null
+++ b/packages/cli/src/ui/components/SettingsDialog.tsx
@@ -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>
+ );
+}