From 8140c663cebfc8dc189b30c58ed3e4bbb22ff734 Mon Sep 17 00:00:00 2001 From: Yunfei He Date: Sat, 30 May 2026 14:30:02 +0800 Subject: [PATCH] fix(runtime): give screen-reader mode a dedicated write path (Ink parity, G59) (#62) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .agents/docs/parity-ledger.md | 2 +- .../accessibility/screen-reader-live.test.tsx | 149 +++++++++++++++++- packages/runtime/src/render.ts | 71 ++++++++- 3 files changed, 217 insertions(+), 5 deletions(-) diff --git a/.agents/docs/parity-ledger.md b/.agents/docs/parity-ledger.md index 92d3b3d..122070f 100644 --- a/.agents/docs/parity-ledger.md +++ b/.agents/docs/parity-ledger.md @@ -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 `` with DIRECT text/Newline children (not wrapped in ``) 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 | — | — | diff --git a/packages/runtime-tests/integration/accessibility/screen-reader-live.test.tsx b/packages/runtime-tests/integration/accessibility/screen-reader-live.test.tsx index 184c5a7..0980ac8 100644 --- a/packages/runtime-tests/integration/accessibility/screen-reader-live.test.tsx +++ b/packages/runtime-tests/integration/accessibility/screen-reader-live.test.tsx @@ -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 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 () => ( + + + {{ + default: ({ item }: { item: string }) => {item}, + }} + + Alpha {tick.value} + Bravo + Charlie + Delta + + ); + }); + + 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 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 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 () => ( + + + {{ + default: ({ item }: { item: string }) => {item}, + }} + + Live frame + + ); + }); + + 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(); +}); diff --git a/packages/runtime/src/render.ts b/packages/runtime/src/render.ts index 8ef736f..664539b 100644 --- a/packages/runtime/src/render.ts +++ b/packages/runtime/src/render.ts @@ -727,7 +727,18 @@ export function createApp(root: Component, rootProps?: RootProps | null): TuiApp } } const hasStaticOutput = staticOutput !== "" && staticOutput !== "\n"; - if (hasStaticOutput) { + // fullStaticOutput is the accumulated 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"); }