fix(runtime): suppress trailing newline on non-empty screen-reader frames (Ink parity, G46) (#58)

Ink's screen-reader branch (ink.tsx:617-621) writes the wrapped output
verbatim — `stdout.write(erase + wrappedOutput)` with
`lastOutputToRender = wrappedOutput` (NO appended "\n" in ANY case) and
`lastOutputHeight = wrappedOutput === "" ? 0 : wrappedOutput.split("\n").length`.

The earlier G17 fix only suppressed the trailing newline for the EMPTY SR
frame (`output === ""`); a non-empty multi-line SR frame still fell through
to `output + "\n"`, which (1) parked the cursor on a spurious blank line
below the content and (2) made log-update count the previous frame as N+1
lines, emitting `eraseLines(N+1)` instead of `eraseLines(N)` on every
subsequent multi-line SR frame (off-by-one erase).

Broaden the suppression to ALL screen-reader frames so the written output
and its height match Ink's SR branch exactly. Non-SR interactive frames are
untouched — they still append "\n" as before.

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Yunfei He
2026-05-30 11:54:22 +08:00
committed by GitHub
parent 288372dc78
commit 371a5ed556
3 changed files with 77 additions and 8 deletions
+1 -1
View File
@@ -86,7 +86,7 @@ Non-obvious calls made while fixing gaps, recorded for review in the final repor
| G43 | render-lifecycle-reconciler | Non-interactive unmount SKIPS the trailing newline when the final frame is empty — sweep-5 LOW | P3 | todo | — | — | | G43 | render-lifecycle-reconciler | Non-interactive unmount SKIPS the trailing newline when the final frame is empty — sweep-5 LOW | P3 | todo | — | — |
| G44 | static-newline-spacer | `<Static style>` layout props (flexDirection/padding/justify/align/gap/margin/width) dropped from isolated paint — only column-default + visual border/color survive (MEDIUM, sweep-6) | P1 | todo | — | — | | G44 | static-newline-spacer | `<Static style>` layout props (flexDirection/padding/justify/align/gap/margin/width) dropped from isolated paint — only column-default + visual border/color survive (MEDIUM, sweep-6) | P1 | todo | — | — |
| G45 | focus | Programmatic focusNext()/focusPrevious() are no-ops while focus is disabled; Ink keeps them live (guard belongs only in Tab listener) (MEDIUM, sweep-6) | P1 | pr-open | `fix/parity-programmatic-focus-while-disabled` | #57 | | G45 | focus | Programmatic focusNext()/focusPrevious() are no-ops while focus is disabled; Ink keeps them live (guard belongs only in Tab listener) (MEDIUM, sweep-6) | P1 | pr-open | `fix/parity-programmatic-focus-while-disabled` | #57 |
| G46 | render-lifecycle-reconciler | Non-empty multi-line screen-reader frames append a spurious trailing `\n` + off-by-one erase that Ink does not (G17 only handled the empty case) (MEDIUM, sweep-6) | P1 | todo | — | — | | G46 | render-lifecycle-reconciler | Non-empty multi-line screen-reader frames append a spurious trailing `\n` + off-by-one erase that Ink does not (G17 only handled the empty case) (MEDIUM, sweep-6) | P1 | pr-open | `fix/parity-nonempty-sr-frame-newline` | #58 |
| G47 | box-layout-border | Per-edge border toggles (borderTop/Bottom/Left/Right) reserve yoga border space even with no borderStyle set (only on reactive patch) — sweep-6 LOW | P3 | todo | — | — | | G47 | box-layout-border | Per-edge border toggles (borderTop/Bottom/Left/Right) reserve yoga border space even with no borderStyle set (only on reactive patch) — sweep-6 LOW | P3 | todo | — | — |
| G48 | text-wrap-transform | Measure path omits sanitizeAnsi that Ink applies in squashTextNodes (diverges only for private-param/intermediate-byte CSI) — sweep-6 LOW | P3 | todo | — | — | | G48 | text-wrap-transform | Measure path omits sanitizeAnsi that Ink applies in squashTextNodes (diverges only for private-param/intermediate-byte CSI) — sweep-6 LOW | P3 | todo | — | — |
| G49 | stdout-stderr-stdin-size-cursor | restoreLastOutput() uses `??` (nullish) instead of Ink's ` | | ` (falsy) fallback, dropping the restored trailing newline before the first frame — sweep-6 LOW | P3 | todo | — | — | | G49 | stdout-stderr-stdin-size-cursor | restoreLastOutput() uses `??` (nullish) instead of Ink's ` | | ` (falsy) fallback, dropping the restored trailing newline before the first frame — sweep-6 LOW | P3 | todo | — | — |
@@ -1,5 +1,6 @@
import { defineComponent, nextTick } from "vue"; import { defineComponent, nextTick, shallowRef } from "vue";
import { expect, test } from "vite-plus/test"; import { expect, test } from "vite-plus/test";
import ansiEscapes from "ansi-escapes";
import { Box, createApp, Static, Text } from "@vue-tui/runtime"; import { Box, createApp, Static, Text } from "@vue-tui/runtime";
import { import {
makeFakeStdin, makeFakeStdin,
@@ -180,3 +181,67 @@ test.sequential("empty SR frame does not write a spurious blank trailing line",
app.unmount(); app.unmount();
}); });
// G46 (Ink parity): a NON-empty multi-line SR frame must be written verbatim
// with NO appended trailing newline, matching Ink's SR branch (ink.tsx:617-621):
// stdout.write(erase + wrappedOutput); lastOutputToRender = wrappedOutput (no
// "\n"); lastOutputHeight = wrappedOutput.split("\n").length. G17 only handled
// the empty case, so a 2-line frame "Line one\nLine two" got a spurious "\n"
// appended — parking the cursor on a blank line below content AND making every
// subsequent multi-line SR frame erase N+1 lines instead of N (off-by-one).
test.sequential("non-empty multi-line SR frame appends no trailing newline and erases exactly N lines", async () => {
const labels = shallowRef<[string, string]>(["Line one", "Line two"]);
const App = defineComponent(() => {
return () => (
<Box flexDirection="column">
<Text>{labels.value[0]}</Text>
<Text>{labels.value[1]}</Text>
</Box>
);
});
const app = createApp(App);
const stdout = makeFakeWritable({ columns: 80 });
const stderr = makeFakeWritable({ columns: 80 });
const { stream: stdin } = makeFakeStdin();
const writes = captureWrites(stdout);
app.mount({
stdout,
stdin,
stderr,
exitOnCtrlC: false,
isScreenReaderEnabled: true,
});
await nextTick();
await nextTick();
const firstContent = getContentWrites(writes).join("");
// (a) The first SR frame must end exactly at the last content line — no
// spurious trailing newline below "Line two".
expect(firstContent).toContain("Line one\nLine two");
expect(firstContent.endsWith("Line two")).toBe(true);
expect(firstContent.endsWith("Line two\n")).toBe(false);
// Reactive update to a second multi-line frame.
writes.length = 0;
labels.value = ["Line three", "Line four"];
await nextTick();
await nextTick();
const secondRaw = writes.join("");
// (b) The erase for the previous 2-line frame must be exactly eraseLines(2),
// NOT eraseLines(3) (the off-by-one caused by the spurious trailing newline).
expect(secondRaw).toContain(ansiEscapes.eraseLines(2));
expect(secondRaw).not.toContain(ansiEscapes.eraseLines(3));
// And the second frame is itself written verbatim with no trailing newline.
const secondContent = getContentWrites(writes).join("");
expect(secondContent.endsWith("Line four")).toBe(true);
expect(secondContent.endsWith("Line four\n")).toBe(false);
app.unmount();
});
+10 -6
View File
@@ -639,12 +639,16 @@ export function createApp(root: Component, rootProps?: RootProps | null): TuiApp
// Fullscreen: output fills or exceeds terminal height — no trailing newline. // Fullscreen: output fills or exceeds terminal height — no trailing newline.
// Only apply when writing to a real TTY — piped output always gets trailing newlines. // Only apply when writing to a real TTY — piped output always gets trailing newlines.
const isFullscreen = isTty && outputHeight >= viewportRows; const isFullscreen = isTty && outputHeight >= viewportRows;
// SR parity (G17 edge b): Ink's screen-reader path writes the wrapped // SR parity (G17 + G46): Ink's screen-reader branch (ink.tsx:617-621)
// output directly with NO appended newline (ink.tsx:617-621), so an empty // writes the wrapped output verbatim — `stdout.write(erase + wrappedOutput)`
// SR frame emits zero lines instead of a spurious blank line. We scope // with `lastOutputToRender = wrappedOutput` (NO appended "\n" in ANY case)
// this to EMPTY SR output to avoid touching non-SR or non-empty SR frames. // and `lastOutputHeight = wrappedOutput === "" ? 0 : split("\n").length`.
const isEmptyScreenReaderFrame = isScreenReaderEnabled && output === ""; // So EVERY SR frame, empty or not, must skip the trailing newline: an empty
const outputToRender = isFullscreen || isEmptyScreenReaderFrame ? output : output + "\n"; // frame emits zero lines instead of a spurious blank line (G17), and a
// non-empty multi-line frame keeps its true line count so the next-frame
// erase is eraseLines(N), not eraseLines(N+1) (G46 off-by-one). Non-SR
// interactive frames are untouched — they still append "\n" as before.
const outputToRender = isFullscreen || isScreenReaderEnabled ? output : output + "\n";
const shouldClear = shouldClearTerminalForFrame({ const shouldClear = shouldClearTerminalForFrame({
isTty, isTty,