[READ-ONLY] Mirror of https://github.com/bombshell-dev/tty. Platform independent 2D layout engine for terminal applications based on Clay bomb.sh/docs/tty
tty specs terminfo-spec.md
27 kB

Clayterm Terminfo & Capability Specification #

Version: 0.2 (draft) Status: Proposed. Normative for the shared capability layer.


1. Purpose #

This specification defines Clayterm's terminal capability layer: how static capability data (compiled terminfo binaries) and runtime capability data (query/response handshakes such as OSC color queries and DA1) are resolved into typed values that the renderer and the input parser consume independently.

The capability layer answers one question for both consumers: what can this terminal do? The renderer receives a read-only runtime snapshot. Focused renderer features decide how to consume that snapshot (color encoding, erase strategy, synchronized-output frame wrapping, or protocol-specific output). The input parser uses the raw key-sequence material from the terminfo entry to seed its trie, and surfaces probe responses as CapabilityEvent values in the normal event stream.


2. Scope #

In scope (normative) #

  • The Capabilities interface: its fields and the rules for how they are set
  • The TerminalInfo value returned by detectTerminal()
  • The CapabilityEvent discriminated union and its key/value shapes
  • Compiled terminfo binary parsing (legacy and extended formats)
  • The probe model: query batch, DA1 completion fence, and sans-IO contract
  • The public API: detectTerminal(), createTerm, createInput, and Term.update
  • The baseline capability set and the progressive-enhancement evidence model

Out of scope #

  • How the renderer maps capabilities to emitted bytes (see Renderer Specification §7.6 and §7.8)
  • How the input parser recognizes query responses byte-by-byte (see Input Specification §6)
  • Caller-layer mode management (alt screen, mouse reporting, keyboard protocol modes) — capabilities inform these decisions but do not perform them

3. Terminology #

Capabilities. A plain TypeScript interface holding the static capability state of one terminal, resolved at detection time. Frozen after detectTerminal returns. No WASM backing.

TerminalInfo. The value returned by detectTerminal(). Carries the frozen Capabilities, the probe query batch (probe), and the raw terminfo key-sequence bytes the input parser needs to seed its trie (keys).

CapabilityEvent. A discriminated union in the input event stream. The input parser emits one CapabilityEvent for each probe response it recognizes. Each event carries a key (a string literal identifying the capability) and a value (an Rgb color or a boolean).

RuntimeCapabilities. The renderer's merged view: the static Capabilities fields plus all CapabilityEvent values folded in by update() calls. Exposed as a read-only property on Term.

Probe. A batch of terminal query sequences emitted as bytes by the caller, whose responses arrive on the input stream and surface as CapabilityEvent values from scan().

Fence. The final query in a probe batch, chosen because every terminal answers it. Its response marks the probe complete. DA1 (CSI c) is the fence.


4. Architectural model #

4.1 Static and dynamic capabilities #

Capabilities fall into two categories that never mix at runtime.

Static capabilities are resolved once by detectTerminal() from the compiled terminfo entry and the process environment. They are fields on the Capabilities interface, frozen at detection time, and never mutated. The renderer and input parser each receive a copy at construction.

Dynamic capabilities are discovered at runtime through the probe. The terminal's responses arrive on the input stream, where the input parser recognizes them and emits CapabilityEvent values alongside keys and mouse events. The host loop routes these events to term.update(), which folds them into the renderer's RuntimeCapabilities and returns any bytes to write immediately.

4.2 Data flow #

terminfo file bytes ──▶ terminfo_parse() ─▶ Capabilities (frozen)
                                                  │
                         ┌────────────────────────┤
                         ▼                        ▼
                    createInput()           createTerm()
                         │                        │
probe bytes ──▶ terminal ──▶ stdin                │
                    scan() ─▶ CapabilityEvent ─▶ update() ─▶ Uint8Array (write now)
                                                  │
                                           RuntimeCapabilities
  • detectTerminal() parses raw bytes into Capabilities. It performs no IO.
  • The input parser is the only source of CapabilityEvent values. It writes nothing; it emits.
  • The renderer holds its own private capability state. update() is the only path that changes it.
  • The renderer and the input parser share no memory and hold no reference to each other. The host loop is the only place they meet.

4.3 Standalone operation #

createTerm and createInput remain usable without a TerminalInfo. When no terminfo option is provided, each factory initializes from the §7.1 baseline. Behavior is identical to a TerminalInfo with no terminfo bytes, no environment evidence, and no probe responses.


