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) <noreply@anthropic.com>
This commit is contained in:
Yunfei He
2026-06-05 12:21:10 +08:00
committed by GitHub
parent c05cb5df36
commit f6a552d855
5 changed files with 146 additions and 31 deletions
@@ -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");
});
@@ -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");
@@ -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");
@@ -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");