Files
vue-tui/README.md
T
Yunfei He 62543d9452 feat(vite): replace @vue-tui/cli with an in-process @vue-tui/vite plugin (#215)
* docs(runtime): tighten the README status banner

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

* feat(runtime): add connectDevtools/isDevConnected dev API (internal)

* refactor(runtime): gate dev overlay on isDevConnected(); drop __VUE_TUI_DEV__ define

The build-define approach required a bundler transform and couldn't be
tested without a real build. Replace the two __VUE_TUI_DEV__ gates in
render.ts with isDevConnected() (set by connectDevtools() at runtime)
so the overlay can be exercised in unit tests without a define injection.

Also removes the dead __VUE_TUI_DEV__: "true" define from the @vue-tui/cli
vite plugin and deletes the ambient env.d.ts declaration.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* feat(vite): scaffold @vue-tui/vite + forceClientCompile

Creates the new @vue-tui/vite package with forceClientCompile helper that
forces @vitejs/plugin-vue to emit client render functions (with HMR) even
when running in Vite's SSR runnable environment, by intercepting transform/load
hooks and flipping the ssr option to false.

* feat(vite): add bridgeHmrEventsToRunner (state-preserving HMR)

* test(vite): cover bridgeHmrEventsToRunner object-form + no-ssr branches

* feat(vite): add isExternalId build filter

* feat(vite): add virtual:vue-tui/dev module

Adds devVmodPlugin (apply:'serve') that resolves virtual:vue-tui/dev
to its \0-prefixed id and loads a snippet that imports connectDevtools
from @vue-tui/runtime/internal and calls it with import.meta.hot.
Re-exports DEV_VMOD_ID and RESOLVED_DEV_VMOD_ID from index.ts.

* feat(vite): in-process dev plugin + vueTui() factory with HMR integration test

Implements devPlugin (src/dev.ts) that injects the dev-vmod connector at the
entry point, bridges HMR events to the SSR runner, and boots the app via the
runnable SSR environment. Wires everything in vueTui() (src/index.ts).

Adds the basic fixture (test/fixtures/basic) and a sequential integration test
that verifies (1) the app boots in-process rendering LABEL-A, and (2) a
template-only edit hot-swaps to LABEL-B-HOT with counter state preserved (≥3,
proving bridgeHmrEventsToRunner prevents a state-resetting reload).

Note: test uses configFile:false to pass vueTui() plugins inline, bypassing a
rolldown v0.2.1 bug where combining transform.define with a plugin transform
returning {code, map:null} throws "Cannot convert undefined or null to object"
during bundleConfigFile. The actual plugin and HMR behaviour are fully exercised.

* fix(vite): inject dev module into the configured entry (not just conventions)

The transform inject condition matched a Set of root-relative ids against the
ABSOLUTE fs path Vite passes to the transform hook, so injectInto.has(path)
never matched. A custom entry (vueTui({ entry: "/src/app.ts" })) silently got
no virtual:vue-tui/dev import → no overlay, no HMR-connect; the default entry
only worked by accident via the endsWith fallback over ENTRY_CONVENTIONS.

Match on the absolute path with path.endsWith(entry) (entry is root-relative,
so the leading "/" anchors the match), injecting into exactly the entry that
configureServer's runner.import(entry) loads. Drop the dead injectInto Set and
ENTRY_CONVENTIONS list.

Adds src/dev.spec.ts pinning: custom entry injects, default entry injects, query
suffix is stripped, and non-entry modules are left untouched.

* fix(vite): forward build-error HMR payloads to the SSR runner so the dev overlay renders

This dev server runs the app in the SSR runnable environment with the browser
socket off, so Vite's typed { type: "error" } compile/build broadcast (sent over
the same object as server.ws) never reached the module runner. The runtime's
initHmrBridge listens for `vite:error` on the SSR hot channel, so the dev overlay
never learned of build errors. bridgeHmrEventsToRunner only forwarded
type:"custom" payloads; extend it to also forward type:"error" AS-IS — the runner
dispatches `vite:error` straight from that payload (whose .err the runtime reads).

