* fix(runtime): margin/padding edge removal falls back to the surviving shorthand
Withdrawing a per-edge/axis margin or padding override from a box that still
has a broader shorthand collapsed the edge to 0 instead of falling back. E.g.
`margin={5} marginTop={8}` with marginTop later removed: the setter ran
setMargin(EDGE_TOP, 0), and per yoga edge precedence EDGE_TOP=0 overrides the
surviving EDGE_ALL=5, so the top margin became 0 (the box jumps 5 cells) when
the declarative model (render = f(current props), current = {margin:5}) says 5.
A single per-prop yoga setter can't reconcile an edge that depends on the
specific edge + axis + all-edges shorthand together.
Fix mirrors the existing reconcileBorderEdges pattern: the 14 margin/padding
setters become no-ops, and reconcileMarginEdges/reconcilePaddingEdges recompute
all four physical edges from the box's full el.props with most-specific-wins
precedence (top = marginTop ?? marginY ?? margin ?? 0, ...), zeroing the
composite edges so nothing layers on top. A present-but-non-finite value
(NaN/Infinity) or a withdrawn prop falls THROUGH to the next precedence level,
preserving yoga's prior setMargin(NaN)->fallback behavior; an explicit 0 is
finite and still overrides. margin keeps EDGE_START/END and padding keeps
EDGE_LEFT/RIGHT for left/right, matching the prior setters.
Verified against real yoga-layout@3.2.1 that the SET path produces identical
computed edges as the old per-setter code (no layout regression) across all
combinations and patch orders, and the correct fallback on removal.
This is NOT an Ink-parity item: run against Ink v7.0.4, Ink and pre-fix vue-tui
both collapse to 0 (the identical bug). The fix diverges from Ink by being
declaratively correct under the already-documented G19 reset principle;
recorded in ink-divergences.md alongside the display / flexDirection entries.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(runtime): pin margin/padding spacing to a finite-number contract (Codex review)
The family recompute resolves an edge from a prop only when it is a finite
number (matching the `number` prop type + Ink's number-only spacing); numeric
strings (`margin="5"`) are coerced for Vue static-template ergonomics, but other
non-numeric values (`"50%"`, junk, `""`) are treated as not-set and fall through
to the surviving shorthand instead of being forwarded to yoga.
This makes intentional the behavior change the final review flagged: the OLD
per-setter code incidentally forwarded off-contract strings to yoga (so
`marginTop="50%"` became a percent and `marginTop="foo"` threw). That was
undocumented and non-Ink. Also excludes "" from the present() check so all
non-numeric strings fall through uniformly (Number("")===0 would otherwise
resolve to 0). The numeric/numeric-string SET path is unchanged (re-verified
across all 5040 patch orders).
Tests pin the contract (numeric, numeric-string, "50%"/"foo"/"" fall-through,
NaN/withdrawn fall-through, explicit 0), and ink-divergences.md records it.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
vue-tui
Status — the runtime API is stabilizing; the CLI and dev toolkit are still experimental and may change. Bug reports welcome.
The Vue framework for terminal UIs. Build with components, develop with HMR, test with confidence.
@vue-tui/runtime · @vue-tui/cli · @vue-tui/testing
- Vue SFC & JSX — write terminal interfaces with
<template>, TSX, or both - Flexbox layout — powered by Yoga, the same engine behind React Native
- Dev toolkit (experimental) — HMR in the terminal via
vue-tui dev - Input & focus — keyboard handling, focus management, Tab navigation, Kitty keyboard protocol
- Testing harness — out-of-the-box component-level terminal testing — render, simulate input, assert frames
Flappy Bird — one of the examples included in the repo
Quick Start
npx tiged vuejs-ai/vue-tui-starter my-app
cd my-app
npm install
npm run dev
Edit app.vue and watch the terminal update instantly.
Add to an existing project
npm install @vue-tui/runtime
Example
// 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>
For non-interactive output — snapshots, CI logs, piped commands — renderToString(App) renders a single frame to a string instead of mounting.
Table of Contents
- Quick Start
- Example
- Packages
- Examples
- Components
- Composables (Hooks)
- Testing
- Development
- Contributing
- Credits
- License
Packages
| Package | Description |
|---|---|
@vue-tui/runtime |
The core framework — Vue 3 renderer for the terminal with components (Box, Text, Static, etc.), composables (useInput, useFocus, useApp, etc.), and yoga-based flexbox layout. API stabilizing. |
@vue-tui/cli |
Development tool — vue-tui dev starts your app with Vite-powered HMR. Experimental; may change. |
@vue-tui/testing |
Test harness — render in an isolated fake terminal, simulate input, assert output frame by frame |
Examples
| Example | Description |
|---|---|
basic-template |
Vue SFC with <template> syntax |
basic-jsx |
Same app in TSX |
coding-agent |
AI coding agent with LLM streaming and interactive UI |
flappy-bird |
Physics-based terminal game with reactive state and borders |
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 (Hooks)
| Composable | Description |
|---|---|
useInput(handler, opts?) |
Handle keyboard input — receives (input, key) with modifier and arrow key detection |
usePaste(handler, opts?) |
Handle bracketed paste — receives the pasted text as a single event |
useFocus(opts?) |
Component-level focus — returns { isFocused, focus } |
useFocusManager() |
App-level focus control — focusNext(), focusPrevious(), focus(id) |
useApp() |
App lifecycle — { exit(error?), waitUntilRenderFlush() } |
useWindowSize() |
Reactive terminal dimensions — { columns, rows } |
useStdin() |
Access stdin stream and raw mode control |
useStdout() |
Write directly to stdout |
useStderr() |
Write directly to stderr |
useBoxMetrics(ref) |
Measure a <Box> via a template ref — reactive { width, height, left, top, hasMeasured } (or measureElement(el) for a one-off { width, height } read) |
useCursor() |
Control the terminal cursor — setCursorPosition(pos) in output coordinates |
useIsScreenReaderEnabled() |
Whether a screen reader is active — returns a boolean for adapting accessible output |
useAnimation(opts?) |
Frame-based animation driver — reactive { frame, time, delta } + reset() |
Testing
The @vue-tui/testing package renders components in an isolated environment and lets you simulate input and assert visual output:
npm install -D @vue-tui/testing
import { defineComponent, shallowRef } from "vue";
import { expect, test } from "vitest";
import { render } from "@vue-tui/testing";
import { Box, Text, useInput } from "@vue-tui/runtime";
test("counter responds to + and - keys", async () => {
const Counter = defineComponent(() => {
const count = shallowRef(0);
useInput((input) => {
if (input === "+") count.value++;
if (input === "-") count.value--;
});
return () => (
<Box>
<Text>Count: {count.value}</Text>
</Box>
);
});
const { lastFrame, stdin } = await render(Counter);
expect(lastFrame()).toContain("Count: 0");
await stdin.write("+");
expect(lastFrame()).toContain("Count: 1");
await stdin.write("-");
expect(lastFrame()).toContain("Count: 0");
});
Development
Requires pnpm and Node.js 22+.
pnpm install # install dependencies
vp run ready # lint, typecheck, test, and build (the full check)
vp run -r test # run tests across all packages
vp run -r build # build all packages
vue-tui dev # start an example with HMR
Contributing
Contributions welcome! vue-tui is evolving fast — please open an issue before starting large changes. If you use AI tools, disclose it in your PR and make sure you've reviewed and tested everything before submitting.
Credits
vue-tui is built on the ideas pioneered by Ink — component model, yoga-based layout, focus system, and rendering pipeline — adapted to Vue's philosophy. Thanks to Vadim Demedes, Sindre Sorhus, and the Ink contributors.
License
MIT