hottyHTML over the TTY

☰ Contents · Reference › hotty-ts

hotty-ts

hotty-ts is the TypeScript SDK for hotty, HTML over the TTY, for Node and Bun. With it, a terminal program shows small HTML documents, called surfaces, on rectangles of cells, and hears what the user does in them. It follows the hotty repository's SDK.md: the same two layers, names and defaults as the other SDKs, and every SDK section of the conformance vectors.

It speaks hotty 0.4: a host's replies and events carry msgpack bodies (SPEC §3.3), its query lists the version it speaks (a=q:n=1:v=0.4, SPEC §4), and keys are named as myelin key names 0.1 names them (SPEC §10.4): ctrl+a, arrow_left, shift+g, and mod+s in a binding. A terminal of another hotty version, a 0.3 one included, is no host to it, and the program draws in cells. Moving a program from 0.3 is CHANGES.md's "Upgrading from 0.3" in the hotty repository.

The package is @neuroplastio/hotty. It is not on npm yet: the first release will be 0.1.0.

import { NoTerminalError, Terminal } from "@neuroplastio/hotty/terminal";

let t: Terminal;
try {
  t = Terminal.open("hello"); // the terminal (/dev/tty), whatever stdout is
} catch (e) {
  if (!(e instanceof NoTerminalError)) throw e;
  console.log("Hello."); // no terminal: a pipe, a cron job
  process.exit(0);
}
try {
  if (await t.detect()) {
    await t.lineStart(); // surfaces are placed at the cursor
    t.print("hello", "<p>Hello, <b>HTML</b> in the terminal.</p>", { cols: 40, rows: 1 });
    await t.fence(); // the host's replies, before the shell reads the terminal
  } else {
    console.log("Hello."); // a terminal that is not a host, or not of this version
  }
} finally {
  t.close();
}

Testing it needs no terminal:

import { Host } from "@neuroplastio/hotty/test";

const h = new Host();
await run(new Terminal({ input: h, output: h, name: "hello", size: () => h }));
h.surface("hello-hello")?.text(); // "Hello, HTML in the terminal."

Entry points