Empirically verified (real ws/client.hot/ssr.hot taps): the error broadcasts
in-process, ws.send IS client.hot.send, and forwarding as-is fires the runner's
vite:error listener → devState becomes error → the overlay renders "Build Error"
plus the real [vue/compiler-sfc] diagnostic. Red/green confirms it is load-bearing.

- unit: error payloads are forwarded as-is onto the ssr hot channel
- integration (overlay.sequential): boot, inject a <script setup> syntax error,
  assert the overlay's "Build Error" header + "compiler-sfc" diagnostic in-process
- the integration test uses a dedicated fixtures/overlay copy so it can't race
  dev.sequential's edits to fixtures/basic/app.vue under file-level parallelism
- drop an unused `vi` import that was failing lint in the dev-overlay spec

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

* feat(vite): production build path (single Node entry)

vite build now emits a single self-contained Node entry via buildConfigPlugin
(apply: "build"): target esnext, modulePreload:false, rollupOptions.input=entry,
external=isExternalId, output entryFileNames "[name].js". Wired into vueTui()
alongside the apply:"serve" dev plugins so the two coexist per mode.

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

* feat(vite): in-process full-reload restart + app-exit dev-server teardown

An entry-level edit Vite can't hot-accept (e.g. editing main.ts) emits a full
reload. Verified by a real run against the configFile:false harness: Vite's SSR
module runner already re-executes the entry on full reload, and the runtime's
existing `vite:beforeFullReload` handler fires BEFORE that re-import. So no
manual re-import is needed in dev.ts — but the OLD app was never torn down,
leaving a zombie: its renderer/timers keep writing while the new mount() either
hits the instance-reuse guard (reload no-ops) or interleaves frames.

Runtime: render.ts registers the active dev app's internal teardown() with the
HMR bridge on mount and clears it on unmount; hmr.ts's vite:beforeFullReload
handler runs that teardown just before the runner re-imports. teardown() (not
unmount()) is used so the reload does NOT settle the exit promise.

App-exit teardown: in dev the app runs in-process under the dev server, which
holds the event loop open, so a genuine app exit (useApp().exit() / drain /
error) would hang. The runtime snapshots a `__VUE_TUI_TEARDOWN__` hook at mount
and calls it when the exit promise settles; dev.ts sets it to close the server
so the process exits cleanly. A full reload never settles the exit promise, so
it can't trigger a server close.

Test: full-reload.sequential.test.ts (dedicated reload/exit fixtures to avoid a
file-parallelism race) proves a single clean monotonic counter after one and
two consecutive entry edits (no zombie), and that a genuine app exit closes the
dev server. Verified red→green by disabling each hook.

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

* feat: remove @vue-tui/cli; migrate examples + README to @vue-tui/vite

The @vue-tui/vite plugin (vueTui()) replaces the @vue-tui/cli bundledDev +
child-process dev story with an in-process Vite dev server (HMR) plus a
production build, so the cli package is now obsolete.

- Delete packages/cli/ entirely (bundledDev, hmr-loader, process-manager,
  bundle-extractor).
- Migrate examples basic-template, basic-jsx, and coding-agent to the plugin
  form: vite.config.ts uses vueTui(); scripts become dev=vite, build=vite build,
  start=vite build && node dist/main.js; drop the @vue-tui/cli dependency.
- Add examples/basic-template/README.md: the example is a config reference for
  vanilla vite@8 (recommended, proven). In this monorepo `vite` is overridden to
  vite-plus-core: `vite build` works, but the in-process dev server cannot run
  (its ssr environment is not a runnable dev environment) — a vite-plus-core
  limitation, not a plugin bug.
- Update root README quick-start and package READMEs to the plugin form.
- CI task graph: replace ci:test:cli with ci:test:vite.
- build-output integration test: swap the cli package case for vite.

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

* fix(runtime): give DevOverlay Box slot functions to silence Non-function-slot warning

The dev overlay passed array children to the `Box` component in two places —
the ok-state wrapper render (fires on EVERY dev session) and ErrorDisplay.
Vue warns "Non-function value encountered for default slot" for array children
on a component, and the runtime routes console.warn through the frame writer, so
the warning was visible in a real terminal on every dev boot. Wrap the children
in slot functions (`() => [...]`); rendered output is unchanged.

