fix(runtime): gate interactive cursor hide/show on isTTY, matching cli-cursor (#102)

On a forced interactive:true mount over a non-TTY stdout, vue emitted cursor
hide/show escapes where Ink emits none — Ink routes render()/done() cursor writes
through cli-cursor, which short-circuits `if (!stream.isTTY) return`, and its only
mount-time hide is alt-screen-only (alt-screen itself requires a TTY).

Gate the non-alt-screen cursor writes on stream.isTTY: log-update's hideCursor/
showCursor (used by render()/done() and the incremental writer) and render.ts's
bare mount-hide + teardown-show. The alternate-screen cursor writes are left as-is
(already gated behind alternateScreen, which requires isTTY). log-update's sync()
direct hide is deliberately NOT gated — Ink writes it directly, not via cli-cursor.

Locked by a non-TTY interactive mount test asserting no \x1b[?25l/\x1b[?25h; the
real-TTY hide-on-mount/show-on-teardown path stays covered by cursor.test.tsx.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Yunfei He
2026-05-31 22:44:40 +08:00
committed by GitHub
parent c76a0f09b2
commit 759af7fa5f
3 changed files with 122 additions and 2 deletions
@@ -0,0 +1,82 @@
// Forced-interactive + NON-TTY stdout must emit NO cursor hide/show escapes,
// matching Ink. Ink routes every cursor hide/show through `cli-cursor`, which
// short-circuits `if (!stream.isTTY) return` (cli-cursor/index.js:8-24), and
// its mount-hide is alt-screen-only (also isTTY-gated). So when a caller forces
// `interactive: true` onto a piped, non-TTY stdout (isTTY false), Ink writes
// neither `\x1b[?25l` nor `\x1b[?25h`. vue must do the same: the cursor-control
// writes are a TTY concern, and forcing interactive must not leak them to a pipe.
import { defineComponent, nextTick } from "vue";
import { expect, test } from "vite-plus/test";
import { createApp, Text } from "@vue-tui/runtime";
import { PassThrough } from "node:stream";
const hideCursorEscape = "\x1b[?25l";
const showCursorEscape = "\x1b[?25h";
function makeNonTtyStdout() {
const stream = new PassThrough() as unknown as NodeJS.WriteStream & { chunks: string[] };
// isTTY explicitly false: a piped/redirected stdout the caller forced into
// interactive mode. columns/rows still provided so layout has a width.
Object.assign(stream, { isTTY: false, columns: 80, rows: 24 });
stream.chunks = [];
(stream as unknown as PassThrough).on("data", (chunk: Buffer) =>
stream.chunks.push(chunk.toString()),
);
return stream;
}
function makeTtyStream() {
const stream = new PassThrough() as unknown as NodeJS.WriteStream & { chunks: string[] };
Object.assign(stream, { isTTY: true, columns: 80, rows: 24 });
stream.chunks = [];
(stream as unknown as PassThrough).on("data", (chunk: Buffer) =>
stream.chunks.push(chunk.toString()),
);
return stream;
}
function makeFakeStdin(): NodeJS.ReadStream {
const stdin = new PassThrough() as unknown as NodeJS.ReadStream;
Object.assign(stdin, {
isTTY: true,
setRawMode() {
return stdin;
},
setEncoding() {
return stdin;
},
ref() {},
unref() {},
});
return stdin;
}
test("forced interactive + non-TTY stdout emits NO cursor hide/show escapes", async () => {
const stdout = makeNonTtyStdout();
const stdin = makeFakeStdin();
const App = defineComponent(() => () => <Text>hello</Text>);
const app = createApp(App);
app.mount({
stdout,
stdin,
stderr: makeTtyStream(),
interactive: true,
exitOnCtrlC: false,
});
await nextTick();
const afterMount = stdout.chunks.join("");
// Ink emits no hide on mount for a non-TTY stdout (cli-cursor short-circuit).
expect(afterMount).not.toContain(hideCursorEscape);
const exited = app.waitUntilExit();
app.unmount();
await exited;
const afterUnmount = stdout.chunks.join("");
// ...and no show on teardown either.
expect(afterUnmount).not.toContain(hideCursorEscape);
expect(afterUnmount).not.toContain(showCursorEscape);
});