fix(tui): visual-line aware HOME/END/UP/DOWN navigation#22197
fix(tui): visual-line aware HOME/END/UP/DOWN navigation#22197wesleysimplicio wants to merge 2 commits into
Conversation
There was a problem hiding this comment.
Pull request overview
This PR updates the TUI composer’s cursor navigation so that HOME/END and UP/DOWN operate on visual rows produced by word-wrapping (instead of only logical \n-delimited lines). This aligns cursor motion with what users see on screen and prevents UP/DOWN from incorrectly falling through to history cycling when the cursor is still within the wrapped input.
Changes:
- Add visual-row helpers in
inputMetrics.ts(visualLineBounds,visualLineNav, and first/last visual-row checks). - Update
TextInputto use the new helpers for HOME/END and UP/DOWN. - Update the global input handler to decide history-cycling based on visual-row boundaries via a plumbed composer column-width ref, plus new unit tests for the helpers.
Reviewed changes
Copilot reviewed 6 out of 6 changed files in this pull request and generated 1 comment.
Show a summary per file
| File | Description |
|---|---|
| ui-tui/src/lib/inputMetrics.ts | Adds visual-line bounds/navigation helpers built on existing wrap/cursor metric utilities. |
| ui-tui/src/components/textInput.tsx | Switches HOME/END and UP/DOWN behavior to visual-row-aware navigation. |
| ui-tui/src/app/useMainApp.ts | Introduces and passes composerColsRef so global handlers can use live composer wrap width. |
| ui-tui/src/app/useInputHandlers.ts | Uses visual first/last row detection to gate history cycling on UP/DOWN. |
| ui-tui/src/app/interfaces.ts | Extends InputHandlerContext to optionally include composerColsRef. |
| ui-tui/src/tests/visualLineNav.test.ts | Adds unit tests covering visual-line bounds/navigation and boundary detection. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| const detailsVisible = detailsLayoutKey !== 'hidden:hidden' | ||
| const userPromptWidth = composerPromptWidth(ui.theme.brand.prompt) | ||
| const composerInputCols = stableComposerColumns(cols, userPromptWidth) | ||
| const composerColsRef = useRef(composerInputCols) | ||
| composerColsRef.current = composerInputCols | ||
| const heightCacheKey = `${ui.sid ?? 'draft'}:${cols}:${userPromptWidth}:${ui.compact ? '1' : '0'}:${detailsLayoutKey}` |
The global Up/Down handler used composerPromptWidth(ui.theme.brand.prompt) to compute the wrap width, but the actual composer renders a `$` prompt in shell-mode (input starts with `!`). Mirror the ComposerPane logic so isOnFirst/LastVisualLine matches the wrap width TextInput uses. Addresses Copilot review feedback.
…e 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 (NousResearch#26717, NousResearch#25860, NousResearch#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.
…e 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 (NousResearch#26717, NousResearch#25860, NousResearch#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. (cherry picked from commit 3b4dd68)
…e 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 (NousResearch#26717, NousResearch#25860, NousResearch#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.
|
Closing — PR has merge conflicts that can't be auto-resolved. The codebase has evolved past this fix. Re-opening with a fresh rebase welcome if the issue is still open. |
What does this PR do?
When word-wrap is active in the TUI composer, cursor navigation keys operated on logical lines (separated by
) instead of visual rows. This made HOME/END jump past the visible row boundary and UP/DOWN cycle history when the user expected intra-line cursor motion.Root cause
The detailed rationale from the original PR body is preserved below. This template update keeps the review structure consistent with #29640.
Fix
ui-tui/src/lib/inputMetrics.ts— new helpers:visualLineBounds,visualLineNav,isOnFirstVisualLine,isOnLastVisualLine. Built on existingvisualLines/cursorLayout/offsetFromPositionso word-wrap geometry stays in one place.ui-tui/src/components/textInput.tsx— HOME/END usevisualLineBounds; UP/DOWN usevisualLineNav. LegacylineNavexport kept for the existingtextInputLineNav.test.ts.ui-tui/src/app/useInputHandlers.ts+useMainApp.ts+interfaces.ts— plumbs the live composer column width throughcomposerColsRefso the global UP/DOWN handler uses the same visual-row check as the composer (avoids double-firing where the cursor moves AND history cycles on the same key).Why this shape
This shape mirrors #29640 so reviewers can quickly compare scope, root cause, fix, tests, and related context without having to decode a custom PR description.
Tests
Original body
Related PRs / issues
Original body
Summary
When word-wrap is active in the TUI composer, cursor navigation keys operated on logical lines (separated by
) instead of visual rows. This made HOME/END jump past the visible row boundary and UP/DOWN cycle history when the user expected intra-line cursor motion.What Changed
Fluxo
A mudança continua seguindo o fluxo original descrito na seção preservada abaixo, sem ampliar o escopo funcional deste PR.
Visão
A padronização melhora a revisão, reduz ruído e evita deriva de formatação entre PRs abertos.
Test Plan
Original body
Summary
When word-wrap is active in the TUI composer, cursor navigation keys operated on logical lines (separated by
) instead of visual rows. This made HOME/END jump past the visible row boundary and UP/DOWN cycle history when the user expected intra-line cursor motion.This PR makes HOME / END / UP / DOWN word-wrap aware, with a small additive surface and full test coverage.
Closes #22008
Closes #22009
Behavior
Before
After
Ctrl+HOME/ word-modifier still forces logical start.) continue to act as hard row boundaries.Changes
ui-tui/src/lib/inputMetrics.ts— new helpers:visualLineBounds,visualLineNav,isOnFirstVisualLine,isOnLastVisualLine. Built on existingvisualLines/cursorLayout/offsetFromPositionso word-wrap geometry stays in one place.ui-tui/src/components/textInput.tsx— HOME/END usevisualLineBounds; UP/DOWN usevisualLineNav. LegacylineNavexport kept for the existingtextInputLineNav.test.ts.ui-tui/src/app/useInputHandlers.ts+useMainApp.ts+interfaces.ts— plumbs the live composer column width throughcomposerColsRefso the global UP/DOWN handler uses the same visual-row check as the composer (avoids double-firing where the cursor moves AND history cycles on the same key).Validation
ui-tui/src/__tests__/visualLineNav.test.ts— 17 new tests covering: wrapped one-liners, logical newline boundaries, cursor at offset 0, clamping past end, empty string, column preservation across UP/DOWN, column clamping when destination row is shorter, first/last visual row detection on multiline blocks.Scope
Additive only. No behavior change for unwrapped single-line input, no API removed, no unrelated refactors.
Generated by Hermes Turbo
Generated by Hermes Turbo