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:
@@ -101,7 +101,7 @@ Non-obvious calls made while fixing gaps, recorded for review in the final repor
|
||||
| G56 | app-exit-instances-animation-sr | exit() misclassifies cross-realm Error objects (no [object Error] fallback) → resolves waitUntilExit() instead of rejecting — sweep-7 LOW | P3 | todo | — | — |
|
||||
| G57 | app-exit-instances-animation-sr | useAnimation reset() while isActive=false zeros frame/time/delta; Ink keeps the frozen (paused) values — sweep-7 LOW | P3 | todo | — | — |
|
||||
| G58 | static-newline-spacer | Standalone `<Transform>` with DIRECT text/Newline children (not wrapped in `<Text>`) renders nothing — text silently dropped; Ink's Transform IS an ink-text host so bare-string children render inline (MEDIUM, sweep-8; canonical README pattern) | P1 | pr-open | `fix/parity-standalone-transform-text` | #61 |
|
||||
| G59 | render-lifecycle-reconciler | Interactive SR frames route through renderInteractiveFrame → a tall/overflowing SR frame emits clearTerminal + replays fullStaticOutput + hides cursor; Ink's dedicated SR branch never clears/accumulates/hides (MEDIUM, sweep-8) | P1 | todo | — | — |
|
||||
| G59 | render-lifecycle-reconciler | Interactive SR frames route through renderInteractiveFrame → a tall/overflowing SR frame emits clearTerminal + replays fullStaticOutput + hides cursor; Ink's dedicated SR branch never clears/accumulates/hides (MEDIUM, sweep-8) | P1 | pr-open | `fix/parity-sr-dedicated-write-path` | #62 |
|
||||
| G60 | box-layout-border | String-typed dimension/position values (bare numeric string e.g. width="50") treated as POINT not PERCENT; Ink applies all string dims as percent — sweep-8 LOW | P3 | todo | — | — |
|
||||
| G61 | stdout-stderr-stdin-size-cursor | Bracketed-paste disable write during stdin dispose() lacks Ink's destroyed/writableEnded (canWriteToStdout) guard — sweep-8 LOW | P3 | todo | — | — |
|
||||
| G62 | render-lifecycle-reconciler | resolveExit()/teardown() writable-stream checks omit the writableLength fallback + stdout.writable flag that Ink's getWritableStreamState uses — sweep-8 LOW | P3 | todo | — | — |
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
@@ -727,7 +727,18 @@ export function createApp(root: Component, rootProps?: RootProps | null): TuiApp
|
||||
}
|
||||
}
|
||||
const hasStaticOutput = staticOutput !== "" && staticOutput !== "\n";
|
||||
if (hasStaticOutput) {
|
||||
// fullStaticOutput is the accumulated <Static> history. Mirror Ink's three
|
||||
// onRender branches: it accumulates in the DEBUG branch (ink.tsx:550-553)
|
||||
// and the normal-interactive branch (ink.tsx:626-628), but NOT in the
|
||||
// dedicated interactive screen-reader branch (ink.tsx:573-625), which
|
||||
// writes static inline + never clears + never replays history. So we must
|
||||
// accumulate ALWAYS in debug (so the debug writeToStdout/writeToStderr
|
||||
// replay of `fullStaticOutput + lastOutput` still includes static history,
|
||||
// regardless of SR), and otherwise only when NOT in the interactive SR
|
||||
// path. The SR exclusion is non-debug only: accumulating for interactive SR
|
||||
// would also make a later non-SR remount on the same stream replay stale
|
||||
// history (the original G59 motivation).
|
||||
if (hasStaticOutput && (debug || !isScreenReaderEnabled)) {
|
||||
frameState.fullStaticOutput += staticOutput;
|
||||
}
|
||||
|
||||
@@ -772,6 +783,59 @@ export function createApp(root: Component, rootProps?: RootProps | null): TuiApp
|
||||
return;
|
||||
}
|
||||
|
||||
if (isScreenReaderEnabled) {
|
||||
// Dedicated screen-reader write path (Ink parity G59), mirroring Ink's
|
||||
// onRender SR branch (ink.tsx:573-625). It writes the transcript with a
|
||||
// RAW stdout.write using manual ansiEscapes.eraseLines(previousHeight) +
|
||||
// (inline static, if any) + the wrapped output, then RETURNS — before the
|
||||
// normal interactive frame path. Crucially it:
|
||||
// - NEVER calls shouldClearTerminalForFrame / emits clearTerminal, so a
|
||||
// tall/overflowing SR transcript does not wipe the user's scrollback;
|
||||
// - NEVER accumulates or replays fullStaticOutput (gated above);
|
||||
// - NEVER routes through the log-update writer (raw writes only);
|
||||
// - leaves the cursor visible (the mount-time hide is skipped for SR).
|
||||
// `frame` is already the wrapped SR output (renderFrame -> wrapAnsi), so
|
||||
// it plays the role of Ink's `wrappedOutput`.
|
||||
const sync = synchronize;
|
||||
if (sync) stdout.write(bsu);
|
||||
|
||||
if (hasStaticOutput) {
|
||||
// Erase the previous main output before writing new static output
|
||||
// (ink.tsx:579-588), then reset the tracked height to 0.
|
||||
const erase =
|
||||
frameState.outputHeight > 0 ? ansiEscapes.eraseLines(frameState.outputHeight) : "";
|
||||
stdout.write(erase + staticOutput);
|
||||
frameState.outputHeight = 0;
|
||||
}
|
||||
|
||||
if (frame === frameState.lastOutput && !hasStaticOutput) {
|
||||
// Unchanged frame and no new static: nothing to write (ink.tsx:590-596).
|
||||
if (sync) stdout.write(esu);
|
||||
if (onRender) onRender({ renderTime: performance.now() - start });
|
||||
return;
|
||||
}
|
||||
|
||||
if (hasStaticOutput) {
|
||||
// Already erased above; write the wrapped output directly.
|
||||
stdout.write(frame);
|
||||
} else {
|
||||
const erase =
|
||||
frameState.outputHeight > 0 ? ansiEscapes.eraseLines(frameState.outputHeight) : "";
|
||||
stdout.write(erase + frame);
|
||||
}
|
||||
|
||||
// Match Ink: lastOutputToRender = wrappedOutput (NO appended "\n" in ANY
|
||||
// case — empty frame => 0 lines, multi-line frame keeps its true count so
|
||||
// the next-frame erase is eraseLines(N), not eraseLines(N+1)).
|
||||
frameState.lastOutput = frame;
|
||||
frameState.lastOutputToRender = frame;
|
||||
frameState.outputHeight = frame === "" ? 0 : frame.split("\n").length;
|
||||
|
||||
if (sync) stdout.write(esu);
|
||||
if (onRender) onRender({ renderTime: performance.now() - start });
|
||||
return;
|
||||
}
|
||||
|
||||
// Interactive path
|
||||
renderInteractiveFrame(frame, outputHeight, hasStaticOutput ? staticOutput : "");
|
||||
if (onRender) onRender({ renderTime: performance.now() - start });
|
||||
@@ -839,7 +903,10 @@ export function createApp(root: Component, rootProps?: RootProps | null): TuiApp
|
||||
|
||||
// Hide cursor on mount (matching Ink). Only in interactive mode — in
|
||||
// debug/test mode or non-interactive the stream may not be a real TTY.
|
||||
if (!debug && interactive && !mountedAlternateScreen) {
|
||||
// Screen-reader mode leaves the cursor VISIBLE (Ink parity G59): Ink's SR
|
||||
// path never hides the cursor (the dedicated SR write branch above does no
|
||||
// cursor management), so a screen-reader user keeps a real terminal cursor.
|
||||
if (!debug && interactive && !mountedAlternateScreen && !isScreenReaderEnabled) {
|
||||
stdout.write("\x1b[?25l");
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user