Merge branch 'main' into fix/select-state-refinement

This commit is contained in:
Mark McLaughlin
2026-03-09 14:49:04 -07:00
committed by GitHub
310 changed files with 8706 additions and 2518 deletions
@@ -35,6 +35,7 @@ export interface BaseSelectionListProps<
priority?: boolean;
/** Horizontal padding for items when not selected. Defaults to 2. */
horizontalPadding?: number;
selectedIndicator?: string;
renderItem: (item: TItem, context: RenderItemContext) => React.ReactNode;
}
@@ -68,6 +69,7 @@ export function BaseSelectionList<
focusKey,
priority,
horizontalPadding = 1,
selectedIndicator = '●',
renderItem,
}: BaseSelectionListProps<T, TItem>): React.JSX.Element {
const { activeIndex } = useSelectionList({
@@ -155,7 +157,7 @@ export function BaseSelectionList<
color={isSelected ? theme.ui.focus : theme.text.primary}
aria-hidden
>
{isSelected ? '●' : ' '}
{isSelected ? selectedIndicator : ' '}
</Text>
</Box>
@@ -177,7 +177,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');
@@ -805,4 +808,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();
});
});
});
@@ -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,15 +17,13 @@ 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 { Command } from '../../keyMatchers.js';
import { useSettingsNavigation } from '../../hooks/useSettingsNavigation.js';
import { useInlineEditBuffer } from '../../hooks/useInlineEditBuffer.js';
import { formatCommand } from '../../utils/keybindingUtils.js';
import { useKeyMatchers } from '../../hooks/useKeyMatchers.js';
/**
* Represents a single item in the settings dialog.
@@ -60,7 +58,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 +103,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 +134,114 @@ export function BaseSettingsDialog({
onItemClear,
onClose,
onKeyPress,
footerContent,
availableHeight,
footer,
}: BaseSettingsDialogProps): React.JSX.Element {
const keyMatchers = useKeyMatchers();
// 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 +250,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 +273,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 +285,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 +328,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 +389,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 +404,7 @@ export function BaseSettingsDialog({
},
{
isActive: true,
priority: focusSection === 'settings' && !editingKey,
priority: effectiveFocusSection === 'settings',
},
);
@@ -444,10 +421,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 +436,7 @@ export function BaseSettingsDialog({
borderColor={
editingKey
? theme.border.default
: focusSection === 'settings'
: effectiveFocusSection === 'settings'
? theme.ui.focus
: theme.border.default
}
@@ -468,7 +445,7 @@ export function BaseSettingsDialog({
marginTop={1}
>
<TextInput
focus={focusSection === 'settings' && !editingKey}
focus={effectiveFocusSection === 'settings' && !editingKey}
buffer={searchBuffer}
placeholder={searchPlaceholder}
/>
@@ -490,9 +467,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;
@@ -608,21 +586,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>
)}
@@ -633,12 +611,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>
);
@@ -20,7 +20,7 @@ import { formatCommand } from '../../utils/keybindingUtils.js';
*/
export const MINIMUM_MAX_HEIGHT = 2;
interface MaxSizedBoxProps {
export interface MaxSizedBoxProps {
children?: React.ReactNode;
maxWidth?: number;
maxHeight?: number;
@@ -5,13 +5,23 @@
*/
import type React from 'react';
import { useState, useRef, useCallback, useMemo, useLayoutEffect } from 'react';
import {
useState,
useRef,
useCallback,
useMemo,
useLayoutEffect,
useEffect,
useId,
} from 'react';
import { Box, ResizeObserver, type DOMElement } from 'ink';
import { useKeypress, type Key } from '../../hooks/useKeypress.js';
import { useScrollable } from '../../contexts/ScrollProvider.js';
import { useAnimatedScrollbar } from '../../hooks/useAnimatedScrollbar.js';
import { useBatchedScroll } from '../../hooks/useBatchedScroll.js';
import { keyMatchers, Command } from '../../keyMatchers.js';
import { Command } from '../../keyMatchers.js';
import { useOverflowActions } from '../../contexts/OverflowContext.js';
import { useKeyMatchers } from '../../hooks/useKeyMatchers.js';
interface ScrollableProps {
children?: React.ReactNode;
@@ -22,6 +32,7 @@ interface ScrollableProps {
hasFocus: boolean;
scrollToBottom?: boolean;
flexGrow?: number;
reportOverflow?: boolean;
}
export const Scrollable: React.FC<ScrollableProps> = ({
@@ -33,10 +44,14 @@ export const Scrollable: React.FC<ScrollableProps> = ({
hasFocus,
scrollToBottom,
flexGrow,
reportOverflow = false,
}) => {
const keyMatchers = useKeyMatchers();
const [scrollTop, setScrollTop] = useState(0);
const viewportRef = useRef<DOMElement | null>(null);
const contentRef = useRef<DOMElement | null>(null);
const overflowActions = useOverflowActions();
const id = useId();
const [size, setSize] = useState({
innerHeight: typeof height === 'number' ? height : 0,
scrollHeight: 0,
@@ -52,6 +67,27 @@ export const Scrollable: React.FC<ScrollableProps> = ({
scrollTopRef.current = scrollTop;
}, [scrollTop]);
useEffect(() => {
if (reportOverflow && size.scrollHeight > size.innerHeight) {
overflowActions?.addOverflowingId?.(id);
} else {
overflowActions?.removeOverflowingId?.(id);
}
}, [
reportOverflow,
size.scrollHeight,
size.innerHeight,
id,
overflowActions,
]);
useEffect(
() => () => {
overflowActions?.removeOverflowingId?.(id);
},
[id, overflowActions],
);
const viewportObserverRef = useRef<ResizeObserver | null>(null);
const contentObserverRef = useRef<ResizeObserver | null>(null);
@@ -22,7 +22,8 @@ import { useScrollable } from '../../contexts/ScrollProvider.js';
import { Box, type DOMElement } from 'ink';
import { useAnimatedScrollbar } from '../../hooks/useAnimatedScrollbar.js';
import { useKeypress, type Key } from '../../hooks/useKeypress.js';
import { keyMatchers, Command } from '../../keyMatchers.js';
import { Command } from '../../keyMatchers.js';
import { useKeyMatchers } from '../../hooks/useKeyMatchers.js';
const ANIMATION_FRAME_DURATION_MS = 33;
@@ -46,6 +47,7 @@ function ScrollableList<T>(
props: ScrollableListProps<T>,
ref: React.Ref<ScrollableListRef<T>>,
) {
const keyMatchers = useKeyMatchers();
const { hasFocus, width } = props;
const virtualizedListRef = useRef<VirtualizedListRef<T>>(null);
const containerRef = useRef<DOMElement>(null);
@@ -11,7 +11,8 @@ import { useSelectionList } from '../../hooks/useSelectionList.js';
import { TextInput } from './TextInput.js';
import type { TextBuffer } from './text-buffer.js';
import { useKeypress } from '../../hooks/useKeypress.js';
import { keyMatchers, Command } from '../../keyMatchers.js';
import { Command } from '../../keyMatchers.js';
import { useKeyMatchers } from '../../hooks/useKeyMatchers.js';
/**
* Generic interface for items in a searchable list.
@@ -85,6 +86,7 @@ export function SearchableList<T extends GenericListItem>({
onSearch,
resetSelectionOnItemsChange = false,
}: SearchableListProps<T>): React.JSX.Element {
const keyMatchers = useKeyMatchers();
const { filteredItems, searchBuffer, maxLabelWidth } = useSearch({
items,
onSearch,
@@ -0,0 +1,123 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { render } from '../../../test-utils/render.js';
import { OverflowProvider } from '../../contexts/OverflowContext.js';
import { SlicingMaxSizedBox } from './SlicingMaxSizedBox.js';
import { Box, Text } from 'ink';
import { describe, it, expect } from 'vitest';
describe('<SlicingMaxSizedBox />', () => {
it('renders string data without slicing when it fits', async () => {
const { lastFrame, waitUntilReady, unmount } = render(
<OverflowProvider>
<SlicingMaxSizedBox data="Hello World" maxWidth={80}>
{(truncatedData) => <Text>{truncatedData}</Text>}
</SlicingMaxSizedBox>
</OverflowProvider>,
);
await waitUntilReady();
expect(lastFrame()).toContain('Hello World');
unmount();
});
it('slices string data by characters when very long', async () => {
const veryLongString = 'A'.repeat(25000);
const { lastFrame, waitUntilReady, unmount } = render(
<OverflowProvider>
<SlicingMaxSizedBox
data={veryLongString}
maxWidth={80}
overflowDirection="bottom"
>
{(truncatedData) => <Text>{truncatedData.length}</Text>}
</SlicingMaxSizedBox>
</OverflowProvider>,
);
await waitUntilReady();
// 20000 characters + 3 for '...'
expect(lastFrame()).toContain('20003');
unmount();
});
it('slices string data by lines when maxLines is provided', async () => {
const multilineString = 'Line 1\nLine 2\nLine 3\nLine 4\nLine 5';
const { lastFrame, waitUntilReady, unmount } = render(
<OverflowProvider>
<SlicingMaxSizedBox
data={multilineString}
maxLines={3}
maxWidth={80}
maxHeight={10}
overflowDirection="bottom"
>
{(truncatedData) => <Text>{truncatedData}</Text>}
</SlicingMaxSizedBox>
</OverflowProvider>,
);
await waitUntilReady();
// maxLines=3, so it should keep 3-1 = 2 lines
expect(lastFrame()).toContain('Line 1');
expect(lastFrame()).toContain('Line 2');
expect(lastFrame()).not.toContain('Line 3');
expect(lastFrame()).toContain(
'... last 3 lines hidden (Ctrl+O to show) ...',
);
unmount();
});
it('slices array data when maxLines is provided', async () => {
const dataArray = ['Item 1', 'Item 2', 'Item 3', 'Item 4', 'Item 5'];
const { lastFrame, waitUntilReady, unmount } = render(
<OverflowProvider>
<SlicingMaxSizedBox
data={dataArray}
maxLines={3}
maxWidth={80}
maxHeight={10}
overflowDirection="bottom"
>
{(truncatedData) => (
<Box flexDirection="column">
{truncatedData.map((item, i) => (
<Text key={i}>{item}</Text>
))}
</Box>
)}
</SlicingMaxSizedBox>
</OverflowProvider>,
);
await waitUntilReady();
// maxLines=3, so it should keep 3-1 = 2 items
expect(lastFrame()).toContain('Item 1');
expect(lastFrame()).toContain('Item 2');
expect(lastFrame()).not.toContain('Item 3');
expect(lastFrame()).toContain(
'... last 3 lines hidden (Ctrl+O to show) ...',
);
unmount();
});
it('does not slice when isAlternateBuffer is true', async () => {
const multilineString = 'Line 1\nLine 2\nLine 3\nLine 4\nLine 5';
const { lastFrame, waitUntilReady, unmount } = render(
<OverflowProvider>
<SlicingMaxSizedBox
data={multilineString}
maxLines={3}
maxWidth={80}
isAlternateBuffer={true}
>
{(truncatedData) => <Text>{truncatedData}</Text>}
</SlicingMaxSizedBox>
</OverflowProvider>,
);
await waitUntilReady();
expect(lastFrame()).toContain('Line 5');
expect(lastFrame()).not.toContain('hidden');
unmount();
});
});
@@ -0,0 +1,103 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { useMemo } from 'react';
import { MaxSizedBox, type MaxSizedBoxProps } from './MaxSizedBox.js';
// Large threshold to ensure we don't cause performance issues for very large
// outputs that will get truncated further MaxSizedBox anyway.
const MAXIMUM_RESULT_DISPLAY_CHARACTERS = 20000;
export interface SlicingMaxSizedBoxProps<T>
extends Omit<MaxSizedBoxProps, 'children'> {
data: T;
maxLines?: number;
isAlternateBuffer?: boolean;
children: (truncatedData: T) => React.ReactNode;
}
/**
* An extension of MaxSizedBox that performs explicit slicing of the input data
* (string or array) before rendering. This is useful for performance and to
* ensure consistent truncation behavior for large outputs.
*/
export function SlicingMaxSizedBox<T>({
data,
maxLines,
isAlternateBuffer,
children,
...boxProps
}: SlicingMaxSizedBoxProps<T>) {
const { truncatedData, hiddenLinesCount } = useMemo(() => {
let hiddenLines = 0;
const overflowDirection = boxProps.overflowDirection ?? 'top';
// Only truncate string output if not in alternate buffer mode to ensure
// we can scroll through the full output.
if (typeof data === 'string' && !isAlternateBuffer) {
let text: string = data as string;
if (text.length > MAXIMUM_RESULT_DISPLAY_CHARACTERS) {
if (overflowDirection === 'bottom') {
text = text.slice(0, MAXIMUM_RESULT_DISPLAY_CHARACTERS) + '...';
} else {
text = '...' + text.slice(-MAXIMUM_RESULT_DISPLAY_CHARACTERS);
}
}
if (maxLines) {
const hasTrailingNewline = text.endsWith('\n');
const contentText = hasTrailingNewline ? text.slice(0, -1) : text;
const lines = contentText.split('\n');
if (lines.length > maxLines) {
// We will have a label from MaxSizedBox. Reserve space for it.
const targetLines = Math.max(1, maxLines - 1);
hiddenLines = lines.length - targetLines;
if (overflowDirection === 'bottom') {
text =
lines.slice(0, targetLines).join('\n') +
(hasTrailingNewline ? '\n' : '');
} else {
text =
lines.slice(-targetLines).join('\n') +
(hasTrailingNewline ? '\n' : '');
}
}
}
return {
truncatedData: text,
hiddenLinesCount: hiddenLines,
};
}
if (Array.isArray(data) && !isAlternateBuffer && maxLines) {
if (data.length > maxLines) {
// We will have a label from MaxSizedBox. Reserve space for it.
const targetLines = Math.max(1, maxLines - 1);
const hiddenCount = data.length - targetLines;
return {
truncatedData:
overflowDirection === 'bottom'
? data.slice(0, targetLines)
: data.slice(-targetLines),
hiddenLinesCount: hiddenCount,
};
}
}
return { truncatedData: data, hiddenLinesCount: 0 };
}, [data, isAlternateBuffer, maxLines, boxProps.overflowDirection]);
return (
<MaxSizedBox
{...boxProps}
additionalHiddenLinesCount={
(boxProps.additionalHiddenLinesCount ?? 0) + hiddenLinesCount
}
>
{/* eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion */}
{children(truncatedData as unknown as T)}
</MaxSizedBox>
);
}
@@ -17,7 +17,8 @@ vi.mock('../../hooks/useKeypress.js', () => ({
useKeypress: vi.fn(),
}));
vi.mock('./text-buffer.js', () => {
vi.mock('./text-buffer.js', async (importOriginal) => {
const actual = await importOriginal<typeof import('./text-buffer.js')>();
const mockTextBuffer = {
text: '',
lines: [''],
@@ -60,6 +61,7 @@ vi.mock('./text-buffer.js', () => {
};
return {
...actual,
useTextBuffer: vi.fn(() => mockTextBuffer as unknown as TextBuffer),
TextBuffer: vi.fn(() => mockTextBuffer as unknown as TextBuffer),
};
@@ -82,6 +84,7 @@ describe('TextInput', () => {
cursor: [0, 0],
visualCursor: [0, 0],
viewportVisualLines: [''],
pastedContent: {} as Record<string, string>,
handleInput: vi.fn((key) => {
if (key.sequence) {
buffer.text += key.sequence;
@@ -298,6 +301,58 @@ describe('TextInput', () => {
unmount();
});
it('expands paste placeholder to real content on submit', async () => {
const placeholder = '[Pasted Text: 6 lines]';
const realContent = 'line1\nline2\nline3\nline4\nline5\nline6';
mockBuffer.setText(placeholder);
mockBuffer.pastedContent = { [placeholder]: realContent };
const { waitUntilReady, unmount } = render(
<TextInput buffer={mockBuffer} onSubmit={onSubmit} onCancel={onCancel} />,
);
await waitUntilReady();
const keypressHandler = mockedUseKeypress.mock.calls[0][0];
await act(async () => {
keypressHandler({
name: 'return',
shift: false,
alt: false,
ctrl: false,
cmd: false,
sequence: '',
});
});
await waitUntilReady();
expect(onSubmit).toHaveBeenCalledWith(realContent);
unmount();
});
it('submits text unchanged when pastedContent is empty', async () => {
mockBuffer.setText('normal text');
mockBuffer.pastedContent = {};
const { waitUntilReady, unmount } = render(
<TextInput buffer={mockBuffer} onSubmit={onSubmit} onCancel={onCancel} />,
);
await waitUntilReady();
const keypressHandler = mockedUseKeypress.mock.calls[0][0];
await act(async () => {
keypressHandler({
name: 'return',
shift: false,
alt: false,
ctrl: false,
cmd: false,
sequence: '',
});
});
await waitUntilReady();
expect(onSubmit).toHaveBeenCalledWith('normal text');
unmount();
});
it('calls onCancel on escape', async () => {
vi.useFakeTimers();
const { waitUntilReady, unmount } = render(
@@ -12,8 +12,10 @@ import { useKeypress } from '../../hooks/useKeypress.js';
import chalk from 'chalk';
import { theme } from '../../semantic-colors.js';
import type { TextBuffer } from './text-buffer.js';
import { expandPastePlaceholders } from './text-buffer.js';
import { cpSlice, cpIndexToOffset } from '../../utils/textUtils.js';
import { keyMatchers, Command } from '../../keyMatchers.js';
import { Command } from '../../keyMatchers.js';
import { useKeyMatchers } from '../../hooks/useKeyMatchers.js';
export interface TextInputProps {
buffer: TextBuffer;
@@ -30,6 +32,7 @@ export function TextInput({
onCancel,
focus = true,
}: TextInputProps): React.JSX.Element {
const keyMatchers = useKeyMatchers();
const {
text,
handleInput,
@@ -47,14 +50,14 @@ export function TextInput({
}
if (keyMatchers[Command.SUBMIT](key) && onSubmit) {
onSubmit(text);
onSubmit(expandPastePlaceholders(text, buffer.pastedContent));
return true;
}
const handled = handleInput(key);
return handled;
},
[handleInput, onCancel, onSubmit, text],
[handleInput, onCancel, onSubmit, text, buffer.pastedContent, keyMatchers],
);
useKeypress(handleKeyPress, { isActive: focus, priority: true });
@@ -25,11 +25,12 @@ import {
} from '../../utils/textUtils.js';
import { parsePastedPaths } from '../../utils/clipboardUtils.js';
import type { Key } from '../../contexts/KeypressContext.js';
import { keyMatchers, Command } from '../../keyMatchers.js';
import { Command } from '../../keyMatchers.js';
import type { VimAction } from './vim-buffer-actions.js';
import { handleVimAction } from './vim-buffer-actions.js';
import { LRU_BUFFER_PERF_CACHE_LIMIT } from '../../constants.js';
import { openFileInEditor } from '../../utils/editorUtils.js';
import { useKeyMatchers } from '../../hooks/useKeyMatchers.js';
export const LARGE_PASTE_LINE_THRESHOLD = 5;
export const LARGE_PASTE_CHAR_THRESHOLD = 500;
@@ -38,6 +39,17 @@ export const LARGE_PASTE_CHAR_THRESHOLD = 500;
export const PASTED_TEXT_PLACEHOLDER_REGEX =
/\[Pasted Text: \d+ (?:lines|chars)(?: #\d+)?\]/g;
// Replace paste placeholder strings with their actual pasted content.
export function expandPastePlaceholders(
text: string,
pastedContent: Record<string, string>,
): string {
return text.replace(
PASTED_TEXT_PLACEHOLDER_REGEX,
(match) => pastedContent[match] || match,
);
}
export type Direction =
| 'left'
| 'right'
@@ -2697,6 +2709,7 @@ export function useTextBuffer({
singleLine = false,
getPreferredEditor,
}: UseTextBufferProps): TextBuffer {
const keyMatchers = useKeyMatchers();
const initialState = useMemo((): TextBufferState => {
const lines = initialText.split('\n');
const [initialCursorRow, initialCursorCol] = calculateInitialCursorPosition(
@@ -3086,10 +3099,7 @@ export function useTextBuffer({
const tmpDir = fs.mkdtempSync(pathMod.join(os.tmpdir(), 'gemini-edit-'));
const filePath = pathMod.join(tmpDir, 'buffer.txt');
// Expand paste placeholders so user sees full content in editor
const expandedText = text.replace(
PASTED_TEXT_PLACEHOLDER_REGEX,
(match) => pastedContent[match] || match,
);
const expandedText = expandPastePlaceholders(text, pastedContent);
fs.writeFileSync(filePath, expandedText, 'utf8');
dispatch({ type: 'create_undo_snapshot' });
@@ -3262,6 +3272,7 @@ export function useTextBuffer({
text,
visualCursor,
visualLines,
keyMatchers,
],
);