Skip to content

fix: keep cursor on the last line when output has no trailing newline - #982

Merged
sindresorhus merged 4 commits into
vadimdemedes:masterfrom
yasuda0320:fix/cursor-suffix-trailing-newline
Aug 3, 2026
Merged

fix: keep cursor on the last line when output has no trailing newline#982
sindresorhus merged 4 commits into
vadimdemedes:masterfrom
yasuda0320:fix/cursor-suffix-trailing-newline

Conversation

@yasuda0320

@yasuda0320 yasuda0320 commented Jul 31, 2026

Copy link
Copy Markdown
Contributor

Problem

buildCursorSuffix() states its precondition in its own docstring:

Assumes cursor is at (col 0, line visibleLineCount) — i.e. just after the last output line.

log-update breaks that precondition whenever the rendered string has no trailing newline (fullscreen mode). In that case both renderers deliberately leave the cursor on the last line instead of past it:

  • createIncremental skips the final cursorNextLine and omits the trailing \n for the last line — the code says so explicitly: "Don't move past the last line when there's no trailing newline, otherwise the cursor overshoots the rendered block."
  • createStandard writes the raw string, which ends mid-line.

So the cursor is at line visibleLineCount - 1, while buildCursorSuffix computes moveUp = visibleLineCount - cursorPosition.y. Every such frame overshoots by exactly one row.

Impact

Apps that use useCursor() in fullscreen (an Ink frame that fills the viewport) put the terminal's real cursor one line above the line they asked for. The column is always correct — only the row is off, and it is off on every render.

