# hotty-ts

hotty-ts is the TypeScript SDK for [hotty](https://hotty.neuroplast.io/docs/),
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.

```ts
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:

```ts
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:

```ts
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:

```ts
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:

```ts
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`](https://github.com/neuroplastio/hotty-ts/blob/main/examples/hello.ts) | open, detect, `lineStart`, `print`, `fence`: the smallest program |
| [`progress`](https://github.com/neuroplastio/hotty-ts/blob/main/examples/progress.ts) | a bar moved by one delta of `varOp` and `text` ops; three renditions: surface, cells, pipe |
| [`ask`](https://github.com/neuroplastio/hotty-ts/blob/main/examples/ask.ts) | a form that takes the keyboard and reports a submit, then a detached summary in its place |
| [`board`](https://github.com/neuroplastio/hotty-ts/blob/main/examples/board.ts) | 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 `Float`s, 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 `Float`s: 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.
