fix(runtime): sync log-update with the bytes actually written on the clear path (#199)

The clear-terminal branch of renderInteractiveFrame writes raw `output` (no
trailing newline) via a direct stdout.write, but then called
`writer.sync(outputToRender)`. `outputToRender` appends "\n" for non-fullscreen
frames, so on a fullscreen→non-fullscreen transition (a fullscreen frame
shrinking below the viewport) sync recorded a state that didn't match the screen:
with a declared cursor (useCursor) it placed the persistent caret one row too
high (buildCursorSuffix with hasTrailingNewline=true, basing the caret on row
`visibleLineCount` instead of the real `visibleLineCount - 1`), and it recorded
previousLineCount off by one so the next frame's erase was eraseLines(N+1) (G46
residue). This is the fullscreen→non-fullscreen sibling of #198.

Fix: sync the SAME string just written (`output`). `outputToRender === output`
whenever the frame is fullscreen or screen-reader, so steady-state fullscreen and
SR are byte-for-byte unchanged (G17: an empty SR frame still syncs "" → zero
lines). Leaving-fullscreen, overflowing, and unmount-clear all write raw `output`,
so syncing `output` is consistent for every clear sub-case.

TDD: a new integration test mounts a fullscreen TTY frame with a declared cursor,
shrinks it below the viewport, and asserts the emitted caret row and the
following frame's erase count. Red before the fix (cursorUp(3)/eraseLines(4)),
green after (cursorUp(2)/eraseLines(3)).

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Yunfei He
2026-06-15 04:45:07 +08:00
committed by GitHub
parent 8afc82fcd3
commit 0ba8a5ff0e
2 changed files with 184 additions and 2 deletions
+18 -2
View File
@@ -975,8 +975,24 @@ export function createApp(root: Component, rootProps?: RootProps | null): TuiApp
// string) so synchronization survives log-update's line diffing.
if (synchronize) stdout.write(bsu);
stdout.write(ansiEscapes.clearTerminal + frameState.fullStaticOutput + output);
// Sync log-update state so next render computes correct erase
writer.sync(outputToRender);
// Sync log-update with the SAME bytes we just wrote (raw `output`), NOT
// `outputToRender`. The direct write above emits raw `output` (no
// appended "\n"), so sync()'s recorded state — line count AND the
// hasTrailingNewline basis it feeds buildCursorSuffix — must reflect
// `output`, not the "\n"-suffixed `outputToRender`. They DIVERGE only
// when leaving fullscreen (a fullscreen frame shrinking below the
// viewport): there `outputToRender = output + "\n"` while the screen has
// raw `output`. Syncing `outputToRender` would (a) place the persistent
// caret one row too HIGH (sync emits buildCursorSuffix with
// hasTrailingNewline=true, basing the caret on row `visibleLineCount`
// instead of the real `visibleLineCount - 1`), and (b) record
// previousLineCount off by one so the next frame's return-to-bottom /
// erase is eraseLines(N+1) (G46 residue). This is the
// fullscreen→non-fullscreen sibling of #198. For the steady-state
// fullscreen and screen-reader sub-cases `outputToRender === output`, so
// this is byte-for-byte unchanged (G17: an empty SR frame still syncs ""
// → zero lines, no spurious blank line).
writer.sync(output);
if (synchronize) stdout.write(esu);
} else if (hasStaticOutput) {
// Clear frame -> write static -> re-render frame via log-update