be045cfaff
app.clear() should wipe the rendered output and leave the terminal caret
HIDDEN, like Ink v7.0.4. Instead vue-tui repositioned and RE-SHOWED the
caret on the now-blank screen.
Same scenario both sides (useCursor {x:5,y:0}, "Hello", columns 40):
Ink clear() bytes: \x1b[?25l \x1b[1B \x1b[1G \x1b[2K \x1b[1A \x1b[2K \x1b[G
vue-tui clear() bytes: ...same... + \x1b[1A \x1b[6G \x1b[?25h (BUG)
Root cause: mountedClear() runs writer.clear() (hide + erase, correct) then
writer.sync(...). vue-tui's sync re-emits the PERSISTENT declared cursor (a
blessed divergence that is correct for repaints, which redraw the content),
so it wrote buildCursorSuffix = reposition + show. But clear() erases WITHOUT
redrawing, so re-asserting the caret floats it on a blank screen. Ink's own
clear()-time sync sees cursorDirty=false and emits no caret for the same
reason.
Fix: add an optional SyncOptions { cursor?: boolean } to log-update's sync
(both the standard and incremental variants) and thread it through
FrameWriter.sync. When cursor:false, sync treats the active cursor as
undefined for that call only: no reposition/show, and (since clear() already
set cursorWasShown=false) no hide either. It does NOT touch the persistent
cursorPosition, so the NEXT real commit re-shows the caret normally. Only
mountedClear() passes { cursor: false }; the clearTerminal/resize sync and
the external-write restoreLastOutput path (which redraw) keep the default
cursor:true, so they still re-assert the caret.
Verified byte-exact against real Ink v7.0.4 across a 10-scenario matrix
(active cursor, no cursor, clear-then-rerender, multiline y>0, {0,0}, two
clears, owner-unmounted, non-interactive/debug no-op, external-write restore,
clear-then-resize). New test: clear-cursor.test.tsx (raw interactive stdout
byte capture; testing lastFrame() is content-only and cannot see cursor
escapes).
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@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.
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() } |
useWindowSize() |
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 });
Links
- vue-tui — monorepo root
@vue-tui/cli— dev server with HMR@vue-tui/testing— test harness for terminal components
License
MIT