API
new TermDOM(options?) #
import {TermDOM} from "@b9g/termdom";
const term = new TermDOM();Construction writes nothing to the terminal. The document is usable
immediately: it can be built, styled, and measured before attach(), or
without calling it at all.
Options:
transport?: TerminalTransportโ the terminal to render to. Defaults totransportFromProcess(), a wrapper around the globalprocess.html?: stringโ the initial document's markup, parsed as a whole page. Defaults to an empty document. A page written as a file starts here:import {readFile} from "node:fs/promises"; const term = new TermDOM({html: await readFile("page.html", "utf8")});url?: stringโ the document's URL, asdocument.URLandlocation.hrefreport it.cellSize?: "unit" | "auto" | {width, height}โ how many CSS pixels one terminal cell is.Leave it alone for an app written for the terminal. Set it to
"auto"to show a page written for a browser, like a 600px-wide email, at about the size it would be in one.const term = new TermDOM({cellSize: "auto"});Value One cell is "unit"(default)1ร1 px "auto"what the terminal reports, or 8ร16 if it can't say {width, height}that size, such as {width: 8, height: 16}With
"auto", the first frame waits for the terminal's answer, a second at most, and a font zoom asks again.Whatever the size:
- A box's edges round to the nearest cell, so columns that add up to their container still fit it. A border is whole cells, at least one.
1chis one cell: a column across, a row down.1lhis one row.
So a stylesheet written in
chandlhlooks the same at every size.
term.document, term.window #
document is a Document. Setting document.title sets the terminal
window title; the previous title is restored on dispose().
document.close() writes the document into the scrollback and seals
it, and the next mutation starts a fresh document below it.
window has the DOM interfaces and event constructors, and these members
are wired to the terminal:
innerWidth,innerHeight,outerWidth,outerHeightโ the terminal size, in cellsscreenTopโ the row the rendered region starts atscrollY,pageYOffset,scrollTo(),scrollBy(),scroll()โ document scrollingrequestAnimationFrame(),cancelAnimationFrame()โ the callback fires at the start of the next frame, before it is laid out and painted, as in a browser; what the callback changes lands in that framematchMedia()โ liveMediaQueryLists, re-evaluated on resizeresizeโ fired when the terminal size changes, before theMediaQueryListchangeevents that resize triggersgetSelection()โ the document selection,modify()includedCSS.highlightsโ the highlight registry, painted by::highlight()rulesnavigator.clipboard.writeText()/readText()โ the system clipboard over OSC 52, reachable only during the dispatch of a trusted user event;readText()rejects when the terminal does not answernavigator.userActivationโhasBeenActiveandisActiveMutationObserver,ResizeObserver,IntersectionObserverโ entries are delivered per rendered frameclose()โ quit: flush the final frame to scrollback, restore terminal modes, dispose, and calltransport.close({status: 0}), which exits the process on the default transport. Ctrl-C calls this as its default action; akeydownlistener that callspreventDefault()overrides it.
Anything not listed behaves as the DOM and CSSOM standards specify, without terminal wiring.
Parts of the built-in controls #
The form controls are shadow trees, and ::part() styles their pieces:
| Control | Parts |
|---|---|
<input> text types, <textarea> | value, placeholder |
<select> | indicator, picker, option, optgroup |
<progress>, <meter> | track, groove, bar |
<details> | details-content |
A <select>'s highlighted option carries data-highlighted, a disabled
one data-disabled, and a <meter>'s bar carries data-level of
optimum, suboptimum, or even-less-good.
term.attach(transport?) #
Puts the terminal in raw mode, starts input handling, mouse reporting, and bracketed paste, and paints whatever the document holds. Idempotent; no other call writes to the terminal. Returns a promise that resolves once the first frame has been written.
While attached to the process transport, the Node event loop stays alive
until dispose() or window.close().
document.visibilityState is "hidden" before attach(), "visible"
until dispose(), and "hidden" after. It is also "hidden" while the
wheel has been handed to the terminal's scrollback, until the next
keystroke. visibilitychange fires on each change.
Passing a transport rebinds the instance to it, only before the first attach.
term.renderANSI(html?) #
Returns the whole document as an ANSI string at the terminal's width: colors and line breaks, no cursor movement. Pass an HTML string to render that instead; the document is left alone.
const page = term.renderANSI();
const error = term.renderANSI(`<div style="color:red">error</div>`);It works attached or not, and doesn't disturb a live session.
term.print(html?) #
Writes renderANSI(html) to the terminal as ordinary output. Await it
before exiting.
term.dispose() #
Reverses attach(): flushes the document into scrollback, restores every
terminal mode and the title, and releases the transport. The process
continues; window.close() is the quit. Returns a promise that resolves
when every queued restore has reached the transport; await it before
writing further output. The process transport also restores
shell-critical modes synchronously, so a caller that exits without
awaiting still leaves the shell usable. using term = new TermDOM()
disposes on scope exit.
TerminalTransport #
The interface between the engine and a terminal, for embedding TermDOM somewhere other than a process โ an SSH server, a browser terminal, a test harness:
interface TerminalTransport {
readonly cols: number; // live: always the current size
readonly rows: number;
readonly colorDepth: "ansi" | "256" | "rgb";
readonly interactive: boolean; // false: plain line output (a pipe)
// Optional. Takes an error's text somewhere the frame does not share;
// true when it did. The engine keeps what it cannot place and prints
// it below the document at the end.
logError?(text: string): boolean;
readonly sharesScreen: boolean; // true: anchor below existing content
readonly readable: ReadableStream<string>; // user input
readonly writable: WritableStream<string>; // frames out
readonly resizes: ReadableStream<{cols: number; rows: number}>;
readonly ready: Promise<void>; // established; Promise.resolve() if born so
readonly closed: Promise<TerminalCloseInfo>; // the terminal went away
// Ends the medium if the transport owns it (the process transport exits
// the process); a no-op otherwise.
close(info?: TerminalCloseInfo): void;
}
interface TerminalCloseInfo {
status?: number; // process-exit semantics
signal?: string; // "SIGHUP", "SIGTERM", ... when a signal ended it
reason?: string;
}Chunks on readable are strings; a byte-backed wrapper must decode with
a streaming decoder so code points never split. Escape sequences may
split across chunks; the engine reassembles them. When closed fulfills,
the engine disposes in response.
transportFromProcess(proc?, options?) #
Returns a TerminalTransport over a Node-process-shaped object.
import {TermDOM, transportFromProcess} from "@b9g/termdom";
const term = new TermDOM({transport: transportFromProcess(process)});procโ a structural subset of Node'sprocess(the exportedProcessLiketype). Defaults to the globalprocess.options.sharesScreenโ overridessharesScreen, which defaults to true for the global process (it sits below a shell) and false for anything else.
The wrapper owns all process-level behavior: raw mode, SIGWINCH โ
resizes, signals โ closed, TERM/COLORTERM โ colorDepth,
stdout.isTTY โ interactive, stderr when it is not a terminal โ
logError, and an exit hook that restores the cursor if the app exits
without disposing.