The frame writer initialized lastFrame to "" and also reset it to "" on clear(). Since commit() calls clear() then write(), any component that painted to an empty string (e.g. Transform with no children, or a component returning null after unmounting children) would be deduplicated against the cleared state and never emitted. Use null as the sentinel for "no previous frame" so the first write after a clear always goes through, even when the frame is empty. Fixes: Transform with null/undefined children tests Fixes: Focus unmount crash tests (stale frame persisted) Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@vue-tui/runtime
Vue-idiomatic terminal renderer in the spirit of React Ink. Platform-specific runtime parallel to @vue/runtime-dom.
Install
pnpm add @vue-tui/runtime vue
Quickstart
import { defineComponent, h, ref } from "vue";
import { createApp, Box, Text, useInput } from "@vue-tui/runtime";
const Counter = defineComponent({
setup() {
const count = ref(0);
useInput((input) => {
if (input === "+") count.value++;
if (input === "-") count.value--;
});
return () => h(Box, null, h(Text, null, `Count: ${count.value}`));
},
});
createApp(Counter).mount();
API
Single entry point — createApp(root, rootProps?). Returns a TuiApp (Omit<VueApp<TuiNode>, "mount"> plus four TUI methods).
import { createApp } from "@vue-tui/runtime";
const app = createApp(App, { initialState }).use(myPlugin).provide(themeKey, dark);
app.mount(); // all defaults (process.*)
app.mount({ debug: true }); // just flags
app.mount({ stdout: customWritable }); // partial stream override
app.mount({ stdout, stdin, stderr }); // explicit streams (testing)
await app.waitUntilExit();
app.unmount(); // optional — see "Cleanup" below
Cleanup
mount() registers a process.on("exit") listener that runs the same teardown
as unmount(). So you only need app.unmount() if you want to tear down the
app before the process is ready to exit (mid-program, in tests, or when one
process hosts multiple UIs sequentially). For a normal CLI that exits when the
user quits or the script ends, cleanup happens automatically.
TuiApp interface
interface TuiApp extends VueApp<TuiNode> {
mount(options?: MountOptions): ComponentPublicInstance;
unmount(): void;
waitUntilExit(): Promise<void>;
}
interface MountOptions {
stdout?: NodeJS.WriteStream; // default: process.stdout
stdin?: NodeJS.ReadStream; // default: process.stdin
stderr?: NodeJS.WriteStream; // default: process.stderr
debug?: boolean; // default: false
exitOnCtrlC?: boolean; // default: true
}
All fields optional with per-field fallback. Matches Ink's RenderOptions shape.
Components
Box, Text, Newline, Spacer, Static, Transform.
Composables
useExit, useInput, useFocus, useFocusManager, useStdin, useStdout, useStderr, useTerminalSize.
Tab / Shift+Tab / Escape are handled automatically when any component uses useFocus. useFocus returns { isFocused, focus } and manages raw mode. useFocusManager exposes activeId in addition to focusNext / focusPrevious / focus / enableFocus / disableFocus.
Waiting on exit / handling errors
// Fire-and-forget (most common):
createApp(App).mount();
// Wait for the app to exit:
const app = createApp(App);
app.mount();
await app.waitUntilExit();
// Catch errors thrown from setup / render / useExit(err):
const app = createApp(App);
app.mount();
app.waitUntilExit().catch((err) => {
console.error(err);
process.exitCode = 1;
});
See docs/superpowers/specs/2026-05-18-vue-tui-core-design.md for the full design.