5. Core invariants #

This section is normative.

TINV-1. Frozen static capabilities. Capabilities is frozen after detectTerminal returns. Neither the renderer nor the input parser may mutate it.

TINV-2. Pure parsing. terminfo_parse performs no IO, allocates no memory, and never traps on malformed input. Input larger than MAX_TERMINFO_ENTRY (32768 bytes) never reaches the parser: an oversized entry rejects at the TypeScript boundary (§10.1), and an oversized file found on the search path is skipped. Malformed or truncated binaries yield the §7.1 baseline and a nonzero parse-result code. They MUST NOT partially apply.

TINV-3. Progressive enhancement. The capability layer starts from the conservative §7.1 baseline and raises a capability only on positive evidence. Evidence sources, in increasing precedence: the baseline, the terminfo entry, environment evidence collected at detection (e.g. COLORTERM), and probe responses. A capability no evidence supports keeps its baseline value. Higher-precedence evidence overrides lower-precedence evidence in both directions: a probe denial overrides a statically-set capability.

TINV-4. Sans-IO probe. TerminalInfo.probe is a Uint8Array produced by detectTerminal() without touching any stream. The host writes the bytes. detectTerminal() resolves — never rejects — on timeout, non-TTY streams, a missing, oversized, or malformed terminfo file, or abort. Environmental conditions degrade to the §7.1 baseline. The only rejection is caller error (§10.1).

TINV-5. Immediate update output. term.update() returns a Uint8Array of bytes to write now. It MUST NOT defer output to the next render() call. An empty array is a valid return when the update changes no rendered state.

TINV-6. Event-only probe surface. The input parser MUST NOT write to any shared state when it recognizes a probe response. It surfaces the result as a CapabilityEvent in the scan() return value. The host loop is responsible for routing the event to term.update().


6. Capability types #

This section is normative for the shapes and field semantics.

6.1 Capabilities — static fields #

interface Capabilities {
  readonly colors: number;
  readonly trueColor: boolean;
  readonly bce: boolean;
  readonly autoMargin: boolean;
  readonly xenl: boolean;
  readonly altScreen: boolean;
  readonly styledUnderline: boolean;
  readonly pointerShape: boolean;
}
Field Source
colors max_colors from terminfo; 256 at baseline
trueColor RGB/Tc extended caps; COLORTERM env evidence; probe overrides
bce bce boolean cap
autoMargin am boolean cap
xenl xenl boolean cap
altScreen smcup string present
styledUnderline Su boolean or Smulx string
pointerShape known OSC 22 support table (below); probe overrides

Key sequences (key_* capabilities) are not stored here. The input parser reads them directly from the raw terminfo bytes in TerminalInfo.keys at initialization time.

Known OSC 22 support. Some terminals implement OSC 22 pointer shape setting but never answer its support query (Renderer Specification §7.9); silence must not count as denial, so detectTerminal() seeds pointerShape: true when the environment identifies a terminal in this table. Entries require primary evidence from the terminal's own documentation or specification.

Terminal Environment evidence Version floor Evidence
ghostty TERM=xterm-ghostty or TERM_PROGRAM=ghostty 1.0.0 ghostty.org/docs/vt/osc/22
kitty TERM=xterm-kitty or KITTY_WINDOW_ID set 0.31.0 kitty pointer-shapes spec
foot TERM starting with foot 1.12.0 foot-ctlseqs(7)
xterm XTERM_VERSION parses as patch ≥ 367 367 xterm ctlseqs (OSC 22)

The table names terminals by what they emit, not by what they parse: an implementation that accepts only a non-CSS vocabulary (e.g. Windows cursor names) MUST NOT be listed, since the renderer emits the kitty CSS names exclusively (§6.3). This is static evidence with known failure modes: nested contexts that inherit the environment unchanged (for example Emacs term-mode running inside a listed terminal) are granted by association and will garble OSC 22. Terminals that rewrite TERM (tmux, Zellij) or TERM_PROGRAM (IDE terminals) are naturally excluded; ssh forwards are true positives because the bytes still reach the real terminal. Stale entries degrade to the same silent swallowing the gate was built to avoid.

6.2 ColorDepth #

type ColorDepth = "truecolor" | "256" | "16";

ColorDepth expresses the terminal's confirmed color rendering tier. It is the value type for key: "colordepth" CapabilityEvent values.

