termdom-vue
| Install | |
|---|---|
npm install termdom-vue |
|
| Latest Version: | 0.1.0 |
| Vue: | 3 |
| License: | MIT |
| Last Updated: | Aug 13, 2026 |
| Links: | GitHub · npm |
termdom-vue
Vue 3 in the terminal, by way of TermDOM.
There is no custom renderer here and no component vocabulary to learn. TermDOM implements a real DOM with real CSS, so @vue/runtime-dom renders into it the way it renders into a browser's — templates, v-if/v-for, v-model, refs, template refs, transitions. What this package adds is the wiring: globals installed in the right order, composables for what a terminal has and a browser does not, and one specific error message that saves an afternoon.
npm install termdom-vue vue
// main.ts
import {render} from "termdom-vue";
export const css = `.card {border: 1px solid #5fafff; padding: 0 1ch;}`;
await render(() => import("./app.js").then((module) => module.App), {css});
// app.ts
import {defineComponent} from "vue";
import {useExit, useKeydown} from "termdom-vue";
export const App = defineComponent({
setup() {
const exit = useExit();
useKeydown((event) => event.key === "q" && exit());
},
template: `<div class="card">hello — press q to quit</div>`,
});
Vue is a peer dependency — and note that the package never imports it until you mount, for the reason below.
Running the examples
The examples live in the repository, not in the package — clone it, then:
npm install
npm run hello # a card, a stylesheet, one global key
npm run counter # refs, keyboard input, a bar drawn with flexbox
npm run todo # v-model, checkboxes, list navigation, focus
npm run dashboard # full screen, live data, a media query that reflows
npm test # the suite, including the examples, headless
Node 24 runs these directly; the examples are TypeScript and go through tsx. Templates are compiled at runtime — Node resolves vue to a build that carries the compiler, so there is no build step.
The import-order rule
This is the one thing about Vue in a terminal that is not like Vue in a browser, and it is worth understanding rather than pattern-matching.
@vue/runtime-dom reads the document once, when its module evaluates:
const doc = typeof document !== "undefined" ? document : null;
Every node the renderer ever creates comes from that captured reference. A Node process has no document until render() builds a terminal and installs one — so if Vue is imported before that, doc is null for the rest of the process and mounting dies on Cannot read properties of null (reading 'createElement'). Installing the globals afterwards changes nothing; the capture already happened.
So nothing may import Vue until render() has run, which has two consequences:
- This package never imports Vue statically.
src/vue.tsholds a lazily loaded copy; the composables reach for it, by which time they are running inside a mounted component. If you extend this package, keep it that way — oneimport {ref} from "vue"undersrc/re-breaks everything. - Your components are handed to
render()as a loader, not as a value:render(() => import("./app.js").then((m) => m.App)). The entry point holds the stylesheet and the loader — neither needs Vue — and the component module, which does, is imported after the terminal exists. That is why each example here is a pair:03-todo.ts(entry) and03-todo.app.ts(components).
Get it wrong and render() says so, in those words, with the original TypeError as cause. There is a test that imports Vue too early on purpose to keep that message honest.
API
render(source, options?): Promise<TermVueApp>
Mounts a Vue application into a terminal and resolves once it is on screen. source is a component or, preferably, a loader returning one ({default} interop included).
| option | default | what it does |
|---|---|---|
transport |
the process's terminal | Where to render. Tests and embeds pass their own; see Testing. |
globals |
"vue" |
"vue" publishes document, window, Element and SVGElement; "dom" adds the constructors and helpers third-party libraries reach for; "none" publishes nothing. |
fullscreen |
false |
Take the alternate screen, so the shell's scrollback comes back untouched on exit. |
css |
— | A stylesheet appended to <head> before the first render. |
props |
— | Props for the root component. |
setup |
— | Called with the App before mounting: (app) => app.use(pinia). |
The returned object carries the terminal (term, document, window, columns, rows, exit()), the Vue app, the host element, a closed promise, and two teardowns: unmount() (Vue goes, terminal stays) and dispose() (both go, process stays alive).
Vue needs exactly four globals. document and window are the obvious two; Element and SVGElement are there because mount() tests its container against both — on every mount, whether or not an SVG is anywhere in sight — and a missing constructor is a ReferenceError before a single node is made.
Composables
useTerminal()— the terminal for this tree:term,document,window,columns,rows,exit(). Throws outside arender()tree rather than silently reaching for a global.useTerminalSize()—{columns, rows}as refs that follow every resize. Layout usually belongs in CSS — TermDOM runs@mediaqueries against the real terminal — so reach for this when the decision is not expressible as style.useKeydown(handler, options?)— a document-level keydown listener, unwound with the component's scope.@keydownin a template only fires for the element it is on; a global shortcut has to reach the document. Options:target,capture.useExit()— closes the window: last frame flushed, terminal restored, process exited. The same thing an unhandled Ctrl-C does.
How it works
Globals go up before Vue loads — the rule above, implemented in render().
Resizes come from a probe element (src/size.ts). TermDOM has no window.onresize: SIGWINCH lands as a media-query re-evaluation and a fresh layout. A ResizeObserver on <html> measures content height, so a terminal that gets shorter without getting narrower looks unchanged; matchMedia only fires when an answer flips, and re-arming a query per resize leaks. So one hidden 100vw × 100vh element, pinned with position: fixed, gets a single ResizeObserver, and its callback writes the two refs.
A second terminal in the same process works, despite Vue creating its nodes with the first terminal's document: inserting a node into another document adopts it, so layout and media queries follow the tree a node is in. The suite asserts this rather than assuming it.
Writing terminal CSS
The layout engine is real CSS, but the canvas is a character grid. These hold for TermDOM 0.1.1 and were all found by rendering the examples:
- Lengths need units. A column is
1ch, a row is1px.padding: 1 2is silently dropped;padding: 1px 2chis one row of vertical padding and two columns of horizontal. - Percentage widths resolve against the viewport, not the containing block:
width: 50%inside a20chbox comes out as half the terminal. Build proportional bars out of flex children that grow against each other (:style="{flexGrow: n}"), which is what the counter and dashboard examples do. flex: 1sizes items by their content and lets them overflow a narrow terminal.flex: 1 1 0pxwithmin-width: 0splits a row evenly — note the unit on the basis.- There is no implicit form submission.
@submitwill not fire when Enter is pressed in a field; handle Enter in@keydown. - Whitespace in a template is text on screen. An interpolation on its own line becomes a leading space in a cell grid; keep
{{…}}on the element's line.
Forms, and where batching bites
v-model works — typing updates the ref with no shim of any kind, which is the whole difference from React, where a value tracker has to be defeated before onChange fires at all.
The catch is batching. A terminal delivers input in chunks, and everything in one chunk runs before Vue's next render: paste ship it\n into a field and the text and the Enter are processed back to back. A handler that reads the model and resets it in that same burst leaves v-model comparing the value it last wrote ("") with the value now (""), concluding there is nothing to write — while the field still holds what was typed. The todo example resets the element alongside the ref, with a comment; the same is true of a plain :value binding, and the events suite covers both.
Testing
test/harness.ts puts a headless xterm behind TermDOM's transport interface, so a test drives the app the way a user would and reads back what is on screen.
const terminal = new TestTerminal({cols: 40, rows: 10});
const app = await render(loadCounter, {transport: terminal});
terminal.type("+++"); // keys in, escape sequences literal
await terminal.settle(); // wait for Vue, TermDOM, and the emulator
assert.match(terminal.screen, /n=3/);
await app.dispose();
terminal.screen and terminal.lines are the emulator's cell grid as text; terminal.cell(x, y) reports the resolved style of one cell. settle() waits for the terminal to go quiet rather than counting ticks — a resize is coalesced for tens of milliseconds before anything repaints.
26 tests: the renderer and the composables, the event surface most at risk (v-model on text/checkbox/select, listener teardown, Ctrl-C), the four examples, and the import-order diagnostic. Note that no test file imports vue at the top — except the one whose whole point is to.
Known limits
- Single-file components are untried here. They need a build step (
@vue/compiler-sfcthrough a bundler), which is orthogonal to the terminal but would change how examples are laid out; runtime template compilation was the simpler demonstration. <Transition>and<TransitionGroup>are untried. They lean ongetComputedStyletransition timings, which a character grid has no equivalent for.- Vue Router and Teleport are untried. Teleport to a selector inside the same document ought to work; the router wants a URL the terminal does not have.
app.closedresolves on the decision to close, not on the last byte; await the transport's ownclosedfor that.unmount()leaves the globals up for one turn, because Vue flushes teardown work on a microtask.dispose()waits that turn out for you.
Related Packages
Version History
| Version | Released | Vue | License |
|---|---|---|---|
| 0.1.0 | ^3.5.0 | MIT |