Build terminal apps with Vue 3, rendered via OpenTUI.
Ground-up rewrite. The old implementation (custom renderer + yoga) lives in
old/ and is being stripped out progressively.
pnpm build # build core with tsdown
pnpm test # full suite: build + coverage + typecheck
pnpm test:cov # vitest with coverage
pnpm exec vitest run src/renderer/nodeOps.spec.ts # single test file
pnpm lint # oxlint
pnpm lint:fix # oxlint with auto-fix
pnpm test:types # tsc type checking
pnpm --filter playground dev # run the playground (OpenTUI)
pnpm --filter playground play # build + run; pass a route: node dist/main.js /demos/fractalOpenTUI's native renderer is loaded over FFI. Creating a renderer
(createCliRenderer) requires Node.js >= 26.3.0 with --experimental-ffi.
Plain imports of @opentui/core do not need FFI. The playground dev script
already passes --experimental-ffi.
Tests that build a renderer (e.g. @opentui/core/testing's createTestRenderer)
also need FFI, so test:cov / dev set NODE_OPTIONS=--experimental-ffi
(vitest's poolOptions.forks.execArgv did not propagate the flag to workers;
NODE_OPTIONS does). Such specs run under the node environment via a
// @vitest-environment node header (the suite default is happy-dom).
Always keep this file up to date when project commands, structure, or tooling change.
Doc comments: say what a thing is for, not how it works — short and stable over exhaustive and quick to go stale. Let the code/types carry the detail.
- Root package
vue-termuiis the core library.src/index.tsre-exports fromsrc/*.ts. Tests co-located as*.spec.ts; type tests as*.test-d.ts. packages/three/is@vue-termui/three: three.js WebGPU scenes rendered into the terminal (see its section below).playground/is a workspace package depending on the core viaworkspace:*. It imports only fromvue-termui(no direct@opentui/coreorvue); 3D pages may also import@vue-termui/three,threeand@tresjs/core(see the TresJS bullet in the three section).old/holds the previous monorepo, kept for reference while migrating.
vue-termuiis a Vue custom renderer (@vue/runtime-core'screateRenderer) over OpenTUI.nodeOps.tsmaps Vue tree mutations to OpenTUIRenderablemutations;index.tsexposescreateApp(async — it awaitscreateCliRenderer, then mounts the Vue root intorenderer.root).- Host element tags are lowercase and internal:
box→BoxRenderable,text→TextRenderable,input→InputRenderable,select→SelectRenderable. A lone string child of<text>goes through thesetElementTextfast path (.content =); array/interpolated text usesTextNodeRenderable. Comments are invisibleBoxRenderableanchors. - Text nodes only belong inside
<text>. OpenTUI'sBox.addrequires a layout node (getLayoutNode), whichTextNodeRenderablelacks. But Vue creates Fragment boundary anchors as empty text nodes and inserts them into the container — sov-for, multi-root components and<RouterView>put text nodes inside a<box>.nodeOpstherefore substitutes an invisible, out-of-flowBoxRenderableanchor for any text node placed in a non-<text>parent, and maps between the text node and its stand-in (per-appWeakMaps) forinsert/remove/parentNode/nextSibling. Without this, anyv-forthrowsgetLayoutNode is not a function. @opentui/coreis a private dependency — never re-exported.vue-termuire-exports@vue/runtime-coreso apps geth,defineComponent,ref, etc. from the same runtime-core instance the renderer is built on. Import these fromvue-termui, never fromvue(a second runtime-core copy breaks vnode/instance interop).
- Public components are unprefixed:
Box,Text,Newline(the oldTui-prefix is dropped — no DOM clash in a terminal). They are thin wrappers over thebox/texthost tags. The lowercase host tags still work directly in templates (the vite plugin registers them as custom elements). Boxis a passthrough functional component: OpenTUI'sBoxRenderableowns layout/border/padding/margin natively, so {@link BoxProps} forward unchanged.Textfolds its boolean style props (bold,italic,underline,dim,strikethrough,inverse,blink) into OpenTUI's singleattributesbitmask;fg/bg/wrapmap tofg/bg/wrapMode. Content is the slot. DefaultsflexShrink: 0(overridable): a flex-squeezedTextRenderablestill paints every wrapped row, drawing over the sibling below it — in a height-constrained column, wrapped Texts corrupted their neighbors (chars showing through spaces) before this default.
Wrapping an OpenTUI renderable (e.g. TabSelect, done as the template). Don't re-research — follow this:
- Find the renderable's API. List exports:
node --input-type=module -e "import * as c from '@opentui/core'; console.log(Object.keys(c).filter(k => /Foo/i.test(k)))". Read the types innode_modules/.pnpm/@opentui+core@*/node_modules/@opentui/core/renderables/<Name>.d.ts— it has the*RenderableOptions, the option type ({ name, description, value? }), and the*RenderableEventsenum. The implementation is bundled in that package'sindex.js(no per-renderable.js);grep -n "setSelectedIndex\|this.emit\|selectCurrent" index.jsto read behavior. - Decide how the model syncs in. Check whether the renderable exposes a property setter
(like
SelectRenderable.selectedIndex, silent) → ride the prop path, nowatch. If it only hassetX()methods (likeTabSelectRenderable.setSelectedIndex(), and there's noselectedIndexoption/setter) → seed ononMountedand drive changes with awatchon the prop. Confirm the setter that fires events (setSelectedIndexemitsselectionChanged) vs. the silent one, and guard the listener (if (index !== props.modelValue)) so it can't loop. - Copy the closest sibling.
Selectis the template for list/v-model+selectwidgets. Mirror itsprops/emits/onMountedshape; narrowoptionsto a local option type;Omitnative options you manage (options, andselectedIndexif the prop path doesn't apply). - Files to touch (all of them):
src/components/<Name>.ts+ co-located.spec.ts(tests first, watch them fail); host tag in bothnodeOps.createElement's switch +TuiElementTag(the vite plugin needs nothing — it reserves the wholetui-prefix); export component + types fromsrc/index.ts; aplayground/src/pages/<name>.vuepage + aSidebar.vuenav entry (routes are file-based viavue-router/auto-routes). - Sizing (fixed-width renderables like
TabSelect): a native renderable given a numericwidth≥ its container overflows the border (its inner fills/underline bleed past). Use a percentage/flex width (width="100%") — it resolves against the box interior (border + padding aware, e.g. awidth:60padding:1bordered box →56), so the renderable clamps to it and never overflows, at any terminal size. Percentages work becauseBoxis real flexbox (Selectuses40%). - Run:
NODE_OPTIONS='--experimental-ffi --disable-warning=ExperimentalWarning' pnpm exec vitest run src/components/<Name>.spec.ts, thenpnpm test:types+pnpm lint. In specs,test.mockInput.pressArrow('left'|'right'|'up'|'down')/pressEnter()drive keyboard nav; check the renderable's default keybindings inindex.js(defaultTabSelectKeybindings) for which keys move it.
- Default to stateful
defineComponents. Only a stateful component has a public instance, so consumers can grab it withuseTemplateRefand reach$el(the backing OpenTUI renderable) and any exposed methods — a functional component exposes none of that. (Inputis the template;Textarea/Selectfollow it.) Shape:setup+shallowReffor the renderable +onMountedto wire events/focus; aname; a runtimepropsdecl (only the non-native props:modelValue,autofocus, …); and anemitsobject of runtime validators ending insatisfies ExtractEventsNames<Props, RenderableOptions>(compile-time check that every event is declared). Type the exportTuiComponent<Props, Renderable>so$elis the concrete renderable, extendRenderableEventPropsin the Props, spread...renderableEmits, and callsetupRenderableEvents(el, emit)for the common focus/blur/destroyed. Read event payloads/values offelat emit time. - Reach for
FunctionalComponent<Props, Emits>only for pure passthroughs no one needs a handle to (Text,Box,Newline): no lifecycle, justh()— but also no instance, so nouseTemplateRef/$el/exposed methods. The explicitconsttype satisfiesisolatedDeclarations; add.displayNameand runtime.props(Boolean coercion + extracts real props fromattrs). - Fallthrough for native options: spread
...attrsso only set props reach the renderable. Never forwardundefined— it clobbers renderable defaults (e.g.Input.maxLengthdefaults to 1000;undefineddrops all typed input). v-model/ outside→renderable sync rides the prop path — pass the mapped prop explicitly (value: modelValue,selectedIndex: modelValue).patchPropassignsel[key]only when it changes (Vue skips unchanged props) and the OpenTUI setter is idempotent, so nowatchis needed. Verify the setter is silent/guarded (e.g.selectedIndexsetter doesn't emit, unlikesetSelectedIndex()) or you'll loop.- Mount-shaped side effects (listeners, initial
focus): stateful components useonMountedreading theshallowRef; functional ones use a functionrefdeduped with a moduleWeakSet<Renderable>so re-invocation on updates doesn't double-wire. - Consume the event payload, don't re-query — OpenTUI emits it
(
selectionChanged/itemSelected→(index, option)). OmitOpenTUI options you don't honor: leaked/dead ones (Inputomits theonSubmitit inherits from Textarea but never fires) and ones you manage yourself (Selectomitsoptions/selectedIndex). Don't invent semantics —Inputhas no submit event (a form concept); react to Enter viaonKeyDown.Textarea(TextareaRenderable) is a multi-line editor:modelValueseeds the buffer via the renderable's one-timeinitialValuesetter (so it rides the prop path yet never clobbers cursor/undo on reassignment — an editor owns its text), and edits emitupdate:modelValuevia theonContentChangehandler (readplainText;ContentChangeEventis empty). UnlikeInput, submit is real here: Enter → newline, Meta/Cmd+Enter →submit(OpenTUI's default keybinding); the component ownsonSubmitand re-emits it assubmitwith the text. To reset the editor, remount it with a:key.ProgressBaris aBox+Textcomposite (OpenTUI has no native progress bar).Image(ImageRenderable) draws PNG/JPEG/WebP/GIF from a path, URL, bytes or aNativeImage(re-exported, so apps can generate/resize sources). ItsonLoad/onErrorare native options, not emitter events, so@load/@errorneed no wiring — they fall through with the other attrs. Forwardingundefinedis safe forsource/fit/protocol(their setters map it to the default, and an unsetsourcedeliberately blanks the image). Like every renderable it has no intrinsic size: withoutwidth/heightor a flex rule it paints nothing. GIFs show their first frame only.Link/TextTransformare NOT ported yet — they need TextNode-with-link/ transform support threaded throughnodeOps(text-node children don't carry per-node link/style). Tracked intodos.json(phase 7).
Thin reactive wrappers over the renderer; all clean up via onScopeDispose.
onKeyDown/onKeyUpoverrenderer.keyInput(keypress/keyrelease). The publicKeyEventtype is defined locally — OpenTUI'sKeyEventis not exported from the package root. Mouse has no global stream: use per-elementonMouse*props (forwarded natively byBox).onResize/useTerminalSize(dims read live offrenderer.width/height),useTitle(setTerminalTitle, reset on unmount).useInterval/useTimeout— pure timers with scope cleanup.useFocus/useFocusManager. OpenTUI has no global Tab cycling — apps manage their own ordered list and callfocus().useFocus().refis a function ref, not a ref object:<script setup>unwraps a destructured composable ref used in:ref="x"(compiles toref: x.value→null), so a plain ref never binds. A function passes through untouched and forwards through component wrappers (<Box :ref>) in both SFCs and render fns. Read the element via the separateelementref if needed.
Three.js WebGPU scenes rendered into the terminal — a Node port of
@opentui/three (which is Bun-only). Bun still works: the hooks no-op there
and bun-webgpu runs natively over the real bun:ffi (bun dist/main.js runs a
built app; never import node:module's registerHooks as a named import —
Bun lacks it and fails ESM validation at load time).
- How it runs on Node:
bun-webgpu(Dawn overbun:ffi) is loaded throughnode:moduleregisterHooks(src/ffi/register.ts) that rewrite itsbun:ffiimports to anode:ffishim (src/ffi/bun-ffi.ts, needs--experimental-ffi) and resolve its platform package's.tsentry (which Node refuses to load from node_modules) to the native dylib path. Pointer model: Bun pointers are numbers, node:ffi's are bigints — the shim converts at every boundary.setupWebGPU()installsnavigator.gpu, theGPU*constructors and arequestAnimationFramepolyfill (three's internalAnimationloop needs it). - Ports (keep close to upstream for diffability):
canvas.ts(CLICanvas + supersampling, WGSL inlined as a TS template),WGPURenderer.ts(ThreeCliRenderer),ThreeRenderable.ts,TextureUtils/SpriteUtils/SpriteResourceManager/animation/*(sprites & particles; pure three/jimp).jimpis externalized by file URL in app builds likebun-webgpu— vite's client resolver would pick its browser build. - Vue layer:
Threecomponent (atui-boxfilled with aThreeRenderableviauseRenderer(); propsscene/camera/rendererOptions/autoAspect) andonFrame(cb)(per-frame callback with effect-scope cleanup). Both import Vue APIs fromvue-termui(peer dep) — never fromvue. autoAspectre-syncs every frame (epsilon-guarded): the terminal answers OpenTUI's pixel-size query asynchronously after setup/resize, socliRenderer.resolution(real cell metrics) is null at mount and the aspect falls back to assuming 1:2 cells — a one-shot computation stays subtly stretched (~8% in Ghostty).- One render mode prop (
src/render-mode.ts):rendererOptions.modeis either a name ('none' | 'cpu' | 'gpu' | 'ascii', default'gpu'— the WGSL compute shader packing 2×2 px per cell as quadrant glyphs) or{ name, options }with the options that only that mode reads (ascii:chars/style/contrast,gpu:algorithm).resolveRenderMode()merges them over the current state, so a partial update keeps the rest and every mode's options surviverenderer.cycleMode()(NONE→CPU→GPU→ASCII; the texture + tres demos bind it toU/M).cellSizeFor()is the single source of render px per cell (4×8 for ascii'shape', else 2×2, 1×1 for'none') — the renderer resizes the target when a switch changes it. - Build/bundling invariant: in app builds the 3D stack is bundled (so
it shares the bundle's single
@vue/runtime-coreandthree); onlybun-webgpustays external, resolved to an absolute file URL at build time by thevue-termui:native-externalsplugin insrc/vite.ts(a bare external would be unresolvable from the app under pnpm's isolated node_modules). Externalizing@vue-termui/threeinstead loads a second runtime-core and breaks provide/inject (useRenderer() must be called…). - Tests need the FFI env var like the core suite; the WGPU specs create real
GPU devices (Metal/Vulkan required). Root
vitest.config.tsincludespackages/three/src. - TresJS works in the terminal, unpatched (
/demos/tres, adapter:playground/src/components/TresTerminal.vue).<TresCanvasContext>(from@tresjs/core) runs Tres's custom Vue renderer over the slot to build the scene graph;<Three>draws it. Tres only touches itscanvasprop for sizing (parentElement.offsetWidth/Height, read once on mount byuseElementSize) and pointer listeners/capture, so a stub object suffices; itsrendererprop is a factory, replaced by a no-op shell whosedomElementmust have nonzerowidth/height(gates thereadyevent → slot mount) and which must carryshadowMap(the Booleanshadowsprop coerces tofalse, notundefined, and is written unconditionally). Camera and scene flow out of thereadycontext (context.camera.activeCamerais reactive — buttoRawit: Tres keeps cameras in a deep ref, so the value is a reactive proxy, and autoAspect's per-framecamera.aspectwrite through it re-triggers Tres's camera watcher, which writes its stub-derived aspect back — a per-frame ping-pong the terminal loses. The stub also reportsoffsetWidth: 0so Tres's aspect management disables itself (aspectRatiofalsy) while nonzerooffsetHeightavoids the "canvas has no area" warning. Requires the single@vue/runtime-coreinstance (Tres importsvue, which shares the same runtime-core in dev-external and bundled builds — slot vnodes cross renderer boundaries). Caveats: nowindow→@vueuseuseRafFnnever starts, so Tres's own loop anduseLoopare dead (animate withonFrame); pointer events never fire. Tres tags compile as custom elements via the playground vite config'sisCustomElement(whitelist real components:TresCanvas,TresCanvasContext,TresTerminal). Don't grab Tres objects withuseTemplateRef— its dev-only readonly proxy blocks per-frame mutation (silently in prod); use a plainshallowRef+ string ref.
- SFCs (
.vue) are compiled byvue-termui/vite(src/vite.ts); render-function components (.tswithh()) work with no build at all. - No auto-import / component resolver. Import components and composables
explicitly from
vue-termui— clearer, fully typed, and avoids maintaining unplugin magic against the dev server's runnable-ssrmodule runner. (Decision for phase 8; revisit only if the explicit-import friction becomes real.) - New host tags are added in
nodeOps.createElementand theTuiElementTagunion only: the vite plugin treats the wholetui-prefix as custom elements, so it needs no per-tag list.
Built with tsdown (tsdown.config.ts), outputs ESM to dist/. oxc toolchain:
oxlint for linting, oxfmt for formatting.
Docs are a workspace package (docs/package.json, no engines) because Vercel
reads the nearest package.json above the project Root Directory and hard-fails
on engines.node >=26.3.0 (max supported: 24.x) before install/build commands
run. Vercel project Root Directory MUST be docs; build settings pinned in
docs/vercel.json. Keep engines out of docs/package.json.