6.3 CapabilityEvent — dynamic fields #

type CapabilityEvent =
  | {
    readonly type: "capability";
    readonly key: "foreground-color";
    readonly value: Rgb;
  }
  | {
    readonly type: "capability";
    readonly key: "background-color";
    readonly value: Rgb;
  }
  | {
    readonly type: "capability";
    readonly key: "cursor-color";
    readonly value: Rgb;
  }
  | {
    readonly type: "capability";
    readonly key: "colordepth";
    readonly value: ColorDepth;
  }
  | {
    readonly type: "capability";
    readonly key: "sync-output";
    readonly value: boolean;
  }
  | {
    readonly type: "capability";
    readonly key: "kitty-keyboard";
    readonly value: boolean;
  }
  | {
    readonly type: "capability";
    readonly key: "kitty-graphics";
    readonly value: boolean;
  }
  | {
    readonly type: "capability";
    readonly key: "pointer-shape";
    readonly value: boolean;
  };

Each variant maps to one probe query. The key identifies the capability; the value is the terminal's answer. Capability references per terminfo.dev:

key Query terminfo.dev slug
foreground-color OSC 10 osc-10-fg-color-query
background-color OSC 11 osc-11-bg-color-query
cursor-color OSC 12 or OSC 21 osc-12-cursor-color
colordepth XTGETTCAP RGB/Tc 24-bit-truecolor
sync-output DECRPM mode 2026 decset-2026-synchronized-output
kitty-keyboard CSI ? u kitty-keyboard-protocol
kitty-graphics APC _G… kitty-graphics-protocol
pointer-shape OSC 22 osc-22-pointer-shape

Color values. Rgb values in probe responses MUST be recognized in at least the rgb:RR/GG/BB (1–4 hex digits per channel) and #-hash forms.

colordepth denial. When XTGETTCAP replies with an invalid-capability response for both RGB and Tc, the parser emits { key: "colordepth", value: D } where D is derived from the static Capabilities.colors field: "16" when colors <= 16, "256" otherwise. This preserves TINV-3: the probe response takes precedence over static evidence, including in the negative direction.

OSC 21. An OSC 21 reply may supply any subset of foreground-color, background-color, and cursor-color. The parser emits one CapabilityEvent per field the reply carries. OSC 21 arriving alongside OSC 10/11/12 replies does not merge; each response yields its own event.

OSC 22. An OSC 22 reply sets key: "pointer-shape", value: true. The shape name in the reply is discarded in v1; only protocol support is recorded. The renderer emits pointer shapes only while pointerShape is true (Renderer Specification §7.9). Terminals that implement OSC 22 set but not its query never reply; detectTerminal() supplies that evidence itself from the known-support table (§6.1), and a caller may still override it with term.update([{ type: "capability", key: "pointer-shape", value: true }]).

DA1. The DA1 reply is recognized internally as the probe fence and MUST NOT surface as a CapabilityEvent.

6.4 RuntimeCapabilities — merged view #

interface RuntimeCapabilities extends Capabilities {
  readonly syncOutput: boolean;
  readonly kittyKeyboard: boolean;
  readonly kittyGraphics: boolean;
  readonly theme: {
    readonly foreground?: Rgb;
    readonly background?: Rgb;
    readonly cursor?: Rgb;
  };
}

RuntimeCapabilities is the renderer's current merged view: the static Capabilities fields (including pointerShape, which starts from the known-support table and is raised by a probe reply or a pointer-shape CapabilityEvent) plus all other CapabilityEvent values folded in so far. The trueColor field reflects the latest evidence at any precedence level; a colordepth probe event overrides the static value. RuntimeCapabilities is exposed as term.capabilities and is a frozen snapshot at the moment of access.


7. Baseline and environment evidence #

This section is normative.

7.1 Baseline #

With no terminfo bytes, no environment evidence, and no probe responses, Capabilities is initialized to:

Field Default
colors 256
trueColor false
bce true
autoMargin true
xenl true
altScreen true
styledUnderline false
pointerShape false

Truecolor is not assumed at baseline. Per TINV-3, it requires positive evidence.

7.2 Environment evidence #

detectTerminal() applies environment evidence after parsing the terminfo entry:

  • COLORTERM equal to truecolor or 24bit sets trueColor: true.

Environment evidence outranks the terminfo entry and is outranked by probe responses (TINV-3).


8. Terminfo binary parsing #

