The terminal backend
runtime/term/ is the backend that roots/backend resolves to for the term target (CONTRACT §1). It renders the host primitives of CONTRACT §4 to a terminal, with its own flexbox layout engine, a cell buffer with differential output, and keyboard, mouse and focus handling. It is written in typed Roots Lisp, compiles to JavaScript and to TypeScript that passes tsc --strict --lib es2022 (no DOM or Node typings), runs on Node 22, and has no dependencies: no Yoga, no string-width, no npm packages, no eval.
tree mutation ─► requestRender ─► (≤ 60 fps, coalesced) frame:
layout (layout.lisp) ─► paint into a CellBuffer (paint.lisp) ─► diff vs previous frame (buffer.lisp) ─► one write
stdin ─► InputParser (input.lisp) ─► DOM-shaped events (events.lisp) ─► listeners, then default actions (screen.lisp)| Module | Contents |
|---|---|
index.lisp | backend (CONTRACT §5), mount, the test handle, setTheme, setDarkTheme, setAppClasses, re-exports |
node.lisp | TermNode: tree, props, listeners, tag → primitive mapping, style computation, glyph collection |
layout.lisp | the flexbox engine |
text.lisp | glyph runs, word wrap, truncation |
width.lisp, unicode-data.lisp | grapheme clusters (UAX #29) and cell widths; the table is generated by tools/gen-unicode.mjs |
style.lisp, color.lisp | utility classes, variants, inline styles, theme tokens, color downsampling |
paint.lisp, buffer.lisp | painter, hit regions, cell buffer, ANSI diff |
input.lisp, events.lisp | raw input parser; events, dispatch, tab order, text editing |
screen.lisp, host.lisp | a mounted app: frames, input, focus, the page's scroll, terminal setup/restore; typed host access |
display.lisp | what an app may ask about the terminal apart from one screen: light or dark, the size, the scroll position (roots/platform reads it) |
Using it #
Apps do not call the backend directly: the build mounts the root component with createApp(App).mount(backend), which uses the process terminal. The backend can also be driven directly through CONTRACT §5:
import { backend } from "roots/backend"
const root = backend.createElement("Box")
backend.setProp(root, "class", "p-1 border-round")
backend.insert(root, backend.createText("Hello"), null)
const handle = backend.mount(root) // the process terminal
const handle = backend.mount(root, { stdout, stdin, columns, rows }) // other streams (fixed size if given)
handle.unmount() // restores the terminalmount(root, target, options) options (all optional; target fields are accepted too):
| Option | Default | Meaning |
|---|---|---|
headless | false | no I/O at all; frames render on demand (tests) |
columns, rows | the terminal's | fixed size (otherwise follows resize) |
theme | built-in | token → color, merged over setTheme() and the built-in theme |
darkTheme | none | token → color that a dark screen puts over theme (merged over setDarkTheme()) |
colorDepth | detected | 16777216 (truecolor), 256, 16, 0 |
dark | from COLORFGBG | whether the screen is dark while the color scheme is "system" (see Light and dark) |
scroll | true | the page scrolls when the app is taller than the screen (see Scrolling); false: it is cut at the bottom |
mouse | true | SGR mouse reporting (press, release, motion, wheel) |
alternateScreen | true | use the alternate screen (false: draw over the primary screen) |
exitOnCtrlC | true | Ctrl+C unmounts and exits (false: it is a normal keydown) |
patchConsole | true | capture console.* while mounted, print it after the terminal is restored |
kittyKeyboard | false | push the kitty keyboard protocol (unambiguous Escape, Ctrl+Backspace …) |
autoFocus | false | focus the first tabbable element on mount (elements with autofocus always are) |
fps | 60 | frame rate cap |
onExit(info) | process.exit(info.code) | after Ctrl+C or a signal: { reason, code } (130 Ctrl+C/SIGINT, 143 SIGTERM, 129 SIGHUP) |
onError(err) | rethrow (headless) / log | errors thrown by event listeners |
onWarning(code, message) | collected | R36x warnings |
backend.layer(node) (CONTRACT §5) returns the screen's internal root once node is in a mounted tree, null before: overlay content (the runtime's portal(), the UI kit's dialogs, menus and toasts) is inserted there, next to the app, so absolute positions are relative to the whole terminal and it is painted above the app (by z-index, then document order).
setTheme(tokens) installs the app theme (roots.lisp :theme) for every screen, mounted or not; the term bootstrap can call it before createApp(...).mount(backend). setDarkTheme(tokens) does the same for the colors of :theme :dark, which a dark screen puts over the theme, and setAppClasses(names) names the classes of the app's own CSS (see Styling). The build's bootstrap calls both.
Light and dark #
A screen is dark when the terminal is: the dark mount option, else COLORFGBG (a background of 7 or 15 is a light terminal; without the variable the screen is dark). An app switches with the theme API of roots/platform, the same call as on the web:
import { theme } from "roots/platform"
theme.set("light") // or "dark"; "system" follows the terminal again
theme.resolved.value // "dark" | "light": what is shown now (a ref)The choice is kept in the app's storage file and applied before the app mounts. Dark turns the dark: variants on and puts the colors of roots.lisp :theme :dark over the theme. Without roots/platform, the backend has the same switch:
import { setColorScheme, colorScheme, screenDarkP } from "roots/backend"
setColorScheme("dark") // every screen, mounted or not; "system" hands it back to the terminalScrolling #
The page scrolls by itself. The app's root takes the height of its content, and when that is more than the screen has rows, the screen shows a window of it:
| Input | Scrolls |
|---|---|
| Page Down / Page Up | one screen less one row, always |
| ↓ ↑, Home, End | a row, the top, the end, while nothing has the focus (a focused control uses these keys itself) |
| the mouse wheel | three rows, unless a wheel listener calls preventDefault() (a scroll view of its own does) |
Tab, node.focus() | the focused control into view, with one row of context above and below (its label, the next line) |
A scrollbar in the last column (│ track, ┃ thumb) shows where the window is, only while there is something to scroll to. That column is then the scrollbar's: the app is laid out one column narrower (79 of 80), so a full-width row, a right-aligned value or the right border of an input is never under the bar. A page that fits has the whole width. screen.columns stays the terminal's width in both cases. The offset is clamped when the content gets shorter or the terminal taller. From code:
import { screen } from "roots/platform"
screen.scrollTo(0) // e.g. after a navigation: the new page from its topconst t = backend.mount(root, { headless: true, columns: 80, rows: 24 })
t.scrollTo(12); t.scrollTop() // the test harness: set and read the first row shownAn app that lays out the screen itself does not scroll: give its root h-full (it then is exactly the screen, and what does not fit is cut, as before), or mount with scroll: false. Scrolling a part of the screen is the UI kit's ScrollArea (ui.md).
The screen's size #
import { screen } from "roots/platform"
screen.columns.value // refs: they follow a resize
screen.rows.valueroots/backend has screenSize() ({ columns, rows }) and watchScreen(fn), which calls fn when the size or the darkness changes or a screen is mounted or unmounted, and returns the function that stops it.
Headless testing #
mount(root, { headless: true, columns, rows }) returns a handle that renders on demand:
const t = backend.mount(root, { headless: true, columns: 40, rows: 10 })
t.frame() // the screen as plain text (pending changes are rendered first)
t.lines() // every row (trailing spaces trimmed)
t.cells() // rows of { char, width, fg, bg, bold, dim, italic, underline, inverse, strike } (colors as "#rrggbb" or "default")
t.output() // the ANSI bytes of the last frame (for diff tests)
t.press("Shift+Tab") // a key: DOM names (Enter, Escape, ArrowUp, " ", F5, a, A) with Ctrl+/Shift+/Alt+/Meta+
t.type("hello\n") // one key per grapheme (\n is Enter, \t is Tab)
t.paste("text") // a bracketed paste
t.input("\x1b[A") // raw terminal bytes through the parser
t.click(x, y) // mousedown + mouseup (also mouseDown, mouseUp, mouseMove, wheel(x, y, deltaY))
t.resize(80, 24)
t.scrollTo(12); t.scrollTop() // the page's scroll: set and read the first row of the app that is shown
t.focused() // the focused node or null
await t.settle() // after timers and microtasks (async updates), the frame
t.stats() // { frames, lastMs, layoutMs, paintMs, diffMs, lastBytes, totalBytes }
t.warnings(), t.logs(), t.exitInfo(), t.unmount()With the runtime: const app = createApp(App); app.mount(backend, { headless: true, columns, rows }); const t = app.handle.
Host primitives and HTML tags #
| Primitive | Rendering | Default style | Focusable |
|---|---|---|---|
Box | flex box | flex column, align-items: stretch | with tabindex |
Text | a text block; nested Text are inline spans | — | with tabindex |
Button | label or children, rounded border | border-round px-1 shrink-0 self-start | yes |
Input | single-line field, cursor shown as an inverse cell | border-round px-1 w-24 shrink-0 self-start | yes (not disabled) |
Checkbox | [x] label / [ ] label, [-] with indeterminate; radio: (*) / ( ) | shrink-0 self-start | yes |
Image | [alt] (or [image]) | dim italic | no |
Link | children, underlined | underline text-accent self-start | yes |
Spacer | empty | grow | no |
HTML tags map onto them (CONTRACT §4): div section main header footer nav article aside form ul ol li → Box; span p label → Text; h1 (bold, underlined), h2–h6, strong, b (bold), em, i (italic), small (dim), code (accent), pre (no wrapping), s, del, u, mark → Text; a → Link; button → Button; input → Input, or Checkbox for type="checkbox"/"radio"; img → Image; br → a line break. Other lowercase tags warn (R360) and become a Box. Capitalized names that are not primitives (R305) and <script> (R306) are refused, like on the DOM.
Text is rendered like CSS white-space: pre-wrap: \n breaks lines, spaces are kept, lines wrap at spaces and words longer than the line are broken. Adjacent text nodes in a Box form one line (Count: {{ n }}); whitespace-only text between elements is ignored, as in a DOM flex container. A Text that contains Buttons, Inputs or Boxes becomes an inline flow (a wrapping row, items centered on the line). Control characters in text are never written to the terminal, so app data cannot inject escape sequences.
Props: class, style (object with camelCase or kebab-case CSS keys, or a CSS string), id, hidden, disabled, tabindex, autofocus, value, checked, placeholder, type (text password number email checkbox radio), label, href, to, alt, src, maxlength, readonly, and onX listener props. v-show sets style.display = "none".
Nodes are also the event targets and carry DOM-shaped members: value, checked, focus(), blur(), click(), contains(), getBoundingClientRect() (cells, from the last frame), tagName, id, disabled, parentNode, firstChild, nextSibling, childNodes, textContent, addEventListener.
Layout #
All sizes are whole cells; boxes are border-box sized (like Tailwind's preflight). Supported:
flex-direction(row,column, both reversed),flex-wrap,flex-grow,flex-shrink(weighted by base size),flex-basis, resolved with the CSS §9.7 algorithm (min/max freezing); flexible sizes are rounded cumulatively so siblings always add up to their container;gap,row-gap,column-gap; padding; margins includingauto(ml-auto,mx-auto);- borders of 1 cell on any side (
border,border-t,border-x…), styles single, round, double, heavy, ascii; width/heightin cells or percent (w-full,w-1/2),min-*/max-*;align-items,align-self(start, center, end, stretch),justify-content(start, center, end, between, around, evenly),align-contentfor wrapped lines;position: absolutewithtop/right/bottom/leftagainst the parent's padding box, painted above in-flow content inz-indexorder;overflow-hiddenclips to the padding box;- CSS minimum sizes: an item's
autominimum is its min-content size along the main axis (the longest word of a text; the content height in a column). Content overflows instead of being squashed, exactly as on the DOM;min-w-0/min-h-0,overflow-hiddenortruncatelet an item shrink below it. A list taller than the screen makes the page scroll (Scrolling). - Controls keep their size: a Button, Input, Checkbox, Link or Image is not stretched across its container. It sits at the start, and in a row it follows the row's
items-centeroritems-endlike a Text does (self-*on the control still decides).
The internal root is the terminal's size (a flex column); the app's root stretches to the full width and takes its content height, so h-full fills the terminal. Measurements are cached (Yoga-style measure modes) and kept across frames for untouched subtrees.
Not supported: baseline alignment (treated as start), order, aspect ratios, grid, display other than flex/none, scrolling containers other than the page (the UI kit's ScrollArea is one, built on overflow-hidden and wheel), right-to-left text.
Styling (CONTRACT §6) #
Every utility of the CONTRACT works; units are cells (1 spacing step = 1 cell).
One step is one cell, also vertically. A class string written for the web is usually too tall here: gap-3 is 0.75rem in a browser and three blank rows in a terminal. Write the terminal's value next to the DOM's, with term::
<Box class="p-4 gap-3 term:p-1 term:gap-0">term: classes produce no CSS on the DOM and win here over the plain ones. The scale is not rounded per surface: gap-1 is one row in every app, whatever else is in the class string.
Terminal mappings: text-lg/xl/2xl are bold, text-sm is normal, rounded and shadow are ignored, border-round draws ╭╮╰╯. Extras: flex-auto flex-none flex-initial grow-N shrink-N basis-N size-N w-[N] w-[N%] inset-0 top-N … z-N border-heavy border-ascii border-0 border-t/r/b/l/x/y font-normal not-italic no-underline inverse opacity-N truncate-start truncate-middle whitespace-nowrap self-auto content-*. The router's r-link-active is accepted, and r-link-exact-active makes the Link of the current route bold.
Classes that are not utilities: a class behind dom: or mobile: (dom:card) is for another surface and is dropped without a word, and so is a class the app's own CSS defines (a component <style> block, a CSS file in public/): the build's bootstrap passes those names with setAppClasses. So one template can carry class="card p-2" for both surfaces. Any other unknown class warns once per class string (R361) and is ignored.
Variants: term: always applies; dom:, mobile: and other surfaces never do; focus: (and focus-visible:), focus-within: (the element or a descendant is focused), hover: (mouse motion), disabled:, dark: (a dark screen: Light and dark), and the breakpoints sm: ≥ 80, md: ≥ 96, lg: ≥ 128 columns (the DOM's 640/768/1024 px at 8 px per cell). Variants stack (term:focus:bold).
The computed style is: primitive defaults → utilities → focus defaults (when focused) → variant utilities → the style prop. Inline style values: numbers are cells; "8px" is 2 cells (4 px = one step), "1rem"/"1em" 4 cells, "3ch" 3 cells, "50%" relative; shorthands padding, margin, gap, flex, border ("1px solid #fff", "double") work; fontWeight ≥ 600, fontStyle: italic, textDecoration, opacity < 1 (dim), whiteSpace: nowrap, textOverflow: ellipsis, borderRadius (round) map to attributes.
Colors are theme tokens (primary accent muted surface border focus success warning error info text bg, plus any token added by the theme), [#rrggbb] escapes, and in inline styles CSS colors (#abc, rgb()), ANSI names (red, bright-red) or default. text and bg default to the terminal's own colors. The built-in theme:
| primary | accent | muted | surface | border | focus | success | warning | error | info |
|---|---|---|---|---|---|---|---|---|---|
| #42b883 | #bb9af7 | #8b8b8b | #1f2430 | #4b5263 | #7aa2f7 | #9ece6a | #e0af68 | #f7768e | #7dcfff |
The color depth is detected from NO_COLOR (none), FORCE_COLOR (0–3), TERM=dumb (none), COLORTERM=truecolor|24bit, WT_SESSION, TERM_PROGRAM (iTerm, WezTerm, VS Code, ghostty), and TERM (*256* and xterm-like → 256, else 16). 24-bit colors are downsampled to the xterm-256 cube or gray ramp (nearest), and to 16 colors by hue (grays by lightness). Unknown colors warn (R362) and render in the default color.
Focus is never shown by color alone: a focused Button gets a heavy border (┏━┓) in the focus color and a bold label (a Button whose classes removed its border, border-0, keeps its size: only the bold label in the focus color shows it), a focused Input shows its cursor, a focused Checkbox or Link turns bold in the focus color; other focusables get a focus-colored border (or text).
Text, graphemes and widths #
Text is segmented into extended grapheme clusters (UAX #29: CR LF, controls, Hangul syllables, Extend/ZWJ/SpacingMark, emoji ZWJ sequences, flags, and the Indic conjunct rule GB9c; Prepend is not implemented); the segmenter is tested against Intl.Segmenter on a corpus and 20,000 random strings. A cluster takes 0, 1 or 2 cells: East Asian Wide/Fullwidth and emoji presentation are 2, VS16 makes an emoji 2 and VS15 makes it 1, flags and keycaps are 2, marks/format characters/default ignorables are 0. The code point table (869 ranges, Unicode 17 properties plus East Asian Width) is generated by tools/gen-unicode.mjs and decoded once into an O(1) BMP lookup.
Terminals disagree about some clusters (a ZWJ family renders 2–8 cells wide, VS16 on text-default emoji 1 or 2). At startup the backend asks for grapheme-cluster mode 2027 and enables it when the terminal supports it. Otherwise each such glyph is written over cleared cells, the cursor is re-positioned right after it, and the rest of its row is repainted, so a width disagreement never shifts other content. Autowrap is off while mounted, so a glyph at the right edge can never scroll the screen.
Rendering #
Every tree mutation (insert, remove, text, prop, value, focus, hover) requests a frame. Frames run on a timer after the current task and its microtasks, at most 60 per second, so a burst of reactive updates paints once. A frame lays out the tree, paints a cell buffer and compares it with the previous one: only changed spans of each row are written (spans closer than 8 cells merge), with absolute cursor moves and minimal SGR transitions; nothing is written for an unchanged frame, the screen is cleared only on the first frame and after a resize, and every frame is one write wrapped in synchronized output (ESC[?2026h … ESC[?2026l).
Layout is incremental: a subtree that was not touched since the last frame keeps its measurements, and its final layout when its constraints are unchanged; a randomized test compares incremental frames with full relayouts after every mutation. Boxes entirely outside the visible area are not painted. Typical costs on Node 22 (median frame time): a 100×40 todo screen of ~130 nodes, focus change: ~0.4 ms and ~95 bytes; a 2000-row list (6000 nodes), one row edited: ~2.5 ms and 29 bytes; changing one character anywhere writes under 30 bytes.
When stdout is not a terminal (or TERM=dumb), no escape sequences are written: the first frame and the last one (at unmount) are printed as plain text.
Input and interaction #
The parser understands printable Unicode, C0 controls (Ctrl+letter, Tab, Enter, Backspace, Ctrl+Space), Alt as an ESC prefix, CSI keys with xterm modifiers (arrows, Home/End, PageUp/Down, Insert/Delete, F1–F12, Shift+Tab), SS3 keys, kitty/fixterms CSI-u, bracketed paste (split markers included), SGR mouse reports, focus reports and DECRPM mode reports. CSI sequences are parsed by their ECMA-48 structure, so unknown ones are skipped. A lone ESC is an Escape key after 30 ms without more input.
Events are DOM-shaped (CONTRACT §4): type, target, currentTarget, key, code, ctrlKey/shiftKey/altKey/metaKey, button, clientX/clientY/offsetX/offsetY (cells), deltaY (wheel, in lines), detail, relatedTarget, data/inputType (input events), preventDefault(), stopPropagation(), stopImmediatePropagation(). Listeners run on the target, then bubble to the root (no capture phase). Fired events: keydown, keyup (right after keydown: terminals report no releases), input, change, paste, click, mousedown, mouseup, mousemove, mouseenter/mouseleave, wheel, focus/blur, focusin/focusout.
Keys go to the focused element, or to the app root when nothing is focused. Unless a listener called preventDefault(), the default action follows:
| Element | Keys | Default action |
|---|---|---|
| any | Tab / Shift+Tab | next / previous focusable (positive tabindex first, then document order; disabled and hidden elements are skipped; tabindex="-1" only by click or focus()) |
| any | Ctrl+C | unmount and exit (unless exitOnCtrlC: false) |
| Button | Enter, Space | click |
| Checkbox | Space, Enter | click, then toggle with input + change (target.checked) |
| Link | Enter | click |
| Input | text, paste | insert at the cursor (newlines become spaces; maxlength, type="number", readonly) |
| Input | ← → Home End, Ctrl+B/F/A/E | move the cursor |
| Input | Ctrl/Alt+← →, Alt+B/F | move by word |
| Input | Backspace, Delete, Ctrl+D | delete a grapheme |
| Input | Ctrl+W, Alt/Ctrl+Backspace, Alt+D, Ctrl/Alt+Delete | delete a word |
| Input | Ctrl+U, Ctrl+K | delete to the start / end |
| Input | Enter | change (when the value changed since the last change; also on blur) |
Every edit fires input with target.value, data and inputType (insertText, insertFromPaste, deleteContentBackward, deleteWordBackward, …). Setting value from the outside replaces the text and moves the cursor to the end; echoing the same value (v-model) keeps the cursor. Inputs scroll horizontally to keep the cursor visible; type="password" shows *.
Mouse: pressing focuses the nearest focusable ancestor of the element under the pointer (or blurs), and in an Input places the cursor; click fires on the nearest common ancestor of the press and release targets. Inline Links inside a Text are their own hit targets. Disabled Buttons, Inputs and Checkboxes receive no mouse events. Elements with hover: classes restyle under the pointer.
The terminal #
On the process terminal (or the given streams) mounting enters the alternate screen, hides the cursor, disables autowrap, enables bracketed paste and mouse reporting (modes 1000, 1002, 1003, 1006), queries mode 2027, puts stdin in raw mode, and captures console.*. Unmounting reverses all of it (attributes reset, modes off, autowrap on, cursor shown, primary screen) and then prints the captured console output and warnings. The terminal is also restored on process.exit, on SIGTERM (exit 143), SIGHUP (129) and SIGINT (130), and on an uncaught exception or unhandled rejection: the uncaught-exception monitor restores the terminal before Node prints the error, so the stack trace is visible. Resizes re-lay out and redraw. Ctrl+Z is delivered as a key (no job-control suspend).
Errors and warnings #
| Code | Meaning |
|---|---|
| R303 | innerHTML, outerHTML or srcdoc refused (same as the DOM backend) |
| R304 | inline on* string attribute refused (same as the DOM backend) |
| R305 | capitalized tag that is not a host primitive (same as the DOM backend) |
| R306 | <script> refused (same as the DOM backend) |
| R350 | mount(): the root is not a terminal node |
| R351 | insert(): the parent is a text node or an anchor |
| R352 | insert(): the node would contain itself |
| R353 | insert(): the anchor is not a child of the parent |
| R354 | mount(): the node is already mounted |
| R355 | mount(): the target is not an object |
| R356 | a backend operation got a value that is not a terminal node |
| R360 | warning: an unknown tag is rendered as a Box |
| R361 | warning: unknown utility classes are ignored (not reported: classes behind dom: / mobile:, and the classes of the app's own CSS) |
| R362 | warning: an unknown color renders in the default color |
Tests #
bin/test-system roots-runtime-term/tests [filter]The system compiles the backend (and the reactive core and component runtime) into a temp directory and runs Node suites against it: width (segmentation vs Intl.Segmenter, widths), style (every CONTRACT §6 utility, variants, inline styles, colors), layout (flexbox, text, wide characters, emoji), paint (cells: colors, attributes, focus styling), diff (minimal output, SGR, color depths, emoji re-sync), input (the parser), interaction (focus, events, Button/Checkbox/Link/Input, the page's scrolling), mount (streams, setup/restore, scheduling, resize, plain output, errors, warnings, the color scheme and the screen's size), and integration (components compiled in the CONTRACT §2 shape on the runtime). term-typescript runs tsc --strict on the TypeScript output, term-no-eval checks for eval/new Function/innerHTML, and term-pty runs a real session in a pseudo-terminal (python3 pty): typing, Tab, Enter, a mouse click, SIGWINCH, Ctrl+C, SIGTERM and a crash, each checking that the terminal is restored.
Not in 0.1 #
Inline mode (rendering below the prompt, keeping scrollback, TU-2), scroll containers other than the page, text selection and undo in Inputs, textarea and select, images (kitty/sixel), right-to-left text, the native terminal cursor for IME, a useLayout() hook (nodes do have getBoundingClientRect()), and job-control suspend.