diff --git a/packages/runtime-tests/integration/lifecycle/clear-cursor.test.tsx b/packages/runtime-tests/integration/lifecycle/clear-cursor.test.tsx new file mode 100644 index 0000000..8cd6bc3 --- /dev/null +++ b/packages/runtime-tests/integration/lifecycle/clear-cursor.test.tsx @@ -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(); + }); +}); diff --git a/packages/runtime/src/io/frame-writer.ts b/packages/runtime/src/io/frame-writer.ts index 6912602..f91076d 100644 --- a/packages/runtime/src/io/frame-writer.ts +++ b/packages/runtime/src/io/frame-writer.ts @@ -1,11 +1,11 @@ -import logUpdate, { type LogUpdate } from "./log-update.ts"; +import logUpdate, { type LogUpdate, type SyncOptions } from "./log-update.ts"; import type { CursorPosition } from "./cursor-helpers.ts"; export interface FrameWriter { write: (frame: string) => void; done: () => void; clear: () => void; - sync: (frame: string) => void; + sync: (frame: string, options?: SyncOptions) => void; setCursorPosition: (pos: CursorPosition | undefined) => void; isCursorDirty: () => boolean; willRender: (frame: string) => boolean; @@ -46,14 +46,16 @@ export function createFrameWriter( lastFrame = null; if (log) log.clear(); }, - sync(frame: string) { + sync(frame: string, options?: SyncOptions) { // Keep this writer's dedup baseline aligned with log-update's internal // previousOutput. Without this, a later write() of `frame` is skipped by // log-update (state synced) while a write() of the *pre-sync* lastFrame // passes this layer's dedup but is dropped by log-update — desyncing the // two dedup layers and dropping a legitimately-changed frame. + // `options` (e.g. { cursor: false } from app.clear()) is forwarded so the + // caller can suppress the cursor emit on this sync — see log-update.sync. lastFrame = frame; - if (log) log.sync(frame); + if (log) log.sync(frame, options); }, setCursorPosition(pos) { if (log) log.setCursorPosition(pos); diff --git a/packages/runtime/src/io/log-update.ts b/packages/runtime/src/io/log-update.ts index 69074dc..ea131e5 100644 --- a/packages/runtime/src/io/log-update.ts +++ b/packages/runtime/src/io/log-update.ts @@ -12,11 +12,21 @@ import { export type { CursorPosition } from "./cursor-helpers.ts"; +export type SyncOptions = { + // When false, sync re-seats only the OUTPUT bookkeeping and emits NO cursor + // escape (no reposition, no show, and — because clear() has already set + // cursorWasShown=false — no hide either). Used by app.clear(): clear() erases + // the lines WITHOUT redrawing them, so re-asserting the persistent caret would + // float it on a blank screen. Defaults to true (the restoreLastOutput path, + // which DOES redraw, still re-shows the caret). See render.ts mountedClear. + cursor?: boolean; +}; + export type LogUpdate = { clear: () => void; done: () => void; reset: () => void; - sync: (str: string) => void; + sync: (str: string, options?: SyncOptions) => void; setCursorPosition: (position: CursorPosition | undefined) => void; isCursorDirty: () => boolean; willRender: (str: string) => boolean; @@ -171,11 +181,20 @@ const createStandard = ( cursorWasShown = false; }; - render.sync = (str: string) => { + render.sync = (str: string, options?: SyncOptions) => { // Persistent-declaration: sync the LAST-declared position (not cursorDirty- // gated), so the clearTerminal / restoreLastOutput sync re-seats the caret // at the declared point too. - const activeCursor = getActiveCursor(); + // + // options.cursor === false suppresses the cursor emit for THIS sync (the + // app.clear() path). clear() erased the lines WITHOUT redrawing them, so + // re-asserting the persistent caret would float it on a blank screen — Ink + // leaves it hidden (its clear()-time sync sees cursorDirty=false, so it + // emits no caret either). We do NOT touch cursorPosition (the declaration + // persists), so the NEXT real render re-shows the caret normally. Treating + // the active cursor as undefined here also drives previousCursorPosition/ + // cursorWasShown to the true post-clear blank state. + const activeCursor = options?.cursor === false ? undefined : getActiveCursor(); cursorDirty = false; const lines = str.split("\n"); @@ -185,6 +204,8 @@ const createStandard = ( // NOT isTTY-gated: Ink's sync() writes the hide directly (Ink // log-update.ts:149-151), NOT via cli-cursor, so it has no isTTY guard — // unlike render()/done()'s hide/show which DO route through cli-cursor. + // After clear() cursorWasShown is already false, so the clear() path (which + // passes cursor:false → activeCursor undefined) writes no hide here either. if (!activeCursor && cursorWasShown) { stream.write(hideCursorEscape); } @@ -372,11 +393,20 @@ const createIncremental = ( cursorWasShown = false; }; - render.sync = (str: string) => { + render.sync = (str: string, options?: SyncOptions) => { // Persistent-declaration: sync the LAST-declared position (not cursorDirty- // gated), so the clearTerminal / restoreLastOutput sync re-seats the caret // at the declared point too. - const activeCursor = getActiveCursor(); + // + // options.cursor === false suppresses the cursor emit for THIS sync (the + // app.clear() path). clear() erased the lines WITHOUT redrawing them, so + // re-asserting the persistent caret would float it on a blank screen — Ink + // leaves it hidden (its clear()-time sync sees cursorDirty=false, so it + // emits no caret either). We do NOT touch cursorPosition (the declaration + // persists), so the NEXT real render re-shows the caret normally. Treating + // the active cursor as undefined here also drives previousCursorPosition/ + // cursorWasShown to the true post-clear blank state. + const activeCursor = options?.cursor === false ? undefined : getActiveCursor(); cursorDirty = false; const lines = str.split("\n"); @@ -386,6 +416,8 @@ const createIncremental = ( // NOT isTTY-gated: Ink's sync() writes the hide directly (Ink // log-update.ts:149-151), NOT via cli-cursor, so it has no isTTY guard — // unlike render()/done()'s hide/show which DO route through cli-cursor. + // After clear() cursorWasShown is already false, so the clear() path (which + // passes cursor:false → activeCursor undefined) writes no hide here either. if (!activeCursor && cursorWasShown) { stream.write(hideCursorEscape); } diff --git a/packages/runtime/src/render.ts b/packages/runtime/src/render.ts index 902357f..654d8ad 100644 --- a/packages/runtime/src/render.ts +++ b/packages/runtime/src/render.ts @@ -841,7 +841,18 @@ export function createApp(root: Component, rootProps?: RootProps | null): TuiApp mountedClear = () => { if (!interactive || debug) return; writer.clear(); - writer.sync(frameState.lastOutputToRender || frameState.lastOutput + "\n"); + // cursor:false — leave the caret HIDDEN after clear() (Ink parity: + // ink.js clear() -> log.clear() then log.sync(...) where cursorDirty is + // already false, so its sync emits no caret). clear() erased the lines + // WITHOUT redrawing them, so re-asserting the persistent caret would float + // it on a now-blank screen. The declared position is NOT discarded (sync + // doesn't touch it), so the next real commit re-shows the caret normally. + // This is scoped to clear() ONLY: restoreLastOutput()'s writer.write() + // (the external-write path) still REDRAWS the content and re-shows the + // caret, so it must keep the default cursor:true behavior. + writer.sync(frameState.lastOutputToRender || frameState.lastOutput + "\n", { + cursor: false, + }); }; const synchronize = shouldSynchronize(stdout, interactive);