From 759af7fa5fd19f2c416ac5bee6d9fa1180b6197e Mon Sep 17 00:00:00 2001 From: Yunfei He Date: Sun, 31 May 2026 22:44:40 +0800 Subject: [PATCH] fix(runtime): gate interactive cursor hide/show on isTTY, matching cli-cursor (#102) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .../lifecycle/cursor-non-tty.test.tsx | 82 +++++++++++++++++++ packages/runtime/src/io/log-update.ts | 19 +++++ packages/runtime/src/render.ts | 23 +++++- 3 files changed, 122 insertions(+), 2 deletions(-) create mode 100644 packages/runtime-tests/integration/lifecycle/cursor-non-tty.test.tsx diff --git a/packages/runtime-tests/integration/lifecycle/cursor-non-tty.test.tsx b/packages/runtime-tests/integration/lifecycle/cursor-non-tty.test.tsx new file mode 100644 index 0000000..4bc8b37 --- /dev/null +++ b/packages/runtime-tests/integration/lifecycle/cursor-non-tty.test.tsx @@ -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(() => () => hello); + + 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); +}); diff --git a/packages/runtime/src/io/log-update.ts b/packages/runtime/src/io/log-update.ts index 024e31d..814d642 100644 --- a/packages/runtime/src/io/log-update.ts +++ b/packages/runtime/src/io/log-update.ts @@ -28,11 +28,24 @@ export type LogUpdate = { const visibleLineCount = (lines: string[], str: string): number => str.endsWith("\n") ? lines.length - 1 : lines.length; +// Cursor hide/show is a TTY-only concern. Ink routes every hide/show through +// `cli-cursor`, which short-circuits `if (!stream.isTTY) return` +// (cli-cursor/index.js:8-24), so a forced-interactive run on a piped/non-TTY +// stream emits no cursor escapes. `stream` is typed `Writable`, which has no +// `isTTY`, so we read it off the runtime object (WriteStream sets it). +const isTtyStream = (stream: Writable): boolean => Boolean((stream as { isTTY?: boolean }).isTTY); + const hideCursor = (stream: Writable): void => { + if (!isTtyStream(stream)) { + return; + } stream.write(hideCursorEscape); }; const showCursor = (stream: Writable): void => { + if (!isTtyStream(stream)) { + return; + } stream.write(showCursorEscape); }; @@ -140,6 +153,9 @@ const createStandard = ( previousOutput = str; previousLineCount = lines.length; + // NOT isTTY-gated: Ink's sync() writes the hide directly (Ink + // log-update.ts:149-151), NOT via cli-cursor, so it has no isTTY guard — + // unlike render()/done()'s hide/show which DO route through cli-cursor. if (!activeCursor && cursorWasShown) { stream.write(hideCursorEscape); } @@ -330,6 +346,9 @@ const createIncremental = ( previousOutput = str; previousLines = lines; + // NOT isTTY-gated: Ink's sync() writes the hide directly (Ink + // log-update.ts:149-151), NOT via cli-cursor, so it has no isTTY guard — + // unlike render()/done()'s hide/show which DO route through cli-cursor. if (!activeCursor && cursorWasShown) { stream.write(hideCursorEscape); } diff --git a/packages/runtime/src/render.ts b/packages/runtime/src/render.ts index b90ea92..f9ce66c 100644 --- a/packages/runtime/src/render.ts +++ b/packages/runtime/src/render.ts @@ -375,7 +375,15 @@ export function createApp(root: Component, rootProps?: RootProps | null): TuiApp writeBestEffort(mountedAppContext.stdout, ansiEscapes.exitAlternativeScreen, sync); writeBestEffort(mountedAppContext.stdout, "\x1b[?25h", sync); mountedAlternateScreen = false; - } else if (!mountedDebug && mountedInteractive && mountedAppContext) { + } else if ( + !mountedDebug && + mountedInteractive && + mountedAppContext && + Boolean(mountedAppContext.stdout.isTTY) + ) { + // isTTY gate (cli-cursor short-circuit): Ink's non-alt-screen teardown + // show goes through log.done() -> cliCursor.show, which no-ops on a + // non-TTY stream. Forced-interactive on a piped stdout emits no show. writeBestEffort(mountedAppContext.stdout, "\x1b[?25h", sync); } if (mountedRoot) detachYoga(mountedRoot); @@ -969,7 +977,18 @@ export function createApp(root: Component, rootProps?: RootProps | null): TuiApp // 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. - if (!debug && interactive && !mountedAlternateScreen && !isScreenReaderEnabled) { + // 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"); }