From 61e4e09a1ed09ef1b23e2a33092f704a89c6a9bd Mon Sep 17 00:00:00 2001 From: Yunfei He Date: Sat, 30 May 2026 02:12:27 +0800 Subject: [PATCH] fix(runtime): wrap external stdout/stderr writes in synchronized-update markers (Ink parity, G09) (#39) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(runtime): wrap external stdout/stderr writes in synchronized-update markers (Ink parity, G09) writeToStdout/writeToStderr now emit bsu/esu around clear+write+restore when shouldSynchronize, matching the render path and Ink ink.tsx:687-728. The sync variable was already computed at mount time (render.ts:489); the external-write functions simply lacked the wrapping. For writeToStderr, BSU/ESU go to stdout (not stderr) because synchronized-update mode is a stdout capability — exactly mirroring Ink's ink.tsx:717-728 behaviour. Co-Authored-By: Claude Opus 4.8 * chore(parity): ledger — G09 pr-open Co-Authored-By: Claude Opus 4.8 --------- Co-authored-by: Claude Opus 4.8 --- .agents/docs/parity-ledger.md | 2 +- .../composables/use-stdout-bsu.test.tsx | 144 ++++++++++++++++++ packages/runtime/src/render.ts | 10 ++ 3 files changed, 155 insertions(+), 1 deletion(-) create mode 100644 packages/runtime-tests/integration/composables/use-stdout-bsu.test.tsx diff --git a/.agents/docs/parity-ledger.md b/.agents/docs/parity-ledger.md index 3dd863c..df6dda8 100644 --- a/.agents/docs/parity-ledger.md +++ b/.agents/docs/parity-ledger.md @@ -42,7 +42,7 @@ Non-obvious calls made while fixing gaps, recorded for review in the final repor | G06 | text-wrap-transform | Nested / transform fn receives hardcoded index 0 instead of childNode index | P2 | refuted | — | — | | G07 | input-keypress-kitty-paste | Kitty-protocol Ctrl+C triggers app exit in vue-tui but only suppresses the handler in Ink | P2 | candidate | — (see ink-parity.md) | — | | G08 | focus | useFocus does not react to changes in the id prop | P2 | merged | `fix/parity-usefocus-id` | #31 | -| G09 | stdout-stderr-stdin-size-cursor | External stdout/stderr writes are not wrapped in synchronized-update (BSU/ESU) markers | P2 | todo | — | — | +| G09 | stdout-stderr-stdin-size-cursor | External stdout/stderr writes are not wrapped in synchronized-update (BSU/ESU) markers | P2 | pr-open | `fix/parity-external-bsu` | #39 | | G10 | stdout-stderr-stdin-size-cursor | setRawMode silently no-ops in unsupported environments instead of throwing a descriptive error | P2 | todo | — | — | | G11 | render-lifecycle-reconciler | Resize handler does not clear+reset on terminal-width decrease | P2 | todo | — | — | | G12 | render-lifecycle-reconciler | Renderer frame width/rows lack terminal-size fallback (only ?? defaults) | P2 | merged | `fix/parity-renderer-size` | #33 | diff --git a/packages/runtime-tests/integration/composables/use-stdout-bsu.test.tsx b/packages/runtime-tests/integration/composables/use-stdout-bsu.test.tsx new file mode 100644 index 0000000..d615775 --- /dev/null +++ b/packages/runtime-tests/integration/composables/use-stdout-bsu.test.tsx @@ -0,0 +1,144 @@ +/** + * Tests that writeToStdout / writeToStderr wrap external writes in + * synchronized-update markers (BSU/ESU) when the stream is a TTY and the + * runtime is in interactive mode — Ink parity G09. + * + * We use createApp with debug:false and a fake TTY stream so the interactive + * path is exercised. The test config forces CI:"false" so isInCi() returns + * false and shouldSynchronize() returns true. + */ +import { PassThrough } from "node:stream"; +import { defineComponent } from "vue"; +import { expect, test } from "vite-plus/test"; +import { createApp, Text, useStdout, useStderr } from "@vue-tui/runtime"; + +const BSU = "\x1b[?2026h"; +const ESU = "\x1b[?2026l"; + +function makeTtyStream(): NodeJS.WriteStream & { chunks: string[] } { + const s = new PassThrough() as unknown as NodeJS.WriteStream & { chunks: string[] }; + Object.assign(s, { columns: 80, rows: 24, isTTY: true, chunks: [] as string[] }); + s.on("data", (chunk: Buffer) => s.chunks.push(chunk.toString())); + return s; +} + +function makeFakeStdin(): NodeJS.ReadStream { + const s = new PassThrough() as unknown as NodeJS.ReadStream; + Object.assign(s, { + isTTY: true, + setRawMode() { + return s; + }, + setEncoding() { + return s; + }, + }); + (s as any).ref = () => {}; + (s as any).unref = () => {}; + return s; +} + +test("writeToStdout wraps external write in BSU/ESU on TTY interactive stream", async () => { + const stdout = makeTtyStream(); + const stderr = makeTtyStream(); + const stdin = makeFakeStdin(); + + let writeRef: ((data: string) => void) | undefined; + + const App = defineComponent(() => { + const { write } = useStdout(); + writeRef = write; + return () => frame; + }); + + const app = createApp(App); + app.mount({ stdout, stdin, stderr, debug: false, exitOnCtrlC: false }); + + // Let the initial render settle + await new Promise((r) => setTimeout(r, 60)); + + // Clear captured output from initial render + stdout.chunks.length = 0; + + // Trigger an external write through useStdout().write() + writeRef!("external-data\n"); + + // Collect everything written during this external-write call + const output = stdout.chunks.join(""); + + const bsuIdx = output.indexOf(BSU); + const dataIdx = output.indexOf("external-data"); + const esuIdx = output.indexOf(ESU); + + // BSU must appear before the data, and ESU must appear after BSU + expect( + bsuIdx, + `BSU (\\x1b[?2026h) must be present. Got: ${JSON.stringify(output)}`, + ).toBeGreaterThanOrEqual(0); + expect( + dataIdx, + `external-data must be in output. Got: ${JSON.stringify(output)}`, + ).toBeGreaterThanOrEqual(0); + expect( + esuIdx, + `ESU (\\x1b[?2026l) must be present. Got: ${JSON.stringify(output)}`, + ).toBeGreaterThanOrEqual(0); + expect(bsuIdx).toBeLessThan(dataIdx); + expect(dataIdx).toBeLessThan(esuIdx); + + app.unmount(); +}); + +test("writeToStderr wraps external write in BSU/ESU on stdout (Ink parity: stderr gates on stdout TTY)", async () => { + const stdout = makeTtyStream(); + const stderr = makeTtyStream(); + const stdin = makeFakeStdin(); + + let writeRef: ((data: string) => void) | undefined; + + const App = defineComponent(() => { + const { write } = useStderr(); + writeRef = write; + return () => frame; + }); + + const app = createApp(App); + app.mount({ stdout, stdin, stderr, debug: false, exitOnCtrlC: false }); + + // Let the initial render settle + await new Promise((r) => setTimeout(r, 60)); + + // Clear captured output from initial render + stdout.chunks.length = 0; + stderr.chunks.length = 0; + + // Record the cross-stream write order synchronously (writes are sync calls): + // BSU/ESU go to stdout, the data goes to stderr, so a per-stream string can't + // prove the interleaving. Tag each relevant write into one shared timeline. + const timeline: string[] = []; + const origStdoutWrite = stdout.write.bind(stdout); + const origStderrWrite = stderr.write.bind(stderr); + (stdout as { write: (d: unknown, ...a: unknown[]) => unknown }).write = (d, ...a) => { + const s = String(d); + if (s.includes(BSU)) timeline.push("BSU"); + if (s.includes(ESU)) timeline.push("ESU"); + return (origStdoutWrite as (d: unknown, ...a: unknown[]) => unknown)(d, ...a); + }; + (stderr as { write: (d: unknown, ...a: unknown[]) => unknown }).write = (d, ...a) => { + if (String(d).includes("external-err")) timeline.push("DATA"); + return (origStderrWrite as (d: unknown, ...a: unknown[]) => unknown)(d, ...a); + }; + + // Trigger an external write through useStderr().write() + writeRef!("external-err\n"); + + // Per Ink ink.tsx:717-728: bsu/esu are written to STDOUT (not stderr) and the + // data goes to stderr, as one atomic synchronized update. The interleaved + // order across both streams must be BSU → stderr data → ESU. + expect( + timeline, + `expected synchronized order BSU(stdout) → DATA(stderr) → ESU(stdout); got ${JSON.stringify(timeline)}`, + ).toEqual(["BSU", "DATA", "ESU"]); + + app.unmount(); +}); diff --git a/packages/runtime/src/render.ts b/packages/runtime/src/render.ts index 62c9150..1441797 100644 --- a/packages/runtime/src/render.ts +++ b/packages/runtime/src/render.ts @@ -394,9 +394,14 @@ export function createApp(root: Component, rootProps?: RootProps | null): TuiApp stdout.write(data); return; } + // Mirror the render path: wrap clear+write+restore in BSU/ESU when the + // terminal supports synchronized updates, so the three-step sequence is + // atomic and prevents tear/flicker (Ink parity G09, ink.tsx:687-698). + if (synchronize) stdout.write(bsu); writer.clear(); stdout.write(data); restoreLastOutput(); + if (synchronize) stdout.write(esu); } function writeToStderr(data: string) { @@ -409,9 +414,14 @@ export function createApp(root: Component, rootProps?: RootProps | null): TuiApp stderr.write(data); return; } + // Per Ink ink.tsx:717-728: BSU/ESU are emitted on STDOUT (not stderr) + // because synchronized-update mode is a stdout capability, while the + // actual data goes to stderr. The sync gate also uses stdout's isTTY. + if (synchronize) stdout.write(bsu); writer.clear(); stderr.write(data); restoreLastOutput(); + if (synchronize) stdout.write(esu); } const appContext: AppContext = {