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'scodeandop, an event'sfields(), aCapsfield the host left out. A submit'sfields()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 (EVERSIONfrom 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,capsabsent, and the reply, detection's, never reaches the input. Withlate, 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 withEVERSION. - Errors are exceptions:
HottyError(fromreply.err()), andNoAnswerErrorwhen a request or a fence gets no answer in time, which carries the replies read so far.NoTerminalErrorwhen the process has no terminal, andHostErrorfrom the test host. - Messages are discriminated unions on
kind: theScanner's segments, the link's input, theSession's messages. - Bodies. A host's replies and events carry msgpack maps (SPEC §3.3),
and
Reply,EventandCapsread each whole, every field it knows as its type. One that does not decode (an int field sent as7.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'scodeanddetail,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'sdetailand a message'sbody()have them, and with{ floats: true }each float as aFloat. A timestamp (extension −1) is aTime, seconds and nanoseconds since 1970, withtoDate(); another extension is skipped. What fails a body failsunpacktoo, with aSyntaxError, asJSON.parsethrows. 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'sescapeDelay), 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.openreads 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.openthrowsNoTerminalError. ATerminalmade from streams, theSessionand 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,keymapandeditall run, and none is skipped: the runner declares everyrequires. Characters are grapheme clusters, fromIntl.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 featureshover,countandmenu, and lays nothing out, so the rows it givesr=autoare an estimate and are not checked, and an event'sareais not either;revealanswers as a host does and scrolls nothing, and it sends noview. It skips the vectors that requirescroll,steps,drop,view,touch,mouse,undo,passthroughorcommands(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 asFloats, asunpackgives a body's. A reply'stypesis checked by reading its body as that type, asReplyandCapsread one, and itsbodyby unpacking it with its floats asFloats: 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.