fix(runtime,testing): debug-mode Ink byte-parity + source-hook frame capture (#123)
Two coupled changes, both about debug mode (which @vue-tui/testing's render() is built on): 1. Debug stdout is now byte-identical to Ink v7.0.4: the debug commit branch re-emits the FULL accumulated <Static> history every frame (not just the per-commit delta), writes every frame unconditionally (no FrameWriter dedup), and drops the synthetic trailing "\n" — matching Ink's `fullStaticOutput + output` (ink.tsx:558, output.ts has no trailing newline). 2. The test frame-capture no longer reverse-engineers frames out of stdout. The runtime exposes an internal, per-app frame sink (INTERNAL_FRAME_SINK, a Symbol from @vue-tui/runtime/internal; the public MountOptions type is untouched). The debug branch hands each committed frame to the sink, mirroring the stdout writes. @vue-tui/testing's render() builds frames[]/lastFrame() from the sink instead of sniffing stdout. Why: an isTTY:true test stdout (which render() needs for the interactive resize listener) lets isTTY-gated escapes — bracket-paste \x1b[?2004h/l from usePaste — land in a stdout-sniffing capture and pollute frames[]. Capturing at the source makes frames[] provably content-only regardless of which composables a test mounts, while public debug stdout stays byte-exact to Ink (escapes still written, not debug-gated). The test surface stays cleanly tiered (Ink's model): render() = content; createApp+debug:false = in-process control sequences; PTY = real terminal. '' floor, verbatim SGR/OSC8, frames[] multi-frame/static semantics, and terminal.resize() are all preserved. Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -338,9 +338,10 @@ test("render only new items in static output on final render", async () => {
|
||||
|
||||
const { frames, unmount } = await render(App);
|
||||
|
||||
// Initial render — no items, should produce empty or near-empty output
|
||||
// Initial render — no items: Ink writes `fullStaticOutput + output`, both ""
|
||||
// here (ink.tsx:558), so the captured initial frame is the empty string.
|
||||
const initialFrame = frames.at(-1);
|
||||
expect(initialFrame !== undefined).toBe(true);
|
||||
expect(initialFrame).toBe("");
|
||||
|
||||
items.value = ["A"];
|
||||
await nextTick();
|
||||
@@ -679,7 +680,9 @@ test("Static overflows a non-wrapping two-Text row wider than the terminal (Ink
|
||||
const staticFrame = frames.find((f) => f.includes("ABC") && !f.includes("[live]"));
|
||||
expect(staticFrame).toBeDefined();
|
||||
// Ink content-width output is exactly "ABCDEF" (Texts do not shrink/wrap here).
|
||||
expect(staticFrame).toBe("ABCDEF");
|
||||
// The captured static chunk is the "\n"-terminated `fullStaticOutput` (each
|
||||
// static frame is joined as `frame + "\n"`, Ink-faithful — ink.tsx static path).
|
||||
expect(staticFrame).toBe("ABCDEF\n");
|
||||
});
|
||||
|
||||
// G64 (matches): plain wide TEXT must still WRAP to the terminal width, because
|
||||
@@ -706,7 +709,8 @@ test("Static wraps a plain wide text to the terminal width (Ink parity, G64)", a
|
||||
|
||||
const staticFrame = frames.find((f) => f.includes("ABCDE") && !f.includes("[live]"));
|
||||
expect(staticFrame).toBeDefined();
|
||||
expect(staticFrame).toBe("ABCDE\nFGHIJ");
|
||||
// "\n"-terminated static chunk (`fullStaticOutput`), Ink-faithful.
|
||||
expect(staticFrame).toBe("ABCDE\nFGHIJ\n");
|
||||
});
|
||||
|
||||
// G64 (matches): a percent-width child wraps against the terminal-width
|
||||
@@ -742,7 +746,8 @@ test("Static lays out percent-width children against the terminal width (Ink par
|
||||
|
||||
const staticFrame = frames.find((f) => f.includes("HAL") && !f.includes("[live]"));
|
||||
expect(staticFrame).toBeDefined();
|
||||
expect(staticFrame).toBe("HALEND\nF");
|
||||
// "\n"-terminated static chunk (`fullStaticOutput`), Ink-faithful.
|
||||
expect(staticFrame).toBe("HALEND\nF\n");
|
||||
});
|
||||
|
||||
test("Static items do not add blank lines to the dynamic frame", async () => {
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
import { defineComponent, nextTick, shallowRef } from "vue";
|
||||
import { expect, test } from "vite-plus/test";
|
||||
import { Box, createApp, Static, Text } from "@vue-tui/runtime";
|
||||
import {
|
||||
captureWrites,
|
||||
getContentWrites,
|
||||
makeFakeStdin,
|
||||
makeFakeWritable,
|
||||
} from "./test-streams.ts";
|
||||
|
||||
// Debug-mode stdout parity with Ink v7.0.4 (ink.tsx onRender debug branch,
|
||||
// ~lines 550-558). Ink's debug contract is "every update rendered as a separate,
|
||||
// FULL output": it writes `this.fullStaticOutput + output` on every render — the
|
||||
// ENTIRE accumulated <Static> history prepended to the current dynamic frame,
|
||||
// UNCONDITIONALLY (no equality short-circuit). vue-tui writes the same byte
|
||||
// stream as two consecutive stdout.write calls (static history, then dynamic
|
||||
// frame), so these tests assert on the per-render byte stream, not chunk count.
|
||||
// They pin both halves of the contract: (a) the FULL static history is replayed
|
||||
// every render (not just the new delta), and (b) a commit that paints a
|
||||
// byte-identical frame still emits (no FrameWriter dedup).
|
||||
|
||||
test("debug mode replays the FULL accumulated <Static> history on each render", async () => {
|
||||
// Frame 2 (after appending B) must re-print A as well, like Ink's
|
||||
// `fullStaticOutput + output`, not only the per-commit static delta (B).
|
||||
const items = shallowRef<string[]>(["A"]);
|
||||
|
||||
const App = defineComponent(() => () => (
|
||||
<Box flexDirection="column">
|
||||
<Static items={items.value}>
|
||||
{{ default: ({ item }: { item: string }) => <Text key={item}>{item}</Text> }}
|
||||
</Static>
|
||||
<Text>dyn</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, debug: true, exitOnCtrlC: false });
|
||||
|
||||
await nextTick();
|
||||
await nextTick();
|
||||
const rawWritesAfterFrame1 = writes.length;
|
||||
|
||||
// Append B → a render whose static history is now A AND B.
|
||||
items.value = ["A", "B"];
|
||||
await nextTick();
|
||||
await nextTick();
|
||||
|
||||
// Ink writes `fullStaticOutput + output` each render; vue-tui writes that same
|
||||
// byte stream as (static history) + (dynamic frame). Concatenate the raw
|
||||
// post-append writes and assert the FULL history (A AND B) is present — i.e. A
|
||||
// is replayed alongside the new B, not only the delta B.
|
||||
const frame2Stream = writes.slice(rawWritesAfterFrame1).join("");
|
||||
|
||||
expect(frame2Stream).toContain("B"); // sanity: this is the post-append render
|
||||
expect(frame2Stream).toContain("A"); // Ink replays the FULL static history every render
|
||||
// And the dynamic frame is still its own chunk (testing-helper frame model).
|
||||
// Ink writes `fullStaticOutput + output` with NO trailing newline (ink.tsx:558;
|
||||
// `output` is \n-joined and returned WITHOUT a trailing \n), so the dynamic
|
||||
// frame chunk is "dyn", not "dyn\n".
|
||||
expect(getContentWrites(writes)).toContain("dyn");
|
||||
|
||||
app.unmount();
|
||||
});
|
||||
|
||||
test("debug mode emits a frame on a commit that produces a byte-identical frame (no dedup)", async () => {
|
||||
// A re-render that DOES commit (a host mutation occurs — here inserting an
|
||||
// empty 0x0 <Box> via v-if) but paints a byte-identical frame ("row"). Ink's
|
||||
// resetAfterCommit fires onRender() on every React commit and writes
|
||||
// `fullStaticOutput + output` UNCONDITIONALLY, so the identical frame is
|
||||
// re-emitted. vue-tui must too: pre-fix the frame went through the FrameWriter,
|
||||
// whose `frame === lastFrame` dedup swallowed this second identical frame.
|
||||
//
|
||||
// (Note: a pure reactive tick that mutates NO host node never reaches a commit
|
||||
// at all in vue-tui — Vue's fine-grained reconciler only fires the renderer's
|
||||
// onCommit on host mutations, where React runs the commit phase for every
|
||||
// re-render. That deeper render-vs-commit difference is out of scope for this
|
||||
// FrameWriter-dedup fix; this test exercises the dedup the audit identified.)
|
||||
const show = shallowRef(false);
|
||||
|
||||
const App = defineComponent(() => () => (
|
||||
<Box>
|
||||
<Text>row</Text>
|
||||
{show.value ? <Box /> : null}
|
||||
</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, debug: true, exitOnCtrlC: false });
|
||||
|
||||
await nextTick();
|
||||
await nextTick();
|
||||
|
||||
const framesAfterFirst = getContentWrites(writes).filter((w) => w.includes("row")).length;
|
||||
expect(framesAfterFirst).toBeGreaterThanOrEqual(1);
|
||||
|
||||
// Insert an empty box: a host mutation fires the commit, but the painted frame
|
||||
// is byte-identical ("row").
|
||||
show.value = true;
|
||||
await nextTick();
|
||||
await nextTick();
|
||||
|
||||
const framesAfterSecond = getContentWrites(writes).filter((w) => w.includes("row")).length;
|
||||
|
||||
// Ink writes the frame again unconditionally; vue-tui debug mode must emit too.
|
||||
expect(framesAfterSecond).toBe(framesAfterFirst + 1);
|
||||
|
||||
app.unmount();
|
||||
});
|
||||
@@ -0,0 +1,36 @@
|
||||
import { defineComponent } from "vue";
|
||||
import { expect, test } from "vite-plus/test";
|
||||
import { Text, usePaste } from "@vue-tui/runtime";
|
||||
import { render } from "@vue-tui/testing";
|
||||
|
||||
// Headline invariant of the B′ "source-hook frame capture" design: the testing
|
||||
// helper's `frames[]` / `lastFrame()` are CONTENT-ONLY. They are fed each
|
||||
// committed frame DIRECTLY from the runtime (an internal per-app frame sink),
|
||||
// NOT reverse-engineered out of stdout. So terminal-control escapes that the
|
||||
// runtime legitimately writes to stdout (to stay byte-faithful to Ink) must NOT
|
||||
// leak into `frames[]`.
|
||||
//
|
||||
// usePaste() enables bracketed-paste mode, which writes the OSC `\x1b[?2004h`
|
||||
// enable sequence to stdout (gated on stdout.isTTY, which the testing helper's
|
||||
// fake stdout is — render.ts setBracketedPasteMode). Under the OLD
|
||||
// stdout-sniffing capture this escape was pushed into `frames[]` as its own
|
||||
// frame, polluting the content the test observer sees. Under B′ it goes only to
|
||||
// stdout; `frames[]` contains the rendered content alone.
|
||||
test("render() frames are content-only — bracketed-paste escape never leaks into frames", async () => {
|
||||
const App = defineComponent(() => {
|
||||
usePaste(() => {});
|
||||
return () => <Text>content</Text>;
|
||||
});
|
||||
|
||||
const { frames, lastFrame } = await render(App);
|
||||
|
||||
// The rendered content is observable.
|
||||
expect(lastFrame()).toBe("content");
|
||||
|
||||
// The bracketed-paste enable escape was written to stdout (byte-faithful to
|
||||
// Ink) but must NEVER appear in any captured frame.
|
||||
for (const frame of frames) {
|
||||
expect(frame).not.toContain("\x1b[?2004h");
|
||||
expect(frame).not.toContain("\x1b[?2004l");
|
||||
}
|
||||
});
|
||||
@@ -19,3 +19,4 @@ export {
|
||||
resolveFlags,
|
||||
type KittyKeyboardController,
|
||||
} from "./io/kitty-keyboard.ts";
|
||||
export { INTERNAL_FRAME_SINK, type FrameSink } from "./io/frame-sink.ts";
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
/**
|
||||
* Internal, test-only frame observer.
|
||||
*
|
||||
* The `@vue-tui/testing` `render()` helper needs to capture each committed
|
||||
* frame's CONTENT, with no terminal-control escapes mixed in. Reverse-
|
||||
* engineering frames out of the stdout byte stream is fragile: stdout must stay
|
||||
* byte-faithful to Ink (cursor hide/show, bracketed-paste enable/disable, BSU/
|
||||
* ESU, etc.), so any escape the runtime legitimately writes leaks into the
|
||||
* captured frames.
|
||||
*
|
||||
* Instead, the runtime exposes this per-app frame sink: a callback that the
|
||||
* commit path invokes with the EXACT content chunks it writes to stdout (the
|
||||
* accumulated `<Static>` history chunk, then the dynamic frame), in write order
|
||||
* — but NOT the escapes. The helper passes a sink via a Symbol-keyed mount
|
||||
* option (so it never appears on the public `MountOptions` type, keeping that
|
||||
* Ink-faithful) and builds `frames[]` / `lastFrame()` from the callbacks.
|
||||
*
|
||||
* The sink is closure-captured per `mount()` call — there is NO module-global
|
||||
* mutable state — so concurrent test files / multiple apps are fully isolated.
|
||||
*
|
||||
* This is intentionally NOT a public API: it lives behind `@vue-tui/runtime/
|
||||
* internal` and is keyed by a unique symbol the runtime reads off the loosely
|
||||
* typed mount options.
|
||||
*/
|
||||
export type FrameSink = (chunk: string) => void;
|
||||
|
||||
/**
|
||||
* Symbol key for the internal frame sink on the mount options object. Unique
|
||||
* (created via `Symbol(...)`, not `Symbol.for(...)`) so it can never collide
|
||||
* with a user-supplied key and is invisible to normal property enumeration.
|
||||
*/
|
||||
export const INTERNAL_FRAME_SINK: unique symbol = Symbol("vue-tui.internal.frameSink");
|
||||
@@ -29,6 +29,7 @@ import { paint } from "./paint/paint.ts";
|
||||
import { renderScreenReaderOutput } from "./paint/screen-reader.ts";
|
||||
import { findStatics, paintStaticNode } from "./paint/static-channel.ts";
|
||||
import { createFrameWriter } from "./io/frame-writer.ts";
|
||||
import { INTERNAL_FRAME_SINK, type FrameSink } from "./io/frame-sink.ts";
|
||||
import { bsu, esu, shouldSynchronize } from "./io/write-synchronized.ts";
|
||||
import {
|
||||
AppContextKey,
|
||||
@@ -484,6 +485,15 @@ export function createApp(root: Component, rootProps?: RootProps | null): TuiApp
|
||||
const stderr = options.stderr ?? process.stderr;
|
||||
const debug = options.debug ?? false;
|
||||
|
||||
// Internal, test-only per-app frame sink (see io/frame-sink.ts). Read off a
|
||||
// Symbol-keyed mount option so it never appears on the public MountOptions
|
||||
// type (which stays Ink-faithful). Closure-captured here — no module-global
|
||||
// state — so concurrent test files / multiple apps stay isolated. When set,
|
||||
// the debug commit branch forwards the EXACT content chunks it writes to
|
||||
// stdout (static-history chunk, then dynamic frame), MINUS escapes, so the
|
||||
// testing helper's frames[] are provably content-only.
|
||||
const frameSink = (options as { [INTERNAL_FRAME_SINK]?: FrameSink })[INTERNAL_FRAME_SINK];
|
||||
|
||||
// Instance-reuse guard (Ink parity G14): if a live Vue TUI instance is
|
||||
// already rendering to this stdout, warn on stderr and skip wiring a second
|
||||
// competing renderer. The second mount is a deliberate no-op: the caller
|
||||
@@ -559,7 +569,13 @@ export function createApp(root: Component, rootProps?: RootProps | null): TuiApp
|
||||
// clear()/write/restore on an already-torn-down renderer.
|
||||
if (teardownStarted) return;
|
||||
if (debug) {
|
||||
stdout.write(data + frameState.fullStaticOutput + frameState.lastOutput);
|
||||
const out = data + frameState.fullStaticOutput + frameState.lastOutput;
|
||||
stdout.write(out);
|
||||
// Forward the EXACT stdout bytes to the test-only sink. This is content
|
||||
// (app data + replayed frame), not a terminal-control escape, so the
|
||||
// testing helper's frames[] reproduce today's verbatim capture — only
|
||||
// escapes are excluded under B′.
|
||||
frameSink?.(out);
|
||||
return;
|
||||
}
|
||||
if (!interactive) {
|
||||
@@ -582,7 +598,13 @@ export function createApp(root: Component, rootProps?: RootProps | null): TuiApp
|
||||
if (teardownStarted) return;
|
||||
if (debug) {
|
||||
stderr.write(data);
|
||||
stdout.write(frameState.fullStaticOutput + frameState.lastOutput);
|
||||
const replay = frameState.fullStaticOutput + frameState.lastOutput;
|
||||
stdout.write(replay);
|
||||
// Forward the replayed-frame stdout bytes to the test-only sink (content,
|
||||
// not an escape). The stderr `data` is intentionally NOT forwarded: the
|
||||
// old verbatim capture wrapped STDOUT only, so stderr never appeared in
|
||||
// frames[]. Faithful B′ keeps frames[] = the stdout content stream.
|
||||
frameSink?.(replay);
|
||||
return;
|
||||
}
|
||||
if (!interactive) {
|
||||
@@ -872,19 +894,49 @@ export function createApp(root: Component, rootProps?: RootProps | null): TuiApp
|
||||
const outputHeight = frame === "" ? 0 : frame.split("\n").length;
|
||||
|
||||
if (debug) {
|
||||
// Debug mode: write static output directly to stdout, then frame
|
||||
// through the frame writer (which appends "\n" in debug mode).
|
||||
// Clear the writer first so the frame is always emitted even when
|
||||
// the dynamic content is unchanged (static output was written above
|
||||
// as a separate chunk, so the test harness sees it separately).
|
||||
if (hasStaticOutput) {
|
||||
writer.clear();
|
||||
stdout.write(staticOutput);
|
||||
// Debug mode mirrors Ink's onRender debug branch (ink.tsx:550-558): its
|
||||
// contract is "every update rendered as a separate, FULL output". Ink
|
||||
// writes `this.fullStaticOutput + output` UNCONDITIONALLY on every render
|
||||
// — the ENTIRE accumulated <Static> history (not just this commit's delta)
|
||||
// prepended to the current dynamic frame, with NO equality short-circuit.
|
||||
// Two fixes vs the old behavior, BOTH scoped to this debug branch only:
|
||||
// (a) re-emit the FULL accumulated history (frameState.fullStaticOutput,
|
||||
// accumulated at line 847-849) on every render, not just this
|
||||
// commit's static delta — Ink re-prints all static every render; and
|
||||
// (b) write straight to stdout, bypassing the FrameWriter, whose
|
||||
// `frame === lastFrame` dedup would swallow a byte-identical debug
|
||||
// rerender that Ink still emits.
|
||||
// The static history and dynamic frame are written as two consecutive
|
||||
// stdout.write calls: the byte stream reaching the terminal is identical
|
||||
// to Ink's single `fullStaticOutput + output` write (stdout.write inserts
|
||||
// no separator), while keeping the dynamic frame its own chunk so the
|
||||
// @vue-tui/testing render() helper can still split frames (its `lastFrame`
|
||||
// is the dynamic frame, `frames` distinguishes static from dynamic).
|
||||
// The non-debug interactive path below is untouched — it keeps the
|
||||
// per-commit static delta and the FrameWriter dedup (the correct,
|
||||
// efficient live-render behavior).
|
||||
if (frameState.fullStaticOutput !== "") {
|
||||
stdout.write(frameState.fullStaticOutput);
|
||||
// Forward the static-history chunk to the test-only frame sink in the
|
||||
// SAME order it reaches stdout (static first, dynamic next), with the
|
||||
// EXACT value written — so the testing helper's frames[] reproduce
|
||||
// today's content faithfully, minus the escapes (which only go to
|
||||
// stdout). No-op when no sink is registered (normal runs).
|
||||
frameSink?.(frameState.fullStaticOutput);
|
||||
}
|
||||
frameState.lastOutput = frame;
|
||||
frameState.lastOutputToRender = frame;
|
||||
frameState.outputHeight = outputHeight;
|
||||
writer.write(frame);
|
||||
// Ink writes `fullStaticOutput + output` with NO trailing newline
|
||||
// (ink.tsx:558; `output` is \n-joined and returned WITHOUT a trailing
|
||||
// \n — output.ts:305-312). Writing `frame` (not `frame + "\n"`) makes
|
||||
// the concatenation of these two stdout.write calls byte-identical to
|
||||
// Ink's single write.
|
||||
stdout.write(frame);
|
||||
// Always forward the dynamic frame to the sink (mirrors the always-run
|
||||
// stdout.write above). lastFrame() is the most recent dynamic frame; an
|
||||
// empty render forwards "" so lastFrame() reads back "" (Ink-faithful).
|
||||
frameSink?.(frame);
|
||||
if (onRender) onRender({ renderTime: performance.now() - start });
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { PassThrough } from "node:stream";
|
||||
import { nextTick, type Component } from "vue";
|
||||
import { createApp, type TuiApp } from "@vue-tui/runtime";
|
||||
import { INTERNAL_FRAME_SINK, type FrameSink } from "@vue-tui/runtime/internal";
|
||||
import { makeFakeStdin, makeFakeWritable, type RawModeState } from "./streams.ts";
|
||||
import { trackApp } from "./cleanup.ts";
|
||||
|
||||
@@ -57,16 +58,45 @@ export async function render(
|
||||
});
|
||||
const { stream: stdin, rawMode } = makeFakeStdin();
|
||||
|
||||
// Capture committed frames via the runtime's internal per-app frame SINK
|
||||
// (@vue-tui/runtime/internal: INTERNAL_FRAME_SINK), NOT by reverse-engineering
|
||||
// them out of stdout. The runtime's debug commit branch invokes this callback
|
||||
// with the EXACT content chunks it writes to stdout — the accumulated <Static>
|
||||
// history chunk (when non-empty), then the dynamic frame — in write order, and
|
||||
// the debug writeToStdout/writeToStderr branches forward their replayed-frame
|
||||
// bytes too. Terminal-control escapes the runtime writes to stay byte-faithful
|
||||
// to Ink (bracketed-paste `\x1b[?2004h/l`, cursor hide/show, BSU/ESU) are NOT
|
||||
// forwarded, so `frames[]` are provably content-only.
|
||||
//
|
||||
// Properties this preserves vs the old stdout-sniffing capture:
|
||||
// - EMPTY render is forwarded as "" (Ink-faithful: `fullStaticOutput +
|
||||
// output`, both "" — ink.tsx:558), so `frames.at(-1)` reads back "" after
|
||||
// rendering null and `lastFrame()` correctly returns "".
|
||||
// - VERBATIM content — NO trailing-newline stripping. The static-history
|
||||
// chunk stays "\n"-terminated; the dynamic-frame chunk has NO trailing
|
||||
// newline (output.ts:305-312), so a real blank trailing row (e.g. a height
|
||||
// 4 box "AB\n\n\n") survives. `trimFrame` / `trimLines` handle display
|
||||
// trimming in `lastFrame()` instead.
|
||||
// - The flush WRITE BARRIER (`stdout.write("", () => ...)`) never reaches the
|
||||
// sink — barriers are pure stdout drain awaits, not commits — so they can't
|
||||
// clobber `lastFrame()`.
|
||||
const frames: string[] = [];
|
||||
stdout.on("data", (chunk) => {
|
||||
let raw = chunk.toString();
|
||||
// Debug-mode frame writer appends "\n"; strip it so frame height matches yoga layout
|
||||
if (raw.endsWith("\n")) raw = raw.slice(0, -1);
|
||||
frames.push(raw);
|
||||
});
|
||||
const frameSink: FrameSink = (chunk) => {
|
||||
frames.push(chunk);
|
||||
};
|
||||
|
||||
const app: TuiApp = createApp(component, options.props ?? undefined);
|
||||
app.mount({ stdout, stdin, stderr, debug: true, exitOnCtrlC: options.exitOnCtrlC ?? false });
|
||||
// The frame sink is passed via a Symbol-keyed INTERNAL option, kept off the
|
||||
// public MountOptions type (Ink-faithful). Cast through `Parameters` to attach
|
||||
// it without widening the public type.
|
||||
app.mount({
|
||||
stdout,
|
||||
stdin,
|
||||
stderr,
|
||||
debug: true,
|
||||
exitOnCtrlC: options.exitOnCtrlC ?? false,
|
||||
[INTERNAL_FRAME_SINK]: frameSink,
|
||||
} as Parameters<TuiApp["mount"]>[0]);
|
||||
|
||||
trackApp(app);
|
||||
|
||||
@@ -113,8 +143,11 @@ export async function render(
|
||||
|
||||
return {
|
||||
lastFrame: (opts?: LastFrameOptions) => {
|
||||
const f = frames.at(-1);
|
||||
if (f === undefined) return undefined;
|
||||
// An empty render is written (and so captured) as "", so `frames.at(-1)`
|
||||
// reads back "" after rendering null — matching Ink. `?? ""` is a defensive
|
||||
// floor for the (unreachable in practice) pre-first-render read; `render()`
|
||||
// always flushes at least one render before returning.
|
||||
const f = frames.at(-1) ?? "";
|
||||
if (opts?.raw) return f;
|
||||
if (opts?.trimLines)
|
||||
return f
|
||||
|
||||
Reference in New Issue
Block a user