This section is normative.

Parsing is implemented in C and exported from the wasm module. It is internal: detectTerminal() is the only caller, and nothing below is part of the public API.

int terminfo_parse(const uint8_t *bytes, int len, struct TermInfo *out);

terminfo_parse accepts a compiled terminfo entry and populates a Capabilities-shaped struct. It returns 0 on success and a nonzero parse-result code on failure. The specific nonzero values are unspecified. On failure, out is left untouched (TINV-2), and detectTerminal() proceeds from the §7.1 baseline. Parse failures are never surfaced to public callers; they observe only the resulting Capabilities.

  • Both storage formats MUST be supported: legacy (magic 0432, 16-bit numbers) and extended number format (magic 01036, 32-bit numbers).
  • The extended-capability string table MUST be parsed for the user-defined capabilities RGB, Tc, Su, and Smulx.
  • All reads are bounds-checked against len. Out-of-range offsets, truncated sections, and odd-length string tables yield the parse failure path (TINV-2).
  • The maximum accepted size is 32 768 bytes, the extended ncurses format limit. The TypeScript boundary enforces this before bytes reach the parser.

Standard capability indices consumed: booleans am (1), xenl (4), bce (28); number max_colors (13); strings smcup (28) and the key_* range (see Input Specification §6.1 for the key set).


9. The probe #

9.1 Query batch #

This section is normative.

TerminalInfo.probe contains the following queries as one Uint8Array, in order:

# Query Bytes CapabilityEvent key
1 Foreground color OSC 10 ; ? BEL foreground-color
2 Background color OSC 11 ; ? BEL background-color
3 Cursor color OSC 12 ; ? BEL cursor-color
4 Kitty color OSC 21 ; foreground=? ; background=? ; cursor=? ST (per field, see §6.3)
5 Pointer shape OSC 22 ; ?__current__ ST pointer-shape
6 Truecolor caps DCS + q 524742 ; 5463 ST (XTGETTCAP RGB;Tc) colordepth
7 Synchronized output CSI ? 2026 $ p (DECRQM) sync-output
8 Kitty keyboard CSI ? u kitty-keyboard
9 Kitty graphics APC _G i=31,s=1,v=1,a=q,t=d,f=24 ; AAAA ST kitty-graphics
10 Fence: DA1 CSI c (internal only)

Terminals answer queries in order and ignore queries they do not understand. DA1 is answered by every terminal, so its response marks the probe complete. Any of queries 1–9 not yet answered when the DA1 reply arrives will never be answered, and the renderer keeps their current values.

The batch is safe to emit unconditionally: every query is either answered or ignored; none changes terminal state.

9.2 Response path #

Probe responses arrive on the terminal's input stream, potentially interleaved with user input. The input parser recognizes and consumes them during its normal scan() (see Input Specification §6.2). Each recognized response produces one or more CapabilityEvent values in the scan() result, interleaved with key and mouse events in arrival order. The host loop routes them to term.update().

9.3 Capability change over time #

A capability event can arrive after the renderer has already emitted frames. term.update() folds it into the runtime snapshot. Any renderer-side output invalidation or immediate bytes are defined by the renderer section that consumes that capability (Renderer Specification §7.8).


10. Public API #

This section is normative for the shapes shown.

10.1 detectTerminal #

function detectTerminal(options?: DetectOptions): Promise<TerminalInfo>;

interface DetectOptions {
  term?: string;
  env?: Record<string, string | undefined>;
  entry?: Uint8Array;
  signal?: AbortSignal;
}

interface TerminalInfo {
  readonly capabilities: Capabilities;
  /**
   * Write to stdout immediately after detection. Responses arrive as
   * CapabilityEvent values in input.scan().
   */
  readonly probe: Uint8Array;
  /** Raw terminfo key bytes. The input parser loads its trie from this. Opaque to callers. */
  readonly keys: KeyTable;
}

type KeyTable = Uint8Array;

