diff --git a/packages/runtime-tests/integration/lifecycle/cursor.test.tsx b/packages/runtime-tests/integration/lifecycle/cursor.test.tsx new file mode 100644 index 0000000..8df1f69 --- /dev/null +++ b/packages/runtime-tests/integration/lifecycle/cursor.test.tsx @@ -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(() => () => hello); + + 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"); +}); diff --git a/packages/runtime/src/render.ts b/packages/runtime/src/render.ts index d506c6c..63b41c9 100644 --- a/packages/runtime/src/render.ts +++ b/packages/runtime/src/render.ts @@ -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) => {