From a280b4f6848c395ab0f3d5b770bf75f78f0dfc00 Mon Sep 17 00:00:00 2001 From: Yunfei He Date: Tue, 26 May 2026 15:34:41 +0800 Subject: [PATCH] feat: add interactive mode detection and output stream coordination - Auto-detect interactive mode via is-in-ci + stdout.isTTY - writeToStdout/writeToStderr with frame clear/restore coordination - Non-interactive mode: static immediate, dynamic at unmount - Cursor position tracking for Phase 5 useCursor integration - useStdout/useStderr route through coordinated context methods Co-Authored-By: Claude Opus 4.7 (1M context) --- .../composables/use-stderr.test.tsx | 25 +++ .../composables/use-stdout.test.tsx | 44 ++++++ packages/runtime/package.json | 1 + packages/runtime/src/composables/useStderr.ts | 2 +- packages/runtime/src/composables/useStdout.ts | 2 +- packages/runtime/src/context.ts | 10 ++ packages/runtime/src/render.ts | 148 ++++++++++++++++-- pnpm-lock.yaml | 13 ++ pnpm-workspace.yaml | 1 + 9 files changed, 233 insertions(+), 13 deletions(-) create mode 100644 packages/runtime-tests/integration/composables/use-stderr.test.tsx create mode 100644 packages/runtime-tests/integration/composables/use-stdout.test.tsx diff --git a/packages/runtime-tests/integration/composables/use-stderr.test.tsx b/packages/runtime-tests/integration/composables/use-stderr.test.tsx new file mode 100644 index 0000000..238469a --- /dev/null +++ b/packages/runtime-tests/integration/composables/use-stderr.test.tsx @@ -0,0 +1,25 @@ +import { defineComponent, onMounted } from "vue"; +import { expect, test } from "vite-plus/test"; +import { render } from "@vue-tui/testing"; +import { Text, useStderr } from "@vue-tui/runtime"; + +test("useStderr.write does not corrupt active frame in debug mode", async () => { + const App = defineComponent(() => { + const { write } = useStderr(); + onMounted(() => write("err line\n")); + return () => UI; + }); + const { lastFrame } = await render(App); + expect(lastFrame()).toContain("UI"); +}); + +test("useStderr returns stderr stream from context", async () => { + let stderrRef: NodeJS.WriteStream | undefined; + const App = defineComponent(() => { + const { stderr } = useStderr(); + stderrRef = stderr; + return () => hello; + }); + await render(App); + expect(stderrRef).toBeDefined(); +}); diff --git a/packages/runtime-tests/integration/composables/use-stdout.test.tsx b/packages/runtime-tests/integration/composables/use-stdout.test.tsx new file mode 100644 index 0000000..2259c10 --- /dev/null +++ b/packages/runtime-tests/integration/composables/use-stdout.test.tsx @@ -0,0 +1,44 @@ +import { defineComponent, onMounted } from "vue"; +import { expect, test } from "vite-plus/test"; +import { render } from "@vue-tui/testing"; +import { Text, useStdout } from "@vue-tui/runtime"; + +test("useStdout.write does not corrupt active frame in debug mode", async () => { + const App = defineComponent(() => { + const { write } = useStdout(); + onMounted(() => write("log line\n")); + return () => UI; + }); + const { lastFrame } = await render(App); + expect(lastFrame()).toContain("UI"); +}); + +test("useStdout returns stdout stream from context", async () => { + let stdoutRef: NodeJS.WriteStream | undefined; + const App = defineComponent(() => { + const { stdout } = useStdout(); + stdoutRef = stdout; + return () => hello; + }); + await render(App); + expect(stdoutRef).toBeDefined(); +}); + +test("useStdout.write routes through writeToStdout", async () => { + const writes: string[] = []; + const App = defineComponent(() => { + const { write, stdout } = useStdout(); + // Capture all writes to the stdout stream + const origWrite = stdout.write.bind(stdout); + stdout.write = ((data: string) => { + writes.push(data); + return origWrite(data); + }) as typeof stdout.write; + onMounted(() => write("test-data")); + return () => frame; + }); + const { lastFrame } = await render(App); + // The write should have gone through, and the frame should still be intact + expect(lastFrame()).toContain("frame"); + expect(writes.some((w) => w.includes("test-data"))).toBe(true); +}); diff --git a/packages/runtime/package.json b/packages/runtime/package.json index e4ca431..b2f227e 100644 --- a/packages/runtime/package.json +++ b/packages/runtime/package.json @@ -27,6 +27,7 @@ "@vue/runtime-core": "^3.4.0", "chalk": "^5.3.0", "cli-boxes": "^3.0.0", + "is-in-ci": "catalog:", "log-update": "^6.0.0", "slice-ansi": "^7.1.0", "string-width": "^7.2.0", diff --git a/packages/runtime/src/composables/useStderr.ts b/packages/runtime/src/composables/useStderr.ts index a73c5ff..fbc8788 100644 --- a/packages/runtime/src/composables/useStderr.ts +++ b/packages/runtime/src/composables/useStderr.ts @@ -4,5 +4,5 @@ import { AppContextKey } from "../context.ts"; export function useStderr(): { stderr: NodeJS.WriteStream; write: (data: string) => void } { const ctx = inject(AppContextKey); if (!ctx) throw new Error("useStderr() must be called inside a vue-tui render tree"); - return { stderr: ctx.stderr, write: (data) => ctx.stderr.write(data) }; + return { stderr: ctx.stderr, write: (data) => ctx.writeToStderr(data) }; } diff --git a/packages/runtime/src/composables/useStdout.ts b/packages/runtime/src/composables/useStdout.ts index e6d73f3..9538ce0 100644 --- a/packages/runtime/src/composables/useStdout.ts +++ b/packages/runtime/src/composables/useStdout.ts @@ -4,5 +4,5 @@ import { AppContextKey } from "../context.ts"; export function useStdout(): { stdout: NodeJS.WriteStream; write: (data: string) => void } { const ctx = inject(AppContextKey); if (!ctx) throw new Error("useStdout() must be called inside a vue-tui render tree"); - return { stdout: ctx.stdout, write: (data) => ctx.stdout.write(data) }; + return { stdout: ctx.stdout, write: (data) => ctx.writeToStdout(data) }; } diff --git a/packages/runtime/src/context.ts b/packages/runtime/src/context.ts index fa7b5ed..69b6a5a 100644 --- a/packages/runtime/src/context.ts +++ b/packages/runtime/src/context.ts @@ -1,14 +1,24 @@ import type { InjectionKey, ShallowRef } from "vue"; import type { EventEmitter } from "node:events"; +export interface CursorPosition { + x: number; + y: number; +} + export interface AppContext { exit: (errorOrResult?: unknown) => void; stdout: NodeJS.WriteStream; stderr: NodeJS.WriteStream; stdin: NodeJS.ReadStream; debug: boolean; + interactive: boolean; isRawModeSupported: boolean; setRawMode: (mode: boolean) => void; + writeToStdout: (data: string) => void; + writeToStderr: (data: string) => void; + cursorPosition: CursorPosition | undefined; + setCursorPosition: (pos: CursorPosition | undefined) => void; } export interface FocusContext { diff --git a/packages/runtime/src/render.ts b/packages/runtime/src/render.ts index 0e9257c..6dfaa8c 100644 --- a/packages/runtime/src/render.ts +++ b/packages/runtime/src/render.ts @@ -11,19 +11,21 @@ import { } from "vue"; import { createRenderer } from "@vue/runtime-core"; import { EventEmitter } from "node:events"; +import isInCi from "is-in-ci"; import { createInputParser, type InputEvent } from "./io/input-parser.ts"; import { createRoot, type TuiRoot, type TuiNode } from "./host/nodes.ts"; import { attachYoga, detachYoga } from "./host/yoga.ts"; import { buildNodeOps } from "./host/node-ops.ts"; import { createCommitScheduler } from "./scheduler.ts"; -import { paint } from "./paint/paint.ts"; -import { flushStatic } from "./paint/static-channel.ts"; +import { paint, paintIsolated } from "./paint/paint.ts"; +import { flushStatic, findStatics } from "./paint/static-channel.ts"; import { createFrameWriter } from "./io/frame-writer.ts"; import { AppContextKey, FocusContextKey, StdinContextKey, type AppContext, + type CursorPosition, type FocusContext, type StdinContext, } from "./context.ts"; @@ -38,6 +40,19 @@ export interface MountOptions { debug?: boolean; exitOnCtrlC?: boolean; rawMode?: boolean; + /** + * Override automatic interactive mode detection. + * + * By default, vue-tui detects whether the environment is interactive based + * on CI detection (via `is-in-ci`) and `stdout.isTTY`. Most users should + * not need to set this. + * + * When non-interactive, vue-tui disables ANSI erase sequences, cursor + * manipulation, resize handling, writing only the final frame at unmount. + * + * @default true (false if in CI or `stdout.isTTY` is falsy) + */ + interactive?: boolean; } export interface TuiApp extends Omit, "mount"> { @@ -69,7 +84,9 @@ export function createApp(root: Component, rootProps?: RootProps | null): TuiApp let mountedResizeHandler: (() => void) | null = null; let mountedExitListener: (() => void) | null = null; let mountedDebug = false; + let mountedInteractive = true; let mountedRawMode = false; + let mountedGetLastOutput: (() => string) | null = null; // The renderer's onCommit closure is wired at createApp time but only does // real work after mount swaps in scheduler.schedule. One renderer per app @@ -86,9 +103,16 @@ export function createApp(root: Component, rootProps?: RootProps | null): TuiApp } catch { // 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) { + if (!mountedDebug && !mountedInteractive && mountedAppContext) { + // Non-interactive: write the deferred last frame at unmount (matching Ink). + const lastFrame = mountedGetLastOutput?.() ?? ""; + if (lastFrame) { + mountedAppContext.stdout.write(lastFrame + "\n"); + } + } + if (mountedWriter && !mountedDebug && mountedInteractive) mountedWriter.done(); + // Show cursor on unmount (matching Ink). Only in interactive mode. + if (!mountedDebug && mountedInteractive && mountedAppContext) { mountedAppContext.stdout.write("\x1b[?25h"); } if (mountedRoot) detachYoga(mountedRoot); @@ -161,6 +185,68 @@ export function createApp(root: Component, rootProps?: RootProps | null): TuiApp const rawMode = options.rawMode ?? true; mountedDebug = debug; + // Interactive mode detection — matches Ink's logic: + // CI detection takes precedence: even a TTY stdout in CI defaults to + // non-interactive. Using Boolean(isTTY) (rather than an 'in' guard) + // correctly handles piped streams where the property is absent. + const interactive = options.interactive ?? (!isInCi && Boolean(stdout.isTTY)); + mountedInteractive = interactive; + + // Frame coordination state — tracks the last rendered output so + // writeToStdout/writeToStderr can clear and restore the active frame. + // Frame state: lastOutput is the most recent rendered frame string, + // outputHeight is its line count (used for erase-lines on resize and + // screen-reader mode in future tasks), fullStaticOutput is the + // accumulated content. + const frameState = { lastOutput: "", outputHeight: 0, fullStaticOutput: "" }; + let cursorPosition: CursorPosition | undefined; + mountedGetLastOutput = () => frameState.lastOutput; + + function restoreLastOutput() { + if (!interactive) return; + // Re-write the last frame through the frame writer (log-update) so + // the cursor returns to the correct position after external writes. + writer.write(frameState.lastOutput); + // Cursor position handling (for Phase 5's useCursor integration): + // If cursor position is set, move cursor there and show it; + // otherwise hide it. + if (cursorPosition) { + stdout.write(`\x1b[${cursorPosition.y + 1};${cursorPosition.x + 1}H`); + stdout.write("\x1b[?25h"); + } else { + stdout.write("\x1b[?25l"); + } + } + + function writeToStdout(data: string) { + if (debug) { + stdout.write(data + frameState.fullStaticOutput + frameState.lastOutput); + return; + } + if (!interactive) { + stdout.write(data); + return; + } + writer.clear(); + stdout.write(data); + restoreLastOutput(); + } + + function writeToStderr(data: string) { + if (debug) { + stderr.write(data); + stdout.write(frameState.fullStaticOutput + frameState.lastOutput); + return; + } + if (!interactive) { + stderr.write(data); + return; + } + writer.clear(); + stderr.write(data); + restoreLastOutput(); + } + const appContext: AppContext = { exit(errorOrResult?: unknown) { // Defer teardown to a microtask: exit() is frequently called from @@ -179,6 +265,7 @@ export function createApp(root: Component, rootProps?: RootProps | null): TuiApp stderr, stdin, debug, + interactive, isRawModeSupported: !!(stdin as { isTTY?: boolean }).isTTY, setRawMode(mode: boolean) { if ( @@ -187,6 +274,13 @@ export function createApp(root: Component, rootProps?: RootProps | null): TuiApp (stdin as { setRawMode: (mode: boolean) => unknown }).setRawMode(mode); } }, + writeToStdout, + writeToStderr, + cursorPosition: undefined, + setCursorPosition(pos: CursorPosition | undefined) { + cursorPosition = pos; + appContext.cursorPosition = pos; + }, }; mountedAppContext = appContext; @@ -207,12 +301,41 @@ export function createApp(root: Component, rootProps?: RootProps | null): TuiApp mountedWriter = writer; function commit() { + if (!interactive && !debug) { + // Non-interactive: write static output immediately, defer dynamic frame. + // We inline the static flush logic so we can both capture and write it. + const w = stdout.columns ?? 80; + for (const stat of findStatics(tuiRoot)) { + const fresh = stat.children.slice(stat.writtenCount); + if (fresh.length === 0) continue; + const staticFrame = paintIsolated(fresh, w); + if (staticFrame.length > 0) { + const output = staticFrame + "\n"; + frameState.fullStaticOutput += output; + stdout.write(output); + } + stat.writtenCount = stat.children.length; + } + + tuiRoot.yoga.setWidth(w); + tuiRoot.yoga.calculateLayout(w, undefined, Yoga.DIRECTION_LTR); + const frame = paint(tuiRoot); + frameState.lastOutput = frame; + frameState.outputHeight = frame === "" ? 0 : frame.split("\n").length; + return; + } + writer.clear(); flushStatic(tuiRoot, stdout); const w = stdout.columns ?? 80; tuiRoot.yoga.setWidth(w); tuiRoot.yoga.calculateLayout(w, undefined, Yoga.DIRECTION_LTR); const frame = paint(tuiRoot); + + // Track last output for writeToStdout/writeToStderr frame coordination + frameState.lastOutput = frame; + frameState.outputHeight = frame === "" ? 0 : frame.split("\n").length; + writer.write(frame); } @@ -246,15 +369,18 @@ 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) { + // Hide cursor on mount (matching Ink). Only in interactive mode — in + // debug/test mode or non-interactive the stream may not be a real TTY. + if (!debug && interactive) { stdout.write("\x1b[?25l"); } - const onResize = () => scheduler.schedule(); - stdout.on("resize", onResize); - mountedResizeHandler = onResize; + // Only listen for resize in interactive mode (matching Ink). + if (interactive) { + const onResize = () => scheduler.schedule(); + stdout.on("resize", onResize); + mountedResizeHandler = onResize; + } // Auto-cleanup on process exit (process.exit, event-loop drain, uncaught // exception — anything that fires Node's 'exit' event). teardown() is diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index bcb81d0..db7a869 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -18,6 +18,9 @@ catalogs: chalk: specifier: ^5.6.2 version: 5.6.2 + is-in-ci: + specifier: ^1.0.0 + version: 1.0.0 strip-ansi: specifier: ^7.2.0 version: 7.2.0 @@ -155,6 +158,9 @@ importers: cli-boxes: specifier: ^3.0.0 version: 3.0.0 + is-in-ci: + specifier: 'catalog:' + version: 1.0.0 log-update: specifier: ^6.0.0 version: 6.1.0 @@ -1358,6 +1364,11 @@ packages: resolution: {integrity: sha512-5XHYaSyiqADb4RnZ1Bdad6cPp8Toise4TzEjcOYDHZkTCbKgiUl7WTUCpNWHuxmDt91wnsZBc9xinNzopv3JMQ==} engines: {node: '>=18'} + is-in-ci@1.0.0: + resolution: {integrity: sha512-eUuAjybVTHMYWm/U+vBO1sY/JOCgoPCXRxzdju0K+K0BiGW0SChEL1MLC0PoCIR1OlPo5YAp8HuQoUlsWEICwg==} + engines: {node: '>=18'} + hasBin: true + js-tokens@4.0.0: resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} @@ -2741,6 +2752,8 @@ snapshots: dependencies: get-east-asian-width: 1.6.0 + is-in-ci@1.0.0: {} + js-tokens@4.0.0: {} jsesc@3.1.0: {} diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 61217bc..e3b956d 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -15,6 +15,7 @@ catalog: vite-plus: ^0.1.22 "@vitejs/plugin-vue-jsx": ^5.1.5 chalk: ^5.6.2 + is-in-ci: ^1.0.0 strip-ansi: ^7.2.0 overrides: vite: "catalog:"