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;
}
/**