feat: hide cursor on mount, show on unmount (matching Ink)

Write ESC[?25l to stdout after mount and ESC[?25h during teardown,
matching Ink's cursor management. Only active in production mode
(debug: false) to avoid interfering with test streams.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Yunfei He
2026-05-26 01:00:17 +08:00
parent 8585598d14
commit 42c69d72f2
2 changed files with 53 additions and 0 deletions
@@ -0,0 +1,43 @@
import { defineComponent, nextTick } from "vue";
import { expect, test } from "vite-plus/test";
import { createApp, Text } from "@vue-tui/runtime";
import { PassThrough } from "node: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;
}
test("mount hides cursor, unmount shows cursor", async () => {
const stdout = makeTtyStream();
const stderr = makeTtyStream();
const stdin = new PassThrough() as unknown as NodeJS.ReadStream;
Object.assign(stdin, {
isTTY: true,
setRawMode() {
return stdin;
},
});
const App = defineComponent(() => () => <Text>hello</Text>);
const app = createApp(App);
app.mount({ stdout, stdin, stderr, debug: false, exitOnCtrlC: false });
await nextTick();
// Cursor should be hidden after mount
const afterMount = stdout.chunks.join("");
expect(afterMount).toContain("\x1b[?25l");
app.unmount();
await nextTick();
// Cursor should be shown after unmount
const afterUnmount = stdout.chunks.join("");
expect(afterUnmount).toContain("\x1b[?25h");
});
+10
View File
@@ -74,6 +74,10 @@ export function createApp(root: Component, rootProps?: RootProps | null): TuiApp
// Vue's unmount may throw on double-unmount; swallow for idempotency.
}
if (mountedWriter && !mountedDebug) mountedWriter.done();
// Show cursor on unmount (matching Ink).
if (!mountedDebug && mountedAppContext) {
mountedAppContext.stdout.write("\x1b[?25h");
}
if (mountedRoot) detachYoga(mountedRoot);
if (mountedResizeHandler && mountedAppContext) {
mountedAppContext.stdout.off("resize", mountedResizeHandler);
@@ -204,6 +208,12 @@ export function createApp(root: Component, rootProps?: RootProps | null): TuiApp
mountedRawMode = true;
}
// Hide cursor on mount (matching Ink). Only in production mode — in
// debug/test mode the stream may not be a real TTY.
if (!debug) {
stdout.write("\x1b[?25l");
}
// Built-in Tab / Shift+Tab / Escape focus navigation (matches Ink).
// Placed AFTER mount so a sync mount failure doesn't leak the listener.
const focusInputListener = (chunk: Buffer | string) => {