Files
vue-tui/packages/runtime
Yunfei He 7707322382 fix(runtime): align debug-mode unmount byte stream to Ink (#142)
* fix(runtime): align debug-mode unmount byte stream to Ink

In DEBUG mode (non-interactive) vue-tui's teardown emitted neither a final-frame
re-emit nor a trailing newline, while Ink emits both (ink.tsx:749-762 settleThrottle
re-emit + ink.tsx:812-819 `debug ? '\n' : lastOutput + '\n'`). So for a debug app
that renders "Hello" once, Ink's byte stream is "HelloHello\n" but vue-tui's was
just "Hello" — a divergence that matters when porting Ink debug snapshots / CI logs.

- Fire the final-frame re-emit `mountedCommit()` for debug too (was interactive-only).
- In the non-interactive teardown write, emit a bare "\n" for debug (Ink parity),
  keeping the non-debug `lastFrame + "\n"` branch byte-identical.

Because the @vue-tui/testing render() helper captures debug commits via an internal
frame sink, gate the debug commit's two `frameSink?.(...)` forwards on
`!teardownStarted` so the teardown re-emit (a stdout byte-parity FLUSH, not a render)
does not append a spurious entry to the helper's live `frames[]`. `teardownStarted`
is set at the top of teardown() before the re-emit, so this covers EVERY teardown
route (unmount / cleanup / exit / Ctrl+C / signal / process.exit). Both `stdout.write`
calls stay unconditional, preserving byte parity.

Adds a PTY byte-parity test (asserts "HelloHello\r\n") and a testing-helper test
covering all teardown routes (frames.length stable, incl. <Static>).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* test: drop CI-fragile exitOnCtrlC frames-teardown case

The Ctrl+C case timed out in CI (waitUntilExit never resolved — stdin/raw-mode
timing is environment-fragile), while passing locally. Ctrl+C routes through the
SAME exit-driven teardown path as programmatic useApp().exit()
(emitInput → appContext.exit() → teardown()), which the remaining exit() cases
already cover, so removing it loses no teardown-route coverage of the
!teardownStarted frame-sink gate.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 13:31:35 +08:00
..

@vue-tui/runtime

Early stage — under active development. Bug reports welcome, but not recommended for production use yet.

Vue 3 terminal renderer with Yoga flexbox layout — build rich TUI apps with the same component model you use on the web.

npm version npm downloads

Why

  • Vue SFC & JSX — <template>, TSX, or render functions — your choice
  • Yoga flexbox — the same layout engine behind React Native, not a CSS-subset hack
  • Built-in input system — keyboard handling, focus management, Tab navigation
  • Terminal-native — renders directly to stdout, purpose-built for CLI tools and AI agent interfaces

@vue-tui/runtime is a terminal platform renderer parallel to @vue/runtime-dom, comparable to React Ink but adapted for Vue's reactivity model.

Install

npm install @vue-tui/runtime vue

Quick Start

// src/main.ts
import { createApp } from "@vue-tui/runtime";
import App from "./app.vue";

createApp(App).mount();
<!-- src/app.vue -->
<script setup lang="ts">
import { shallowRef } from "vue";
import { Box, Text, useInput } from "@vue-tui/runtime";

const count = shallowRef(0);

useInput((input) => {
  if (input === "+") count.value++;
  if (input === "-") count.value--;
});
</script>

<template>
  <Box>
    <Text>Count: </Text>
    <Text bold color="green">{{ count }}</Text>
    <Text dimColor> (+/- to change)</Text>
  </Box>
</template>

Components

Component Description
<Box> Flexbox container — direction, wrap, align, justify, gap, padding, margin, borders, background
<Text> Styled text — color, bold, italic, underline, strikethrough, dimColor, wrap/truncate modes
<Spacer> Expands to fill available space (flex-grow: 1)
<Newline> Inserts line breaks (configurable count)
<Static> Renders a list of items once, above the redrawn region
<Transform> Applies a string transform function to each rendered line

Composables

Composable Description
useInput(handler, opts?) Keyboard input — (input, key) with modifier and arrow key detection
useFocus(opts?) Component-level focus — returns { isFocused, focus }
useFocusManager() App-level focus — focusNext(), focusPrevious(), focus(id)
useApp() App lifecycle — { exit(error?), waitUntilRenderFlush() }
useTerminalSize() Reactive terminal dimensions — { columns, rows }
useAnimation(opts?) Frame-based animation loop — returns { frame, time, delta, reset }
useBoxMetrics(ref) Reactive layout metrics — { width, height, left, top, hasMeasured }
measureElement(node) Imperative read of computed { width, height } from a yoga node
useCursor() Control terminal cursor visibility
usePaste(handler, opts?) Handle clipboard paste events
useStdin() Access stdin stream and raw mode control
useStdout() Write directly to stdout
useStderr() Write directly to stderr

App Lifecycle

import { createApp } from "@vue-tui/runtime";

// Fire and forget (most common):
createApp(App).mount();

// Wait for the app to exit:
const app = createApp(App);
app.mount();
await app.waitUntilExit();

// Custom streams (for testing):
createApp(App).mount({ stdout, stdin, stderr });

License

MIT