Files
vue-tui/packages/runtime
Yunfei He c8cbacfbb8 fix(runtime): render direct text/Newline children of a standalone <Transform> (Ink parity, G58) (#61)
In Ink, <Transform> IS an ink-text host node (Transform.tsx renders
<ink-text internal_transform={fn}>), and the reconciler sets isInsideText
for ink-text. So bare-string and <Newline> children of a standalone
<Transform> render inline within that ink-text, with the transform applied
per line by the Output, and a nested <Text> child is squashed inline too.
The canonical README pattern `<Transform transform={fn}>Hello World</Transform>`
(no inner <Text>) works standalone.

vue-tui's transform host was a non-text yoga carrier: its direct text-leaf
children hit the paint no-op leaf branch and were silently dropped, a
<Newline> inside it rendered as a standalone "text" node (name-based
isInsideText saw no <Text> ancestor), and the SR squash ignored bare
text-leaf children. So `<Transform>ab</Transform>` rendered nothing and
`<Transform>a<Newline/>b</Transform>` applied the transform to empty lines.

Fix (treat a standalone <Transform> as a text context, matching Ink):
- paint.ts: when a transform has no yoga-carrying child, squash its inline
  children to a string and write it like a text node, pushing the transform
  as a per-line Output transformer (applied at paint, never at squash —
  matching Ink). Transforms wrapping a yoga child keep the recursion path.
- yoga.ts: bind a text-style measure func on a standalone transform (its
  squashed children, WITHOUT its own fn — matching Ink measureTextNode), and
  toggle it off/on as yoga children are inserted/removed.
- Newline.ts / Text.ts: treat a <Transform> ancestor as a text context, so a
  <Newline>/<Text> directly inside a transform emits inline virtual-text.
- node-ops.ts: allow bare text-leaf children of a transform (text-context
  check) and mark a standalone transform dirty on inline child change; the
  <Box>-in-<Text> guard still excludes transform (Ink renders Box-in-Transform
  empty, no throw).
- screen-reader.ts: include bare text-leaf/virtual-text children of a
  transform in SR output (Ink squashTextNodes includes #text).

All transform-in-text (G21/G32/G52), <Transform><Text>…</Text></Transform>,
nested transforms, plain <Text>/<Newline>, and Box-in-Transform cases verified
against Ink 7.0.4. Expected outputs (columns=40) confirmed by running the Ink
reference: `<Transform transform={s=>`<${s}>`}>ab</Transform>` + after →
"<ab>\nafter"; `<Transform>a<Newline/>b</Transform>` → "<a>\n<b>" (per-line);
SR → "ab" / "a\nb".

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-30 14:07:56 +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)
useExit() Programmatic exit — returns exit(error?)
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