refactor(cli): overhaul settings UI with noun-first labels, positive logic, and tabbed navigation

This commit is contained in:
Keith Guerin
2026-02-24 00:22:55 -08:00
parent b44af7c168
commit 0ce5805712
6 changed files with 512 additions and 257 deletions
@@ -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,93 +621,69 @@ 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;
// Search box is hidden when restart prompt is shown to save space and avoid key conflicts // Search box is hidden when restart prompt is shown to save space and avoid key conflicts
const shouldShowSearch = !showRestartPrompt; 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 HEADER_HEIGHT = 2; // Category Label + 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;
// 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
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;
}
}
if (!availableTerminalHeight) {
return { return {
effectiveMaxItemsToShow: Math.min(maxItems, items.length), maxListHeight: 24, // Reasonable default for tall terminals
showScopeSelection: shouldShowScope, showScopeSelection: hasWorkspace,
showSearch: shouldShowSearch, showSearch: shouldShowSearch,
}; };
}, [ }
availableTerminalHeight,
items.length, // Layout constants based on BaseSettingsDialog structure:
settings.workspace.path, // 4 for border (2) and padding (2)
showRestartPrompt, const DIALOG_PADDING = 4;
]); const SETTINGS_TITLE_HEIGHT = 1;
const TABS_SECTION_HEIGHT = 3; // marginTop(1) + Tabs(1) + marginBottom(1)
const SEARCH_SECTION_HEIGHT = shouldShowSearch ? 4 : 0; // marginTop(1) + height(3)
const LIST_SPACING_HEIGHT = 2; // Box height(1) after search + Box height(1) after list
const SCROLL_ARROWS_HEIGHT = 0; // Handled within list height
const SCOPE_SECTION_HEIGHT = hasWorkspace ? 5 : 0; // Label(1) + Select(3) + Spacing(1)
const HELP_TEXT_HEIGHT = 1;
const RESTART_PROMPT_HEIGHT = showRestartPrompt ? 1 : 0;
const currentAvailableHeight = availableTerminalHeight - DIALOG_PADDING;
const baseFixedHeight =
SETTINGS_TITLE_HEIGHT +
TABS_SECTION_HEIGHT +
SEARCH_SECTION_HEIGHT +
LIST_SPACING_HEIGHT +
SCROLL_ARROWS_HEIGHT +
HELP_TEXT_HEIGHT +
RESTART_PROMPT_HEIGHT;
// In small terminals, hide scope selector if it would allow more items to show
let shouldShowScope = hasWorkspace;
let finalFixedHeight =
baseFixedHeight + (shouldShowScope ? SCOPE_SECTION_HEIGHT : 0);
if (hasWorkspace && availableTerminalHeight < 25) {
const availableForItemsWithScope =
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;
finalFixedHeight = baseFixedHeight;
}
}
return {
maxListHeight: Math.max(5, currentAvailableHeight - finalFixedHeight),
showScopeSelection: shouldShowScope,
showSearch: shouldShowSearch,
};
}, [availableTerminalHeight, 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,38 +155,100 @@ 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 prevActiveItem = prevItems[activeIndex];
if (prevActiveItem) {
const newIndex = items.findIndex((i) => i.key === prevActiveItem.key);
if (newIndex !== -1) {
// Item still exists in the filtered list, keep focus on it
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); const tabChanged =
setScrollOffset(Math.min(newScroll, maxScroll)); 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];
if (prevActiveItem) {
const newIndex = items.findIndex((i) => i.key === prevActiveItem.key);
if (newIndex !== -1) {
// Item still exists in the filtered list, keep focus on it
setActiveIndex(newIndex);
// Adjust scroll offset to ensure the item is visible within the height budget
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 {
// Item was filtered out, reset to the top
setActiveIndex(0);
setScrollOffset(0);
}
} else { } else {
// Item was filtered out, reset to the top
setActiveIndex(0); setActiveIndex(0);
setScrollOffset(0); setScrollOffset(0);
} }
} else {
setActiveIndex(0);
setScrollOffset(0);
} }
prevItemsRef.current = items; prevItemsRef.current = items;
prevTabIndexRef.current = currentIndex;
} }
}, [items, activeIndex, scrollOffset, maxItemsToShow]); }, [
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,149 +618,163 @@ export function BaseSettingsDialog({
<Box height={1} /> <Box height={1} />
{/* Items list */} {/* Items list */}
{visibleItems.length === 0 ? ( <Box height={maxListHeight} flexDirection="column">
<Box marginX={1} height={1} flexDirection="column"> {visibleItems.length === 0 ? (
<Text color={theme.text.secondary}>No matches found.</Text> <Box marginX={1} height={1} flexDirection="column">
</Box> <Text color={theme.text.secondary}>No matches found.</Text>
) : ( </Box>
<> ) : (
{showScrollUp && ( <>
<Box marginX={1}> {showScrollUp ? (
<Text color={theme.text.secondary}></Text> <Box marginX={1}>
</Box> <Text color={theme.text.secondary}></Text>
)} </Box>
{visibleItems.map((item, idx) => { ) : (
const globalIndex = idx + scrollOffset; <Box height={1} />
const isActive = )}
focusSection === 'settings' && activeIndex === globalIndex; {visibleItems.map((item, idx) => {
const globalIndex = idx + scrollOffset;
const isActive =
focusSection === 'settings' && activeIndex === globalIndex;
const previousItem = const previousItem =
globalIndex > 0 ? items[globalIndex - 1] : undefined; globalIndex > 0 ? items[globalIndex - 1] : undefined;
const showCategoryHeader = const showCategoryHeader =
item.category && item.category !== previousItem?.category; item.category && item.category !== previousItem?.category;
// Compute display value with edit mode cursor // Compute display value with edit mode cursor
let displayValue: string; let displayValue: string;
if (editingKey === item.key) { if (editingKey === item.key) {
// Show edit buffer with cursor highlighting // Show edit buffer with cursor highlighting
if (cursorVisible && editCursorPos < cpLen(editBuffer)) { if (cursorVisible && editCursorPos < cpLen(editBuffer)) {
// Cursor is in the middle or at start of text // Cursor is in the middle or at start of text
const beforeCursor = cpSlice(editBuffer, 0, editCursorPos); const beforeCursor = cpSlice(editBuffer, 0, editCursorPos);
const atCursor = cpSlice( const atCursor = cpSlice(
editBuffer, editBuffer,
editCursorPos, editCursorPos,
editCursorPos + 1, editCursorPos + 1,
); );
const afterCursor = cpSlice(editBuffer, editCursorPos + 1); const afterCursor = cpSlice(editBuffer, editCursorPos + 1);
displayValue = displayValue =
beforeCursor + chalk.inverse(atCursor) + afterCursor; beforeCursor + chalk.inverse(atCursor) + afterCursor;
} else if (editCursorPos >= cpLen(editBuffer)) { } else if (editCursorPos >= cpLen(editBuffer)) {
// Cursor is at the end - show inverted space // Cursor is at the end - show inverted space
displayValue = displayValue =
editBuffer + (cursorVisible ? chalk.inverse(' ') : ' '); editBuffer + (cursorVisible ? chalk.inverse(' ') : ' ');
} else {
// Cursor not visible
displayValue = editBuffer;
}
} else { } else {
// Cursor not visible displayValue = item.displayValue;
displayValue = editBuffer;
} }
} else {
displayValue = item.displayValue;
}
return ( return (
<React.Fragment key={item.key}> <React.Fragment key={item.key}>
{showCategoryHeader && ( {showCategoryHeader && (
<Box
marginX={1}
marginBottom={1}
marginTop={idx === 0 ? 0 : 1}
flexDirection="row"
alignItems="center"
>
<Box flexShrink={0}>
<Text bold>{item.category} </Text>
</Box>
<Box
flexGrow={1}
borderStyle="single"
borderTop={false}
borderBottom
borderLeft={false}
borderRight={false}
borderColor={theme.border.default}
height={0}
/>
</Box>
)}
<Box <Box
marginX={1} marginX={1}
marginBottom={1}
marginTop={idx === 0 ? 0 : 1}
flexDirection="row" flexDirection="row"
alignItems="center"
height={1}
>
<Text bold>{item.category} </Text>
<Box
flexGrow={1}
borderStyle="single"
borderTop
borderBottom={false}
borderLeft={false}
borderRight={false}
borderColor={theme.border.default}
/>
</Box>
)}
<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" alignItems="flex-start"
> >
<Box <Box minWidth={2} flexShrink={0}>
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 <Text
color={ color={
isActive isActive
? theme.status.success ? theme.status.success
: item.isGreyedOut : theme.text.secondary
? theme.text.secondary
: theme.text.primary
} }
terminalCursorFocus={
editingKey === item.key && cursorVisible
}
terminalCursorPosition={cpIndexToOffset(
editBuffer,
editCursorPos,
)}
> >
{displayValue} {isActive ? '●' : ''}
</Text> </Text>
</Box> </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
}
terminalCursorFocus={
editingKey === item.key && cursorVisible
}
terminalCursorPosition={cpIndexToOffset(
editBuffer,
editCursorPos,
)}
>
{displayValue}
</Text>
</Box>
</Box>
</Box> </Box>
</Box> <Box height={1} />
<Box height={1} /> </React.Fragment>
</React.Fragment> );
); })}
})} {showScrollDown && (
{showScrollDown && ( <Box marginX={1}>
<Box marginX={1}> <Text color={theme.text.secondary}></Text>
<Text color={theme.text.secondary}></Text> </Box>
</Box> )}
)} </>
</> )}
)} </Box>
<Box height={1} /> <Box height={1} />
+26 -2
View File
@@ -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;
} }
/** /**