feat(ui): add terminal cursor support (#17711)

This commit is contained in:
Jacob Richman
2026-01-27 16:43:37 -08:00
committed by GitHub
parent fe8de892f7
commit 5e41b7d29e
5 changed files with 263 additions and 6 deletions

View File

@@ -23,6 +23,7 @@ import * as path from 'node:path';
import type { CommandContext, SlashCommand } from '../commands/types.js';
import { CommandKind } from '../commands/types.js';
import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest';
import { Text } from 'ink';
import type { UseShellHistoryReturn } from '../hooks/useShellHistory.js';
import { useShellHistory } from '../hooks/useShellHistory.js';
import type { UseCommandCompletionReturn } from '../hooks/useCommandCompletion.js';
@@ -56,6 +57,17 @@ vi.mock('../utils/terminalUtils.js', () => ({
isLowColorDepth: vi.fn(() => false),
}));
// Mock ink BEFORE importing components that use it to intercept terminalCursorPosition
vi.mock('ink', async (importOriginal) => {
const actual = await importOriginal<typeof import('ink')>();
return {
...actual,
Text: vi.fn(({ children, ...props }) => (
<actual.Text {...props}>{children}</actual.Text>
)),
};
});
const mockSlashCommands: SlashCommand[] = [
{
name: 'clear',
@@ -1708,12 +1720,24 @@ describe('InputPrompt', () => {
visualCursor: [0, 6],
expected: `hello ${chalk.inverse('👍')} world`,
},
{
name: 'after multi-byte unicode characters',
text: '👍A',
visualCursor: [0, 1],
expected: `👍${chalk.inverse('A')}`,
},
{
name: 'at the end of a line with unicode characters',
text: 'hello 👍',
visualCursor: [0, 8],
expected: `hello 👍${chalk.inverse(' ')}`,
},
{
name: 'at the end of a short line with unicode characters',
text: '👍',
visualCursor: [0, 1],
expected: `👍${chalk.inverse(' ')}`,
},
{
name: 'on an empty line',
text: '',
@@ -3368,6 +3392,202 @@ describe('InputPrompt', () => {
);
});
describe('IME Cursor Support', () => {
it('should report correct cursor position for simple ASCII text', async () => {
const text = 'hello';
mockBuffer.text = text;
mockBuffer.lines = [text];
mockBuffer.viewportVisualLines = [text];
mockBuffer.visualToLogicalMap = [[0, 0]];
mockBuffer.visualCursor = [0, 3]; // Cursor after 'hel'
mockBuffer.visualScrollRow = 0;
const { stdout, unmount } = renderWithProviders(
<InputPrompt {...props} />,
{ uiActions },
);
await waitFor(() => {
expect(stdout.lastFrame()).toContain('hello');
});
// Check Text calls from the LAST render
const textCalls = vi.mocked(Text).mock.calls;
const cursorLineCall = [...textCalls]
.reverse()
.find((call) => call[0].terminalCursorFocus === true);
expect(cursorLineCall).toBeDefined();
// 'hel' is 3 characters wide
expect(cursorLineCall![0].terminalCursorPosition).toBe(3);
unmount();
});
it('should report correct cursor position for text with double-width characters', async () => {
const text = '👍hello';
mockBuffer.text = text;
mockBuffer.lines = [text];
mockBuffer.viewportVisualLines = [text];
mockBuffer.visualToLogicalMap = [[0, 0]];
mockBuffer.visualCursor = [0, 2]; // Cursor after '👍h' (Note: '👍' is one code point but width 2)
mockBuffer.visualScrollRow = 0;
const { stdout, unmount } = renderWithProviders(
<InputPrompt {...props} />,
{ uiActions },
);
await waitFor(() => {
expect(stdout.lastFrame()).toContain('👍hello');
});
const textCalls = vi.mocked(Text).mock.calls;
const cursorLineCall = [...textCalls]
.reverse()
.find((call) => call[0].terminalCursorFocus === true);
expect(cursorLineCall).toBeDefined();
// '👍' is width 2, 'h' is width 1. Total width = 3.
expect(cursorLineCall![0].terminalCursorPosition).toBe(3);
unmount();
});
it('should report correct cursor position for a line full of "😀" emojis', async () => {
const text = '😀😀😀';
mockBuffer.text = text;
mockBuffer.lines = [text];
mockBuffer.viewportVisualLines = [text];
mockBuffer.visualToLogicalMap = [[0, 0]];
mockBuffer.visualCursor = [0, 2]; // Cursor after 2 emojis (each 1 code point, width 2)
mockBuffer.visualScrollRow = 0;
const { stdout, unmount } = renderWithProviders(
<InputPrompt {...props} />,
{ uiActions },
);
await waitFor(() => {
expect(stdout.lastFrame()).toContain('😀😀😀');
});
const textCalls = vi.mocked(Text).mock.calls;
const cursorLineCall = [...textCalls]
.reverse()
.find((call) => call[0].terminalCursorFocus === true);
expect(cursorLineCall).toBeDefined();
// 2 emojis * width 2 = 4
expect(cursorLineCall![0].terminalCursorPosition).toBe(4);
unmount();
});
it('should report correct cursor position for mixed emojis and multi-line input', async () => {
const lines = ['😀😀', 'hello 😀', 'world'];
mockBuffer.text = lines.join('\n');
mockBuffer.lines = lines;
mockBuffer.viewportVisualLines = lines;
mockBuffer.visualToLogicalMap = [
[0, 0],
[1, 0],
[2, 0],
];
mockBuffer.visualCursor = [1, 7]; // Second line, after 'hello 😀' (6 chars + 1 emoji = 7 code points)
mockBuffer.visualScrollRow = 0;
const { stdout, unmount } = renderWithProviders(
<InputPrompt {...props} />,
{ uiActions },
);
await waitFor(() => {
expect(stdout.lastFrame()).toContain('hello 😀');
});
const textCalls = vi.mocked(Text).mock.calls;
const lineCalls = textCalls.filter(
(call) => call[0].terminalCursorPosition !== undefined,
);
const lastRenderLineCalls = lineCalls.slice(-3);
const focusCall = lastRenderLineCalls.find(
(call) => call[0].terminalCursorFocus === true,
);
expect(focusCall).toBeDefined();
// 'hello ' is 6 units, '😀' is 2 units. Total = 8.
expect(focusCall![0].terminalCursorPosition).toBe(8);
unmount();
});
it('should report correct cursor position and focus for multi-line input', async () => {
const lines = ['first line', 'second line', 'third line'];
mockBuffer.text = lines.join('\n');
mockBuffer.lines = lines;
mockBuffer.viewportVisualLines = lines;
mockBuffer.visualToLogicalMap = [
[0, 0],
[1, 0],
[2, 0],
];
mockBuffer.visualCursor = [1, 7]; // Cursor on second line, after 'second '
mockBuffer.visualScrollRow = 0;
const { stdout, unmount } = renderWithProviders(
<InputPrompt {...props} />,
{ uiActions },
);
await waitFor(() => {
expect(stdout.lastFrame()).toContain('second line');
});
const textCalls = vi.mocked(Text).mock.calls;
// We look for the last set of line calls.
// Line calls have terminalCursorPosition set.
const lineCalls = textCalls.filter(
(call) => call[0].terminalCursorPosition !== undefined,
);
const lastRenderLineCalls = lineCalls.slice(-3);
expect(lastRenderLineCalls.length).toBe(3);
// Only one line should have terminalCursorFocus=true
const focusCalls = lastRenderLineCalls.filter(
(call) => call[0].terminalCursorFocus === true,
);
expect(focusCalls.length).toBe(1);
expect(focusCalls[0][0].terminalCursorPosition).toBe(7);
unmount();
});
it('should report cursor position 0 when input is empty and placeholder is shown', async () => {
mockBuffer.text = '';
mockBuffer.lines = [''];
mockBuffer.viewportVisualLines = [''];
mockBuffer.visualToLogicalMap = [[0, 0]];
mockBuffer.visualCursor = [0, 0];
mockBuffer.visualScrollRow = 0;
const { stdout, unmount } = renderWithProviders(
<InputPrompt {...props} placeholder="Type here" />,
{ uiActions },
);
await waitFor(() => {
expect(stdout.lastFrame()).toContain('Type here');
});
const textCalls = vi.mocked(Text).mock.calls;
const cursorLineCall = [...textCalls]
.reverse()
.find((call) => call[0].terminalCursorFocus === true);
expect(cursorLineCall).toBeDefined();
expect(cursorLineCall![0].terminalCursorPosition).toBe(0);
unmount();
});
});
describe('image path transformation snapshots', () => {
const logicalLine = '@/path/to/screenshots/screenshot2x.png';
const transformations = calculateTransformationsForLine(logicalLine);

View File

@@ -18,7 +18,12 @@ import {
PASTED_TEXT_PLACEHOLDER_REGEX,
getTransformUnderCursor,
} from './shared/text-buffer.js';
import { cpSlice, cpLen, toCodePoints } from '../utils/textUtils.js';
import {
cpSlice,
cpLen,
toCodePoints,
cpIndexToOffset,
} from '../utils/textUtils.js';
import chalk from 'chalk';
import stringWidth from 'string-width';
import { useShellHistory } from '../hooks/useShellHistory.js';
@@ -1231,7 +1236,10 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
<Box flexGrow={1} flexDirection="column" ref={innerBoxRef}>
{buffer.text.length === 0 && placeholder ? (
showCursor ? (
<Text>
<Text
terminalCursorFocus={showCursor}
terminalCursorPosition={0}
>
{chalk.inverse(placeholder.slice(0, 1))}
<Text color={theme.text.secondary}>
{placeholder.slice(1)}
@@ -1352,7 +1360,13 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
return (
<Box key={`line-${visualIdxInRenderedSet}`} height={1}>
<Text>
<Text
terminalCursorFocus={showCursor && isOnCursorLine}
terminalCursorPosition={cpIndexToOffset(
lineText,
cursorVisualColAbsolute,
)}
>
{renderedLine}
{showCursorBeforeGhost &&
(showCursor ? chalk.inverse(' ') : ' ')}

View File

@@ -17,6 +17,7 @@ import {
cpSlice,
cpLen,
stripUnsafeCharacters,
cpIndexToOffset,
} from '../../utils/textUtils.js';
import { useKeypress, type Key } from '../../hooks/useKeypress.js';
import { keyMatchers, Command } from '../../keyMatchers.js';
@@ -558,6 +559,13 @@ export function BaseSettingsDialog({
? theme.text.secondary
: theme.text.primary
}
terminalCursorFocus={
editingKey === item.key && cursorVisible
}
terminalCursorPosition={cpIndexToOffset(
editBuffer,
editCursorPos,
)}
>
{displayValue}
</Text>

View File

@@ -12,7 +12,7 @@ import { useKeypress } from '../../hooks/useKeypress.js';
import chalk from 'chalk';
import { theme } from '../../semantic-colors.js';
import type { TextBuffer } from './text-buffer.js';
import { cpSlice } from '../../utils/textUtils.js';
import { cpSlice, cpIndexToOffset } from '../../utils/textUtils.js';
export interface TextInputProps {
buffer: TextBuffer;
@@ -64,7 +64,7 @@ export function TextInput({
return (
<Box>
{focus ? (
<Text>
<Text terminalCursorFocus={focus} terminalCursorPosition={0}>
{chalk.inverse(placeholder[0] || ' ')}
<Text color={theme.text.secondary}>{placeholder.slice(1)}</Text>
</Text>
@@ -96,7 +96,15 @@ export function TextInput({
return (
<Box key={idx} height={1}>
<Text>{lineDisplay}</Text>
<Text
terminalCursorFocus={isCursorLine}
terminalCursorPosition={cpIndexToOffset(
lineText,
cursorVisualColAbsolute,
)}
>
{lineDisplay}
</Text>
</Box>
);
})}

View File

@@ -71,6 +71,13 @@ export function cpLen(str: string): number {
return toCodePoints(str).length;
}
/**
* Converts a code point index to a UTF-16 code unit offset.
*/
export function cpIndexToOffset(str: string, cpIndex: number): number {
return cpSlice(str, 0, cpIndex).length;
}
export function cpSlice(str: string, start: number, end?: number): string {
// Slice by codepoint indices and rejoin.
const arr = toCodePoints(str).slice(start, end);