Files
gemini-cli/packages/cli/src/ui/components/SettingsDialog.tsx
2026-01-27 16:55:35 +00:00

727 lines
23 KiB
TypeScript

/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import type React from 'react';
import { useState, useEffect, useMemo, useCallback } from 'react';
import { Text } from 'ink';
import { AsyncFzf } from 'fzf';
import type { Key } from '../hooks/useKeypress.js';
import { theme } from '../semantic-colors.js';
import type {
LoadableSettingScope,
LoadedSettings,
Settings,
} from '../../config/settings.js';
import { SettingScope } from '../../config/settings.js';
import { getScopeMessageForSetting } from '../../utils/dialogScopeUtils.js';
import {
getDialogSettingKeys,
setPendingSettingValue,
getDisplayValue,
hasRestartRequiredSettings,
saveModifiedSettings,
getSettingDefinition,
isDefaultValue,
requiresRestart,
getRestartRequiredFromModified,
getEffectiveDefaultValue,
setPendingSettingValueAny,
getEffectiveValue,
} from '../../utils/settingsUtils.js';
import { useVimMode } from '../contexts/VimModeContext.js';
import { getCachedStringWidth } from '../utils/textUtils.js';
import {
type SettingsValue,
TOGGLE_TYPES,
} from '../../config/settingsSchema.js';
import { coreEvents, debugLogger } from '@google/gemini-cli-core';
import type { Config } from '@google/gemini-cli-core';
import { useUIState } from '../contexts/UIStateContext.js';
import { useTextBuffer } from './shared/text-buffer.js';
import {
BaseSettingsDialog,
type SettingsDialogItem,
} from './shared/BaseSettingsDialog.js';
interface FzfResult {
item: string;
start: number;
end: number;
score: number;
positions?: number[];
}
interface SettingsDialogProps {
settings: LoadedSettings;
onSelect: (settingName: string | undefined, scope: SettingScope) => void;
onRestartRequest?: () => void;
availableTerminalHeight?: number;
config?: Config;
}
const MAX_ITEMS_TO_SHOW = 8;
export function SettingsDialog({
settings,
onSelect,
onRestartRequest,
availableTerminalHeight,
config,
}: SettingsDialogProps): React.JSX.Element {
// Get vim mode context to sync vim mode changes
const { vimEnabled, toggleVimEnabled } = useVimMode();
// Scope selector state (User by default)
const [selectedScope, setSelectedScope] = useState<LoadableSettingScope>(
SettingScope.User,
);
const [showRestartPrompt, setShowRestartPrompt] = useState(false);
// Search state
const [searchQuery, setSearchQuery] = useState('');
const [filteredKeys, setFilteredKeys] = useState<string[]>(() =>
getDialogSettingKeys(),
);
const { fzfInstance, searchMap } = useMemo(() => {
const keys = getDialogSettingKeys();
const map = new Map<string, string>();
const searchItems: string[] = [];
keys.forEach((key) => {
const def = getSettingDefinition(key);
if (def?.label) {
searchItems.push(def.label);
map.set(def.label.toLowerCase(), key);
}
});
const fzf = new AsyncFzf(searchItems, {
fuzzy: 'v2',
casing: 'case-insensitive',
});
return { fzfInstance: fzf, searchMap: map };
}, []);
// Perform search
useEffect(() => {
let active = true;
if (!searchQuery.trim() || !fzfInstance) {
setFilteredKeys(getDialogSettingKeys());
return;
}
const doSearch = async () => {
const results = await fzfInstance.find(searchQuery);
if (!active) return;
const matchedKeys = new Set<string>();
results.forEach((res: FzfResult) => {
const key = searchMap.get(res.item.toLowerCase());
if (key) matchedKeys.add(key);
});
setFilteredKeys(Array.from(matchedKeys));
};
// eslint-disable-next-line @typescript-eslint/no-floating-promises
doSearch();
return () => {
active = false;
};
}, [searchQuery, fzfInstance, searchMap]);
// 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(),
);
// Preserve pending changes across scope switches
type PendingValue = boolean | number | string;
const [globalPendingChanges, setGlobalPendingChanges] = useState<
Map<string, PendingValue>
>(new Map());
// Track restart-required settings across scope changes
const [_restartRequiredSettings, setRestartRequiredSettings] = useState<
Set<string>
>(new Set());
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') {
updated = setPendingSettingValue(key, value, updated);
} else if (
(def?.type === 'number' && typeof value === 'number') ||
(def?.type === 'string' && typeof value === 'string')
) {
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]);
// Calculate max width for the left column (Label/Description) to keep values aligned or close
const maxLabelOrDescriptionWidth = useMemo(() => {
const allKeys = getDialogSettingKeys();
let max = 0;
for (const key of allKeys) {
const def = getSettingDefinition(key);
if (!def) continue;
const scopeMessage = getScopeMessageForSetting(
key,
selectedScope,
settings,
);
const label = def.label || key;
const labelFull = label + (scopeMessage ? ` ${scopeMessage}` : '');
const lWidth = getCachedStringWidth(labelFull);
const dWidth = def.description
? getCachedStringWidth(def.description)
: 0;
max = Math.max(max, lWidth, dWidth);
}
return max;
}, [selectedScope, settings]);
// Get mainAreaWidth for search buffer viewport
const { mainAreaWidth } = useUIState();
const viewportWidth = mainAreaWidth - 8;
// Search input buffer
const searchBuffer = useTextBuffer({
initialText: '',
initialCursorOffset: 0,
viewport: {
width: viewportWidth,
height: 1,
},
isValidPath: () => false,
singleLine: true,
onChange: (text) => setSearchQuery(text),
});
// Generate items for BaseSettingsDialog
const settingKeys = searchQuery ? filteredKeys : getDialogSettingKeys();
const items: SettingsDialogItem[] = useMemo(() => {
const scopeSettings = settings.forScope(selectedScope).settings;
const mergedSettings = settings.merged;
return settingKeys.map((key) => {
const definition = getSettingDefinition(key);
const type = definition?.type ?? 'string';
// Get the display value (with * indicator if modified)
const displayValue = getDisplayValue(
key,
scopeSettings,
mergedSettings,
modifiedSettings,
pendingSettings,
);
// Get the scope message (e.g., "(Modified in Workspace)")
const scopeMessage = getScopeMessageForSetting(
key,
selectedScope,
settings,
);
// Check if the value is at default (grey it out)
const isGreyedOut = isDefaultValue(key, scopeSettings);
// Get raw value for edit mode initialization
const rawValue = getEffectiveValue(key, pendingSettings, {});
return {
key,
label: definition?.label || key,
description: definition?.description,
type: type as 'boolean' | 'number' | 'string' | 'enum',
displayValue,
isGreyedOut,
scopeMessage,
rawValue: rawValue as string | number | boolean | undefined,
};
});
}, [settingKeys, selectedScope, settings, modifiedSettings, pendingSettings]);
// Scope selection handler
const handleScopeChange = useCallback((scope: LoadableSettingScope) => {
setSelectedScope(scope);
}, []);
// Toggle handler for boolean/enum settings
const handleItemToggle = useCallback(
(key: string, _item: SettingsDialogItem) => {
const definition = getSettingDefinition(key);
if (!TOGGLE_TYPES.has(definition?.type)) {
return;
}
const currentValue = getEffectiveValue(key, pendingSettings, {});
let newValue: SettingsValue;
if (definition?.type === 'boolean') {
newValue = !(currentValue as boolean);
setPendingSettings((prev) =>
setPendingSettingValue(key, newValue as boolean, prev),
);
} else if (definition?.type === 'enum' && definition.options) {
const options = definition.options;
const currentIndex = options?.findIndex(
(opt) => opt.value === currentValue,
);
if (currentIndex !== -1 && currentIndex < options.length - 1) {
newValue = options[currentIndex + 1].value;
} else {
newValue = options[0].value; // loop back to start.
}
setPendingSettings((prev) =>
setPendingSettingValueAny(key, newValue, prev),
);
}
if (!requiresRestart(key)) {
const immediateSettings = new Set([key]);
const currentScopeSettings = settings.forScope(selectedScope).settings;
const immediateSettingsObject = setPendingSettingValueAny(
key,
newValue,
currentScopeSettings,
);
debugLogger.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 === 'general.vimMode' && newValue !== vimEnabled) {
// Call toggleVimEnabled to sync the VimModeContext local state
toggleVimEnabled().catch((error) => {
coreEvents.emitFeedback(
'error',
'Failed to toggle vim mode:',
error,
);
});
}
// Remove from modifiedSettings since it's now saved
setModifiedSettings((prev) => {
const updated = new Set(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;
});
// Remove from global pending changes if present
setGlobalPendingChanges((prev) => {
if (!prev.has(key)) return prev;
const next = new Map(prev);
next.delete(key);
return next;
});
if (key === 'general.previewFeatures') {
config?.setPreviewFeatures(newValue as boolean);
}
} else {
// For restart-required settings, track as modified
setModifiedSettings((prev) => {
const updated = new Set(prev).add(key);
const needsRestart = hasRestartRequiredSettings(updated);
debugLogger.log(
`[DEBUG SettingsDialog] Modified settings:`,
Array.from(updated),
'Needs restart:',
needsRestart,
);
if (needsRestart) {
setShowRestartPrompt(true);
setRestartRequiredSettings((prevRestart) =>
new Set(prevRestart).add(key),
);
}
return updated;
});
// Record pending change globally
setGlobalPendingChanges((prev) => {
const next = new Map(prev);
next.set(key, newValue as PendingValue);
return next;
});
}
},
[
pendingSettings,
settings,
selectedScope,
vimEnabled,
toggleVimEnabled,
config,
],
);
// Edit commit handler
const handleEditCommit = useCallback(
(key: string, newValue: string, _item: SettingsDialogItem) => {
const definition = getSettingDefinition(key);
const type = definition?.type;
if (newValue.trim() === '' && type === 'number') {
// Nothing entered for a number; cancel edit
return;
}
let parsed: string | number;
if (type === 'number') {
const numParsed = Number(newValue.trim());
if (Number.isNaN(numParsed)) {
// Invalid number; cancel edit
return;
}
parsed = numParsed;
} else {
// For strings, use the buffer as is.
parsed = newValue;
}
// Update pending
setPendingSettings((prev) =>
setPendingSettingValueAny(key, parsed, prev),
);
if (!requiresRestart(key)) {
const immediateSettings = new Set([key]);
const currentScopeSettings = settings.forScope(selectedScope).settings;
const immediateSettingsObject = setPendingSettingValueAny(
key,
parsed,
currentScopeSettings,
);
saveModifiedSettings(
immediateSettings,
immediateSettingsObject,
settings,
selectedScope,
);
// Remove from modified sets if present
setModifiedSettings((prev) => {
const updated = new Set(prev);
updated.delete(key);
return updated;
});
setRestartRequiredSettings((prev) => {
const updated = new Set(prev);
updated.delete(key);
return updated;
});
// Remove from global pending since it's immediately saved
setGlobalPendingChanges((prev) => {
if (!prev.has(key)) return prev;
const next = new Map(prev);
next.delete(key);
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;
});
// Record pending change globally for persistence across scopes
setGlobalPendingChanges((prev) => {
const next = new Map(prev);
next.set(key, parsed as PendingValue);
return next;
});
}
},
[settings, selectedScope],
);
// Clear/reset handler - removes the value from settings.json so it falls back to default
const handleItemClear = useCallback(
(key: string, _item: SettingsDialogItem) => {
const defaultValue = getEffectiveDefaultValue(key, config);
// Update local pending state to show the default value
if (typeof defaultValue === 'boolean') {
setPendingSettings((prev) =>
setPendingSettingValue(key, defaultValue, prev),
);
} else if (
typeof defaultValue === 'number' ||
typeof defaultValue === 'string'
) {
setPendingSettings((prev) =>
setPendingSettingValueAny(key, defaultValue, prev),
);
}
// Clear the value from settings.json (set to undefined to remove the key)
if (!requiresRestart(key)) {
settings.setValue(selectedScope, key, undefined);
// Special handling for vim mode
if (key === 'general.vimMode') {
const booleanDefaultValue =
typeof defaultValue === 'boolean' ? defaultValue : false;
if (booleanDefaultValue !== vimEnabled) {
toggleVimEnabled().catch((error) => {
coreEvents.emitFeedback(
'error',
'Failed to toggle vim mode:',
error,
);
});
}
}
if (key === 'general.previewFeatures') {
const booleanDefaultValue =
typeof defaultValue === 'boolean' ? defaultValue : false;
config?.setPreviewFeatures(booleanDefaultValue);
}
}
// Remove from modified sets
setModifiedSettings((prev) => {
const updated = new Set(prev);
updated.delete(key);
return updated;
});
setRestartRequiredSettings((prev) => {
const updated = new Set(prev);
updated.delete(key);
return updated;
});
setGlobalPendingChanges((prev) => {
if (!prev.has(key)) return prev;
const next = new Map(prev);
next.delete(key);
return next;
});
// Update restart prompt
setShowRestartPrompt((_prev) => {
const remaining = getRestartRequiredFromModified(modifiedSettings);
return remaining.filter((k) => k !== key).length > 0;
});
},
[
config,
settings,
selectedScope,
vimEnabled,
toggleVimEnabled,
modifiedSettings,
],
);
const saveRestartRequiredSettings = useCallback(() => {
const restartRequiredSettings =
getRestartRequiredFromModified(modifiedSettings);
const restartRequiredSet = new Set(restartRequiredSettings);
if (restartRequiredSet.size > 0) {
saveModifiedSettings(
restartRequiredSet,
pendingSettings,
settings,
selectedScope,
);
// Remove saved keys from global pending changes
setGlobalPendingChanges((prev) => {
if (prev.size === 0) return prev;
const next = new Map(prev);
for (const key of restartRequiredSet) {
next.delete(key);
}
return next;
});
}
}, [modifiedSettings, pendingSettings, settings, selectedScope]);
// Close handler
const handleClose = useCallback(() => {
// Save any restart-required settings before closing
saveRestartRequiredSettings();
onSelect(undefined, selectedScope as SettingScope);
}, [saveRestartRequiredSettings, onSelect, selectedScope]);
// Custom key handler for restart key
const handleKeyPress = useCallback(
(key: Key, _currentItem: SettingsDialogItem | undefined): boolean => {
// 'r' key for restart
if (showRestartPrompt && key.sequence === 'r') {
saveRestartRequiredSettings();
setShowRestartPrompt(false);
setModifiedSettings(new Set());
setRestartRequiredSettings(new Set());
if (onRestartRequest) onRestartRequest();
return true;
}
return false;
},
[showRestartPrompt, onRestartRequest, saveRestartRequiredSettings],
);
// Calculate effective max items and scope visibility based on terminal height
const { effectiveMaxItemsToShow, showScopeSelection, showSearch } =
useMemo(() => {
// Only show scope selector if we have a workspace
const hasWorkspace = settings.workspace.path !== undefined;
// Search box is hidden when restart prompt is shown to save space and avoid key conflicts
const shouldShowSearch = !showRestartPrompt;
if (!availableTerminalHeight) {
return {
effectiveMaxItemsToShow: Math.min(MAX_ITEMS_TO_SHOW, items.length),
showScopeSelection: hasWorkspace,
showSearch: shouldShowSearch,
};
}
// Layout constants based on BaseSettingsDialog structure:
// 4 for border (2) and padding (2)
const DIALOG_PADDING = 4;
const SETTINGS_TITLE_HEIGHT = 1;
// 3 for box + 1 for marginTop + 1 for spacing after
const SEARCH_SECTION_HEIGHT = shouldShowSearch ? 5 : 0;
const SCROLL_ARROWS_HEIGHT = 2;
const ITEMS_SPACING_AFTER = 1;
// 1 for Label + 3 for Scope items + 1 for spacing after
const SCOPE_SECTION_HEIGHT = hasWorkspace ? 5 : 0;
const HELP_TEXT_HEIGHT = 1;
const RESTART_PROMPT_HEIGHT = showRestartPrompt ? 1 : 0;
const ITEM_HEIGHT = 3; // Label + description + spacing
const currentAvailableHeight = availableTerminalHeight - DIALOG_PADDING;
const baseFixedHeight =
SETTINGS_TITLE_HEIGHT +
SEARCH_SECTION_HEIGHT +
SCROLL_ARROWS_HEIGHT +
ITEMS_SPACING_AFTER +
HELP_TEXT_HEIGHT +
RESTART_PROMPT_HEIGHT;
// Calculate max items with scope selector
const heightWithScope = baseFixedHeight + SCOPE_SECTION_HEIGHT;
const availableForItemsWithScope =
currentAvailableHeight - heightWithScope;
const maxItemsWithScope = Math.max(
1,
Math.floor(availableForItemsWithScope / ITEM_HEIGHT),
);
// Calculate max items without scope selector
const availableForItemsWithoutScope =
currentAvailableHeight - baseFixedHeight;
const maxItemsWithoutScope = Math.max(
1,
Math.floor(availableForItemsWithoutScope / ITEM_HEIGHT),
);
// In small terminals, hide scope selector if it would allow more items to show
let shouldShowScope = hasWorkspace;
let maxItems = maxItemsWithScope;
if (hasWorkspace && availableTerminalHeight < 25) {
// Hide scope selector if it gains us more than 1 extra item
if (maxItemsWithoutScope > maxItemsWithScope + 1) {
shouldShowScope = false;
maxItems = maxItemsWithoutScope;
}
}
return {
effectiveMaxItemsToShow: Math.min(maxItems, items.length),
showScopeSelection: shouldShowScope,
showSearch: shouldShowSearch,
};
}, [
availableTerminalHeight,
items.length,
settings.workspace.path,
showRestartPrompt,
]);
// Footer content for restart prompt
const footerContent = showRestartPrompt ? (
<Text color={theme.status.warning}>
To see changes, Gemini CLI must be restarted. Press r to exit and apply
changes now.
</Text>
) : null;
return (
<BaseSettingsDialog
title="Settings"
borderColor={showRestartPrompt ? theme.status.warning : undefined}
searchEnabled={showSearch}
searchBuffer={searchBuffer}
items={items}
showScopeSelector={showScopeSelection}
selectedScope={selectedScope}
onScopeChange={handleScopeChange}
maxItemsToShow={effectiveMaxItemsToShow}
maxLabelWidth={maxLabelOrDescriptionWidth}
onItemToggle={handleItemToggle}
onEditCommit={handleEditCommit}
onItemClear={handleItemClear}
onClose={handleClose}
onKeyPress={handleKeyPress}
footerContent={footerContent}
/>
);
}