fix(tui): align composer cursorLayout with wrap-ansi to kill multiline cursor drift

The composer's `cursorLayout` (in `ui-tui/src/lib/inputMetrics.ts`) used a
hand-rolled word-wrap algorithm to decide where `useDeclaredCursor`
should park the hardware cursor. But Ink's `<Text wrap="wrap">` renders
the same text via `wrap-ansi`. The two algorithms disagreed on common
real-world inputs — `"branch investigate"` at cols=20, `"hello world"`
at cols=8, exact-fill strings like `"abcdefgh"` at cols=8 — so the
hardware cursor parked several cells past where Ink actually rendered
the last character. Users saw a multi-cell blank gap between their
last-typed letter and the cursor block, especially on narrow terminals
(the Cursor IDE built-in terminal was the worst offender).

Three previous PRs (#26717, #25860, #22197) chased fast-echo
displayCursor/cursorDeclaration drift and in-band-vs-native cursor
heuristics. None of them touched the underlying wrap-algorithm
mismatch, which is why the bug kept resurfacing.

Fix: source cursorLayout's line breaks from wrap-ansi directly. Walk
its emitted string char-by-char, tracking original-string offsets, push
a VisualLine at each '\n'. Also drop the buggy `column >= w` overflow
rule in cursorLayout — that's what pushed exact-fill text onto a
phantom next row.

canFastBackspaceShape now detects the wrap boundary in BOTH coordinate
conventions (column === 0 OR column >= columns), since exact-fill now
reports as (0, columns) instead of the previous (1, 0). The physical
state is identical — the terminal auto-wraps at column N either way —
but the layout function reports the position more honestly.

Tests:
- ui-tui/src/__tests__/textInputWrap.test.ts: 3 tests that pinned the
  BUGGY behavior were updated to assert wrap-ansi parity (the real
  invariant). Added a typing-prefix invariant: cursorLayout must agree
  with wrap-ansi at every character of a long input.
- ui-tui/src/__tests__/cursorDriftRegression.test.ts: new file. Walks
  the user-reported bug message char-by-char at 7 widths and asserts
  agreement with wrap-ansi at every prefix.

Verification:
- 791/791 vitest tests pass.
- 84/84 tui-gateway pytest tests pass via scripts/run_tests.sh.
- PTY repro (typing into a real `hermes --tui` PTY at cols=50/55/60):
  cursor lands exactly 1 cell past the last typed char in every case
  the bug previously drifted.
This commit is contained in:
Brooklyn Nicholson
2026-05-17 11:10:06 -05:00
parent f36c89cd57
commit 3b4dd68326
4 changed files with 253 additions and 83 deletions

View File

@@ -272,10 +272,22 @@ export function canFastBackspaceShape(current: string, cursor: number, columns?:
}
// If we know the wrap width, reject at the soft-wrap boundary: the
// caret's visual column is 0, so "\b \b" can't represent the physical
// move back to the previous visual line.
if (columns !== undefined && cursorLayout(current, cursor, columns).column === 0) {
return false
// caret's physical column would be at (or past) the terminal's right
// edge, so the terminal has already auto-wrapped to the next row.
// "\b \b" can't represent the physical move back across that wrap.
//
// We check `column === 0` for the "wrap-ansi broke onto a new line"
// case AND `column >= columns` for the "exact-fill, terminal auto-wraps"
// case. Both manifest as the same physical state (cursor parked at
// col 0 of the next row) but cursorLayout reports them differently
// because it now mirrors wrap-ansi's break points exactly (see the
// cursor-drift-multiline fix in lib/inputMetrics.ts).
if (columns !== undefined) {
const layout = cursorLayout(current, cursor, columns)
if (layout.column === 0 || layout.column >= columns) {
return false
}
}
const removed = current.slice(prevPos(current, cursor), cursor)