refactor(cli): better react patterns for BaseSettingsDialog (#21206)

This commit is contained in:
Pyush Sinha
2026-03-09 11:35:08 -07:00
committed by GitHub
parent 4c9f9bb3e2
commit b68d7bc0f9
10 changed files with 834 additions and 282 deletions

View File

@@ -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(
<KeypressProvider>
<AgentConfigDialog
agentName="test-agent"
displayName="Test Agent"
definition={createMockAgentDefinition()}
settings={settings}
onClose={mockOnClose}
onSave={mockOnSave}
availableTerminalHeight={20}
/>
</KeypressProvider>,
);
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();
});
});
});

View File

@@ -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<LoadableSettingScope>(
@@ -395,12 +398,6 @@ export function AgentConfigDialog({
[pendingOverride, saveFieldValue],
);
// Footer content
const footerContent =
modifiedFields.size > 0 ? (
<Text color={theme.text.secondary}>Changes saved automatically.</Text>
) : null;
return (
<BaseSettingsDialog
title={`Configure: ${displayName}`}
@@ -410,12 +407,24 @@ export function AgentConfigDialog({
selectedScope={selectedScope}
onScopeChange={handleScopeChange}
maxItemsToShow={maxItemsToShow}
availableHeight={availableTerminalHeight}
maxLabelWidth={maxLabelWidth}
onItemToggle={handleItemToggle}
onEditCommit={handleEditCommit}
onItemClear={handleItemClear}
onClose={onClose}
footerContent={footerContent}
footer={
modifiedFields.size > 0
? {
content: (
<Text color={theme.text.secondary}>
Changes saved automatically.
</Text>
),
height: 1,
}
: undefined
}
/>
);
}

View File

@@ -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

View File

@@ -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 ? (
<Text color={theme.status.warning}>
Changes that require a restart have been modified. Press r to exit and
apply changes now.
</Text>
) : null;
// Decisions on what features to enable
const hasWorkspace = settings.workspace.path !== undefined;
const showSearch = !showRestartPrompt;
return (
<BaseSettingsDialog
@@ -442,17 +357,30 @@ export function SettingsDialog({
searchEnabled={showSearch}
searchBuffer={searchBuffer}
items={items}
showScopeSelector={showScopeSelection}
showScopeSelector={hasWorkspace}
selectedScope={selectedScope}
onScopeChange={handleScopeChange}
maxItemsToShow={effectiveMaxItemsToShow}
maxItemsToShow={MAX_ITEMS_TO_SHOW}
availableHeight={availableTerminalHeight}
maxLabelWidth={maxLabelOrDescriptionWidth}
onItemToggle={handleItemToggle}
onEditCommit={handleEditCommit}
onItemClear={handleItemClear}
onClose={handleClose}
onKeyPress={handleKeyPress}
footerContent={footerContent}
footer={
showRestartPrompt
? {
content: (
<Text color={theme.status.warning}>
Changes that require a restart have been modified. Press r to
exit and apply changes now.
</Text>
),
height: 1,
}
: undefined
}
/>
);
}

View File

@@ -174,7 +174,10 @@ describe('BaseSettingsDialog', () => {
it('should render footer content when provided', async () => {
const { lastFrame, unmount } = await renderDialog({
footerContent: <Text>Custom Footer</Text>,
footer: {
content: <Text>Custom Footer</Text>,
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();
});
});
});

View File

@@ -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<string | null>(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 */}
<Box marginX={1}>
<Text
bold={focusSection === 'settings' && !editingKey}
bold={effectiveFocusSection === 'settings' && !editingKey}
wrap="truncate"
>
{focusSection === 'settings' ? '> ' : ' '}
{effectiveFocusSection === 'settings' ? '> ' : ' '}
{title}{' '}
</Text>
</Box>
@@ -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}
>
<TextInput
focus={focusSection === 'settings' && !editingKey}
focus={effectiveFocusSection === 'settings' && !editingKey}
buffer={searchBuffer}
placeholder={searchPlaceholder}
/>
@@ -490,9 +465,10 @@ export function BaseSettingsDialog({
</Box>
)}
{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({
<Box height={1} />
{/* Scope Selection */}
{showScopeSelector && (
{finalShowScopeSelector && (
<Box marginX={1} flexDirection="column">
<Text bold={focusSection === 'scope'} wrap="truncate">
{focusSection === 'scope' ? '> ' : ' '}Apply To
<Text bold={effectiveFocusSection === 'scope'} wrap="truncate">
{effectiveFocusSection === 'scope' ? '> ' : ' '}Apply To
</Text>
<RadioButtonSelect
items={scopeItems}
initialIndex={scopeItems.findIndex(
(item) => 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'}
/>
</Box>
)}
@@ -627,12 +603,13 @@ export function BaseSettingsDialog({
<Box marginX={1}>
<Text color={theme.text.secondary}>
(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)
</Text>
</Box>
{/* Footer content (e.g., restart prompt) */}
{footerContent && <Box marginX={1}>{footerContent}</Box>}
{footer && <Box marginX={1}>{footer.content}</Box>}
</Box>
</Box>
);

View File

@@ -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
});
});

View File

@@ -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,
};
}

View File

@@ -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
});
});

View File

@@ -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,
};
}