mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-05-13 05:12:55 -07:00
refactor(cli): overhaul settings UI with noun-first labels, positive logic, and tabbed navigation
This commit is contained in:
@@ -424,6 +424,9 @@ export function AgentConfigDialog({
|
|||||||
<Text color={theme.text.secondary}>Changes saved automatically.</Text>
|
<Text color={theme.text.secondary}>Changes saved automatically.</Text>
|
||||||
) : null;
|
) : null;
|
||||||
|
|
||||||
|
// Estimate height needed for the list
|
||||||
|
const maxListHeight = Math.max(15, maxItemsToShow * 3);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<BaseSettingsDialog
|
<BaseSettingsDialog
|
||||||
title={`Configure: ${displayName}`}
|
title={`Configure: ${displayName}`}
|
||||||
@@ -432,7 +435,7 @@ export function AgentConfigDialog({
|
|||||||
showScopeSelector={true}
|
showScopeSelector={true}
|
||||||
selectedScope={selectedScope}
|
selectedScope={selectedScope}
|
||||||
onScopeChange={handleScopeChange}
|
onScopeChange={handleScopeChange}
|
||||||
maxItemsToShow={maxItemsToShow}
|
maxListHeight={maxListHeight}
|
||||||
maxLabelWidth={maxLabelWidth}
|
maxLabelWidth={maxLabelWidth}
|
||||||
onItemToggle={handleItemToggle}
|
onItemToggle={handleItemToggle}
|
||||||
onEditCommit={handleEditCommit}
|
onEditCommit={handleEditCommit}
|
||||||
|
|||||||
@@ -65,6 +65,20 @@ vi.mock('../../config/settingsSchema.js', async (importOriginal) => {
|
|||||||
return {
|
return {
|
||||||
...original,
|
...original,
|
||||||
getSettingsSchema: vi.fn(original.getSettingsSchema),
|
getSettingsSchema: vi.fn(original.getSettingsSchema),
|
||||||
|
SETTING_CATEGORY_ORDER: [
|
||||||
|
'General',
|
||||||
|
'UI',
|
||||||
|
'Model',
|
||||||
|
'Context',
|
||||||
|
'Tools',
|
||||||
|
'IDE',
|
||||||
|
'Privacy',
|
||||||
|
'Extensions',
|
||||||
|
'Security',
|
||||||
|
'Experimental',
|
||||||
|
'Admin',
|
||||||
|
'Advanced',
|
||||||
|
],
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -81,11 +95,57 @@ vi.mock('../contexts/VimModeContext.js', async () => {
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
vi.mock('../../utils/settingsUtils.js', async () => {
|
vi.mock('../../utils/settingsUtils.js', async (importOriginal) => {
|
||||||
const actual = await vi.importActual('../../utils/settingsUtils.js');
|
const original =
|
||||||
|
await importOriginal<typeof import('../../utils/settingsUtils.js')>();
|
||||||
|
const CATEGORY_ORDER = [
|
||||||
|
'General',
|
||||||
|
'UI',
|
||||||
|
'Model',
|
||||||
|
'Context',
|
||||||
|
'Tools',
|
||||||
|
'IDE',
|
||||||
|
'Privacy',
|
||||||
|
'Extensions',
|
||||||
|
'Security',
|
||||||
|
'Experimental',
|
||||||
|
'Admin',
|
||||||
|
'Advanced',
|
||||||
|
];
|
||||||
return {
|
return {
|
||||||
...actual,
|
...original,
|
||||||
saveModifiedSettings: vi.fn(),
|
saveModifiedSettings: vi.fn(),
|
||||||
|
SETTING_CATEGORY_ORDER: CATEGORY_ORDER,
|
||||||
|
getDialogSettingsByCategory: vi.fn(() => {
|
||||||
|
// Use original logic but with our local order to avoid hoisting issues
|
||||||
|
const categories: Record<
|
||||||
|
string,
|
||||||
|
Array<SettingDefinition & { key: string }>
|
||||||
|
> = {};
|
||||||
|
Object.values(original.getFlattenedSchema())
|
||||||
|
.filter(
|
||||||
|
(definition: SettingDefinition) => definition.showInDialog !== false,
|
||||||
|
)
|
||||||
|
.forEach((definition: SettingDefinition & { key: string }) => {
|
||||||
|
const category = definition.category;
|
||||||
|
if (!categories[category]) {
|
||||||
|
categories[category] = [];
|
||||||
|
}
|
||||||
|
categories[category].push(definition);
|
||||||
|
});
|
||||||
|
|
||||||
|
const ordered: Record<string, Array<SettingDefinition & { key: string }>> =
|
||||||
|
{};
|
||||||
|
CATEGORY_ORDER.forEach((cat) => {
|
||||||
|
if (categories[cat]) ordered[cat] = categories[cat];
|
||||||
|
});
|
||||||
|
Object.keys(categories)
|
||||||
|
.sort()
|
||||||
|
.forEach((cat) => {
|
||||||
|
if (!ordered[cat]) ordered[cat] = categories[cat];
|
||||||
|
});
|
||||||
|
return ordered;
|
||||||
|
}),
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -291,7 +351,7 @@ describe('SettingsDialog', () => {
|
|||||||
const lines = output.trim().split('\n');
|
const lines = output.trim().split('\n');
|
||||||
|
|
||||||
expect(lines.length).toBeGreaterThanOrEqual(24);
|
expect(lines.length).toBeGreaterThanOrEqual(24);
|
||||||
expect(lines.length).toBeLessThanOrEqual(25);
|
expect(lines.length).toBeLessThanOrEqual(27);
|
||||||
});
|
});
|
||||||
unmount();
|
unmount();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -30,8 +30,10 @@ import {
|
|||||||
getEffectiveDefaultValue,
|
getEffectiveDefaultValue,
|
||||||
setPendingSettingValueAny,
|
setPendingSettingValueAny,
|
||||||
getEffectiveValue,
|
getEffectiveValue,
|
||||||
|
getDialogSettingsByCategory,
|
||||||
} from '../../utils/settingsUtils.js';
|
} from '../../utils/settingsUtils.js';
|
||||||
import { useVimMode } from '../contexts/VimModeContext.js';
|
import { useVimMode } from '../contexts/VimModeContext.js';
|
||||||
|
import { useTabbedNavigation } from '../hooks/useTabbedNavigation.js';
|
||||||
import { getCachedStringWidth } from '../utils/textUtils.js';
|
import { getCachedStringWidth } from '../utils/textUtils.js';
|
||||||
import {
|
import {
|
||||||
type SettingsValue,
|
type SettingsValue,
|
||||||
@@ -62,8 +64,6 @@ interface SettingsDialogProps {
|
|||||||
config?: Config;
|
config?: Config;
|
||||||
}
|
}
|
||||||
|
|
||||||
const MAX_ITEMS_TO_SHOW = 8;
|
|
||||||
|
|
||||||
export function SettingsDialog({
|
export function SettingsDialog({
|
||||||
settings,
|
settings,
|
||||||
onSelect,
|
onSelect,
|
||||||
@@ -136,6 +136,25 @@ export function SettingsDialog({
|
|||||||
};
|
};
|
||||||
}, [searchQuery, fzfInstance, searchMap]);
|
}, [searchQuery, fzfInstance, searchMap]);
|
||||||
|
|
||||||
|
// Tab state
|
||||||
|
const tabs = useMemo(() => {
|
||||||
|
const categories = Object.keys(getDialogSettingsByCategory());
|
||||||
|
return [
|
||||||
|
{ key: 'all', header: 'All' },
|
||||||
|
...categories.map((cat) => ({ key: cat.toLowerCase(), header: cat })),
|
||||||
|
];
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const { currentIndex } = useTabbedNavigation({
|
||||||
|
tabCount: tabs.length,
|
||||||
|
initialIndex: 0,
|
||||||
|
wrapAround: true,
|
||||||
|
// Disable tab key navigation when searching or editing to avoid conflicts
|
||||||
|
enableTabKey: !searchQuery,
|
||||||
|
});
|
||||||
|
|
||||||
|
const selectedCategory = tabs[currentIndex].header;
|
||||||
|
|
||||||
// Local pending settings state for the selected scope
|
// Local pending settings state for the selected scope
|
||||||
const [pendingSettings, setPendingSettings] = useState<Settings>(() =>
|
const [pendingSettings, setPendingSettings] = useState<Settings>(() =>
|
||||||
// Deep clone to avoid mutation
|
// Deep clone to avoid mutation
|
||||||
@@ -215,7 +234,17 @@ export function SettingsDialog({
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Generate items for BaseSettingsDialog
|
// Generate items for BaseSettingsDialog
|
||||||
const settingKeys = searchQuery ? filteredKeys : getDialogSettingKeys();
|
const settingKeys = useMemo(() => {
|
||||||
|
const baseKeys = searchQuery ? filteredKeys : getDialogSettingKeys();
|
||||||
|
if (selectedCategory === 'All') {
|
||||||
|
return baseKeys;
|
||||||
|
}
|
||||||
|
return baseKeys.filter((key) => {
|
||||||
|
const def = getSettingDefinition(key);
|
||||||
|
return def?.category === selectedCategory;
|
||||||
|
});
|
||||||
|
}, [searchQuery, filteredKeys, selectedCategory]);
|
||||||
|
|
||||||
const items: SettingsDialogItem[] = useMemo(() => {
|
const items: SettingsDialogItem[] = useMemo(() => {
|
||||||
const scopeSettings = settings.forScope(selectedScope).settings;
|
const scopeSettings = settings.forScope(selectedScope).settings;
|
||||||
const mergedSettings = settings.merged;
|
const mergedSettings = settings.merged;
|
||||||
@@ -592,9 +621,8 @@ export function SettingsDialog({
|
|||||||
[showRestartPrompt, onRestartRequest, saveRestartRequiredSettings],
|
[showRestartPrompt, onRestartRequest, saveRestartRequiredSettings],
|
||||||
);
|
);
|
||||||
|
|
||||||
// Calculate effective max items and scope visibility based on terminal height
|
// Calculate effective max list height and scope visibility based on terminal height
|
||||||
const { effectiveMaxItemsToShow, showScopeSelection, showSearch } =
|
const { maxListHeight, showScopeSelection, showSearch } = useMemo(() => {
|
||||||
useMemo(() => {
|
|
||||||
// Only show scope selector if we have a workspace
|
// Only show scope selector if we have a workspace
|
||||||
const hasWorkspace = settings.workspace.path !== undefined;
|
const hasWorkspace = settings.workspace.path !== undefined;
|
||||||
|
|
||||||
@@ -603,7 +631,7 @@ export function SettingsDialog({
|
|||||||
|
|
||||||
if (!availableTerminalHeight) {
|
if (!availableTerminalHeight) {
|
||||||
return {
|
return {
|
||||||
effectiveMaxItemsToShow: Math.min(MAX_ITEMS_TO_SHOW, items.length),
|
maxListHeight: 24, // Reasonable default for tall terminals
|
||||||
showScopeSelection: hasWorkspace,
|
showScopeSelection: hasWorkspace,
|
||||||
showSearch: shouldShowSearch,
|
showSearch: shouldShowSearch,
|
||||||
};
|
};
|
||||||
@@ -613,72 +641,49 @@ export function SettingsDialog({
|
|||||||
// 4 for border (2) and padding (2)
|
// 4 for border (2) and padding (2)
|
||||||
const DIALOG_PADDING = 4;
|
const DIALOG_PADDING = 4;
|
||||||
const SETTINGS_TITLE_HEIGHT = 1;
|
const SETTINGS_TITLE_HEIGHT = 1;
|
||||||
// 3 for box + 1 for marginTop + 1 for spacing after
|
const TABS_SECTION_HEIGHT = 3; // marginTop(1) + Tabs(1) + marginBottom(1)
|
||||||
const SEARCH_SECTION_HEIGHT = shouldShowSearch ? 5 : 0;
|
const SEARCH_SECTION_HEIGHT = shouldShowSearch ? 4 : 0; // marginTop(1) + height(3)
|
||||||
const SCROLL_ARROWS_HEIGHT = 2;
|
const LIST_SPACING_HEIGHT = 2; // Box height(1) after search + Box height(1) after list
|
||||||
const ITEMS_SPACING_AFTER = 1;
|
const SCROLL_ARROWS_HEIGHT = 0; // Handled within list height
|
||||||
// 1 for Label + 3 for Scope items + 1 for spacing after
|
const SCOPE_SECTION_HEIGHT = hasWorkspace ? 5 : 0; // Label(1) + Select(3) + Spacing(1)
|
||||||
const SCOPE_SECTION_HEIGHT = hasWorkspace ? 5 : 0;
|
|
||||||
const HELP_TEXT_HEIGHT = 1;
|
const HELP_TEXT_HEIGHT = 1;
|
||||||
const RESTART_PROMPT_HEIGHT = showRestartPrompt ? 1 : 0;
|
const RESTART_PROMPT_HEIGHT = showRestartPrompt ? 1 : 0;
|
||||||
const ITEM_HEIGHT = 3; // Label + description + spacing
|
|
||||||
const HEADER_HEIGHT = 2; // Category Label + spacing
|
|
||||||
|
|
||||||
const currentAvailableHeight = availableTerminalHeight - DIALOG_PADDING;
|
const currentAvailableHeight = availableTerminalHeight - DIALOG_PADDING;
|
||||||
|
|
||||||
const baseFixedHeight =
|
const baseFixedHeight =
|
||||||
SETTINGS_TITLE_HEIGHT +
|
SETTINGS_TITLE_HEIGHT +
|
||||||
|
TABS_SECTION_HEIGHT +
|
||||||
SEARCH_SECTION_HEIGHT +
|
SEARCH_SECTION_HEIGHT +
|
||||||
|
LIST_SPACING_HEIGHT +
|
||||||
SCROLL_ARROWS_HEIGHT +
|
SCROLL_ARROWS_HEIGHT +
|
||||||
ITEMS_SPACING_AFTER +
|
|
||||||
HELP_TEXT_HEIGHT +
|
HELP_TEXT_HEIGHT +
|
||||||
RESTART_PROMPT_HEIGHT;
|
RESTART_PROMPT_HEIGHT;
|
||||||
|
|
||||||
// Estimate average number of items per category to account for headers
|
|
||||||
// In the default schema, we have about 10 categories for ~30 settings shown in dialog.
|
|
||||||
// So roughly 1 header per 3 items.
|
|
||||||
const EFFECTIVE_ITEM_HEIGHT = ITEM_HEIGHT + HEADER_HEIGHT / 3;
|
|
||||||
|
|
||||||
// Calculate max items with scope selector
|
|
||||||
const heightWithScope = baseFixedHeight + SCOPE_SECTION_HEIGHT;
|
|
||||||
const availableForItemsWithScope =
|
|
||||||
currentAvailableHeight - heightWithScope;
|
|
||||||
const maxItemsWithScope = Math.max(
|
|
||||||
1,
|
|
||||||
Math.floor(availableForItemsWithScope / EFFECTIVE_ITEM_HEIGHT),
|
|
||||||
);
|
|
||||||
|
|
||||||
// Calculate max items without scope selector
|
|
||||||
const availableForItemsWithoutScope =
|
|
||||||
currentAvailableHeight - baseFixedHeight;
|
|
||||||
const maxItemsWithoutScope = Math.max(
|
|
||||||
1,
|
|
||||||
Math.floor(availableForItemsWithoutScope / EFFECTIVE_ITEM_HEIGHT),
|
|
||||||
);
|
|
||||||
|
|
||||||
// In small terminals, hide scope selector if it would allow more items to show
|
// In small terminals, hide scope selector if it would allow more items to show
|
||||||
let shouldShowScope = hasWorkspace;
|
let shouldShowScope = hasWorkspace;
|
||||||
let maxItems = maxItemsWithScope;
|
let finalFixedHeight =
|
||||||
|
baseFixedHeight + (shouldShowScope ? SCOPE_SECTION_HEIGHT : 0);
|
||||||
|
|
||||||
if (hasWorkspace && availableTerminalHeight < 25) {
|
if (hasWorkspace && availableTerminalHeight < 25) {
|
||||||
// Hide scope selector if it gains us more than 1 extra item
|
const availableForItemsWithScope =
|
||||||
if (maxItemsWithoutScope > maxItemsWithScope + 1) {
|
currentAvailableHeight - (baseFixedHeight + SCOPE_SECTION_HEIGHT);
|
||||||
|
const availableForItemsWithoutScope =
|
||||||
|
currentAvailableHeight - baseFixedHeight;
|
||||||
|
|
||||||
|
// If hiding scope gives us a much larger list area, do it
|
||||||
|
if (availableForItemsWithoutScope > availableForItemsWithScope + 5) {
|
||||||
shouldShowScope = false;
|
shouldShowScope = false;
|
||||||
maxItems = maxItemsWithoutScope;
|
finalFixedHeight = baseFixedHeight;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
effectiveMaxItemsToShow: Math.min(maxItems, items.length),
|
maxListHeight: Math.max(5, currentAvailableHeight - finalFixedHeight),
|
||||||
showScopeSelection: shouldShowScope,
|
showScopeSelection: shouldShowScope,
|
||||||
showSearch: shouldShowSearch,
|
showSearch: shouldShowSearch,
|
||||||
};
|
};
|
||||||
}, [
|
}, [availableTerminalHeight, settings.workspace.path, showRestartPrompt]);
|
||||||
availableTerminalHeight,
|
|
||||||
items.length,
|
|
||||||
settings.workspace.path,
|
|
||||||
showRestartPrompt,
|
|
||||||
]);
|
|
||||||
|
|
||||||
// Footer content for restart prompt
|
// Footer content for restart prompt
|
||||||
const footerContent = showRestartPrompt ? (
|
const footerContent = showRestartPrompt ? (
|
||||||
@@ -694,11 +699,13 @@ export function SettingsDialog({
|
|||||||
borderColor={showRestartPrompt ? theme.status.warning : undefined}
|
borderColor={showRestartPrompt ? theme.status.warning : undefined}
|
||||||
searchEnabled={showSearch}
|
searchEnabled={showSearch}
|
||||||
searchBuffer={searchBuffer}
|
searchBuffer={searchBuffer}
|
||||||
|
tabs={tabs}
|
||||||
|
currentIndex={currentIndex}
|
||||||
items={items}
|
items={items}
|
||||||
showScopeSelector={showScopeSelection}
|
showScopeSelector={showScopeSelection}
|
||||||
selectedScope={selectedScope}
|
selectedScope={selectedScope}
|
||||||
onScopeChange={handleScopeChange}
|
onScopeChange={handleScopeChange}
|
||||||
maxItemsToShow={effectiveMaxItemsToShow}
|
maxListHeight={maxListHeight}
|
||||||
maxLabelWidth={maxLabelOrDescriptionWidth}
|
maxLabelWidth={maxLabelOrDescriptionWidth}
|
||||||
onItemToggle={handleItemToggle}
|
onItemToggle={handleItemToggle}
|
||||||
onEditCommit={handleEditCommit}
|
onEditCommit={handleEditCommit}
|
||||||
|
|||||||
@@ -107,7 +107,7 @@ describe('BaseSettingsDialog', () => {
|
|||||||
title: 'Test Settings',
|
title: 'Test Settings',
|
||||||
items: createMockItems(),
|
items: createMockItems(),
|
||||||
selectedScope: SettingScope.User,
|
selectedScope: SettingScope.User,
|
||||||
maxItemsToShow: 8,
|
maxListHeight: 24,
|
||||||
onItemToggle: mockOnItemToggle,
|
onItemToggle: mockOnItemToggle,
|
||||||
onEditCommit: mockOnEditCommit,
|
onEditCommit: mockOnEditCommit,
|
||||||
onItemClear: mockOnItemClear,
|
onItemClear: mockOnItemClear,
|
||||||
@@ -310,7 +310,7 @@ describe('BaseSettingsDialog', () => {
|
|||||||
const { rerender, stdin, lastFrame, waitUntilReady, unmount } =
|
const { rerender, stdin, lastFrame, waitUntilReady, unmount } =
|
||||||
await renderDialog({
|
await renderDialog({
|
||||||
items,
|
items,
|
||||||
maxItemsToShow: 5,
|
maxListHeight: 15,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Move focus down to item 2 ("Number Setting")
|
// Move focus down to item 2 ("Number Setting")
|
||||||
@@ -333,7 +333,7 @@ describe('BaseSettingsDialog', () => {
|
|||||||
title="Test Settings"
|
title="Test Settings"
|
||||||
items={filteredItems}
|
items={filteredItems}
|
||||||
selectedScope={SettingScope.User}
|
selectedScope={SettingScope.User}
|
||||||
maxItemsToShow={5}
|
maxListHeight={15}
|
||||||
onItemToggle={mockOnItemToggle}
|
onItemToggle={mockOnItemToggle}
|
||||||
onEditCommit={mockOnEditCommit}
|
onEditCommit={mockOnEditCommit}
|
||||||
onItemClear={mockOnItemClear}
|
onItemClear={mockOnItemClear}
|
||||||
@@ -371,7 +371,7 @@ describe('BaseSettingsDialog', () => {
|
|||||||
const { rerender, stdin, lastFrame, waitUntilReady, unmount } =
|
const { rerender, stdin, lastFrame, waitUntilReady, unmount } =
|
||||||
await renderDialog({
|
await renderDialog({
|
||||||
items,
|
items,
|
||||||
maxItemsToShow: 5,
|
maxListHeight: 15,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Move focus down to item 2 ("Number Setting")
|
// Move focus down to item 2 ("Number Setting")
|
||||||
@@ -393,7 +393,7 @@ describe('BaseSettingsDialog', () => {
|
|||||||
title="Test Settings"
|
title="Test Settings"
|
||||||
items={filteredItems}
|
items={filteredItems}
|
||||||
selectedScope={SettingScope.User}
|
selectedScope={SettingScope.User}
|
||||||
maxItemsToShow={5}
|
maxListHeight={15}
|
||||||
onItemToggle={mockOnItemToggle}
|
onItemToggle={mockOnItemToggle}
|
||||||
onEditCommit={mockOnEditCommit}
|
onEditCommit={mockOnEditCommit}
|
||||||
onItemClear={mockOnItemClear}
|
onItemClear={mockOnItemClear}
|
||||||
|
|||||||
@@ -4,13 +4,20 @@
|
|||||||
* SPDX-License-Identifier: Apache-2.0
|
* SPDX-License-Identifier: Apache-2.0
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import React, { useState, useEffect, useCallback, useRef } from 'react';
|
import React, {
|
||||||
|
useState,
|
||||||
|
useEffect,
|
||||||
|
useCallback,
|
||||||
|
useRef,
|
||||||
|
useMemo,
|
||||||
|
} from 'react';
|
||||||
import { Box, Text } from 'ink';
|
import { Box, Text } from 'ink';
|
||||||
import chalk from 'chalk';
|
import chalk from 'chalk';
|
||||||
import { theme } from '../../semantic-colors.js';
|
import { theme } from '../../semantic-colors.js';
|
||||||
import type { LoadableSettingScope } from '../../../config/settings.js';
|
import type { LoadableSettingScope } from '../../../config/settings.js';
|
||||||
import { getScopeItems } from '../../../utils/dialogScopeUtils.js';
|
import { getScopeItems } from '../../../utils/dialogScopeUtils.js';
|
||||||
import { RadioButtonSelect } from './RadioButtonSelect.js';
|
import { RadioButtonSelect } from './RadioButtonSelect.js';
|
||||||
|
import { TabHeader, type Tab } from './TabHeader.js';
|
||||||
import { TextInput } from './TextInput.js';
|
import { TextInput } from './TextInput.js';
|
||||||
import type { TextBuffer } from './text-buffer.js';
|
import type { TextBuffer } from './text-buffer.js';
|
||||||
import {
|
import {
|
||||||
@@ -64,6 +71,12 @@ export interface BaseSettingsDialogProps {
|
|||||||
/** Text buffer for search input */
|
/** Text buffer for search input */
|
||||||
searchBuffer?: TextBuffer;
|
searchBuffer?: TextBuffer;
|
||||||
|
|
||||||
|
// Tabs
|
||||||
|
/** Array of tab definitions */
|
||||||
|
tabs?: Tab[];
|
||||||
|
/** Currently active tab index */
|
||||||
|
currentIndex?: number;
|
||||||
|
|
||||||
// Items - parent provides the list
|
// Items - parent provides the list
|
||||||
/** List of items to display */
|
/** List of items to display */
|
||||||
items: SettingsDialogItem[];
|
items: SettingsDialogItem[];
|
||||||
@@ -77,8 +90,8 @@ export interface BaseSettingsDialogProps {
|
|||||||
onScopeChange?: (scope: LoadableSettingScope) => void;
|
onScopeChange?: (scope: LoadableSettingScope) => void;
|
||||||
|
|
||||||
// Layout
|
// Layout
|
||||||
/** Maximum number of items to show at once */
|
/** Maximum height in rows for the settings list section */
|
||||||
maxItemsToShow: number;
|
maxListHeight: number;
|
||||||
/** Maximum label width for alignment */
|
/** Maximum label width for alignment */
|
||||||
maxLabelWidth?: number;
|
maxLabelWidth?: number;
|
||||||
|
|
||||||
@@ -116,11 +129,13 @@ export function BaseSettingsDialog({
|
|||||||
searchEnabled = true,
|
searchEnabled = true,
|
||||||
searchPlaceholder = 'Search to filter',
|
searchPlaceholder = 'Search to filter',
|
||||||
searchBuffer,
|
searchBuffer,
|
||||||
|
tabs,
|
||||||
|
currentIndex,
|
||||||
items,
|
items,
|
||||||
showScopeSelector = true,
|
showScopeSelector = true,
|
||||||
selectedScope,
|
selectedScope,
|
||||||
onScopeChange,
|
onScopeChange,
|
||||||
maxItemsToShow,
|
maxListHeight,
|
||||||
maxLabelWidth,
|
maxLabelWidth,
|
||||||
onItemToggle,
|
onItemToggle,
|
||||||
onEditCommit,
|
onEditCommit,
|
||||||
@@ -140,26 +155,79 @@ export function BaseSettingsDialog({
|
|||||||
const [editCursorPos, setEditCursorPos] = useState(0);
|
const [editCursorPos, setEditCursorPos] = useState(0);
|
||||||
const [cursorVisible, setCursorVisible] = useState(true);
|
const [cursorVisible, setCursorVisible] = useState(true);
|
||||||
|
|
||||||
const prevItemsRef = useRef(items);
|
// Helper to calculate height of an item including its optional header
|
||||||
|
const getItemTotalHeight = useCallback(
|
||||||
|
(idx: number): number => {
|
||||||
|
const item = items[idx];
|
||||||
|
if (!item) return 0;
|
||||||
|
|
||||||
// Preserve focus when items change (e.g., search filter)
|
const previousItem = idx > 0 ? items[idx - 1] : undefined;
|
||||||
|
const hasHeader =
|
||||||
|
item.category && item.category !== previousItem?.category;
|
||||||
|
|
||||||
|
let height = 3; // base item height (label + description + spacing)
|
||||||
|
if (hasHeader) {
|
||||||
|
height += 3; // header height (marginTop(1) + Label(1) + marginBottom(1))
|
||||||
|
}
|
||||||
|
return height;
|
||||||
|
},
|
||||||
|
[items],
|
||||||
|
);
|
||||||
|
|
||||||
|
const prevItemsRef = useRef(items);
|
||||||
|
const prevTabIndexRef = useRef(currentIndex);
|
||||||
|
|
||||||
|
// Preserve focus when items change (e.g., search filter) or handle tab changes
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const prevItems = prevItemsRef.current;
|
const prevItems = prevItemsRef.current;
|
||||||
if (prevItems !== items) {
|
const prevTabIndex = prevTabIndexRef.current;
|
||||||
|
|
||||||
|
const tabChanged =
|
||||||
|
currentIndex !== undefined &&
|
||||||
|
prevTabIndex !== undefined &&
|
||||||
|
currentIndex !== prevTabIndex;
|
||||||
|
const itemsChanged = prevItems !== items;
|
||||||
|
|
||||||
|
if (tabChanged || itemsChanged) {
|
||||||
|
// Always reset to top when navigating back to "All" (index 0)
|
||||||
|
// or if tab changed and we want standard top-of-list behavior
|
||||||
|
if (tabChanged && currentIndex === 0) {
|
||||||
|
setActiveIndex(0);
|
||||||
|
setScrollOffset(0);
|
||||||
|
} else if (itemsChanged) {
|
||||||
const prevActiveItem = prevItems[activeIndex];
|
const prevActiveItem = prevItems[activeIndex];
|
||||||
if (prevActiveItem) {
|
if (prevActiveItem) {
|
||||||
const newIndex = items.findIndex((i) => i.key === prevActiveItem.key);
|
const newIndex = items.findIndex((i) => i.key === prevActiveItem.key);
|
||||||
if (newIndex !== -1) {
|
if (newIndex !== -1) {
|
||||||
// Item still exists in the filtered list, keep focus on it
|
// Item still exists in the filtered list, keep focus on it
|
||||||
setActiveIndex(newIndex);
|
setActiveIndex(newIndex);
|
||||||
// Adjust scroll offset to ensure the item is visible
|
|
||||||
let newScroll = scrollOffset;
|
|
||||||
if (newIndex < scrollOffset) newScroll = newIndex;
|
|
||||||
else if (newIndex >= scrollOffset + maxItemsToShow)
|
|
||||||
newScroll = newIndex - maxItemsToShow + 1;
|
|
||||||
|
|
||||||
const maxScroll = Math.max(0, items.length - maxItemsToShow);
|
// Adjust scroll offset to ensure the item is visible within the height budget
|
||||||
setScrollOffset(Math.min(newScroll, maxScroll));
|
if (newIndex < scrollOffset) {
|
||||||
|
setScrollOffset(newIndex);
|
||||||
|
} else {
|
||||||
|
// Calculate height from scrollOffset to newIndex
|
||||||
|
let heightUsed = 0;
|
||||||
|
|
||||||
|
// Forward scan to see if current index fits
|
||||||
|
for (let i = scrollOffset; i <= newIndex; i++) {
|
||||||
|
heightUsed += getItemTotalHeight(i);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (heightUsed > maxListHeight) {
|
||||||
|
// Too far down, scroll until it fits
|
||||||
|
let tempHeight = 0;
|
||||||
|
let startIdx = newIndex;
|
||||||
|
while (
|
||||||
|
startIdx >= 0 &&
|
||||||
|
tempHeight + getItemTotalHeight(startIdx) <= maxListHeight
|
||||||
|
) {
|
||||||
|
tempHeight += getItemTotalHeight(startIdx);
|
||||||
|
startIdx--;
|
||||||
|
}
|
||||||
|
setScrollOffset(startIdx + 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
// Item was filtered out, reset to the top
|
// Item was filtered out, reset to the top
|
||||||
setActiveIndex(0);
|
setActiveIndex(0);
|
||||||
@@ -169,9 +237,18 @@ export function BaseSettingsDialog({
|
|||||||
setActiveIndex(0);
|
setActiveIndex(0);
|
||||||
setScrollOffset(0);
|
setScrollOffset(0);
|
||||||
}
|
}
|
||||||
prevItemsRef.current = items;
|
|
||||||
}
|
}
|
||||||
}, [items, activeIndex, scrollOffset, maxItemsToShow]);
|
prevItemsRef.current = items;
|
||||||
|
prevTabIndexRef.current = currentIndex;
|
||||||
|
}
|
||||||
|
}, [
|
||||||
|
items,
|
||||||
|
currentIndex,
|
||||||
|
activeIndex,
|
||||||
|
scrollOffset,
|
||||||
|
maxListHeight,
|
||||||
|
getItemTotalHeight,
|
||||||
|
]);
|
||||||
|
|
||||||
// Cursor blink effect
|
// Cursor blink effect
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -196,12 +273,25 @@ export function BaseSettingsDialog({
|
|||||||
key: item.value,
|
key: item.value,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
// Calculate visible items based on scroll offset
|
// Calculate which items fit in the current scroll window given maxListHeight
|
||||||
const visibleItems = items.slice(scrollOffset, scrollOffset + maxItemsToShow);
|
const { visibleItems } = useMemo(() => {
|
||||||
|
const visible: SettingsDialogItem[] = [];
|
||||||
|
let currentHeight = 0;
|
||||||
|
|
||||||
|
for (let i = scrollOffset; i < items.length; i++) {
|
||||||
|
const itemHeight = getItemTotalHeight(i);
|
||||||
|
if (currentHeight + itemHeight > maxListHeight) break;
|
||||||
|
visible.push(items[i]);
|
||||||
|
currentHeight += itemHeight;
|
||||||
|
}
|
||||||
|
|
||||||
|
return { visibleItems: visible };
|
||||||
|
}, [items, scrollOffset, maxListHeight, getItemTotalHeight]);
|
||||||
|
|
||||||
// Show scroll indicators if there are more items than can be displayed
|
// Show scroll indicators if there are more items than can be displayed
|
||||||
const showScrollUp = items.length > maxItemsToShow;
|
const showScrollUp = scrollOffset > 0;
|
||||||
const showScrollDown = items.length > maxItemsToShow;
|
const showScrollDown =
|
||||||
|
items.length > 0 && items.length > scrollOffset + visibleItems.length;
|
||||||
|
|
||||||
// Get current item
|
// Get current item
|
||||||
const currentItem = items[activeIndex];
|
const currentItem = items[activeIndex];
|
||||||
@@ -240,6 +330,37 @@ export function BaseSettingsDialog({
|
|||||||
[onScopeChange],
|
[onScopeChange],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Helper to scroll down until target index fits at bottom
|
||||||
|
const scrollToFitBottom = useCallback(
|
||||||
|
(targetIdx: number) => {
|
||||||
|
let tempHeight = 0;
|
||||||
|
let startIdx = targetIdx;
|
||||||
|
while (
|
||||||
|
startIdx >= 0 &&
|
||||||
|
tempHeight + getItemTotalHeight(startIdx) <= maxListHeight
|
||||||
|
) {
|
||||||
|
tempHeight += getItemTotalHeight(startIdx);
|
||||||
|
startIdx--;
|
||||||
|
}
|
||||||
|
setScrollOffset(startIdx + 1);
|
||||||
|
},
|
||||||
|
[getItemTotalHeight, maxListHeight],
|
||||||
|
);
|
||||||
|
|
||||||
|
// Helper to find scrollOffset when wrapping from top to bottom
|
||||||
|
const getBottomScrollOffset = useCallback(() => {
|
||||||
|
let tempHeight = 0;
|
||||||
|
let startIdx = items.length - 1;
|
||||||
|
while (
|
||||||
|
startIdx >= 0 &&
|
||||||
|
tempHeight + getItemTotalHeight(startIdx) <= maxListHeight
|
||||||
|
) {
|
||||||
|
tempHeight += getItemTotalHeight(startIdx);
|
||||||
|
startIdx--;
|
||||||
|
}
|
||||||
|
return startIdx + 1;
|
||||||
|
}, [items.length, getItemTotalHeight, maxListHeight]);
|
||||||
|
|
||||||
// Keyboard handling
|
// Keyboard handling
|
||||||
useKeypress(
|
useKeypress(
|
||||||
(key: Key) => {
|
(key: Key) => {
|
||||||
@@ -314,7 +435,7 @@ export function BaseSettingsDialog({
|
|||||||
const newIndex = activeIndex > 0 ? activeIndex - 1 : items.length - 1;
|
const newIndex = activeIndex > 0 ? activeIndex - 1 : items.length - 1;
|
||||||
setActiveIndex(newIndex);
|
setActiveIndex(newIndex);
|
||||||
if (newIndex === items.length - 1) {
|
if (newIndex === items.length - 1) {
|
||||||
setScrollOffset(Math.max(0, items.length - maxItemsToShow));
|
setScrollOffset(getBottomScrollOffset());
|
||||||
} else if (newIndex < scrollOffset) {
|
} else if (newIndex < scrollOffset) {
|
||||||
setScrollOffset(newIndex);
|
setScrollOffset(newIndex);
|
||||||
}
|
}
|
||||||
@@ -326,8 +447,15 @@ export function BaseSettingsDialog({
|
|||||||
setActiveIndex(newIndex);
|
setActiveIndex(newIndex);
|
||||||
if (newIndex === 0) {
|
if (newIndex === 0) {
|
||||||
setScrollOffset(0);
|
setScrollOffset(0);
|
||||||
} else if (newIndex >= scrollOffset + maxItemsToShow) {
|
} else {
|
||||||
setScrollOffset(newIndex - maxItemsToShow + 1);
|
// Check if it fits
|
||||||
|
let heightUsed = 0;
|
||||||
|
for (let i = scrollOffset; i <= newIndex; i++) {
|
||||||
|
heightUsed += getItemTotalHeight(i);
|
||||||
|
}
|
||||||
|
if (heightUsed > maxListHeight) {
|
||||||
|
scrollToFitBottom(newIndex);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -361,7 +489,7 @@ export function BaseSettingsDialog({
|
|||||||
const newIndex = activeIndex > 0 ? activeIndex - 1 : items.length - 1;
|
const newIndex = activeIndex > 0 ? activeIndex - 1 : items.length - 1;
|
||||||
setActiveIndex(newIndex);
|
setActiveIndex(newIndex);
|
||||||
if (newIndex === items.length - 1) {
|
if (newIndex === items.length - 1) {
|
||||||
setScrollOffset(Math.max(0, items.length - maxItemsToShow));
|
setScrollOffset(getBottomScrollOffset());
|
||||||
} else if (newIndex < scrollOffset) {
|
} else if (newIndex < scrollOffset) {
|
||||||
setScrollOffset(newIndex);
|
setScrollOffset(newIndex);
|
||||||
}
|
}
|
||||||
@@ -372,8 +500,15 @@ export function BaseSettingsDialog({
|
|||||||
setActiveIndex(newIndex);
|
setActiveIndex(newIndex);
|
||||||
if (newIndex === 0) {
|
if (newIndex === 0) {
|
||||||
setScrollOffset(0);
|
setScrollOffset(0);
|
||||||
} else if (newIndex >= scrollOffset + maxItemsToShow) {
|
} else {
|
||||||
setScrollOffset(newIndex - maxItemsToShow + 1);
|
// Check if it fits
|
||||||
|
let heightUsed = 0;
|
||||||
|
for (let i = scrollOffset; i <= newIndex; i++) {
|
||||||
|
heightUsed += getItemTotalHeight(i);
|
||||||
|
}
|
||||||
|
if (heightUsed > maxListHeight) {
|
||||||
|
scrollToFitBottom(newIndex);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
@@ -445,6 +580,18 @@ export function BaseSettingsDialog({
|
|||||||
</Text>
|
</Text>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
|
{/* Tabs */}
|
||||||
|
{tabs && currentIndex !== undefined && (
|
||||||
|
<Box marginX={1} marginTop={1}>
|
||||||
|
<TabHeader
|
||||||
|
tabs={tabs}
|
||||||
|
currentIndex={currentIndex}
|
||||||
|
showStatusIcons={false}
|
||||||
|
showArrows={false}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Search input (if enabled) */}
|
{/* Search input (if enabled) */}
|
||||||
{searchEnabled && searchBuffer && (
|
{searchEnabled && searchBuffer && (
|
||||||
<Box
|
<Box
|
||||||
@@ -471,16 +618,19 @@ export function BaseSettingsDialog({
|
|||||||
<Box height={1} />
|
<Box height={1} />
|
||||||
|
|
||||||
{/* Items list */}
|
{/* Items list */}
|
||||||
|
<Box height={maxListHeight} flexDirection="column">
|
||||||
{visibleItems.length === 0 ? (
|
{visibleItems.length === 0 ? (
|
||||||
<Box marginX={1} height={1} flexDirection="column">
|
<Box marginX={1} height={1} flexDirection="column">
|
||||||
<Text color={theme.text.secondary}>No matches found.</Text>
|
<Text color={theme.text.secondary}>No matches found.</Text>
|
||||||
</Box>
|
</Box>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
{showScrollUp && (
|
{showScrollUp ? (
|
||||||
<Box marginX={1}>
|
<Box marginX={1}>
|
||||||
<Text color={theme.text.secondary}>▲</Text>
|
<Text color={theme.text.secondary}>▲</Text>
|
||||||
</Box>
|
</Box>
|
||||||
|
) : (
|
||||||
|
<Box height={1} />
|
||||||
)}
|
)}
|
||||||
{visibleItems.map((item, idx) => {
|
{visibleItems.map((item, idx) => {
|
||||||
const globalIndex = idx + scrollOffset;
|
const globalIndex = idx + scrollOffset;
|
||||||
@@ -528,25 +678,33 @@ export function BaseSettingsDialog({
|
|||||||
marginTop={idx === 0 ? 0 : 1}
|
marginTop={idx === 0 ? 0 : 1}
|
||||||
flexDirection="row"
|
flexDirection="row"
|
||||||
alignItems="center"
|
alignItems="center"
|
||||||
height={1}
|
|
||||||
>
|
>
|
||||||
|
<Box flexShrink={0}>
|
||||||
<Text bold>{item.category} </Text>
|
<Text bold>{item.category} </Text>
|
||||||
|
</Box>
|
||||||
<Box
|
<Box
|
||||||
flexGrow={1}
|
flexGrow={1}
|
||||||
borderStyle="single"
|
borderStyle="single"
|
||||||
borderTop
|
borderTop={false}
|
||||||
borderBottom={false}
|
borderBottom
|
||||||
borderLeft={false}
|
borderLeft={false}
|
||||||
borderRight={false}
|
borderRight={false}
|
||||||
borderColor={theme.border.default}
|
borderColor={theme.border.default}
|
||||||
|
height={0}
|
||||||
/>
|
/>
|
||||||
</Box>
|
</Box>
|
||||||
)}
|
)}
|
||||||
<Box marginX={1} flexDirection="row" alignItems="flex-start">
|
<Box
|
||||||
|
marginX={1}
|
||||||
|
flexDirection="row"
|
||||||
|
alignItems="flex-start"
|
||||||
|
>
|
||||||
<Box minWidth={2} flexShrink={0}>
|
<Box minWidth={2} flexShrink={0}>
|
||||||
<Text
|
<Text
|
||||||
color={
|
color={
|
||||||
isActive ? theme.status.success : theme.text.secondary
|
isActive
|
||||||
|
? theme.status.success
|
||||||
|
: theme.text.secondary
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{isActive ? '●' : ''}
|
{isActive ? '●' : ''}
|
||||||
@@ -565,7 +723,9 @@ export function BaseSettingsDialog({
|
|||||||
>
|
>
|
||||||
<Text
|
<Text
|
||||||
color={
|
color={
|
||||||
isActive ? theme.status.success : theme.text.primary
|
isActive
|
||||||
|
? theme.status.success
|
||||||
|
: theme.text.primary
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{item.label}
|
{item.label}
|
||||||
@@ -614,6 +774,7 @@ export function BaseSettingsDialog({
|
|||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
</Box>
|
||||||
|
|
||||||
<Box height={1} />
|
<Box height={1} />
|
||||||
|
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ import type {
|
|||||||
SettingsType,
|
SettingsType,
|
||||||
SettingsValue,
|
SettingsValue,
|
||||||
} from '../config/settingsSchema.js';
|
} from '../config/settingsSchema.js';
|
||||||
import { getSettingsSchema } from '../config/settingsSchema.js';
|
import { SETTING_CATEGORY_ORDER , getSettingsSchema } from '../config/settingsSchema.js';
|
||||||
import type { Config } from '@google/gemini-cli-core';
|
import type { Config } from '@google/gemini-cli-core';
|
||||||
import { ExperimentFlags } from '@google/gemini-cli-core';
|
import { ExperimentFlags } from '@google/gemini-cli-core';
|
||||||
|
|
||||||
@@ -239,6 +239,7 @@ export function shouldShowInDialog(key: string): boolean {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Get all settings that should be shown in the dialog, grouped by category
|
* Get all settings that should be shown in the dialog, grouped by category
|
||||||
|
* Returns categories in the canonical order defined in SETTING_CATEGORY_ORDER.
|
||||||
*/
|
*/
|
||||||
export function getDialogSettingsByCategory(): Record<
|
export function getDialogSettingsByCategory(): Record<
|
||||||
string,
|
string,
|
||||||
@@ -249,6 +250,7 @@ export function getDialogSettingsByCategory(): Record<
|
|||||||
Array<SettingDefinition & { key: string }>
|
Array<SettingDefinition & { key: string }>
|
||||||
> = {};
|
> = {};
|
||||||
|
|
||||||
|
// Group settings by category
|
||||||
Object.values(getFlattenedSchema())
|
Object.values(getFlattenedSchema())
|
||||||
.filter((definition) => definition.showInDialog !== false)
|
.filter((definition) => definition.showInDialog !== false)
|
||||||
.forEach((definition) => {
|
.forEach((definition) => {
|
||||||
@@ -259,7 +261,29 @@ export function getDialogSettingsByCategory(): Record<
|
|||||||
categories[category].push(definition);
|
categories[category].push(definition);
|
||||||
});
|
});
|
||||||
|
|
||||||
return categories;
|
// Reorder categories based on SETTING_CATEGORY_ORDER
|
||||||
|
const orderedCategories: Record<
|
||||||
|
string,
|
||||||
|
Array<SettingDefinition & { key: string }>
|
||||||
|
> = {};
|
||||||
|
|
||||||
|
// Add known categories in order
|
||||||
|
SETTING_CATEGORY_ORDER.forEach((cat) => {
|
||||||
|
if (categories[cat]) {
|
||||||
|
orderedCategories[cat] = categories[cat];
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Add any remaining categories alphabetically
|
||||||
|
Object.keys(categories)
|
||||||
|
.sort()
|
||||||
|
.forEach((cat) => {
|
||||||
|
if (!orderedCategories[cat]) {
|
||||||
|
orderedCategories[cat] = categories[cat];
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return orderedCategories;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
Reference in New Issue
Block a user