chore(refactor): extract BaseSettingsDialog component (#17369)

This commit is contained in:
Sandy Tao
2026-01-23 11:29:29 -08:00
committed by GitHub
parent 37c7286295
commit 68f5f6d3b0
2 changed files with 677 additions and 521 deletions
+256 -431
View File
@@ -4,8 +4,8 @@
* SPDX-License-Identifier: Apache-2.0
*/
import React, { useState, useEffect, useMemo } from 'react';
import { Box, Text } from 'ink';
import React, { useState, useEffect, useMemo, useCallback } from 'react';
import { Text } from 'ink';
import { AsyncFzf } from 'fzf';
import { theme } from '../semantic-colors.js';
import type {
@@ -14,11 +14,7 @@ import type {
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 {
getDialogSettingKeys,
setPendingSettingValue,
@@ -36,7 +32,6 @@ import {
} from '../../utils/settingsUtils.js';
import { useVimMode } from '../contexts/VimModeContext.js';
import { useKeypress } from '../hooks/useKeypress.js';
import chalk from 'chalk';
import {
cpSlice,
cpLen,
@@ -52,7 +47,10 @@ import { keyMatchers, Command } from '../keyMatchers.js';
import type { Config } from '@google/gemini-cli-core';
import { useUIState } from '../contexts/UIStateContext.js';
import { useTextBuffer } from './shared/text-buffer.js';
import { TextInput } from './shared/TextInput.js';
import {
BaseSettingsDialog,
type SettingsDialogItem,
} from './shared/BaseSettingsDialog.js';
interface FzfResult {
item: string;
@@ -90,6 +88,16 @@ export function SettingsDialog({
const [selectedScope, setSelectedScope] = useState<LoadableSettingScope>(
SettingScope.User,
);
// Scope selection handlers
const handleScopeHighlight = useCallback((scope: LoadableSettingScope) => {
setSelectedScope(scope);
}, []);
const handleScopeSelect = useCallback((scope: LoadableSettingScope) => {
setSelectedScope(scope);
setFocusSection('settings');
}, []);
// Active indices
const [activeSettingIndex, setActiveSettingIndex] = useState(0);
// Scroll offset for settings
@@ -224,18 +232,129 @@ export function SettingsDialog({
return max;
}, [selectedScope, settings]);
const generateSettingsItems = () => {
const settingKeys = searchQuery ? filteredKeys : getDialogSettingKeys();
// Generic edit state
const [editingKey, setEditingKey] = useState<string | null>(null);
const [editBuffer, setEditBuffer] = useState<string>('');
const [editCursorPos, setEditCursorPos] = useState<number>(0);
const [cursorVisible, setCursorVisible] = useState<boolean>(true);
return settingKeys.map((key: string) => {
useEffect(() => {
if (!editingKey) {
setCursorVisible(true);
return;
}
const id = setInterval(() => setCursorVisible((v) => !v), 500);
return () => clearInterval(id);
}, [editingKey]);
const startEditing = useCallback((key: string, initial?: string) => {
setEditingKey(key);
const initialValue = initial ?? '';
setEditBuffer(initialValue);
setEditCursorPos(cpLen(initialValue));
}, []);
const commitEdit = useCallback(
(key: string) => {
const definition = getSettingDefinition(key);
const type = definition?.type;
return {
label: definition?.label || key,
description: definition?.description,
value: key,
type: definition?.type,
toggle: () => {
if (editBuffer.trim() === '' && type === 'number') {
// Nothing entered for a number; cancel edit
setEditingKey(null);
setEditBuffer('');
setEditCursorPos(0);
return;
}
let parsed: string | number;
if (type === 'number') {
const numParsed = Number(editBuffer.trim());
if (Number.isNaN(numParsed)) {
// Invalid number; cancel edit
setEditingKey(null);
setEditBuffer('');
setEditCursorPos(0);
return;
}
parsed = numParsed;
} else {
// For strings, use the buffer as is.
parsed = editBuffer;
}
// 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;
});
}
setEditingKey(null);
setEditBuffer('');
setEditCursorPos(0);
},
[editBuffer, settings, selectedScope],
);
// Toggle handler for boolean/enum settings
const toggleSetting = useCallback(
(key: string) => {
const definition = getSettingDefinition(key);
if (!TOGGLE_TYPES.has(definition?.type)) {
return;
}
@@ -263,8 +382,7 @@ export function SettingsDialog({
if (!requiresRestart(key)) {
const immediateSettings = new Set([key]);
const currentScopeSettings =
settings.forScope(selectedScope).settings;
const currentScopeSettings = settings.forScope(selectedScope).settings;
const immediateSettingsObject = setPendingSettingValueAny(
key,
newValue,
@@ -346,155 +464,96 @@ export function SettingsDialog({
});
}
},
};
});
};
const items = generateSettingsItems();
// Generic edit state
const [editingKey, setEditingKey] = useState<string | null>(null);
const [editBuffer, setEditBuffer] = useState<string>('');
const [editCursorPos, setEditCursorPos] = useState<number>(0); // Cursor position within edit buffer
const [cursorVisible, setCursorVisible] = useState<boolean>(true);
useEffect(() => {
if (!editingKey) {
setCursorVisible(true);
return;
}
const id = setInterval(() => setCursorVisible((v) => !v), 500);
return () => clearInterval(id);
}, [editingKey]);
const startEditing = (key: string, initial?: string) => {
setEditingKey(key);
const initialValue = initial ?? '';
setEditBuffer(initialValue);
setEditCursorPos(cpLen(initialValue)); // Position cursor at end of initial value
};
const commitEdit = (key: string) => {
const definition = getSettingDefinition(key);
const type = definition?.type;
if (editBuffer.trim() === '' && type === 'number') {
// Nothing entered for a number; cancel edit
setEditingKey(null);
setEditBuffer('');
setEditCursorPos(0);
return;
}
let parsed: string | number;
if (type === 'number') {
const numParsed = Number(editBuffer.trim());
if (Number.isNaN(numParsed)) {
// Invalid number; cancel edit
setEditingKey(null);
setEditBuffer('');
setEditCursorPos(0);
return;
}
parsed = numParsed;
} else {
// For strings, use the buffer as is.
parsed = editBuffer;
}
// 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,
[
pendingSettings,
settings,
selectedScope,
vimEnabled,
toggleVimEnabled,
config,
],
);
// 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;
});
// Generate items for BaseSettingsDialog
const settingKeys = searchQuery ? filteredKeys : getDialogSettingKeys();
const items: SettingsDialogItem[] = useMemo(() => {
const scopeSettings = settings.forScope(selectedScope).settings;
const mergedSettings = settings.merged;
// 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;
});
return settingKeys.map((key) => {
const definition = getSettingDefinition(key);
const type = definition?.type ?? 'string';
// Compute display value
let displayValue: string;
if (type === 'number' || type === 'string') {
const path = key.split('.');
const currentValue = getNestedValue(pendingSettings, path);
const defaultValue = getEffectiveDefaultValue(key, config);
if (currentValue !== undefined && currentValue !== null) {
displayValue = String(currentValue);
} 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),
displayValue =
defaultValue !== undefined && defaultValue !== null
? String(defaultValue)
: '';
}
// Add * if value differs from default OR if currently being modified
const isModified = modifiedSettings.has(key);
const effectiveCurrentValue =
currentValue !== undefined && currentValue !== null
? currentValue
: defaultValue;
const isDifferentFromDefault = effectiveCurrentValue !== defaultValue;
if (isDifferentFromDefault || isModified) {
displayValue += '*';
}
} else {
// For booleans and enums, use existing logic
displayValue = getDisplayValue(
key,
scopeSettings,
mergedSettings,
modifiedSettings,
pendingSettings,
);
}
return updated;
return {
key,
label: definition?.label || key,
description: definition?.description,
type: type as 'boolean' | 'number' | 'string' | 'enum',
displayValue,
isGreyedOut: isDefaultValue(key, scopeSettings),
scopeMessage: getScopeMessageForSetting(key, selectedScope, settings),
};
});
}, [
settingKeys,
settings,
selectedScope,
pendingSettings,
modifiedSettings,
config,
]);
// Record pending change globally for persistence across scopes
setGlobalPendingChanges((prev) => {
const next = new Map(prev);
next.set(key, parsed as PendingValue);
return next;
});
}
setEditingKey(null);
setEditBuffer('');
setEditCursorPos(0);
};
// Scope selector items
const scopeItems = getScopeItems().map((item) => ({
...item,
key: item.value,
}));
const handleScopeHighlight = (scope: LoadableSettingScope) => {
setSelectedScope(scope);
};
const handleScopeSelect = (scope: LoadableSettingScope) => {
handleScopeHighlight(scope);
setFocusSection('settings');
};
// Height constraint calculations similar to ThemeDialog
// Height constraint calculations
const DIALOG_PADDING = 5;
const SETTINGS_TITLE_HEIGHT = 2; // "Settings" title + spacing
const SCROLL_ARROWS_HEIGHT = 2; // Up and down arrows
const SPACING_HEIGHT = 1; // Space between settings list and scope
const SCOPE_SELECTION_HEIGHT = 4; // Apply To section height
const BOTTOM_HELP_TEXT_HEIGHT = 1; // Help text
const SETTINGS_TITLE_HEIGHT = 2;
const SCROLL_ARROWS_HEIGHT = 2;
const SPACING_HEIGHT = 1;
const SCOPE_SELECTION_HEIGHT = 4;
const BOTTOM_HELP_TEXT_HEIGHT = 1;
const RESTART_PROMPT_HEIGHT = showRestartPrompt ? 1 : 0;
let currentAvailableTerminalHeight =
availableTerminalHeight ?? Number.MAX_SAFE_INTEGER;
currentAvailableTerminalHeight -= 2; // Top and bottom borders
// Start with basic fixed height (without scope selection)
let totalFixedHeight =
DIALOG_PADDING +
SETTINGS_TITLE_HEIGHT +
@@ -503,21 +562,16 @@ export function SettingsDialog({
BOTTOM_HELP_TEXT_HEIGHT +
RESTART_PROMPT_HEIGHT;
// Calculate how much space we have for settings
let availableHeightForSettings = Math.max(
1,
currentAvailableTerminalHeight - totalFixedHeight,
);
// Each setting item takes up to 3 lines (label/value row, description row, and spacing)
let maxVisibleItems = Math.max(1, Math.floor(availableHeightForSettings / 3));
// Decide whether to show scope selection based on remaining space
let showScopeSelection = true;
// If we have limited height, prioritize showing more settings over scope selection
if (availableTerminalHeight && availableTerminalHeight < 25) {
// For very limited height, hide scope selection to show more settings
const totalWithScope = totalFixedHeight + SCOPE_SELECTION_HEIGHT;
const availableWithScope = Math.max(
1,
@@ -525,11 +579,9 @@ export function SettingsDialog({
);
const maxItemsWithScope = Math.max(1, Math.floor(availableWithScope / 3));
// If hiding scope selection allows us to show significantly more settings, do it
if (maxVisibleItems > maxItemsWithScope + 1) {
showScopeSelection = false;
} else {
// Otherwise include scope selection and recalculate
totalFixedHeight += SCOPE_SELECTION_HEIGHT;
availableHeightForSettings = Math.max(
1,
@@ -538,7 +590,6 @@ export function SettingsDialog({
maxVisibleItems = Math.max(1, Math.floor(availableHeightForSettings / 3));
}
} else {
// For normal height, include scope selection
totalFixedHeight += SCOPE_SELECTION_HEIGHT;
availableHeightForSettings = Math.max(
1,
@@ -547,7 +598,6 @@ export function SettingsDialog({
maxVisibleItems = Math.max(1, Math.floor(availableHeightForSettings / 3));
}
// Use the calculated maxVisibleItems or fall back to the original maxItemsToShow
const effectiveMaxItemsToShow = availableTerminalHeight
? Math.min(maxVisibleItems, items.length)
: MAX_ITEMS_TO_SHOW;
@@ -559,16 +609,7 @@ export function SettingsDialog({
}
}, [showScopeSelection, focusSection]);
// Scroll logic for settings
const visibleItems = items.slice(
scrollOffset,
scrollOffset + effectiveMaxItemsToShow,
);
// Show arrows if there are more items than can be displayed
const showScrollUp = items.length > effectiveMaxItemsToShow;
const showScrollDown = items.length > effectiveMaxItemsToShow;
const saveRestartRequiredSettings = () => {
const saveRestartRequiredSettings = useCallback(() => {
const restartRequiredSettings =
getRestartRequiredFromModified(modifiedSettings);
const restartRequiredSet = new Set(restartRequiredSettings);
@@ -591,8 +632,9 @@ export function SettingsDialog({
return next;
});
}
};
}, [modifiedSettings, pendingSettings, settings, selectedScope]);
// Keyboard handling
useKeypress(
(key) => {
const { name } = key;
@@ -635,7 +677,6 @@ export function SettingsDialog({
const after = cpSlice(b, editCursorPos + 1);
return before + after;
});
// Cursor position stays the same for delete
}
return;
}
@@ -651,12 +692,9 @@ export function SettingsDialog({
let ch = key.sequence;
let isValidChar = false;
if (type === 'number') {
// Allow digits, minus, plus, and dot.
isValidChar = /[0-9\-+.]/.test(ch);
} else {
ch = stripUnsafeCharacters(ch);
// For strings, allow any single character that isn't a control
// sequence.
isValidChar = ch.length === 1;
}
@@ -692,14 +730,12 @@ export function SettingsDialog({
return;
}
if (keyMatchers[Command.DIALOG_NAVIGATION_UP](key)) {
// If editing, commit first
if (editingKey) {
commitEdit(editingKey);
}
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 - effectiveMaxItemsToShow),
@@ -708,14 +744,12 @@ export function SettingsDialog({
setScrollOffset(newIndex);
}
} else if (keyMatchers[Command.DIALOG_NAVIGATION_DOWN](key)) {
// If editing, commit first
if (editingKey) {
commitEdit(editingKey);
}
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 + effectiveMaxItemsToShow) {
@@ -727,14 +761,14 @@ export function SettingsDialog({
currentItem?.type === 'number' ||
currentItem?.type === 'string'
) {
startEditing(currentItem.value);
startEditing(currentItem.key);
} else {
currentItem?.toggle();
toggleSetting(currentItem.key);
}
} else if (/^[0-9]$/.test(key.sequence || '') && !editingKey) {
const currentItem = items[activeSettingIndex];
if (currentItem?.type === 'number') {
startEditing(currentItem.value, key.sequence);
startEditing(currentItem.key, key.sequence);
}
} else if (
keyMatchers[Command.CLEAR_INPUT](key) ||
@@ -744,7 +778,7 @@ export function SettingsDialog({
const currentSetting = items[activeSettingIndex];
if (currentSetting) {
const defaultValue = getEffectiveDefaultValue(
currentSetting.value,
currentSetting.key,
config,
);
const defType = currentSetting.type;
@@ -753,7 +787,7 @@ export function SettingsDialog({
typeof defaultValue === 'boolean' ? defaultValue : false;
setPendingSettings((prev) =>
setPendingSettingValue(
currentSetting.value,
currentSetting.key,
booleanDefaultValue,
prev,
),
@@ -765,7 +799,7 @@ export function SettingsDialog({
) {
setPendingSettings((prev) =>
setPendingSettingValueAny(
currentSetting.value,
currentSetting.key,
defaultValue,
prev,
),
@@ -776,20 +810,20 @@ export function SettingsDialog({
// Remove from modified settings since it's now at default
setModifiedSettings((prev) => {
const updated = new Set(prev);
updated.delete(currentSetting.value);
updated.delete(currentSetting.key);
return updated;
});
// Remove from restart-required settings if it was there
setRestartRequiredSettings((prev) => {
const updated = new Set(prev);
updated.delete(currentSetting.value);
updated.delete(currentSetting.key);
return updated;
});
// If this setting doesn't require restart, save it immediately
if (!requiresRestart(currentSetting.value)) {
const immediateSettings = new Set([currentSetting.value]);
if (!requiresRestart(currentSetting.key)) {
const immediateSettings = new Set([currentSetting.key]);
const toSaveValue =
currentSetting.type === 'boolean'
? typeof defaultValue === 'boolean'
@@ -804,7 +838,7 @@ export function SettingsDialog({
const immediateSettingsObject =
toSaveValue !== undefined
? setPendingSettingValueAny(
currentSetting.value,
currentSetting.key,
toSaveValue,
currentScopeSettings,
)
@@ -819,9 +853,9 @@ export function SettingsDialog({
// Remove from global pending changes if present
setGlobalPendingChanges((prev) => {
if (!prev.has(currentSetting.value)) return prev;
if (!prev.has(currentSetting.key)) return prev;
const next = new Map(prev);
next.delete(currentSetting.value);
next.delete(currentSetting.key);
return next;
});
} else {
@@ -836,7 +870,7 @@ export function SettingsDialog({
) {
setGlobalPendingChanges((prev) => {
const next = new Map(prev);
next.set(currentSetting.value, defaultValue as PendingValue);
next.set(currentSetting.key, defaultValue as PendingValue);
return next;
});
}
@@ -868,7 +902,7 @@ export function SettingsDialog({
const { mainAreaWidth } = useUIState();
const viewportWidth = mainAreaWidth - 8;
const buffer = useTextBuffer({
const searchBuffer = useTextBuffer({
initialText: '',
initialCursorOffset: 0,
viewport: {
@@ -880,243 +914,34 @@ export function SettingsDialog({
onChange: (text) => setSearchQuery(text),
});
return (
<Box
borderStyle="round"
borderColor={theme.border.default}
flexDirection="row"
padding={1}
width="100%"
height="100%"
>
<Box flexDirection="column" flexGrow={1}>
<Box marginX={1}>
<Text
bold={focusSection === 'settings' && !editingKey}
wrap="truncate"
>
{focusSection === 'settings' ? '> ' : ' '}Settings{' '}
</Text>
</Box>
<Box
borderStyle="round"
borderColor={
editingKey
? theme.border.default
: focusSection === 'settings'
? theme.border.focused
: theme.border.default
}
paddingX={1}
height={3}
marginTop={1}
>
<TextInput
focus={focusSection === 'settings' && !editingKey}
buffer={buffer}
placeholder="Search to filter"
/>
</Box>
<Box height={1} />
{visibleItems.length === 0 ? (
<Box marginX={1} height={1} flexDirection="column">
<Text color={theme.text.secondary}>No matches found.</Text>
</Box>
) : (
<>
{showScrollUp && (
<Box marginX={1}>
<Text color={theme.text.secondary}></Text>
</Box>
)}
{visibleItems.map((item, idx) => {
const isActive =
focusSection === 'settings' &&
activeSettingIndex === idx + scrollOffset;
const scopeSettings = settings.forScope(selectedScope).settings;
const mergedSettings = settings.merged;
let displayValue: string;
if (editingKey === item.value) {
// Show edit buffer with advanced cursor highlighting
if (cursorVisible && editCursorPos < cpLen(editBuffer)) {
// Cursor is in the middle or at start of text
const beforeCursor = cpSlice(editBuffer, 0, editCursorPos);
const atCursor = cpSlice(
editBuffer,
editCursorPos,
editCursorPos + 1,
);
const afterCursor = cpSlice(editBuffer, editCursorPos + 1);
displayValue =
beforeCursor + chalk.inverse(atCursor) + afterCursor;
} else if (editCursorPos >= cpLen(editBuffer)) {
// Cursor is at the end - show inverted space
displayValue =
editBuffer + (cursorVisible ? chalk.inverse(' ') : ' ');
} else {
// Cursor not visible
displayValue = editBuffer;
}
} else if (item.type === 'number' || item.type === 'string') {
// For numbers/strings, get the actual current value from pending settings
const path = item.value.split('.');
const currentValue = getNestedValue(pendingSettings, path);
const defaultValue = getEffectiveDefaultValue(
item.value,
config,
);
if (currentValue !== undefined && currentValue !== null) {
displayValue = String(currentValue);
} else {
displayValue =
defaultValue !== undefined && defaultValue !== null
? String(defaultValue)
: '';
}
// Add * if value differs from default OR if currently being modified
const isModified = modifiedSettings.has(item.value);
const effectiveCurrentValue =
currentValue !== undefined && currentValue !== null
? currentValue
: defaultValue;
const isDifferentFromDefault =
effectiveCurrentValue !== defaultValue;
if (isDifferentFromDefault || isModified) {
displayValue += '*';
}
} else {
// For booleans and other types, use existing logic
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 marginX={1} flexDirection="row" alignItems="flex-start">
<Box minWidth={2} flexShrink={0}>
<Text
color={
isActive ? theme.status.success : theme.text.secondary
}
>
{isActive ? '●' : ''}
</Text>
</Box>
<Box
flexDirection="row"
flexGrow={1}
minWidth={0}
alignItems="flex-start"
>
<Box
flexDirection="column"
width={maxLabelOrDescriptionWidth}
minWidth={0}
>
<Text
color={
isActive ? theme.status.success : theme.text.primary
}
>
{item.label}
{scopeMessage && (
<Text color={theme.text.secondary}>
{' '}
{scopeMessage}
</Text>
)}
</Text>
<Text color={theme.text.secondary} wrap="truncate">
{item.description ?? ''}
</Text>
</Box>
<Box minWidth={3} />
<Box flexShrink={0}>
<Text
color={
isActive
? theme.status.success
: shouldBeGreyedOut
? theme.text.secondary
: theme.text.primary
}
>
{displayValue}
</Text>
</Box>
</Box>
</Box>
<Box height={1} />
</React.Fragment>
);
})}
{showScrollDown && (
<Box marginX={1}>
<Text color={theme.text.secondary}></Text>
</Box>
)}
</>
)}
<Box height={1} />
{/* Scope Selection - conditionally visible based on height constraints */}
{showScopeSelection && (
<Box marginX={1} flexDirection="column">
<Text bold={focusSection === 'scope'} wrap="truncate">
{focusSection === 'scope' ? '> ' : ' '}Apply To
</Text>
<RadioButtonSelect
items={scopeItems}
initialIndex={scopeItems.findIndex(
(item) => item.value === selectedScope,
)}
onSelect={handleScopeSelect}
onHighlight={handleScopeHighlight}
isFocused={focusSection === 'scope'}
showNumbers={focusSection === 'scope'}
/>
</Box>
)}
<Box height={1} />
<Box marginX={1}>
<Text color={theme.text.secondary}>
(Use Enter to select
{showScopeSelection ? ', Tab to change focus' : ''}, Esc to close)
</Text>
</Box>
{showRestartPrompt && (
<Box marginX={1}>
// Restart prompt as footer content
const footerContent = showRestartPrompt ? (
<Text color={theme.status.warning}>
To see changes, Gemini CLI must be restarted. Press r to exit and
apply changes now.
To see changes, Gemini CLI must be restarted. Press r to exit and apply
changes now.
</Text>
</Box>
)}
</Box>
</Box>
) : null;
return (
<BaseSettingsDialog
title="Settings"
searchEnabled={true}
searchBuffer={searchBuffer}
items={items}
activeIndex={activeSettingIndex}
editingKey={editingKey}
editBuffer={editBuffer}
editCursorPos={editCursorPos}
cursorVisible={cursorVisible}
showScopeSelector={showScopeSelection}
selectedScope={selectedScope}
onScopeHighlight={handleScopeHighlight}
onScopeSelect={handleScopeSelect}
focusSection={focusSection}
scrollOffset={scrollOffset}
maxItemsToShow={effectiveMaxItemsToShow}
maxLabelWidth={maxLabelOrDescriptionWidth}
footerContent={footerContent}
/>
);
}
@@ -0,0 +1,331 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import React from 'react';
import { Box, Text } from 'ink';
import chalk from 'chalk';
import { theme } from '../../semantic-colors.js';
import type { LoadableSettingScope } from '../../../config/settings.js';
import { getScopeItems } from '../../../utils/dialogScopeUtils.js';
import { RadioButtonSelect } from './RadioButtonSelect.js';
import { TextInput } from './TextInput.js';
import type { TextBuffer } from './text-buffer.js';
import { cpSlice, cpLen } from '../../utils/textUtils.js';
/**
* Represents a single item in the settings dialog.
*/
export interface SettingsDialogItem {
/** Unique identifier for the item */
key: string;
/** Display label */
label: string;
/** Optional description below label */
description?: string;
/** Item type for determining interaction behavior */
type: 'boolean' | 'number' | 'string' | 'enum';
/** Pre-formatted display value (with * if modified) */
displayValue: string;
/** Grey out value (at default) */
isGreyedOut?: boolean;
/** Scope message e.g., "(Modified in Workspace)" */
scopeMessage?: string;
}
/**
* Props for BaseSettingsDialog component.
*/
export interface BaseSettingsDialogProps {
// Header
/** Dialog title displayed at the top */
title: string;
// Search (optional feature)
/** Whether to show the search input. Default: true */
searchEnabled?: boolean;
/** Placeholder text for search input. Default: "Search to filter" */
searchPlaceholder?: string;
/** Text buffer for search input */
searchBuffer?: TextBuffer;
// Items - parent provides the list
/** List of items to display */
items: SettingsDialogItem[];
/** Currently active/highlighted item index */
activeIndex: number;
// Edit mode state
/** Key of the item currently being edited, or null if not editing */
editingKey: string | null;
/** Current edit buffer content */
editBuffer: string;
/** Cursor position within edit buffer */
editCursorPos: number;
/** Whether cursor is visible (for blinking effect) */
cursorVisible: boolean;
// Scope selector
/** Whether to show the scope selector. Default: true */
showScopeSelector?: boolean;
/** Currently selected scope */
selectedScope: LoadableSettingScope;
/** Callback when scope is highlighted (hovered/navigated to) */
onScopeHighlight?: (scope: LoadableSettingScope) => void;
/** Callback when scope is selected (Enter pressed) */
onScopeSelect?: (scope: LoadableSettingScope) => void;
// Focus management
/** Which section has focus: 'settings' or 'scope' */
focusSection: 'settings' | 'scope';
// Scroll
/** Current scroll offset */
scrollOffset: number;
/** Maximum number of items to show at once */
maxItemsToShow: number;
// Layout
/** Maximum label width for alignment */
maxLabelWidth?: number;
// Optional extra content below help text (for restart prompt, etc.)
/** Optional footer content (e.g., restart prompt) */
footerContent?: React.ReactNode;
}
/**
* A base settings dialog component that handles rendering and layout.
* Parent components handle business logic (saving, filtering, etc.).
*/
export function BaseSettingsDialog({
title,
searchEnabled = true,
searchPlaceholder = 'Search to filter',
searchBuffer,
items,
activeIndex,
editingKey,
editBuffer,
editCursorPos,
cursorVisible,
showScopeSelector = true,
selectedScope,
onScopeHighlight,
onScopeSelect,
focusSection,
scrollOffset,
maxItemsToShow,
maxLabelWidth,
footerContent,
}: BaseSettingsDialogProps): React.JSX.Element {
// Scope selector items
const scopeItems = getScopeItems().map((item) => ({
...item,
key: item.value,
}));
// Calculate visible items based on scroll offset
const visibleItems = items.slice(scrollOffset, scrollOffset + maxItemsToShow);
// Show scroll indicators if there are more items than can be displayed
const showScrollUp = items.length > maxItemsToShow;
const showScrollDown = items.length > maxItemsToShow;
return (
<Box
borderStyle="round"
borderColor={theme.border.default}
flexDirection="row"
padding={1}
width="100%"
height="100%"
>
<Box flexDirection="column" flexGrow={1}>
{/* Title */}
<Box marginX={1}>
<Text
bold={focusSection === 'settings' && !editingKey}
wrap="truncate"
>
{focusSection === 'settings' ? '> ' : ' '}
{title}{' '}
</Text>
</Box>
{/* Search input (if enabled) */}
{searchEnabled && searchBuffer && (
<Box
borderStyle="round"
borderColor={
editingKey
? theme.border.default
: focusSection === 'settings'
? theme.border.focused
: theme.border.default
}
paddingX={1}
height={3}
marginTop={1}
>
<TextInput
focus={focusSection === 'settings' && !editingKey}
buffer={searchBuffer}
placeholder={searchPlaceholder}
/>
</Box>
)}
<Box height={1} />
{/* Items list */}
{visibleItems.length === 0 ? (
<Box marginX={1} height={1} flexDirection="column">
<Text color={theme.text.secondary}>No matches found.</Text>
</Box>
) : (
<>
{showScrollUp && (
<Box marginX={1}>
<Text color={theme.text.secondary}></Text>
</Box>
)}
{visibleItems.map((item, idx) => {
const globalIndex = idx + scrollOffset;
const isActive =
focusSection === 'settings' && activeIndex === globalIndex;
// Compute display value with edit mode cursor
let displayValue: string;
if (editingKey === item.key) {
// Show edit buffer with cursor highlighting
if (cursorVisible && editCursorPos < cpLen(editBuffer)) {
// Cursor is in the middle or at start of text
const beforeCursor = cpSlice(editBuffer, 0, editCursorPos);
const atCursor = cpSlice(
editBuffer,
editCursorPos,
editCursorPos + 1,
);
const afterCursor = cpSlice(editBuffer, editCursorPos + 1);
displayValue =
beforeCursor + chalk.inverse(atCursor) + afterCursor;
} else if (editCursorPos >= cpLen(editBuffer)) {
// Cursor is at the end - show inverted space
displayValue =
editBuffer + (cursorVisible ? chalk.inverse(' ') : ' ');
} else {
// Cursor not visible
displayValue = editBuffer;
}
} else {
displayValue = item.displayValue;
}
return (
<React.Fragment key={item.key}>
<Box marginX={1} flexDirection="row" alignItems="flex-start">
<Box minWidth={2} flexShrink={0}>
<Text
color={
isActive ? theme.status.success : theme.text.secondary
}
>
{isActive ? '●' : ''}
</Text>
</Box>
<Box
flexDirection="row"
flexGrow={1}
minWidth={0}
alignItems="flex-start"
>
<Box
flexDirection="column"
width={maxLabelWidth}
minWidth={0}
>
<Text
color={
isActive ? theme.status.success : theme.text.primary
}
>
{item.label}
{item.scopeMessage && (
<Text color={theme.text.secondary}>
{' '}
{item.scopeMessage}
</Text>
)}
</Text>
<Text color={theme.text.secondary} wrap="truncate">
{item.description ?? ''}
</Text>
</Box>
<Box minWidth={3} />
<Box flexShrink={0}>
<Text
color={
isActive
? theme.status.success
: item.isGreyedOut
? theme.text.secondary
: theme.text.primary
}
>
{displayValue}
</Text>
</Box>
</Box>
</Box>
<Box height={1} />
</React.Fragment>
);
})}
{showScrollDown && (
<Box marginX={1}>
<Text color={theme.text.secondary}></Text>
</Box>
)}
</>
)}
<Box height={1} />
{/* Scope Selection */}
{showScopeSelector && (
<Box marginX={1} flexDirection="column">
<Text bold={focusSection === 'scope'} wrap="truncate">
{focusSection === 'scope' ? '> ' : ' '}Apply To
</Text>
<RadioButtonSelect
items={scopeItems}
initialIndex={scopeItems.findIndex(
(item) => item.value === selectedScope,
)}
onSelect={onScopeSelect ?? (() => {})}
onHighlight={onScopeHighlight}
isFocused={focusSection === 'scope'}
showNumbers={focusSection === 'scope'}
/>
</Box>
)}
<Box height={1} />
{/* Help text */}
<Box marginX={1}>
<Text color={theme.text.secondary}>
(Use Enter to select
{showScopeSelector ? ', Tab to change focus' : ''}, Esc to close)
</Text>
</Box>
{/* Footer content (e.g., restart prompt) */}
{footerContent && <Box marginX={1}>{footerContent}</Box>}
</Box>
</Box>
);
}