fix(runtime): feed useCursor position to the interactive commit path (Ink parity) (#80)
On the interactive commit path the active cursor position was never forwarded to the frame writer, so the cursor was never shown at the useCursor() position, never followed input, and a cursor-only move on a byte-identical frame emitted nothing. Aligns with Ink v7.0.4 by wiring three coupled defects together: - render.ts setCursorPosition now forwards to writer.setCursorPosition, marking log-update's cursorDirty (Ink ink.tsx:494-497). - the synchronized-update commit gate is split into Ink's two levels: the write is gated on willRender() || isCursorDirty(), but BSU/ESU wrap only when willRender() (Ink ink.tsx:1094 outer, :372-382 inner) -- an idle cursor-dirty re-render emits zero bytes, not an empty BSU/ESU pair. - FrameWriter.write() bypasses its frame===lastFrame dedup when the cursor is dirty, so a cursor-only move still reaches log-update's buildCursorOnlySequence. The mount-time hide-cursor write moves before originalMount so the first commit's show is the last visibility change (Ink hides before its first render); a synchronous mount throw now runs best-effort teardown (cursor/alt-screen restore) before rethrowing the ORIGINAL error, matching Ink's constructor-wired signalExit. Adds 8 interactive-TTY tests; the prior use-cursor tests used the debug render() helper where log-update never runs, so they passed for the wrong reason. Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,363 @@
|
||||
// Stream-level cursor parity (Ink test/cursor.tsx:89-193). The existing
|
||||
// use-cursor.test.tsx tests pass for the WRONG reason — they go through the
|
||||
// debug render() helper (debug:true => FrameWriter.log is null, so log-update
|
||||
// never runs) and only assert a local capturedX + lastFrame().toContain. These
|
||||
// tests mount a REAL interactive TTY (isTTY:true, debug:false) so log-update
|
||||
// actually composes the cursor escapes, then capture the raw stdout write
|
||||
// chunks (like Ink's getWriteCalls) and assert the real ANSI cursor sequence.
|
||||
//
|
||||
// ansiEscapes.cursorTo(x) === `\x1b[${x+1}G`, so:
|
||||
// useCursor x=2 -> cursorTo(2) -> "\x1b[3G"
|
||||
// after typing 'a' x=3 -> cursorTo(3) -> "\x1b[4G"
|
||||
// after a space x=4 -> cursorTo(4) -> "\x1b[5G"
|
||||
import { PassThrough } from "node:stream";
|
||||
import { defineComponent, h, nextTick, shallowRef } from "vue";
|
||||
import { describe, expect, test } from "vite-plus/test";
|
||||
import { Box, Text, createApp, useCursor, useInput, useStdout } from "@vue-tui/runtime";
|
||||
|
||||
const showCursorEscape = "\x1b[?25h";
|
||||
const hideCursorEscape = "\x1b[?25l";
|
||||
// Synchronized-update markers (BSU/ESU): begin/end the "?2026" private mode.
|
||||
const bsu = "\x1b[?2026h";
|
||||
const esu = "\x1b[?2026l";
|
||||
// ansiEscapes.cursorTo(x) is a 1-based column move: `\x1b[${x + 1}G`.
|
||||
const cursorTo = (x: number) => `\x1b[${x + 1}G`;
|
||||
|
||||
function makeTtyStdout(): { stream: NodeJS.WriteStream; writes: string[] } {
|
||||
const stream = new PassThrough() as unknown as NodeJS.WriteStream;
|
||||
Object.assign(stream, { isTTY: true, columns: 100, rows: 100 });
|
||||
// Capture EVERY write() call (like Ink's getWriteCalls). The cursor escapes
|
||||
// and synchronized-update wrappers are separate write() calls, so an
|
||||
// on("data") listener that coalesces chunks would still see them — but
|
||||
// wrapping write directly mirrors Ink's sinon spy exactly and lets us count
|
||||
// calls for the "writes increased" assertion.
|
||||
const writes: string[] = [];
|
||||
const original = stream.write.bind(stream);
|
||||
stream.write = ((...args: unknown[]) => {
|
||||
writes.push(String(args[0]));
|
||||
return (original as (...a: unknown[]) => boolean)(...args);
|
||||
}) as NodeJS.WriteStream["write"];
|
||||
return { stream, writes };
|
||||
}
|
||||
|
||||
function makeTtyStdin(): NodeJS.ReadStream {
|
||||
const s = new PassThrough() as unknown as NodeJS.ReadStream;
|
||||
Object.assign(s, {
|
||||
isTTY: true,
|
||||
setRawMode(this: NodeJS.ReadStream) {
|
||||
return this;
|
||||
},
|
||||
setEncoding(this: NodeJS.ReadStream) {
|
||||
return this;
|
||||
},
|
||||
});
|
||||
(s as unknown as { ref: () => void }).ref = () => {};
|
||||
(s as unknown as { unref: () => void }).unref = () => {};
|
||||
return s;
|
||||
}
|
||||
|
||||
// Mirrors Ink's InputApp (test/cursor.tsx:65-87): cursor at x = 2 + text.length.
|
||||
const InputApp = defineComponent(() => {
|
||||
const text = shallowRef("");
|
||||
const { setCursorPosition } = useCursor();
|
||||
|
||||
useInput((input, key) => {
|
||||
if (key.backspace || key.delete) {
|
||||
text.value = text.value.slice(0, -1);
|
||||
return;
|
||||
}
|
||||
if (!key.ctrl && !key.meta && input) {
|
||||
text.value = text.value + input;
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
setCursorPosition({ x: 2 + text.value.length, y: 0 });
|
||||
return (
|
||||
<Box>
|
||||
<Text>{`> ${text.value}`}</Text>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
});
|
||||
|
||||
describe("cursor commit-path wiring (interactive stream level)", () => {
|
||||
test("cursor is shown at the useCursor position after first render", async () => {
|
||||
const { stream: stdout, writes } = makeTtyStdout();
|
||||
const stdin = makeTtyStdin();
|
||||
|
||||
// maxFps:0 makes commits immediate (no ~34ms throttle), so the first frame
|
||||
// is flushed synchronously through log-update.
|
||||
const app = createApp(InputApp);
|
||||
app.mount({ stdout, stdin, exitOnCtrlC: false, maxFps: 0 });
|
||||
await app.waitUntilRenderFlush();
|
||||
|
||||
const output = writes.join("");
|
||||
expect(output).toContain(showCursorEscape);
|
||||
// x=2 -> cursorTo(2) -> "\x1b[3G"
|
||||
expect(output).toContain(cursorTo(2));
|
||||
|
||||
app.unmount();
|
||||
});
|
||||
|
||||
test("last cursor visibility change after first render is SHOW, not HIDE", async () => {
|
||||
// Ink test/cursor.tsx:113-134. A later commit must not re-hide the cursor
|
||||
// that log-update showed.
|
||||
const { stream: stdout, writes } = makeTtyStdout();
|
||||
const stdin = makeTtyStdin();
|
||||
|
||||
const app = createApp(InputApp);
|
||||
app.mount({ stdout, stdin, exitOnCtrlC: false, maxFps: 0 });
|
||||
await app.waitUntilRenderFlush();
|
||||
|
||||
const output = writes.join("");
|
||||
expect(output.lastIndexOf(showCursorEscape)).toBeGreaterThan(
|
||||
output.lastIndexOf(hideCursorEscape),
|
||||
);
|
||||
|
||||
app.unmount();
|
||||
});
|
||||
|
||||
test("cursor follows text input (cursorTo(3) after typing 'a')", async () => {
|
||||
const { stream: stdout, writes } = makeTtyStdout();
|
||||
const stdin = makeTtyStdin();
|
||||
|
||||
const app = createApp(InputApp);
|
||||
app.mount({ stdout, stdin, exitOnCtrlC: false, maxFps: 0 });
|
||||
await app.waitUntilRenderFlush();
|
||||
|
||||
stdin.emit("data", "a");
|
||||
await app.waitUntilRenderFlush();
|
||||
|
||||
const output = writes.join("");
|
||||
expect(output).toContain(showCursorEscape);
|
||||
// After 'a', x=3 -> cursorTo(3) -> "\x1b[4G"
|
||||
expect(output).toContain(cursorTo(3));
|
||||
|
||||
app.unmount();
|
||||
});
|
||||
|
||||
test("cursor moves on a space keystroke even when the frame is byte-identical", async () => {
|
||||
// Ink test/cursor.tsx:159-193. A space appends to the text so the cursor
|
||||
// moves, but the rendered frame string can be byte-identical to the previous
|
||||
// one (trailing space gets collapsed/trimmed in layout). Ink still writes a
|
||||
// cursor-only sequence (buildCursorOnlySequence) gated on isCursorDirty(),
|
||||
// so write count must INCREASE and cursorTo(4) must appear.
|
||||
const { stream: stdout, writes } = makeTtyStdout();
|
||||
const stdin = makeTtyStdin();
|
||||
|
||||
const app = createApp(InputApp);
|
||||
app.mount({ stdout, stdin, exitOnCtrlC: false, maxFps: 0 });
|
||||
await app.waitUntilRenderFlush();
|
||||
|
||||
stdin.emit("data", "a");
|
||||
await app.waitUntilRenderFlush();
|
||||
const writeCountAfterA = writes.length;
|
||||
|
||||
stdin.emit("data", " ");
|
||||
await app.waitUntilRenderFlush();
|
||||
|
||||
expect(writes.length).toBeGreaterThan(writeCountAfterA);
|
||||
const output = writes.join("");
|
||||
// After "a ", x=4 -> cursorTo(4) -> "\x1b[5G"
|
||||
expect(output).toContain(cursorTo(4));
|
||||
|
||||
app.unmount();
|
||||
});
|
||||
|
||||
test("a useStdout().write() does not leave the cursor hidden", async () => {
|
||||
// After an external stdout write, restoreLastOutput re-shows the cursor;
|
||||
// a subsequent commit must not re-hide it. So the LAST show index must be
|
||||
// after the LAST hide index (Ink's invariant for an active cursor).
|
||||
let writeFromHook: ((data: string) => void) | undefined;
|
||||
const StdoutWriteApp = defineComponent(() => {
|
||||
const { setCursorPosition } = useCursor();
|
||||
const { write } = useStdout();
|
||||
writeFromHook = write;
|
||||
|
||||
return () => {
|
||||
// Set the cursor every render so it stays active across commits.
|
||||
setCursorPosition({ x: 2, y: 0 });
|
||||
return <Text>Hello</Text>;
|
||||
};
|
||||
});
|
||||
|
||||
const { stream: stdout, writes } = makeTtyStdout();
|
||||
const stdin = makeTtyStdin();
|
||||
|
||||
const app = createApp(StdoutWriteApp);
|
||||
app.mount({ stdout, stdin, exitOnCtrlC: false, maxFps: 0 });
|
||||
await app.waitUntilRenderFlush();
|
||||
|
||||
// External write -> clear() + data + restoreLastOutput() (which re-shows
|
||||
// the cursor). A trailing commit must NOT re-hide it.
|
||||
writeFromHook?.("from stdout hook\n");
|
||||
await app.waitUntilRenderFlush();
|
||||
|
||||
const output = writes.join("");
|
||||
expect(output).toContain(showCursorEscape);
|
||||
expect(output.lastIndexOf(showCursorEscape)).toBeGreaterThan(
|
||||
output.lastIndexOf(hideCursorEscape),
|
||||
);
|
||||
|
||||
app.unmount();
|
||||
});
|
||||
|
||||
test("an idle cursor-dirty re-render emits NO empty BSU/ESU pair", async () => {
|
||||
// DEFECT 1 (Ink fidelity, ink.tsx:372-382 + 1094). When a render marks the
|
||||
// cursor dirty (a fresh position object each render) but BOTH the position
|
||||
// value AND the output are unchanged, willRender() is false. Ink's inner
|
||||
// BSU/ESU gate is `willRender()` ALONE, so it emits ZERO bytes — it does not
|
||||
// wrap a no-op log-update write in a synchronized-update pair. The gate must
|
||||
// therefore NOT emit `\x1b[?2026h` immediately followed by `\x1b[?2026l`
|
||||
// with nothing between (an empty sync-update pair).
|
||||
//
|
||||
// Repro note: Vue's static-VNode optimization suppresses a second commit
|
||||
// when nothing in the tree changes, so we force one with a `key` bump that
|
||||
// remounts the child (remove+insert -> onCommit) while the rendered text
|
||||
// stays byte-identical. patchConsole:false keeps the render path clean.
|
||||
const { stream: stdout, writes } = makeTtyStdout();
|
||||
const stdin = makeTtyStdin();
|
||||
|
||||
let bumpKey: (() => void) | undefined;
|
||||
const KeyBumpApp = defineComponent(() => {
|
||||
const { setCursorPosition } = useCursor();
|
||||
const tick = shallowRef(0);
|
||||
bumpKey = () => {
|
||||
tick.value++;
|
||||
};
|
||||
return () => {
|
||||
// Fresh position object every render at the SAME x/y: marks cursorDirty
|
||||
// but the position VALUE is unchanged from the previous render.
|
||||
setCursorPosition({ x: 2, y: 0 });
|
||||
// Bumping `key` remounts this Text (forces a commit) but the text is
|
||||
// byte-identical, so the rendered frame does not change.
|
||||
return <Box>{h(Text, { key: tick.value }, () => "Hello")}</Box>;
|
||||
};
|
||||
});
|
||||
|
||||
const app = createApp(KeyBumpApp);
|
||||
app.mount({ stdout, stdin, exitOnCtrlC: false, maxFps: 0, patchConsole: false });
|
||||
await app.waitUntilRenderFlush();
|
||||
|
||||
// Only inspect the SECOND (idle) commit's writes — the first commit
|
||||
// legitimately emits a BSU/ESU pair around the initial frame.
|
||||
const writesBeforeIdle = writes.length;
|
||||
bumpKey?.();
|
||||
await nextTick();
|
||||
await app.waitUntilRenderFlush();
|
||||
|
||||
const idleWrites = writes.slice(writesBeforeIdle);
|
||||
// No BSU immediately followed by ESU (an empty synchronized-update pair).
|
||||
const hasEmptySyncPair = idleWrites.some(
|
||||
(chunk, i) => chunk === bsu && idleWrites[i + 1] === esu,
|
||||
);
|
||||
expect(hasEmptySyncPair).toBe(false);
|
||||
// And no BSU leaks at all on this no-op frame (Ink emits zero bytes here).
|
||||
expect(idleWrites).not.toContain(bsu);
|
||||
|
||||
app.unmount();
|
||||
});
|
||||
|
||||
test("a synchronous mount-time throw does not leave the cursor hidden", async () => {
|
||||
// DEFECT 2 (regression). The cursor is hidden BEFORE originalMount so the
|
||||
// first commit's SHOW is the last visibility change. But if originalMount
|
||||
// throws SYNCHRONOUSLY in a way onErrorCaptured cannot catch (a renderer/
|
||||
// patch-level vnode error — here a vnode whose `type` getter throws), the
|
||||
// teardown handlers were registered only AFTER originalMount, so nothing
|
||||
// would ever re-show the cursor -> terminal left permanently invisible.
|
||||
// Ink wires signalExit(this.unmount) in its constructor (ink.tsx:426),
|
||||
// before any hide; we get the same guarantee by tearing down (which shows
|
||||
// the cursor) on a synchronous mount throw before rethrowing.
|
||||
const { stream: stdout, writes } = makeTtyStdout();
|
||||
const stdin = makeTtyStdin();
|
||||
|
||||
const ThrowOnPatchApp = defineComponent(() => {
|
||||
return () => {
|
||||
// A vnode whose `type` getter throws during the renderer's patch phase.
|
||||
// This bypasses the onErrorCaptured boundary (it is a renderer-level
|
||||
// error, not a child component render error).
|
||||
const vnode = h("div");
|
||||
Object.defineProperty(vnode, "type", {
|
||||
get() {
|
||||
throw new Error("boom from vnode type getter");
|
||||
},
|
||||
});
|
||||
return vnode as never;
|
||||
};
|
||||
});
|
||||
|
||||
const app = createApp(ThrowOnPatchApp);
|
||||
let mountThrew = false;
|
||||
try {
|
||||
app.mount({ stdout, stdin, exitOnCtrlC: false, maxFps: 0 });
|
||||
} catch {
|
||||
mountThrew = true;
|
||||
}
|
||||
|
||||
// The mount must have actually thrown (otherwise the repro is invalid).
|
||||
expect(mountThrew).toBe(true);
|
||||
|
||||
const output = writes.join("");
|
||||
// The cursor was hidden on mount; the terminal must not be left hidden.
|
||||
// Either no hide leaked, or a SHOW follows the last HIDE.
|
||||
if (output.includes(hideCursorEscape)) {
|
||||
expect(output).toContain(showCursorEscape);
|
||||
expect(output.lastIndexOf(showCursorEscape)).toBeGreaterThan(
|
||||
output.lastIndexOf(hideCursorEscape),
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test("a synchronous mount throw rethrows the ORIGINAL error even if cursor-restore also throws", async () => {
|
||||
// DEFECT 2b (Codex review): the mount path tears down on a synchronous
|
||||
// throw to re-show the cursor, but teardown's restore (mountedWriter.done()
|
||||
// -> log-update showCursor -> stdout.write("\x1b[?25h")) can ITSELF throw
|
||||
// (e.g. stdout.write fails). If that restore error escapes teardown() it
|
||||
// REPLACES the original mount error, masking the real failure. The contract:
|
||||
// a synchronous mount throw must ALWAYS rethrow the ORIGINAL error, even
|
||||
// when the best-effort cursor/screen restore also fails.
|
||||
const { stream: stdout } = makeTtyStdout();
|
||||
const stdin = makeTtyStdin();
|
||||
|
||||
// Make the show-cursor restore write throw. We wrap write so that the very
|
||||
// act of restoring the cursor (the "\x1b[?25h" escape teardown emits) fails,
|
||||
// standing in for a real stdout whose write() throws during restore.
|
||||
const restoreError = new Error("stdout.write blew up during cursor restore");
|
||||
const originalWrite = stdout.write.bind(stdout);
|
||||
stdout.write = ((...args: unknown[]) => {
|
||||
if (String(args[0]).includes(showCursorEscape)) {
|
||||
throw restoreError;
|
||||
}
|
||||
return (originalWrite as (...a: unknown[]) => boolean)(...args);
|
||||
}) as NodeJS.WriteStream["write"];
|
||||
|
||||
// The root ALSO throws synchronously during mount (a renderer/patch-level
|
||||
// vnode error that bypasses onErrorCaptured), with a distinctive message.
|
||||
const ThrowOnPatchApp = defineComponent(() => {
|
||||
return () => {
|
||||
const vnode = h("div");
|
||||
Object.defineProperty(vnode, "type", {
|
||||
get() {
|
||||
throw new Error("boom from vnode type getter");
|
||||
},
|
||||
});
|
||||
return vnode as never;
|
||||
};
|
||||
});
|
||||
|
||||
const app = createApp(ThrowOnPatchApp);
|
||||
let caught: unknown;
|
||||
try {
|
||||
app.mount({ stdout, stdin, exitOnCtrlC: false, maxFps: 0 });
|
||||
} catch (err) {
|
||||
caught = err;
|
||||
}
|
||||
|
||||
// The error propagated out of mount must be the ORIGINAL mount error, not
|
||||
// the cursor-restore error that the best-effort teardown raised.
|
||||
expect(caught).toBeInstanceOf(Error);
|
||||
expect((caught as Error).message).toBe("boom from vnode type getter");
|
||||
expect((caught as Error).message).not.toBe(restoreError.message);
|
||||
});
|
||||
});
|
||||
@@ -7,6 +7,7 @@ export interface FrameWriter {
|
||||
clear: () => void;
|
||||
sync: (frame: string) => void;
|
||||
setCursorPosition: (pos: CursorPosition | undefined) => void;
|
||||
isCursorDirty: () => boolean;
|
||||
willRender: (frame: string) => boolean;
|
||||
}
|
||||
|
||||
@@ -24,7 +25,13 @@ export function createFrameWriter(
|
||||
|
||||
return {
|
||||
write(frame: string) {
|
||||
if (frame === lastFrame) return;
|
||||
// Skip the frame-dedup early-return when the cursor is dirty: a
|
||||
// cursor-only move (output byte-identical, cursor position changed —
|
||||
// e.g. typing a space that the layout collapses) must still reach
|
||||
// log-update so it emits buildCursorOnlySequence. log-update's own
|
||||
// hasChanges() then decides whether to actually write. Mirrors Ink,
|
||||
// which has no FrameWriter dedup layer and lets log-update own this.
|
||||
if (frame === lastFrame && !(log && log.isCursorDirty())) return;
|
||||
lastFrame = frame;
|
||||
if (debug) {
|
||||
stream.write(frame + "\n");
|
||||
@@ -51,6 +58,9 @@ export function createFrameWriter(
|
||||
setCursorPosition(pos) {
|
||||
if (log) log.setCursorPosition(pos);
|
||||
},
|
||||
isCursorDirty() {
|
||||
return log ? log.isCursorDirty() : false;
|
||||
},
|
||||
willRender(frame: string) {
|
||||
return log ? log.willRender(frame) : true;
|
||||
},
|
||||
|
||||
@@ -592,6 +592,16 @@ export function createApp(root: Component, rootProps?: RootProps | null): TuiApp
|
||||
setCursorPosition(pos: CursorPosition | undefined) {
|
||||
cursorPosition = pos;
|
||||
appContext.cursorPosition = pos;
|
||||
// Mirror Ink's single setCursorPosition (ink.tsx:494-497), which sets
|
||||
// BOTH the instance field AND this.log.setCursorPosition(position) on
|
||||
// every render. Forwarding to the frame writer marks log-update's
|
||||
// cursorDirty so getActiveCursor() returns the position and the commit
|
||||
// gate (output !== lastOutput || isCursorDirty) fires the cursor suffix.
|
||||
// Without this the cursor is never shown/moved on the interactive path.
|
||||
// `writer` is created below in mount() but is always initialized before
|
||||
// any render/setup can call this (originalMount runs after writer creation),
|
||||
// so the optional-chain guards only the pre-mount appContext shape.
|
||||
mountedWriter?.setCursorPosition(pos);
|
||||
},
|
||||
};
|
||||
mountedAppContext = appContext;
|
||||
@@ -676,14 +686,33 @@ export function createApp(root: Component, rootProps?: RootProps | null): TuiApp
|
||||
stdout.write(staticOutput);
|
||||
writer.write(outputToRender);
|
||||
if (synchronize) stdout.write(esu);
|
||||
} else if (synchronize && writer.willRender(outputToRender)) {
|
||||
// Only emit BSU/ESU when log-update will actually write, so unchanged
|
||||
// frames don't produce empty synchronized-update pairs.
|
||||
stdout.write(bsu);
|
||||
writer.write(outputToRender);
|
||||
stdout.write(esu);
|
||||
} else {
|
||||
writer.write(outputToRender);
|
||||
// Mirror Ink's TWO-LEVEL commit gate, which keeps the synchronized-update
|
||||
// wrapper and the "should we touch log-update at all" decision separate:
|
||||
//
|
||||
// - Outer gate (ink.tsx:1094 `output !== lastOutput || log.isCursorDirty()`):
|
||||
// decides whether to call the (throttled) log at all. A cursor-only move
|
||||
// whose position is unchanged from the previous render is still dirty, so
|
||||
// it must reach log-update — willRender() alone would miss it because it
|
||||
// compares positions, not the dirty flag. Here that gate is `willRender ||
|
||||
// isCursorDirty`; when both are false we skip the write entirely.
|
||||
// - Inner gate (ink.tsx:372-382, inside throttledLog): wraps the write in
|
||||
// BSU/ESU only when `willRender(output)` is true. The cursor-dirty-but-not-
|
||||
// willRender case calls log-update WITHOUT the BSU/ESU wrapper, so the dirty
|
||||
// flag is reset and the write no-ops cleanly — Ink emits ZERO bytes there,
|
||||
// not an empty `BSU`+`ESU` pair.
|
||||
//
|
||||
// willRender()/isCursorDirty() must be read BEFORE writer.write():
|
||||
// log-update's render consumes/resets isCursorDirty, so reading them
|
||||
// afterwards would be stale. Both reads are pure (no mutation), and the
|
||||
// bsu/esu wrapper is gated on this single pre-write snapshot.
|
||||
const willRender = writer.willRender(outputToRender);
|
||||
if (willRender || writer.isCursorDirty()) {
|
||||
const shouldWrap = synchronize && willRender;
|
||||
if (shouldWrap) stdout.write(bsu);
|
||||
writer.write(outputToRender);
|
||||
if (shouldWrap) stdout.write(esu);
|
||||
}
|
||||
}
|
||||
|
||||
frameState.lastOutput = output;
|
||||
@@ -894,7 +923,49 @@ export function createApp(root: Component, rootProps?: RootProps | null): TuiApp
|
||||
}
|
||||
mountedAlternateScreen = alternateScreen;
|
||||
|
||||
const proxy = originalMount(tuiRoot) as unknown as ComponentPublicInstance;
|
||||
// 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.
|
||||
// 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.
|
||||
//
|
||||
// This MUST happen BEFORE originalMount: mounting flushes Vue synchronously
|
||||
// and the first commit (which, when useCursor() is active, ends with a
|
||||
// showCursor + cursorTo via log-update) runs inside originalMount via a
|
||||
// post-flush callback. Writing the hide afterwards would land AFTER that
|
||||
// show and leave the cursor hidden — the last visibility change must be the
|
||||
// show, mirroring Ink, which hides before its first render, not after.
|
||||
if (!debug && interactive && !mountedAlternateScreen && !isScreenReaderEnabled) {
|
||||
stdout.write("\x1b[?25l");
|
||||
}
|
||||
|
||||
// The cursor (and alternate screen) have already been hidden/entered above,
|
||||
// but the process-exit and signal-exit teardown handlers are not wired until
|
||||
// after originalMount returns (the resize handler below needs `writer`). If
|
||||
// originalMount throws SYNCHRONOUSLY in a way the onErrorCaptured boundary
|
||||
// can't catch — a renderer/patch-level vnode error (e.g. a vnode whose
|
||||
// `type` getter throws) — nothing would ever restore the cursor and the
|
||||
// terminal would be left permanently invisible. Ink avoids this by wiring
|
||||
// signalExit(this.unmount) in its CONSTRUCTOR (ink.tsx:426), before any
|
||||
// hide/render. We get the same "teardown wired before hide" guarantee by
|
||||
// running teardown() (which shows the cursor, leaves the alt screen and
|
||||
// cleans up — idempotent) before rethrowing. The success path is unchanged:
|
||||
// teardown only runs on a throw, so the last visibility change on a normal
|
||||
// mount is still the SHOW emitted by the first commit.
|
||||
let proxy: ComponentPublicInstance;
|
||||
try {
|
||||
proxy = originalMount(tuiRoot) as unknown as ComponentPublicInstance;
|
||||
} catch (err) {
|
||||
try {
|
||||
teardown(); // best-effort cursor/alt-screen restore
|
||||
} catch {
|
||||
// teardown's restore write (mountedWriter.done() -> log-update
|
||||
// showCursor -> stdout.write("\x1b[?25h")) can itself throw if
|
||||
// stdout.write fails. A failing best-effort restore must NOT replace
|
||||
// `err` — the ORIGINAL mount error must always survive and be rethrown.
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
|
||||
// errorHandler as fallback for errors that bypass onErrorCaptured (e.g.
|
||||
// async errors in Vue's internal scheduler). The error boundary returns
|
||||
@@ -903,15 +974,6 @@ export function createApp(root: Component, rootProps?: RootProps | null): TuiApp
|
||||
appContext.exit(err instanceof Error ? err : new Error(String(err)));
|
||||
};
|
||||
|
||||
// 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.
|
||||
// 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");
|
||||
}
|
||||
|
||||
// Only listen for resize in interactive mode (matching Ink).
|
||||
// Render synchronously on resize rather than through the commit throttle:
|
||||
// a resize is a discrete event that changes the viewport, and Ink's
|
||||
|
||||
Reference in New Issue
Block a user