detectTerminal():

  1. Rejects with a RangeError if entry is provided and exceeds MAX_TERMINFO_ENTRY bytes. This is the only rejection: it reports invalid caller input, not an environmental condition.
  2. Locates and reads the compiled terminfo entry for the terminal (unless raw bytes are provided via entry), following the ncurses search path: $TERMINFO, $HOME/.terminfo, $TERMINFO_DIRS (empty entry = compiled-in defaults), then /usr/share/terminfo, /etc/terminfo, /lib/terminfo, /usr/lib/terminfo. Both directory layouts are probed: first-letter (Linux) and two-hex-digit (macOS). Names with path separators, NUL, or a leading . are rejected. Files are validated by magic number and skipped when larger than MAX_TERMINFO_ENTRY.
  3. Parses the bytes into Capabilities and extracts key-sequence bytes into keys.
  4. Applies environment evidence (§7.2) from the injectable env.
  5. Constructs probe without performing any IO.
  6. Resolves the TerminalInfo. Apart from step 1, it never rejects.

Every environmental dependency is injectable (env, entry), making the function fully testable without a TTY or real terminfo files.

10.2 createTerm #

function createTerm(options: {
  width: number;
  height: number;
  terminfo?: TerminalInfo;
}): Promise<Term>;

When terminfo is provided, the renderer initializes its private RuntimeCapabilities from terminfo.capabilities and RuntimeCapabilities dynamic fields at their baseline values. When omitted, the renderer uses the §7.1 baseline for all fields.

10.3 createInput #

function createInput(options?: {
  escLatency?: number;
  terminfo?: TerminalInfo;
}): Promise<Input>;

When terminfo is provided, the parser loads its key trie from terminfo.keys. When omitted, the parser uses built-in xterm key sequences.

10.4 Term.update #

interface Term {
  render(ops: Op[], options?: RenderOptions): RenderResult;
  update(events: readonly InputEvent[]): Uint8Array;
  readonly capabilities: RuntimeCapabilities;
}

update() accepts an array of InputEvent values. Events other than ResizeEvent and CapabilityEvent are no-op steps, so the full events array from scan() can be passed without filtering. Events are folded in order: each event produces the next RuntimeCapabilities and any bytes defined by the consuming feature, and the returned bytes are concatenated. The foundation defines no such bytes. The return value is always a Uint8Array; callers write it to their output stream when non-empty (TINV-5).

term.capabilities is a frozen snapshot of the current RuntimeCapabilities.

10.5 Host loop #

import { detectTerminal } from "./terminfo.ts";
import { createTerm } from "./term.ts";
import { createInput } from "./input.ts";

const terminfo = await detectTerminal({ env: process.env });
const term = await createTerm({ width: 80, height: 24, terminfo });
const input = await createInput({ terminfo });

process.stdout.write(terminfo.probe);

process.stdin.on("data", (bytes: Uint8Array) => {
  const { events } = input.scan(bytes);
  const out = term.update(events);
  if (out.length) process.stdout.write(out);
  for (const event of events) dispatch(event);
});

process.on("SIGWINCH", () => {
  const out = term.update([{ type: "resize", width: cols(), height: rows() }]);
  if (out.length) process.stdout.write(out);
});

createTerm and createInput each take terminfo and build their own private state from it. Passing the same TerminalInfo to both passes the same plain value twice. There is no shared memory and no attachment guard.


11. Deferred / future areas #

Non-normative.

OSC 4 palette queries. The 256-entry palette is not probed; the theme group covers foreground, background, and cursor (OSC 10/11/12) only.

Theme-change notification (mode 2031). The probe captures a snapshot; live dark/light switching is not tracked.

XTVERSION / DA2 / DA3 identity parsing. The DA1 reply is used purely as a fence; terminal identification is not extracted.

Re-probing after suspend/resume. TerminalInfo.probe may be written again by the host at any time; responses arrive as events as normal. The spec does not define a managed re-probe lifecycle.

Pixel mouse (1016) and in-band resize (2048) probing. Candidates for the batch once consumers exist.

terminfo string emission (sgr, cup from terminfo). The renderer continues to emit hardcoded ANSI; terminfo strings inform input parsing only.


Open decisions #

  1. What detects OSC 8 hyperlinks? The OSC 8 specification states no detection mechanism exists and the sequence degrades gracefully on unsupported terminals. Candidate sources: an extended user-capability convention, terminal identity heuristics from DA2/XTVERSION (currently unparsed), or treating OSC 8 as permanently ignore-safe.

  2. Should per-shape pointer support be probed? The kitty pointer shape protocol's ?name,name,… query reports support for individual shape names. v1 records protocol support only; a shape-vocabulary field would let the renderer pick portable shape names.

  3. colordepth values beyond "truecolor" | "256" | "16". The three-tier union covers every value XTGETTCAP can produce. Extend it only if a new probe source can deliver finer granularity.