f6a552d855
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>
43 lines
2.0 KiB
TypeScript
43 lines
2.0 KiB
TypeScript
// 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");
|
|
});
|