From 34883942cfe3f3554fb865f45056ab71e349e2fe Mon Sep 17 00:00:00 2001 From: DragonnZhang <731557579@qq.com> Date: Mon, 8 Jun 2026 17:48:56 +0800 Subject: [PATCH 1/2] fix(cli): fix cursor left-move stalling at hard-wrapped line boundary in text buffer When a single logical line is hard-wrapped across multiple visual lines, pressing left-arrow at the start of a wrapped visual row could land on a position that mapped back to the same logical cursor, making the cursor appear stuck. Detect this case and step one column further left so the visual cursor actually advances. --- .../ui/components/shared/text-buffer.test.ts | 20 +++++++++++++++++++ .../src/ui/components/shared/text-buffer.ts | 12 +++++++++++ 2 files changed, 32 insertions(+) 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 567727bf7bb..c59ac23d478 100644 --- a/packages/cli/src/ui/components/shared/text-buffer.test.ts +++ b/packages/cli/src/ui/components/shared/text-buffer.test.ts @@ -1123,6 +1123,26 @@ describe('useTextBuffer', () => { expect(state.viewportVisualLines).toEqual(['l1', 'l2', 'l3']); expect(state.visualCursor).toEqual([0, 0]); }); + + it('moves left across a hard-wrapped single-line boundary', () => { + const { result } = renderHook(() => + useTextBuffer({ + initialText: '1234567890ABCDE', + initialCursorOffset: 10, + viewport: { width: 10, height: 1 }, + isValidPath: () => false, + }), + ); + + expect(getBufferState(result).viewportVisualLines).toEqual(['ABCDE']); + + act(() => result.current.move('left')); + + const state = getBufferState(result); + expect(state.cursor).toEqual([0, 9]); + expect(state.visualCursor).toEqual([0, 9]); + expect(state.viewportVisualLines).toEqual(['1234567890']); + }); }); describe('Undo/Redo', () => { diff --git a/packages/cli/src/ui/components/shared/text-buffer.ts b/packages/cli/src/ui/components/shared/text-buffer.ts index f3f0c4b5d68..55eb10676c6 100644 --- a/packages/cli/src/ui/components/shared/text-buffer.ts +++ b/packages/cli/src/ui/components/shared/text-buffer.ts @@ -1397,6 +1397,18 @@ function textBufferReducerLogic( } else if (newVisualRow > 0) { newVisualRow--; newVisualCol = cpLen(visualLines[newVisualRow] ?? ''); + const previousMapping = visualToLogicalMap[newVisualRow]; + if (previousMapping) { + const [previousLogRow, previousLogStartCol] = previousMapping; + const previousCursorCol = previousLogStartCol + newVisualCol; + if ( + previousLogRow === cursorRow && + previousCursorCol === cursorCol && + newVisualCol > 0 + ) { + newVisualCol--; + } + } } break; case 'right': From 53504c0a4e464e2a935a2a71479f20be1673f959 Mon Sep 17 00:00:00 2001 From: Dragon <731557579@qq.com> Date: Wed, 10 Jun 2026 05:36:17 +0800 Subject: [PATCH 2/2] docs(cli): explain why right-movement needs no hard-wrap stall-fix Address review: the left-arrow stall fix relies on an implicit invariant in calculateVisualCursorFromLayout (a strict `logicalCol < nextStartColInLogical` segment selection) that places a boundary cursor at the start of the next visual row, which is why `case 'right'` has no symmetric stall. Document that coupling next to `case 'right'` so a future change to the segment selection doesn't silently reintroduce a mirror-image right-movement stall. Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/cli/src/ui/components/shared/text-buffer.ts | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/packages/cli/src/ui/components/shared/text-buffer.ts b/packages/cli/src/ui/components/shared/text-buffer.ts index 55eb10676c6..b225650c02a 100644 --- a/packages/cli/src/ui/components/shared/text-buffer.ts +++ b/packages/cli/src/ui/components/shared/text-buffer.ts @@ -1412,6 +1412,14 @@ function textBufferReducerLogic( } break; case 'right': + // No stall-fix needed here (unlike 'left' above): the cursor + // resolver (calculateVisualCursorFromLayout) selects segments with + // a strict `logicalCol < nextStartColInLogical`, so a cursor at a + // hard-wrap boundary always lands at the START of the next visual + // row, not the end of the current one — wrapping right never maps + // back to the same logical position. If that segment selection ever + // changes (e.g. `<` → `<=`), a symmetric decrement would be needed + // here to avoid a mirror-image right-movement stall. newPreferredCol = null; if (newVisualCol < currentVisLineLen) { newVisualCol++;