fix(runtime): hide the caret on app.clear() instead of re-showing it (Ink parity) (#190)

app.clear() should wipe the rendered output and leave the terminal caret
HIDDEN, like Ink v7.0.4. Instead vue-tui repositioned and RE-SHOWED the
caret on the now-blank screen.

Same scenario both sides (useCursor {x:5,y:0}, "Hello", columns 40):
  Ink     clear() bytes: \x1b[?25l \x1b[1B \x1b[1G \x1b[2K \x1b[1A \x1b[2K \x1b[G
  vue-tui clear() bytes: ...same... + \x1b[1A \x1b[6G \x1b[?25h   (BUG)

Root cause: mountedClear() runs writer.clear() (hide + erase, correct) then
writer.sync(...). vue-tui's sync re-emits the PERSISTENT declared cursor (a
blessed divergence that is correct for repaints, which redraw the content),
so it wrote buildCursorSuffix = reposition + show. But clear() erases WITHOUT
redrawing, so re-asserting the caret floats it on a blank screen. Ink's own
clear()-time sync sees cursorDirty=false and emits no caret for the same
reason.

Fix: add an optional SyncOptions { cursor?: boolean } to log-update's sync
(both the standard and incremental variants) and thread it through
FrameWriter.sync. When cursor:false, sync treats the active cursor as
undefined for that call only: no reposition/show, and (since clear() already
set cursorWasShown=false) no hide either. It does NOT touch the persistent
cursorPosition, so the NEXT real commit re-shows the caret normally. Only
mountedClear() passes { cursor: false }; the clearTerminal/resize sync and
the external-write restoreLastOutput path (which redraw) keep the default
cursor:true, so they still re-assert the caret.

Verified byte-exact against real Ink v7.0.4 across a 10-scenario matrix
(active cursor, no cursor, clear-then-rerender, multiline y>0, {0,0}, two
clears, owner-unmounted, non-interactive/debug no-op, external-write restore,
clear-then-resize). New test: clear-cursor.test.tsx (raw interactive stdout
byte capture; testing lastFrame() is content-only and cannot see cursor
escapes).

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Yunfei He
2026-06-15 01:01:34 +08:00
committed by GitHub
parent fd81656c3d
commit be045cfaff
4 changed files with 492 additions and 10 deletions
@@ -0,0 +1,437 @@
// app.clear() must wipe the rendered output and leave the terminal caret
// HIDDEN — matching real Ink v7.0.4. The bug: vue-tui's clear() re-seated the
// persistent cursor (reposition + show) on the now-blank screen, so the caret
// floated on a wiped frame. clear() erases WITHOUT redrawing, so re-asserting
// the caret there is wrong; the persistent-declaration only applies to real
// commits/restores (which DO redraw the content).
//
// This is observable only at the interactive stream level: the @vue-tui/testing
// lastFrame() is content-only and never sees the cursor escapes (\x1b[?25h /
// \x1b[?25l / reposition). So we mount a REAL interactive TTY (isTTY:true,
// debug:false), capture the raw stdout write chunks (Ink's getWriteCalls
// pattern), and assert the byte-level cursor sequences.
//
// Every expectation below was cross-checked against real Ink v7.0.4 (the
// captured CLEAR_BYTES are inlined per scenario). ansiEscapes.cursorTo(x) is a
// 1-based column move `\x1b[${x+1}G`, and ansiEscapes.cursorTo(0) collapses to
// the bare `\x1b[G`.
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, useStdout } from "@vue-tui/runtime";
const SHOW = "\x1b[?25h";
const HIDE = "\x1b[?25l";
function makeTtyStdout(): { stream: NodeJS.WriteStream; writes: string[] } {
const stream = new PassThrough() as unknown as NodeJS.WriteStream;
// columns:40 to match the Ink ground-truth capture geometry.
Object.assign(stream, { isTTY: true, columns: 40, rows: 10 });
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;
}
function makeTtyStderr(): NodeJS.WriteStream {
const stream = new PassThrough() as unknown as NodeJS.WriteStream;
Object.assign(stream, { isTTY: true, columns: 40, rows: 10 });
return stream;
}
// maxFps:0 makes commits immediate (no ~34ms throttle), so each frame is
// flushed synchronously through log-update via waitUntilRenderFlush().
function mountOpts(stdout: NodeJS.WriteStream) {
return {
stdout,
stdin: makeTtyStdin(),
stderr: makeTtyStderr(),
interactive: true,
exitOnCtrlC: false,
maxFps: 0,
patchConsole: false,
};
}
describe("app.clear() cursor parity (interactive stream level)", () => {
test("S1: clear() with an active cursor leaves the caret HIDDEN (no show, no reposition)", async () => {
// Ink v7.0.4 CLEAR_BYTES:
// "\x1b[?25l\x1b[1B\x1b[1G\x1b[2K\x1b[1A\x1b[2K\x1b[G"
// -> hide + return-to-bottom + erase the 2 lines, NO show, NO reposition.
const { stream: stdout, writes } = makeTtyStdout();
const App = defineComponent(() => {
const { setCursorPosition } = useCursor();
return () => {
setCursorPosition({ x: 5, y: 0 });
return h(Text, null, () => "Hello");
};
});
const app = createApp(App);
app.mount(mountOpts(stdout));
await app.waitUntilRenderFlush();
const before = writes.length;
app.clear();
const clearBytes = writes.slice(before).join("");
// The core assertion: clear() must NOT show the cursor and must NOT
// reposition it. Re-showing it would float the caret on the wiped screen.
expect(clearBytes).not.toContain(SHOW);
// No reposition (cursorTo(5) -> "\x1b[6G") after the erase.
expect(clearBytes).not.toContain("\x1b[6G");
// It DOES still hide + erase (Ink emits the hide via the return-to-bottom
// prefix, which begins with HIDE because the cursor was shown).
expect(clearBytes).toContain(HIDE);
expect(clearBytes).toContain("\x1b[2K");
// Byte-exact match to the Ink ground-truth capture.
expect(clearBytes).toBe("\x1b[?25l\x1b[1B\x1b[1G\x1b[2K\x1b[1A\x1b[2K\x1b[G");
app.unmount();
});
test("S2: clear() with NO cursor ever declared erases only (no hide, no show)", async () => {
// Ink v7.0.4 CLEAR_BYTES: "\x1b[2K\x1b[1A\x1b[2K\x1b[G" (erase only).
const { stream: stdout, writes } = makeTtyStdout();
const App = defineComponent(() => () => h(Text, null, () => "Hello"));
const app = createApp(App);
app.mount(mountOpts(stdout));
await app.waitUntilRenderFlush();
const before = writes.length;
app.clear();
const clearBytes = writes.slice(before).join("");
expect(clearBytes).not.toContain(SHOW);
expect(clearBytes).not.toContain(HIDE);
expect(clearBytes).toBe("\x1b[2K\x1b[1A\x1b[2K\x1b[G");
app.unmount();
});
test("S3: clear() then a reactive update brings the caret BACK (declared position not lost)", async () => {
// Ink v7.0.4: clear() emits no show; the subsequent rerender DELTA re-shows
// the caret (hasShow=true). The clear() hides for now; the next real commit
// re-asserts the persistent declaration and shows it again.
const { stream: stdout, writes } = makeTtyStdout();
const text = shallowRef("Hello");
const App = defineComponent(() => {
const { setCursorPosition } = useCursor();
return () => {
setCursorPosition({ x: 3, y: 0 });
return h(Text, null, () => text.value);
};
});
const app = createApp(App);
app.mount(mountOpts(stdout));
await app.waitUntilRenderFlush();
const beforeClear = writes.length;
app.clear();
const clearBytes = writes.slice(beforeClear).join("");
// clear() does not show the caret.
expect(clearBytes).not.toContain(SHOW);
const beforeRerender = writes.length;
text.value = "World";
await nextTick();
await app.waitUntilRenderFlush();
const rerenderBytes = writes.slice(beforeRerender).join("");
// The caret comes back on the next commit: the new content is drawn and the
// cursor is re-shown at the still-declared position (x=3 -> cursorTo(3) ->
// "\x1b[4G").
expect(rerenderBytes).toContain("World");
expect(rerenderBytes).toContain(SHOW);
expect(rerenderBytes).toContain("\x1b[4G");
app.unmount();
});
test("S4: clear() with multi-line output and a cursor on a non-first line stays HIDDEN", async () => {
// Ink v7.0.4 CLEAR_BYTES:
// "\x1b[?25l\x1b[2B\x1b[1G\x1b[2K\x1b[1A\x1b[2K\x1b[1A\x1b[2K\x1b[1A\x1b[2K\x1b[G"
// -> hide + return-to-bottom (down 2) + erase 4 lines, NO show, NO reposition.
const { stream: stdout, writes } = makeTtyStdout();
const App = defineComponent(() => {
const { setCursorPosition } = useCursor();
return () => {
setCursorPosition({ x: 2, y: 1 });
return h(Box, { flexDirection: "column" }, () => [
h(Text, null, () => "Line1"),
h(Text, null, () => "Line2"),
h(Text, null, () => "Line3"),
]);
};
});
const app = createApp(App);
app.mount(mountOpts(stdout));
await app.waitUntilRenderFlush();
const before = writes.length;
app.clear();
const clearBytes = writes.slice(before).join("");
expect(clearBytes).not.toContain(SHOW);
// No reposition (cursorTo(2) -> "\x1b[3G") after erase.
expect(clearBytes).not.toContain("\x1b[3G");
expect(clearBytes).toContain(HIDE);
expect(clearBytes).toBe(
"\x1b[?25l\x1b[2B\x1b[1G\x1b[2K\x1b[1A\x1b[2K\x1b[1A\x1b[2K\x1b[1A\x1b[2K\x1b[G",
);
app.unmount();
});
test("S5: clear() with the cursor at {x:0,y:0} stays HIDDEN (no reposition, no show)", async () => {
// Ink v7.0.4 CLEAR_BYTES:
// "\x1b[?25l\x1b[1B\x1b[1G\x1b[2K\x1b[1A\x1b[2K\x1b[G"
// -> identical to S1 (the cursor x/y only affect the SHOW path, which is
// suppressed here). The buggy code added "\x1b[1A\x1b[1G\x1b[?25h".
const { stream: stdout, writes } = makeTtyStdout();
const App = defineComponent(() => {
const { setCursorPosition } = useCursor();
return () => {
setCursorPosition({ x: 0, y: 0 });
return h(Text, null, () => "Hello");
};
});
const app = createApp(App);
app.mount(mountOpts(stdout));
await app.waitUntilRenderFlush();
const before = writes.length;
app.clear();
const clearBytes = writes.slice(before).join("");
expect(clearBytes).not.toContain(SHOW);
expect(clearBytes).toBe("\x1b[?25l\x1b[1B\x1b[1G\x1b[2K\x1b[1A\x1b[2K\x1b[G");
app.unmount();
});
test("S6: two clear() calls in a row — second is an erase-only no-op (no hide, no show)", async () => {
// Ink v7.0.4:
// FIRST_CLEAR : "\x1b[?25l\x1b[1B\x1b[1G\x1b[2K\x1b[1A\x1b[2K\x1b[G"
// SECOND_CLEAR: "\x1b[2K\x1b[1A\x1b[2K\x1b[G"
// After the first clear the cursor is hidden and nothing is drawn, so the
// second clear only erases (no hide — cursorWasShown is already false).
const { stream: stdout, writes } = makeTtyStdout();
const App = defineComponent(() => {
const { setCursorPosition } = useCursor();
return () => {
setCursorPosition({ x: 5, y: 0 });
return h(Text, null, () => "Hello");
};
});
const app = createApp(App);
app.mount(mountOpts(stdout));
await app.waitUntilRenderFlush();
const before1 = writes.length;
app.clear();
const first = writes.slice(before1).join("");
const before2 = writes.length;
app.clear();
const second = writes.slice(before2).join("");
expect(first).toBe("\x1b[?25l\x1b[1B\x1b[1G\x1b[2K\x1b[1A\x1b[2K\x1b[G");
expect(second).not.toContain(SHOW);
expect(second).not.toContain(HIDE);
expect(second).toBe("\x1b[2K\x1b[1A\x1b[2K\x1b[G");
app.unmount();
});
test("S7: clear() after the cursor owner unmounted (declaration cleared) erases only", async () => {
// Ink v7.0.4 CLEAR_BYTES: "\x1b[2K\x1b[1A\x1b[2K\x1b[G" (erase only): the
// owner's onScopeDispose set the cursor to undefined, so the prior commit
// already hid it; clear() just erases.
const { stream: stdout, writes } = makeTtyStdout();
const showChild = shallowRef(true);
const Child = defineComponent(() => {
const { setCursorPosition } = useCursor();
return () => {
setCursorPosition({ x: 5, y: 0 });
return h(Text, null, () => "child");
};
});
const App = defineComponent(
() => () => (showChild.value ? h(Child) : h(Text, null, () => "no cursor")),
);
const app = createApp(App);
app.mount(mountOpts(stdout));
await app.waitUntilRenderFlush();
showChild.value = false;
await nextTick();
await app.waitUntilRenderFlush();
const before = writes.length;
app.clear();
const clearBytes = writes.slice(before).join("");
expect(clearBytes).not.toContain(SHOW);
expect(clearBytes).not.toContain(HIDE);
expect(clearBytes).toBe("\x1b[2K\x1b[1A\x1b[2K\x1b[G");
app.unmount();
});
test("S8a: clear() in non-interactive mode is a no-op (no bytes)", async () => {
// Ink no-ops a non-interactive clear() (ink.js:619 `if (this.interactive ...`).
const { stream: stdout, writes } = makeTtyStdout();
const App = defineComponent(() => {
const { setCursorPosition } = useCursor();
return () => {
setCursorPosition({ x: 5, y: 0 });
return h(Text, null, () => "Hello");
};
});
const app = createApp(App);
app.mount({
stdout,
stdin: makeTtyStdin(),
stderr: makeTtyStderr(),
interactive: false,
exitOnCtrlC: false,
patchConsole: false,
});
await app.waitUntilRenderFlush();
const before = writes.length;
app.clear();
expect(writes.slice(before).join("")).toBe("");
app.unmount();
});
test("S8b: clear() in debug mode is a no-op (no bytes)", async () => {
// Ink no-ops a debug clear() (ink.js:619 `&& !this.options.debug`).
const { stream: stdout, writes } = makeTtyStdout();
const App = defineComponent(() => {
const { setCursorPosition } = useCursor();
return () => {
setCursorPosition({ x: 5, y: 0 });
return h(Text, null, () => "Hello");
};
});
const app = createApp(App);
app.mount({
stdout,
stdin: makeTtyStdin(),
stderr: makeTtyStderr(),
debug: true,
exitOnCtrlC: false,
patchConsole: false,
});
await app.waitUntilRenderFlush();
const before = writes.length;
app.clear();
expect(writes.slice(before).join("")).toBe("");
app.unmount();
});
test("S9: the external-write restore path still SHOWS the caret (the fix must not touch it)", async () => {
// Ink v7.0.4 WRITE_BYTES (external useStdout().write): the restore re-shows
// the cursor (hasShow=true). restoreLastOutput() explicitly re-seats the
// cursor and REDRAWS the content, so the caret SHOULD be shown there — unlike
// clear(), which erases without redraw. This guards that the fix is scoped to
// the clear() path only.
const { stream: stdout, writes } = makeTtyStdout();
let writeFn: ((data: string) => void) | undefined;
const App = defineComponent(() => {
const { setCursorPosition } = useCursor();
const { write } = useStdout();
writeFn = write;
return () => {
setCursorPosition({ x: 2, y: 0 });
return h(Text, null, () => "Hello");
};
});
const app = createApp(App);
app.mount(mountOpts(stdout));
await app.waitUntilRenderFlush();
const before = writes.length;
writeFn?.("external write\n");
await app.waitUntilRenderFlush();
const writeBytes = writes.slice(before).join("");
// The content is redrawn AND the cursor is re-shown at x=2 (cursorTo(2) ->
// "\x1b[3G"). This path REDRAWS, so showing the caret is correct.
expect(writeBytes).toContain("Hello");
expect(writeBytes).toContain(SHOW);
expect(writeBytes).toContain("\x1b[3G");
// The LAST visibility change is a SHOW (the caret ends up visible).
expect(writeBytes.lastIndexOf(SHOW)).toBeGreaterThan(writeBytes.lastIndexOf(HIDE));
app.unmount();
});
test("S10: clear() then a resize repaints and re-shows the caret", async () => {
// After clear() the screen is blank and the caret hidden; a resize triggers
// a synchronous repaint (Ink-aligned) that redraws the content and re-shows
// the persistent caret. clear() did not lose the declared position.
const { stream: stdout, writes } = makeTtyStdout();
const App = defineComponent(() => {
const { setCursorPosition } = useCursor();
return () => {
setCursorPosition({ x: 4, y: 0 });
return h(Text, null, () => "Hello");
};
});
const app = createApp(App);
app.mount(mountOpts(stdout));
await app.waitUntilRenderFlush();
app.clear();
const beforeResize = writes.length;
// A resize event drives a synchronous commit (render.ts onResize).
Object.assign(stdout, { columns: 30 });
(stdout as unknown as PassThrough).emit("resize");
await app.waitUntilRenderFlush();
const resizeBytes = writes.slice(beforeResize).join("");
// The repaint redraws the content and re-shows the caret at x=4
// (cursorTo(4) -> "\x1b[5G").
expect(resizeBytes).toContain("Hello");
expect(resizeBytes).toContain(SHOW);
expect(resizeBytes).toContain("\x1b[5G");
app.unmount();
});
});