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:"