* 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>
11 KiB
vue-tui
Public beta — the
@vue-tui/runtimeAPI 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/viteplugin (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
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
- 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/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