diff --git a/packages/cli/src/ui/components/AgentConfigDialog.test.tsx b/packages/cli/src/ui/components/AgentConfigDialog.test.tsx
index 05cd4a47f5..52cda094e0 100644
--- a/packages/cli/src/ui/components/AgentConfigDialog.test.tsx
+++ b/packages/cli/src/ui/components/AgentConfigDialog.test.tsx
@@ -327,5 +327,31 @@ describe('AgentConfigDialog', () => {
expect(frame).toContain('false');
unmount();
});
+ it('should respond to availableTerminalHeight and truncate list', async () => {
+ const settings = createMockSettings();
+ // Agent config has about 6 base items + 2 per tool
+ // Render with very small height (20)
+ const { lastFrame, unmount } = render(
+
+
+ ,
+ );
+ await waitFor(() =>
+ expect(lastFrame()).toContain('Configure: Test Agent'),
+ );
+
+ const frame = lastFrame();
+ // At height 20, it should be heavily truncated and show '▼'
+ expect(frame).toContain('▼');
+ unmount();
+ });
});
});
diff --git a/packages/cli/src/ui/components/AgentConfigDialog.tsx b/packages/cli/src/ui/components/AgentConfigDialog.tsx
index 4079c6df77..819b32d7b0 100644
--- a/packages/cli/src/ui/components/AgentConfigDialog.tsx
+++ b/packages/cli/src/ui/components/AgentConfigDialog.tsx
@@ -110,6 +110,8 @@ interface AgentConfigDialogProps {
settings: LoadedSettings;
onClose: () => void;
onSave?: () => void;
+ /** Available terminal height for dynamic windowing */
+ availableTerminalHeight?: number;
}
/**
@@ -192,6 +194,7 @@ export function AgentConfigDialog({
settings,
onClose,
onSave,
+ availableTerminalHeight,
}: AgentConfigDialogProps): React.JSX.Element {
// Scope selector state (User by default)
const [selectedScope, setSelectedScope] = useState(
@@ -395,12 +398,6 @@ export function AgentConfigDialog({
[pendingOverride, saveFieldValue],
);
- // Footer content
- const footerContent =
- modifiedFields.size > 0 ? (
- Changes saved automatically.
- ) : null;
-
return (
0
+ ? {
+ content: (
+
+ Changes saved automatically.
+
+ ),
+ height: 1,
+ }
+ : undefined
+ }
/>
);
}
diff --git a/packages/cli/src/ui/components/DialogManager.tsx b/packages/cli/src/ui/components/DialogManager.tsx
index 5119c1b343..de62401e1e 100644
--- a/packages/cli/src/ui/components/DialogManager.tsx
+++ b/packages/cli/src/ui/components/DialogManager.tsx
@@ -252,6 +252,7 @@ export const DialogManager = ({
displayName={uiState.selectedAgentDisplayName}
definition={uiState.selectedAgentDefinition}
settings={settings}
+ availableTerminalHeight={terminalHeight - staticExtraHeight}
onClose={uiActions.closeAgentConfigDialog}
onSave={async () => {
// Reload agent registry to pick up changes
diff --git a/packages/cli/src/ui/components/SettingsDialog.tsx b/packages/cli/src/ui/components/SettingsDialog.tsx
index 23e8a55a7d..b8136254f3 100644
--- a/packages/cli/src/ui/components/SettingsDialog.tsx
+++ b/packages/cli/src/ui/components/SettingsDialog.tsx
@@ -346,94 +346,9 @@ export function SettingsDialog({
[showRestartPrompt, onRestartRequest],
);
- // 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;
-
- // 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 currentAvailableHeight = availableTerminalHeight - DIALOG_PADDING;
-
- const baseFixedHeight =
- SETTINGS_TITLE_HEIGHT +
- SEARCH_SECTION_HEIGHT +
- SCROLL_ARROWS_HEIGHT +
- ITEMS_SPACING_AFTER +
- HELP_TEXT_HEIGHT +
- RESTART_PROMPT_HEIGHT;
-
- // Calculate max items with scope selector
- const heightWithScope = baseFixedHeight + SCOPE_SECTION_HEIGHT;
- const availableForItemsWithScope =
- currentAvailableHeight - heightWithScope;
- const maxItemsWithScope = Math.max(
- 1,
- Math.floor(availableForItemsWithScope / ITEM_HEIGHT),
- );
-
- // Calculate max items without scope selector
- const availableForItemsWithoutScope =
- currentAvailableHeight - baseFixedHeight;
- const maxItemsWithoutScope = Math.max(
- 1,
- Math.floor(availableForItemsWithoutScope / 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;
- }
- }
-
- return {
- effectiveMaxItemsToShow: Math.min(maxItems, items.length),
- showScopeSelection: shouldShowScope,
- showSearch: shouldShowSearch,
- };
- }, [
- availableTerminalHeight,
- items.length,
- settings.workspace.path,
- showRestartPrompt,
- ]);
-
- const footerContent = showRestartPrompt ? (
-
- Changes that require a restart have been modified. Press r to exit and
- apply changes now.
-
- ) : null;
+ // Decisions on what features to enable
+ const hasWorkspace = settings.workspace.path !== undefined;
+ const showSearch = !showRestartPrompt;
return (
+ Changes that require a restart have been modified. Press r to
+ exit and apply changes now.
+
+ ),
+ height: 1,
+ }
+ : undefined
+ }
/>
);
}
diff --git a/packages/cli/src/ui/components/shared/BaseSettingsDialog.test.tsx b/packages/cli/src/ui/components/shared/BaseSettingsDialog.test.tsx
index 4047ec9ef8..5cc731e3f7 100644
--- a/packages/cli/src/ui/components/shared/BaseSettingsDialog.test.tsx
+++ b/packages/cli/src/ui/components/shared/BaseSettingsDialog.test.tsx
@@ -174,7 +174,10 @@ describe('BaseSettingsDialog', () => {
it('should render footer content when provided', async () => {
const { lastFrame, unmount } = await renderDialog({
- footerContent: Custom Footer,
+ footer: {
+ content: Custom Footer,
+ height: 1,
+ },
});
expect(lastFrame()).toContain('Custom Footer');
@@ -801,4 +804,57 @@ describe('BaseSettingsDialog', () => {
unmount();
});
});
+
+ describe('responsiveness', () => {
+ it('should show the scope selector when availableHeight is sufficient (25)', async () => {
+ const { lastFrame, unmount } = await renderDialog({
+ availableHeight: 25,
+ showScopeSelector: true,
+ });
+
+ const frame = lastFrame();
+ expect(frame).toContain('Apply To');
+ unmount();
+ });
+
+ it('should hide the scope selector when availableHeight is small (24) to show more items', async () => {
+ const { lastFrame, unmount } = await renderDialog({
+ availableHeight: 24,
+ showScopeSelector: true,
+ });
+
+ const frame = lastFrame();
+ expect(frame).not.toContain('Apply To');
+ unmount();
+ });
+
+ it('should reduce the number of visible items based on height', async () => {
+ // At height 25, it should show 2 items (math: (25-4 - (10+5))/3 = 2)
+ const { lastFrame, unmount } = await renderDialog({
+ availableHeight: 25,
+ items: createMockItems(10),
+ });
+
+ const frame = lastFrame();
+ // Items 0 and 1 should be there
+ expect(frame).toContain('Boolean Setting');
+ expect(frame).toContain('String Setting');
+ // Item 2 should NOT be there
+ expect(frame).not.toContain('Number Setting');
+ unmount();
+ });
+
+ it('should show scroll indicators when list is truncated by height', async () => {
+ const { lastFrame, unmount } = await renderDialog({
+ availableHeight: 25,
+ items: createMockItems(10),
+ });
+
+ const frame = lastFrame();
+ // Shows both scroll indicators when the list is truncated by height
+ expect(frame).toContain('▼');
+ expect(frame).toContain('▲');
+ unmount();
+ });
+ });
});
diff --git a/packages/cli/src/ui/components/shared/BaseSettingsDialog.tsx b/packages/cli/src/ui/components/shared/BaseSettingsDialog.tsx
index 05cef4fcf2..bccde9766d 100644
--- a/packages/cli/src/ui/components/shared/BaseSettingsDialog.tsx
+++ b/packages/cli/src/ui/components/shared/BaseSettingsDialog.tsx
@@ -4,7 +4,7 @@
* SPDX-License-Identifier: Apache-2.0
*/
-import React, { useState, useEffect, useCallback, useRef } from 'react';
+import React, { useMemo, useState, useCallback } from 'react';
import { Box, Text } from 'ink';
import chalk from 'chalk';
import { theme } from '../../semantic-colors.js';
@@ -17,14 +17,11 @@ import { getScopeItems } from '../../../utils/dialogScopeUtils.js';
import { RadioButtonSelect } from './RadioButtonSelect.js';
import { TextInput } from './TextInput.js';
import type { TextBuffer } from './text-buffer.js';
-import {
- cpSlice,
- cpLen,
- stripUnsafeCharacters,
- cpIndexToOffset,
-} from '../../utils/textUtils.js';
+import { cpSlice, cpLen, cpIndexToOffset } from '../../utils/textUtils.js';
import { useKeypress, type Key } from '../../hooks/useKeypress.js';
import { keyMatchers, Command } from '../../keyMatchers.js';
+import { useSettingsNavigation } from '../../hooks/useSettingsNavigation.js';
+import { useInlineEditBuffer } from '../../hooks/useInlineEditBuffer.js';
import { formatCommand } from '../../utils/keybindingUtils.js';
/**
@@ -60,7 +57,6 @@ export interface BaseSettingsDialogProps {
title: string;
/** Optional border color for the dialog */
borderColor?: string;
-
// Search (optional feature)
/** Whether to show the search input. Default: true */
searchEnabled?: boolean;
@@ -106,9 +102,14 @@ export interface BaseSettingsDialogProps {
currentItem: SettingsDialogItem | undefined,
) => boolean;
- // Optional extra content below help text (for restart prompt, etc.)
- /** Optional footer content (e.g., restart prompt) */
- footerContent?: React.ReactNode;
+ /** Available terminal height for dynamic windowing */
+ availableHeight?: number;
+
+ /** Optional footer configuration */
+ footer?: {
+ content: React.ReactNode;
+ height: number;
+ };
}
/**
@@ -132,68 +133,113 @@ export function BaseSettingsDialog({
onItemClear,
onClose,
onKeyPress,
- footerContent,
+ availableHeight,
+ footer,
}: BaseSettingsDialogProps): React.JSX.Element {
+ // Calculate effective max items and scope visibility based on terminal height
+ const { effectiveMaxItemsToShow, finalShowScopeSelector } = useMemo(() => {
+ const initialShowScope = showScopeSelector;
+ const initialMaxItems = maxItemsToShow;
+
+ if (!availableHeight) {
+ return {
+ effectiveMaxItemsToShow: initialMaxItems,
+ finalShowScopeSelector: initialShowScope,
+ };
+ }
+
+ // Layout constants based on BaseSettingsDialog structure:
+ const DIALOG_PADDING = 4;
+ const SETTINGS_TITLE_HEIGHT = 1;
+ // Account for the unconditional spacer below search/title section
+ const SEARCH_SECTION_HEIGHT = searchEnabled ? 5 : 1;
+ const SCROLL_ARROWS_HEIGHT = 2;
+ const ITEMS_SPACING_AFTER = 1;
+ const SCOPE_SECTION_HEIGHT = 5;
+ const HELP_TEXT_HEIGHT = 1;
+ const FOOTER_CONTENT_HEIGHT = footer?.height ?? 0;
+ const ITEM_HEIGHT = 3;
+
+ const currentAvailableHeight = availableHeight - DIALOG_PADDING;
+
+ const baseFixedHeight =
+ SETTINGS_TITLE_HEIGHT +
+ SEARCH_SECTION_HEIGHT +
+ SCROLL_ARROWS_HEIGHT +
+ ITEMS_SPACING_AFTER +
+ HELP_TEXT_HEIGHT +
+ FOOTER_CONTENT_HEIGHT;
+
+ // Calculate max items with scope selector
+ const heightWithScope = baseFixedHeight + SCOPE_SECTION_HEIGHT;
+ const availableForItemsWithScope = currentAvailableHeight - heightWithScope;
+ const maxItemsWithScope = Math.max(
+ 1,
+ Math.floor(availableForItemsWithScope / ITEM_HEIGHT),
+ );
+
+ // Calculate max items without scope selector
+ const availableForItemsWithoutScope =
+ currentAvailableHeight - baseFixedHeight;
+ const maxItemsWithoutScope = Math.max(
+ 1,
+ Math.floor(availableForItemsWithoutScope / ITEM_HEIGHT),
+ );
+
+ // In small terminals, hide scope selector if it would allow more items to show
+ let shouldShowScope = initialShowScope;
+ let maxItems = initialShowScope ? maxItemsWithScope : maxItemsWithoutScope;
+
+ if (initialShowScope && availableHeight < 25) {
+ // Hide scope selector if it gains us more than 1 extra item
+ if (maxItemsWithoutScope > maxItemsWithScope + 1) {
+ shouldShowScope = false;
+ maxItems = maxItemsWithoutScope;
+ }
+ }
+
+ return {
+ effectiveMaxItemsToShow: Math.min(maxItems, items.length),
+ finalShowScopeSelector: shouldShowScope,
+ };
+ }, [
+ availableHeight,
+ maxItemsToShow,
+ items.length,
+ searchEnabled,
+ showScopeSelector,
+ footer,
+ ]);
+
// Internal state
- const [activeIndex, setActiveIndex] = useState(0);
- const [scrollOffset, setScrollOffset] = useState(0);
+ const { activeIndex, windowStart, moveUp, moveDown } = useSettingsNavigation({
+ items,
+ maxItemsToShow: effectiveMaxItemsToShow,
+ });
+
+ const { editState, editDispatch, startEditing, commitEdit, cursorVisible } =
+ useInlineEditBuffer({
+ onCommit: (key, value) => {
+ const itemToCommit = items.find((i) => i.key === key);
+ if (itemToCommit) {
+ onEditCommit(key, value, itemToCommit);
+ }
+ },
+ });
+
+ const {
+ editingKey,
+ buffer: editBuffer,
+ cursorPos: editCursorPos,
+ } = editState;
+
const [focusSection, setFocusSection] = useState<'settings' | 'scope'>(
'settings',
);
- const [editingKey, setEditingKey] = useState(null);
- const [editBuffer, setEditBuffer] = useState('');
- const [editCursorPos, setEditCursorPos] = useState(0);
- const [cursorVisible, setCursorVisible] = useState(true);
-
- const prevItemsRef = useRef(items);
-
- // Preserve focus when items change (e.g., search filter)
- 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 maxScroll = Math.max(0, items.length - maxItemsToShow);
- setScrollOffset(Math.min(newScroll, maxScroll));
- } else {
- // Item was filtered out, reset to the top
- setActiveIndex(0);
- setScrollOffset(0);
- }
- } else {
- setActiveIndex(0);
- setScrollOffset(0);
- }
- prevItemsRef.current = items;
- }
- }, [items, activeIndex, scrollOffset, maxItemsToShow]);
-
- // Cursor blink effect
- useEffect(() => {
- if (!editingKey) return;
- setCursorVisible(true);
- const interval = setInterval(() => {
- setCursorVisible((v) => !v);
- }, 500);
- return () => clearInterval(interval);
- }, [editingKey]);
-
- // Ensure focus stays on settings when scope selection is hidden
- useEffect(() => {
- if (!showScopeSelector && focusSection === 'scope') {
- setFocusSection('settings');
- }
- }, [showScopeSelector, focusSection]);
+ const effectiveFocusSection =
+ !finalShowScopeSelector && focusSection === 'scope'
+ ? 'settings'
+ : focusSection;
// Scope selector items
const scopeItems = getScopeItems().map((item) => ({
@@ -202,43 +248,20 @@ export function BaseSettingsDialog({
}));
// Calculate visible items based on scroll offset
- const visibleItems = items.slice(scrollOffset, scrollOffset + maxItemsToShow);
+ const visibleItems = items.slice(
+ windowStart,
+ windowStart + effectiveMaxItemsToShow,
+ );
// Show scroll indicators if there are more items than can be displayed
- const showScrollUp = items.length > maxItemsToShow;
- const showScrollDown = items.length > maxItemsToShow;
+ const showScrollUp = items.length > effectiveMaxItemsToShow;
+ const showScrollDown = items.length > effectiveMaxItemsToShow;
// Get current item
const currentItem = items[activeIndex];
- // Start editing a field
- const startEditing = useCallback((key: string, initialValue: string) => {
- setEditingKey(key);
- setEditBuffer(initialValue);
- setEditCursorPos(cpLen(initialValue));
- setCursorVisible(true);
- }, []);
-
- // Commit edit and exit edit mode
- const commitEdit = useCallback(() => {
- if (editingKey && currentItem) {
- onEditCommit(editingKey, editBuffer, currentItem);
- }
- setEditingKey(null);
- setEditBuffer('');
- setEditCursorPos(0);
- }, [editingKey, editBuffer, currentItem, onEditCommit]);
-
- // Handle scope highlight (for RadioButtonSelect)
- const handleScopeHighlight = useCallback(
- (scope: LoadableSettingScope) => {
- onScopeChange?.(scope);
- },
- [onScopeChange],
- );
-
- // Handle scope select (for RadioButtonSelect)
- const handleScopeSelect = useCallback(
+ // Handle scope changes (for RadioButtonSelect)
+ const handleScopeChange = useCallback(
(scope: LoadableSettingScope) => {
onScopeChange?.(scope);
},
@@ -248,8 +271,8 @@ export function BaseSettingsDialog({
// Keyboard handling
useKeypress(
(key: Key) => {
- // Let parent handle custom keys first
- if (onKeyPress?.(key, currentItem)) {
+ // Let parent handle custom keys first (only if not editing)
+ if (!editingKey && onKeyPress?.(key, currentItem)) {
return;
}
@@ -260,44 +283,31 @@ export function BaseSettingsDialog({
// Navigation within edit buffer
if (keyMatchers[Command.MOVE_LEFT](key)) {
- setEditCursorPos((p) => Math.max(0, p - 1));
+ editDispatch({ type: 'MOVE_LEFT' });
return;
}
if (keyMatchers[Command.MOVE_RIGHT](key)) {
- setEditCursorPos((p) => Math.min(cpLen(editBuffer), p + 1));
+ editDispatch({ type: 'MOVE_RIGHT' });
return;
}
if (keyMatchers[Command.HOME](key)) {
- setEditCursorPos(0);
+ editDispatch({ type: 'HOME' });
return;
}
if (keyMatchers[Command.END](key)) {
- setEditCursorPos(cpLen(editBuffer));
+ editDispatch({ type: 'END' });
return;
}
// Backspace
if (keyMatchers[Command.DELETE_CHAR_LEFT](key)) {
- if (editCursorPos > 0) {
- setEditBuffer((b) => {
- const before = cpSlice(b, 0, editCursorPos - 1);
- const after = cpSlice(b, editCursorPos);
- return before + after;
- });
- setEditCursorPos((p) => p - 1);
- }
+ editDispatch({ type: 'DELETE_LEFT' });
return;
}
// Delete
if (keyMatchers[Command.DELETE_CHAR_RIGHT](key)) {
- if (editCursorPos < cpLen(editBuffer)) {
- setEditBuffer((b) => {
- const before = cpSlice(b, 0, editCursorPos);
- const after = cpSlice(b, editCursorPos + 1);
- return before + after;
- });
- }
+ editDispatch({ type: 'DELETE_RIGHT' });
return;
}
@@ -316,70 +326,35 @@ export function BaseSettingsDialog({
// Up/Down in edit mode - commit and navigate
if (keyMatchers[Command.DIALOG_NAVIGATION_UP](key)) {
commitEdit();
- const newIndex = activeIndex > 0 ? activeIndex - 1 : items.length - 1;
- setActiveIndex(newIndex);
- if (newIndex === items.length - 1) {
- setScrollOffset(Math.max(0, items.length - maxItemsToShow));
- } else if (newIndex < scrollOffset) {
- setScrollOffset(newIndex);
- }
+ moveUp();
return;
}
if (keyMatchers[Command.DIALOG_NAVIGATION_DOWN](key)) {
commitEdit();
- const newIndex = activeIndex < items.length - 1 ? activeIndex + 1 : 0;
- setActiveIndex(newIndex);
- if (newIndex === 0) {
- setScrollOffset(0);
- } else if (newIndex >= scrollOffset + maxItemsToShow) {
- setScrollOffset(newIndex - maxItemsToShow + 1);
- }
+ moveDown();
return;
}
// Character input
- let ch = key.sequence;
- let isValidChar = false;
- if (type === 'number') {
- isValidChar = /[0-9\-+.]/.test(ch);
- } else {
- isValidChar = ch.length === 1 && ch.charCodeAt(0) >= 32;
- // Sanitize string input to prevent unsafe characters
- ch = stripUnsafeCharacters(ch);
- }
-
- if (isValidChar && ch.length > 0) {
- setEditBuffer((b) => {
- const before = cpSlice(b, 0, editCursorPos);
- const after = cpSlice(b, editCursorPos);
- return before + ch + after;
+ if (key.sequence) {
+ editDispatch({
+ type: 'INSERT_CHAR',
+ char: key.sequence,
+ isNumberType: type === 'number',
});
- setEditCursorPos((p) => p + 1);
}
return;
}
// Not in edit mode - handle navigation and actions
- if (focusSection === 'settings') {
+ if (effectiveFocusSection === 'settings') {
// Up/Down navigation with wrap-around
if (keyMatchers[Command.DIALOG_NAVIGATION_UP](key)) {
- const newIndex = activeIndex > 0 ? activeIndex - 1 : items.length - 1;
- setActiveIndex(newIndex);
- if (newIndex === items.length - 1) {
- setScrollOffset(Math.max(0, items.length - maxItemsToShow));
- } else if (newIndex < scrollOffset) {
- setScrollOffset(newIndex);
- }
+ moveUp();
return true;
}
if (keyMatchers[Command.DIALOG_NAVIGATION_DOWN](key)) {
- const newIndex = activeIndex < items.length - 1 ? activeIndex + 1 : 0;
- setActiveIndex(newIndex);
- if (newIndex === 0) {
- setScrollOffset(0);
- } else if (newIndex >= scrollOffset + maxItemsToShow) {
- setScrollOffset(newIndex - maxItemsToShow + 1);
- }
+ moveDown();
return true;
}
@@ -412,7 +387,7 @@ export function BaseSettingsDialog({
}
// Tab - switch focus section
- if (key.name === 'tab' && showScopeSelector) {
+ if (key.name === 'tab' && finalShowScopeSelector) {
setFocusSection((s) => (s === 'settings' ? 'scope' : 'settings'));
return;
}
@@ -427,7 +402,7 @@ export function BaseSettingsDialog({
},
{
isActive: true,
- priority: focusSection === 'settings' && !editingKey,
+ priority: effectiveFocusSection === 'settings',
},
);
@@ -444,10 +419,10 @@ export function BaseSettingsDialog({
{/* Title */}
- {focusSection === 'settings' ? '> ' : ' '}
+ {effectiveFocusSection === 'settings' ? '> ' : ' '}
{title}{' '}
@@ -459,7 +434,7 @@ export function BaseSettingsDialog({
borderColor={
editingKey
? theme.border.default
- : focusSection === 'settings'
+ : effectiveFocusSection === 'settings'
? theme.ui.focus
: theme.border.default
}
@@ -468,7 +443,7 @@ export function BaseSettingsDialog({
marginTop={1}
>
@@ -490,9 +465,10 @@ export function BaseSettingsDialog({
)}
{visibleItems.map((item, idx) => {
- const globalIndex = idx + scrollOffset;
+ const globalIndex = idx + windowStart;
const isActive =
- focusSection === 'settings' && activeIndex === globalIndex;
+ effectiveFocusSection === 'settings' &&
+ activeIndex === globalIndex;
// Compute display value with edit mode cursor
let displayValue: string;
@@ -602,21 +578,21 @@ export function BaseSettingsDialog({
{/* Scope Selection */}
- {showScopeSelector && (
+ {finalShowScopeSelector && (
-
- {focusSection === 'scope' ? '> ' : ' '}Apply To
+
+ {effectiveFocusSection === 'scope' ? '> ' : ' '}Apply To
item.value === selectedScope,
)}
- onSelect={handleScopeSelect}
- onHighlight={handleScopeHighlight}
- isFocused={focusSection === 'scope'}
- showNumbers={focusSection === 'scope'}
- priority={focusSection === 'scope'}
+ onSelect={handleScopeChange}
+ onHighlight={handleScopeChange}
+ isFocused={effectiveFocusSection === 'scope'}
+ showNumbers={effectiveFocusSection === 'scope'}
+ priority={effectiveFocusSection === 'scope'}
/>
)}
@@ -627,12 +603,13 @@ export function BaseSettingsDialog({
(Use Enter to select, {formatCommand(Command.CLEAR_SCREEN)} to reset
- {showScopeSelector ? ', Tab to change focus' : ''}, Esc to close)
+ {finalShowScopeSelector ? ', Tab to change focus' : ''}, Esc to
+ close)
{/* Footer content (e.g., restart prompt) */}
- {footerContent && {footerContent}}
+ {footer && {footer.content}}
);
diff --git a/packages/cli/src/ui/hooks/useInlineEditBuffer.test.ts b/packages/cli/src/ui/hooks/useInlineEditBuffer.test.ts
new file mode 100644
index 0000000000..b22ee62c81
--- /dev/null
+++ b/packages/cli/src/ui/hooks/useInlineEditBuffer.test.ts
@@ -0,0 +1,158 @@
+/**
+ * @license
+ * Copyright 2026 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import { renderHook } from '../../test-utils/render.js';
+import { act } from 'react';
+import { describe, it, expect, vi, beforeEach, type Mock } from 'vitest';
+import { useInlineEditBuffer } from './useInlineEditBuffer.js';
+
+describe('useEditBuffer', () => {
+ let mockOnCommit: Mock;
+
+ beforeEach(() => {
+ vi.clearAllMocks();
+ mockOnCommit = vi.fn();
+ });
+
+ it('should initialize with empty state', () => {
+ const { result } = renderHook(() =>
+ useInlineEditBuffer({ onCommit: mockOnCommit }),
+ );
+ expect(result.current.editState.editingKey).toBeNull();
+ expect(result.current.editState.buffer).toBe('');
+ expect(result.current.editState.cursorPos).toBe(0);
+ });
+
+ it('should start editing correctly', () => {
+ const { result } = renderHook(() =>
+ useInlineEditBuffer({ onCommit: mockOnCommit }),
+ );
+ act(() => result.current.startEditing('my-key', 'initial'));
+
+ expect(result.current.editState.editingKey).toBe('my-key');
+ expect(result.current.editState.buffer).toBe('initial');
+ expect(result.current.editState.cursorPos).toBe(7); // End of string
+ });
+
+ it('should commit edit and reset state', () => {
+ const { result } = renderHook(() =>
+ useInlineEditBuffer({ onCommit: mockOnCommit }),
+ );
+
+ act(() => result.current.startEditing('my-key', 'text'));
+ act(() => result.current.commitEdit());
+
+ expect(mockOnCommit).toHaveBeenCalledWith('my-key', 'text');
+ expect(result.current.editState.editingKey).toBeNull();
+ expect(result.current.editState.buffer).toBe('');
+ });
+
+ it('should move cursor left and right', () => {
+ const { result } = renderHook(() =>
+ useInlineEditBuffer({ onCommit: mockOnCommit }),
+ );
+ act(() => result.current.startEditing('key', 'ab')); // cursor at 2
+
+ act(() => result.current.editDispatch({ type: 'MOVE_LEFT' }));
+ expect(result.current.editState.cursorPos).toBe(1);
+
+ act(() => result.current.editDispatch({ type: 'MOVE_LEFT' }));
+ expect(result.current.editState.cursorPos).toBe(0);
+
+ // Shouldn't go below 0
+ act(() => result.current.editDispatch({ type: 'MOVE_LEFT' }));
+ expect(result.current.editState.cursorPos).toBe(0);
+
+ act(() => result.current.editDispatch({ type: 'MOVE_RIGHT' }));
+ expect(result.current.editState.cursorPos).toBe(1);
+ });
+
+ it('should handle home and end', () => {
+ const { result } = renderHook(() =>
+ useInlineEditBuffer({ onCommit: mockOnCommit }),
+ );
+ act(() => result.current.startEditing('key', 'testing')); // cursor at 7
+
+ act(() => result.current.editDispatch({ type: 'HOME' }));
+ expect(result.current.editState.cursorPos).toBe(0);
+
+ act(() => result.current.editDispatch({ type: 'END' }));
+ expect(result.current.editState.cursorPos).toBe(7);
+ });
+
+ it('should delete characters to the left (backspace)', () => {
+ const { result } = renderHook(() =>
+ useInlineEditBuffer({ onCommit: mockOnCommit }),
+ );
+ act(() => result.current.startEditing('key', 'abc')); // cursor at 3
+
+ act(() => result.current.editDispatch({ type: 'DELETE_LEFT' }));
+ expect(result.current.editState.buffer).toBe('ab');
+ expect(result.current.editState.cursorPos).toBe(2);
+
+ // Move to start, shouldn't delete
+ act(() => result.current.editDispatch({ type: 'HOME' }));
+ act(() => result.current.editDispatch({ type: 'DELETE_LEFT' }));
+ expect(result.current.editState.buffer).toBe('ab');
+ });
+
+ it('should delete characters to the right (delete tab)', () => {
+ const { result } = renderHook(() =>
+ useInlineEditBuffer({ onCommit: mockOnCommit }),
+ );
+ act(() => result.current.startEditing('key', 'abc'));
+ act(() => result.current.editDispatch({ type: 'HOME' })); // cursor at 0
+
+ act(() => result.current.editDispatch({ type: 'DELETE_RIGHT' }));
+ expect(result.current.editState.buffer).toBe('bc');
+ expect(result.current.editState.cursorPos).toBe(0);
+ });
+
+ it('should insert valid characters into string', () => {
+ const { result } = renderHook(() =>
+ useInlineEditBuffer({ onCommit: mockOnCommit }),
+ );
+ act(() => result.current.startEditing('key', 'ab'));
+ act(() => result.current.editDispatch({ type: 'MOVE_LEFT' })); // cursor at 1
+
+ act(() =>
+ result.current.editDispatch({
+ type: 'INSERT_CHAR',
+ char: 'x',
+ isNumberType: false,
+ }),
+ );
+ expect(result.current.editState.buffer).toBe('axb');
+ expect(result.current.editState.cursorPos).toBe(2);
+ });
+
+ it('should validate number character insertions', () => {
+ const { result } = renderHook(() =>
+ useInlineEditBuffer({ onCommit: mockOnCommit }),
+ );
+ act(() => result.current.startEditing('key', '12'));
+
+ // Valid number char
+ act(() =>
+ result.current.editDispatch({
+ type: 'INSERT_CHAR',
+ char: '.',
+ isNumberType: true,
+ }),
+ );
+ expect(result.current.editState.buffer).toBe('12.');
+
+ // Invalid number char
+ act(() =>
+ result.current.editDispatch({
+ type: 'INSERT_CHAR',
+ char: 'a',
+ isNumberType: true,
+ }),
+ );
+ expect(result.current.editState.buffer).toBe('12.'); // Unchanged
+ });
+});
diff --git a/packages/cli/src/ui/hooks/useInlineEditBuffer.ts b/packages/cli/src/ui/hooks/useInlineEditBuffer.ts
new file mode 100644
index 0000000000..c3dbb05016
--- /dev/null
+++ b/packages/cli/src/ui/hooks/useInlineEditBuffer.ts
@@ -0,0 +1,152 @@
+/**
+ * @license
+ * Copyright 2026 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import { useReducer, useCallback, useEffect, useState } from 'react';
+import { cpSlice, cpLen, stripUnsafeCharacters } from '../utils/textUtils.js';
+
+export interface EditBufferState {
+ editingKey: string | null;
+ buffer: string;
+ cursorPos: number;
+}
+
+export type EditBufferAction =
+ | { type: 'START_EDIT'; key: string; initialValue: string }
+ | { type: 'COMMIT_EDIT' }
+ | { type: 'MOVE_LEFT' }
+ | { type: 'MOVE_RIGHT' }
+ | { type: 'HOME' }
+ | { type: 'END' }
+ | { type: 'DELETE_LEFT' }
+ | { type: 'DELETE_RIGHT' }
+ | { type: 'INSERT_CHAR'; char: string; isNumberType: boolean };
+
+const initialState: EditBufferState = {
+ editingKey: null,
+ buffer: '',
+ cursorPos: 0,
+};
+
+function editBufferReducer(
+ state: EditBufferState,
+ action: EditBufferAction,
+): EditBufferState {
+ switch (action.type) {
+ case 'START_EDIT':
+ return {
+ editingKey: action.key,
+ buffer: action.initialValue,
+ cursorPos: cpLen(action.initialValue),
+ };
+
+ case 'COMMIT_EDIT':
+ return initialState;
+
+ case 'MOVE_LEFT':
+ return {
+ ...state,
+ cursorPos: Math.max(0, state.cursorPos - 1),
+ };
+
+ case 'MOVE_RIGHT':
+ return {
+ ...state,
+ cursorPos: Math.min(cpLen(state.buffer), state.cursorPos + 1),
+ };
+
+ case 'HOME':
+ return { ...state, cursorPos: 0 };
+
+ case 'END':
+ return { ...state, cursorPos: cpLen(state.buffer) };
+
+ case 'DELETE_LEFT': {
+ if (state.cursorPos === 0) return state;
+ const before = cpSlice(state.buffer, 0, state.cursorPos - 1);
+ const after = cpSlice(state.buffer, state.cursorPos);
+ return {
+ ...state,
+ buffer: before + after,
+ cursorPos: state.cursorPos - 1,
+ };
+ }
+
+ case 'DELETE_RIGHT': {
+ if (state.cursorPos === cpLen(state.buffer)) return state;
+ const before = cpSlice(state.buffer, 0, state.cursorPos);
+ const after = cpSlice(state.buffer, state.cursorPos + 1);
+ return {
+ ...state,
+ buffer: before + after,
+ };
+ }
+
+ case 'INSERT_CHAR': {
+ let ch = action.char;
+ let isValidChar = false;
+
+ if (action.isNumberType) {
+ isValidChar = /[0-9\-+.]/.test(ch);
+ } else {
+ isValidChar = ch.length === 1 && ch.charCodeAt(0) >= 32;
+ ch = stripUnsafeCharacters(ch);
+ }
+
+ if (!isValidChar || ch.length === 0) return state;
+
+ const before = cpSlice(state.buffer, 0, state.cursorPos);
+ const after = cpSlice(state.buffer, state.cursorPos);
+ return {
+ ...state,
+ buffer: before + ch + after,
+ cursorPos: state.cursorPos + 1,
+ };
+ }
+
+ default:
+ return state;
+ }
+}
+
+export interface UseEditBufferProps {
+ onCommit: (key: string, value: string) => void;
+}
+
+export function useInlineEditBuffer({ onCommit }: UseEditBufferProps) {
+ const [state, dispatch] = useReducer(editBufferReducer, initialState);
+ const [cursorVisible, setCursorVisible] = useState(true);
+
+ useEffect(() => {
+ if (!state.editingKey) {
+ setCursorVisible(true);
+ return;
+ }
+ setCursorVisible(true);
+ const interval = setInterval(() => {
+ setCursorVisible((v) => !v);
+ }, 500);
+ return () => clearInterval(interval);
+ }, [state.editingKey, state.buffer, state.cursorPos]);
+
+ const startEditing = useCallback((key: string, initialValue: string) => {
+ dispatch({ type: 'START_EDIT', key, initialValue });
+ }, []);
+
+ const commitEdit = useCallback(() => {
+ if (state.editingKey) {
+ onCommit(state.editingKey, state.buffer);
+ }
+ dispatch({ type: 'COMMIT_EDIT' });
+ }, [state.editingKey, state.buffer, onCommit]);
+
+ return {
+ editState: state,
+ editDispatch: dispatch,
+ startEditing,
+ commitEdit,
+ cursorVisible,
+ };
+}
diff --git a/packages/cli/src/ui/hooks/useSettingsNavigation.test.ts b/packages/cli/src/ui/hooks/useSettingsNavigation.test.ts
new file mode 100644
index 0000000000..5a64119f40
--- /dev/null
+++ b/packages/cli/src/ui/hooks/useSettingsNavigation.test.ts
@@ -0,0 +1,121 @@
+/**
+ * @license
+ * Copyright 2026 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import { renderHook } from '../../test-utils/render.js';
+import { act } from 'react';
+import { describe, it, expect } from 'vitest';
+import { useSettingsNavigation } from './useSettingsNavigation.js';
+
+describe('useSettingsNavigation', () => {
+ const mockItems = [
+ { key: 'a' },
+ { key: 'b' },
+ { key: 'c' },
+ { key: 'd' },
+ { key: 'e' },
+ ];
+
+ it('should initialize with the first item active', () => {
+ const { result } = renderHook(() =>
+ useSettingsNavigation({ items: mockItems, maxItemsToShow: 3 }),
+ );
+ expect(result.current.activeIndex).toBe(0);
+ expect(result.current.activeItemKey).toBe('a');
+ expect(result.current.windowStart).toBe(0);
+ });
+
+ it('should move down correctly', () => {
+ const { result } = renderHook(() =>
+ useSettingsNavigation({ items: mockItems, maxItemsToShow: 3 }),
+ );
+ act(() => result.current.moveDown());
+ expect(result.current.activeIndex).toBe(1);
+ expect(result.current.activeItemKey).toBe('b');
+ });
+
+ it('should move up correctly', () => {
+ const { result } = renderHook(() =>
+ useSettingsNavigation({ items: mockItems, maxItemsToShow: 3 }),
+ );
+ act(() => result.current.moveDown()); // to index 1
+ act(() => result.current.moveUp()); // back to 0
+ expect(result.current.activeIndex).toBe(0);
+ });
+
+ it('should wrap around from top to bottom', () => {
+ const { result } = renderHook(() =>
+ useSettingsNavigation({ items: mockItems, maxItemsToShow: 3 }),
+ );
+ act(() => result.current.moveUp());
+ expect(result.current.activeIndex).toBe(4);
+ expect(result.current.activeItemKey).toBe('e');
+ });
+
+ it('should wrap around from bottom to top', () => {
+ const { result } = renderHook(() =>
+ useSettingsNavigation({ items: mockItems, maxItemsToShow: 3 }),
+ );
+ // Move to last item
+ // Move to last item (index 4)
+ act(() => result.current.moveDown()); // 1
+ act(() => result.current.moveDown()); // 2
+ act(() => result.current.moveDown()); // 3
+ act(() => result.current.moveDown()); // 4
+ expect(result.current.activeIndex).toBe(4);
+
+ // Move down once more
+ act(() => result.current.moveDown());
+ expect(result.current.activeIndex).toBe(0);
+ });
+
+ it('should adjust scrollOffset when moving down past visible area', () => {
+ const { result } = renderHook(() =>
+ useSettingsNavigation({ items: mockItems, maxItemsToShow: 3 }),
+ );
+
+ act(() => result.current.moveDown()); // index 1
+ act(() => result.current.moveDown()); // index 2, still offset 0
+ expect(result.current.windowStart).toBe(0);
+
+ act(() => result.current.moveDown()); // index 3, offset should be 1
+ expect(result.current.windowStart).toBe(1);
+ });
+
+ it('should adjust scrollOffset when moving up past visible area', () => {
+ const { result } = renderHook(() =>
+ useSettingsNavigation({ items: mockItems, maxItemsToShow: 3 }),
+ );
+
+ act(() => result.current.moveDown()); // 1
+ act(() => result.current.moveDown()); // 2
+ act(() => result.current.moveDown()); // 3
+ expect(result.current.windowStart).toBe(1);
+
+ act(() => result.current.moveUp()); // index 2
+ act(() => result.current.moveUp()); // index 1, offset should become 1
+ act(() => result.current.moveUp()); // index 0, offset should become 0
+ expect(result.current.windowStart).toBe(0);
+ });
+
+ it('should handle item preservation when list filters (Part 1 logic)', () => {
+ let items = mockItems;
+ const { result, rerender } = renderHook(
+ ({ list }) => useSettingsNavigation({ items: list, maxItemsToShow: 3 }),
+ { initialProps: { list: items } },
+ );
+
+ act(() => result.current.moveDown());
+ act(() => result.current.moveDown()); // Item 'c'
+ expect(result.current.activeItemKey).toBe('c');
+
+ // Filter items but keep 'c'
+ items = [mockItems[0], mockItems[2], mockItems[4]]; // 'a', 'c', 'e'
+ rerender({ list: items });
+
+ expect(result.current.activeItemKey).toBe('c');
+ expect(result.current.activeIndex).toBe(1); // 'c' is now at index 1
+ });
+});
diff --git a/packages/cli/src/ui/hooks/useSettingsNavigation.ts b/packages/cli/src/ui/hooks/useSettingsNavigation.ts
new file mode 100644
index 0000000000..1f47b2eb74
--- /dev/null
+++ b/packages/cli/src/ui/hooks/useSettingsNavigation.ts
@@ -0,0 +1,124 @@
+/**
+ * @license
+ * Copyright 2026 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import { useMemo, useReducer, useCallback } from 'react';
+
+export interface UseSettingsNavigationProps {
+ items: Array<{ key: string }>;
+ maxItemsToShow: number;
+}
+
+type NavState = {
+ activeItemKey: string | null;
+ windowStart: number;
+};
+
+type NavAction = { type: 'MOVE_UP' } | { type: 'MOVE_DOWN' };
+
+function calculateSlidingWindow(
+ start: number,
+ activeIndex: number,
+ itemCount: number,
+ windowSize: number,
+): number {
+ // User moves up above the window start
+ if (activeIndex < start) {
+ start = activeIndex;
+ // User moves down below the window end
+ } else if (activeIndex >= start + windowSize) {
+ start = activeIndex - windowSize + 1;
+ }
+ // User is inside the window but performed search or terminal resized
+ const maxScroll = Math.max(0, itemCount - windowSize);
+ const bounded = Math.min(start, maxScroll);
+ return Math.max(0, bounded);
+}
+
+function createNavReducer(
+ items: Array<{ key: string }>,
+ maxItemsToShow: number,
+) {
+ return function navReducer(state: NavState, action: NavAction): NavState {
+ if (items.length === 0) return state;
+
+ const currentIndex = items.findIndex((i) => i.key === state.activeItemKey);
+ const activeIndex = currentIndex !== -1 ? currentIndex : 0;
+
+ switch (action.type) {
+ case 'MOVE_UP': {
+ const newIndex = activeIndex > 0 ? activeIndex - 1 : items.length - 1;
+ return {
+ activeItemKey: items[newIndex].key,
+ windowStart: calculateSlidingWindow(
+ state.windowStart,
+ newIndex,
+ items.length,
+ maxItemsToShow,
+ ),
+ };
+ }
+ case 'MOVE_DOWN': {
+ const newIndex = activeIndex < items.length - 1 ? activeIndex + 1 : 0;
+ return {
+ activeItemKey: items[newIndex].key,
+ windowStart: calculateSlidingWindow(
+ state.windowStart,
+ newIndex,
+ items.length,
+ maxItemsToShow,
+ ),
+ };
+ }
+ default: {
+ return state;
+ }
+ }
+ };
+}
+
+export function useSettingsNavigation({
+ items,
+ maxItemsToShow,
+}: UseSettingsNavigationProps) {
+ const reducer = useMemo(
+ () => createNavReducer(items, maxItemsToShow),
+ [items, maxItemsToShow],
+ );
+
+ const [state, dispatch] = useReducer(reducer, {
+ activeItemKey: items[0]?.key ?? null,
+ windowStart: 0,
+ });
+
+ // Retain the proper highlighting when items change (e.g. search)
+ const activeIndex = useMemo(() => {
+ if (items.length === 0) return 0;
+ const idx = items.findIndex((i) => i.key === state.activeItemKey);
+ return idx !== -1 ? idx : 0;
+ }, [items, state.activeItemKey]);
+
+ const windowStart = useMemo(
+ () =>
+ calculateSlidingWindow(
+ state.windowStart,
+ activeIndex,
+ items.length,
+ maxItemsToShow,
+ ),
+ [state.windowStart, activeIndex, items.length, maxItemsToShow],
+ );
+
+ const moveUp = useCallback(() => dispatch({ type: 'MOVE_UP' }), []);
+ const moveDown = useCallback(() => dispatch({ type: 'MOVE_DOWN' }), []);
+
+ return {
+ activeItemKey: state.activeItemKey,
+ activeIndex,
+ windowStart,
+ moveUp,
+ moveDown,
+ };
+}