Skip to content

fix(tui): visual-line aware HOME/END/UP/DOWN navigation#22197

Closed
wesleysimplicio wants to merge 2 commits into
NousResearch:mainfrom
wesleysimplicio:fix/tui-visual-line-navigation
Closed

fix(tui): visual-line aware HOME/END/UP/DOWN navigation#22197
wesleysimplicio wants to merge 2 commits into
NousResearch:mainfrom
wesleysimplicio:fix/tui-visual-line-navigation

Conversation

@wesleysimplicio

@wesleysimplicio wesleysimplicio commented May 9, 2026

Copy link
Copy Markdown
Contributor

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 existing visualLines / cursorLayout / offsetFromPosition so word-wrap geometry stays in one place.
  • ui-tui/src/components/textInput.tsx — HOME/END use visualLineBounds; UP/DOWN use visualLineNav. Legacy lineNav export kept for the existing textInputLineNav.test.ts.
  • ui-tui/src/app/useInputHandlers.ts + useMainApp.ts + interfaces.ts — plumbs the live composer column width through composerColsRef so 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

  • Veja a descrição original preservada abaixo para detalhes de validação, testes e notas de verificação.
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

  • Standardized this PR body to the current Hermes Turbo template.
  • Preserved the original detailed description below for reference.

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

  • Veja a descrição original preservada abaixo para detalhes de validação, testes e notas de verificação.
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

  • HOME / END — jumped to start/end of the entire logical line, even when the line was wrapped across many visual rows.
  • UP / DOWN — only moved by logical lines. On a wrapped one-liner, UP/DOWN immediately fell through to history cycling, losing the cursor position.

After

  • HOME — moves to the start of the current visual row. Pressing HOME again (when already at visual start) escalates to logical line start. Ctrl+HOME / word-modifier still forces logical start.
  • END — mirrors HOME (visual end → second press → logical end).
  • UP / DOWN — moves one visual row, preserving the visual column (clamped when destination row is shorter). Only defers to history cycling when the cursor is on the first / last visual row.
  • Logical newlines ( ) continue to act as hard row boundaries.

Changes

  • ui-tui/src/lib/inputMetrics.ts — new helpers: visualLineBounds, visualLineNav, isOnFirstVisualLine, isOnLastVisualLine. Built on existing visualLines / cursorLayout / offsetFromPosition so word-wrap geometry stays in one place.
  • ui-tui/src/components/textInput.tsx — HOME/END use visualLineBounds; UP/DOWN use visualLineNav. Legacy lineNav export kept for the existing textInputLineNav.test.ts.
  • ui-tui/src/app/useInputHandlers.ts + useMainApp.ts + interfaces.ts — plumbs the live composer column width through composerColsRef so 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.
  • Full suite: 665 tests / 62 files passing (no regressions).
  • Type-check clean. No new lint errors.
  • Manual TUI screenshots not feasible (terminal app); covered via Vitest unit tests on the pure layout helpers.

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

Copilot AI review requested due to automatic review settings May 9, 2026 02:02

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 TextInput to 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.

Comment on lines 248 to 253
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}`
@alt-glitch alt-glitch added type/bug Something isn't working P3 Low — cosmetic, nice to have comp/tui Terminal UI (ui-tui/ + tui_gateway/) labels May 9, 2026
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.
XpeRIall pushed a commit to XpeRIall/hermes-agent that referenced this pull request May 17, 2026
…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.
aashizpoudel pushed a commit to aashizpoudel/hermes-agent that referenced this pull request May 21, 2026
…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)
gweeteve pushed a commit to gweeteve/hermes-agent that referenced this pull request Jun 2, 2026
…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.
@wesleysimplicio

Copy link
Copy Markdown
Contributor Author

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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

comp/tui Terminal UI (ui-tui/ + tui_gateway/) P3 Low — cosmetic, nice to have type/bug Something isn't working

Projects

None yet

3 participants