From f6a552d855f5580b0dad67f1a68e515c6cbefa9a Mon Sep 17 00:00:00 2001 From: Yunfei He Date: Fri, 5 Jun 2026 12:21:10 +0800 Subject: [PATCH] fix(runtime): don't hide the cursor for an empty interactive app (Ink parity) (#140) vue-tui hid the terminal cursor EAGERLY at mount regardless of content, so an interactive app whose root renders nothing emitted `\x1b[?25l` and hid the user's cursor. Ink hides LAZILY (log-update, on the first render that writes) and skips log-update entirely for an empty frame, so an empty app emits zero cursor escapes. Remove the eager mount-time hide and rely on log-update's lazy hide. That alone was insufficient: an empty frame becomes "\n", and the old commit gate `willRender(outputToRender) || isCursorDirty()` was true for "\n", so log-update (and its lazy hide) was still reached. Align the outer commit gate to Ink's exact condition (ink.tsx:1094) `output !== frameState.lastOutput || isCursorDirty()`, comparing the RAW frame; on an empty first commit both are "" so log-update is never reached. `willRender` is retained only for the inner BSU/ESU wrap gate. Verified via PTY: empty app = 0 hides; non-empty = 1 lazy hide; useCursor = hide-then-show within one render (SHOW last, cursor positioned). alt-screen, screen-reader, and non-TTY cursor behavior unchanged. Co-authored-by: Claude Opus 4.8 (1M context) --- .../integration/pty/cursor-empty-app.test.ts | 42 +++++++++++++ .../pty/fixtures/cursor-empty-app.tsx | 25 ++++++++ .../pty/fixtures/cursor-nonempty-app.tsx | 21 +++++++ .../pty/fixtures/cursor-usecursor-app.tsx | 27 ++++++++ packages/runtime/src/render.ts | 62 +++++++++---------- 5 files changed, 146 insertions(+), 31 deletions(-) create mode 100644 packages/runtime-tests/integration/pty/cursor-empty-app.test.ts create mode 100644 packages/runtime-tests/integration/pty/fixtures/cursor-empty-app.tsx create mode 100644 packages/runtime-tests/integration/pty/fixtures/cursor-nonempty-app.tsx create mode 100644 packages/runtime-tests/integration/pty/fixtures/cursor-usecursor-app.tsx diff --git a/packages/runtime-tests/integration/pty/cursor-empty-app.test.ts b/packages/runtime-tests/integration/pty/cursor-empty-app.test.ts new file mode 100644 index 0000000..f6f676c --- /dev/null +++ b/packages/runtime-tests/integration/pty/cursor-empty-app.test.ts @@ -0,0 +1,42 @@ +// Lazy cursor-hide parity (Ink v7.0.4). Ink hides the cursor LAZILY: log-update +// hides on the first render that actually writes (log-update.ts:55-59), and the +// onRender outer gate `output !== lastOutput || log.isCursorDirty()` +// (ink.tsx:1094) skips log-update entirely for an empty frame (both ""). So an +// interactive app whose root renders nothing emits ZERO cursor escapes; vue-tui +// must match. Non-empty + useCursor apps still hide on the first render (the +// lazy hide), so the cursor lifecycle is preserved. +// +// These run under a real PTY (run() spawns a TTY child with FORCE_COLOR=3 + +// CI=false) so the genuine interactive log-update path is exercised, not the +// debug helper. +import { test as it, expect } from "vite-plus/test"; +import { run } from "./helpers/run.ts"; + +const HIDE = "\x1b[?25l"; +const SHOW = "\x1b[?25h"; + +it("interactive empty app (() => null) emits NO cursor-hide escape", async () => { + const output = await run("cursor-empty-app"); + expect(output).toContain("exited"); + // The bug: vue-tui eagerly hid the cursor at mount even though nothing + // renders. Ink emits zero cursor escapes for an empty frame. + expect(output).not.toContain(HIDE); +}); + +it("interactive non-empty app still hides the cursor on first render", async () => { + const output = await run("cursor-nonempty-app"); + expect(output).toContain("exited"); + // The lazy hide (log-update render) covers the non-empty case. + expect(output).toContain(HIDE); +}); + +it("useCursor app: last cursor visibility change is SHOW (cursor visible + positioned)", async () => { + const output = await run("cursor-usecursor-app"); + expect(output).toContain("exited"); + // log-update hides-then-shows within one render; the SHOW must come last so + // the cursor stays visible at the requested position. + expect(output).toContain(SHOW); + expect(output.lastIndexOf(SHOW)).toBeGreaterThan(output.lastIndexOf(HIDE)); + // cursorTo(x=2) -> "\x1b[3G": the cursor is placed at the useCursor position. + expect(output).toContain("\x1b[3G"); +}); diff --git a/packages/runtime-tests/integration/pty/fixtures/cursor-empty-app.tsx b/packages/runtime-tests/integration/pty/fixtures/cursor-empty-app.tsx new file mode 100644 index 0000000..9d0cc68 --- /dev/null +++ b/packages/runtime-tests/integration/pty/fixtures/cursor-empty-app.tsx @@ -0,0 +1,25 @@ +import process from "node:process"; +import { createApp, useApp } from "@vue-tui/runtime"; +import { defineComponent, onMounted } from "vue"; + +// An interactive app whose ROOT renders nothing. Ink emits ZERO cursor escapes +// for an empty frame (its onRender outer gate `output !== lastOutput` is false +// when both are "", so log-update — and its lazy hide — is never reached, and +// the only mount-time hide lives in setAlternateScreen). vue-tui must match: +// a no-content interactive app must NOT hide the terminal cursor. +// +// rawMode "auto" so the no-input app does not depend on the lifetime raw-mode +// hold; we exit explicitly after signalling readiness so the PTY run resolves. +const App = defineComponent(() => { + const { exit } = useApp(); + onMounted(() => { + process.stdout.write("__READY__"); + setTimeout(() => exit(), 100); + }); + return () => null; +}); + +const app = createApp(App); +app.mount({ rawMode: "auto", exitOnCtrlC: false }); +await app.waitUntilExit(); +console.log("exited"); diff --git a/packages/runtime-tests/integration/pty/fixtures/cursor-nonempty-app.tsx b/packages/runtime-tests/integration/pty/fixtures/cursor-nonempty-app.tsx new file mode 100644 index 0000000..102afc6 --- /dev/null +++ b/packages/runtime-tests/integration/pty/fixtures/cursor-nonempty-app.tsx @@ -0,0 +1,21 @@ +import process from "node:process"; +import { createApp, Text, useApp } from "@vue-tui/runtime"; +import { defineComponent, h, onMounted } from "vue"; + +// A non-empty interactive app. The first frame has content, so log-update's +// render() runs and its lazy hide fires — the cursor MUST be hidden on the first +// render, matching Ink. This proves the lazy hide fully covers the non-empty +// case once the eager mount-time hide is removed. +const App = defineComponent(() => { + const { exit } = useApp(); + onMounted(() => { + process.stdout.write("__READY__"); + setTimeout(() => exit(), 100); + }); + return () => h(Text, null, () => "hello"); +}); + +const app = createApp(App); +app.mount({ rawMode: "auto", exitOnCtrlC: false }); +await app.waitUntilExit(); +console.log("exited"); diff --git a/packages/runtime-tests/integration/pty/fixtures/cursor-usecursor-app.tsx b/packages/runtime-tests/integration/pty/fixtures/cursor-usecursor-app.tsx new file mode 100644 index 0000000..f2333e3 --- /dev/null +++ b/packages/runtime-tests/integration/pty/fixtures/cursor-usecursor-app.tsx @@ -0,0 +1,27 @@ +import process from "node:process"; +import { Box, Text, createApp, useApp, useCursor } from "@vue-tui/runtime"; +import { defineComponent, h, onMounted } from "vue"; + +// A useCursor app. log-update hides-then-shows the cursor within a single +// render(): it lazily hides at the top, then emits the cursor SHOW + cursorTo +// suffix for the active position. So the LAST cursor visibility change on the +// first frame must be a SHOW (cursor visible at the requested position), with no +// trailing re-hide — exactly Ink's ordering, and unchanged by removing the eager +// mount-time hide. +const App = defineComponent(() => { + const { exit } = useApp(); + const { setCursorPosition } = useCursor(); + onMounted(() => { + process.stdout.write("__READY__"); + setTimeout(() => exit(), 100); + }); + return () => { + setCursorPosition({ x: 2, y: 0 }); + return h(Box, null, () => h(Text, null, () => "> ")); + }; +}); + +const app = createApp(App); +app.mount({ rawMode: "auto", exitOnCtrlC: false }); +await app.waitUntilExit(); +console.log("exited"); diff --git a/packages/runtime/src/render.ts b/packages/runtime/src/render.ts index 3c6833b..fefdf5f 100644 --- a/packages/runtime/src/render.ts +++ b/packages/runtime/src/render.ts @@ -788,11 +788,17 @@ export function createApp(root: Component, rootProps?: RootProps | null): TuiApp // 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. + // decides whether to call the (throttled) log at all. It compares the RAW + // frame (`output`, no trailing "\n") against the PREVIOUS frame + // (frameState.lastOutput, set at the end of this fn) — NOT log-update's + // \n-suffixed previousOutput. This is load-bearing for the empty-frame + // case: on the first commit of an app that renders nothing, both are "" + // so the gate is false and log-update — including its LAZY cursor hide — + // is never reached, so an empty app emits zero cursor escapes (cursor + // stays visible), matching Ink. Using willRender(outputToRender) here + // instead would compare "\n" against "" and wrongly fire the hide. A + // cursor-only move whose position is unchanged is still dirty, so the + // `|| isCursorDirty` disjunct keeps it reaching log-update. // - 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 @@ -804,7 +810,7 @@ export function createApp(root: Component, rootProps?: RootProps | null): TuiApp // 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()) { + if (output !== frameState.lastOutput || writer.isCursorDirty()) { const shouldWrap = synchronize && willRender; if (shouldWrap) stdout.write(bsu); writer.write(outputToRender); @@ -1050,32 +1056,26 @@ export function createApp(root: Component, rootProps?: RootProps | null): TuiApp } mountedAlternateScreen = alternateScreen; - // 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. + // No eager mount-time cursor hide here (matching Ink). Ink hides the cursor + // LAZILY: the non-alt-screen hide comes from log-update's isTTY-gated + // cliCursor.hide on the first render that actually writes (log-update.ts: + // 55-59), and the onRender outer gate skips log-update entirely for an empty + // frame (ink.tsx:1094 `output !== lastOutput`, both "" on the first empty + // commit). So an interactive app whose root renders nothing emits ZERO + // cursor escapes — the cursor stays visible — while a non-empty / useCursor + // app hides on its first render via the same lazy path. The renderInteractive + // commit gate below mirrors that `output !== frameState.lastOutput` outer + // condition so the empty-frame skip (and thus the no-hide behavior) holds. // - // 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. - // isTTY gate (cli-cursor short-circuit, cli-cursor/index.js:8-24): cursor - // hide/show is a TTY-only concern. In Ink the only mount-time hide lives in - // setAlternateScreen (alt-screen + isTTY gated); the non-alt-screen hide - // comes from log-update's isTTY-gated cliCursor.hide. So a caller forcing - // interactive onto a piped/non-TTY stdout must NOT leak a hide here. - if ( - !debug && - interactive && - !mountedAlternateScreen && - !isScreenReaderEnabled && - Boolean(stdout.isTTY) - ) { - stdout.write("\x1b[?25l"); - } + // Ordering for a useCursor app is preserved without an eager hide: log-update + // hides-then-shows WITHIN a single render() (it hides at the top, then emits + // the showCursor + cursorTo suffix for the active position), so the last + // visibility change on the first frame is the SHOW — exactly Ink's ordering. + // + // Screen-reader mode leaves the cursor VISIBLE (Ink parity G59): its + // dedicated write branch never routes through log-update, so no hide. The + // only mount-time hide that remains is the alt-screen one above + // (setAlternateScreen, alt-screen + isTTY gated), mirroring Ink. // The cursor (and alternate screen) have already been hidden/entered above, // but the process-exit and signal-exit teardown handlers are not wired until