import what it is SDK.md
@neuroplastio/hotty the wire layer: builders (with the op builders text, attr, varOp, …, the menu's and touch), encode, Decoder, Scanner, Detector, Reply, Event, Caps, unpack for msgpack, what a host or a relay reads of a program's commands (deltaOf, menuOf, menuShowOf, menuLevelOf, menuFind, touchOf), and keys: parseKey, decodeKeys, Keymap, TERMINAL_KEYS. Pure: no I/O, no dependencies §3
@neuroplastio/hotty/field Field, a text field's value, caret and selection edited by SPEC §10.2's actions, for a program that draws its fields in cells §4.6
@neuroplastio/hotty/terminal Terminal, for a program with a terminal: one that prints documents and exits, asks a question, or streams a chart §4.1
@neuroplastio/hotty/session Session, for a UI framework's adapter: modes, layout, relayout, limits, messages §4.3
@neuroplastio/hotty/link Link, what Terminal and Session stand on: for an environment that reads the terminal itself and hands over what it reads §4.2
@neuroplastio/hotty/test Host, a hotty host that runs inside a test. Needs parse5 (an optional peer dependency) §4.4

Only @neuroplastio/hotty/test has a dependency. The rest uses the runtime's own modules (node:fs, node:tty, node:zlib), which Node and Bun both have, and reads msgpack with its own decoder (below).

Deltas, flags and menus

A frame's changes to a surface go out as one delta, a list of ops that the host applies in order and shows as one (SPEC §6.1). The op builders make the ops; the one-op helpers (setText, setAttr, setVar, removeAttr, morphTo, reveal) stay for a single change:

import { delta, menu, menuHide, menuShow, text, varOp } from "@neuroplastio/hotty";

t.send(delta("app-bar", [varOp("fill", "p", 40), text("label", "40 of 100")]));

press, fit and hover of a placement are true, false or absent: they belong to the surface (SPEC §5.2), and a placement that leaves them out leaves them as they were. A Session sends each only when it changes.

A reply to a delta names the op that failed by its index (reply.op). A program that can do things sends them as a menu tree (menu), shows the level its keys reach (menuShow(["space", "f"])) and hides it (menuHide), and a pick, from a multiplexer's palette or an agent, reaches it as a command event with the item's name. A host asks of its capabilities caps.has("menu"), and of a host's caps event caps.merge(ev.changed()); the Terminal, the Link and the Session keep their caps current that way.

A program that drags a slider with the mouse in its cells names those cells with touch([{ c, r, w, h, drag: "x" }]), so that a finger drags it too (SPEC §9.6). The areas are the screen's cells, counted from 0, kept per screen: it sends them again when what it drew moves, the size changes or it switches screens, and touch() when it ends.

A framework's adapter

The Session never writes a frame, and owns no stream. With each frame, the program says where its surfaces are, and the adapter writes what flush returns in the same write as the frame's cells:

const s = new Session({ link: t, onMessage: (m) => program.send(m) }); // ready, event, error, ack, relayout, pong, input
s.detect();

// each frame:
s.layout([{ name: t.surface("chart"), rect: { x: 2, y: 3, w: 40, h: 10 }, doc: () => chartHtml() }]);
s.watch(cells); // the framework's output: erases and scrolls ask for a relayout
write("\x1b[?2026h" + cells + s.flush() + "\x1b[?2026l");

// on the way out:
write(s.close()); // deletes the surfaces it sent, and no one else's

Some environments parse their input themselves and hand over complete sequences. OpenTUI is one: it may keep the DA1 answers for itself, and it writes between its frames. For those, the Session stands on a Link instead of a Terminal. The adapter calls link.osc(seq) with each OSC, which returns whether it was hotty's, and link.da1() with a DA1 answer it does not keep. Detection then decides on the reply to the query alone, and is done 300 ms later. A byte stream goes to link.bytes(chunk).

Text fields in cells

A program that draws its fields in cells edits them with the keymap it gives its surfaces' fields, so a field edits the same on a host and off one. It puts TERMINAL_KEYS in the data-keys of an element that holds its fields, and in cells looks each key up in the same keymap. Shift with a move selects, as on a host, and the field's selection() is what to draw as selected:

import { INSERT, TERMINAL_KEYS, decodeKeys, keyText, platformOf, resolve } from "@neuroplastio/hotty";
import { Field } from "@neuroplastio/hotty/field";

const html = `<form data-keys="${TERMINAL_KEYS}"><input id=name></form>`; // on a host
const keys = resolve(platformOf(caps), false, TERMINAL_KEYS); // in cells, with the host's mod
const f = new Field();
for (const key of decodeKeys(read)) {
  const a = key === undefined ? undefined : keys.lookup(key);
  if (a === INSERT) f.type(keyText(key!)!); // "shift+a" types "A"
  else if (a === "submit") submit(f.value);
  else if (a && keys.selects(key!)) f.extend(a); // shift+arrow_left, ctrl+shift+end
  else if (a) f.do(a); // true when the value changed: an input event's worth
  else onKey(key); // not the field's: the program's own keymap has it
}

Examples

Each one shows its hotty part in the open, and each is tested against the test host (test/examples.test.ts):

example what it shows
hello open, detect, lineStart, print, fence: the smallest program
progress a bar moved by one delta of varOp and text ops; three renditions: surface, cells, pipe
ask a form that takes the keyboard and reports a submit, then a detached summary in its place
board a full-screen program on a Session under its own renderer: layout, deltas, relayout, clicks and keys
node examples/hello.ts      # or: bun examples/hello.ts

The SDK holds protocol tools only. Markdown, highlighting, charts and document models are the program's, as SDK.md §1 has it.

Names and choices

The names are Appendix A's TypeScript column: placeAt, setText, reveal, delRes, del (delete is a reserved word), fitRows, cellCss, and on Event view(), and on Caps has(feature). The op builders are morph, inner, replace, append, prepend, before, after, remove, attr, unattr, text, varOp and revealOp; an Op is { op, target, key, value }, a menu is { label, items } of { key, label, name, enabled, items }, and a touch area { c, r, w, h, drag }. Options are objects: doc(s, html, { detached: true, q: 0 }), place(s, { cols, rows, keepCursor }).

  • Absence is undefined: a reply's code and op, an event's fields(), a Caps field the host left out. A submit's fields() are pairs, [name, value][]; mods() is the modifier bits (MOD_SHIFT, …).
  • Versions. The query lists the version the SDK speaks, VERSION (a=q:n=1:v=0.4, SPEC §4), and detection takes the terminal for a host only when it answers with capabilities that decode and name it. Any other answer to the query, an error (EVERSION from a host of other versions, or another code) or capabilities it cannot read (a 0.1 host's JSON) or of another version, is text at once, done with the DA1 behind it: detect() is false, caps absent, and the reply, detection's, never reaches the input. With late, only a reply that makes the terminal a host is the late answer. The test host speaks 0.4 only, and answers a query that does not list it with EVERSION.
  • Errors are exceptions: HottyError (from reply.err()), and NoAnswerError when a request or a fence gets no answer in time, which carries the replies read so far. NoTerminalError when the process has no terminal, and HostError from the test host.
  • Messages are discriminated unions on kind: the Scanner's segments, the link's input, the Session's messages.
  • Bodies. A host's replies and events carry msgpack maps (SPEC §3.3), and Reply, Event and Caps read each whole, every field it knows as its type. One that does not decode (an int field sent as 7.0, a body cut short or nested more than 32 levels deep, or one that holds anywhere, in a field the SDK knows or not, what no host sends: a nil, a map key that is not a string or is given twice in one map, a string that is not UTF-8, an int further than 2^53 − 1 from zero, a timestamp of another size or out of range) leaves undefined what it would fill: caps(), an error's code and detail, fields(). The message stands.
  • msgpack is read by the SDK's own decoder, not a package: JavaScript has one number type, and a reader of hotty's bodies keeps floats apart from ints. unpack(bytes) gives plain values, as an event's detail and a message's body() have them, and with { floats: true } each float as a Float. A timestamp (extension −1) is a Time, seconds and nanoseconds since 1970, with toDate(); another extension is skipped. What fails a body fails unpack too, with a SyntaxError, as JSON.parse throws. The test host writes its bodies with a writer beside it, a float as one even when whole (scale: 2.0), and never a nil or a key that is not a string.
  • Escape. A lone ESC at the end of a read is the Escape key once nothing follows it within 25 ms (ESCAPE_DELAY, the link's escapeDelay), so a sequence split between two reads is not taken for a key.
  • zlib comes from the runtime's node:zlib. An environment without it gives the codec its own (setZlib).
  • Bun cannot stop a blocking read of a terminal. So on Bun, Terminal.open reads stdin when stdin is the terminal, and otherwise polls /dev/tty without blocking, every 10 ms while it reads. Either way the process exits once the terminal is closed.
  • Windows has no /dev/tty: Terminal.open throws NoTerminalError. A Terminal made from streams, the Session and the wire layer work anywhere.

Conformance

The vectors are the hotty repository's conformance/vectors.json, of version 0.4, copied to test/testdata, and checked against a checkout of hotty when one is at hand (HOTTY_DIR, by default ../../hotty/main).

  • SDK sections. wire, build, encode, decode, scan, detect, keys, keymap and edit all run, and none is skipped: the runner declares every requires. Characters are grapheme clusters, from Intl.Segmenter, which Node and Bun both have (graphemes).
  • Host section. The test host runs the commands, the inspections, the keys, the menu steps and the writes of the host vectors (Host.key, Host.shown, Host.pick), and keeps the touch areas a program sends for each screen (Host.touchAreas). It has the features hover, count and menu, and lays nothing out, so the rows it gives r=auto are an estimate and are not checked, and an event's area is not either; reveal answers as a host does and scrolls nothing, and it sends no view. It skips the vectors that require scroll, steps, drop, view, touch, mouse, undo, passthrough or commands (SDK.md §4.4). The vectors' pointer steps (drags, presses, hover) and the events that follow from a layout are not run: a vector whose every check is in such steps is reported as skipped. An event's detail is compared each value of its type: the vectors' floats are read as Floats, as unpack gives a body's. A reply's types is checked by reading its body as that type, as Reply and Caps read one, and its body by unpacking it with its floats as Floats: each field it names, equal as a value.

Development

The toolchain is in mise.toml: Node 26 and Bun.

make install   # npm ci
make check     # typecheck, build, the tests on Node and on Bun, the built package on both
make vectors   # copy the vectors from HOTTY_DIR

The sources are TypeScript that both runtimes run as they are (erasable syntax only). make build writes the package's JavaScript and types to dist.

Licence

Apache-2.0.

Starting the terminal…