Add a focused guard spec that mounts the dev overlay in both the ok and error
states with a console.warn spy and asserts no Non-function/default-slot warning
is emitted (verified RED against the unfixed code).

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

* fix(vite): make isExternalId Windows-safe (port vue-tui#209/#210 to @vue-tui/vite)

@vitejs/plugin-vue resolves the SFC to an absolute path; the old POSIX-only
/^[./]/ check missed Windows drive-letter/UNC paths, so the .vue file was
externalized and the built bundle crashed with ERR_MODULE_NOT_FOUND on Windows.
Use posix.isAbsolute || win32.isAbsolute, mirroring the CLI fix being deleted.

* chore: align vitest to upstream per vite-plus#1588 (drop vitest override)

* chore: run repo on vanilla vite (repoint the vite override from core to vanilla)

- catalog vite -> vanilla 8.1.0; the original @voidzero-dev/vite-plus-core spec is
  preserved as a commented catalog line for easy revert
- KEEP the `vite: "catalog:"` override ACTIVE: it just tracks catalog.vite, so with
  catalog on vanilla it now pins vite's version spec (incl. third-party peer ranges)
  tree-wide to 8.1.0 -- vanilla, not core. The override was never inherently 'Vite+';
  repointing the catalog is enough to flip the whole tree to vanilla.
- keep the single-@types/node override for stable types across the workspace
- vp commands still work; vp run ready green (build incl. all examples on vanilla,
  lint, type, 1289 + 129 PTY tests); vitest already on upstream (prev commit)

* chore(examples): drop needless spread of vueTui() in basic-jsx config

vueTui() returns Plugin[] and Vite flattens nested plugin arrays, so
`[vueTui({ entry }), vueJsx()]` works without the `...` and matches how the
other examples consume it. Verified: basic-jsx still builds on vanilla vite
8.1.0 (5 modules -> dist/main.js).

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

* refactor(vite): trim dead exports, micro-opt transform, dedupe test helpers

/simplify cleanup pass:
- index.ts: export only `vueTui` (+ default). The re-exported forceClientCompile/
  bridgeHmrEventsToRunner/isExternalId/buildConfigPlugin/DEV_VMOD_ID/RESOLVED_DEV_VMOD_ID
  were consumed by nothing (specs import from their own modules; examples import only
  vueTui) and the package ships no types.
- dev.ts: strip the entry query with indexOf/slice instead of split('?')[0], dropping a
  throwaway array on every module transform.
- extract packages/vite/test/helpers.ts (capture/waitUntil/waitFor), replacing the
  byte-identical copies in the three *.sequential.test.ts files.

vp run ready green (1289 + 129 PTY); @vue-tui/vite 9 files / 18 tests pass.

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

* chore(examples): rename example "start" script to "preview"

Align basic-template/basic-jsx/coding-agent with flappy-bird and the Vite
dev/build/preview convention; the script is unchanged (vite build && node
dist/main.js), only its name. README scripts block updated to match.

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

* fix(runtime): re-arm the HMR bridge per-hot so dev survives full reloads on published installs

initHmrBridge guarded registration with a process-lifetime `initialized` flag. On a real
npm install @vue-tui/runtime lives in node_modules, which Vite's SSR dev runner EXTERNALIZES
— so the runtime's module-globals persist across full reloads. After reload #1 the
re-imported dev module's connectDevtools() hit `if (initialized) return` and never
re-registered listeners on the new hot, so vite:beforeFullReload stopped firing: the dev
overlay + HMR status went dead and the next reload leaked a zombie app (the instance-reuse
guard no-ops the new mount, the old renderer keeps writing). The monorepo BUNDLES the runtime
(workspace real-path outside node_modules), re-executing it each reload so the flag reset —
which is why full-reload.sequential.test.ts passed and masked the regression.

Track the hot identity instead: re-arm each new hot, skip only a redundant re-call on the
same hot. Adds a failing-first test that forces ssr.external (the published path) and asserts
a SECOND full reload tears down cleanly.

Found by adversarial review; reproduced with the real built runtime under forced ssr.external
(reload #2 zombie counter climbing 130->264), now clean across 3 reloads.

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

* fix(vite): force-client-compile user-added plugin-vue-jsx so JSX renders in dev

vueTui() force-client-compiled only the @vitejs/plugin-vue it creates itself, never a
@vitejs/plugin-vue-jsx the user adds alongside it (basic-jsx does `plugins: [vueTui({entry}),
vueJsx()]`). So in the dev SSR module runner the .tsx compiled in SSR mode (ssrRegisterHelper,
no import.meta.hot) and the terminal CLIENT renderer got SSR-shaped output -> a BLANK frame,
silently (no error).

Move force-client-compile into devPlugin's configResolved and apply it to every vite:vue /
vite:vue-jsx plugin in the resolved set (idempotently), so both our own plugin-vue and any
user-added plugin-vue-jsx emit client render functions in the SSR dev environment. Verified
by run: basic-jsx went from 1 byte (blank) to a full render. Adds a JSX dev fixture + a
failing-first render test (and @vitejs/plugin-vue-jsx as a devDependency for it).

Note: JSX edits still full-reload rather than state-preserving hot-swap (import.meta.hot is
injected by Vite core only in the client env, not by the plugin) — a known limitation, not a
blank screen. Found by adversarial review.

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

* fix(vite): normalize a './'-prefixed custom entry so dev injection matches build

dev injects the dev module when the absolute module id endsWith(entry); build feeds entry
to rollupOptions.input. A "./src/main.ts" entry slipped past dev's match (absolute ids never
end with "./...") -> no virtual:vue-tui/dev -> no HMR/overlay, while build's stripLeadingSlash
left "./" intact and still succeeded — a silent dev/build split. normalizeEntry() canonicalizes
"/x", "x", and "./x" to a bare form (dev re-adds the slash, build uses it as-is).

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

* test(vite): give build.sequential its own fixture to remove a cross-file flake

build.sequential and dev.sequential both targeted fixtures/basic; dev.sequential's hot-swap
test writes app.vue (LABEL-A -> LABEL-B-HOT), and with fileParallelism that edit could land in
build.sequential's output mid-run and break its toContain("LABEL-A"). Copy basic -> a private
`build` fixture (the pattern overlay/reload already use).

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

* docs(runtime): caution against top-level await waitUntilExit() in dev entries

Under the @vue-tui/vite dev server a top-level `await app.waitUntilExit()` blocks the entry
module's evaluation, wedging Vite's serial HMR full-reload queue after the first reload (the
dev server already keeps the process alive). Prefer fire-and-forget mount() in dev; reserve
waitUntilExit() for standalone/production entries.

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

* fix(vite): clone the Vue hook options instead of mutating Vite's shared object

forceClientCompile flipped opt.ssr=false in place on the transform hook's options arg, but
Vite reuses that object for the transform hooks of plugins ordered after vue/vue-jsx — so they
saw ssr:false and compiled for the wrong environment. Pass a clone {...opt, ssr:false} to the
Vue hook instead; the shared object is untouched. Adds a no-mutation test. Regression from
a350609 (which widened force-client-compile to the JSX path); found by round-2 review.

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

* fix(vite): preserve Windows-absolute entries in normalizeEntry

normalizeEntry stripped/prefixed unconditionally, turning a "C:/proj/src/main.ts" entry into
"/C:/proj/src/main.ts" — which never matches Vite's drive-letter module id, so dev injection
(HMR/overlay) silently missed. Leave drive-letter absolute paths as-is; only root-relative
"/x"/"x"/"./x" get the canonical slash treatment. Adds a regression test. Regression from
b373fa2; found by round-2 review.

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

* fix(vite): neutralize Vite's CLI keyboard shortcuts so they don't hijack the TUI's stdin

The PR runs the TUI in-process with the `vite` CLI, which binds keyboard shortcuts (q=quit,
r=restart, …) via a readline 'line' listener on process.stdin — the same stdin the runtime owns
in raw mode. So a submitted "q"/"r"/… line ran a dev-server action out from under the app
(q = server.close(), killing the session). configureServer now stubs server.bindCLIShortcuts;
the terminal app, not the CLI, owns the keys. Adds a sequential test that forces the enable gate
(httpServer + isTTY + !CI) and asserts no _shortcutsState is bound. Found by round-2 review.

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

* test(runtime): pin the per-hot HMR re-arm + fix a stale guard comment

The idempotency test header still described the old "MODULE-LEVEL boolean" guard the per-hot
refactor (54c1f77) replaced, and no unit test distinguished the per-hot guard from the boolean.
Update the comment to the hot-identity guard and add a hot-A->hot-B re-arm test (the integration
test already guards it end-to-end; this pins it at unit speed). Found by round-2 review.

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

* fix(vite): preserve Windows UNC entries in normalizeEntry

The round-2 Windows-absolute fix normalized backslashes then stripped leading slashes, turning
a UNC entry "\\server\share\src\main.ts" -> "//server/share/src/main.ts" -> relative
"server/share/src/main.ts" — so build resolved the wrong file (and it diverged from external.ts's
UNC-aware contract). Detect UNC ("//host/share/…") alongside drive-letter and leave it absolute.
Adds a UNC dev+build regression test. Regression from ecc8690; found by round-3 review.

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

* fix(vite): pass POSIX-absolute (and any rooted) entries through normalizeEntry

normalizeEntry special-cased only Windows drive-letter + UNC absolutes; a plain POSIX-absolute
entry (the standard fileURLToPath(new URL('./src/main.ts', import.meta.url)) idiom) fell through
and had its leading slash stripped, so vite build got a project-relative path and failed with
UNRESOLVED_ENTRY — while dev's endsWith still matched, hiding it until build/CI. Generalize the
guard: pass through anything already rooted (a leading '/' — covering root-relative,
POSIX-absolute, and UNC — or a drive-letter), normalizing only the relative forms. Adds a
POSIX-absolute dev+build regression test. Found by round-3 review.

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

* refactor(vite): table-drive the entry-form tests + fix stale entry comments

Consolidate the four near-identical vueTui entry tests ('./', drive-letter, UNC, POSIX) into a
single test.each — shorter, and now every form asserts BOTH dev injection and the build input
(previously './' and drive-letter only checked dev). Also correct two comments the entry-handling
evolution left stale: index.ts no longer claims build 'must have no leading slash' (rooted entries
pass through), and dev.ts's transform note reflects that entry can be a drive-letter path, not only
'/'-rooted. No behavior change.

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

* docs: rewrite the beta banner — scope experimental note to dev-mode HMR, tighten wording

The Vite plugin's build path is solid; it's dev-mode HMR that's still experimental. Both READMEs:
'Public beta — the @vue-tui/runtime API is stabilizing toward 1.0; dev-mode HMR is still
experimental. Bug reports welcome.'

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

* chore(ci): rename the ci:test:vite task to ci:test:vite-plugin

Clearer name for the task running @vue-tui/vite's (the Vite plugin's) suite. Renamed the
definition + its reference in the 'ci' aggregate's dependsOn; command (vp run @vue-tui/vite#test)
unchanged. Verified 'vp run ci:test:vite-plugin' resolves and passes.

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-28 00:42:22 +08:00

11 KiB

vue-tui

Public beta — the @vue-tui/runtime API is stabilizing toward 1.0; dev-mode HMR is still experimental. Bug reports welcome.

The Vue framework for terminal UIs. Build with components, develop with HMR, test with confidence.

@vue-tui/runtime · @vue-tui/vite · @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 the @vue-tui/vite plugin (npm run 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

Flappy Bird built with vue-tui

Quick Start

npx tiged vuejs-ai/vue-tui-starter my-app
cd my-app
npm install
npm run dev      # vite + @vue-tui/vite plugin, in-process terminal HMR

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

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/vite Vite plugin — add vueTui() to vite.config.ts for an in-process terminal dev server with HMR (npm run dev) plus a production build (vite build). 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

To run an example with terminal HMR, use vanilla vite@8 (the recommended setup): cd examples/basic-template && npm run dev. See that example's README.md for the in-monorepo caveat.

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