diff --git a/packages/cli/src/ui/components/shared/text-buffer.test.ts b/packages/cli/src/ui/components/shared/text-buffer.test.ts index 02bdd9d533..f8b19cbf58 100644 --- a/packages/cli/src/ui/components/shared/text-buffer.test.ts +++ b/packages/cli/src/ui/components/shared/text-buffer.test.ts @@ -963,6 +963,34 @@ describe('useTextBuffer', () => { expect(state.cursor).toEqual([0, 1]); expect(state.visualCursor).toEqual([0, 1]); }); + + it('moveToVisualPosition: should correctly handle wide characters (Chinese)', () => { + const { result } = renderHook(() => + useTextBuffer({ + initialText: '你好', // 2 chars, width 4 + viewport: { width: 10, height: 1 }, + isValidPath: () => false, + }), + ); + + // '你' (width 2): visual 0-1. '好' (width 2): visual 2-3. + + // Click on '你' (first half, x=0) -> index 0 + act(() => result.current.moveToVisualPosition(0, 0)); + expect(getBufferState(result).cursor).toEqual([0, 0]); + + // Click on '你' (second half, x=1) -> index 1 (after first char) + act(() => result.current.moveToVisualPosition(0, 1)); + expect(getBufferState(result).cursor).toEqual([0, 1]); + + // Click on '好' (first half, x=2) -> index 1 (before second char) + act(() => result.current.moveToVisualPosition(0, 2)); + expect(getBufferState(result).cursor).toEqual([0, 1]); + + // Click on '好' (second half, x=3) -> index 2 (after second char) + act(() => result.current.moveToVisualPosition(0, 3)); + expect(getBufferState(result).cursor).toEqual([0, 2]); + }); }); describe('handleInput', () => { diff --git a/packages/cli/src/ui/components/shared/text-buffer.ts b/packages/cli/src/ui/components/shared/text-buffer.ts index 1a2de85313..51edbfeb81 100644 --- a/packages/cli/src/ui/components/shared/text-buffer.ts +++ b/packages/cli/src/ui/components/shared/text-buffer.ts @@ -853,7 +853,7 @@ export interface TextBufferState { lines: string[]; cursorRow: number; cursorCol: number; - preferredCol: number | null; // This is visual preferred col + preferredCol: number | null; // This is the logical character offset in the visual line undoStack: UndoHistoryEntry[]; redoStack: UndoHistoryEntry[]; clipboard: string | null; @@ -2022,20 +2022,40 @@ export function useTextBuffer({ Math.min(visRow, visualLines.length - 1), ); const visualLine = visualLines[clampedVisRow] || ''; - // Clamp visCol to the length of the visual line - const clampedVisCol = Math.max(0, Math.min(visCol, cpLen(visualLine))); if (visualToLogicalMap[clampedVisRow]) { const [logRow, logStartCol] = visualToLogicalMap[clampedVisRow]; + + const codePoints = toCodePoints(visualLine); + let currentVisX = 0; + let charOffset = 0; + + for (const char of codePoints) { + const charWidth = getCachedStringWidth(char); + // If the click is within this character + if (visCol < currentVisX + charWidth) { + // Check if we clicked the second half of a wide character + if (charWidth > 1 && visCol >= currentVisX + charWidth / 2) { + charOffset++; + } + break; + } + currentVisX += charWidth; + charOffset++; + } + + // Clamp charOffset to length + charOffset = Math.min(charOffset, codePoints.length); + const newCursorRow = logRow; - const newCursorCol = logStartCol + clampedVisCol; + const newCursorCol = logStartCol + charOffset; dispatch({ type: 'set_cursor', payload: { cursorRow: newCursorRow, cursorCol: newCursorCol, - preferredCol: clampedVisCol, + preferredCol: charOffset, }, }); }