mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-05-13 05:12:55 -07:00
feat(cli): enable mouse clicking for cursor positioning in AskUser multi-line answers (#24630)
This commit is contained in:
@@ -4,7 +4,7 @@
|
|||||||
* SPDX-License-Identifier: Apache-2.0
|
* SPDX-License-Identifier: Apache-2.0
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { render } from '../../test-utils/render.js';
|
import { renderWithProviders } from '../../test-utils/render.js';
|
||||||
import { waitFor } from '../../test-utils/async.js';
|
import { waitFor } from '../../test-utils/async.js';
|
||||||
import { describe, it, expect, vi, beforeEach, type Mock } from 'vitest';
|
import { describe, it, expect, vi, beforeEach, type Mock } from 'vitest';
|
||||||
import { ApiAuthDialog } from './ApiAuthDialog.js';
|
import { ApiAuthDialog } from './ApiAuthDialog.js';
|
||||||
@@ -40,11 +40,16 @@ vi.mock('../components/shared/text-buffer.js', async (importOriginal) => {
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
vi.mock('../contexts/UIStateContext.js', () => ({
|
vi.mock('../contexts/UIStateContext.js', async (importOriginal) => {
|
||||||
useUIState: vi.fn(() => ({
|
const actual =
|
||||||
terminalWidth: 80,
|
await importOriginal<typeof import('../contexts/UIStateContext.js')>();
|
||||||
})),
|
return {
|
||||||
}));
|
...actual,
|
||||||
|
useUIState: vi.fn(() => ({
|
||||||
|
terminalWidth: 80,
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
const mockedUseKeypress = useKeypress as Mock;
|
const mockedUseKeypress = useKeypress as Mock;
|
||||||
const mockedUseTextBuffer = useTextBuffer as Mock;
|
const mockedUseTextBuffer = useTextBuffer as Mock;
|
||||||
@@ -73,7 +78,7 @@ describe('ApiAuthDialog', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('renders correctly', async () => {
|
it('renders correctly', async () => {
|
||||||
const { lastFrame, unmount } = await render(
|
const { lastFrame, unmount } = await renderWithProviders(
|
||||||
<ApiAuthDialog onSubmit={onSubmit} onCancel={onCancel} />,
|
<ApiAuthDialog onSubmit={onSubmit} onCancel={onCancel} />,
|
||||||
);
|
);
|
||||||
expect(lastFrame()).toMatchSnapshot();
|
expect(lastFrame()).toMatchSnapshot();
|
||||||
@@ -81,7 +86,7 @@ describe('ApiAuthDialog', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('renders with a defaultValue', async () => {
|
it('renders with a defaultValue', async () => {
|
||||||
const { unmount } = await render(
|
const { unmount } = await renderWithProviders(
|
||||||
<ApiAuthDialog
|
<ApiAuthDialog
|
||||||
onSubmit={onSubmit}
|
onSubmit={onSubmit}
|
||||||
onCancel={onCancel}
|
onCancel={onCancel}
|
||||||
@@ -111,7 +116,7 @@ describe('ApiAuthDialog', () => {
|
|||||||
'calls $expectedCall.name when $keyName is pressed',
|
'calls $expectedCall.name when $keyName is pressed',
|
||||||
async ({ keyName, sequence, expectedCall, args }) => {
|
async ({ keyName, sequence, expectedCall, args }) => {
|
||||||
mockBuffer.text = 'submitted-key'; // Set for the onSubmit case
|
mockBuffer.text = 'submitted-key'; // Set for the onSubmit case
|
||||||
const { unmount } = await render(
|
const { unmount } = await renderWithProviders(
|
||||||
<ApiAuthDialog onSubmit={onSubmit} onCancel={onCancel} />,
|
<ApiAuthDialog onSubmit={onSubmit} onCancel={onCancel} />,
|
||||||
);
|
);
|
||||||
// calls[0] is the ApiAuthDialog's useKeypress (Ctrl+C handler)
|
// calls[0] is the ApiAuthDialog's useKeypress (Ctrl+C handler)
|
||||||
@@ -133,7 +138,7 @@ describe('ApiAuthDialog', () => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
it('displays an error message', async () => {
|
it('displays an error message', async () => {
|
||||||
const { lastFrame, unmount } = await render(
|
const { lastFrame, unmount } = await renderWithProviders(
|
||||||
<ApiAuthDialog
|
<ApiAuthDialog
|
||||||
onSubmit={onSubmit}
|
onSubmit={onSubmit}
|
||||||
onCancel={onCancel}
|
onCancel={onCancel}
|
||||||
@@ -146,7 +151,7 @@ describe('ApiAuthDialog', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('calls clearApiKey and clears buffer when Ctrl+C is pressed', async () => {
|
it('calls clearApiKey and clears buffer when Ctrl+C is pressed', async () => {
|
||||||
const { unmount } = await render(
|
const { unmount } = await renderWithProviders(
|
||||||
<ApiAuthDialog onSubmit={onSubmit} onCancel={onCancel} />,
|
<ApiAuthDialog onSubmit={onSubmit} onCancel={onCancel} />,
|
||||||
);
|
);
|
||||||
// Call 0 is ApiAuthDialog (isActive: true)
|
// Call 0 is ApiAuthDialog (isActive: true)
|
||||||
|
|||||||
@@ -13,7 +13,8 @@ import {
|
|||||||
useReducer,
|
useReducer,
|
||||||
useContext,
|
useContext,
|
||||||
} from 'react';
|
} from 'react';
|
||||||
import { Box, Text } from 'ink';
|
import { Box, Text, type DOMElement } from 'ink';
|
||||||
|
import { useMouseClick } from '../hooks/useMouseClick.js';
|
||||||
import { theme } from '../semantic-colors.js';
|
import { theme } from '../semantic-colors.js';
|
||||||
import { checkExhaustive, type Question } from '@google/gemini-cli-core';
|
import { checkExhaustive, type Question } from '@google/gemini-cli-core';
|
||||||
import { BaseSelectionList } from './shared/BaseSelectionList.js';
|
import { BaseSelectionList } from './shared/BaseSelectionList.js';
|
||||||
@@ -85,6 +86,24 @@ function autoBoldIfPlain(text: string): string {
|
|||||||
return text;
|
return text;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const ClickableCheckbox: React.FC<{
|
||||||
|
isChecked: boolean;
|
||||||
|
onClick: () => void;
|
||||||
|
}> = ({ isChecked, onClick }) => {
|
||||||
|
const ref = useRef<DOMElement>(null);
|
||||||
|
useMouseClick(ref, () => {
|
||||||
|
onClick();
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box ref={ref}>
|
||||||
|
<Text color={isChecked ? theme.status.success : theme.text.secondary}>
|
||||||
|
[{isChecked ? 'x' : ' '}]
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
interface AskUserDialogState {
|
interface AskUserDialogState {
|
||||||
answers: { [key: string]: string };
|
answers: { [key: string]: string };
|
||||||
isEditingCustomOption: boolean;
|
isEditingCustomOption: boolean;
|
||||||
@@ -919,13 +938,14 @@ const ChoiceQuestionView: React.FC<ChoiceQuestionViewProps> = ({
|
|||||||
return (
|
return (
|
||||||
<Box flexDirection="row">
|
<Box flexDirection="row">
|
||||||
{showCheck && (
|
{showCheck && (
|
||||||
<Text
|
<ClickableCheckbox
|
||||||
color={
|
isChecked={isChecked}
|
||||||
isChecked ? theme.status.success : theme.text.secondary
|
onClick={() => {
|
||||||
}
|
if (!context.isSelected) {
|
||||||
>
|
handleSelect(optionItem);
|
||||||
[{isChecked ? 'x' : ' '}]
|
}
|
||||||
</Text>
|
}}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
<Text color={theme.text.primary}> </Text>
|
<Text color={theme.text.primary}> </Text>
|
||||||
<TextInput
|
<TextInput
|
||||||
@@ -966,13 +986,14 @@ const ChoiceQuestionView: React.FC<ChoiceQuestionViewProps> = ({
|
|||||||
<Box flexDirection="column">
|
<Box flexDirection="column">
|
||||||
<Box flexDirection="row">
|
<Box flexDirection="row">
|
||||||
{showCheck && (
|
{showCheck && (
|
||||||
<Text
|
<ClickableCheckbox
|
||||||
color={
|
isChecked={isChecked}
|
||||||
isChecked ? theme.status.success : theme.text.secondary
|
onClick={() => {
|
||||||
}
|
if (!context.isSelected) {
|
||||||
>
|
handleSelect(optionItem);
|
||||||
[{isChecked ? 'x' : ' '}]
|
}
|
||||||
</Text>
|
}}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
<Text color={labelColor} bold={optionItem.type === 'done'}>
|
<Text color={labelColor} bold={optionItem.type === 'done'}>
|
||||||
{' '}
|
{' '}
|
||||||
|
|||||||
@@ -4,7 +4,8 @@
|
|||||||
* SPDX-License-Identifier: Apache-2.0
|
* SPDX-License-Identifier: Apache-2.0
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
import { describe, it, expect, vi, beforeEach, type Mock } from 'vitest';
|
||||||
|
import { act } from 'react';
|
||||||
import { renderWithProviders } from '../../../test-utils/render.js';
|
import { renderWithProviders } from '../../../test-utils/render.js';
|
||||||
import {
|
import {
|
||||||
BaseSelectionList,
|
BaseSelectionList,
|
||||||
@@ -14,8 +15,10 @@ import {
|
|||||||
import { useSelectionList } from '../../hooks/useSelectionList.js';
|
import { useSelectionList } from '../../hooks/useSelectionList.js';
|
||||||
import { Text } from 'ink';
|
import { Text } from 'ink';
|
||||||
import type { theme } from '../../semantic-colors.js';
|
import type { theme } from '../../semantic-colors.js';
|
||||||
|
import { useMouseClick } from '../../hooks/useMouseClick.js';
|
||||||
|
|
||||||
vi.mock('../../hooks/useSelectionList.js');
|
vi.mock('../../hooks/useSelectionList.js');
|
||||||
|
vi.mock('../../hooks/useMouseClick.js');
|
||||||
|
|
||||||
const mockTheme = {
|
const mockTheme = {
|
||||||
text: { primary: 'COLOR_PRIMARY', secondary: 'COLOR_SECONDARY' },
|
text: { primary: 'COLOR_PRIMARY', secondary: 'COLOR_SECONDARY' },
|
||||||
@@ -35,6 +38,7 @@ describe('BaseSelectionList', () => {
|
|||||||
const mockOnSelect = vi.fn();
|
const mockOnSelect = vi.fn();
|
||||||
const mockOnHighlight = vi.fn();
|
const mockOnHighlight = vi.fn();
|
||||||
const mockRenderItem = vi.fn();
|
const mockRenderItem = vi.fn();
|
||||||
|
const mockSetActiveIndex = vi.fn();
|
||||||
|
|
||||||
const items = [
|
const items = [
|
||||||
{ value: 'A', label: 'Item A', key: 'A' },
|
{ value: 'A', label: 'Item A', key: 'A' },
|
||||||
@@ -54,7 +58,7 @@ describe('BaseSelectionList', () => {
|
|||||||
) => {
|
) => {
|
||||||
vi.mocked(useSelectionList).mockReturnValue({
|
vi.mocked(useSelectionList).mockReturnValue({
|
||||||
activeIndex,
|
activeIndex,
|
||||||
setActiveIndex: vi.fn(),
|
setActiveIndex: mockSetActiveIndex,
|
||||||
});
|
});
|
||||||
|
|
||||||
mockRenderItem.mockImplementation(
|
mockRenderItem.mockImplementation(
|
||||||
@@ -484,6 +488,79 @@ describe('BaseSelectionList', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('Mouse Interaction', () => {
|
||||||
|
it('should register mouse click handler for each item', async () => {
|
||||||
|
const { unmount } = await renderComponent();
|
||||||
|
|
||||||
|
// items are A, B (disabled), C
|
||||||
|
expect(useMouseClick).toHaveBeenCalledTimes(3);
|
||||||
|
unmount();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should update activeIndex on first click and call onSelect on second click', async () => {
|
||||||
|
const { unmount, waitUntilReady } = await renderComponent();
|
||||||
|
await waitUntilReady();
|
||||||
|
|
||||||
|
// items[0] is 'A' (enabled)
|
||||||
|
// items[1] is 'B' (disabled)
|
||||||
|
// items[2] is 'C' (enabled)
|
||||||
|
|
||||||
|
// Get the mouse click handler for the third item (index 2)
|
||||||
|
const mouseClickHandler = (useMouseClick as Mock).mock.calls[2][1];
|
||||||
|
|
||||||
|
// First click on item C
|
||||||
|
act(() => {
|
||||||
|
mouseClickHandler();
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(mockSetActiveIndex).toHaveBeenCalledWith(2);
|
||||||
|
expect(mockOnSelect).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
// Now simulate being on item C (isSelected = true)
|
||||||
|
// Rerender or update mocks for the next check
|
||||||
|
await renderComponent({}, 2);
|
||||||
|
|
||||||
|
// Get the updated mouse click handler for item C
|
||||||
|
// useMouseClick is called 3 more times on rerender
|
||||||
|
const updatedMouseClickHandler = (useMouseClick as Mock).mock.calls[5][1];
|
||||||
|
|
||||||
|
// Second click on item C
|
||||||
|
act(() => {
|
||||||
|
updatedMouseClickHandler();
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(mockOnSelect).toHaveBeenCalledWith('C');
|
||||||
|
unmount();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not call onSelect when a disabled item is clicked', async () => {
|
||||||
|
const { unmount, waitUntilReady } = await renderComponent();
|
||||||
|
await waitUntilReady();
|
||||||
|
|
||||||
|
// items[1] is 'B' (disabled)
|
||||||
|
const mouseClickHandler = (useMouseClick as Mock).mock.calls[1][1];
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
mouseClickHandler();
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(mockSetActiveIndex).not.toHaveBeenCalled();
|
||||||
|
expect(mockOnSelect).not.toHaveBeenCalled();
|
||||||
|
unmount();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should pass isActive: isFocused to useMouseClick', async () => {
|
||||||
|
const { unmount } = await renderComponent({ isFocused: false });
|
||||||
|
|
||||||
|
expect(useMouseClick).toHaveBeenCalledWith(
|
||||||
|
expect.any(Object),
|
||||||
|
expect.any(Function),
|
||||||
|
{ isActive: false },
|
||||||
|
);
|
||||||
|
unmount();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('Scroll Arrows (showScrollArrows)', () => {
|
describe('Scroll Arrows (showScrollArrows)', () => {
|
||||||
const longList = Array.from({ length: 10 }, (_, i) => ({
|
const longList = Array.from({ length: 10 }, (_, i) => ({
|
||||||
value: `Item ${i + 1}`,
|
value: `Item ${i + 1}`,
|
||||||
|
|||||||
@@ -5,13 +5,14 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import type React from 'react';
|
import type React from 'react';
|
||||||
import { useState } from 'react';
|
import { useState, useRef } from 'react';
|
||||||
import { Text, Box } from 'ink';
|
import { Text, Box, type DOMElement } from 'ink';
|
||||||
import { theme } from '../../semantic-colors.js';
|
import { theme } from '../../semantic-colors.js';
|
||||||
import {
|
import {
|
||||||
useSelectionList,
|
useSelectionList,
|
||||||
type SelectionListItem,
|
type SelectionListItem,
|
||||||
} from '../../hooks/useSelectionList.js';
|
} from '../../hooks/useSelectionList.js';
|
||||||
|
import { useMouseClick } from '../../hooks/useMouseClick.js';
|
||||||
|
|
||||||
export interface RenderItemContext {
|
export interface RenderItemContext {
|
||||||
isSelected: boolean;
|
isSelected: boolean;
|
||||||
@@ -38,6 +39,119 @@ export interface BaseSelectionListProps<
|
|||||||
renderItem: (item: TItem, context: RenderItemContext) => React.ReactNode;
|
renderItem: (item: TItem, context: RenderItemContext) => React.ReactNode;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface SelectionListItemRowProps<
|
||||||
|
T,
|
||||||
|
TItem extends SelectionListItem<T> = SelectionListItem<T>,
|
||||||
|
> {
|
||||||
|
item: TItem;
|
||||||
|
itemIndex: number;
|
||||||
|
isSelected: boolean;
|
||||||
|
isFocused: boolean;
|
||||||
|
showNumbers: boolean;
|
||||||
|
selectedIndicator: string;
|
||||||
|
numberColumnWidth: number;
|
||||||
|
onSelect: (value: T) => void;
|
||||||
|
setActiveIndex: (index: number) => void;
|
||||||
|
renderItem: (item: TItem, context: RenderItemContext) => React.ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
function SelectionListItemRow<
|
||||||
|
T,
|
||||||
|
TItem extends SelectionListItem<T> = SelectionListItem<T>,
|
||||||
|
>({
|
||||||
|
item,
|
||||||
|
itemIndex,
|
||||||
|
isSelected,
|
||||||
|
isFocused,
|
||||||
|
showNumbers,
|
||||||
|
selectedIndicator,
|
||||||
|
numberColumnWidth,
|
||||||
|
onSelect,
|
||||||
|
setActiveIndex,
|
||||||
|
renderItem,
|
||||||
|
}: SelectionListItemRowProps<T, TItem>) {
|
||||||
|
const containerRef = useRef<DOMElement>(null);
|
||||||
|
|
||||||
|
useMouseClick(
|
||||||
|
containerRef,
|
||||||
|
() => {
|
||||||
|
if (!item.disabled) {
|
||||||
|
if (isSelected) {
|
||||||
|
// Second click on the same item triggers submission
|
||||||
|
onSelect(item.value);
|
||||||
|
} else {
|
||||||
|
// First click highlights the item
|
||||||
|
setActiveIndex(itemIndex);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ isActive: isFocused },
|
||||||
|
);
|
||||||
|
|
||||||
|
let titleColor = theme.text.primary;
|
||||||
|
let numberColor = theme.text.primary;
|
||||||
|
|
||||||
|
if (isSelected) {
|
||||||
|
titleColor = theme.ui.focus;
|
||||||
|
numberColor = theme.ui.focus;
|
||||||
|
} 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
|
||||||
|
ref={containerRef}
|
||||||
|
key={item.key}
|
||||||
|
alignItems="flex-start"
|
||||||
|
backgroundColor={isSelected ? theme.background.focus : undefined}
|
||||||
|
>
|
||||||
|
{/* Radio button indicator */}
|
||||||
|
<Box minWidth={2} flexShrink={0}>
|
||||||
|
<Text
|
||||||
|
color={isSelected ? theme.ui.focus : theme.text.primary}
|
||||||
|
aria-hidden
|
||||||
|
>
|
||||||
|
{isSelected ? selectedIndicator : ' '}
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* Item number */}
|
||||||
|
{showNumbers && !item.hideNumber && (
|
||||||
|
<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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Base component for selection lists that provides common UI structure
|
* Base component for selection lists that provides common UI structure
|
||||||
* and keyboard navigation logic via the useSelectionList hook.
|
* and keyboard navigation logic via the useSelectionList hook.
|
||||||
@@ -70,7 +184,7 @@ export function BaseSelectionList<
|
|||||||
selectedIndicator = '●',
|
selectedIndicator = '●',
|
||||||
renderItem,
|
renderItem,
|
||||||
}: BaseSelectionListProps<T, TItem>): React.JSX.Element {
|
}: BaseSelectionListProps<T, TItem>): React.JSX.Element {
|
||||||
const { activeIndex } = useSelectionList({
|
const { activeIndex, setActiveIndex } = useSelectionList({
|
||||||
items,
|
items,
|
||||||
initialIndex,
|
initialIndex,
|
||||||
onSelect,
|
onSelect,
|
||||||
@@ -107,10 +221,12 @@ export function BaseSelectionList<
|
|||||||
);
|
);
|
||||||
const numberColumnWidth = String(items.length).length;
|
const numberColumnWidth = String(items.length).length;
|
||||||
|
|
||||||
|
const showArrows = showScrollArrows && items.length > maxItemsToShow;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box flexDirection="column">
|
<Box flexDirection="column">
|
||||||
{/* Use conditional coloring instead of conditional rendering */}
|
{/* Use conditional coloring instead of conditional rendering */}
|
||||||
{showScrollArrows && items.length > maxItemsToShow && (
|
{showArrows && (
|
||||||
<Text
|
<Text
|
||||||
color={
|
color={
|
||||||
effectiveScrollOffset > 0
|
effectiveScrollOffset > 0
|
||||||
@@ -126,71 +242,24 @@ export function BaseSelectionList<
|
|||||||
const itemIndex = effectiveScrollOffset + index;
|
const itemIndex = effectiveScrollOffset + index;
|
||||||
const isSelected = activeIndex === itemIndex;
|
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.ui.focus;
|
|
||||||
numberColor = theme.ui.focus;
|
|
||||||
} 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 (
|
return (
|
||||||
<Box
|
<SelectionListItemRow
|
||||||
key={item.key}
|
key={item.key}
|
||||||
alignItems="flex-start"
|
item={item}
|
||||||
backgroundColor={isSelected ? theme.background.focus : undefined}
|
itemIndex={itemIndex}
|
||||||
>
|
isSelected={isSelected}
|
||||||
{/* Radio button indicator */}
|
isFocused={isFocused}
|
||||||
<Box minWidth={2} flexShrink={0}>
|
showNumbers={showNumbers}
|
||||||
<Text
|
selectedIndicator={selectedIndicator}
|
||||||
color={isSelected ? theme.ui.focus : theme.text.primary}
|
numberColumnWidth={numberColumnWidth}
|
||||||
aria-hidden
|
onSelect={onSelect}
|
||||||
>
|
setActiveIndex={setActiveIndex}
|
||||||
{isSelected ? selectedIndicator : ' '}
|
renderItem={renderItem}
|
||||||
</Text>
|
/>
|
||||||
</Box>
|
|
||||||
|
|
||||||
{/* Item number */}
|
|
||||||
{showNumbers && !item.hideNumber && (
|
|
||||||
<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 && items.length > maxItemsToShow && (
|
{showArrows && (
|
||||||
<Text
|
<Text
|
||||||
color={
|
color={
|
||||||
effectiveScrollOffset + maxItemsToShow < items.length
|
effectiveScrollOffset + maxItemsToShow < items.length
|
||||||
|
|||||||
@@ -11,12 +11,17 @@ import { act } from 'react';
|
|||||||
import { TextInput } from './TextInput.js';
|
import { TextInput } from './TextInput.js';
|
||||||
import { useKeypress } from '../../hooks/useKeypress.js';
|
import { useKeypress } from '../../hooks/useKeypress.js';
|
||||||
import { useTextBuffer, type TextBuffer } from './text-buffer.js';
|
import { useTextBuffer, type TextBuffer } from './text-buffer.js';
|
||||||
|
import { useMouseClick } from '../../hooks/useMouseClick.js';
|
||||||
|
|
||||||
// Mocks
|
// Mocks
|
||||||
vi.mock('../../hooks/useKeypress.js', () => ({
|
vi.mock('../../hooks/useKeypress.js', () => ({
|
||||||
useKeypress: vi.fn(),
|
useKeypress: vi.fn(),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
vi.mock('../../hooks/useMouseClick.js', () => ({
|
||||||
|
useMouseClick: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
vi.mock('./text-buffer.js', async (importOriginal) => {
|
vi.mock('./text-buffer.js', async (importOriginal) => {
|
||||||
const actual = await importOriginal<typeof import('./text-buffer.js')>();
|
const actual = await importOriginal<typeof import('./text-buffer.js')>();
|
||||||
const mockTextBuffer = {
|
const mockTextBuffer = {
|
||||||
@@ -69,6 +74,7 @@ vi.mock('./text-buffer.js', async (importOriginal) => {
|
|||||||
|
|
||||||
const mockedUseKeypress = useKeypress as Mock;
|
const mockedUseKeypress = useKeypress as Mock;
|
||||||
const mockedUseTextBuffer = useTextBuffer as Mock;
|
const mockedUseTextBuffer = useTextBuffer as Mock;
|
||||||
|
const mockedUseMouseClick = useMouseClick as Mock;
|
||||||
|
|
||||||
describe('TextInput', () => {
|
describe('TextInput', () => {
|
||||||
const onCancel = vi.fn();
|
const onCancel = vi.fn();
|
||||||
@@ -84,6 +90,7 @@ describe('TextInput', () => {
|
|||||||
cursor: [0, 0],
|
cursor: [0, 0],
|
||||||
visualCursor: [0, 0],
|
visualCursor: [0, 0],
|
||||||
viewportVisualLines: [''],
|
viewportVisualLines: [''],
|
||||||
|
visualScrollRow: 0,
|
||||||
pastedContent: {} as Record<string, string>,
|
pastedContent: {} as Record<string, string>,
|
||||||
handleInput: vi.fn((key) => {
|
handleInput: vi.fn((key) => {
|
||||||
if (key.sequence) {
|
if (key.sequence) {
|
||||||
@@ -408,4 +415,36 @@ describe('TextInput', () => {
|
|||||||
expect(lastFrame()).toContain('line2');
|
expect(lastFrame()).toContain('line2');
|
||||||
unmount();
|
unmount();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('registers mouse click handler for free-form text input', async () => {
|
||||||
|
const { unmount } = await render(
|
||||||
|
<TextInput buffer={mockBuffer} onSubmit={onSubmit} onCancel={onCancel} />,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(mockedUseMouseClick).toHaveBeenCalledWith(
|
||||||
|
expect.any(Object),
|
||||||
|
expect.any(Function),
|
||||||
|
expect.objectContaining({ isActive: true, name: 'left-press' }),
|
||||||
|
);
|
||||||
|
unmount();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('registers mouse click handler for placeholder view', async () => {
|
||||||
|
mockBuffer.text = '';
|
||||||
|
const { unmount } = await render(
|
||||||
|
<TextInput
|
||||||
|
buffer={mockBuffer}
|
||||||
|
placeholder="test"
|
||||||
|
onSubmit={onSubmit}
|
||||||
|
onCancel={onCancel}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(mockedUseMouseClick).toHaveBeenCalledWith(
|
||||||
|
expect.any(Object),
|
||||||
|
expect.any(Function),
|
||||||
|
expect.objectContaining({ isActive: true, name: 'left-press' }),
|
||||||
|
);
|
||||||
|
unmount();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -5,8 +5,8 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import type React from 'react';
|
import type React from 'react';
|
||||||
import { useCallback } from 'react';
|
import { useCallback, useRef } from 'react';
|
||||||
import { Text, Box } from 'ink';
|
import { Text, Box, type DOMElement } from 'ink';
|
||||||
import { useKeypress, type Key } from '../../hooks/useKeypress.js';
|
import { useKeypress, type Key } from '../../hooks/useKeypress.js';
|
||||||
import chalk from 'chalk';
|
import chalk from 'chalk';
|
||||||
import { theme } from '../../semantic-colors.js';
|
import { theme } from '../../semantic-colors.js';
|
||||||
@@ -14,6 +14,7 @@ import { expandPastePlaceholders, type TextBuffer } from './text-buffer.js';
|
|||||||
import { cpSlice, cpIndexToOffset } from '../../utils/textUtils.js';
|
import { cpSlice, cpIndexToOffset } from '../../utils/textUtils.js';
|
||||||
import { Command } from '../../key/keyMatchers.js';
|
import { Command } from '../../key/keyMatchers.js';
|
||||||
import { useKeyMatchers } from '../../hooks/useKeyMatchers.js';
|
import { useKeyMatchers } from '../../hooks/useKeyMatchers.js';
|
||||||
|
import { useMouseClick } from '../../hooks/useMouseClick.js';
|
||||||
|
|
||||||
export interface TextInputProps {
|
export interface TextInputProps {
|
||||||
buffer: TextBuffer;
|
buffer: TextBuffer;
|
||||||
@@ -31,6 +32,8 @@ export function TextInput({
|
|||||||
focus = true,
|
focus = true,
|
||||||
}: TextInputProps): React.JSX.Element {
|
}: TextInputProps): React.JSX.Element {
|
||||||
const keyMatchers = useKeyMatchers();
|
const keyMatchers = useKeyMatchers();
|
||||||
|
const containerRef = useRef<DOMElement>(null);
|
||||||
|
|
||||||
const {
|
const {
|
||||||
text,
|
text,
|
||||||
handleInput,
|
handleInput,
|
||||||
@@ -40,6 +43,17 @@ export function TextInput({
|
|||||||
} = buffer;
|
} = buffer;
|
||||||
const [cursorVisualRowAbsolute, cursorVisualColAbsolute] = visualCursor;
|
const [cursorVisualRowAbsolute, cursorVisualColAbsolute] = visualCursor;
|
||||||
|
|
||||||
|
useMouseClick(
|
||||||
|
containerRef,
|
||||||
|
(_event, relativeX, relativeY) => {
|
||||||
|
if (focus) {
|
||||||
|
const visRowAbsolute = visualScrollRow + relativeY;
|
||||||
|
buffer.moveToVisualPosition(visRowAbsolute, relativeX);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ isActive: focus, name: 'left-press' },
|
||||||
|
);
|
||||||
|
|
||||||
const handleKeyPress = useCallback(
|
const handleKeyPress = useCallback(
|
||||||
(key: Key) => {
|
(key: Key) => {
|
||||||
if (key.name === 'escape' && onCancel) {
|
if (key.name === 'escape' && onCancel) {
|
||||||
@@ -64,7 +78,7 @@ export function TextInput({
|
|||||||
|
|
||||||
if (showPlaceholder) {
|
if (showPlaceholder) {
|
||||||
return (
|
return (
|
||||||
<Box>
|
<Box ref={containerRef}>
|
||||||
{focus ? (
|
{focus ? (
|
||||||
<Text terminalCursorFocus={focus} terminalCursorPosition={0}>
|
<Text terminalCursorFocus={focus} terminalCursorPosition={0}>
|
||||||
{chalk.inverse(placeholder[0] || ' ')}
|
{chalk.inverse(placeholder[0] || ' ')}
|
||||||
@@ -78,7 +92,7 @@ export function TextInput({
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box flexDirection="column">
|
<Box ref={containerRef} flexDirection="column">
|
||||||
{viewportVisualLines.map((lineText, idx) => {
|
{viewportVisualLines.map((lineText, idx) => {
|
||||||
const currentVisualRow = visualScrollRow + idx;
|
const currentVisualRow = visualScrollRow + idx;
|
||||||
const isCursorLine =
|
const isCursorLine =
|
||||||
|
|||||||
Reference in New Issue
Block a user