Sync upstream Gemini-CLI v0.8.2 (#838)

This commit is contained in:
tanzhenxin
2025-10-23 09:27:04 +08:00
committed by GitHub
parent 096fabb5d6
commit eb95c131be
644 changed files with 70389 additions and 23709 deletions

View File

@@ -0,0 +1,534 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { waitFor } from '@testing-library/react';
import { renderWithProviders } from '../../../test-utils/render.js';
import {
BaseSelectionList,
type BaseSelectionListProps,
type RenderItemContext,
} from './BaseSelectionList.js';
import { useSelectionList } from '../../hooks/useSelectionList.js';
import { Text } from 'ink';
import type { theme } from '../../semantic-colors.js';
vi.mock('../../hooks/useSelectionList.js');
const mockTheme = {
text: { primary: 'COLOR_PRIMARY', secondary: 'COLOR_SECONDARY' },
status: { success: 'COLOR_SUCCESS' },
} as typeof theme;
vi.mock('../../semantic-colors.js', () => ({
theme: {
text: { primary: 'COLOR_PRIMARY', secondary: 'COLOR_SECONDARY' },
status: { success: 'COLOR_SUCCESS' },
},
}));
describe('BaseSelectionList', () => {
const mockOnSelect = vi.fn();
const mockOnHighlight = vi.fn();
const mockRenderItem = vi.fn();
const items = [
{ value: 'A', label: 'Item A', key: 'A' },
{ value: 'B', label: 'Item B', disabled: true, key: 'B' },
{ value: 'C', label: 'Item C', key: 'C' },
];
// Helper to render the component with default props
const renderComponent = (
props: Partial<
BaseSelectionListProps<
string,
{ value: string; label: string; disabled?: boolean; key: string }
>
> = {},
activeIndex: number = 0,
) => {
vi.mocked(useSelectionList).mockReturnValue({
activeIndex,
setActiveIndex: vi.fn(),
});
mockRenderItem.mockImplementation(
(
item: { value: string; label: string; disabled?: boolean; key: string },
context: RenderItemContext,
) => <Text color={context.titleColor}>{item.label}</Text>,
);
const defaultProps: BaseSelectionListProps<
string,
{ value: string; label: string; disabled?: boolean; key: string }
> = {
items,
onSelect: mockOnSelect,
onHighlight: mockOnHighlight,
renderItem: mockRenderItem,
...props,
};
return renderWithProviders(<BaseSelectionList {...defaultProps} />);
};
beforeEach(() => {
vi.clearAllMocks();
});
describe('Rendering and Structure', () => {
it('should render all items using the renderItem prop', () => {
const { lastFrame } = renderComponent();
expect(lastFrame()).toContain('Item A');
expect(lastFrame()).toContain('Item B');
expect(lastFrame()).toContain('Item C');
expect(mockRenderItem).toHaveBeenCalledTimes(3);
expect(mockRenderItem).toHaveBeenCalledWith(items[0], expect.any(Object));
});
it('should render the selection indicator (● or space) and layout', () => {
const { lastFrame } = renderComponent({}, 0);
const output = lastFrame();
// Use regex to assert the structure: Indicator + Whitespace + Number + Label
expect(output).toMatch(/●\s+1\.\s+Item A/);
expect(output).toMatch(/\s+2\.\s+Item B/);
expect(output).toMatch(/\s+3\.\s+Item C/);
});
it('should handle an empty list gracefully', () => {
const { lastFrame } = renderComponent({ items: [] });
expect(mockRenderItem).not.toHaveBeenCalled();
expect(lastFrame()).toBe('');
});
});
describe('useSelectionList Integration', () => {
it('should pass props correctly to useSelectionList', () => {
const initialIndex = 1;
const isFocused = false;
const showNumbers = false;
renderComponent({ initialIndex, isFocused, showNumbers });
expect(useSelectionList).toHaveBeenCalledWith({
items,
initialIndex,
onSelect: mockOnSelect,
onHighlight: mockOnHighlight,
isFocused,
showNumbers,
});
});
it('should use the activeIndex returned by the hook', () => {
renderComponent({}, 2); // Active index is C
expect(mockRenderItem).toHaveBeenCalledWith(
items[0],
expect.objectContaining({ isSelected: false }),
);
expect(mockRenderItem).toHaveBeenCalledWith(
items[2],
expect.objectContaining({ isSelected: true }),
);
});
});
describe('Styling and Colors', () => {
it('should apply success color to the selected item', () => {
renderComponent({}, 0); // Item A selected
// Check renderItem context colors against the mocked theme
expect(mockRenderItem).toHaveBeenCalledWith(
items[0],
expect.objectContaining({
titleColor: mockTheme.status.success,
numberColor: mockTheme.status.success,
isSelected: true,
}),
);
});
it('should apply primary color to unselected, enabled items', () => {
renderComponent({}, 0); // Item A selected, Item C unselected/enabled
// Check renderItem context colors for Item C
expect(mockRenderItem).toHaveBeenCalledWith(
items[2],
expect.objectContaining({
titleColor: mockTheme.text.primary,
numberColor: mockTheme.text.primary,
isSelected: false,
}),
);
});
it('should apply secondary color to disabled items (when not selected)', () => {
renderComponent({}, 0); // Item A selected, Item B disabled
// Check renderItem context colors for Item B
expect(mockRenderItem).toHaveBeenCalledWith(
items[1],
expect.objectContaining({
titleColor: mockTheme.text.secondary,
numberColor: mockTheme.text.secondary,
isSelected: false,
}),
);
});
it('should apply success color to disabled items if they are selected', () => {
// The component should visually reflect the selection even if the item is disabled.
renderComponent({}, 1); // Item B (disabled) selected
// Check renderItem context colors for Item B
expect(mockRenderItem).toHaveBeenCalledWith(
items[1],
expect.objectContaining({
titleColor: mockTheme.status.success,
numberColor: mockTheme.status.success,
isSelected: true,
}),
);
});
});
describe('Numbering (showNumbers)', () => {
it('should show numbers by default with correct formatting', () => {
const { lastFrame } = renderComponent();
const output = lastFrame();
expect(output).toContain('1.');
expect(output).toContain('2.');
expect(output).toContain('3.');
});
it('should hide numbers when showNumbers is false', () => {
const { lastFrame } = renderComponent({ showNumbers: false });
const output = lastFrame();
expect(output).not.toContain('1.');
expect(output).not.toContain('2.');
expect(output).not.toContain('3.');
});
it('should apply correct padding for alignment in long lists', () => {
const longList = Array.from({ length: 15 }, (_, i) => ({
value: `Item ${i + 1}`,
label: `Item ${i + 1}`,
key: `Item ${i + 1}`,
}));
// We must increase maxItemsToShow (default 10) to see the 10th item and beyond
const { lastFrame } = renderComponent({
items: longList,
maxItemsToShow: 15,
});
const output = lastFrame();
// Check formatting for single and double digits.
// The implementation uses padStart, resulting in " 1." and "10.".
expect(output).toContain(' 1.');
expect(output).toContain('10.');
});
it('should apply secondary color to numbers if showNumbers is false (internal logic check)', () => {
renderComponent({ showNumbers: false }, 0);
expect(mockRenderItem).toHaveBeenCalledWith(
items[0],
expect.objectContaining({
isSelected: true,
titleColor: mockTheme.status.success,
numberColor: mockTheme.text.secondary,
}),
);
});
});
describe('Scrolling and Pagination (maxItemsToShow)', () => {
const longList = Array.from({ length: 10 }, (_, i) => ({
value: `Item ${i + 1}`,
label: `Item ${i + 1}`,
key: `Item ${i + 1}`,
}));
const MAX_ITEMS = 3;
const renderScrollableList = (initialActiveIndex: number = 0) => {
// Define the props used for the initial render and subsequent rerenders
const componentProps: BaseSelectionListProps<
string,
{ value: string; label: string; key: string }
> = {
items: longList,
maxItemsToShow: MAX_ITEMS,
onSelect: mockOnSelect,
onHighlight: mockOnHighlight,
renderItem: mockRenderItem,
};
vi.mocked(useSelectionList).mockReturnValue({
activeIndex: initialActiveIndex,
setActiveIndex: vi.fn(),
});
mockRenderItem.mockImplementation(
(item: (typeof longList)[0], context: RenderItemContext) => (
<Text color={context.titleColor}>{item.label}</Text>
),
);
const { rerender, lastFrame } = renderWithProviders(
<BaseSelectionList {...componentProps} />,
);
// Function to simulate the activeIndex changing over time
const updateActiveIndex = async (newIndex: number) => {
vi.mocked(useSelectionList).mockReturnValue({
activeIndex: newIndex,
setActiveIndex: vi.fn(),
});
rerender(<BaseSelectionList {...componentProps} />);
await waitFor(() => {
expect(lastFrame()).toBeTruthy();
});
};
return { updateActiveIndex, lastFrame };
};
it('should only show maxItemsToShow items initially', () => {
const { lastFrame } = renderScrollableList(0);
const output = lastFrame();
expect(output).toContain('Item 1');
expect(output).toContain('Item 3');
expect(output).not.toContain('Item 4');
});
it('should scroll down when activeIndex moves beyond the visible window', async () => {
const { updateActiveIndex, lastFrame } = renderScrollableList(0);
// Move to index 3 (Item 4). Should trigger scroll.
// New visible window should be Items 2, 3, 4 (scroll offset 1).
await updateActiveIndex(3);
const output = lastFrame();
expect(output).not.toContain('Item 1');
expect(output).toContain('Item 2');
expect(output).toContain('Item 4');
expect(output).not.toContain('Item 5');
});
it('should scroll up when activeIndex moves before the visible window', async () => {
const { updateActiveIndex, lastFrame } = renderScrollableList(0);
await updateActiveIndex(4);
let output = lastFrame();
expect(output).toContain('Item 3'); // Should see items 3, 4, 5
expect(output).toContain('Item 5');
expect(output).not.toContain('Item 2');
// Now test scrolling up: move to index 1 (Item 2)
// This should trigger scroll up to show items 2, 3, 4
await updateActiveIndex(1);
output = lastFrame();
expect(output).toContain('Item 2');
expect(output).toContain('Item 4');
expect(output).not.toContain('Item 5'); // Item 5 should no longer be visible
});
it('should pin the scroll offset to the end if selection starts near the end', async () => {
// List length 10. Max items 3. Active index 9 (last item).
// Scroll offset should be 10 - 3 = 7.
// Visible items: 8, 9, 10.
const { lastFrame } = renderScrollableList(9);
await waitFor(() => {
const output = lastFrame();
expect(output).toContain('Item 10');
expect(output).toContain('Item 8');
expect(output).not.toContain('Item 7');
});
});
it('should handle dynamic scrolling through multiple activeIndex changes', async () => {
const { updateActiveIndex, lastFrame } = renderScrollableList(0);
expect(lastFrame()).toContain('Item 1');
expect(lastFrame()).toContain('Item 3');
// Scroll down gradually
await updateActiveIndex(2); // Still within window
expect(lastFrame()).toContain('Item 1');
await updateActiveIndex(3); // Should trigger scroll
let output = lastFrame();
expect(output).toContain('Item 2');
expect(output).toContain('Item 4');
expect(output).not.toContain('Item 1');
await updateActiveIndex(5); // Scroll further
output = lastFrame();
expect(output).toContain('Item 4');
expect(output).toContain('Item 6');
expect(output).not.toContain('Item 3');
});
it('should correctly identify the selected item within the visible window', () => {
renderScrollableList(1); // activeIndex 1 = Item 2
expect(mockRenderItem).toHaveBeenCalledTimes(MAX_ITEMS);
expect(mockRenderItem).toHaveBeenCalledWith(
expect.objectContaining({ value: 'Item 1' }),
expect.objectContaining({ isSelected: false }),
);
expect(mockRenderItem).toHaveBeenCalledWith(
expect.objectContaining({ value: 'Item 2' }),
expect.objectContaining({ isSelected: true }),
);
});
it('should correctly identify the selected item when scrolled (high index)', async () => {
renderScrollableList(5);
await waitFor(() => {
// Item 6 (index 5) should be selected
expect(mockRenderItem).toHaveBeenCalledWith(
expect.objectContaining({ value: 'Item 6' }),
expect.objectContaining({ isSelected: true }),
);
});
// Item 4 (index 3) should not be selected
expect(mockRenderItem).toHaveBeenCalledWith(
expect.objectContaining({ value: 'Item 4' }),
expect.objectContaining({ isSelected: false }),
);
});
it('should handle maxItemsToShow larger than the list length', () => {
// Test edge case where maxItemsToShow exceeds available items
const { lastFrame } = renderComponent(
{ items: longList, maxItemsToShow: 15 },
0,
);
const output = lastFrame();
// Should show all available items (10 items)
expect(output).toContain('Item 1');
expect(output).toContain('Item 10');
expect(mockRenderItem).toHaveBeenCalledTimes(10);
});
});
describe('Scroll Arrows (showScrollArrows)', () => {
const longList = Array.from({ length: 10 }, (_, i) => ({
value: `Item ${i + 1}`,
label: `Item ${i + 1}`,
key: `Item ${i + 1}`,
}));
const MAX_ITEMS = 3;
it('should not show arrows by default', () => {
const { lastFrame } = renderComponent({
items: longList,
maxItemsToShow: MAX_ITEMS,
});
const output = lastFrame();
expect(output).not.toContain('▲');
expect(output).not.toContain('▼');
});
it('should show arrows with correct colors when enabled (at the top)', async () => {
const { lastFrame } = renderComponent(
{
items: longList,
maxItemsToShow: MAX_ITEMS,
showScrollArrows: true,
},
0,
);
await waitFor(() => {
const output = lastFrame();
// At the top, should show first 3 items
expect(output).toContain('Item 1');
expect(output).toContain('Item 3');
expect(output).not.toContain('Item 4');
// Both arrows should be visible
expect(output).toContain('▲');
expect(output).toContain('▼');
});
});
it('should show arrows and correct items when scrolled to the middle', async () => {
const { lastFrame } = renderComponent(
{ items: longList, maxItemsToShow: MAX_ITEMS, showScrollArrows: true },
5,
);
await waitFor(() => {
const output = lastFrame();
// After scrolling to middle, should see items around index 5
expect(output).toContain('Item 4');
expect(output).toContain('Item 6');
expect(output).not.toContain('Item 3');
expect(output).not.toContain('Item 7');
// Both scroll arrows should be visible
expect(output).toContain('▲');
expect(output).toContain('▼');
});
});
it('should show arrows and correct items when scrolled to the end', async () => {
const { lastFrame } = renderComponent(
{ items: longList, maxItemsToShow: MAX_ITEMS, showScrollArrows: true },
9,
);
await waitFor(() => {
const output = lastFrame();
// At the end, should show last 3 items
expect(output).toContain('Item 8');
expect(output).toContain('Item 10');
expect(output).not.toContain('Item 7');
// Both arrows should be visible
expect(output).toContain('▲');
expect(output).toContain('▼');
});
});
it('should show both arrows dimmed when list fits entirely', () => {
const { lastFrame } = renderComponent({
items,
maxItemsToShow: 5,
showScrollArrows: true,
});
const output = lastFrame();
// Should show all items since maxItemsToShow > items.length
expect(output).toContain('Item A');
expect(output).toContain('Item B');
expect(output).toContain('Item C');
// Both arrows should be visible but dimmed (this test doesn't need waitFor since no scrolling occurs)
expect(output).toContain('▲');
expect(output).toContain('▼');
});
});
});

View File

@@ -0,0 +1,179 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import type React from 'react';
import { useEffect, useState } from 'react';
import { Text, Box } from 'ink';
import { theme } from '../../semantic-colors.js';
import { useSelectionList } from '../../hooks/useSelectionList.js';
import type { SelectionListItem } from '../../hooks/useSelectionList.js';
export interface RenderItemContext {
isSelected: boolean;
titleColor: string;
numberColor: string;
}
export interface BaseSelectionListProps<
T,
TItem extends SelectionListItem<T> = SelectionListItem<T>,
> {
items: TItem[];
initialIndex?: number;
onSelect: (value: T) => void;
onHighlight?: (value: T) => void;
isFocused?: boolean;
showNumbers?: boolean;
showScrollArrows?: boolean;
maxItemsToShow?: number;
renderItem: (item: TItem, context: RenderItemContext) => React.ReactNode;
}
/**
* Base component for selection lists that provides common UI structure
* and keyboard navigation logic via the useSelectionList hook.
*
* This component handles:
* - Radio button indicators
* - Item numbering
* - Scrolling for long lists
* - Color theming based on selection/disabled state
* - Keyboard navigation and numeric selection
*
* Specific components should use this as a base and provide
* their own renderItem implementation for custom content.
*/
export function BaseSelectionList<
T,
TItem extends SelectionListItem<T> = SelectionListItem<T>,
>({
items,
initialIndex = 0,
onSelect,
onHighlight,
isFocused = true,
showNumbers = true,
showScrollArrows = false,
maxItemsToShow = 10,
renderItem,
}: BaseSelectionListProps<T, TItem>): React.JSX.Element {
const { activeIndex } = useSelectionList({
items,
initialIndex,
onSelect,
onHighlight,
isFocused,
showNumbers,
});
const [scrollOffset, setScrollOffset] = useState(0);
// Handle scrolling for long lists
useEffect(() => {
const newScrollOffset = Math.max(
0,
Math.min(activeIndex - maxItemsToShow + 1, items.length - maxItemsToShow),
);
if (activeIndex < scrollOffset) {
setScrollOffset(activeIndex);
} else if (activeIndex >= scrollOffset + maxItemsToShow) {
setScrollOffset(newScrollOffset);
}
}, [activeIndex, items.length, scrollOffset, maxItemsToShow]);
const visibleItems = items.slice(scrollOffset, scrollOffset + maxItemsToShow);
const numberColumnWidth = String(items.length).length;
return (
<Box flexDirection="column">
{/* Use conditional coloring instead of conditional rendering */}
{showScrollArrows && (
<Text
color={scrollOffset > 0 ? theme.text.primary : theme.text.secondary}
>
</Text>
)}
{visibleItems.map((item, index) => {
const itemIndex = scrollOffset + index;
const isSelected = activeIndex === itemIndex;
// Determine colors based on selection and disabled state
let titleColor = theme.text.primary;
let numberColor = theme.text.primary;
if (isSelected) {
titleColor = theme.status.success;
numberColor = theme.status.success;
} else if (item.disabled) {
titleColor = theme.text.secondary;
numberColor = theme.text.secondary;
}
if (!isFocused && !item.disabled) {
numberColor = theme.text.secondary;
}
if (!showNumbers) {
numberColor = theme.text.secondary;
}
const itemNumberText = `${String(itemIndex + 1).padStart(
numberColumnWidth,
)}.`;
return (
<Box key={item.key} alignItems="flex-start">
{/* Radio button indicator */}
<Box minWidth={2} flexShrink={0}>
<Text
color={isSelected ? theme.status.success : theme.text.primary}
aria-hidden
>
{isSelected ? '●' : ' '}
</Text>
</Box>
{/* Item number */}
{showNumbers && (
<Box
marginRight={1}
flexShrink={0}
minWidth={itemNumberText.length}
aria-state={{ checked: isSelected }}
>
<Text color={numberColor}>{itemNumberText}</Text>
</Box>
)}
{/* Custom content via render prop */}
<Box flexGrow={1}>
{renderItem(item, {
isSelected,
titleColor,
numberColor,
})}
</Box>
</Box>
);
})}
{showScrollArrows && (
<Text
color={
scrollOffset + maxItemsToShow < items.length
? theme.text.primary
: theme.text.secondary
}
>
</Text>
)}
</Box>
);
}

View File

@@ -0,0 +1,97 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { renderWithProviders } from '../../../test-utils/render.js';
import {
DescriptiveRadioButtonSelect,
type DescriptiveRadioSelectItem,
type DescriptiveRadioButtonSelectProps,
} from './DescriptiveRadioButtonSelect.js';
vi.mock('./BaseSelectionList.js', async (importOriginal) => {
const actual =
await importOriginal<typeof import('./BaseSelectionList.js')>();
return {
...actual,
BaseSelectionList: vi.fn(({ children, ...props }) => (
<actual.BaseSelectionList {...props}>{children}</actual.BaseSelectionList>
)),
};
});
vi.mock('../../semantic-colors.js', () => ({
theme: {
text: {
primary: 'COLOR_PRIMARY',
secondary: 'COLOR_SECONDARY',
},
status: {
success: 'COLOR_SUCCESS',
},
},
}));
describe('DescriptiveRadioButtonSelect', () => {
const mockOnSelect = vi.fn();
const mockOnHighlight = vi.fn();
const ITEMS: Array<DescriptiveRadioSelectItem<string>> = [
{
title: 'Foo Title',
description: 'This is Foo.',
value: 'foo',
key: 'foo',
},
{
title: 'Bar Title',
description: 'This is Bar.',
value: 'bar',
key: 'bar',
},
{
title: 'Baz Title',
description: 'This is Baz.',
value: 'baz',
disabled: true,
key: 'baz',
},
];
const renderComponent = (
props: Partial<DescriptiveRadioButtonSelectProps<string>> = {},
) => {
const defaultProps: DescriptiveRadioButtonSelectProps<string> = {
items: ITEMS,
onSelect: mockOnSelect,
...props,
};
return renderWithProviders(
<DescriptiveRadioButtonSelect {...defaultProps} />,
);
};
beforeEach(() => {
vi.clearAllMocks();
});
it('should render correctly with default props', () => {
const { lastFrame } = renderComponent();
expect(lastFrame()).toMatchSnapshot();
});
it('should render correctly with custom props', () => {
const { lastFrame } = renderComponent({
initialIndex: 1,
isFocused: false,
showScrollArrows: true,
maxItemsToShow: 5,
showNumbers: true,
onHighlight: mockOnHighlight,
});
expect(lastFrame()).toMatchSnapshot();
});
});

View File

@@ -0,0 +1,70 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import type React from 'react';
import { Text, Box } from 'ink';
import { theme } from '../../semantic-colors.js';
import { BaseSelectionList } from './BaseSelectionList.js';
import type { SelectionListItem } from '../../hooks/useSelectionList.js';
export interface DescriptiveRadioSelectItem<T> extends SelectionListItem<T> {
title: string;
description: string;
}
export interface DescriptiveRadioButtonSelectProps<T> {
/** An array of items to display as descriptive radio options. */
items: Array<DescriptiveRadioSelectItem<T>>;
/** The initial index selected */
initialIndex?: number;
/** Function called when an item is selected. Receives the `value` of the selected item. */
onSelect: (value: T) => void;
/** Function called when an item is highlighted. Receives the `value` of the selected item. */
onHighlight?: (value: T) => void;
/** Whether this select input is currently focused and should respond to input. */
isFocused?: boolean;
/** Whether to show numbers next to items. */
showNumbers?: boolean;
/** Whether to show the scroll arrows. */
showScrollArrows?: boolean;
/** The maximum number of items to show at once. */
maxItemsToShow?: number;
}
/**
* A radio button select component that displays items with title and description.
*
* @template T The type of the value associated with each descriptive radio item.
*/
export function DescriptiveRadioButtonSelect<T>({
items,
initialIndex = 0,
onSelect,
onHighlight,
isFocused = true,
showNumbers = false,
showScrollArrows = false,
maxItemsToShow = 10,
}: DescriptiveRadioButtonSelectProps<T>): React.JSX.Element {
return (
<BaseSelectionList<T, DescriptiveRadioSelectItem<T>>
items={items}
initialIndex={initialIndex}
onSelect={onSelect}
onHighlight={onHighlight}
isFocused={isFocused}
showNumbers={showNumbers}
showScrollArrows={showScrollArrows}
maxItemsToShow={maxItemsToShow}
renderItem={(item, { titleColor }) => (
<Box flexDirection="column" key={item.key}>
<Text color={titleColor}>{item.title}</Text>
<Text color={theme.text.secondary}>{item.description}</Text>
</Box>
)}
/>
);
}

View File

@@ -0,0 +1,152 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { renderWithProviders } from '../../../test-utils/render.js';
import { EnumSelector } from './EnumSelector.js';
import type { SettingEnumOption } from '../../../config/settingsSchema.js';
import { describe, it, expect } from 'vitest';
const LANGUAGE_OPTIONS: readonly SettingEnumOption[] = [
{ label: 'English', value: 'en' },
{ label: '中文 (简体)', value: 'zh' },
{ label: 'Español', value: 'es' },
{ label: 'Français', value: 'fr' },
];
const NUMERIC_OPTIONS: readonly SettingEnumOption[] = [
{ label: 'Low', value: 1 },
{ label: 'Medium', value: 2 },
{ label: 'High', value: 3 },
];
describe('<EnumSelector />', () => {
it('renders with string options and matches snapshot', () => {
const { lastFrame } = renderWithProviders(
<EnumSelector
options={LANGUAGE_OPTIONS}
currentValue="en"
isActive={true}
onValueChange={() => {}}
/>,
);
expect(lastFrame()).toMatchSnapshot();
});
it('renders with numeric options and matches snapshot', () => {
const { lastFrame } = renderWithProviders(
<EnumSelector
options={NUMERIC_OPTIONS}
currentValue={2}
isActive={true}
onValueChange={() => {}}
/>,
);
expect(lastFrame()).toMatchSnapshot();
});
it('renders inactive state and matches snapshot', () => {
const { lastFrame } = renderWithProviders(
<EnumSelector
options={LANGUAGE_OPTIONS}
currentValue="zh"
isActive={false}
onValueChange={() => {}}
/>,
);
expect(lastFrame()).toMatchSnapshot();
});
it('renders with single option and matches snapshot', () => {
const singleOption: readonly SettingEnumOption[] = [
{ label: 'Only Option', value: 'only' },
];
const { lastFrame } = renderWithProviders(
<EnumSelector
options={singleOption}
currentValue="only"
isActive={true}
onValueChange={() => {}}
/>,
);
expect(lastFrame()).toMatchSnapshot();
});
it('renders nothing when no options are provided', () => {
const { lastFrame } = renderWithProviders(
<EnumSelector
options={[]}
currentValue=""
isActive={true}
onValueChange={() => {}}
/>,
);
expect(lastFrame()).toBe('');
});
it('handles currentValue not found in options', () => {
const { lastFrame } = renderWithProviders(
<EnumSelector
options={LANGUAGE_OPTIONS}
currentValue="invalid"
isActive={true}
onValueChange={() => {}}
/>,
);
// Should default to first option
expect(lastFrame()).toContain('English');
});
it('updates when currentValue changes externally', () => {
const { rerender, lastFrame } = renderWithProviders(
<EnumSelector
options={LANGUAGE_OPTIONS}
currentValue="en"
isActive={true}
onValueChange={() => {}}
/>,
);
expect(lastFrame()).toContain('English');
rerender(
<EnumSelector
options={LANGUAGE_OPTIONS}
currentValue="zh"
isActive={true}
onValueChange={() => {}}
/>,
);
expect(lastFrame()).toContain('中文 (简体)');
});
it('shows navigation arrows when multiple options available', () => {
const { lastFrame } = renderWithProviders(
<EnumSelector
options={LANGUAGE_OPTIONS}
currentValue="en"
isActive={true}
onValueChange={() => {}}
/>,
);
expect(lastFrame()).toContain('←');
expect(lastFrame()).toContain('→');
});
it('hides navigation arrows when single option available', () => {
const singleOption: readonly SettingEnumOption[] = [
{ label: 'Only Option', value: 'only' },
];
const { lastFrame } = renderWithProviders(
<EnumSelector
options={singleOption}
currentValue="only"
isActive={true}
onValueChange={() => {}}
/>,
);
expect(lastFrame()).not.toContain('←');
expect(lastFrame()).not.toContain('→');
});
});

View File

@@ -0,0 +1,87 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { useState, useEffect } from 'react';
import type React from 'react';
import { Box, Text } from 'ink';
import { Colors } from '../../colors.js';
import type { SettingEnumOption } from '../../../config/settingsSchema.js';
interface EnumSelectorProps {
options: readonly SettingEnumOption[];
currentValue: string | number;
isActive: boolean;
onValueChange: (value: string | number) => void;
}
/**
* A left-right scrolling selector for enum values
*/
export function EnumSelector({
options,
currentValue,
isActive,
onValueChange: _onValueChange,
}: EnumSelectorProps): React.JSX.Element {
const [currentIndex, setCurrentIndex] = useState(() => {
// Guard against empty options array
if (!options || options.length === 0) {
return 0;
}
const index = options.findIndex((option) => option.value === currentValue);
return index >= 0 ? index : 0;
});
// Update index when currentValue changes externally
useEffect(() => {
// Guard against empty options array
if (!options || options.length === 0) {
return;
}
const index = options.findIndex((option) => option.value === currentValue);
// Always update index, defaulting to 0 if value not found
setCurrentIndex(index >= 0 ? index : 0);
}, [currentValue, options]);
// Guard against empty options array
if (!options || options.length === 0) {
return <Box />;
}
// Left/right navigation is handled by parent component
// This component is purely for display
// onValueChange is kept for interface compatibility but not used internally
const currentOption = options[currentIndex] || options[0];
const canScrollLeft = options.length > 1;
const canScrollRight = options.length > 1;
return (
<Box flexDirection="row" alignItems="center">
<Text
color={isActive && canScrollLeft ? Colors.AccentGreen : Colors.Gray}
>
{canScrollLeft ? '←' : ' '}
</Text>
<Text> </Text>
<Text
color={isActive ? Colors.AccentGreen : Colors.Foreground}
bold={isActive}
>
{currentOption.label}
</Text>
<Text> </Text>
<Text
color={isActive && canScrollRight ? Colors.AccentGreen : Colors.Gray}
>
{canScrollRight ? '→' : ' '}
</Text>
</Box>
);
}
// Export the interface for external use
export type { EnumSelectorProps };

View File

@@ -7,7 +7,7 @@
import React, { Fragment, useEffect, useId } from 'react';
import { Box, Text } from 'ink';
import stringWidth from 'string-width';
import { Colors } from '../../colors.js';
import { theme } from '../../semantic-colors.js';
import { toCodePoints } from '../../utils/textUtils.js';
import { useOverflowActions } from '../../contexts/OverflowContext.js';
@@ -186,14 +186,14 @@ export const MaxSizedBox: React.FC<MaxSizedBoxProps> = ({
return (
<Box flexDirection="column" width={maxWidth} flexShrink={0}>
{totalHiddenLines > 0 && overflowDirection === 'top' && (
<Text color={Colors.Gray} wrap="truncate">
<Text color={theme.text.secondary} wrap="truncate">
... first {totalHiddenLines} line{totalHiddenLines === 1 ? '' : 's'}{' '}
hidden ...
</Text>
)}
{visibleLines}
{totalHiddenLines > 0 && overflowDirection === 'bottom' && (
<Text color={Colors.Gray} wrap="truncate">
<Text color={theme.text.secondary} wrap="truncate">
... last {totalHiddenLines} line{totalHiddenLines === 1 ? '' : 's'}{' '}
hidden ...
</Text>
@@ -264,13 +264,12 @@ function visitBoxRow(element: React.ReactNode): Row {
if (enableDebugLog) {
const boxProps = element.props as {
children?: React.ReactNode | undefined;
children?: React.ReactNode;
readonly flexDirection?:
| 'row'
| 'column'
| 'row-reverse'
| 'column-reverse'
| undefined;
| 'column-reverse';
};
// Ensure the Box has no props other than the default ones and key.
let maxExpectedProps = 4;

View File

@@ -4,178 +4,195 @@
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { renderWithProviders } from '../../../test-utils/render.js';
import { waitFor } from '@testing-library/react';
import type React from 'react';
import {
RadioButtonSelect,
type RadioSelectItem,
type RadioButtonSelectProps,
} from './RadioButtonSelect.js';
import { describe, it, expect, vi } from 'vitest';
import {
BaseSelectionList,
type BaseSelectionListProps,
type RenderItemContext,
} from './BaseSelectionList.js';
const ITEMS: Array<RadioSelectItem<string>> = [
{ label: 'Option 1', value: 'one' },
{ label: 'Option 2', value: 'two' },
{ label: 'Option 3', value: 'three', disabled: true },
];
vi.mock('./BaseSelectionList.js', () => ({
BaseSelectionList: vi.fn(() => null),
}));
describe('<RadioButtonSelect />', () => {
it('renders a list of items and matches snapshot', () => {
const { lastFrame } = renderWithProviders(
<RadioButtonSelect items={ITEMS} onSelect={() => {}} isFocused={true} />,
vi.mock('../../semantic-colors.js', () => ({
theme: {
text: { secondary: 'COLOR_SECONDARY' },
},
}));
const MockedBaseSelectionList = vi.mocked(
BaseSelectionList,
) as unknown as ReturnType<typeof vi.fn>;
type RadioRenderItemFn = (
item: RadioSelectItem<string>,
context: RenderItemContext,
) => React.JSX.Element;
const extractRenderItem = (): RadioRenderItemFn => {
const mockCalls = MockedBaseSelectionList.mock.calls;
if (mockCalls.length === 0) {
throw new Error(
'BaseSelectionList was not called. Ensure RadioButtonSelect is rendered before calling extractRenderItem.',
);
expect(lastFrame()).toMatchSnapshot();
}
const props = mockCalls[0][0] as BaseSelectionListProps<
string,
RadioSelectItem<string>
>;
if (typeof props.renderItem !== 'function') {
throw new Error('renderItem prop was not found on BaseSelectionList call.');
}
return props.renderItem as RadioRenderItemFn;
};
describe('RadioButtonSelect', () => {
const mockOnSelect = vi.fn();
const mockOnHighlight = vi.fn();
const ITEMS: Array<RadioSelectItem<string>> = [
{ label: 'Option 1', value: 'one', key: 'one' },
{ label: 'Option 2', value: 'two', key: 'two' },
{ label: 'Option 3', value: 'three', disabled: true, key: 'three' },
];
const renderComponent = (
props: Partial<RadioButtonSelectProps<string>> = {},
) => {
const defaultProps: RadioButtonSelectProps<string> = {
items: ITEMS,
onSelect: mockOnSelect,
...props,
};
return renderWithProviders(<RadioButtonSelect {...defaultProps} />);
};
beforeEach(() => {
vi.clearAllMocks();
});
it('renders with the second item selected and matches snapshot', () => {
const { lastFrame } = renderWithProviders(
<RadioButtonSelect items={ITEMS} initialIndex={1} onSelect={() => {}} />,
);
expect(lastFrame()).toMatchSnapshot();
describe('Prop forwarding to BaseSelectionList', () => {
it('should forward all props correctly when provided', () => {
const props = {
items: ITEMS,
initialIndex: 1,
onSelect: mockOnSelect,
onHighlight: mockOnHighlight,
isFocused: false,
showScrollArrows: true,
maxItemsToShow: 5,
showNumbers: false,
};
renderComponent(props);
expect(BaseSelectionList).toHaveBeenCalledTimes(1);
expect(BaseSelectionList).toHaveBeenCalledWith(
expect.objectContaining({
...props,
renderItem: expect.any(Function),
}),
undefined,
);
});
it('should use default props if not provided', () => {
renderComponent({
items: ITEMS,
onSelect: mockOnSelect,
});
expect(BaseSelectionList).toHaveBeenCalledWith(
expect.objectContaining({
initialIndex: 0,
isFocused: true,
showScrollArrows: false,
maxItemsToShow: 10,
showNumbers: true,
}),
undefined,
);
});
});
it('renders with numbers hidden and matches snapshot', () => {
const { lastFrame } = renderWithProviders(
<RadioButtonSelect
items={ITEMS}
onSelect={() => {}}
showNumbers={false}
/>,
);
expect(lastFrame()).toMatchSnapshot();
});
describe('renderItem implementation', () => {
let renderItem: RadioRenderItemFn;
const mockContext: RenderItemContext = {
isSelected: false,
titleColor: 'MOCK_TITLE_COLOR',
numberColor: 'MOCK_NUMBER_COLOR',
};
it('renders with scroll arrows and matches snapshot', () => {
const manyItems = Array.from({ length: 20 }, (_, i) => ({
label: `Item ${i + 1}`,
value: `item-${i + 1}`,
}));
const { lastFrame } = renderWithProviders(
<RadioButtonSelect
items={manyItems}
onSelect={() => {}}
showScrollArrows={true}
maxItemsToShow={5}
/>,
);
expect(lastFrame()).toMatchSnapshot();
});
beforeEach(() => {
renderComponent();
renderItem = extractRenderItem();
});
it('renders with special theme display and matches snapshot', () => {
const themeItems: Array<RadioSelectItem<string>> = [
{
it('should render the standard label display with correct color and truncation', () => {
const item = ITEMS[0];
const result = renderItem(item, mockContext);
expect(result?.props?.color).toBe(mockContext.titleColor);
expect(result?.props?.children).toBe('Option 1');
expect(result?.props?.wrap).toBe('truncate');
});
it('should render the special theme display when theme props are present', () => {
const themeItem: RadioSelectItem<string> = {
label: 'Theme A (Light)',
value: 'a-light',
themeNameDisplay: 'Theme A',
themeTypeDisplay: '(Light)',
},
{
label: 'Theme B (Dark)',
value: 'b-dark',
themeNameDisplay: 'Theme B',
themeTypeDisplay: '(Dark)',
},
];
const { lastFrame } = renderWithProviders(
<RadioButtonSelect items={themeItems} onSelect={() => {}} />,
);
expect(lastFrame()).toMatchSnapshot();
});
key: 'a-light',
};
it('renders a list with >10 items and matches snapshot', () => {
const manyItems = Array.from({ length: 12 }, (_, i) => ({
label: `Item ${i + 1}`,
value: `item-${i + 1}`,
}));
const { lastFrame } = renderWithProviders(
<RadioButtonSelect items={manyItems} onSelect={() => {}} />,
);
expect(lastFrame()).toMatchSnapshot();
});
const result = renderItem(themeItem, mockContext);
it('renders nothing when no items are provided', () => {
const { lastFrame } = renderWithProviders(
<RadioButtonSelect items={[]} onSelect={() => {}} isFocused={true} />,
);
expect(lastFrame()).toBe('');
});
});
expect(result?.props?.color).toBe(mockContext.titleColor);
expect(result?.props?.wrap).toBe('truncate');
describe('keyboard navigation', () => {
it('should call onSelect when "enter" is pressed', () => {
const onSelect = vi.fn();
const { stdin } = renderWithProviders(
<RadioButtonSelect items={ITEMS} onSelect={onSelect} />,
);
const children = result?.props?.children;
stdin.write('\r');
if (!Array.isArray(children) || children.length < 3) {
throw new Error(
'Expected children to be an array with at least 3 elements for theme display',
);
}
expect(onSelect).toHaveBeenCalledWith('one');
});
expect(children[0]).toBe('Theme A');
expect(children[1]).toBe(' ');
describe('when isFocused is false', () => {
it('should not handle any keyboard input', () => {
const onSelect = vi.fn();
const { stdin } = renderWithProviders(
<RadioButtonSelect
items={ITEMS}
onSelect={onSelect}
isFocused={false}
/>,
);
stdin.write('\u001B[B'); // Down arrow
stdin.write('\u001B[A'); // Up arrow
stdin.write('\r'); // Enter
expect(onSelect).not.toHaveBeenCalled();
});
});
describe.each([
{ description: 'when isFocused is true', isFocused: true },
{ description: 'when isFocused is omitted', isFocused: undefined },
])('$description', ({ isFocused }) => {
it('should navigate down with arrow key and select with enter', async () => {
const onSelect = vi.fn();
const { stdin, lastFrame } = renderWithProviders(
<RadioButtonSelect
items={ITEMS}
onSelect={onSelect}
isFocused={isFocused}
/>,
);
stdin.write('\u001B[B'); // Down arrow
await waitFor(() => {
expect(lastFrame()).toContain('● 2. Option 2');
});
stdin.write('\r');
expect(onSelect).toHaveBeenCalledWith('two');
const nestedTextElement = children[2] as React.ReactElement<{
color?: string;
children?: React.ReactNode;
}>;
expect(nestedTextElement?.props?.color).toBe('COLOR_SECONDARY');
expect(nestedTextElement?.props?.children).toBe('(Light)');
});
it('should navigate up with arrow key and select with enter', async () => {
const onSelect = vi.fn();
const { stdin, lastFrame } = renderWithProviders(
<RadioButtonSelect
items={ITEMS}
onSelect={onSelect}
initialIndex={1}
isFocused={isFocused}
/>,
);
it('should fall back to standard display if only one theme prop is present', () => {
const partialThemeItem: RadioSelectItem<string> = {
label: 'Incomplete Theme',
value: 'incomplete',
themeNameDisplay: 'Only Name',
key: 'incomplete',
};
stdin.write('\u001B[A'); // Up arrow
const result = renderItem(partialThemeItem, mockContext);
await waitFor(() => {
expect(lastFrame()).toContain('● 1. Option 1');
});
stdin.write('\r');
expect(onSelect).toHaveBeenCalledWith('one');
expect(result?.props?.children).toBe('Incomplete Theme');
});
});
});

View File

@@ -5,19 +5,17 @@
*/
import type React from 'react';
import { useEffect, useState, useRef } from 'react';
import { Text, Box } from 'ink';
import { Colors } from '../../colors.js';
import { useKeypress } from '../../hooks/useKeypress.js';
import { Text } from 'ink';
import { theme } from '../../semantic-colors.js';
import { BaseSelectionList } from './BaseSelectionList.js';
import type { SelectionListItem } from '../../hooks/useSelectionList.js';
/**
* Represents a single option for the RadioButtonSelect.
* Requires a label for display and a value to be returned on selection.
*/
export interface RadioSelectItem<T> {
export interface RadioSelectItem<T> extends SelectionListItem<T> {
label: string;
value: T;
disabled?: boolean;
themeNameDisplay?: string;
themeTypeDisplay?: string;
}
@@ -61,178 +59,33 @@ export function RadioButtonSelect<T>({
maxItemsToShow = 10,
showNumbers = true,
}: RadioButtonSelectProps<T>): React.JSX.Element {
const [activeIndex, setActiveIndex] = useState(initialIndex);
const [scrollOffset, setScrollOffset] = useState(0);
const [numberInput, setNumberInput] = useState('');
const numberInputTimer = useRef<NodeJS.Timeout | null>(null);
useEffect(() => {
const newScrollOffset = Math.max(
0,
Math.min(activeIndex - maxItemsToShow + 1, items.length - maxItemsToShow),
);
if (activeIndex < scrollOffset) {
setScrollOffset(activeIndex);
} else if (activeIndex >= scrollOffset + maxItemsToShow) {
setScrollOffset(newScrollOffset);
}
}, [activeIndex, items.length, scrollOffset, maxItemsToShow]);
useEffect(
() => () => {
if (numberInputTimer.current) {
clearTimeout(numberInputTimer.current);
}
},
[],
);
useKeypress(
(key) => {
const { sequence, name } = key;
const isNumeric = showNumbers && /^[0-9]$/.test(sequence);
// Any key press that is not a digit should clear the number input buffer.
if (!isNumeric && numberInputTimer.current) {
clearTimeout(numberInputTimer.current);
setNumberInput('');
}
if (name === 'k' || name === 'up') {
const newIndex = activeIndex > 0 ? activeIndex - 1 : items.length - 1;
setActiveIndex(newIndex);
onHighlight?.(items[newIndex]!.value);
return;
}
if (name === 'j' || name === 'down') {
const newIndex = activeIndex < items.length - 1 ? activeIndex + 1 : 0;
setActiveIndex(newIndex);
onHighlight?.(items[newIndex]!.value);
return;
}
if (name === 'return') {
onSelect(items[activeIndex]!.value);
return;
}
// Handle numeric input for selection.
if (isNumeric) {
if (numberInputTimer.current) {
clearTimeout(numberInputTimer.current);
}
const newNumberInput = numberInput + sequence;
setNumberInput(newNumberInput);
const targetIndex = Number.parseInt(newNumberInput, 10) - 1;
// A single '0' is not a valid selection since items are 1-indexed.
if (newNumberInput === '0') {
numberInputTimer.current = setTimeout(() => setNumberInput(''), 350);
return;
}
if (targetIndex >= 0 && targetIndex < items.length) {
const targetItem = items[targetIndex]!;
setActiveIndex(targetIndex);
onHighlight?.(targetItem.value);
// If the typed number can't be a prefix for another valid number,
// select it immediately. Otherwise, wait for more input.
const potentialNextNumber = Number.parseInt(newNumberInput + '0', 10);
if (potentialNextNumber > items.length) {
onSelect(targetItem.value);
setNumberInput('');
} else {
numberInputTimer.current = setTimeout(() => {
onSelect(targetItem.value);
setNumberInput('');
}, 350); // Debounce time for multi-digit input.
}
} else {
// The typed number is out of bounds, clear the buffer
setNumberInput('');
}
}
},
{ isActive: !!(isFocused && items.length > 0) },
);
const visibleItems = items.slice(scrollOffset, scrollOffset + maxItemsToShow);
return (
<Box flexDirection="column">
{showScrollArrows && (
<Text color={scrollOffset > 0 ? Colors.Foreground : Colors.Gray}>
</Text>
)}
{visibleItems.map((item, index) => {
const itemIndex = scrollOffset + index;
const isSelected = activeIndex === itemIndex;
let textColor = Colors.Foreground;
let numberColor = Colors.Foreground;
if (isSelected) {
textColor = Colors.AccentGreen;
numberColor = Colors.AccentGreen;
} else if (item.disabled) {
textColor = Colors.Gray;
numberColor = Colors.Gray;
<BaseSelectionList<T, RadioSelectItem<T>>
items={items}
initialIndex={initialIndex}
onSelect={onSelect}
onHighlight={onHighlight}
isFocused={isFocused}
showNumbers={showNumbers}
showScrollArrows={showScrollArrows}
maxItemsToShow={maxItemsToShow}
renderItem={(item, { titleColor }) => {
// Handle special theme display case for ThemeDialog compatibility
if (item.themeNameDisplay && item.themeTypeDisplay) {
return (
<Text color={titleColor} wrap="truncate" key={item.key}>
{item.themeNameDisplay}{' '}
<Text color={theme.text.secondary}>{item.themeTypeDisplay}</Text>
</Text>
);
}
if (!showNumbers) {
numberColor = Colors.Gray;
}
const numberColumnWidth = String(items.length).length;
const itemNumberText = `${String(itemIndex + 1).padStart(
numberColumnWidth,
)}.`;
// Regular label display
return (
<Box key={item.label} alignItems="center">
<Box minWidth={2} flexShrink={0}>
<Text
color={isSelected ? Colors.AccentGreen : Colors.Foreground}
aria-hidden
>
{isSelected ? '●' : ' '}
</Text>
</Box>
<Box
marginRight={1}
flexShrink={0}
minWidth={itemNumberText.length}
aria-state={{ checked: isSelected }}
>
<Text color={numberColor}>{itemNumberText}</Text>
</Box>
{item.themeNameDisplay && item.themeTypeDisplay ? (
<Text color={textColor} wrap="truncate">
{item.themeNameDisplay}{' '}
<Text color={Colors.Gray}>{item.themeTypeDisplay}</Text>
</Text>
) : (
<Text color={textColor} wrap="truncate">
{item.label}
</Text>
)}
</Box>
<Text color={titleColor} wrap="truncate">
{item.label}
</Text>
);
})}
{showScrollArrows && (
<Text
color={
scrollOffset + maxItemsToShow < items.length
? Colors.Foreground
: Colors.Gray
}
>
</Text>
)}
</Box>
}}
/>
);
}

View File

@@ -0,0 +1,55 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import type React from 'react';
import { Box, Text } from 'ink';
import type { SettingScope } from '../../../config/settings.js';
import { getScopeItems } from '../../../utils/dialogScopeUtils.js';
import { RadioButtonSelect } from './RadioButtonSelect.js';
interface ScopeSelectorProps {
/** Callback function when a scope is selected */
onSelect: (scope: SettingScope) => void;
/** Callback function when a scope is highlighted */
onHighlight: (scope: SettingScope) => void;
/** Whether the component is focused */
isFocused: boolean;
/** The initial scope to select */
initialScope: SettingScope;
}
export function ScopeSelector({
onSelect,
onHighlight,
isFocused,
initialScope,
}: ScopeSelectorProps): React.JSX.Element {
const scopeItems = getScopeItems().map((item) => ({
...item,
key: item.value,
}));
const initialIndex = scopeItems.findIndex(
(item) => item.value === initialScope,
);
const safeInitialIndex = initialIndex >= 0 ? initialIndex : 0;
return (
<Box flexDirection="column">
<Text bold={isFocused} wrap="truncate">
{isFocused ? '> ' : ' '}Apply To
</Text>
<RadioButtonSelect
items={scopeItems}
initialIndex={safeInitialIndex}
onSelect={onSelect}
onHighlight={onHighlight}
isFocused={isFocused}
showNumbers={isFocused}
/>
</Box>
);
}

View File

@@ -0,0 +1,21 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`DescriptiveRadioButtonSelect > should render correctly with custom props 1`] = `
"▲
1. Foo Title
This is Foo.
● 2. Bar Title
This is Bar.
3. Baz Title
This is Baz.
▼"
`;
exports[`DescriptiveRadioButtonSelect > should render correctly with default props 1`] = `
"● Foo Title
This is Foo.
Bar Title
This is Bar.
Baz Title
This is Baz."
`;

View File

@@ -0,0 +1,9 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`<EnumSelector /> > renders inactive state and matches snapshot 1`] = `"← 中文 (简体) →"`;
exports[`<EnumSelector /> > renders with numeric options and matches snapshot 1`] = `"← Medium →"`;
exports[`<EnumSelector /> > renders with single option and matches snapshot 1`] = `" Only Option"`;
exports[`<EnumSelector /> > renders with string options and matches snapshot 1`] = `"← English →"`;

View File

@@ -1,47 +0,0 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`<RadioButtonSelect /> > renders a list of items and matches snapshot 1`] = `
"● 1. Option 1
2. Option 2
3. Option 3"
`;
exports[`<RadioButtonSelect /> > renders a list with >10 items and matches snapshot 1`] = `
"● 1. Item 1
2. Item 2
3. Item 3
4. Item 4
5. Item 5
6. Item 6
7. Item 7
8. Item 8
9. Item 9
10. Item 10"
`;
exports[`<RadioButtonSelect /> > renders with numbers hidden and matches snapshot 1`] = `
"● 1. Option 1
2. Option 2
3. Option 3"
`;
exports[`<RadioButtonSelect /> > renders with scroll arrows and matches snapshot 1`] = `
"▲
● 1. Item 1
2. Item 2
3. Item 3
4. Item 4
5. Item 5
▼"
`;
exports[`<RadioButtonSelect /> > renders with special theme display and matches snapshot 1`] = `
"● 1. Theme A (Light)
2. Theme B (Dark)"
`;
exports[`<RadioButtonSelect /> > renders with the second item selected and matches snapshot 1`] = `
" 1. Option 1
● 2. Option 2
3. Option 3"
`;

View File

@@ -172,6 +172,117 @@ describe('textBufferReducer', () => {
expect(state.undoStack[0].cursorCol).toBe(5);
});
});
describe('delete_word_left action', () => {
it('should delete a simple word', () => {
const stateWithText: TextBufferState = {
...initialState,
lines: ['hello world'],
cursorRow: 0,
cursorCol: 11,
};
const action: TextBufferAction = { type: 'delete_word_left' };
const state = textBufferReducer(stateWithText, action);
expect(state.lines).toEqual(['hello ']);
expect(state.cursorCol).toBe(6);
});
it('should delete a path segment', () => {
const stateWithText: TextBufferState = {
...initialState,
lines: ['path/to/file'],
cursorRow: 0,
cursorCol: 12,
};
const action: TextBufferAction = { type: 'delete_word_left' };
const state = textBufferReducer(stateWithText, action);
expect(state.lines).toEqual(['path/to/']);
expect(state.cursorCol).toBe(8);
});
it('should delete variable_name parts', () => {
const stateWithText: TextBufferState = {
...initialState,
lines: ['variable_name'],
cursorRow: 0,
cursorCol: 13,
};
const action: TextBufferAction = { type: 'delete_word_left' };
const state = textBufferReducer(stateWithText, action);
expect(state.lines).toEqual(['variable_']);
expect(state.cursorCol).toBe(9);
});
it('should act like backspace at the beginning of a line', () => {
const stateWithText: TextBufferState = {
...initialState,
lines: ['hello', 'world'],
cursorRow: 1,
cursorCol: 0,
};
const action: TextBufferAction = { type: 'delete_word_left' };
const state = textBufferReducer(stateWithText, action);
expect(state.lines).toEqual(['helloworld']);
expect(state.cursorRow).toBe(0);
expect(state.cursorCol).toBe(5);
});
});
describe('delete_word_right action', () => {
it('should delete a simple word', () => {
const stateWithText: TextBufferState = {
...initialState,
lines: ['hello world'],
cursorRow: 0,
cursorCol: 0,
};
const action: TextBufferAction = { type: 'delete_word_right' };
const state = textBufferReducer(stateWithText, action);
expect(state.lines).toEqual(['world']);
expect(state.cursorCol).toBe(0);
});
it('should delete a path segment', () => {
const stateWithText: TextBufferState = {
...initialState,
lines: ['path/to/file'],
cursorRow: 0,
cursorCol: 0,
};
const action: TextBufferAction = { type: 'delete_word_right' };
let state = textBufferReducer(stateWithText, action);
expect(state.lines).toEqual(['/to/file']);
state = textBufferReducer(state, action);
expect(state.lines).toEqual(['to/file']);
});
it('should delete variable_name parts', () => {
const stateWithText: TextBufferState = {
...initialState,
lines: ['variable_name'],
cursorRow: 0,
cursorCol: 0,
};
const action: TextBufferAction = { type: 'delete_word_right' };
const state = textBufferReducer(stateWithText, action);
expect(state.lines).toEqual(['_name']);
expect(state.cursorCol).toBe(0);
});
it('should act like delete at the end of a line', () => {
const stateWithText: TextBufferState = {
...initialState,
lines: ['hello', 'world'],
cursorRow: 0,
cursorCol: 5,
};
const action: TextBufferAction = { type: 'delete_word_right' };
const state = textBufferReducer(stateWithText, action);
expect(state.lines).toEqual(['helloworld']);
expect(state.cursorRow).toBe(0);
expect(state.cursorCol).toBe(5);
});
});
});
// Helper to get the state from the hook
@@ -1282,6 +1393,69 @@ Contrary to popular belief, Lorem Ipsum is not simply random text. It has roots
expect(getBufferState(result).text).toBe('Pasted Text');
});
it('should sanitize large text (>5000 chars) and strip unsafe characters', () => {
const { result } = renderHook(() =>
useTextBuffer({ viewport, isValidPath: () => false }),
);
const unsafeChars = '\x07\x08\x0B\x0C';
const largeTextWithUnsafe =
'safe text'.repeat(600) + unsafeChars + 'more safe text';
expect(largeTextWithUnsafe.length).toBeGreaterThan(5000);
act(() =>
result.current.handleInput({
name: '',
ctrl: false,
meta: false,
shift: false,
paste: false,
sequence: largeTextWithUnsafe,
}),
);
const resultText = getBufferState(result).text;
expect(resultText).not.toContain('\x07');
expect(resultText).not.toContain('\x08');
expect(resultText).not.toContain('\x0B');
expect(resultText).not.toContain('\x0C');
expect(resultText).toContain('safe text');
expect(resultText).toContain('more safe text');
});
it('should sanitize large ANSI text (>5000 chars) and strip escape codes', () => {
const { result } = renderHook(() =>
useTextBuffer({ viewport, isValidPath: () => false }),
);
const largeTextWithAnsi =
'\x1B[31m' +
'red text'.repeat(800) +
'\x1B[0m' +
'\x1B[32m' +
'green text'.repeat(200) +
'\x1B[0m';
expect(largeTextWithAnsi.length).toBeGreaterThan(5000);
act(() =>
result.current.handleInput({
name: '',
ctrl: false,
meta: false,
shift: false,
paste: false,
sequence: largeTextWithAnsi,
}),
);
const resultText = getBufferState(result).text;
expect(resultText).not.toContain('\x1B[31m');
expect(resultText).not.toContain('\x1B[32m');
expect(resultText).not.toContain('\x1B[0m');
expect(resultText).toContain('red text');
expect(resultText).toContain('green text');
});
it('should not strip popular emojis', () => {
const { result } = renderHook(() =>
useTextBuffer({ viewport, isValidPath: () => false }),
@@ -1321,6 +1495,53 @@ Contrary to popular belief, Lorem Ipsum is not simply random text. It has roots
expect(stripAnsi('')).toBe('');
});
});
describe('Memoization', () => {
it('should keep action references stable across re-renders', () => {
// We pass a stable `isValidPath` so that callbacks that depend on it
// are not recreated on every render.
const isValidPath = () => false;
const { result, rerender } = renderHook(() =>
useTextBuffer({ viewport, isValidPath }),
);
const initialInsert = result.current.insert;
const initialBackspace = result.current.backspace;
const initialMove = result.current.move;
const initialHandleInput = result.current.handleInput;
rerender();
expect(result.current.insert).toBe(initialInsert);
expect(result.current.backspace).toBe(initialBackspace);
expect(result.current.move).toBe(initialMove);
expect(result.current.handleInput).toBe(initialHandleInput);
});
it('should have memoized actions that operate on the latest state', () => {
const isValidPath = () => false;
const { result } = renderHook(() =>
useTextBuffer({ viewport, isValidPath }),
);
// Store a reference to the memoized insert function.
const memoizedInsert = result.current.insert;
// Update the buffer state.
act(() => {
result.current.insert('hello');
});
expect(getBufferState(result).text).toBe('hello');
// Now, call the original memoized function reference.
act(() => {
memoizedInsert(' world');
});
// It should have operated on the updated state.
expect(getBufferState(result).text).toBe('hello world');
});
});
});
describe('offsetToLogicalPos', () => {

View File

@@ -9,13 +9,13 @@ import fs from 'node:fs';
import os from 'node:os';
import pathMod from 'node:path';
import { useState, useCallback, useEffect, useMemo, useReducer } from 'react';
import stringWidth from 'string-width';
import { unescapePath } from '@qwen-code/qwen-code-core';
import {
toCodePoints,
cpLen,
cpSlice,
stripUnsafeCharacters,
getCachedStringWidth,
} from '../../utils/textUtils.js';
import type { VimAction } from './vim-buffer-actions.js';
import { handleVimAction } from './vim-buffer-actions.js';
@@ -629,21 +629,23 @@ export function logicalPosToOffset(
return offset;
}
// Helper to calculate visual lines and map cursor positions
function calculateVisualLayout(
logicalLines: string[],
logicalCursor: [number, number],
viewportWidth: number,
): {
export interface VisualLayout {
visualLines: string[];
visualCursor: [number, number];
logicalToVisualMap: Array<Array<[number, number]>>; // For each logical line, an array of [visualLineIndex, startColInLogical]
visualToLogicalMap: Array<[number, number]>; // For each visual line, its [logicalLineIndex, startColInLogical]
} {
// For each logical line, an array of [visualLineIndex, startColInLogical]
logicalToVisualMap: Array<Array<[number, number]>>;
// For each visual line, its [logicalLineIndex, startColInLogical]
visualToLogicalMap: Array<[number, number]>;
}
// Calculates the visual wrapping of lines and the mapping between logical and visual coordinates.
// This is an expensive operation and should be memoized.
function calculateLayout(
logicalLines: string[],
viewportWidth: number,
): VisualLayout {
const visualLines: string[] = [];
const logicalToVisualMap: Array<Array<[number, number]>> = [];
const visualToLogicalMap: Array<[number, number]> = [];
let currentVisualCursor: [number, number] = [0, 0];
logicalLines.forEach((logLine, logIndex) => {
logicalToVisualMap[logIndex] = [];
@@ -652,9 +654,6 @@ function calculateVisualLayout(
logicalToVisualMap[logIndex].push([visualLines.length, 0]);
visualToLogicalMap.push([logIndex, 0]);
visualLines.push('');
if (logIndex === logicalCursor[0] && logicalCursor[1] === 0) {
currentVisualCursor = [visualLines.length - 1, 0];
}
} else {
// Non-empty logical line
let currentPosInLogLine = 0; // Tracks position within the current logical line (code point index)
@@ -670,7 +669,7 @@ function calculateVisualLayout(
// Iterate through code points to build the current visual line (chunk)
for (let i = currentPosInLogLine; i < codePointsInLogLine.length; i++) {
const char = codePointsInLogLine[i];
const charVisualWidth = stringWidth(char);
const charVisualWidth = getCachedStringWidth(char);
if (currentChunkVisualWidth + charVisualWidth > viewportWidth) {
// Character would exceed viewport width
@@ -754,30 +753,6 @@ function calculateVisualLayout(
visualToLogicalMap.push([logIndex, currentPosInLogLine]);
visualLines.push(currentChunk);
// Cursor mapping logic
// Note: currentPosInLogLine here is the start of the currentChunk within the logical line.
if (logIndex === logicalCursor[0]) {
const cursorLogCol = logicalCursor[1]; // This is a code point index
if (
cursorLogCol >= currentPosInLogLine &&
cursorLogCol < currentPosInLogLine + numCodePointsInChunk // Cursor is within this chunk
) {
currentVisualCursor = [
visualLines.length - 1,
cursorLogCol - currentPosInLogLine, // Visual col is also code point index within visual line
];
} else if (
cursorLogCol === currentPosInLogLine + numCodePointsInChunk &&
numCodePointsInChunk > 0
) {
// Cursor is exactly at the end of this non-empty chunk
currentVisualCursor = [
visualLines.length - 1,
numCodePointsInChunk,
];
}
}
const logicalStartOfThisChunk = currentPosInLogLine;
currentPosInLogLine += numCodePointsInChunk;
@@ -793,23 +768,6 @@ function calculateVisualLayout(
currentPosInLogLine++;
}
}
// After all chunks of a non-empty logical line are processed,
// if the cursor is at the very end of this logical line, update visual cursor.
if (
logIndex === logicalCursor[0] &&
logicalCursor[1] === codePointsInLogLine.length // Cursor at end of logical line
) {
const lastVisualLineIdx = visualLines.length - 1;
if (
lastVisualLineIdx >= 0 &&
visualLines[lastVisualLineIdx] !== undefined
) {
currentVisualCursor = [
lastVisualLineIdx,
cpLen(visualLines[lastVisualLineIdx]), // Cursor at end of last visual line for this logical line
];
}
}
}
});
@@ -824,27 +782,67 @@ function calculateVisualLayout(
logicalToVisualMap[0].push([0, 0]);
visualToLogicalMap.push([0, 0]);
}
currentVisualCursor = [0, 0];
}
// Handle cursor at the very end of the text (after all processing)
// This case might be covered by the loop end condition now, but kept for safety.
else if (
logicalCursor[0] === logicalLines.length - 1 &&
logicalCursor[1] === cpLen(logicalLines[logicalLines.length - 1]) &&
visualLines.length > 0
) {
const lastVisLineIdx = visualLines.length - 1;
currentVisualCursor = [lastVisLineIdx, cpLen(visualLines[lastVisLineIdx])];
}
return {
visualLines,
visualCursor: currentVisualCursor,
logicalToVisualMap,
visualToLogicalMap,
};
}
// Calculates the visual cursor position based on a pre-calculated layout.
// This is a lightweight operation.
function calculateVisualCursorFromLayout(
layout: VisualLayout,
logicalCursor: [number, number],
): [number, number] {
const { logicalToVisualMap, visualLines } = layout;
const [logicalRow, logicalCol] = logicalCursor;
const segmentsForLogicalLine = logicalToVisualMap[logicalRow];
if (!segmentsForLogicalLine || segmentsForLogicalLine.length === 0) {
// This can happen for an empty document.
return [0, 0];
}
// Find the segment where the logical column fits.
// The segments are sorted by startColInLogical.
let targetSegmentIndex = segmentsForLogicalLine.findIndex(
([, startColInLogical], index) => {
const nextStartColInLogical =
index + 1 < segmentsForLogicalLine.length
? segmentsForLogicalLine[index + 1][1]
: Infinity;
return (
logicalCol >= startColInLogical && logicalCol < nextStartColInLogical
);
},
);
// If not found, it means the cursor is at the end of the logical line.
if (targetSegmentIndex === -1) {
if (logicalCol === 0) {
targetSegmentIndex = 0;
} else {
targetSegmentIndex = segmentsForLogicalLine.length - 1;
}
}
const [visualRow, startColInLogical] =
segmentsForLogicalLine[targetSegmentIndex];
const visualCol = logicalCol - startColInLogical;
// The visual column should not exceed the length of the visual line.
const clampedVisualCol = Math.min(
visualCol,
cpLen(visualLines[visualRow] ?? ''),
);
return [visualRow, clampedVisualCol];
}
// --- Start of reducer logic ---
export interface TextBufferState {
@@ -857,6 +855,8 @@ export interface TextBufferState {
clipboard: string | null;
selectionAnchor: [number, number] | null;
viewportWidth: number;
viewportHeight: number;
visualLayout: VisualLayout;
}
const historyLimit = 100;
@@ -884,6 +884,14 @@ export type TextBufferAction =
dir: Direction;
};
}
| {
type: 'set_cursor';
payload: {
cursorRow: number;
cursorCol: number;
preferredCol: number | null;
};
}
| { type: 'delete' }
| { type: 'delete_word_left' }
| { type: 'delete_word_right' }
@@ -903,7 +911,7 @@ export type TextBufferAction =
}
| { type: 'move_to_offset'; payload: { offset: number } }
| { type: 'create_undo_snapshot' }
| { type: 'set_viewport_width'; payload: number }
| { type: 'set_viewport'; payload: { width: number; height: number } }
| { type: 'vim_delete_word_forward'; payload: { count: number } }
| { type: 'vim_delete_word_backward'; payload: { count: number } }
| { type: 'vim_delete_word_end'; payload: { count: number } }
@@ -941,7 +949,7 @@ export type TextBufferAction =
| { type: 'vim_move_to_line'; payload: { lineNumber: number } }
| { type: 'vim_escape_insert_mode' };
export function textBufferReducer(
function textBufferReducerLogic(
state: TextBufferState,
action: TextBufferAction,
): TextBufferState {
@@ -1047,80 +1055,120 @@ export function textBufferReducer(
};
}
case 'set_viewport_width': {
if (action.payload === state.viewportWidth) {
case 'set_viewport': {
const { width, height } = action.payload;
if (width === state.viewportWidth && height === state.viewportHeight) {
return state;
}
return { ...state, viewportWidth: action.payload };
return {
...state,
viewportWidth: width,
viewportHeight: height,
};
}
case 'move': {
const { dir } = action.payload;
const { lines, cursorRow, cursorCol, viewportWidth } = state;
const visualLayout = calculateVisualLayout(
lines,
[cursorRow, cursorCol],
viewportWidth,
);
const { visualLines, visualCursor, visualToLogicalMap } = visualLayout;
const { cursorRow, cursorCol, lines, visualLayout, preferredCol } = state;
let newVisualRow = visualCursor[0];
let newVisualCol = visualCursor[1];
let newPreferredCol = state.preferredCol;
// Visual movements
if (
dir === 'left' ||
dir === 'right' ||
dir === 'up' ||
dir === 'down' ||
dir === 'home' ||
dir === 'end'
) {
const visualCursor = calculateVisualCursorFromLayout(visualLayout, [
cursorRow,
cursorCol,
]);
const { visualLines, visualToLogicalMap } = visualLayout;
const currentVisLineLen = cpLen(visualLines[newVisualRow] ?? '');
let newVisualRow = visualCursor[0];
let newVisualCol = visualCursor[1];
let newPreferredCol = preferredCol;
switch (dir) {
case 'left':
newPreferredCol = null;
if (newVisualCol > 0) {
newVisualCol--;
} else if (newVisualRow > 0) {
newVisualRow--;
newVisualCol = cpLen(visualLines[newVisualRow] ?? '');
}
break;
case 'right':
newPreferredCol = null;
if (newVisualCol < currentVisLineLen) {
newVisualCol++;
} else if (newVisualRow < visualLines.length - 1) {
newVisualRow++;
const currentVisLineLen = cpLen(visualLines[newVisualRow] ?? '');
switch (dir) {
case 'left':
newPreferredCol = null;
if (newVisualCol > 0) {
newVisualCol--;
} else if (newVisualRow > 0) {
newVisualRow--;
newVisualCol = cpLen(visualLines[newVisualRow] ?? '');
}
break;
case 'right':
newPreferredCol = null;
if (newVisualCol < currentVisLineLen) {
newVisualCol++;
} else if (newVisualRow < visualLines.length - 1) {
newVisualRow++;
newVisualCol = 0;
}
break;
case 'up':
if (newVisualRow > 0) {
if (newPreferredCol === null) newPreferredCol = newVisualCol;
newVisualRow--;
newVisualCol = clamp(
newPreferredCol,
0,
cpLen(visualLines[newVisualRow] ?? ''),
);
}
break;
case 'down':
if (newVisualRow < visualLines.length - 1) {
if (newPreferredCol === null) newPreferredCol = newVisualCol;
newVisualRow++;
newVisualCol = clamp(
newPreferredCol,
0,
cpLen(visualLines[newVisualRow] ?? ''),
);
}
break;
case 'home':
newPreferredCol = null;
newVisualCol = 0;
}
break;
case 'up':
if (newVisualRow > 0) {
if (newPreferredCol === null) newPreferredCol = newVisualCol;
newVisualRow--;
newVisualCol = clamp(
newPreferredCol,
0,
cpLen(visualLines[newVisualRow] ?? ''),
break;
case 'end':
newPreferredCol = null;
newVisualCol = currentVisLineLen;
break;
default: {
const exhaustiveCheck: never = dir;
console.error(
`Unknown visual movement direction: ${exhaustiveCheck}`,
);
return state;
}
break;
case 'down':
if (newVisualRow < visualLines.length - 1) {
if (newPreferredCol === null) newPreferredCol = newVisualCol;
newVisualRow++;
newVisualCol = clamp(
newPreferredCol,
}
if (visualToLogicalMap[newVisualRow]) {
const [logRow, logStartCol] = visualToLogicalMap[newVisualRow];
return {
...state,
cursorRow: logRow,
cursorCol: clamp(
logStartCol + newVisualCol,
0,
cpLen(visualLines[newVisualRow] ?? ''),
);
}
break;
case 'home':
newPreferredCol = null;
newVisualCol = 0;
break;
case 'end':
newPreferredCol = null;
newVisualCol = currentVisLineLen;
break;
cpLen(lines[logRow] ?? ''),
),
preferredCol: newPreferredCol,
};
}
return state;
}
// Logical movements
switch (dir) {
case 'wordLeft': {
const { cursorRow, cursorCol, lines } = state;
if (cursorCol === 0 && cursorRow === 0) return state;
let newCursorRow = cursorRow;
@@ -1156,7 +1204,6 @@ export function textBufferReducer(
};
}
case 'wordRight': {
const { cursorRow, cursorCol, lines } = state;
if (
cursorRow === lines.length - 1 &&
cursorCol === cpLen(lines[cursorRow] ?? '')
@@ -1186,23 +1233,15 @@ export function textBufferReducer(
};
}
default:
break;
return state;
}
}
if (visualToLogicalMap[newVisualRow]) {
const [logRow, logStartCol] = visualToLogicalMap[newVisualRow];
return {
...state,
cursorRow: logRow,
cursorCol: clamp(
logStartCol + newVisualCol,
0,
cpLen(state.lines[logRow] ?? ''),
),
preferredCol: newPreferredCol,
};
}
return state;
case 'set_cursor': {
return {
...state,
...action.payload,
};
}
case 'delete': {
@@ -1214,14 +1253,22 @@ export function textBufferReducer(
newLines[cursorRow] =
cpSlice(lineContent, 0, cursorCol) +
cpSlice(lineContent, cursorCol + 1);
return { ...nextState, lines: newLines, preferredCol: null };
return {
...nextState,
lines: newLines,
preferredCol: null,
};
} else if (cursorRow < lines.length - 1) {
const nextState = pushUndoLocal(state);
const nextLineContent = currentLine(cursorRow + 1);
const newLines = [...nextState.lines];
newLines[cursorRow] = lineContent + nextLineContent;
newLines.splice(cursorRow + 1, 1);
return { ...nextState, lines: newLines, preferredCol: null };
return {
...nextState,
lines: newLines,
preferredCol: null,
};
}
return state;
}
@@ -1229,47 +1276,38 @@ export function textBufferReducer(
case 'delete_word_left': {
const { cursorRow, cursorCol } = state;
if (cursorCol === 0 && cursorRow === 0) return state;
if (cursorCol === 0) {
const nextState = pushUndoLocal(state);
const newLines = [...nextState.lines];
let newCursorRow = cursorRow;
let newCursorCol = cursorCol;
if (newCursorCol > 0) {
const lineContent = currentLine(newCursorRow);
const prevWordStart = findPrevWordStartInLine(
lineContent,
newCursorCol,
);
const start = prevWordStart === null ? 0 : prevWordStart;
newLines[newCursorRow] =
cpSlice(lineContent, 0, start) + cpSlice(lineContent, newCursorCol);
newCursorCol = start;
} else {
// Act as a backspace
const nextState = pushUndoLocal(state);
const prevLineContent = currentLine(cursorRow - 1);
const currentLineContentVal = currentLine(cursorRow);
const newCol = cpLen(prevLineContent);
const newLines = [...nextState.lines];
newLines[cursorRow - 1] = prevLineContent + currentLineContentVal;
newLines.splice(cursorRow, 1);
return {
...nextState,
lines: newLines,
cursorRow: cursorRow - 1,
cursorCol: newCol,
preferredCol: null,
};
newCursorRow--;
newCursorCol = newCol;
}
const nextState = pushUndoLocal(state);
const lineContent = currentLine(cursorRow);
const arr = toCodePoints(lineContent);
let start = cursorCol;
let onlySpaces = true;
for (let i = 0; i < start; i++) {
if (isWordChar(arr[i])) {
onlySpaces = false;
break;
}
}
if (onlySpaces && start > 0) {
start--;
} else {
while (start > 0 && !isWordChar(arr[start - 1])) start--;
while (start > 0 && isWordChar(arr[start - 1])) start--;
}
const newLines = [...nextState.lines];
newLines[cursorRow] =
cpSlice(lineContent, 0, start) + cpSlice(lineContent, cursorCol);
return {
...nextState,
lines: newLines,
cursorCol: start,
cursorRow: newCursorRow,
cursorCol: newCursorCol,
preferredCol: null,
};
}
@@ -1277,26 +1315,32 @@ export function textBufferReducer(
case 'delete_word_right': {
const { cursorRow, cursorCol, lines } = state;
const lineContent = currentLine(cursorRow);
const arr = toCodePoints(lineContent);
if (cursorCol >= arr.length && cursorRow === lines.length - 1)
const lineLen = cpLen(lineContent);
if (cursorCol >= lineLen && cursorRow === lines.length - 1) {
return state;
if (cursorCol >= arr.length) {
// Act as a delete
const nextState = pushUndoLocal(state);
}
const nextState = pushUndoLocal(state);
const newLines = [...nextState.lines];
if (cursorCol >= lineLen) {
// Act as a delete, joining with the next line
const nextLineContent = currentLine(cursorRow + 1);
const newLines = [...nextState.lines];
newLines[cursorRow] = lineContent + nextLineContent;
newLines.splice(cursorRow + 1, 1);
return { ...nextState, lines: newLines, preferredCol: null };
} else {
const nextWordStart = findNextWordStartInLine(lineContent, cursorCol);
const end = nextWordStart === null ? lineLen : nextWordStart;
newLines[cursorRow] =
cpSlice(lineContent, 0, cursorCol) + cpSlice(lineContent, end);
}
const nextState = pushUndoLocal(state);
let end = cursorCol;
while (end < arr.length && !isWordChar(arr[end])) end++;
while (end < arr.length && isWordChar(arr[end])) end++;
const newLines = [...nextState.lines];
newLines[cursorRow] =
cpSlice(lineContent, 0, cursorCol) + cpSlice(lineContent, end);
return { ...nextState, lines: newLines, preferredCol: null };
return {
...nextState,
lines: newLines,
preferredCol: null,
};
}
case 'kill_line_right': {
@@ -1306,7 +1350,10 @@ export function textBufferReducer(
const nextState = pushUndoLocal(state);
const newLines = [...nextState.lines];
newLines[cursorRow] = cpSlice(lineContent, 0, cursorCol);
return { ...nextState, lines: newLines };
return {
...nextState,
lines: newLines,
};
} else if (cursorRow < lines.length - 1) {
// Act as a delete
const nextState = pushUndoLocal(state);
@@ -1314,7 +1361,11 @@ export function textBufferReducer(
const newLines = [...nextState.lines];
newLines[cursorRow] = lineContent + nextLineContent;
newLines.splice(cursorRow + 1, 1);
return { ...nextState, lines: newLines, preferredCol: null };
return {
...nextState,
lines: newLines,
preferredCol: null,
};
}
return state;
}
@@ -1444,6 +1495,25 @@ export function textBufferReducer(
}
}
export function textBufferReducer(
state: TextBufferState,
action: TextBufferAction,
): TextBufferState {
const newState = textBufferReducerLogic(state, action);
if (
newState.lines !== state.lines ||
newState.viewportWidth !== state.viewportWidth
) {
return {
...newState,
visualLayout: calculateLayout(newState.lines, newState.viewportWidth),
};
}
return newState;
}
// --- End of reducer logic ---
export function useTextBuffer({
@@ -1462,6 +1532,10 @@ export function useTextBuffer({
lines.length === 0 ? [''] : lines,
initialCursorOffset,
);
const visualLayout = calculateLayout(
lines.length === 0 ? [''] : lines,
viewport.width,
);
return {
lines: lines.length === 0 ? [''] : lines,
cursorRow: initialCursorRow,
@@ -1472,21 +1546,29 @@ export function useTextBuffer({
clipboard: null,
selectionAnchor: null,
viewportWidth: viewport.width,
viewportHeight: viewport.height,
visualLayout,
};
}, [initialText, initialCursorOffset, viewport.width]);
}, [initialText, initialCursorOffset, viewport.width, viewport.height]);
const [state, dispatch] = useReducer(textBufferReducer, initialState);
const { lines, cursorRow, cursorCol, preferredCol, selectionAnchor } = state;
const {
lines,
cursorRow,
cursorCol,
preferredCol,
selectionAnchor,
visualLayout,
} = state;
const text = useMemo(() => lines.join('\n'), [lines]);
const visualLayout = useMemo(
() =>
calculateVisualLayout(lines, [cursorRow, cursorCol], state.viewportWidth),
[lines, cursorRow, cursorCol, state.viewportWidth],
const visualCursor = useMemo(
() => calculateVisualCursorFromLayout(visualLayout, [cursorRow, cursorCol]),
[visualLayout, cursorRow, cursorCol],
);
const { visualLines, visualCursor } = visualLayout;
const { visualLines, visualToLogicalMap } = visualLayout;
const [visualScrollRow, setVisualScrollRow] = useState<number>(0);
@@ -1497,12 +1579,17 @@ export function useTextBuffer({
}, [text, onChange]);
useEffect(() => {
dispatch({ type: 'set_viewport_width', payload: viewport.width });
}, [viewport.width]);
dispatch({
type: 'set_viewport',
payload: { width: viewport.width, height: viewport.height },
});
}, [viewport.width, viewport.height]);
// Update visual scroll (vertical)
useEffect(() => {
const { height } = viewport;
const totalVisualLines = visualLines.length;
const maxScrollStart = Math.max(0, totalVisualLines - height);
let newVisualScrollRow = visualScrollRow;
if (visualCursor[0] < visualScrollRow) {
@@ -1510,10 +1597,15 @@ export function useTextBuffer({
} else if (visualCursor[0] >= visualScrollRow + height) {
newVisualScrollRow = visualCursor[0] - height + 1;
}
// When the number of visual lines shrinks (e.g., after widening the viewport),
// ensure scroll never starts beyond the last valid start so we can render a full window.
newVisualScrollRow = clamp(newVisualScrollRow, 0, maxScrollStart);
if (newVisualScrollRow !== visualScrollRow) {
setVisualScrollRow(newVisualScrollRow);
}
}, [visualCursor, visualScrollRow, viewport]);
}, [visualCursor, visualScrollRow, viewport, visualLines.length]);
const insert = useCallback(
(ch: string, { paste = false }: { paste?: boolean } = {}): void => {
@@ -1571,9 +1663,12 @@ export function useTextBuffer({
dispatch({ type: 'delete' });
}, []);
const move = useCallback((dir: Direction): void => {
dispatch({ type: 'move', payload: { dir } });
}, []);
const move = useCallback(
(dir: Direction): void => {
dispatch({ type: 'move', payload: { dir } });
},
[dispatch],
);
const undo = useCallback((): void => {
dispatch({ type: 'undo' });
@@ -1836,11 +1931,23 @@ export function useTextBuffer({
)
backspace();
else if (key.name === 'delete' || (key.ctrl && key.name === 'd')) del();
else if (key.ctrl && !key.shift && key.name === 'z') undo();
else if (key.ctrl && key.shift && key.name === 'z') redo();
else if (input && !key.ctrl && !key.meta) {
insert(input, { paste: key.paste });
}
},
[newline, move, deleteWordLeft, deleteWordRight, backspace, del, insert],
[
newline,
move,
deleteWordLeft,
deleteWordRight,
backspace,
del,
insert,
undo,
redo,
],
);
const renderedVisualLines = useMemo(
@@ -1877,69 +1984,135 @@ export function useTextBuffer({
dispatch({ type: 'move_to_offset', payload: { offset } });
}, []);
const returnValue: TextBuffer = {
lines,
text,
cursor: [cursorRow, cursorCol],
preferredCol,
selectionAnchor,
const returnValue: TextBuffer = useMemo(
() => ({
lines,
text,
cursor: [cursorRow, cursorCol],
preferredCol,
selectionAnchor,
allVisualLines: visualLines,
viewportVisualLines: renderedVisualLines,
visualCursor,
visualScrollRow,
allVisualLines: visualLines,
viewportVisualLines: renderedVisualLines,
visualCursor,
visualScrollRow,
visualToLogicalMap,
setText,
insert,
newline,
backspace,
del,
move,
undo,
redo,
replaceRange,
replaceRangeByOffset,
moveToOffset,
deleteWordLeft,
deleteWordRight,
killLineRight,
killLineLeft,
handleInput,
openInExternalEditor,
// Vim-specific operations
vimDeleteWordForward,
vimDeleteWordBackward,
vimDeleteWordEnd,
vimChangeWordForward,
vimChangeWordBackward,
vimChangeWordEnd,
vimDeleteLine,
vimChangeLine,
vimDeleteToEndOfLine,
vimChangeToEndOfLine,
vimChangeMovement,
vimMoveLeft,
vimMoveRight,
vimMoveUp,
vimMoveDown,
vimMoveWordForward,
vimMoveWordBackward,
vimMoveWordEnd,
vimDeleteChar,
vimInsertAtCursor,
vimAppendAtCursor,
vimOpenLineBelow,
vimOpenLineAbove,
vimAppendAtLineEnd,
vimInsertAtLineStart,
vimMoveToLineStart,
vimMoveToLineEnd,
vimMoveToFirstNonWhitespace,
vimMoveToFirstLine,
vimMoveToLastLine,
vimMoveToLine,
vimEscapeInsertMode,
};
setText,
insert,
newline,
backspace,
del,
move,
undo,
redo,
replaceRange,
replaceRangeByOffset,
moveToOffset,
deleteWordLeft,
deleteWordRight,
killLineRight,
killLineLeft,
handleInput,
openInExternalEditor,
// Vim-specific operations
vimDeleteWordForward,
vimDeleteWordBackward,
vimDeleteWordEnd,
vimChangeWordForward,
vimChangeWordBackward,
vimChangeWordEnd,
vimDeleteLine,
vimChangeLine,
vimDeleteToEndOfLine,
vimChangeToEndOfLine,
vimChangeMovement,
vimMoveLeft,
vimMoveRight,
vimMoveUp,
vimMoveDown,
vimMoveWordForward,
vimMoveWordBackward,
vimMoveWordEnd,
vimDeleteChar,
vimInsertAtCursor,
vimAppendAtCursor,
vimOpenLineBelow,
vimOpenLineAbove,
vimAppendAtLineEnd,
vimInsertAtLineStart,
vimMoveToLineStart,
vimMoveToLineEnd,
vimMoveToFirstNonWhitespace,
vimMoveToFirstLine,
vimMoveToLastLine,
vimMoveToLine,
vimEscapeInsertMode,
}),
[
lines,
text,
cursorRow,
cursorCol,
preferredCol,
selectionAnchor,
visualLines,
renderedVisualLines,
visualCursor,
visualScrollRow,
setText,
insert,
newline,
backspace,
del,
move,
undo,
redo,
replaceRange,
replaceRangeByOffset,
moveToOffset,
deleteWordLeft,
deleteWordRight,
killLineRight,
killLineLeft,
handleInput,
openInExternalEditor,
vimDeleteWordForward,
vimDeleteWordBackward,
vimDeleteWordEnd,
vimChangeWordForward,
vimChangeWordBackward,
vimChangeWordEnd,
vimDeleteLine,
vimChangeLine,
vimDeleteToEndOfLine,
vimChangeToEndOfLine,
vimChangeMovement,
vimMoveLeft,
vimMoveRight,
vimMoveUp,
vimMoveDown,
vimMoveWordForward,
vimMoveWordBackward,
vimMoveWordEnd,
vimDeleteChar,
vimInsertAtCursor,
vimAppendAtCursor,
vimOpenLineBelow,
vimOpenLineAbove,
vimAppendAtLineEnd,
vimInsertAtLineStart,
vimMoveToLineStart,
vimMoveToLineEnd,
vimMoveToFirstNonWhitespace,
vimMoveToFirstLine,
vimMoveToLastLine,
vimMoveToLine,
vimEscapeInsertMode,
visualToLogicalMap,
],
);
return returnValue;
}
@@ -1962,6 +2135,12 @@ export interface TextBuffer {
viewportVisualLines: string[]; // The subset of visual lines to be rendered based on visualScrollRow and viewport.height
visualCursor: [number, number]; // Visual cursor [row, col] relative to the start of all visualLines
visualScrollRow: number; // Scroll position for visual lines (index of the first visible visual line)
/**
* For each visual line (by absolute index in allVisualLines) provides a tuple
* [logicalLineIndex, startColInLogical] that maps where that visual line
* begins within the logical buffer. Indices are code-point based.
*/
visualToLogicalMap: Array<[number, number]>;
// Actions
@@ -2011,6 +2190,7 @@ export interface TextBuffer {
* follows the caret and the next contiguous run of word characters.
*/
deleteWordRight: () => void;
/**
* Deletes text from the cursor to the end of the current line.
*/