Skip to the contentSkip to the navigation
RootsGitHubMenu
On this page

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)
ModuleContents
index.lispbackend (CONTRACT §5), mount, the test handle, setTheme, setDarkTheme, setAppClasses, re-exports
node.lispTermNode: tree, props, listeners, tag → primitive mapping, style computation, glyph collection
layout.lispthe flexbox engine
text.lispglyph runs, word wrap, truncation
width.lisp, unicode-data.lispgrapheme clusters (UAX #29) and cell widths; the table is generated by tools/gen-unicode.mjs
style.lisp, color.lisputility classes, variants, inline styles, theme tokens, color downsampling
paint.lisp, buffer.lisppainter, hit regions, cell buffer, ANSI diff
input.lisp, events.lispraw input parser; events, dispatch, tab order, text editing
screen.lisp, host.lispa mounted app: frames, input, focus, the page's scroll, terminal setup/restore; typed host access
display.lispwhat 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 terminal

mount(root, target, options) options (all optional; target fields are accepted too):

OptionDefaultMeaning
headlessfalseno I/O at all; frames render on demand (tests)
columns, rowsthe terminal'sfixed size (otherwise follows resize)
themebuilt-intoken → color, merged over setTheme() and the built-in theme
darkThemenonetoken → color that a dark screen puts over theme (merged over setDarkTheme())
colorDepthdetected16777216 (truecolor), 256, 16, 0
darkfrom COLORFGBGwhether the screen is dark while the color scheme is "system" (see Light and dark)
scrolltruethe page scrolls when the app is taller than the screen (see Scrolling); false: it is cut at the bottom
mousetrueSGR mouse reporting (press, release, motion, wheel)
alternateScreentrueuse the alternate screen (false: draw over the primary screen)
exitOnCtrlCtrueCtrl+C unmounts and exits (false: it is a normal keydown)
patchConsoletruecapture console.* while mounted, print it after the terminal is restored
kittyKeyboardfalsepush the kitty keyboard protocol (unambiguous Escape, Ctrl+Backspace …)
autoFocusfalsefocus the first tabbable element on mount (elements with autofocus always are)
fps60frame 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) / logerrors thrown by event listeners
onWarning(code, message)collectedR36x 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 terminal

Scrolling #

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:

InputScrolls
Page Down / Page Upone screen less one row, always
↓ ↑, Home, Enda row, the top, the end, while nothing has the focus (a focused control uses these keys itself)
the mouse wheelthree 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 top
const t = backend.mount(root, { headless: true, columns: 80, rows: 24 })
t.scrollTo(12); t.scrollTop()            // the test harness: set and read the first row shown

An 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.value

roots/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 #

PrimitiveRenderingDefault styleFocusable
Boxflex boxflex column, align-items: stretchwith tabindex
Texta text block; nested Text are inline spans—with tabindex
Buttonlabel or children, rounded borderborder-round px-1 shrink-0 self-startyes
Inputsingle-line field, cursor shown as an inverse cellborder-round px-1 w-24 shrink-0 self-startyes (not disabled)
Checkbox[x] label / [ ] label, [-] with indeterminate; radio: (*) / ( )shrink-0 self-startyes
Image[alt] (or [image])dim italicno
Linkchildren, underlinedunderline text-accent self-startyes
Spaceremptygrowno

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 including auto (ml-auto, mx-auto);
  • borders of 1 cell on any side (border, border-t, border-x …), styles single, round, double, heavy, ascii;
  • width/height in 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-content for wrapped lines;
  • position: absolute with top/right/bottom/left against the parent's padding box, painted above in-flow content in z-index order; overflow-hidden clips to the padding box;
  • CSS minimum sizes: an item's auto minimum 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-hidden or truncate let 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-center or items-end like 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:

primaryaccentmutedsurfaceborderfocussuccesswarningerrorinfo
#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:

ElementKeysDefault action
anyTab / Shift+Tabnext / previous focusable (positive tabindex first, then document order; disabled and hidden elements are skipped; tabindex="-1" only by click or focus())
anyCtrl+Cunmount and exit (unless exitOnCtrlC: false)
ButtonEnter, Spaceclick
CheckboxSpace, Enterclick, then toggle with input + change (target.checked)
LinkEnterclick
Inputtext, pasteinsert at the cursor (newlines become spaces; maxlength, type="number", readonly)
Input← → Home End, Ctrl+B/F/A/Emove the cursor
InputCtrl/Alt+← →, Alt+B/Fmove by word
InputBackspace, Delete, Ctrl+Ddelete a grapheme
InputCtrl+W, Alt/Ctrl+Backspace, Alt+D, Ctrl/Alt+Deletedelete a word
InputCtrl+U, Ctrl+Kdelete to the start / end
InputEnterchange (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 #

CodeMeaning
R303innerHTML, outerHTML or srcdoc refused (same as the DOM backend)
R304inline on* string attribute refused (same as the DOM backend)
R305capitalized tag that is not a host primitive (same as the DOM backend)
R306<script> refused (same as the DOM backend)
R350mount(): the root is not a terminal node
R351insert(): the parent is a text node or an anchor
R352insert(): the node would contain itself
R353insert(): the anchor is not a child of the parent
R354mount(): the node is already mounted
R355mount(): the target is not an object
R356a backend operation got a value that is not a terminal node
R360warning: an unknown tag is rendered as a Box
R361warning: unknown utility classes are ignored (not reported: classes behind dom: / mobile:, and the classes of the app's own CSS)
R362warning: 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.