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);
});
});