Ink's focusNext returns `findNextFocusable(...) ?? firstFocusableId` (App.tsx:455-487), so it ALWAYS reassigns activeId — to the next active, the first active, or undefined when NO focusable is active. vue's focusNext/focusPrevious only did `if (next) setActive(next)`, so when no focusable was active (reachable via focus(id) pinning an isActive=false item) the stale activeId was left in place. Call setActive(findNextActive(...)) unconditionally — a null result now clears the stale activeId (and fires the blur notification Ink also fires), matching Ink. Normal Tab cycling among active items is unchanged (findNextActive already wraps to the first/last active, equivalent to Ink's `next ?? first`). The focusables.length===0 guard is kept (activeId is already null at 0 focusables via remove(), and it avoids a %0 in the wrap arithmetic). Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
vue-tui
Early stage — under active development. Bug reports welcome, but not recommended for production use yet.
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, plus build and preview out of the box
- 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.
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>
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 |
@vue-tui/cli |
Development tool — vue-tui dev starts your app with Vite-powered HMR |
@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 |
useFocus(opts?) |
Component-level focus — returns { isFocused, focus } |
useFocusManager() |
App-level focus control — focusNext(), focusPrevious(), focus(id) |
useApp() |
App lifecycle — { exit(error?), waitUntilRenderFlush() } |
useTerminalSize() |
Reactive terminal dimensions — { columns, rows } |
useStdin() |
Access stdin stream and raw mode control |
useStdout() |
Write directly to stdout |
useStderr() |
Write directly to stderr |
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