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:
Yunfei He
2026-05-31 04:57:37 +08:00
committed by GitHub
parent 26cf40987b
commit e3202f345a
3 changed files with 453 additions and 18 deletions
@@ -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);
});
});
+11 -1
View File
@@ -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;
},
+79 -17
View File
@@ -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