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:
@@ -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);
|
||||||
|
});
|
||||||
@@ -28,11 +28,24 @@ export type LogUpdate = {
|
|||||||
const visibleLineCount = (lines: string[], str: string): number =>
|
const visibleLineCount = (lines: string[], str: string): number =>
|
||||||
str.endsWith("\n") ? lines.length - 1 : lines.length;
|
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 => {
|
const hideCursor = (stream: Writable): void => {
|
||||||
|
if (!isTtyStream(stream)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
stream.write(hideCursorEscape);
|
stream.write(hideCursorEscape);
|
||||||
};
|
};
|
||||||
|
|
||||||
const showCursor = (stream: Writable): void => {
|
const showCursor = (stream: Writable): void => {
|
||||||
|
if (!isTtyStream(stream)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
stream.write(showCursorEscape);
|
stream.write(showCursorEscape);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -140,6 +153,9 @@ const createStandard = (
|
|||||||
previousOutput = str;
|
previousOutput = str;
|
||||||
previousLineCount = lines.length;
|
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) {
|
if (!activeCursor && cursorWasShown) {
|
||||||
stream.write(hideCursorEscape);
|
stream.write(hideCursorEscape);
|
||||||
}
|
}
|
||||||
@@ -330,6 +346,9 @@ const createIncremental = (
|
|||||||
previousOutput = str;
|
previousOutput = str;
|
||||||
previousLines = lines;
|
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) {
|
if (!activeCursor && cursorWasShown) {
|
||||||
stream.write(hideCursorEscape);
|
stream.write(hideCursorEscape);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -375,7 +375,15 @@ export function createApp(root: Component, rootProps?: RootProps | null): TuiApp
|
|||||||
writeBestEffort(mountedAppContext.stdout, ansiEscapes.exitAlternativeScreen, sync);
|
writeBestEffort(mountedAppContext.stdout, ansiEscapes.exitAlternativeScreen, sync);
|
||||||
writeBestEffort(mountedAppContext.stdout, "\x1b[?25h", sync);
|
writeBestEffort(mountedAppContext.stdout, "\x1b[?25h", sync);
|
||||||
mountedAlternateScreen = false;
|
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);
|
writeBestEffort(mountedAppContext.stdout, "\x1b[?25h", sync);
|
||||||
}
|
}
|
||||||
if (mountedRoot) detachYoga(mountedRoot);
|
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
|
// 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 and leave the cursor hidden — the last visibility change must be the
|
||||||
// show, mirroring Ink, which hides before its first render, not after.
|
// 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");
|
stdout.write("\x1b[?25l");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user