feat(cli): enable mouse clicking for cursor positioning in AskUser multi-line answers (#24630)

This commit is contained in:
Adib234
2026-04-14 15:07:00 -04:00
committed by GitHub
parent 8f6edc50c1
commit 05aa1465fe
6 changed files with 321 additions and 96 deletions
+16 -11
View File
@@ -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 =