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:
@@ -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");
|
||||||
@@ -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:
|
// wrapper and the "should we touch log-update at all" decision separate:
|
||||||
//
|
//
|
||||||
// - Outer gate (ink.tsx:1094 `output !== lastOutput || log.isCursorDirty()`):
|
// - Outer gate (ink.tsx:1094 `output !== lastOutput || log.isCursorDirty()`):
|
||||||
// decides whether to call the (throttled) log at all. A cursor-only move
|
// decides whether to call the (throttled) log at all. It compares the RAW
|
||||||
// whose position is unchanged from the previous render is still dirty, so
|
// frame (`output`, no trailing "\n") against the PREVIOUS frame
|
||||||
// it must reach log-update — willRender() alone would miss it because it
|
// (frameState.lastOutput, set at the end of this fn) — NOT log-update's
|
||||||
// compares positions, not the dirty flag. Here that gate is `willRender ||
|
// \n-suffixed previousOutput. This is load-bearing for the empty-frame
|
||||||
// isCursorDirty`; when both are false we skip the write entirely.
|
// 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
|
// - 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-
|
// 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
|
// 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
|
// afterwards would be stale. Both reads are pure (no mutation), and the
|
||||||
// bsu/esu wrapper is gated on this single pre-write snapshot.
|
// bsu/esu wrapper is gated on this single pre-write snapshot.
|
||||||
const willRender = writer.willRender(outputToRender);
|
const willRender = writer.willRender(outputToRender);
|
||||||
if (willRender || writer.isCursorDirty()) {
|
if (output !== frameState.lastOutput || writer.isCursorDirty()) {
|
||||||
const shouldWrap = synchronize && willRender;
|
const shouldWrap = synchronize && willRender;
|
||||||
if (shouldWrap) stdout.write(bsu);
|
if (shouldWrap) stdout.write(bsu);
|
||||||
writer.write(outputToRender);
|
writer.write(outputToRender);
|
||||||
@@ -1050,32 +1056,26 @@ export function createApp(root: Component, rootProps?: RootProps | null): TuiApp
|
|||||||
}
|
}
|
||||||
mountedAlternateScreen = alternateScreen;
|
mountedAlternateScreen = alternateScreen;
|
||||||
|
|
||||||
// Hide cursor on mount (matching Ink). Only in interactive mode — in
|
// No eager mount-time cursor hide here (matching Ink). Ink hides the cursor
|
||||||
// debug/test mode or non-interactive the stream may not be a real TTY.
|
// LAZILY: the non-alt-screen hide comes from log-update's isTTY-gated
|
||||||
// Screen-reader mode leaves the cursor VISIBLE (Ink parity G59): Ink's SR
|
// cliCursor.hide on the first render that actually writes (log-update.ts:
|
||||||
// path never hides the cursor (the dedicated SR write branch above does no
|
// 55-59), and the onRender outer gate skips log-update entirely for an empty
|
||||||
// cursor management), so a screen-reader user keeps a real terminal cursor.
|
// 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
|
// Ordering for a useCursor app is preserved without an eager hide: log-update
|
||||||
// and the first commit (which, when useCursor() is active, ends with a
|
// hides-then-shows WITHIN a single render() (it hides at the top, then emits
|
||||||
// showCursor + cursorTo via log-update) runs inside originalMount via a
|
// the showCursor + cursorTo suffix for the active position), so the last
|
||||||
// post-flush callback. Writing the hide afterwards would land AFTER that
|
// visibility change on the first frame is the SHOW — exactly Ink's ordering.
|
||||||
// show and leave the cursor hidden — the last visibility change must be the
|
//
|
||||||
// show, mirroring Ink, which hides before its first render, not after.
|
// Screen-reader mode leaves the cursor VISIBLE (Ink parity G59): its
|
||||||
// isTTY gate (cli-cursor short-circuit, cli-cursor/index.js:8-24): cursor
|
// dedicated write branch never routes through log-update, so no hide. The
|
||||||
// hide/show is a TTY-only concern. In Ink the only mount-time hide lives in
|
// only mount-time hide that remains is the alt-screen one above
|
||||||
// setAlternateScreen (alt-screen + isTTY gated); the non-alt-screen hide
|
// (setAlternateScreen, alt-screen + isTTY gated), mirroring Ink.
|
||||||
// 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");
|
|
||||||
}
|
|
||||||
|
|
||||||
// The cursor (and alternate screen) have already been hidden/entered above,
|
// The cursor (and alternate screen) have already been hidden/entered above,
|
||||||
// but the process-exit and signal-exit teardown handlers are not wired until
|
// but the process-exit and signal-exit teardown handlers are not wired until
|
||||||
|
|||||||
Reference in New Issue
Block a user