From a real capture: the app requested y = 1 in a 4-line frame and Ink emitted ESC[3A ESC[8G where ESC[2A ESC[8G was correct, landing the cursor on the divider line above the input line.

Fix

The row the renderer leaves the cursor on is lines.length - 1 for lines = str.split('\n') — in both cases:

  • With a trailing newline, split yields one extra empty element and the renderer stops just past the last visible line: lines.length - 1.
  • Without one, there is no extra element and the renderer deliberately stops on the last visible line: also lines.length - 1.

That is exactly the basis buildReturnToBottom() already measures from (previousLineCount - 1). buildCursorSuffix() was the odd one out: it took visibleLineCount, which subtracts the trailing empty element and therefore only coincides with the cursor's row when a trailing newline is present.

So the fix is not to thread a flag around — it is to give buildCursorSuffix() the same row basis its sibling already uses. Its first parameter becomes bottomLine, callers pass lines.length - 1, and CursorOnlyInput drops visibleLineCount in favour of the previousLineCount it already carries: buildReturnToBottom() has just placed the cursor on that exact row, so deriving it from the output a second time was recomputing a known value from a weaker input.

log-update.ts comes out shorter than it went in. The function body of buildCursorSuffix() is unchanged — only its contract and its callers move — which is itself the diagnosis: the defect was never in the helper, it was in what the call sites handed it. The existing buildCursorSuffix unit tests pass untouched, since visibleLineCount and bottomLine coincide for the trailing-newline inputs they use.

Frames that end with a newline emit byte-identical output. Verified by diffing the raw stream writes against master across 14 render paths on both renderers — cursor-only updates, shrink, grow, unchanged-last-line, trailing→non-trailing transition, and sync().

Note for anyone tempted by an even smaller diff: subtracting 1 from visibleLineCount unconditionally is not correct. It breaks trailing-newline frames and produces a visible drift where the frame walks down the screen on each keystroke.

Relation to existing work

  • Fix cursorUp offset in incremental rendering for trailing newline #910 (merged) fixed the same class of off-by-one on the rewind side of an incremental frame: cursorUp(previousVisible - 1)cursorUp(previousLines.length - 1). This PR fixes the other end of the same frame — the cursor suffix — which was left with the original assumption.
  • Fullscreen: trailing newline on initial render <> lack of trailing newline on update #808 / Fix extra newline in full-screen mode #769 introduced the "no trailing newline in fullscreen" behaviour that buildCursorSuffix was never taught about.
  • Prior art, which I found only after opening this PR: qwen-code — an Ink-based CLI pinned to ink@7.0.3 — hit this in practice and shipped the same fix as a vendored patch-package patch in QwenLM/qwen-code#7998 (merged 2026-07-29), closing their issue #7980: the hardware cursor rendering one row above the input line, and IME composition windows appearing one row too high as a result. Their patch threads a hasTrailingNewline flag through the helpers; this PR corrects the row basis instead, which turns out to make the flag unnecessary. The diagnosis and the observable signature are identical, though — their pty capture shows cursorUp(3) emitted where cursorUp(2) was correct, and they reached the same conclusion that buildReturnToBottom() needs no change. I arrived at the same diagnosis independently while debugging the same symptom. Since upstream still carries the bug, downstream consumers are each maintaining their own copy of it.
  • fix: clamp cursor-up to viewport height, preventing terminal scroll-to-top #917 (open) adds a viewportHeight parameter to the same buildCursorSuffix signature for an unrelated concern (clamping cursorUp to the viewport), so the two will conflict textually. The changes are orthogonal — clamping bounds the distance, this PR fixes the starting point — and I'm happy to rebase on top of fix: clamp cursor-up to viewport height, preventing terminal scroll-to-top #917 in whichever order suits you.

Verification

  • Replayed the emitted escape sequences through a cursor tracker: after the fix the cursor lands exactly on the requested (x, y) in every tested case on both renderers; before the fix it is 1–2 rows high whenever the frame has no trailing newline (the error compounds across consecutive renders, because the next frame's buildReturnToBottom starts from a position that is already wrong).

  • 11 tests added, all of which fail on master and pass with this change. They are integration tests by necessity — as noted above, buildCursorSuffix()'s body is unchanged, so no unit test of it can distinguish the two versions.

    Four drive Ink itself (test/cursor.tsx), which is where the trailing newline is decided. Both are parameterised over incrementalRendering: false and true, since createStandard and createIncremental are separate implementations:

    • A frame that exactly fills the viewport, asserted across three steps: the first render, a content-changing rerender, and a cursor-only update.
    • A frame taller than the viewport, whose second render clears the terminal and repositions through log.sync() — the only route by which that call site is reachable from a real app.

    Each asserts both that the corrected sequence is present and that the pre-fix one is absent, so a frame emitting both would still fail. Fullscreen is induced by assigning rows on the fake stdout, following the existing convention in test/terminal-resize.tsx; the tests await waitUntilRenderFlush() rather than a fixed delay.

    Seven exercise log-update directly (test/log-update.tsx), parameterised over both renderers where the path exists in both: the cursor suffix after a normal render, after a cursor-only update, after sync(), and after the incremental shrink branch — which establishes the cursor's starting row with different arithmetic than the grow path but shares the same suffix call.

  • The incremental test also asserts its first write, covering the previousOutput.length === 0 branch. Ink reaches that branch in production whenever useStdout().write() clears and restores the frame, so in fullscreen it runs with no trailing newline and a live cursor.

  • npm test passes: 1047 tests, 4 known failures (pre-existing test.failing() declarations in width-height / flex-justify-content, unrelated to this area).

buildCursorSuffix assumed the cursor always sits at line visibleLineCount,
i.e. just past the last output line. That only holds when the output ends
with a newline. Without one — fullscreen mode — the renderer leaves the
cursor on the last line itself (line visibleLineCount - 1): the incremental
renderer skips the final cursorNextLine and omits the trailing newline on
purpose, and the standard renderer simply writes a string that does not end
in a newline. Every cursorUp built from that assumption therefore overshoots
by one line, placing the terminal cursor one row above where the app asked
for it.

Thread the trailing-newline flag from log-update into buildCursorSuffix and
drop one line when it is absent. The parameter defaults to true, so frames
that end with a newline keep emitting byte-identical output.
Every existing sync() test passes a string ending in a newline, so the two
sync() paths touched by the previous commit had no committed coverage for
the case the fix is about. Add it for both renderers.

Also rewrite the buildReturnToBottom() comment. Its arithmetic is already
correct for both cases, but the comment explained only the trailing-newline
one — an asymmetry that is easy to misread now that buildCursorSuffix()
distinguishes them explicitly.
…bling

Replaces the hasTrailingNewline flag introduced in the previous two commits.
The flag was never necessary: the row the renderer leaves the cursor on is
`lines.length - 1` for `lines = str.split('\n')` in both cases. With a trailing
newline the split yields one extra empty element and the renderer stops just
past the last visible line; without one there is no extra element and it stops
on the last visible line. Both are `lines.length - 1`.

That is the basis buildReturnToBottom() already measures from. buildCursorSuffix()
was the odd one out, taking visibleLineCount — which subtracts the trailing empty
element and so only coincides with the cursor's row when a trailing newline is
present. Its first parameter becomes bottomLine and the call sites pass
`lines.length - 1`.

CursorOnlyInput drops visibleLineCount in favour of the previousLineCount it
already carries: buildReturnToBottom() has just placed the cursor on that exact
row, so deriving it from the output a second time recomputed a known value from
a weaker input.

The body of buildCursorSuffix() is unchanged, which is the diagnosis in one line:
the defect was never in the helper, only in what the call sites handed it. Output
is byte-identical to the flag-threading version across 10656 render scenarios.
@sindresorhus

Copy link
Copy Markdown
Collaborator

One thing needed: Add an Ink-level regression test that renders exactly to the viewport height with useCursor() on a nonzero row, then verifies a changed rerender and a cursor-only update. The current tests cover the helper directly, but not the fullscreen and sync wiring.

The existing tests hand log-update a hand-built string, but the trailing
newline is decided a layer above, in `outputToRender = isFullscreen ?
output : output + '\n'`. A test that skips that layer cannot reach the
condition this fix is about, which is why the defect survived the helper's
own unit tests.

These drive `render()` instead. Fullscreen is induced by setting `rows` on
the fake stdout, as `test/terminal-resize.tsx` already does:

- A frame that exactly fills the viewport, asserted across a first render,
  a content-changing rerender, and a cursor-only update.
- A frame taller than the viewport, whose second render clears the terminal
  and repositions through `log.sync()` — the only route by which that call
  site is reachable from a real app.

Both run against `createStandard` and `createIncremental`. They are separate
implementations, so present-day equivalence is not a reason to leave one
untested; and the behaviour that creates the precondition — skipping the
final `cursorNextLine` when there is no trailing newline — exists only in
the incremental renderer.

Each case asserts that the corrected sequence is present *and* that the
pre-fix one is absent, so a frame emitting both would still fail.

Also covers the incremental shrink branch with an active cursor. It reaches
the shared cursor suffix through different arithmetic than the grow path
(`eraseLines()` + `cursorUp(visibleCount)` rather than
`cursorUp(previousLines.length - 1)`), and this change altered what that
call site is passed.

All 11 tests added by this PR fail on master.
@sindresorhus
sindresorhus merged commit cdc18fa into vadimdemedes:master Aug 3, 2026
2 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants