Rendering

TermDOM paints the document one of three ways, and the way is picked by how the terminal is being used rather than by an option.

Flow mode #

attach() on a terminal renders in flow mode. The document is drawn where a command's output would go: on the row below the shell prompt, and it stays there while the program runs. This is the default, and it is what the examples do unless they ask for fullscreen.

const term = new TermDOM();
term.attach();
term.document.body.textContent = "Hello";

The engine finds its starting row by asking the terminal where the cursor is, and paints the document in a region that begins there. The region is as tall as the document, up to the height of the terminal. When the document grows, the region grows with it, and the rows above the region scroll up into the terminal's scrollback the way any command's output pushes earlier output up. Nothing above the region is ever painted over: the prompt that started the program and the output before it stay where they are.

A document taller than the terminal shows a window of itself. The window scrolls with window.scrollTo(), scrollBy(), and the mouse wheel, and window.scrollY reports where it is. Its top is a stop: scrolling above the first row does nothing, as in an editor. A document that fits has nothing to scroll, so a wheel up in it goes to the terminal, and the terminal's own scrollback scrolls. No terminal says when the user has scrolled back down, so the mouse comes back to the document on the next keystroke, which also reaches the page. While the wheel is the terminal's, document.visibilityState is "hidden".

Each frame repaints the region in place. When the program ends, whether by window.close(), dispose(), or Ctrl-C, the engine erases the region and prints the whole document once more as plain output, so the final state sits in the scrollback like the output of any other command, and the shell prompt returns below it.

document.close() does the same thing without ending the program: the document is written into the scrollback and sealed. The next mutation starts a fresh document below it, on a new starting row. A program that prints a sequence of reports, one after another, can build each as a document and close it.

Resizing #

When the terminal is resized, the engine asks for the cursor position again to find where its region landed, re-lays out the document at the new width, and repaints. A terminal that does not answer in time gets a computed guess. The resize event fires on the window after the new size is in place.

Fullscreen #

await element.requestFullscreen();

requestFullscreen() switches the terminal to its alternate screen and paints the fullscreen element from row zero, filling the terminal. The :fullscreen pseudo-class matches it, and fullscreenchange fires on the document. document.exitFullscreen(), or Escape, switches back to the main screen, which restores the shell, the scrollback, and the flow-mode region as they were. If a text field has focus, the first Escape blurs it instead.

A program that wants the whole terminal for its lifetime can call document.documentElement.requestFullscreen() right after attach(). When it exits, nothing of it is left in the scrollback, because the alternate screen is discarded on the switch back.

The document's scroll position has nothing to move in fullscreen: the element fills the screen. Boxes inside it with overflow: auto still scroll.

Static output #

Neither mode is needed for output that does not change. renderANSI() turns HTML into a string of colored lines, and print() writes one to the terminal as ordinary output:

const term = new TermDOM();
await term.print(`<div style="color: red">error: file not found</div>`);

The string has colors and line breaks only, no cursor movement and no mode changes, so it can be piped to a file, logged, or written to stderr. attach() is not called, and the process does not stay alive.

When stdout is not a terminal, attach() behaves like print(): the document is written once as plain lines when the program ends, and input is not read. A program can be run under a pipe or in a CI log without a code path for it.

Frames #

A frame is painted when the document has changed and the next tick of the event loop arrives. Mutations are observed, so nothing calls a render. Style and layout are recomputed for what changed, the document is painted into a buffer of cells, the buffer is diffed against the previous frame, and only the difference is written. A frame is wrapped in synchronized output markers so a terminal that supports them shows it at once.

The terminal cursor is hidden between frames. When a text field or an editing host has focus, the cursor is shown at its caret, so the terminal's own cursor shape and blink apply and an input method can compose there. Every other focused element shows an outline instead.

A scroll of a box, or of the document, that moves rows the terminal can shift on its own is sent as a terminal scroll, and the diff repairs what the shift got wrong. A scroll box narrower than the terminal cannot use this, because terminals shift full rows, so its rows are repainted.

What the engine asks the terminal #

The engine never reads the terminal's name or its environment to decide what it can do. It asks the terminal, in-band, and uses the answer. A terminal that does not answer gets the conservative behavior.

AskUsed for
Cursor position reportThe starting row of the flow-mode region, and its row after a resize
Mode 2027Measuring text by grapheme cluster, so a terminal that does agrees with the engine on the width of emoji sequences
Mode 8Whether the terminal reorders bidirectional text itself, in which case the engine leaves the order to it
SGR 53 by DECRQSSWhether the terminal draws an overline, which MathML uses for the bar of a root
OSC 52 queryReading the clipboard in navigator.clipboard.readText()

The one exception is color depth, which the process transport reads from COLORTERM and TERM because no terminal answers a query for it. A transport of your own can set colorDepth any way it likes.

The modes the engine sets, and resets on exit, are mouse reporting, mouse motion reporting while something watches hover, bracketed paste, the alternate screen in fullscreen, the title stack when the document has a title, and mode 2027 when the terminal agreed to it. An exit hook resets them if a program exits without disposing, so a crash does not leave the shell without a cursor or with the mouse captured.

document.title sets the terminal window title, and the previous title is put back on exit.

Edit this page on GitHub