fix(runtime): give screen-reader mode a dedicated write path (Ink parity, G59) (#62)

Ink's onRender screen-reader branch (ink.tsx:573-625) writes the wrapped SR
transcript with a RAW stdout.write using manual ansiEscapes.eraseLines(prev) +
(inline static, if any) + the wrapped output, sets lastOutput/lastOutputToRender/
lastOutputHeight, and RETURNS before the normal interactive frame path. It emits
NO clearTerminal, does NOT accumulate/replay fullStaticOutput, does NOT go
through log-update, and does NOT hide the cursor.

vue-tui routed SR frames through renderInteractiveFrame, so a tall/overflowing
SR transcript (outputHeight >= viewportRows, then previousOutputHeight >
viewportRows) hit the clearTerminal branch — wiping the SR user's scrollback,
replaying accumulated fullStaticOutput, and the mount-time hide left the cursor
hidden.

This adds a dedicated `if (isScreenReaderEnabled) { ... return; }` branch in
commit(), before fullStaticOutput accumulation (now gated off for SR) and before
renderInteractiveFrame, mirroring Ink's SR branch byte-for-byte (eraseLines +
inline static + wrapped output, lastOutputToRender = wrapped output with no
trailing "\n", height = split count). It never clears the terminal, never
replays static, never uses the log-update writer, and the mount-time cursor-hide
is now skipped for SR mode. The normal (non-SR) interactive path is unchanged —
clearTerminal-on-tall-frame still applies there. G17/G46 SR behavior preserved.

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Yunfei He
2026-05-30 14:30:02 +08:00
committed by GitHub
parent c8cbacfbb8
commit 8140c663ce
3 changed files with 217 additions and 5 deletions
@@ -1,7 +1,7 @@
import { defineComponent, nextTick, shallowRef } from "vue";
import { defineComponent, nextTick, onMounted, shallowRef } from "vue";
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, useStdout } from "@vue-tui/runtime";
import {
makeFakeStdin,
makeFakeWritable,
@@ -245,3 +245,148 @@ test.sequential("non-empty multi-line SR frame appends no trailing newline and e
app.unmount();
});
// G59 (Ink parity): a TALL/overflowing SR transcript must NEVER clear the
// terminal, replay accumulated <Static> history, or hide the cursor. Ink's
// onRender SR branch (ink.tsx:573-625) writes the wrapped transcript with a raw
// `stdout.write(eraseLines(prev) + wrappedOutput)` and RETURNS before reaching
// the normal interactive frame path — so it never emits ansiEscapes.clearTerminal,
// never accumulates/replays fullStaticOutput, never routes through log-update,
// and (because SR mounts leave the cursor visible) never writes \x1b[?25l.
//
// vue-tui previously routed SR through renderInteractiveFrame, so a transcript
// taller than the viewport (outputHeight >= viewportRows on frame 1, then
// previousOutputHeight > viewportRows on frame 2 = wasOverflowing) hit the
// clearTerminal branch — wiping the SR user's scrollback. The fake TTY here is 2
// rows tall and the transcript is 4 lines, so the OLD code would clear on the
// second commit.
test.sequential("tall/overflowing SR transcript never clears terminal, replays static, or hides cursor", async () => {
const tick = shallowRef(0);
const App = defineComponent(() => {
return () => (
<Box flexDirection="column">
<Static items={["History line"]}>
{{
default: ({ item }: { item: string }) => <Text key={item}>{item}</Text>,
}}
</Static>
<Text>Alpha {tick.value}</Text>
<Text>Bravo</Text>
<Text>Charlie</Text>
<Text>Delta</Text>
</Box>
);
});
const app = createApp(App);
// Viewport of only 2 rows: the 4-line transcript overflows it, so the buggy
// code took the clearTerminal branch on the second commit.
const stdout = makeFakeWritable({ columns: 80, rows: 2 });
const stderr = makeFakeWritable({ columns: 80, rows: 2 });
const { stream: stdin } = makeFakeStdin();
const writes = captureWrites(stdout);
app.mount({
stdout,
stdin,
stderr,
exitOnCtrlC: false,
isScreenReaderEnabled: true,
});
await nextTick();
await nextTick();
// Drive a second commit so the overflowing-previous-frame branch would fire.
tick.value = 1;
await nextTick();
await nextTick();
const raw = writes.join("");
// (1) The SR transcript must be present.
expect(raw).toContain("Alpha");
expect(raw).toContain("Delta");
// (2) NEVER clear the terminal in SR mode.
expect(raw).not.toContain(ansiEscapes.clearTerminal);
// (3) NEVER hide the cursor in SR mode (no mount-time \x1b[?25l).
expect(raw).not.toContain("\x1b[?25l");
// (4) The accumulated <Static> history must NOT be replayed: "History line"
// is written exactly once (by the static channel), never a second time as
// part of a clearTerminal + fullStaticOutput replay.
const historyOccurrences = raw.split("History line").length - 1;
expect(historyOccurrences).toBe(1);
// (5) Subsequent SR frames erase via eraseLines (raw stdout.write), not via a
// clearTerminal/log-update repaint.
expect(raw).toContain(ansiEscapes.eraseLines(4));
app.unmount();
});
// G59 follow-up (Ink parity): the G59 gate that stops fullStaticOutput
// accumulation for the INTERACTIVE screen-reader branch must NOT also disable it
// in DEBUG mode. Ink accumulates fullStaticOutput in the debug branch regardless
// of SR (ink.tsx:550-553), and its debug writeToStdout replays
// `data + fullStaticOutput + lastOutput` (ink.tsx:677). vue-tui mirrors this:
// the debug writeToStdout (render.ts) writes `data + frameState.fullStaticOutput
// + frameState.lastOutput`. So in debug + SR, an external useStdout().write()
// after a <Static> render MUST replay the accumulated static history. The
// over-broad G59 gate (`!isScreenReaderEnabled`) skipped accumulation here too,
// dropping the static history from the debug replay — an Ink-parity regression.
test.sequential("debug + SR accumulates static history so external writes replay it (Ink parity)", async () => {
const doWrite = shallowRef(false);
const App = defineComponent(() => {
const { write } = useStdout();
onMounted(() => {
// Defer the external write until after the first static commit so
// fullStaticOutput has had a chance to accumulate.
queueMicrotask(() => {
doWrite.value = true;
write("EXTERNAL-WRITE\n");
});
});
return () => (
<Box flexDirection="column">
<Static items={["History line"]}>
{{
default: ({ item }: { item: string }) => <Text key={item}>{item}</Text>,
}}
</Static>
<Text>Live frame</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,
debug: true,
isScreenReaderEnabled: true,
});
await nextTick();
await nextTick();
await nextTick();
// The external write replays `data + fullStaticOutput + lastOutput`. Find the
// chunk that carries the external write and assert it ALSO carries the
// accumulated static history (which the over-broad G59 gate dropped).
const externalChunk = writes.find((w) => w.includes("EXTERNAL-WRITE"));
expect(externalChunk).toBeDefined();
// Ink replays the accumulated static history in the debug external write.
expect(externalChunk).toContain("History line");
app.unmount();
});