Skip to content

Fix fullscreen trailing newline on initial render#856

Merged
sindresorhus merged 2 commits into
vadimdemedes:masterfrom
costajohnt:fix/initial-render-newline
Feb 7, 2026
Merged

Fix fullscreen trailing newline on initial render#856
sindresorhus merged 2 commits into
vadimdemedes:masterfrom
costajohnt:fix/initial-render-newline

Conversation

@costajohnt

@costajohnt costajohnt commented Jan 23, 2026

Copy link
Copy Markdown
Contributor

Summary

Add a fullscreen render option to fix the trailing newline issue in fullscreen terminal applications.

The problem (issue #808):
In fullscreen mode, the trailing newline added on initial render caused the first line of the UI to be cut off.

Previous approach (broken):
Simply removing the trailing newline broke non-fullscreen use cases where the newline is needed so console output after Ink exits appears on a new line.

New approach (this PR):
Add a fullscreen option that users can enable when building fullscreen terminal applications:

render(<App />, { fullscreen: true });

When fullscreen: true:

  • No trailing newline is added during render (prevents first line cutoff)
  • A newline is added on exit so subsequent console output starts on a new line

When fullscreen: false (default):

  • Existing behavior preserved with trailing newlines (backwards compatible)

Related Issue

Fixes #808

Test plan

  • All existing tests pass (non-pty tests verified locally)
  • Default behavior (fullscreen=false) unchanged from master
  • Fullscreen mode available for apps that need it

Changes

  • src/log-update.ts: Add fullscreen option to createStandard and createIncremental
  • src/ink.tsx: Add fullscreen to Options type and pass to logUpdate.create
  • src/render.ts: Add fullscreen to RenderOptions with documentation

Generated with Claude Code

@costajohnt costajohnt closed this Jan 23, 2026
@costajohnt

Copy link
Copy Markdown
Contributor Author

After further investigation, I found that removing the trailing newline to fix the fullscreen issue (#808) breaks existing functionality for non-fullscreen use cases.

The core problem is that:

  • In fullscreen mode, trailing newlines cause the first line to be cut off
  • In non-fullscreen mode, trailing newlines are needed so console output after Ink exits appears on a new line

A proper fix would require adding a fullscreen option to the render configuration that controls whether trailing newlines are added. This is a more significant change than the simple patch suggested in #808.

I'm closing this PR as the current approach doesn't work without breaking existing tests. If maintainers are interested, I can work on a more comprehensive solution with a fullscreen option.

@costajohnt costajohnt reopened this Jan 23, 2026
@costajohnt

Copy link
Copy Markdown
Contributor Author

I've updated this PR with a better approach.

Previous approach: Unconditionally remove trailing newlines
Problem: This broke non-fullscreen use cases where newlines are needed for proper console output after Ink exits

New approach: Add an opt-in fullscreen option

// For fullscreen terminal apps (fixes #808)
render(<App />, { fullscreen: true });

// Default behavior unchanged (backwards compatible)
render(<App />);

This way:

  • Fullscreen apps can opt-in to the no-newline behavior
  • Existing apps continue to work as expected
  • No breaking changes

Let me know if you'd like any changes to the implementation!

@sindresorhus

Copy link
Copy Markdown
Collaborator

AI review:

  • Avoid overshooting cursor in fullscreen incremental updates — /Users/sindresorhus/dev/forks/ink/src/log-update.ts:109-111
    In fullscreen + incremental mode, cursorUp(fullscreen ? previousCount : previousCount - 1) moves up one line too far because the output no longer ends with a trailing newline. When previousCount is 1, this moves the cursor above the rendered block, so subsequent redraws overwrite the line above the app. This only happens when fullscreen and incrementalRendering are both enabled, but it corrupts terminal output in that mode.

  • Prevent trailing newline during fullscreen incremental redraws — /Users/sindresorhus/dev/forks/ink/src/log-update.ts:120-124
    In fullscreen incremental rendering, the loop still appends " " after every line, so each redraw leaves the cursor on the line below the output and can still scroll the terminal when the output fills the screen, cutting off the first line. This undermines the fullscreen behavior specifically when incrementalRendering is enabled; consider skipping the final newline (or using cursor movement) when fullscreen is true.

@sindresorhus

Copy link
Copy Markdown
Collaborator

We can actually avoid a fullscreen option entirely by making trailing-newline behavior automatic. That would be my preference.

AI idea:

  • In Ink, decide whether to append a trailing newline based on output height vs terminal rows, e.g. append only when outputHeight < rows.
  • In log-update, infer hasTrailingNewline from str.endsWith('\n') and compute cursor math/visible counts from that.

This keeps API surface unchanged and fixes the cursor overshoot/trailing newline issues by making log-update respond to what it actually receives.

@costajohnt

Copy link
Copy Markdown
Contributor Author

Updated per your feedback! I've removed the fullscreen option and implemented automatic detection:

How it works:

  • Ink now compares outputHeight vs terminal rows
  • If outputHeight >= rows (fullscreen): no trailing newline added to the output
  • If outputHeight < rows (non-fullscreen): trailing newline added as before

log-update changes:

  • Detects fullscreen mode by checking if str.endsWith('\n')
  • Handles cursor math and line counts accordingly
  • On done(), ensures proper newline for subsequent console output

This keeps the API surface unchanged while fixing the cursor/newline issues automatically.

@sindresorhus

Copy link
Copy Markdown
Collaborator

AI review:

  • [P1] Fullscreen detection is effectively negated because log-update appends a trailing newline whenever the input lacks one, so fullscreen renders still end with
    \n and can scroll/cut off the first line. src/log-update.ts:26-36, src/log-update.ts:90-93.
  • [P2] Incremental rendering cursor math is off by one when the input already has a trailing newline. visibleCount includes the empty trailing line and
    cursorUp(previousCount - 1) can overshoot, overwriting the line above the app. src/log-update.ts:97-134.
  • [P2] done() tries to add a trailing newline for non‑newline outputs, but previousOutput always ends with \n due to the append, so the newline never gets added.
    This can leave subsequent console output glued to the last line. src/log-update.ts:45-55, src/log-update.ts:161-170.
  • [P2] Fullscreen detection applies a default rows value even when stdout.rows is undefined (non‑TTY), which can suppress trailing newlines for piped output.
    Should gate fullscreen behavior to TTY or only when rows is a real number. src/ink.tsx:275-283.
  • [P3] Patched console output (writeToStdout/writeToStderr) replays this.lastOutput without applying the same newline policy as render output, which can desync
    cursor state once newline handling changes. src/ink.tsx:351-354, src/ink.tsx:372-374.

@costajohnt

Copy link
Copy Markdown
Contributor Author

Updated to address all 5 review issues. Here's a summary of the changes:

[P1] log-update appends trailing newline, negating fullscreen detection
Fixed — log-update no longer appends '\n'. It passes through whatever string it receives. The caller (ink.tsx) now controls whether a trailing newline is included.

[P2] Incremental cursor math off-by-one
Fixed — extracted a visibleLineCount() helper that correctly handles both "a\nb\n" (trailing newline → length - 1) and "a\nb" (no trailing newline → length). All cursor-up and erase-lines math now uses visible counts instead of raw split counts.

[P2] done() newline logic is dead code
Fixed — done() restored to original behavior (reset state + show cursor). The broken conditional newline detection is removed entirely since ink.tsx now owns trailing newline responsibility.

[P2] Fullscreen detection with default rows on non-TTY
Fixed — added isTTY guard: this.options.stdout.isTTY && outputHeight >= this.options.stdout.rows. Piped output always gets trailing newlines since isTTY is falsy. Removed the || 24 default.

[P3] writeToStdout/writeToStderr replay without newline policy
Fixed — added lastOutputToRender field that stores the output string with the correct newline policy from render time. writeToStdout and writeToStderr now replay this.lastOutputToRender instead of this.lastOutput, keeping log-update's cursor state in sync.

@sindresorhus

Copy link
Copy Markdown
Collaborator
  1. [P1] Incremental no-trailing-newline path is still wrong
    File: ink/src/log-update.ts:98, ink/src/log-update.ts:110, ink/src/log-update.ts:118
    createIncremental() still assumes a trailing newline slot in key places:

    • shrink path uses eraseLines(... + 1) unconditionally,
    • unchanged lines always do cursorNextLine,
    • changed lines always append '\n'.
      For inputs without trailing newline, this can move the cursor one line too far (or over-erase).
      Repro I ran:
    • render('A\nB\n'); render('A\nB') emits an extra down move at the end.
    • render('A\nB'); render('A') clears the unchanged first line too.
  2. [P2] Tests no longer cover the new no-trailing behavior in incremental mode
    File: ink/test/log-update.tsx
    The updated tests now pass newline-terminated input almost everywhere, so the no-trailing path (the core fullscreen case) is not protected. Add explicit incremental tests for:

    • trailing -> no-trailing transition,
    • no-trailing -> no-trailing update,
    • shrink/grow when no trailing newline is present.

@costajohnt

Copy link
Copy Markdown
Contributor Author

Addressed both issues:

P1 – Incremental no-trailing-newline path: Fixed three places in createIncremental() that assumed a trailing newline:

  • Shrink path: Only adds the extra erase slot when previousOutput actually ended with '\n'
  • Unchanged lines: Skips cursorNextLine on the last line when there's no trailing newline, preventing cursor overshoot
  • Changed lines: Skips appending '\n' after the last line when the input has no trailing newline

P2 – Tests: Added 5 incremental no-trailing-newline tests:

  • Trailing → no-trailing transition
  • No-trailing → no-trailing update
  • Shrink with no trailing newline
  • Grow with no trailing newline
  • Unchanged last line cursor position (no overshoot)

costajohnt and others added 2 commits February 6, 2026 17:25
Move trailing newline responsibility from log-update to ink.tsx so
fullscreen mode (output fills terminal) can omit the newline that
causes content to scroll up. Adds TTY guard so piped output always
gets trailing newlines. Fixes #856.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
Fix three issues in createIncremental() that assumed a trailing newline:
- Shrink path: only add +1 erase slot when previous output had trailing \n
- Unchanged lines: skip cursorNextLine on last line without trailing \n
- Changed lines: skip appending \n on last line without trailing \n

Add 5 incremental no-trailing-newline tests covering trailing->no-trailing
transition, no-trailing updates, shrink, grow, and cursor overshoot.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@sindresorhus sindresorhus merged commit 9b21b24 into vadimdemedes:master Feb 7, 2026
1 check 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.

Fullscreen: trailing newline on initial render <> lack of trailing newline on update

2 participants