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