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:
@@ -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();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user