diff --git a/packages/cli/src/ui/components/InputPrompt.test.tsx b/packages/cli/src/ui/components/InputPrompt.test.tsx index 89b5d30eeb..2c5de87e0e 100644 --- a/packages/cli/src/ui/components/InputPrompt.test.tsx +++ b/packages/cli/src/ui/components/InputPrompt.test.tsx @@ -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(); + return { + ...actual, + Text: vi.fn(({ children, ...props }) => ( + {children} + )), + }; +}); + 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( + , + { 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( + , + { 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( + , + { 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( + , + { 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( + , + { 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( + , + { 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); diff --git a/packages/cli/src/ui/components/InputPrompt.tsx b/packages/cli/src/ui/components/InputPrompt.tsx index cd82d7f674..549d51f881 100644 --- a/packages/cli/src/ui/components/InputPrompt.tsx +++ b/packages/cli/src/ui/components/InputPrompt.tsx @@ -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 = ({ {buffer.text.length === 0 && placeholder ? ( showCursor ? ( - + {chalk.inverse(placeholder.slice(0, 1))} {placeholder.slice(1)} @@ -1352,7 +1360,13 @@ export const InputPrompt: React.FC = ({ return ( - + {renderedLine} {showCursorBeforeGhost && (showCursor ? chalk.inverse(' ') : ' ')} diff --git a/packages/cli/src/ui/components/shared/BaseSettingsDialog.tsx b/packages/cli/src/ui/components/shared/BaseSettingsDialog.tsx index ab18a71aff..ed454da08a 100644 --- a/packages/cli/src/ui/components/shared/BaseSettingsDialog.tsx +++ b/packages/cli/src/ui/components/shared/BaseSettingsDialog.tsx @@ -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} diff --git a/packages/cli/src/ui/components/shared/TextInput.tsx b/packages/cli/src/ui/components/shared/TextInput.tsx index 4afbe7a0e7..972cf04214 100644 --- a/packages/cli/src/ui/components/shared/TextInput.tsx +++ b/packages/cli/src/ui/components/shared/TextInput.tsx @@ -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 ( {focus ? ( - + {chalk.inverse(placeholder[0] || ' ')} {placeholder.slice(1)} @@ -96,7 +96,15 @@ export function TextInput({ return ( - {lineDisplay} + + {lineDisplay} + ); })} diff --git a/packages/cli/src/ui/utils/textUtils.ts b/packages/cli/src/ui/utils/textUtils.ts index e95977086d..569ede8697 100644 --- a/packages/cli/src/ui/utils/textUtils.ts +++ b/packages/cli/src/ui/utils/textUtils.ts @@ -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 code‑point indices and re‑join. const arr = toCodePoints(str).slice(start, end);