From 0ce58057123f7ec49269c1954258274fd2d7093f Mon Sep 17 00:00:00 2001 From: Keith Guerin Date: Tue, 24 Feb 2026 00:22:55 -0800 Subject: [PATCH] refactor(cli): overhaul settings UI with noun-first labels, positive logic, and tabbed navigation --- .../src/ui/components/AgentConfigDialog.tsx | 5 +- .../src/ui/components/SettingsDialog.test.tsx | 68 ++- .../cli/src/ui/components/SettingsDialog.tsx | 179 +++---- .../shared/BaseSettingsDialog.test.tsx | 10 +- .../components/shared/BaseSettingsDialog.tsx | 479 ++++++++++++------ packages/cli/src/utils/settingsUtils.ts | 28 +- 6 files changed, 512 insertions(+), 257 deletions(-) diff --git a/packages/cli/src/ui/components/AgentConfigDialog.tsx b/packages/cli/src/ui/components/AgentConfigDialog.tsx index 5b4eb1e912..831ea66f43 100644 --- a/packages/cli/src/ui/components/AgentConfigDialog.tsx +++ b/packages/cli/src/ui/components/AgentConfigDialog.tsx @@ -424,6 +424,9 @@ export function AgentConfigDialog({ Changes saved automatically. ) : null; + // Estimate height needed for the list + const maxListHeight = Math.max(15, maxItemsToShow * 3); + return ( { return { ...original, 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 () => { - const actual = await vi.importActual('../../utils/settingsUtils.js'); +vi.mock('../../utils/settingsUtils.js', async (importOriginal) => { + const original = + await importOriginal(); + const CATEGORY_ORDER = [ + 'General', + 'UI', + 'Model', + 'Context', + 'Tools', + 'IDE', + 'Privacy', + 'Extensions', + 'Security', + 'Experimental', + 'Admin', + 'Advanced', + ]; return { - ...actual, + ...original, 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 + > = {}; + 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> = + {}; + 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'); expect(lines.length).toBeGreaterThanOrEqual(24); - expect(lines.length).toBeLessThanOrEqual(25); + expect(lines.length).toBeLessThanOrEqual(27); }); unmount(); }); diff --git a/packages/cli/src/ui/components/SettingsDialog.tsx b/packages/cli/src/ui/components/SettingsDialog.tsx index b87562c9b5..0428395f85 100644 --- a/packages/cli/src/ui/components/SettingsDialog.tsx +++ b/packages/cli/src/ui/components/SettingsDialog.tsx @@ -30,8 +30,10 @@ import { getEffectiveDefaultValue, setPendingSettingValueAny, getEffectiveValue, + getDialogSettingsByCategory, } from '../../utils/settingsUtils.js'; import { useVimMode } from '../contexts/VimModeContext.js'; +import { useTabbedNavigation } from '../hooks/useTabbedNavigation.js'; import { getCachedStringWidth } from '../utils/textUtils.js'; import { type SettingsValue, @@ -62,8 +64,6 @@ interface SettingsDialogProps { config?: Config; } -const MAX_ITEMS_TO_SHOW = 8; - export function SettingsDialog({ settings, onSelect, @@ -136,6 +136,25 @@ export function SettingsDialog({ }; }, [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 const [pendingSettings, setPendingSettings] = useState(() => // Deep clone to avoid mutation @@ -215,7 +234,17 @@ export function SettingsDialog({ }); // 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 scopeSettings = settings.forScope(selectedScope).settings; const mergedSettings = settings.merged; @@ -592,93 +621,69 @@ export function SettingsDialog({ [showRestartPrompt, onRestartRequest, saveRestartRequiredSettings], ); - // Calculate effective max items and scope visibility based on terminal height - const { effectiveMaxItemsToShow, showScopeSelection, showSearch } = - useMemo(() => { - // Only show scope selector if we have a workspace - const hasWorkspace = settings.workspace.path !== undefined; + // Calculate effective max list height and scope visibility based on terminal height + const { maxListHeight, showScopeSelection, showSearch } = useMemo(() => { + // Only show scope selector if we have a workspace + const hasWorkspace = settings.workspace.path !== undefined; - // Search box is hidden when restart prompt is shown to save space and avoid key conflicts - const shouldShowSearch = !showRestartPrompt; - - if (!availableTerminalHeight) { - return { - effectiveMaxItemsToShow: Math.min(MAX_ITEMS_TO_SHOW, items.length), - showScopeSelection: hasWorkspace, - showSearch: shouldShowSearch, - }; - } - - // Layout constants based on BaseSettingsDialog structure: - // 4 for border (2) and padding (2) - const DIALOG_PADDING = 4; - const SETTINGS_TITLE_HEIGHT = 1; - // 3 for box + 1 for marginTop + 1 for spacing after - const SEARCH_SECTION_HEIGHT = shouldShowSearch ? 5 : 0; - const SCROLL_ARROWS_HEIGHT = 2; - const ITEMS_SPACING_AFTER = 1; - // 1 for Label + 3 for Scope items + 1 for spacing after - const SCOPE_SECTION_HEIGHT = hasWorkspace ? 5 : 0; - const HELP_TEXT_HEIGHT = 1; - const RESTART_PROMPT_HEIGHT = showRestartPrompt ? 1 : 0; - const ITEM_HEIGHT = 3; // Label + description + spacing - const 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; - } - } + // Search box is hidden when restart prompt is shown to save space and avoid key conflicts + const shouldShowSearch = !showRestartPrompt; + if (!availableTerminalHeight) { return { - effectiveMaxItemsToShow: Math.min(maxItems, items.length), - showScopeSelection: shouldShowScope, + maxListHeight: 24, // Reasonable default for tall terminals + showScopeSelection: hasWorkspace, showSearch: shouldShowSearch, }; - }, [ - availableTerminalHeight, - items.length, - settings.workspace.path, - showRestartPrompt, - ]); + } + + // Layout constants based on BaseSettingsDialog structure: + // 4 for border (2) and padding (2) + 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 const footerContent = showRestartPrompt ? ( @@ -694,11 +699,13 @@ export function SettingsDialog({ borderColor={showRestartPrompt ? theme.status.warning : undefined} searchEnabled={showSearch} searchBuffer={searchBuffer} + tabs={tabs} + currentIndex={currentIndex} items={items} showScopeSelector={showScopeSelection} selectedScope={selectedScope} onScopeChange={handleScopeChange} - maxItemsToShow={effectiveMaxItemsToShow} + maxListHeight={maxListHeight} maxLabelWidth={maxLabelOrDescriptionWidth} onItemToggle={handleItemToggle} onEditCommit={handleEditCommit} diff --git a/packages/cli/src/ui/components/shared/BaseSettingsDialog.test.tsx b/packages/cli/src/ui/components/shared/BaseSettingsDialog.test.tsx index fbbc6ff517..cc88d07269 100644 --- a/packages/cli/src/ui/components/shared/BaseSettingsDialog.test.tsx +++ b/packages/cli/src/ui/components/shared/BaseSettingsDialog.test.tsx @@ -107,7 +107,7 @@ describe('BaseSettingsDialog', () => { title: 'Test Settings', items: createMockItems(), selectedScope: SettingScope.User, - maxItemsToShow: 8, + maxListHeight: 24, onItemToggle: mockOnItemToggle, onEditCommit: mockOnEditCommit, onItemClear: mockOnItemClear, @@ -310,7 +310,7 @@ describe('BaseSettingsDialog', () => { const { rerender, stdin, lastFrame, waitUntilReady, unmount } = await renderDialog({ items, - maxItemsToShow: 5, + maxListHeight: 15, }); // Move focus down to item 2 ("Number Setting") @@ -333,7 +333,7 @@ describe('BaseSettingsDialog', () => { title="Test Settings" items={filteredItems} selectedScope={SettingScope.User} - maxItemsToShow={5} + maxListHeight={15} onItemToggle={mockOnItemToggle} onEditCommit={mockOnEditCommit} onItemClear={mockOnItemClear} @@ -371,7 +371,7 @@ describe('BaseSettingsDialog', () => { const { rerender, stdin, lastFrame, waitUntilReady, unmount } = await renderDialog({ items, - maxItemsToShow: 5, + maxListHeight: 15, }); // Move focus down to item 2 ("Number Setting") @@ -393,7 +393,7 @@ describe('BaseSettingsDialog', () => { title="Test Settings" items={filteredItems} selectedScope={SettingScope.User} - maxItemsToShow={5} + maxListHeight={15} onItemToggle={mockOnItemToggle} onEditCommit={mockOnEditCommit} onItemClear={mockOnItemClear} diff --git a/packages/cli/src/ui/components/shared/BaseSettingsDialog.tsx b/packages/cli/src/ui/components/shared/BaseSettingsDialog.tsx index 331c6de673..c679e08f38 100644 --- a/packages/cli/src/ui/components/shared/BaseSettingsDialog.tsx +++ b/packages/cli/src/ui/components/shared/BaseSettingsDialog.tsx @@ -4,13 +4,20 @@ * 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 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 { TabHeader, type Tab } from './TabHeader.js'; import { TextInput } from './TextInput.js'; import type { TextBuffer } from './text-buffer.js'; import { @@ -64,6 +71,12 @@ export interface BaseSettingsDialogProps { /** Text buffer for search input */ searchBuffer?: TextBuffer; + // Tabs + /** Array of tab definitions */ + tabs?: Tab[]; + /** Currently active tab index */ + currentIndex?: number; + // Items - parent provides the list /** List of items to display */ items: SettingsDialogItem[]; @@ -77,8 +90,8 @@ export interface BaseSettingsDialogProps { onScopeChange?: (scope: LoadableSettingScope) => void; // Layout - /** Maximum number of items to show at once */ - maxItemsToShow: number; + /** Maximum height in rows for the settings list section */ + maxListHeight: number; /** Maximum label width for alignment */ maxLabelWidth?: number; @@ -116,11 +129,13 @@ export function BaseSettingsDialog({ searchEnabled = true, searchPlaceholder = 'Search to filter', searchBuffer, + tabs, + currentIndex, items, showScopeSelector = true, selectedScope, onScopeChange, - maxItemsToShow, + maxListHeight, maxLabelWidth, onItemToggle, onEditCommit, @@ -140,38 +155,100 @@ export function BaseSettingsDialog({ const [editCursorPos, setEditCursorPos] = useState(0); 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(() => { const prevItems = prevItemsRef.current; - if (prevItems !== items) { - 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 prevTabIndex = prevTabIndexRef.current; - const maxScroll = Math.max(0, items.length - maxItemsToShow); - setScrollOffset(Math.min(newScroll, maxScroll)); + 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]; + 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 { - // Item was filtered out, reset to the top setActiveIndex(0); setScrollOffset(0); } - } else { - setActiveIndex(0); - setScrollOffset(0); } prevItemsRef.current = items; + prevTabIndexRef.current = currentIndex; } - }, [items, activeIndex, scrollOffset, maxItemsToShow]); + }, [ + items, + currentIndex, + activeIndex, + scrollOffset, + maxListHeight, + getItemTotalHeight, + ]); // Cursor blink effect useEffect(() => { @@ -196,12 +273,25 @@ export function BaseSettingsDialog({ key: item.value, })); - // Calculate visible items based on scroll offset - const visibleItems = items.slice(scrollOffset, scrollOffset + maxItemsToShow); + // Calculate which items fit in the current scroll window given maxListHeight + 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 - const showScrollUp = items.length > maxItemsToShow; - const showScrollDown = items.length > maxItemsToShow; + const showScrollUp = scrollOffset > 0; + const showScrollDown = + items.length > 0 && items.length > scrollOffset + visibleItems.length; // Get current item const currentItem = items[activeIndex]; @@ -240,6 +330,37 @@ export function BaseSettingsDialog({ [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 useKeypress( (key: Key) => { @@ -314,7 +435,7 @@ export function BaseSettingsDialog({ const newIndex = activeIndex > 0 ? activeIndex - 1 : items.length - 1; setActiveIndex(newIndex); if (newIndex === items.length - 1) { - setScrollOffset(Math.max(0, items.length - maxItemsToShow)); + setScrollOffset(getBottomScrollOffset()); } else if (newIndex < scrollOffset) { setScrollOffset(newIndex); } @@ -326,8 +447,15 @@ export function BaseSettingsDialog({ setActiveIndex(newIndex); if (newIndex === 0) { setScrollOffset(0); - } else if (newIndex >= scrollOffset + maxItemsToShow) { - setScrollOffset(newIndex - maxItemsToShow + 1); + } else { + // Check if it fits + let heightUsed = 0; + for (let i = scrollOffset; i <= newIndex; i++) { + heightUsed += getItemTotalHeight(i); + } + if (heightUsed > maxListHeight) { + scrollToFitBottom(newIndex); + } } return; } @@ -361,7 +489,7 @@ export function BaseSettingsDialog({ const newIndex = activeIndex > 0 ? activeIndex - 1 : items.length - 1; setActiveIndex(newIndex); if (newIndex === items.length - 1) { - setScrollOffset(Math.max(0, items.length - maxItemsToShow)); + setScrollOffset(getBottomScrollOffset()); } else if (newIndex < scrollOffset) { setScrollOffset(newIndex); } @@ -372,8 +500,15 @@ export function BaseSettingsDialog({ setActiveIndex(newIndex); if (newIndex === 0) { setScrollOffset(0); - } else if (newIndex >= scrollOffset + maxItemsToShow) { - setScrollOffset(newIndex - maxItemsToShow + 1); + } else { + // Check if it fits + let heightUsed = 0; + for (let i = scrollOffset; i <= newIndex; i++) { + heightUsed += getItemTotalHeight(i); + } + if (heightUsed > maxListHeight) { + scrollToFitBottom(newIndex); + } } return true; } @@ -445,6 +580,18 @@ export function BaseSettingsDialog({ + {/* Tabs */} + {tabs && currentIndex !== undefined && ( + + + + )} + {/* Search input (if enabled) */} {searchEnabled && searchBuffer && ( {/* Items list */} - {visibleItems.length === 0 ? ( - - No matches found. - - ) : ( - <> - {showScrollUp && ( - - - - )} - {visibleItems.map((item, idx) => { - const globalIndex = idx + scrollOffset; - const isActive = - focusSection === 'settings' && activeIndex === globalIndex; + + {visibleItems.length === 0 ? ( + + No matches found. + + ) : ( + <> + {showScrollUp ? ( + + + + ) : ( + + )} + {visibleItems.map((item, idx) => { + const globalIndex = idx + scrollOffset; + const isActive = + focusSection === 'settings' && activeIndex === globalIndex; - const previousItem = - globalIndex > 0 ? items[globalIndex - 1] : undefined; - const showCategoryHeader = - item.category && item.category !== previousItem?.category; + const previousItem = + globalIndex > 0 ? items[globalIndex - 1] : undefined; + const showCategoryHeader = + item.category && item.category !== previousItem?.category; - // 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(' ') : ' '); + // 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 { - // Cursor not visible - displayValue = editBuffer; + displayValue = item.displayValue; } - } else { - displayValue = item.displayValue; - } - return ( - - {showCategoryHeader && ( + return ( + + {showCategoryHeader && ( + + + {item.category} + + + + )} - {item.category} - - - )} - - - - {isActive ? '●' : ''} - - - - - - {item.label} - {item.scopeMessage && ( - - {' '} - {item.scopeMessage} - - )} - - - {item.description ?? ''} - - - - + - {displayValue} + {isActive ? '●' : ''} + + + + {item.label} + {item.scopeMessage && ( + + {' '} + {item.scopeMessage} + + )} + + + {item.description ?? ''} + + + + + + {displayValue} + + + - - - - ); - })} - {showScrollDown && ( - - - - )} - - )} + + + ); + })} + {showScrollDown && ( + + + + )} + + )} + diff --git a/packages/cli/src/utils/settingsUtils.ts b/packages/cli/src/utils/settingsUtils.ts index 53c71bfd3a..9b2c26ded2 100644 --- a/packages/cli/src/utils/settingsUtils.ts +++ b/packages/cli/src/utils/settingsUtils.ts @@ -15,7 +15,7 @@ import type { SettingsType, SettingsValue, } 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 { 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 + * Returns categories in the canonical order defined in SETTING_CATEGORY_ORDER. */ export function getDialogSettingsByCategory(): Record< string, @@ -249,6 +250,7 @@ export function getDialogSettingsByCategory(): Record< Array > = {}; + // Group settings by category Object.values(getFlattenedSchema()) .filter((definition) => definition.showInDialog !== false) .forEach((definition) => { @@ -259,7 +261,29 @@ export function getDialogSettingsByCategory(): Record< categories[category].push(definition); }); - return categories; + // Reorder categories based on SETTING_CATEGORY_ORDER + const orderedCategories: Record< + string, + Array + > = {}; + + // 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; } /**