diff --git a/Makefile b/Makefile index 05a2dd1..b684fa7 100644 --- a/Makefile +++ b/Makefile @@ -31,7 +31,11 @@ EXPORTS = \ -Wl,--export=input_scan \ -Wl,--export=input_count \ -Wl,--export=input_event \ - -Wl,--export=input_delay + -Wl,--export=input_delay \ + -Wl,--export=terminfo_size \ + -Wl,--export=terminfo_init \ + -Wl,--export=terminfo_parse \ + -Wl,--export=terminfo_grant LDFLAGS = -Wl,--no-entry \ -Wl,--import-memory \ diff --git a/deno.json b/deno.json index f6bcb17..4fcac6d 100644 --- a/deno.json +++ b/deno.json @@ -2,7 +2,7 @@ "name": "@bomb.sh/tty", "license": "MIT", "tasks": { - "test": "deno test", + "test": "deno test --allow-read --allow-write", "fmt": "deno fmt && clang-format -i src/*.c src/*.h", "fmt:check": "deno fmt --check && clang-format --dry-run --Werror src/*.c src/*.h", "build:npm": "deno run -A tasks/build-npm.ts", diff --git a/input-native.ts b/input-native.ts index 7ed6d1b..d0a644f 100644 --- a/input-native.ts +++ b/input-native.ts @@ -169,39 +169,83 @@ export interface InputNative { delay(st: number): number; } +/** + * Attachment surface provided by a TermInfo handle: the shared memory, + * its bump allocator, and the capability struct / raw terminfo region + * pointers. See terminfo.ts internals(). + */ +export interface InputAttach { + memory: WebAssembly.Memory; + exports: Record; + structPtr: number; + bytesPtr: number; + bytesLen: number; + alloc(size: number, align?: number): number; +} + import { compiled } from "./wasm.ts"; export async function createInputNative( escLatency: number, + attach?: InputAttach, ): Promise { - let memory = new WebAssembly.Memory({ initial: 4 }); - - let instance = await WebAssembly.instantiate(compiled, { - env: { memory }, - clay: { - measureTextFunction() {}, - queryScrollOffsetFunction(ret: number) { - let v = new DataView(memory.buffer); - v.setFloat32(ret, 0, true); - v.setFloat32(ret + 4, 0, true); + let memory = attach?.memory ?? new WebAssembly.Memory({ initial: 4 }); + + let raw: unknown; + if (attach) { + // Reuse the handle's instance: instantiating the module again over + // the shared memory would rewrite its data segments and clobber + // static state already initialized there. + raw = attach.exports; + } else { + let instance = await WebAssembly.instantiate(compiled, { + env: { memory }, + clay: { + measureTextFunction() {}, + queryScrollOffsetFunction(ret: number) { + let v = new DataView(memory.buffer); + v.setFloat32(ret, 0, true); + v.setFloat32(ret + 4, 0, true); + }, }, - }, - }); + }); + raw = instance.exports; + } - let exports = instance.exports as unknown as { + let exports = raw as { __heap_base: WebAssembly.Global; input_size(): number; - input_init(mem: number, escLatency: number): number; + input_init( + mem: number, + escLatency: number, + terminfo: number, + terminfoLen: number, + ti: number, + ): number; input_scan(st: number, buf: number, len: number, now: number): number; input_count(st: number): number; input_event(st: number, index: number): number; input_delay(st: number): number; }; - let heap = exports.__heap_base.value as number; let size = exports.input_size(); - let state = exports.input_init(heap, escLatency); - let buffer = (heap + size + 7) & ~7; + let state: number; + let buffer: number; + if (attach) { + let arena = attach.alloc(size); + buffer = attach.alloc(SCAN_BUFFER_SIZE); + state = exports.input_init( + arena, + escLatency, + attach.bytesLen > 0 ? attach.bytesPtr : 0, + attach.bytesLen, + attach.structPtr, + ); + } else { + let heap = exports.__heap_base.value as number; + state = exports.input_init(heap, escLatency, 0, 0, 0); + buffer = (heap + size + 7) & ~7; + } return { memory, @@ -214,11 +258,6 @@ export async function createInputNative( }; } -// Compiled terminfo entries are limited to 4096 bytes (legacy) or 32768 -// bytes (extended ncurses format). We use the extended limit as our upper -// bound. See https://man7.org/linux/man-pages/man5/term.5.html -export const MAX_TERMINFO = 32768; - // Must match SCAN_BUFFER_SIZE in input.c — the maximum bytes input_scan() // can accept in a single call. export const SCAN_BUFFER_SIZE = 4096; diff --git a/input.ts b/input.ts index 5163e3a..3693c80 100644 --- a/input.ts +++ b/input.ts @@ -77,7 +77,6 @@ import { KEY_SUPER_LEFT, KEY_SUPER_RIGHT, KEY_TAB, - MAX_TERMINFO, MOD_ALT, MOD_CTRL, MOD_MOTION, @@ -87,6 +86,8 @@ import { readEvent, SCAN_BUFFER_SIZE, } from "./input-native.ts"; +import type { InputAttach } from "./input-native.ts"; +import { internals, type TermInfo } from "./terminfo.ts"; /** * Modifier keys held during a key or mouse event. @@ -438,26 +439,32 @@ export interface InputOptions { escLatency?: number; /** - * Compiled terminfo binary to load terminal-specific escape sequences. + * TermInfo handle from queryTermInfo(). Attaches the parser to the + * handle's shared memory and capability struct: terminal-specific key + * sequences from the handle's terminfo entry are loaded into the + * sequence trie, and recognized capability query responses are + * written into the shared struct (see specs/terminfo-spec.md). * - * This is the format used by files like /usr/lib/terminfo/78/xterm-256color - * and they can be directly loaded from disk into this option. - * - * If no terminfo is provided it will use xterm capabilities as the default + * If no handle is provided the parser uses xterm defaults and a + * private capability struct. */ - terminfo?: Uint8Array; + terminfo?: TermInfo; } export async function createInput(options: InputOptions = {}): Promise { let { escLatency = 25, terminfo } = options; - if (terminfo && terminfo.byteLength > MAX_TERMINFO) { - throw new RangeError( - `terminfo exceeds ${MAX_TERMINFO} byte limit (got ${terminfo.byteLength})`, - ); + let attach: InputAttach | undefined; + if (terminfo) { + let native = internals(terminfo); + if (native.inputAttached) { + throw new Error("TermInfo handle is already attached to an Input"); + } + native.inputAttached = true; + attach = native; } - let native = await createInputNative(escLatency); + let native = await createInputNative(escLatency, attach); return { scan(bytes: Uint8Array = new Uint8Array(0)): ScanResult { diff --git a/mod.ts b/mod.ts index 8862d13..b5fb6c9 100644 --- a/mod.ts +++ b/mod.ts @@ -3,3 +3,15 @@ export * from "./term.ts"; export * from "./input.ts"; export * from "./settings.ts"; export * from "./termcodes.ts"; +// terminfo.ts also exports internals(), the attachment surface for +// createTerm/createInput — deliberately not re-exported here. +export { + type Capabilities, + MAX_TERMINFO, + type ProbeInput, + type ProbeOutput, + queryTermInfo, + type QueryTermInfoOptions, + type Rgb, + type TermInfo, +} from "./terminfo.ts"; diff --git a/specs/input-spec.md b/specs/input-spec.md index 3416ebd..7ffef19 100644 --- a/specs/input-spec.md +++ b/specs/input-spec.md @@ -11,15 +11,17 @@ This specification describes Clayterm's terminal input parsing surface: the API for decoding raw terminal byte sequences into structured events. Input parsing is architecturally independent from rendering (see -[Renderer Specification](renderer-spec.md), INV-8). The two concerns share a +[Renderer Specification](renderer-spec.md), INV-7). The two concerns share a compiled WASM binary for loading efficiency, but neither depends on the other's -state, types, or API surface. +state, types, or API surface. Both consume the shared capability layer defined +in the [Terminfo Specification](terminfo-spec.md); the input parser is +additionally that layer's runtime write path (see Section 6). -This specification is currently non-normative. The input API has clear design -intent but has undergone more revision than the rendering core and faces known -upcoming forces that will reshape it (Kitty progressive enhancement field -surfacing, terminfo binary parsing). It is written to document the current -surface and guide future stabilization. +This specification is currently non-normative except where noted. The input API +has clear design intent but has undergone more revision than the rendering core +and faces known upcoming forces that will reshape it (Kitty progressive +enhancement field surfacing). It is written to document the current surface and +guide future stabilization. --- @@ -31,6 +33,8 @@ surface and guide future stabilization. - The scan API and its return type - The `InputEvent` discriminated union and its variants - The ESC timeout resolution model +- Terminfo integration: key sequence loading and capability query response + recognition (Section 6, normative) ### Out of scope @@ -74,8 +78,16 @@ Options: responsiveness (lower values) and correct disambiguation of ESC-prefixed sequences (higher values). -- **`terminfo`** — A `Uint8Array` of raw terminfo binary. Accepted but C-side - parsing is not yet implemented. +- **`terminfo`** — A `TermInfo` handle from `queryTermInfo()` (see + [Terminfo Specification](terminfo-spec.md) §10). Attaches the parser to the + handle's shared memory and capability struct. Terminal-specific key sequences + from the handle's terminfo bytes are loaded into the parser's sequence trie at + initialization (Section 6.1), and the parser becomes the capability struct's + runtime writer (Section 6.2). When omitted, the parser operates standalone + with xterm default sequences and a private capability struct. + + The previous `Uint8Array` form of this option is replaced by the handle form; + raw bytes are supplied via `queryTermInfo({ terminfo: bytes })`. ### 4.2 Scan @@ -135,7 +147,50 @@ has already been extended with fields that are not yet mapped to the TS types). --- -## 6. Deferred / Future Areas +## 6. Terminfo Integration + +_This section is normative. It defines the input parser's two roles in the +capability layer specified by the [Terminfo Specification](terminfo-spec.md)._ + +### 6.1 Key sequences from terminfo + +When attached to a `TermInfo` handle whose terminfo bytes are present, the +parser MUST load the terminal's `key_*` string capabilities into its escape +sequence trie at initialization, before any scan. Terminfo-supplied sequences +take precedence over the built-in xterm defaults when they conflict; defaults +remain registered for sequences the terminfo entry does not define. + +The key capabilities consumed are the `key_*` string range mapped to existing +`KEY_*` codes: arrows (`kcuu1`, `kcud1`, `kcub1`, `kcuf1`), function keys +(`kf1`–`kf12`), editing keys (`khome`, `kend`, `kich1`, `kdch1`, `kpp`, `knp`), +and backtab (`kcbt`). Key capabilities with no corresponding `KEY_*` code are +ignored. + +Strings are read directly from the raw terminfo bytes in the shared region; they +are not copied into the capability struct. + +### 6.2 Query response recognition + +The parser is the runtime write path for the capability struct. During a normal +scan — with responses potentially interleaved with user input — it MUST +recognize and consume the probe responses listed in Terminfo Specification §9.1: +OSC 10/11/12 theme color reports, OSC 21 kitty color reports, OSC 22 pointer +shape reports, XTGETTCAP DCS replies, DECRPM mode-2026 reports, kitty keyboard +flag reports, kitty graphics APC replies, and the DA1 device attributes report. + +For each recognized response the parser updates the corresponding struct fields, +sets the `confirmed` bit, and increments the generation, per Terminfo +Specification §6. Responses are consumed silently: they MUST NOT surface as +`InputEvent`s, and bytes belonging to a recognized response MUST NOT leak into +adjacent events. + +When the parser is standalone (no handle), responses are still recognized and +consumed — writing into the parser's private struct — so stray replies never +corrupt the event stream. + +--- + +## 7. Deferred / Future Areas _These topics are explicitly excluded from this specification. Their omission is intentional, not an oversight._ @@ -144,9 +199,6 @@ intentional, not an oversight._ struct has been extended for progressive enhancement fields. The TypeScript event types have not been updated to surface them. -**Terminfo binary parsing.** The input API accepts a `terminfo` option, but -C-side parsing is not implemented. - **Whether input parsing should be a separate package.** Architecturally independent from the renderer but currently co-located. The distribution decision is open. diff --git a/specs/renderer-spec.md b/specs/renderer-spec.md index 398e78a..9cad0f0 100644 --- a/specs/renderer-spec.md +++ b/specs/renderer-spec.md @@ -40,6 +40,8 @@ Transitions are specified separately in the - The directive model and core helpers - Element identity and frame semantics - Boundary responsibilities (what Clayterm owns and what it does not) +- Capability-gated emission (Section 7.6; the capability layer itself is + specified in the [Terminfo Specification](terminfo-spec.md)) ### In scope (non-normative, descriptive) @@ -58,6 +60,9 @@ Transitions are specified separately in the - Demo applications - The crankterm project or any specific framework built on Clayterm - Input parsing (see [Clayterm Input Specification](input-spec.md)) +- Terminfo parsing and capability probing (see + [Terminfo Specification](terminfo-spec.md)); this specification consumes the + capability struct, it does not define it --- @@ -232,7 +237,10 @@ function scope. concern MUST remain independent. Neither MUST depend on the other's state, types, or API surface. They MAY share a compiled WASM binary for loading efficiency, but this is an implementation convenience, not an architectural -coupling. +coupling. Both MAY consume the shared capability layer defined in the +[Terminfo Specification](terminfo-spec.md); the renderer reads the capability +struct and the input parser writes it, but neither observes the other through it +beyond the capability facts it carries. --- @@ -334,6 +342,57 @@ nests clip regions more deeply than the renderer can track: (see §12.3) before returning, so the caller can detect that some clipping was not applied. +### 7.6 Capability-gated emission + +A Term instance attached to a `TermInfo` handle (see +[Terminfo Specification](terminfo-spec.md)) reads the capability struct at the +start of each render transaction and gates its output accordingly. This consumes +capabilities; it never writes them (Terminfo Specification TINV-4). + +**Color encoding ladder.** The renderer MUST select its SGR color encoding from +the capability struct: + +- `trueColor` set → 24-bit SGR (`38;2;r;g;b` / `48;2;r;g;b`) +- otherwise `colors` ≥ 256 → 256-color SGR (`38;5;n` / `48;5;n`), mapping RGB to + the nearest entry of the 6×6×6 color cube and 24-step grayscale ramp +- otherwise → 16-color SGR (`30–37`, `90–97` and background equivalents), + mapping RGB to the nearest of the 16 ANSI colors + +The nearest-color quantization method is implementation-defined but MUST be +deterministic: the same RGB input always maps to the same palette entry within a +process. + +**Back-color-erase.** When the `bce` capability is set, the renderer MAY use +erase sequences that rely on the terminal filling cleared cells with the current +background. When it is clear, the renderer MUST NOT depend on that behavior. + +**Synchronized output.** When the `syncOutput` capability is set, the renderer +MUST wrap each non-empty cursor-update-mode frame in the synchronized output +protocol: `CSI ? 2026 h` (begin synchronized update) before the first output +byte and `CSI ? 2026 l` (end synchronized update) after the last, within the +same output buffer. The wrap is frame-scoped: begin and end always appear in the +same render transaction's output, so no terminal state persists between frames +(see §11.2). When the capability is unset, the wrap MUST NOT be emitted. +Line-mode output is never wrapped. + +The wrap complements — never replaces — cell diffing. Emitting only changed +cells (§4.4) remains the primary defense against tearing on terminals without +mode 2026 and the dominant reduction in bytes sent, per the +[guidance modern emulators publish for TUI developers](https://ghostty.org/docs/help/synchronized-output); +the wrap adds atomic frame presentation on terminals that support it. + +**Generation invalidation.** The renderer MUST compare the capability struct's +generation counter on each render transaction. When it differs from the +generation of the previously emitted frame, the renderer MUST invalidate its +diff state and emit the frame as a complete redraw, so that no cell on screen +retains bytes encoded under superseded capabilities. + +A Term with no `TermInfo` handle uses the baseline capabilities (Terminfo +Specification §7.1): 256-color emission. Per the progressive-enhancement +invariant (Terminfo Specification TINV-5), truecolor emission requires positive +evidence — a terminfo entry, environment evidence, or a probe reply — which +supersedes the renderer's historical unconditional truecolor output. + --- ## 8. Public Rendering API @@ -344,13 +403,22 @@ included. See Section 5 for what this section does and does not freeze._ ### 8.1 Term creation ``` -createTerm(options: { width: number; height: number }): Promise +createTerm(options: { + width: number; + height: number; + terminfo?: TermInfo; +}): Promise ``` Creates a new Term instance bound to the specified terminal dimensions. The returned promise resolves when the renderer is ready. The `width` and `height` parameters specify the terminal dimensions in character cells. +The optional `terminfo` handle (from `queryTermInfo()`; see +[Terminfo Specification](terminfo-spec.md) §10) attaches the Term to a shared +capability struct that gates emission per §7.6. When omitted, the Term operates +standalone with default capabilities. + ### 8.2 Render invocation ``` @@ -615,6 +683,10 @@ These are the caller's responsibility. The renderer's output contains only the escape sequences needed to render the frame content (cursor positioning for cell writes, SGR attributes for styling, and UTF-8 text). +The synchronized-output frame wrap (§7.6) is not terminal-state management in +this sense: mode 2026 is begun and ended within a single frame's output and +never persists across render transactions. + ### 11.3 The renderer does not own application lifecycle The renderer MUST NOT maintain a run loop, event loop, timer, or subscription diff --git a/specs/terminfo-spec.md b/specs/terminfo-spec.md new file mode 100644 index 0000000..15b9d4d --- /dev/null +++ b/specs/terminfo-spec.md @@ -0,0 +1,517 @@ +# Clayterm Terminfo & Capability Specification + +**Version:** 0.1 (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 parsed into a +single shared capability struct, and how the renderer and the input parser +consume it. + +The capability layer exists to answer one question for both consumers: **what +can this terminal do?** The renderer uses the answer to gate what it emits +(color encoding, erase strategy, synchronized-output frame wrapping). The input +parser uses the answer's raw material (terminal-specific key sequences) and is +the write path through which runtime query responses reach the struct. + +--- + +## 2. Scope + +### In scope (normative) + +- The `TermInfo` capability struct: its field set, mutation rules, and the + generation counter +- The shared-memory region model and its build constraints +- Compiled terminfo binary parsing (legacy and extended formats) +- The probe model: query batch, completion fence, and sans-IO contract +- The public API: `queryTermInfo()`, the `TermInfo` handle, and how the handle + is passed to `createTerm` and `createInput` +- The baseline capability set and the progressive-enhancement evidence model + +### Out of scope + +- How the renderer maps capabilities to emitted bytes (see + [Renderer Specification](renderer-spec.md) §7.6) +- How the input parser recognizes query responses byte-by-byte (see + [Input Specification](input-spec.md) §6) +- Caller-layer mode management (alt screen, kitty push/pop, mouse enable) — + capabilities inform these decisions but do not perform them + +--- + +## 3. Terminology + +**Capability struct (`TermInfo` struct).** A fixed-layout C struct holding the +resolved capability state of one terminal. Lives at a stable pointer for the +lifetime of the handle that owns it. + +**Terminfo region.** A reserved range of linear memory holding the raw compiled +terminfo bytes and the capability struct. + +**Handle (`TermInfo`).** The TypeScript object returned by `queryTermInfo()`. +Owns the shared `WebAssembly.Memory`, the terminfo region, and the pointer to +the capability struct. Passed to `createTerm` and `createInput` to attach them +to the same struct. + +**Probe.** A batch of terminal query sequences emitted as bytes, whose responses +arrive on the input stream and are folded back into the capability struct. + +**Fence.** The final query in a probe batch, chosen because every terminal +answers it. Its response marks the probe as complete. This specification uses +DA1 (`CSI c`) as the fence. + +**Generation.** A monotonic counter on the capability struct, incremented on +every mutation. Consumers compare generations to detect capability change. + +--- + +## 4. Architectural Model + +### 4.1 One memory, three tenants + +When a `TermInfo` handle is in use, a single `WebAssembly.Memory` is shared by +up to three tenants: + +1. The **terminfo region** — raw bytes plus the capability struct, owned by the + handle. +2. The **renderer instance** — heap and transfer buffers for `createTerm`. +3. The **input parser instance** — heap and scan buffer for `createInput`. + +The handle owns the memory and allocates disjoint regions to each tenant. Both +WASM instances import the shared memory (`env.memory`) and receive their region +pointers explicitly, as they do today via `init(mem, …)` and +`input_init(mem, …)`. Each additionally receives the capability struct pointer. + +### 4.2 Data flow + +``` +terminfo file bytes ──▶ terminfo_parse() ──▶ ┌───────────────┐ + │ TermInfo │ ◀── reads ── renderer +probe bytes ──▶ terminal ──▶ stdin ──▶ │ struct │ + input_scan() ── writes ────▶ │ (+generation) │ ◀── reads ── TS capabilities view + └───────────────┘ +``` + +- The **terminfo module** (`terminfo.c`) parses the raw bytes into the struct. + It performs no IO. +- The **input parser** is the only runtime writer: when it recognizes a query + response during a normal scan, it updates the struct and bumps the generation. +- The **renderer** only reads the struct, at render-transaction time. +- The **TypeScript layer** reads the struct through the handle's `capabilities` + view. It writes only at handle creation: `terminfo_parse` plus environment + evidence (§7.2). + +The renderer and the input parser never communicate with each other. Both depend +on the terminfo layer, exactly as both depend on shared substrate modules today +(`mem.c`, `utf8.c`). This preserves the independence invariant (Renderer +Specification INV-7). + +### 4.3 Standalone operation + +`createTerm` and `createInput` remain usable without a handle. When no +`terminfo` option is provided, each factory creates its own private memory (as +today) containing a private capability struct initialized to the §7.1 baseline. +Behavior is identical to a handle with no terminfo bytes, no environment +evidence, and no probe responses. + +--- + +## 5. Core Invariants + +_This section is normative._ + +**TINV-1. Single source of truth.** All capability state lives in the capability +struct. Neither the renderer nor the input parser may cache capability values +across frames/scans in a way that survives a generation change. + +**TINV-2. Monotonic generation.** Every mutation of the capability struct MUST +increment the generation counter exactly once per logical update. The counter +never decreases. A consumer that observes an unchanged generation MAY assume +every other field is unchanged. + +**TINV-3. Pure parsing.** `terminfo_parse` performs no IO, allocates no memory, +and never traps on malformed input. Input larger than 32768 bytes is rejected at +the TypeScript boundary. Malformed or truncated binaries yield the §7.1 baseline +and a nonzero parse-result code; they MUST NOT partially apply. + +**TINV-4. Single runtime writer.** Capability writes happen at handle creation +(terminfo parse plus environment evidence, §7) and thereafter only through the +input parser recognizing query responses. The renderer MUST NOT write the +struct. The TypeScript layer MUST NOT write it after creation. + +**TINV-5. Progressive enhancement.** The capability layer starts from a +conservative baseline owned by the terminfo module — the built-in equivalent of +`xterm-256color` (§7) — and raises a capability only on positive evidence. +Evidence sources, in increasing precedence: the baseline, the terminfo entry, +environment evidence collected at handle creation (e.g. `COLORTERM`), and probe +responses. A capability bit no evidence supports stays unset; a +higher-precedence denial clears a lower-precedence grant. Consumers MUST NOT +assume capabilities beyond what the struct states. + +**TINV-6. Sans-IO probe.** The probe core produces bytes (`probe()`) and +consumes bytes (via the input parser's scan path). It never touches a stream. +The convenience wrapper (`queryTermInfo`) performs IO but MUST resolve — never +reject — on timeout, non-TTY streams, missing terminfo files, or abort. + +**TINV-7. Disjoint static footprints.** Any two WASM modules that import the +same memory MUST be linked with disjoint static data/heap base ranges (e.g. +coordinated `--global-base`) so that instantiating one cannot clobber the +other's data segments. Under the current single-module build this is trivially +satisfied; a split-module build (layout/input) MUST enforce it in the Makefile. + +--- + +## 6. The Capability Struct + +_This section is normative for the field set and semantics. Exact byte offsets +are defined by `terminfo.h` and mirrored in TypeScript via `typedef.ts`; they +are implementation surface, not contract._ + +| Field | Type | Meaning | +| -------------- | ------ | -------------------------------------------------------------- | +| `generation` | uint32 | Mutation counter (TINV-2). Starts at 1 after initialization. | +| `colors` | uint32 | `max_colors` from terminfo; 0 when unknown. | +| `flags` | uint32 | Bitfield of `TERMINFO_*` capability bits (below). | +| `confirmed` | uint32 | Subset of `flags` bits confirmed or denied by probe responses. | +| `theme_fg` | uint32 | Theme foreground as `0x00RRGGBB`; valid bit in `flags`. | +| `theme_bg` | uint32 | Theme background as `0x00RRGGBB`; valid bit in `flags`. | +| `theme_cursor` | uint32 | Theme cursor color as `0x00RRGGBB`; valid bit in `flags`. | + +The three `theme_*` fields form the **theme group**, surfaced in TypeScript as a +single `theme` object (§10.2). + +Flag bits: + +| Bit | Source (static) | Source (probe) | +| --------------------------- | ----------------------------------------------------------------------- | --------------------------- | +| `TERMINFO_TRUECOLOR` | `RGB`/`Tc` extended caps, or `colors` ≥ 1<<24; `COLORTERM` env evidence | XTGETTCAP `RGB`/`Tc` reply | +| `TERMINFO_BCE` | `bce` boolean | — | +| `TERMINFO_AM` | `am` boolean | — | +| `TERMINFO_XENL` | `xenl` boolean | — | +| `TERMINFO_ALTSCREEN` | `smcup` string present | — | +| `TERMINFO_STYLED_UNDERLINE` | `Su` boolean / `Smulx` string | — | +| `TERMINFO_SYNC` | — | DECRPM reply for mode 2026 | +| `TERMINFO_KITTY_KEYBOARD` | — | `CSI ? flags u` reply | +| `TERMINFO_KITTY_GRAPHICS` | — | APC `_G…` reply | +| `TERMINFO_KITTY_COLOR` | — | OSC 21 reply | +| `TERMINFO_HYPERLINKS` | reserved (Open Decision 4) | reserved (Open Decision 4) | +| `TERMINFO_POINTER_SHAPE` | — | OSC 22 `?__current__` reply | +| `TERMINFO_THEME_FG` | — | OSC 10 or OSC 21 reply | +| `TERMINFO_THEME_BG` | — | OSC 11 or OSC 21 reply | +| `TERMINFO_THEME_CURSOR` | — | OSC 12 or OSC 21 reply | + +Evidence precedence follows TINV-5: when a bit is set in `confirmed`, the +corresponding `flags` bit reflects the terminal's answer, not the terminfo file +or environment. A denial (e.g. XTGETTCAP invalid-capability reply) clears a +statically-set bit. + +`TERMINFO_POINTER_SHAPE` (OSC 22) is detected via the +[kitty pointer shape protocol](https://sw.kovidgoyal.net/kitty/pointer-shapes/) +query `OSC 22 ; ?__current__ ST`: supporting terminals reply with the current +shape name; non-supporting terminals stay silent and the DA1 fence closes the +question. Note the flag records _protocol_ support only — shape-name +vocabularies vary by terminal (kitty and Ghostty use CSS names, xterm uses X11 +names); per-shape support can be refined later via the protocol's `?name,name,…` +query form (deferred). + +`TERMINFO_HYPERLINKS` (OSC 8) is allocated because a consumer is planned (#67), +but the +[OSC 8 specification](https://gist.github.com/egmontkob/eb114294efbcd5adb1944c9f3cb5feda) +is explicit that no detection mechanism exists — see Open Decision 4. Until a +source is settled the bit stays unset. OSC 8 is ignore-safe by ECMA-48 parsing +rules: unsupported terminals render the link text without artifacts, so +consumers MAY emit it without capability confirmation. + +Key sequence strings (`key_*` capabilities) are **not** stored in the struct. +The input parser reads them directly from the raw terminfo bytes in the terminfo +region at initialization time (see Input Specification §6.1). The struct carries +resolved _facts_; the raw region carries _material_. + +--- + +## 7. Baseline and Environment Evidence + +_This section is normative._ + +### 7.1 Baseline + +The terminfo module owns the baseline: the built-in equivalent of the +`xterm-256color` terminfo entry. With no terminfo bytes, no environment +evidence, and no probe responses, the struct is initialized to: + +| Field | Default | +| ----------- | -------------------------------- | +| `colors` | 256 | +| `flags` | `AM \| XENL \| ALTSCREEN \| BCE` | +| `confirmed` | 0 | +| `theme_*` | 0 (invalid; theme unknown) | + +Truecolor is **not** assumed at baseline. Per TINV-5 it is enhanced in by +evidence: `RGB`/`Tc` in the terminfo entry, `COLORTERM` in the environment, or +an XTGETTCAP probe reply. This supersedes the renderer's historical +unconditional truecolor emission — a Term with no capability evidence emits +256-color SGR (Renderer Specification §7.6). + +### 7.2 Environment evidence + +At handle creation, `queryTermInfo` applies evidence from the (injectable) +environment after parsing the terminfo entry and before the probe: + +- `COLORTERM` equal to `truecolor` or `24bit` sets `TERMINFO_TRUECOLOR`. + +Environment evidence outranks the terminfo entry and is outranked by probe +responses (TINV-5). + +--- + +## 8. Terminfo Binary Parsing + +_This section is normative._ + +`terminfo_parse(bytes, len, out)` accepts a compiled terminfo entry and +populates the capability struct. + +- 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 (the ncurses extension block after the + standard sections) MUST be parsed for the user-defined capabilities `RGB`, + `Tc`, `Su`, and `Smulx`. +- Parsing is bounds-checked against `len` everywhere. Out-of-range string + offsets, truncated sections, and odd-length string tables yield the parse + failure path (TINV-3), never a trap or partial state. +- The maximum accepted size is 32768 bytes (`MAX_TERMINFO`), the extended + ncurses format limit. The TypeScript boundary enforces this before the bytes + reach linear memory. + +The standard capability indices consumed are: 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._ + +`terminfo.probe()` returns the following queries as one `Uint8Array`, in order: + +| # | Query | Bytes | Answered by | +| -- | ------------------- | ---------------------------------------------------- | --------------------- | +| 1 | Foreground color | `OSC 10 ; ? BEL` | OSC 10 reply | +| 2 | Background color | `OSC 11 ; ? BEL` | OSC 11 reply | +| 3 | Cursor color | `OSC 12 ; ? BEL` | OSC 12 reply | +| 4 | Kitty color | `OSC 21 ; foreground=? ; background=? ; cursor=? ST` | OSC 21 reply | +| 5 | Pointer shape | `OSC 22 ; ?__current__ ST` | OSC 22 reply | +| 6 | Truecolor caps | `DCS + q 524742 ; 5463 ST` (XTGETTCAP `RGB;Tc`) | DCS `1 + r` / `0 + r` | +| 7 | Synchronized output | `CSI ? 2026 $ p` (DECRQM) | `CSI ? 2026 ; Ps $ y` | +| 8 | Kitty keyboard | `CSI ? u` | `CSI ? flags u` | +| 9 | Kitty graphics | `APC _G i=31,s=1,v=1,a=q,t=d,f=24 ; AAAA ST` | APC `_Gi=31;…` reply | +| 10 | **Fence:** DA1 | `CSI c` | `CSI ? … c` | + +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 their capabilities keep their static/default values. + +An [OSC 21](https://sw.kovidgoyal.net/kitty/color-stack/) reply echoes the +queried keys with `?` replaced by the encoded color (or empty when undefined); +it sets `TERMINFO_KITTY_COLOR` and MAY fill any theme fields it carries. The OSC +10/11/12 replies remain the portable theme source. Color values in OSC replies +MUST be recognized in at least the `rgb:RR/GG/BB` (1–4 hex digits per channel) +and `#`-hash forms; other encodings (`rgbi:`, named colors, `@alpha` suffixes) +MAY be ignored. An [OSC 22](https://sw.kovidgoyal.net/kitty/pointer-shapes/) +reply carries the current shape name; the reply's arrival sets +`TERMINFO_POINTER_SHAPE` and the name itself is discarded in v1. + +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. They are recognized and consumed by the input parser during its +normal `scan()` (see Input Specification §6.2). Each recognized response updates +the capability struct, sets the relevant `confirmed` bit, and bumps the +generation. Responses are consumed silently; they do not surface as +`InputEvent`s in v1. + +A render-only consumer (no `createInput`) that wants probe results MUST route +its input stream through the handle's input parser during the probe window; +`queryTermInfo` does exactly this internally. + +### 9.3 Capability change over time + +Capabilities may change after first use — a probe response can arrive after a +frame has already rendered. The generation counter is the mechanism: the +renderer compares the struct generation on each render transaction and +invalidates its diff state when it changed (Renderer Specification §7.6). No +consumer ceremony is required. + +--- + +## 10. Public API + +_This section is normative for the shapes shown. Option names follow the +existing codebase conventions._ + +### 10.1 queryTermInfo + +``` +queryTermInfo(options?: QueryTermInfoOptions): Promise +``` + +The single blessed entry point. It: + +1. Locates and reads the compiled terminfo entry for the terminal (unless raw + bytes are provided), 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 containing path separators, NUL, or a + leading `.` are rejected. Files are validated by magic number. +2. Creates the shared memory, terminfo region, and capability struct; parses the + bytes. +3. Applies environment evidence (§7.2) from the injectable `env`. +4. When `input` and `output` are TTYs: writes the probe batch to `output` and + feeds `input` through the handle's parser until the DA1 fence or timeout. Raw + mode is enabled for the probe window and restored afterward. +5. Resolves the handle. + +``` +interface QueryTermInfoOptions { + term?: string; // terminal name; default env.TERM + env?: Record; // default process.env + terminfo?: Uint8Array; // raw bytes; skips filesystem lookup + input?: ReadStream; // default process.stdin + output?: WriteStream; // default process.stdout + timeout?: number; // ms until probe abandonment; default 100 + signal?: AbortSignal; +} +``` + +Every environmental dependency is injectable (`env`, `terminfo`, `input`, +`output`), making the function fully mockable without a PTY. Per TINV-6 it +resolves — never rejects — when the terminfo file is missing, the streams are +not TTYs, the probe times out, or the signal aborts; the handle then carries +whatever subset of capabilities was resolved. + +### 10.2 The TermInfo handle + +``` +interface TermInfo { + readonly capabilities: Capabilities; // decoded live view of the struct + probe(): Uint8Array; // the §9.1 query batch (sans-IO) +} +``` + +`capabilities` decodes the struct on read (cheap; a handful of field reads) so +it always reflects the current generation: + +``` +interface Rgb { + r: number; + g: number; + b: number; +} + +interface Capabilities { + generation: number; + colors: number; + trueColor: boolean; + bce: boolean; + autoMargin: boolean; + altScreen: boolean; + styledUnderline: boolean; + syncOutput: boolean; + kittyKeyboard: boolean; + kittyGraphics: boolean; + kittyColor: boolean; + hyperlinks: boolean; // reserved; no detection source exists (Open Decision 4) + pointerShape: boolean; // kitty OSC 22 protocol support + theme: { + foreground?: Rgb; // OSC 10 + background?: Rgb; // OSC 11 + cursor?: Rgb; // OSC 12 + }; +} +``` + +The handle also carries the shared memory and region pointers as internal +(non-normative) surface consumed by `createTerm` and `createInput`. + +### 10.3 Attachment + +``` +createTerm({ width, height, terminfo?: TermInfo }): Promise +createInput({ escLatency?, terminfo?: TermInfo }): Promise +``` + +Passing the same handle to both attaches them to the same memory and struct. A +handle MAY be attached to at most one `Term` and one `Input` at a time; +attaching a second is an error. Factories called without `terminfo` operate +standalone (§4.3). + +The previous `terminfo?: Uint8Array` option on `createInput` is replaced by the +handle form. Raw bytes are provided via `queryTermInfo({ terminfo: bytes })`. + +--- + +## 11. Deferred / Future Areas + +_Non-normative. Intentional omissions._ + +**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. + +**Surfacing capability changes as events.** Probe responses are consumed +silently. A `capabilitychange` input event or handle callback may be added once +a consumer needs reactivity beyond the generation counter. + +**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. **Should `capabilities` be an event emitter?** v1 is poll-only via + `generation`. Reactive consumers may justify a subscription API. + +2. **Where does the split-module `--global-base` coordination live?** TINV-7 + states the constraint; the mechanism (Makefile flags vs. a linker script) is + a build-system decision for the PR that splits the modules. + +3. **Should the probe be re-runnable?** `probe()` may be called any number of + times, but `queryTermInfo` runs the managed probe exactly once. Re-probing + after suspend/resume (terminal may have changed) is unaddressed. + +4. **What detects OSC 8 hyperlinks?** The + [OSC 8 specification](https://gist.github.com/egmontkob/eb114294efbcd5adb1944c9f3cb5feda) + states no detection mechanism exists, and the sequence degrades gracefully on + unsupported terminals. Candidate sources: an extended user capability + convention (none has settled), terminal identity heuristics from + DA2/XTVERSION (currently unparsed, see Deferred), or treating OSC 8 as + permanently ignore-safe and dropping the flag. The struct reserves the bit so + consumers have a stable place to look once a source is chosen. + +5. **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. diff --git a/src/clayterm.c b/src/clayterm.c index e48091e..8c36f33 100644 --- a/src/clayterm.c +++ b/src/clayterm.c @@ -84,6 +84,13 @@ struct Clayterm { Clay_ErrorData errors[MAX_ERRORS]; int error_count; int animating_count; + /* capability struct (shared or private) and the generation of the + * last emitted frame; a mismatch forces a full redraw */ + struct TermInfo *ti; + struct TermInfo ti_private; + uint32_t ti_generation; + /* color encoding for the current frame: 0 truecolor, 1 = 256, 2 = 16 */ + int depth; }; /* Memory layout inside the arena provided by the host: @@ -93,6 +100,8 @@ struct Clayterm { * full-screen redraws with truecolor SGR sequences on every cell. */ #define OUT_BYTES_PER_CELL 64 +/* headroom for the frame-scoped synchronized-output wrap */ +#define OUT_WRAP_BYTES 32 /* ── Cell buffer ops ──────────────────────────────────────────────── */ @@ -120,6 +129,80 @@ static void setcell(struct Clayterm *ct, int x, int y, uint32_t ch, uint32_t fg, /* ── Escape sequence generation ───────────────────────────────────── */ +static int dist2(int r1, int g1, int b1, int r2, int g2, int b2) { + int dr = r1 - r2, dg = g1 - g2, db = b1 - b2; + return dr * dr + dg * dg + db * db; +} + +/* Nearest xterm 256-color palette entry: 6x6x6 cube (levels 0, 95, 135, + * 175, 215, 255) vs 24-step grayscale ramp (8, 18, … 238), whichever is + * closer. Matches the tmux/xterm colour_find_rgb approach. */ +static int xterm256(int r, int g, int b) { + static const int q2c[6] = {0, 95, 135, 175, 215, 255}; + int qr = r < 48 ? 0 : r < 115 ? 1 : (r - 35) / 40; + int qg = g < 48 ? 0 : g < 115 ? 1 : (g - 35) / 40; + int qb = b < 48 ? 0 : b < 115 ? 1 : (b - 35) / 40; + + int avg = (r + g + b) / 3; + int gi = avg > 238 ? 23 : (avg - 3) / 10; + if (gi < 0) + gi = 0; + int gray = 8 + 10 * gi; + + if (dist2(r, g, b, gray, gray, gray) < + dist2(r, g, b, q2c[qr], q2c[qg], q2c[qb])) + return 232 + gi; + return 16 + 36 * qr + 6 * qg + qb; +} + +/* Nearest of the 16 ANSI colors (xterm default palette). */ +static int ansi16(int r, int g, int b) { + static const uint8_t palette[16][3] = { + {0, 0, 0}, {205, 0, 0}, {0, 205, 0}, {205, 205, 0}, + {0, 0, 238}, {205, 0, 205}, {0, 205, 205}, {229, 229, 229}, + {127, 127, 127}, {255, 0, 0}, {0, 255, 0}, {255, 255, 0}, + {92, 92, 255}, {255, 0, 255}, {0, 255, 255}, {255, 255, 255}, + }; + int best = 0; + int best_d = 0x7fffffff; + for (int i = 0; i < 16; i++) { + int d = dist2(r, g, b, palette[i][0], palette[i][1], palette[i][2]); + if (d < best_d) { + best_d = d; + best = i; + } + } + return best; +} + +/* Emit one color under the frame's encoding ladder (renderer-spec 7.6): + * truecolor, 256-color, or 16-color SGR. */ +static void emit_color(struct Clayterm *ct, uint32_t c, int is_bg) { + int r = (int)((c >> 16) & 0xff); + int g = (int)((c >> 8) & 0xff); + int b = (int)(c & 0xff); + + if (ct->depth == 0) { + buf_str(&ct->out, is_bg ? "\x1b[48;2;" : "\x1b[38;2;"); + buf_num(&ct->out, r); + buf_put(&ct->out, ";", 1); + buf_num(&ct->out, g); + buf_put(&ct->out, ";", 1); + buf_num(&ct->out, b); + buf_put(&ct->out, "m", 1); + } else if (ct->depth == 1) { + buf_str(&ct->out, is_bg ? "\x1b[48;5;" : "\x1b[38;5;"); + buf_num(&ct->out, xterm256(r, g, b)); + buf_put(&ct->out, "m", 1); + } else { + int i = ansi16(r, g, b); + int code = i < 8 ? (is_bg ? 40 : 30) + i : (is_bg ? 100 : 90) + (i - 8); + buf_str(&ct->out, "\x1b["); + buf_num(&ct->out, code); + buf_put(&ct->out, "m", 1); + } +} + static void emit_attr(struct Clayterm *ct, uint32_t fg, uint32_t bg) { if (fg == ct->lastfg && bg == ct->lastbg) return; @@ -143,27 +226,11 @@ static void emit_attr(struct Clayterm *ct, uint32_t fg, uint32_t bg) { if (fg & ATTR_STRIKEOUT) buf_str(&ct->out, "\x1b[9m"); - /* foreground truecolor */ - if (!(fg & ATTR_DEFAULT)) { - buf_str(&ct->out, "\x1b[38;2;"); - buf_num(&ct->out, (fg >> 16) & 0xff); - buf_put(&ct->out, ";", 1); - buf_num(&ct->out, (fg >> 8) & 0xff); - buf_put(&ct->out, ";", 1); - buf_num(&ct->out, fg & 0xff); - buf_put(&ct->out, "m", 1); - } + if (!(fg & ATTR_DEFAULT)) + emit_color(ct, fg, 0); - /* background truecolor */ - if (!(bg & ATTR_DEFAULT)) { - buf_str(&ct->out, "\x1b[48;2;"); - buf_num(&ct->out, (bg >> 16) & 0xff); - buf_put(&ct->out, ";", 1); - buf_num(&ct->out, (bg >> 8) & 0xff); - buf_put(&ct->out, ";", 1); - buf_num(&ct->out, bg & 0xff); - buf_put(&ct->out, "m", 1); - } + if (!(bg & ATTR_DEFAULT)) + emit_color(ct, bg, 1); ct->lastfg = fg; ct->lastbg = bg; @@ -456,7 +523,7 @@ static int align64(int n) { return (n + 63) & ~63; } int clayterm_size(int w, int h) { int cell_count = w * h; int cell_bytes = cell_count * (int)sizeof(Cell); - int out_bytes = cell_count * OUT_BYTES_PER_CELL; + int out_bytes = cell_count * OUT_BYTES_PER_CELL + OUT_WRAP_BYTES; int clay_bytes = (int)Clay_MinMemorySize(); return align8((int)sizeof(struct Clayterm)) + align8(cell_bytes) /* front */ + align8(cell_bytes) /* back */ @@ -513,11 +580,11 @@ int error_message_ptr(struct Clayterm *ct, int index) { return (int)ct->errors[index].errorText.chars; } -struct Clayterm *init(void *mem, int w, int h) { +struct Clayterm *init(void *mem, int w, int h, struct TermInfo *ti) { struct Clayterm *ct = (struct Clayterm *)mem; int cell_count = w * h; int cell_bytes = align8(cell_count * (int)sizeof(Cell)); - int out_bytes = align8(cell_count * OUT_BYTES_PER_CELL); + int out_bytes = align8(cell_count * OUT_BYTES_PER_CELL + OUT_WRAP_BYTES); char *base = (char *)mem + align8((int)sizeof(struct Clayterm)); char *clay_mem = base + cell_bytes * 2 + out_bytes; @@ -532,13 +599,17 @@ struct Clayterm *init(void *mem, int w, int h) { .h = h, .front = (Cell *)base, .back = (Cell *)(base + cell_bytes), - .out = {base + cell_bytes * 2, 0, cell_count * OUT_BYTES_PER_CELL}, + .out = {base + cell_bytes * 2, 0, + cell_count * OUT_BYTES_PER_CELL + OUT_WRAP_BYTES}, .lastfg = 0xffffffff, .lastbg = 0xffffffff, .lastx = -1, .lasty = -1, }; + ct->ti = ti ? ti : terminfo_init(&ct->ti_private); + ct->ti_generation = ct->ti->generation; + // initialize back buffer with spaces and default fg/bg cells_fill(ct->back, w, h, ' ', ATTR_DEFAULT, ATTR_DEFAULT); @@ -713,6 +784,21 @@ void reduce(struct Clayterm *ct, uint32_t *buf, int len, int mode, int row, ct->clip_depth_exceeded = 0; ct->clipping = 0; + /* capability gating: pick the color encoding for this frame, and on a + * generation change invalidate the front buffer so the frame is + * emitted as a complete redraw under the new capabilities */ + if (ct->ti->flags & TERMINFO_TRUECOLOR) { + ct->depth = 0; + } else if (ct->ti->colors >= 256) { + ct->depth = 1; + } else { + ct->depth = 2; + } + if (ct->ti->generation != ct->ti_generation) { + ct->ti_generation = ct->ti->generation; + cells_fill(ct->front, ct->w, ct->h, 0, 0, 0); + } + cells_fill(ct->back, ct->w, ct->h, ' ', ATTR_DEFAULT, ATTR_DEFAULT); /* walk Clay render commands into back buffer */ @@ -799,7 +885,20 @@ void reduce(struct Clayterm *ct, uint32_t *buf, int len, int mode, int row, if (mode == 1) { present_lines(ct); } else { + /* synchronized output (renderer-spec 7.6): frame-scoped wrap, only + * when confirmed and only around non-empty output */ + int sync = (ct->ti->flags & TERMINFO_SYNC) != 0; + if (sync) + buf_str(&ct->out, "\x1b[?2026h"); + int start = ct->out.length; present_cups(ct, row); + if (sync) { + if (ct->out.length == start) { + ct->out.length = 0; + } else { + buf_str(&ct->out, "\x1b[?2026l"); + } + } } ct_active_context = NULL; diff --git a/src/clayterm.h b/src/clayterm.h index 8a24db4..ae822b8 100644 --- a/src/clayterm.h +++ b/src/clayterm.h @@ -6,12 +6,13 @@ #include #include "cell.h" +#include "terminfo.h" struct Clayterm; /* WASM exports */ int clayterm_size(int w, int h); -struct Clayterm *init(void *mem, int w, int h); +struct Clayterm *init(void *mem, int w, int h, struct TermInfo *ti); void reduce(struct Clayterm *ct, uint32_t *buf, int len, int mode, int row, float deltaTime); char *output(struct Clayterm *ct); diff --git a/src/input.c b/src/input.c index 1f6257c..d6fd9f7 100644 --- a/src/input.c +++ b/src/input.c @@ -13,12 +13,15 @@ */ #include "input.h" +#include "terminfo.h" #include "trie.h" #include "mem.h" #include "utf8.h" #define SCAN_BUFFER_SIZE 4096 #define MAX_EVENTS 128 +/* Longest capability query response we will buffer before giving up. */ +#define MAX_RESPONSE 1024 /* ── State ────────────────────────────────────────────────────────── */ @@ -30,6 +33,8 @@ struct InputState { struct InputEvent events[MAX_EVENTS]; int count; int trie_len; + struct TermInfo *ti; + struct TermInfo ti_private; Trie trie; }; @@ -689,6 +694,356 @@ static int parse_csi_legacy(struct InputState *st, struct InputEvent *ev) { return PARSE_OK; } +/* ── Capability query responses (terminfo-spec section 9) ─────────── */ + +/* Update a capability flag from a probe response: probe evidence sets + * the confirmed bit and overrides whatever the terminfo entry or + * environment granted. One generation bump per logical update. */ +static void ti_confirm(struct InputState *st, uint32_t bit, int on) { + struct TermInfo *ti = st->ti; + uint32_t flags = on ? (ti->flags | bit) : (ti->flags & ~bit); + uint32_t confirmed = ti->confirmed | bit; + if (flags == ti->flags && confirmed == ti->confirmed) + return; + ti->flags = flags; + ti->confirmed = confirmed; + ti->generation++; +} + +static void ti_theme(struct InputState *st, uint32_t bit, uint32_t *slot, + uint32_t color) { + struct TermInfo *ti = st->ti; + if ((ti->flags & bit) && (ti->confirmed & bit) && *slot == color) + return; + *slot = color; + ti->flags |= bit; + ti->confirmed |= bit; + ti->generation++; +} + +static int hexval(char c) { + if (c >= '0' && c <= '9') + return c - '0'; + if (c >= 'a' && c <= 'f') + return c - 'a' + 10; + if (c >= 'A' && c <= 'F') + return c - 'A' + 10; + return -1; +} + +/* Scale a 1-4 hex digit channel to 8-bit (rounded). */ +static int color_channel(const char *s, int n, uint32_t *out) { + if (n < 1 || n > 4) + return 0; + int v = 0; + for (int i = 0; i < n; i++) { + int h = hexval(s[i]); + if (h < 0) + return 0; + v = v * 16 + h; + } + int max = (1 << (4 * n)) - 1; + *out = (uint32_t)((v * 255 + max / 2) / max); + return 1; +} + +/* Parse an OSC color payload: X11 "rgb:R/G/B" (1-4 hex digits per + * channel, "rgba:" alpha ignored) or "#"-hash with equal-width + * channels. Returns 0 when no color is recognized. */ +static int parse_color_spec(const char *s, int len, uint32_t *out) { + if (len >= 4 && s[0] == 'r' && s[1] == 'g' && s[2] == 'b') { + int i = 3; + if (i < len && s[i] == 'a') + i++; + if (i >= len || s[i] != ':') + return 0; + i++; + uint32_t ch[3]; + for (int c = 0; c < 3; c++) { + int start = i; + while (i < len && hexval(s[i]) >= 0) + i++; + if (!color_channel(s + start, i - start, &ch[c])) + return 0; + if (c < 2) { + if (i >= len || s[i] != '/') + return 0; + i++; + } + } + *out = (ch[0] << 16) | (ch[1] << 8) | ch[2]; + return 1; + } + if (len >= 4 && s[0] == '#') { + int n = 0; + while (1 + n < len && hexval(s[1 + n]) >= 0) + n++; + if (n % 3 != 0) + return 0; + int w = n / 3; + uint32_t ch[3]; + for (int c = 0; c < 3; c++) { + if (!color_channel(s + 1 + c * w, w, &ch[c])) + return 0; + } + *out = (ch[0] << 16) | (ch[1] << 8) | ch[2]; + return 1; + } + return 0; +} + +static int payload_contains(const char *s, int len, const char *needle) { + int n = (int)strlen(needle); + for (int i = 0; i + n <= len; i++) { + int j = 0; + while (j < n && s[i + j] == needle[j]) + j++; + if (j == n) + return 1; + } + return 0; +} + +/* Find a BEL or ST terminator from `start`. Returns the index one past + * the terminator, 0 when more bytes are needed, or -1 when the + * response exceeds MAX_RESPONSE. */ +static int find_st(struct InputState *st, int start) { + for (int i = start; i < st->len; i++) { + char c = st->buf[i]; + if (c == '\x07') + return i + 1; + if (c == '\x1b') { + if (i + 1 >= st->len) + return 0; + if (st->buf[i + 1] == '\\') + return i + 2; + return -1; + } + if (i - start > MAX_RESPONSE) + return -1; + } + return 0; +} + +/* OSC 21 payload: ";"-separated key=value pairs. */ +static void osc21_apply(struct InputState *st, const char *s, int len) { + ti_confirm(st, TERMINFO_KITTY_COLOR, 1); + int i = 0; + while (i < len) { + int start = i; + while (i < len && s[i] != ';') + i++; + int eq = start; + while (eq < i && s[eq] != '=') + eq++; + if (eq < i) { + const char *key = s + start; + int klen = eq - start; + const char *val = s + eq + 1; + int vlen = i - eq - 1; + uint32_t color; + if (vlen > 0 && parse_color_spec(val, vlen, &color)) { + if (klen == 10 && payload_contains(key, klen, "foreground")) { + ti_theme(st, TERMINFO_THEME_FG, &st->ti->theme_fg, color); + } else if (klen == 10 && payload_contains(key, klen, "background")) { + ti_theme(st, TERMINFO_THEME_BG, &st->ti->theme_bg, color); + } else if (klen == 6 && payload_contains(key, klen, "cursor")) { + ti_theme(st, TERMINFO_THEME_CURSOR, &st->ti->theme_cursor, color); + } + } + } + if (i < len) + i++; + } +} + +/* OSC 10/11/12 theme color reports, OSC 21 kitty color, OSC 22 kitty + * pointer shape. Other OSC numbers are not consumed. */ +static int parse_osc_response(struct InputState *st) { + int i = 2; + int num = -1; + while (i < st->len && st->buf[i] >= '0' && st->buf[i] <= '9') { + num = (num == -1 ? 0 : num) * 10 + (st->buf[i] - '0'); + if (num > 22) + return PARSE_ERR; + i++; + } + if (i >= st->len) + return PARSE_NEED_MORE; + if (st->buf[i] != ';') + return PARSE_ERR; + if (num != 10 && num != 11 && num != 12 && num != 21 && num != 22) + return PARSE_ERR; + i++; + + int end = find_st(st, i); + if (end == 0) + return PARSE_NEED_MORE; + if (end < 0) + return PARSE_ERR; + + int plen = end - i; + if (st->buf[end - 1] == '\x07') { + plen -= 1; + } else { + plen -= 2; + } + const char *payload = st->buf + i; + + uint32_t color; + switch (num) { + case 10: + if (parse_color_spec(payload, plen, &color)) + ti_theme(st, TERMINFO_THEME_FG, &st->ti->theme_fg, color); + break; + case 11: + if (parse_color_spec(payload, plen, &color)) + ti_theme(st, TERMINFO_THEME_BG, &st->ti->theme_bg, color); + break; + case 12: + if (parse_color_spec(payload, plen, &color)) + ti_theme(st, TERMINFO_THEME_CURSOR, &st->ti->theme_cursor, color); + break; + case 21: + osc21_apply(st, payload, plen); + break; + case 22: + ti_confirm(st, TERMINFO_POINTER_SHAPE, 1); + break; + } + + shift(st, end); + return PARSE_OK; +} + +/* XTGETTCAP reply: DCS 1 + r … ST (valid) or DCS 0 + r … ST (invalid). + * We only query RGB (524742) and Tc (5463), so a valid reply naming + * either confirms truecolor; an invalid reply denies it. */ +static int parse_dcs_response(struct InputState *st) { + if (st->len < 5) + return PARSE_NEED_MORE; + char ok = st->buf[2]; + if ((ok != '0' && ok != '1') || st->buf[3] != '+' || st->buf[4] != 'r') + return PARSE_ERR; + + int end = find_st(st, 5); + if (end == 0) + return PARSE_NEED_MORE; + if (end < 0) + return PARSE_ERR; + + const char *payload = st->buf + 5; + int plen = end - 5 - (st->buf[end - 1] == '\x07' ? 1 : 2); + if (ok == '1') { + if (payload_contains(payload, plen, "524742") || + payload_contains(payload, plen, "5463")) { + ti_confirm(st, TERMINFO_TRUECOLOR, 1); + } + } else { + ti_confirm(st, TERMINFO_TRUECOLOR, 0); + } + + shift(st, end); + return PARSE_OK; +} + +/* Kitty graphics reply: APC _G … ST with ";OK" on success. */ +static int parse_apc_response(struct InputState *st) { + if (st->len < 3) + return PARSE_NEED_MORE; + if (st->buf[2] != 'G') + return PARSE_ERR; + + int end = find_st(st, 3); + if (end == 0) + return PARSE_NEED_MORE; + if (end < 0) + return PARSE_ERR; + + const char *payload = st->buf + 3; + int plen = end - 3 - (st->buf[end - 1] == '\x07' ? 1 : 2); + ti_confirm(st, TERMINFO_KITTY_GRAPHICS, + payload_contains(payload, plen, ";OK")); + + shift(st, end); + return PARSE_OK; +} + +/* CSI ? … replies: kitty keyboard flags (final 'u'), DECRPM (final 'y' + * with '$' intermediate), DA1 device attributes (final 'c'). */ +static int parse_csi_private(struct InputState *st) { + if (st->len < 3) + return PARSE_NEED_MORE; + if (st->buf[2] != '?') + return PARSE_ERR; + + int nums[4] = {-1, -1, -1, -1}; + int ni = 0; + int cur = -1; + char intermediate = 0; + int i = 3; + + while (i < st->len) { + char c = st->buf[i]; + if (c >= '0' && c <= '9') { + if (cur == -1) + cur = 0; + cur = cur * 10 + (c - '0'); + } else if (c == ';' || c == ':') { + if (ni < 4) + nums[ni++] = cur; + cur = -1; + } else if (c >= 0x20 && c <= 0x2f) { + intermediate = c; + } else if (c >= 0x40 && c <= 0x7e) { + if (ni < 4) + nums[ni++] = cur; + i++; + + if (c == 'u' && intermediate == 0) { + ti_confirm(st, TERMINFO_KITTY_KEYBOARD, 1); + } else if (c == 'y' && intermediate == '$') { + if (nums[0] == 2026 && ni >= 2) { + int v = nums[1]; + ti_confirm(st, TERMINFO_SYNC, v == 1 || v == 2 || v == 3); + } + /* other modes: consumed silently */ + } else if (c == 'c' && intermediate == 0) { + if (!(st->ti->confirmed & TERMINFO_DA1)) { + st->ti->confirmed |= TERMINFO_DA1; + st->ti->generation++; + } + } else { + return PARSE_ERR; + } + + shift(st, i); + return PARSE_OK; + } else { + return PARSE_ERR; + } + i++; + } + return PARSE_NEED_MORE; +} + +static int parse_response(struct InputState *st) { + if (st->len < 2) + return PARSE_NEED_MORE; + switch (st->buf[1]) { + case ']': + return parse_osc_response(st); + case 'P': + return parse_dcs_response(st); + case '_': + return parse_apc_response(st); + case '[': + return parse_csi_private(st); + default: + return PARSE_ERR; + } +} + /* ── Cap table (xterm defaults) ───────────────────────────────────── */ struct CapEntry { @@ -927,15 +1282,63 @@ static const struct CapEntry mod_caps[] = { /* ── Public API ───────────────────────────────────────────────────── */ +/* terminfo key_* string capability indices (ncurses Caps) mapped to + * KEY_* codes. Indices verified against tic output. */ +struct KeyCap { + uint16_t index; + uint16_t key; +}; + +static const struct KeyCap key_caps[] = { + {59, KEY_DELETE}, /* kdch1 */ + {61, KEY_ARROW_DOWN}, /* kcud1 */ + {66, KEY_F1}, /* kf1 */ + {67, KEY_F10}, /* kf10 */ + {68, KEY_F2}, /* kf2 */ + {69, KEY_F3}, /* kf3 */ + {70, KEY_F4}, /* kf4 */ + {71, KEY_F5}, /* kf5 */ + {72, KEY_F6}, /* kf6 */ + {73, KEY_F7}, /* kf7 */ + {74, KEY_F8}, /* kf8 */ + {75, KEY_F9}, /* kf9 */ + {76, KEY_HOME}, /* khome */ + {77, KEY_INSERT}, /* kich1 */ + {79, KEY_ARROW_LEFT}, /* kcub1 */ + {81, KEY_PGDN}, /* knp */ + {82, KEY_PGUP}, /* kpp */ + {83, KEY_ARROW_RIGHT}, /* kcuf1 */ + {87, KEY_ARROW_UP}, /* kcuu1 */ + {148, KEY_BACKTAB}, /* kcbt */ + {164, KEY_END}, /* kend */ + {216, KEY_F11}, /* kf11 */ + {217, KEY_F12}, /* kf12 */ + {0, 0}, +}; + int input_size(void) { return align8((int)sizeof(struct InputState)); } -struct InputState *input_init(void *mem, int esc_latency_ms) { +struct InputState *input_init(void *mem, int esc_latency_ms, + const uint8_t *terminfo, int terminfo_len, + struct TermInfo *ti) { struct InputState *st = (struct InputState *)mem; memset(st, 0, sizeof(struct InputState)); st->esc_latency_ms = esc_latency_ms; + st->ti = ti ? ti : terminfo_init(&st->ti_private); - /* build escape sequence trie from cap tables */ + /* build escape sequence trie: terminfo keys first (first writer wins + * in trie_add, so the entry's sequences take precedence), then the + * xterm defaults for anything the entry does not define */ trie_init(st->trie, &st->trie_len); + if (terminfo && terminfo_len > 0) { + for (int i = 0; key_caps[i].key; i++) { + int n = 0; + const char *seq = + terminfo_str(terminfo, terminfo_len, key_caps[i].index, &n); + if (seq && n > 0) + trie_add(st->trie, &st->trie_len, seq, n, key_caps[i].key, 0); + } + } for (int i = 0; base_caps[i].seq; i++) trie_add(st->trie, &st->trie_len, base_caps[i].seq, strlen(base_caps[i].seq), base_caps[i].key, base_caps[i].mod); @@ -1061,6 +1464,19 @@ int input_scan(struct InputState *st, const char *buf, int len, double now) { } } + /* try capability query responses (OSC/DCS/APC/CSI ?) — consumed + * silently into the capability struct, never surfaced as events */ + { + int rv = parse_response(st); + if (rv == PARSE_OK) { + st->esc_time = 0; + continue; + } + if (rv == PARSE_NEED_MORE) { + return accepted; + } + } + /* unrecognized ESC sequence: treat as Alt + next byte */ shift(st, 1); st->esc_time = 0; diff --git a/src/input.h b/src/input.h index c38404e..cd0698f 100644 --- a/src/input.h +++ b/src/input.h @@ -8,7 +8,8 @@ * Usage: * 1. input does not allocate any memory itself, so start by allocating * input_size() bytes of memory. - * 2. Call input_init(mem, esc_latency_ms) to get an InputState. + * 2. Call input_init(mem, esc_latency_ms, terminfo, len, ti) to get + * an InputState (terminfo and ti may be NULL for xterm defaults). * 3. When bytes arrive from stdin, call input_scan(st, buf, len, now). * 4. Read events with input_count(st) and input_event(st, i). * 5. Check input_delay(st): if non-zero, re-call input_scan() with @@ -18,7 +19,7 @@ * Example: * * void *mem = malloc(input_size()); - * struct InputState *st = input_init(mem, 50); + * struct InputState *st = input_init(mem, 50, NULL, 0, NULL); * * // in your event loop: * int accepted = input_scan(st, buf, nread, now_ms); @@ -53,6 +54,8 @@ #include +#include "terminfo.h" + /* ── Event types ──────────────────────────────────────────────────── */ #define EVENT_KEY 1 @@ -212,9 +215,18 @@ int input_size(void); * * @param mem Pointer to at least input_size() bytes. * @param esc_latency_ms ESC disambiguation latency in milliseconds. + * @param terminfo Raw compiled terminfo entry whose key_* string + * capabilities seed the sequence trie (they take + * precedence over the xterm defaults), or NULL. + * @param terminfo_len Byte length of terminfo, or 0. + * @param ti Shared capability struct that recognized query + * responses are written into, or NULL to use a + * parser-private struct. * @return Initialized parser state. */ -struct InputState *input_init(void *mem, int esc_latency_ms); +struct InputState *input_init(void *mem, int esc_latency_ms, + const uint8_t *terminfo, int terminfo_len, + struct TermInfo *ti); /** * Feed raw bytes into the parser and produce events. diff --git a/src/module.c b/src/module.c index bca0757..5438e13 100644 --- a/src/module.c +++ b/src/module.c @@ -3,6 +3,7 @@ #include "../clay/clay.h" #include "mem.c" +#include "terminfo.c" #include "buffer.c" #include "cell.c" #include "utf8.c" diff --git a/src/terminfo.c b/src/terminfo.c new file mode 100644 index 0000000..73e0189 --- /dev/null +++ b/src/terminfo.c @@ -0,0 +1,271 @@ +/* terminfo.c — shared terminal capability layer */ + +#include "terminfo.h" + +#include "mem.h" + +#define TI_MAGIC_LEGACY 0x011a +#define TI_MAGIC_EXTENDED 0x021e + +/* Standard capability indices (ncurses Caps). */ +#define TI_BOOL_AM 1 +#define TI_BOOL_XENL 4 +#define TI_BOOL_BCE 28 +#define TI_NUM_MAX_COLORS 13 +#define TI_STR_SMCUP 28 + +int terminfo_size(void) { return align8(sizeof(struct TermInfo)); } + +struct TermInfo *terminfo_init(void *mem) { + struct TermInfo *ti = (struct TermInfo *)mem; + ti->generation = 1; + ti->colors = 256; + ti->flags = TERMINFO_BCE | TERMINFO_AM | TERMINFO_XENL | TERMINFO_ALTSCREEN; + ti->confirmed = 0; + ti->theme_fg = 0; + ti->theme_bg = 0; + ti->theme_cursor = 0; + return ti; +} + +void terminfo_grant(struct TermInfo *ti, uint32_t flags) { + if ((ti->flags & flags) == flags) + return; + ti->flags |= flags; + ti->generation++; +} + +static uint16_t rd_u16(const uint8_t *b, int off) { + return (uint16_t)(b[off] | (b[off + 1] << 8)); +} + +/* Signed terminfo number: -1 = absent, -2 = cancelled. */ +static int32_t rd_num(const uint8_t *b, int off, int width) { + if (width == 2) { + uint16_t v = rd_u16(b, off); + if (v == 0xffff) + return -1; + if (v == 0xfffe) + return -2; + return (int32_t)v; + } + uint32_t v = (uint32_t)b[off] | ((uint32_t)b[off + 1] << 8) | + ((uint32_t)b[off + 2] << 16) | ((uint32_t)b[off + 3] << 24); + return (int32_t)v; +} + +static int rd_i16(const uint8_t *b, int off) { + uint16_t v = rd_u16(b, off); + if (v == 0xffff) + return -1; + if (v == 0xfffe) + return -2; + return (int)v; +} + +static int ti_strnlen(const uint8_t *b, int max) { + int n = 0; + while (n < max && b[n]) + n++; + return n; +} + +static int name_is(const uint8_t *table, int table_len, int off, + const char *name) { + if (off < 0 || off >= table_len) + return 0; + int i = 0; + while (name[i]) { + if (off + i >= table_len || table[off + i] != (uint8_t)name[i]) + return 0; + i++; + } + return off + i < table_len && table[off + i] == 0; +} + +/* Extended capability block (after the standard sections). Returns 0 on + * success, nonzero when the declared structure runs out of bounds. + * Grants flag bits into *flags for RGB/Tc/Su booleans and Smulx. */ +static int parse_ext(const uint8_t *b, int len, int off, int numw, + uint32_t *flags) { + if (off & 1) + off++; + if (off + 10 > len) + return 0; /* no extended block */ + + int eb = rd_i16(b, off); + int en = rd_i16(b, off + 2); + int es = rd_i16(b, off + 4); + int table_strings = rd_i16(b, off + 6); + int table_len = rd_i16(b, off + 8); + if (eb < 0 || en < 0 || es < 0 || table_strings < 0 || table_len < 0) + return 1; + + int bools_off = off + 10; + int nums_off = bools_off + eb; + if (nums_off & 1) + nums_off++; + int offsets_off = nums_off + en * numw; + int name_count = eb + en + es; + int names_off = offsets_off + es * 2; + int table_off = names_off + name_count * 2; + if (table_off + table_len > len) + return 1; + + const uint8_t *table = b + table_off; + + /* Value strings sit at the head of the table; names follow. Name + * offsets are relative to the start of the names sub-table. */ + int names_base = 0; + for (int i = 0; i < es; i++) { + int v = rd_i16(b, offsets_off + i * 2); + if (v < 0) + continue; + if (v >= table_len) + return 1; + int end = v + ti_strnlen(table + v, table_len - v) + 1; + if (end > names_base) + names_base = end; + } + + for (int i = 0; i < name_count; i++) { + int noff = rd_i16(b, names_off + i * 2); + if (noff < 0) + continue; + noff += names_base; + + if (i < eb) { + if (!b[bools_off + i]) + continue; + if (name_is(table, table_len, noff, "Tc") || + name_is(table, table_len, noff, "RGB")) { + *flags |= TERMINFO_TRUECOLOR; + } else if (name_is(table, table_len, noff, "Su")) { + *flags |= TERMINFO_STYLED_UNDERLINE; + } + } else if (i >= eb + en) { + int v = rd_i16(b, offsets_off + (i - eb - en) * 2); + if (v < 0) + continue; + if (name_is(table, table_len, noff, "Smulx")) { + *flags |= TERMINFO_STYLED_UNDERLINE; + } + } + } + + return 0; +} + +const char *terminfo_str(const uint8_t *bytes, int len, int index, + int *out_len) { + if (!bytes || len < 12 || index < 0) + return 0; + uint16_t magic = rd_u16(bytes, 0); + int numw; + if (magic == TI_MAGIC_LEGACY) { + numw = 2; + } else if (magic == TI_MAGIC_EXTENDED) { + numw = 4; + } else { + return 0; + } + + int name_size = rd_i16(bytes, 2); + int bool_count = rd_i16(bytes, 4); + int num_count = rd_i16(bytes, 6); + int str_count = rd_i16(bytes, 8); + int table_len = rd_i16(bytes, 10); + if (name_size < 0 || bool_count < 0 || num_count < 0 || str_count < 0 || + table_len < 0) + return 0; + if (index >= str_count) + return 0; + + int nums_off = 12 + name_size + bool_count; + if (nums_off & 1) + nums_off++; + int strs_off = nums_off + num_count * numw; + int table_off = strs_off + str_count * 2; + if (table_off + table_len > len) + return 0; + + int v = rd_i16(bytes, strs_off + index * 2); + if (v < 0 || v >= table_len) + return 0; + int n = ti_strnlen(bytes + table_off + v, table_len - v); + if (n == 0) + return 0; + if (out_len) + *out_len = n; + return (const char *)(bytes + table_off + v); +} + +int terminfo_parse(const uint8_t *bytes, int len, struct TermInfo *ti) { + if (len < 12) + return 1; + + uint16_t magic = rd_u16(bytes, 0); + int numw; + if (magic == TI_MAGIC_LEGACY) { + numw = 2; + } else if (magic == TI_MAGIC_EXTENDED) { + numw = 4; + } else { + return 2; + } + + int name_size = rd_i16(bytes, 2); + int bool_count = rd_i16(bytes, 4); + int num_count = rd_i16(bytes, 6); + int str_count = rd_i16(bytes, 8); + int table_len = rd_i16(bytes, 10); + if (name_size < 0 || bool_count < 0 || num_count < 0 || str_count < 0 || + table_len < 0) + return 3; + + int bools_off = 12 + name_size; + int nums_off = bools_off + bool_count; + if (nums_off & 1) + nums_off++; + int strs_off = nums_off + num_count * numw; + int table_off = strs_off + str_count * 2; + int end = table_off + table_len; + if (end > len) + return 4; + + uint32_t flags = 0; + if (bool_count > TI_BOOL_AM && bytes[bools_off + TI_BOOL_AM]) + flags |= TERMINFO_AM; + if (bool_count > TI_BOOL_XENL && bytes[bools_off + TI_BOOL_XENL]) + flags |= TERMINFO_XENL; + if (bool_count > TI_BOOL_BCE && bytes[bools_off + TI_BOOL_BCE]) + flags |= TERMINFO_BCE; + + int32_t colors = 0; + if (num_count > TI_NUM_MAX_COLORS) { + int32_t v = rd_num(bytes, nums_off + TI_NUM_MAX_COLORS * numw, numw); + if (v > 0) + colors = v; + } + if (colors >= (1 << 24)) + flags |= TERMINFO_TRUECOLOR; + + if (str_count > TI_STR_SMCUP) { + int v = rd_i16(bytes, strs_off + TI_STR_SMCUP * 2); + if (v >= 0 && v < table_len) + flags |= TERMINFO_ALTSCREEN; + } + + if (parse_ext(bytes, len, end, numw, &flags)) + return 5; + + /* The entry describes the terminal completely for the capabilities it + * owns: replace them, leave probe-only flags and theme fields alone. */ + uint32_t keep = + ~(TERMINFO_TRUECOLOR | TERMINFO_BCE | TERMINFO_AM | TERMINFO_XENL | + TERMINFO_ALTSCREEN | TERMINFO_STYLED_UNDERLINE); + ti->flags = (ti->flags & keep) | flags; + ti->colors = (uint32_t)colors; + ti->generation++; + return 0; +} diff --git a/src/terminfo.h b/src/terminfo.h new file mode 100644 index 0000000..220059a --- /dev/null +++ b/src/terminfo.h @@ -0,0 +1,100 @@ +/* terminfo.h — shared terminal capability layer + * + * Implements the capability struct and terminfo binary parsing defined + * by specs/terminfo-spec.md. The renderer reads the struct; the input + * parser writes probe responses into it; this module owns the baseline + * and the parse path. + */ + +#ifndef TERMINFO_H +#define TERMINFO_H + +#include + +/* Capability flag bits (terminfo-spec section 6). */ +#define TERMINFO_TRUECOLOR (1u << 0) +#define TERMINFO_BCE (1u << 1) +#define TERMINFO_AM (1u << 2) +#define TERMINFO_XENL (1u << 3) +#define TERMINFO_ALTSCREEN (1u << 4) +#define TERMINFO_STYLED_UNDERLINE (1u << 5) +#define TERMINFO_SYNC (1u << 6) +#define TERMINFO_KITTY_KEYBOARD (1u << 7) +#define TERMINFO_KITTY_GRAPHICS (1u << 8) +#define TERMINFO_KITTY_COLOR (1u << 9) +#define TERMINFO_HYPERLINKS (1u << 10) +#define TERMINFO_POINTER_SHAPE (1u << 11) +#define TERMINFO_THEME_FG (1u << 12) +#define TERMINFO_THEME_BG (1u << 13) +#define TERMINFO_THEME_CURSOR (1u << 14) + +/* Probe-fence marker: set in `confirmed` (never in `flags`) when a DA1 + * device attributes report is recognized. The queryTermInfo probe + * window uses it to detect completion. */ +#define TERMINFO_DA1 (1u << 31) + +struct TermInfo { + uint32_t generation; + uint32_t colors; + uint32_t flags; + uint32_t confirmed; + uint32_t theme_fg; + uint32_t theme_bg; + uint32_t theme_cursor; +}; + +/** + * Return the number of bytes needed to hold a TermInfo struct. + */ +int terminfo_size(void); + +/** + * Initialize a capability struct to the xterm-256color baseline + * (terminfo-spec section 7.1). Generation starts at 1. + * + * @param mem Pointer to at least terminfo_size() bytes. + * @return The initialized struct. + */ +struct TermInfo *terminfo_init(void *mem); + +/** + * Parse a compiled terminfo entry into a capability struct. + * + * Supports the legacy (0432) and extended number (01036) storage + * formats, including the extended capability table (RGB, Tc, Su, + * Smulx). All reads are bounds-checked. On any malformed input the + * struct is left untouched (all-or-nothing, TINV-3). + * + * On success the entry's standard capabilities replace the baseline: + * booleans absent from the entry are cleared, colors becomes the + * entry's max_colors (0 when the entry does not define it), and the + * generation is bumped once. + * + * @param bytes Compiled terminfo entry. + * @param len Byte length. + * @param ti Struct to populate. + * @return 0 on success, nonzero parse-result code on failure. + */ +int terminfo_parse(const uint8_t *bytes, int len, struct TermInfo *ti); + +/** + * Grant capability flag bits from evidence collected outside the + * parser (environment evidence at handle creation, e.g. COLORTERM). + * Bumps the generation only when the flags actually change. + */ +void terminfo_grant(struct TermInfo *ti, uint32_t flags); + +/** + * Look up a standard string capability in a compiled terminfo entry. + * Bounds-checked against len; works for both storage formats. + * + * @param bytes Compiled terminfo entry. + * @param len Byte length. + * @param index Standard string capability index (ncurses Caps). + * @param out_len Receives the string length (excluding NUL). + * @return Pointer into bytes, or NULL when absent/malformed. + */ +const char *terminfo_str(const uint8_t *bytes, int len, int index, + int *out_len); + +#endif diff --git a/tasks/gen-fixtures.ts b/tasks/gen-fixtures.ts new file mode 100644 index 0000000..f03116c --- /dev/null +++ b/tasks/gen-fixtures.ts @@ -0,0 +1,155 @@ +/** + * Regenerates test/fixtures.ts: compiled terminfo entries embedded as + * base64 so the test suite needs no filesystem or tic at run time. + * + * Usage: deno run -A tasks/gen-fixtures.ts + * + * Sources: + * - xterm-256color copied from the system terminfo database (legacy + * format, magic 0432): real-world entry with am/xenl/bce/smcup and + * colors#256, no truecolor capabilities. + * - clayterm-tc / clayterm-16 compiled with tic -x from the inline + * source below (legacy format with an extended capability block). + * - clayterm-direct hand-assembled in the extended number format + * (magic 01036, 32-bit numbers) because macOS ships ncurses 6.0, + * which predates that format: colors#0x1000000 plus an extended RGB + * boolean, mirroring xterm-direct. + */ + +import { encodeBase64 } from "@std/encoding/base64"; + +const ENTRIES_SRC = `clayterm-tc|clayterm truecolor extended-caps test entry, +\tam, xenl, bce, Tc, Su, +\tcolors#256, pairs#65536, cols#80, lines#24, +\tsmcup=\\E[?1049h, rmcup=\\E[?1049l, +\tcup=\\E[%i%p1%d;%p2%dH, +\tkcuu1=\\EOZ, kf5=\\E[99~, +\tSmulx=\\E[4:%p1%dm, + +clayterm-16|clayterm sixteen color test entry, +\tam, xenl, +\tcolors#16, pairs#256, cols#80, lines#24, +\tcup=\\E[%i%p1%d;%p2%dH, +`; + +async function tic(src: string): Promise> { + let dir = await Deno.makeTempDir(); + await Deno.writeTextFile(`${dir}/entries.src`, src); + let out = await new Deno.Command("tic", { + args: ["-x", "-o", `${dir}/db`, `${dir}/entries.src`], + }).output(); + if (!out.success) { + throw new Error(`tic failed: ${new TextDecoder().decode(out.stderr)}`); + } + let entries: Record = {}; + // tic stores under a two-hex-digit dir on macOS ("63" for 'c'). + for await (let f of Deno.readDir(`${dir}/db/63`)) { + entries[f.name] = await Deno.readFile(`${dir}/db/63/${f.name}`); + } + return entries; +} + +/** Little-endian int16 writer. */ +function i16(view: DataView, offset: number, value: number): void { + view.setInt16(offset, value, true); +} + +/** + * Hand-assemble a minimal extended-number-format entry (magic 01036, + * 32-bit numbers): am, xenl, colors#0x1000000, smcup, plus an extended + * boolean RGB. Layout verified against tic 6.0 output for the legacy + * sections and ncurses term(5) for the 32-bit number width. + */ +function buildDirect(): Uint8Array { + let name = "clayterm-direct|clayterm extended number format test entry\0"; + let bools = new Uint8Array(29); // through bce (index 28) + bools[1] = 1; // am + bools[4] = 1; // xenl + let numCount = 14; // through colors (index 13) + let nums = new Int32Array(numCount).fill(-1); + nums[0] = 80; // cols + nums[2] = 24; // lines + nums[13] = 0x1000000; // colors + let strCount = 29; // through smcup (index 28) + let strs = new Int16Array(strCount).fill(-1); + let table = "\x1b[?1049h\0"; + strs[28] = 0; // smcup + + // Extended block: one boolean capability, RGB=1. + // Header, values, value/name offset table, then the string table + // (value strings first, names after; name offsets are relative to + // the start of the names sub-table). + let extName = "RGB\0"; + + let nameBytes = new TextEncoder().encode(name); + let tableBytes = new TextEncoder().encode(table); + let extNameBytes = new TextEncoder().encode(extName); + + let size = 12 + nameBytes.length + bools.length; + if (size % 2) size += 1; + size += numCount * 4 + strCount * 2 + tableBytes.length; + if (size % 2) size += 1; + size += 10 + 1 + 1 + 1 * 2 + extNameBytes.length; + + let buf = new Uint8Array(size); + let view = new DataView(buf.buffer); + let o = 0; + i16(view, 0, 0x021e); // magic + i16(view, 2, nameBytes.length); + i16(view, 4, bools.length); + i16(view, 6, numCount); + i16(view, 8, strCount); + i16(view, 10, tableBytes.length); + o = 12; + buf.set(nameBytes, o); + o += nameBytes.length; + buf.set(bools, o); + o += bools.length; + if (o % 2) o += 1; + for (let i = 0; i < numCount; i++) { + view.setInt32(o + i * 4, nums[i], true); + } + o += numCount * 4; + for (let i = 0; i < strCount; i++) { + i16(view, o + i * 2, strs[i]); + } + o += strCount * 2; + buf.set(tableBytes, o); + o += tableBytes.length; + if (o % 2) o += 1; + i16(view, o, 1); // ext bool count + i16(view, o + 2, 0); // ext num count + i16(view, o + 4, 0); // ext str count + i16(view, o + 6, 1); // ext table string count (names only) + i16(view, o + 8, extNameBytes.length); // ext table size + o += 10; + buf[o] = 1; // RGB = true + o += 1; + if (o % 2) o += 1; + i16(view, o, 0); // name offset (relative to names sub-table) + o += 2; + buf.set(extNameBytes, o); + return buf; +} + +let compiled = await tic(ENTRIES_SRC); +let xterm256 = await Deno.readFile("/usr/share/terminfo/78/xterm-256color"); + +function constant(name: string, bytes: Uint8Array): string { + return `export const ${name}: Uint8Array = decodeBase64(\n` + + ` "${encodeBase64(bytes)}",\n);\n`; +} + +let out = `// Generated by tasks/gen-fixtures.ts — do not edit by hand. +import { decodeBase64 } from "@std/encoding/base64"; + +${constant("XTERM_256COLOR", xterm256)} +${constant("CLAYTERM_TC", compiled["clayterm-tc"])} +${constant("CLAYTERM_16", compiled["clayterm-16"])} +${constant("CLAYTERM_DIRECT", buildDirect())}`; + +await Deno.writeTextFile( + new URL("../test/fixtures.ts", import.meta.url).pathname, + out, +); +console.log("wrote test/fixtures.ts"); diff --git a/term-native.ts b/term-native.ts index 8bb58f4..e84a6ea 100644 --- a/term-native.ts +++ b/term-native.ts @@ -50,42 +50,62 @@ export interface Native { import { compiled } from "./wasm.ts"; +/** + * Attachment surface provided by a TermInfo handle: the shared memory, + * its bump allocator, and the capability struct pointer. See + * terminfo.ts internals(). + */ +export interface TermAttach { + memory: WebAssembly.Memory; + exports: Record; + structPtr: number; + alloc(size: number, align?: number): number; +} + export async function createTermNative( w: number, h: number, + attach?: TermAttach, ): Promise { - let memory = new WebAssembly.Memory({ initial: 2 }); + let memory = attach?.memory ?? new WebAssembly.Memory({ initial: 2 }); let exports: Record = {}; - let instance = await WebAssembly.instantiate(compiled, { - env: { memory }, - clay: { - measureTextFunction( - ret: number, - text: number, - _config: number, - _userData: number, - ) { - exports.measure(ret, text); - }, - queryScrollOffsetFunction( - ret: number, - _elementId: number, - _userData: number, - ) { - let view = new DataView(memory.buffer); - view.setFloat32(ret, 0, true); - view.setFloat32(ret + 4, 0, true); + if (attach) { + // Reuse the handle's instance: instantiating the module again over + // the shared memory would rewrite its data segments and clobber + // static state already initialized there. + Object.assign(exports, attach.exports); + } else { + let instance = await WebAssembly.instantiate(compiled, { + env: { memory }, + clay: { + measureTextFunction( + ret: number, + text: number, + _config: number, + _userData: number, + ) { + exports.measure(ret, text); + }, + queryScrollOffsetFunction( + ret: number, + _elementId: number, + _userData: number, + ) { + let view = new DataView(memory.buffer); + view.setFloat32(ret, 0, true); + view.setFloat32(ret + 4, 0, true); + }, }, - }, - }); + }); - Object.assign(exports, instance.exports); + Object.assign(exports, instance.exports); + } let ct = exports as unknown as { __heap_base: WebAssembly.Global; clayterm_size(w: number, h: number): number; - init(mem: number, w: number, h: number): number; + init(mem: number, w: number, h: number, ti: number): number; reduce( ct: number, buf: number, @@ -108,24 +128,32 @@ export async function createTermNative( error_message_ptr(ct: number, index: number): number; }; - let heap = ct.__heap_base.value as number; let size = ct.clayterm_size(w, h); - // Grow memory once to fit heap + renderer state + fixed transfer buffer. // The transfer budget is intentionally fixed: text/id/snapshot payload bytes // get 1MB, and fixed op overhead gets one max-sized element per Clay element. // Do not grow this dynamically per render; improve the wire format instead. let transferBytes = TEXT_TRANSFER_BUFFER_BYTES + CLAY_DEFAULT_MAX_ELEMENT_COUNT * MAX_FIXED_ELEMENT_WIRE_BYTES; - let needed = heap + size + transferBytes; - let pages = Math.ceil(needed / WASM_PAGE_BYTES); - let current = memory.buffer.byteLength / WASM_PAGE_BYTES; - if (pages > current) { - memory.grow(pages - current); - } - let statePtr = ct.init(heap, w, h); - let opsBuf = (heap + size + 3) & ~3; + let statePtr: number; + let opsBuf: number; + if (attach) { + let arena = attach.alloc(size); + opsBuf = attach.alloc(transferBytes, 4); + statePtr = ct.init(arena, w, h, attach.structPtr); + } else { + // Grow memory once to fit heap + renderer state + fixed transfer buffer. + let heap = ct.__heap_base.value as number; + let needed = heap + size + transferBytes; + let pages = Math.ceil(needed / WASM_PAGE_BYTES); + let current = memory.buffer.byteLength / WASM_PAGE_BYTES; + if (pages > current) { + memory.grow(pages - current); + } + statePtr = ct.init(heap, w, h, 0); + opsBuf = (heap + size + 3) & ~3; + } return { memory, diff --git a/term.ts b/term.ts index 3b8ddef..b1f0bf2 100644 --- a/term.ts +++ b/term.ts @@ -1,9 +1,25 @@ import { type Op, pack } from "./ops.ts"; -import { type BoundingBox, createTermNative } from "./term-native.ts"; +import { + type BoundingBox, + createTermNative, + type TermAttach, +} from "./term-native.ts"; +import { internals, type TermInfo } from "./terminfo.ts"; export interface TermOptions { height: number; width: number; + + /** + * TermInfo handle from queryTermInfo(). Attaches the Term to the + * handle's shared capability struct, which gates emission (color + * encoding ladder, synchronized output, full redraw on capability + * change — see specs/renderer-spec.md section 7.6). + * + * If no handle is provided the Term uses the baseline capabilities + * (256-color emission). + */ + terminfo?: TermInfo; } export interface RenderOptions { @@ -74,8 +90,19 @@ export interface Term { } export async function createTerm(options: TermOptions): Promise { - let { width, height } = options; - let native = await createTermNative(width, height); + let { width, height, terminfo } = options; + + let attach: TermAttach | undefined; + if (terminfo) { + let ti = internals(terminfo); + if (ti.termAttached) { + throw new Error("TermInfo handle is already attached to a Term"); + } + ti.termAttached = true; + attach = ti; + } + + let native = await createTermNative(width, height, attach); let { memory, statePtr, opsBuf } = native; let prev = new Set(); diff --git a/terminfo.ts b/terminfo.ts new file mode 100644 index 0000000..c369abf --- /dev/null +++ b/terminfo.ts @@ -0,0 +1,498 @@ +/** + * Terminal capability layer. + * + * Loads compiled terminfo binaries, probes the terminal with query + * escape sequences, and resolves both into a single capability struct + * shared (via one WebAssembly.Memory) with the renderer and the input + * parser. See specs/terminfo-spec.md. + */ + +import { readFile } from "node:fs/promises"; +import { pathToFileURL } from "node:url"; +import process from "node:process"; + +import { compiled } from "./wasm.ts"; +import { offsets, struct, uint32 } from "./typedef.ts"; + +/** + * Compiled terminfo entries are limited to 4096 bytes (legacy) or 32768 + * bytes (extended ncurses format). We use the extended limit as our + * upper bound. See https://man7.org/linux/man-pages/man5/term.5.html + */ +export const MAX_TERMINFO = 32768; + +/* Flag bits — must match src/terminfo.h. */ +const FLAG_TRUECOLOR = 1 << 0; +const FLAG_BCE = 1 << 1; +const FLAG_AM = 1 << 2; +const FLAG_ALTSCREEN = 1 << 4; +const FLAG_STYLED_UNDERLINE = 1 << 5; +const FLAG_SYNC = 1 << 6; +const FLAG_KITTY_KEYBOARD = 1 << 7; +const FLAG_KITTY_GRAPHICS = 1 << 8; +const FLAG_KITTY_COLOR = 1 << 9; +const FLAG_HYPERLINKS = 1 << 10; +const FLAG_POINTER_SHAPE = 1 << 11; +const FLAG_THEME_FG = 1 << 12; +const FLAG_THEME_BG = 1 << 13; +const FLAG_THEME_CURSOR = 1 << 14; +/* Probe-fence marker, set in `confirmed` when a DA1 report arrives. */ +const FLAG_DA1 = 0x80000000; + +const TermInfoStruct = struct({ + generation: uint32(), + colors: uint32(), + flags: uint32(), + confirmed: uint32(), + theme_fg: uint32(), + theme_bg: uint32(), + theme_cursor: uint32(), +}); + +const TI = offsets(TermInfoStruct); + +/** 8-bit RGB, each channel 0–255. */ +export interface Rgb { + r: number; + g: number; + b: number; +} + +/** Decoded live view of the capability struct. */ +export interface Capabilities { + generation: number; + colors: number; + trueColor: boolean; + bce: boolean; + autoMargin: boolean; + altScreen: boolean; + styledUnderline: boolean; + syncOutput: boolean; + kittyKeyboard: boolean; + kittyGraphics: boolean; + kittyColor: boolean; + hyperlinks: boolean; + pointerShape: boolean; + theme: { + foreground?: Rgb; + background?: Rgb; + cursor?: Rgb; + }; +} + +export interface TermInfo { + /** Decoded live view of the capability struct. */ + readonly capabilities: Capabilities; + /** The probe query batch, fenced by DA1 (sans-IO). */ + probe(): Uint8Array; +} + +/** Minimal read-stream surface consumed by the probe (mockable). */ +export interface ProbeInput { + isTTY?: boolean; + isRaw?: boolean; + setRawMode?(raw: boolean): void; + on(event: "data", cb: (chunk: Uint8Array) => void): unknown; + off(event: "data", cb: (chunk: Uint8Array) => void): unknown; + resume?(): void; + pause?(): void; + isPaused?(): boolean; +} + +/** Minimal write-stream surface consumed by the probe (mockable). */ +export interface ProbeOutput { + isTTY?: boolean; + write(bytes: Uint8Array): unknown; +} + +export interface QueryTermInfoOptions { + /** Terminal name to resolve. Defaults to env.TERM. */ + term?: string; + /** + * Environment for TERM / TERMINFO / TERMINFO_DIRS / HOME / COLORTERM + * lookups. Defaults to process.env. Injectable for testing. + */ + env?: Record; + /** Raw compiled terminfo bytes; skips the filesystem lookup. */ + terminfo?: Uint8Array; + /** Read stream for probe responses. Default: process.stdin. */ + input?: ProbeInput; + /** Write stream for probe queries. Default: process.stdout. */ + output?: ProbeOutput; + /** Milliseconds before the probe is abandoned. Default: 100. */ + timeout?: number; + signal?: AbortSignal; +} + +const encoder = new TextEncoder(); + +/** + * Probe query batch (terminfo-spec section 9.1): theme colors, kitty + * color and pointer shape protocols, XTGETTCAP RGB;Tc, DECRQM 2026, + * kitty keyboard and graphics — fenced by DA1, which every terminal + * answers. + */ +const PROBE = encoder.encode( + "\x1b]10;?\x07" + + "\x1b]11;?\x07" + + "\x1b]12;?\x07" + + "\x1b]21;foreground=?;background=?;cursor=?\x1b\\" + + "\x1b]22;?__current__\x1b\\" + + "\x1bP+q524742;5463\x1b\\" + + "\x1b[?2026$p" + + "\x1b[?u" + + "\x1b_Gi=31,s=1,v=1,a=q,t=d,f=24;AAAA\x1b\\" + + "\x1b[c", +); + +interface TermInfoNative { + memory: WebAssembly.Memory; + /** + * The handle's WASM instance exports. Attached consumers reuse this + * instance rather than instantiating the module again: instantiation + * rewrites the module's data segments, which would clobber the static + * state of anything already initialized in the shared memory. + */ + exports: Record; + structPtr: number; + bytesPtr: number; + bytesLen: number; + alloc(size: number, align?: number): number; + /* At most one Input and one Term may attach (terminfo-spec 10.3). */ + inputAttached?: boolean; + termAttached?: boolean; +} + +/** + * Internal surface consumed by createTerm/createInput to attach to the + * handle's shared memory. Not public API. + */ +export function internals(ti: TermInfo): TermInfoNative { + let native = NATIVE.get(ti); + if (!native) { + throw new TypeError("not a TermInfo handle"); + } + return native; +} + +const NATIVE = new WeakMap(); + +const WASM_PAGE_BYTES = 65536; + +function rgbOf(packed: number): Rgb { + return { + r: (packed >> 16) & 0xff, + g: (packed >> 8) & 0xff, + b: packed & 0xff, + }; +} + +function readCapabilities(native: TermInfoNative): Capabilities { + let view = new DataView(native.memory.buffer); + let ptr = native.structPtr; + let flags = view.getUint32(ptr + TI.flags, true); + let theme: Capabilities["theme"] = {}; + if (flags & FLAG_THEME_FG) { + theme.foreground = rgbOf(view.getUint32(ptr + TI.theme_fg, true)); + } + if (flags & FLAG_THEME_BG) { + theme.background = rgbOf(view.getUint32(ptr + TI.theme_bg, true)); + } + if (flags & FLAG_THEME_CURSOR) { + theme.cursor = rgbOf(view.getUint32(ptr + TI.theme_cursor, true)); + } + return { + generation: view.getUint32(ptr + TI.generation, true), + colors: view.getUint32(ptr + TI.colors, true), + trueColor: !!(flags & FLAG_TRUECOLOR), + bce: !!(flags & FLAG_BCE), + autoMargin: !!(flags & FLAG_AM), + altScreen: !!(flags & FLAG_ALTSCREEN), + styledUnderline: !!(flags & FLAG_STYLED_UNDERLINE), + syncOutput: !!(flags & FLAG_SYNC), + kittyKeyboard: !!(flags & FLAG_KITTY_KEYBOARD), + kittyGraphics: !!(flags & FLAG_KITTY_GRAPHICS), + kittyColor: !!(flags & FLAG_KITTY_COLOR), + hyperlinks: !!(flags & FLAG_HYPERLINKS), + pointerShape: !!(flags & FLAG_POINTER_SHAPE), + theme, + }; +} + +export async function queryTermInfo( + options: QueryTermInfoOptions = {}, +): Promise { + let env = options.env ?? process.env; + let timeout = options.timeout ?? 100; + let bytes = options.terminfo; + + if (bytes && bytes.byteLength > MAX_TERMINFO) { + throw new RangeError( + `terminfo exceeds ${MAX_TERMINFO} byte limit (got ${bytes.byteLength})`, + ); + } + + if (!bytes) { + bytes = await loadTerminfo({ term: options.term, env }); + } + + let memory = new WebAssembly.Memory({ initial: 4 }); + let allExports: Record = {}; + let instance = await WebAssembly.instantiate(compiled, { + env: { memory }, + clay: { + measureTextFunction(ret: number, text: number) { + allExports.measure(ret, text); + }, + queryScrollOffsetFunction(ret: number) { + let view = new DataView(memory.buffer); + view.setFloat32(ret, 0, true); + view.setFloat32(ret + 4, 0, true); + }, + }, + }); + Object.assign(allExports, instance.exports); + + let exports = instance.exports as unknown as { + __heap_base: WebAssembly.Global; + terminfo_size(): number; + terminfo_init(mem: number): number; + terminfo_parse(bytes: number, len: number, ti: number): number; + terminfo_grant(ti: number, flags: number): void; + input_size(): number; + input_init( + mem: number, + escLatency: number, + terminfo: number, + terminfoLen: number, + ti: number, + ): number; + input_scan(st: number, buf: number, len: number, now: number): number; + }; + + let top = ((exports.__heap_base.value as number) + 7) & ~7; + function alloc(size: number, align = 8): number { + top = (top + align - 1) & ~(align - 1); + let ptr = top; + top += size; + let pages = Math.ceil(top / WASM_PAGE_BYTES); + let current = memory.buffer.byteLength / WASM_PAGE_BYTES; + if (pages > current) { + memory.grow(pages - current); + } + return ptr; + } + + let structPtr = alloc(exports.terminfo_size()); + exports.terminfo_init(structPtr); + + let bytesPtr = alloc(MAX_TERMINFO); + let bytesLen = 0; + if (bytes) { + new Uint8Array(memory.buffer).set(bytes, bytesPtr); + if (exports.terminfo_parse(bytesPtr, bytes.byteLength, structPtr) === 0) { + bytesLen = bytes.byteLength; + } + } + + let colorterm = env.COLORTERM; + if (colorterm === "truecolor" || colorterm === "24bit") { + exports.terminfo_grant(structPtr, FLAG_TRUECOLOR); + } + + let native: TermInfoNative = { + memory, + exports: allExports, + structPtr, + bytesPtr, + bytesLen, + alloc, + }; + + let handle: TermInfo = { + get capabilities(): Capabilities { + return readCapabilities(native); + }, + probe(): Uint8Array { + return PROBE.slice(); + }, + }; + NATIVE.set(handle, native); + + let input = options.input ?? process.stdin; + let output = options.output ?? process.stdout; + if (input.isTTY && output.isTTY && !options.signal?.aborted) { + // Probe-window parser: a private input parser over the shared + // memory whose only job is folding responses into the struct and + // spotting the DA1 fence. Abandoned once the probe resolves; the + // consumer's own createInput({ terminfo }) parser takes over. + let scanState = exports.input_init( + alloc(exports.input_size()), + 25, + 0, + 0, + structPtr, + ); + let scanBuf = alloc(SCAN_CHUNK); + + let feed = (chunk: Uint8Array): boolean => { + let offset = 0; + while (offset < chunk.length) { + let part = chunk.subarray(offset, offset + SCAN_CHUNK); + new Uint8Array(memory.buffer).set(part, scanBuf); + let accepted = exports.input_scan( + scanState, + scanBuf, + part.length, + Date.now(), + ); + if (accepted <= 0) break; + offset += accepted; + } + let view = new DataView(memory.buffer); + let confirmed = view.getUint32(structPtr + TI.confirmed, true); + return (confirmed & FLAG_DA1) !== 0; + }; + + await runProbe(input, output, timeout, feed, options.signal); + } + + return handle; +} + +/* Must match SCAN_BUFFER_SIZE in input.c. */ +const SCAN_CHUNK = 4096; + +function runProbe( + input: ProbeInput, + output: ProbeOutput, + timeout: number, + feed: (chunk: Uint8Array) => boolean, + signal?: AbortSignal, +): Promise { + return new Promise((resolve) => { + let settled = false; + let prevRaw = input.isRaw ?? false; + let wasPaused = input.isPaused?.() ?? false; + + function finish(): void { + if (settled) return; + settled = true; + clearTimeout(timer); + input.off("data", onData); + signal?.removeEventListener("abort", onAbort); + input.setRawMode?.(prevRaw); + if (wasPaused) input.pause?.(); + resolve(); + } + + function onData(chunk: Uint8Array): void { + if (feed(chunk)) finish(); + } + + let onAbort = () => finish(); + let timer = setTimeout(finish, timeout); + signal?.addEventListener("abort", onAbort); + + input.setRawMode?.(true); + input.resume?.(); + input.on("data", onData); + output.write(PROBE); + }); +} + +/* ── terminfo filesystem lookup ───────────────────────────────────── */ + +interface LoadOptions { + term?: string; + env: Record; +} + +// Compiled-in fallback locations, searched in order (ncurses convention). +const DEFAULT_DIRS = [ + "/usr/share/terminfo", + "/etc/terminfo", + "/lib/terminfo", + "/usr/lib/terminfo", +]; + +const MAGIC_LEGACY = 0x011a; +const MAGIC_EXTENDED = 0x021e; + +function hasTerminfoMagic(bytes: Uint8Array): boolean { + if (bytes.length < 2) return false; + let magic = bytes[0] | (bytes[1] << 8); + return magic === MAGIC_LEGACY || magic === MAGIC_EXTENDED; +} + +// Turn a directory path into a file URL ending in "/" so that +// `new URL(child, dir)` resolves children *under* it. +function dirUrl(path: string): URL { + return pathToFileURL(path.endsWith("/") ? path : `${path}/`); +} + +function searchPath(env: Record): URL[] { + let dirs: URL[] = []; + let seen = new Set(); + let add = (url: URL): void => { + if (!seen.has(url.href)) { + seen.add(url.href); + dirs.push(url); + } + }; + if (env.TERMINFO) add(dirUrl(env.TERMINFO)); + if (env.HOME) add(new URL(".terminfo/", dirUrl(env.HOME))); + if (env.TERMINFO_DIRS) { + for (let entry of env.TERMINFO_DIRS.split(":")) { + // An empty entry stands in for the compiled-in defaults. + if (entry === "") { + for (let dir of DEFAULT_DIRS) add(dirUrl(dir)); + } else { + add(dirUrl(entry)); + } + } + } + for (let dir of DEFAULT_DIRS) add(dirUrl(dir)); + return dirs; +} + +function candidates(base: URL, name: string): URL[] { + let first = name[0]; + // macOS stores under a two-hex-digit dir; Linux uses the first letter. + let hex = first.charCodeAt(0).toString(16).padStart(2, "0"); + return [new URL(`${first}/${name}`, base), new URL(`${hex}/${name}`, base)]; +} + +async function tryRead(url: URL): Promise { + try { + return await readFile(url); + } catch { + return undefined; + } +} + +async function loadTerminfo( + options: LoadOptions, +): Promise { + let name = options.term ?? options.env.TERM; + if (!name) return undefined; + // Reject path separators, NUL, and leading dots (no traversal; + // terminfo names never begin with '.'). + if ( + name.startsWith(".") || name.includes("/") || name.includes("\\") || + name.includes("\0") + ) { + return undefined; + } + for (let base of searchPath(options.env)) { + for (let url of candidates(base, name)) { + let bytes = await tryRead(url); + if ( + bytes && bytes.byteLength <= MAX_TERMINFO && hasTerminfoMagic(bytes) + ) { + return bytes; + } + } + } + return undefined; +} diff --git a/test/border.test.ts b/test/border.test.ts index 3b78100..222b819 100644 --- a/test/border.test.ts +++ b/test/border.test.ts @@ -1,5 +1,10 @@ import { close, fixed, open, type OpenElement, rgba } from "../ops.ts"; import { createTerm } from "../term.ts"; +import { trueColorTermInfo } from "./caps.ts"; + +async function trueColorTerm(options: { width: number; height: number }) { + return await createTerm({ ...options, terminfo: await trueColorTermInfo() }); +} import { describe, expect, it } from "./suite.ts"; const decode = (b: Uint8Array) => new TextDecoder().decode(b); @@ -107,7 +112,7 @@ type OpenProps = Omit; * mode and parses the full-frame output into cells. Box corners are at * (0,0), (7,0), (0,3), (7,3). */ async function renderBox(props: OpenProps): Promise { - let term = await createTerm({ width: 12, height: 5 }); + let term = await trueColorTerm({ width: 12, height: 5 }); let ansi = decode( term.render([ open("box", { @@ -256,7 +261,7 @@ describe("structured sides", () => { }); it("does not retain a prior frame's side bg", async () => { - let term = await createTerm({ width: 12, height: 5 }); + let term = await trueColorTerm({ width: 12, height: 5 }); let frame = (bg?: number) => [ open("box", { layout: { width: fixed(8), height: fixed(4) }, @@ -442,8 +447,8 @@ describe("directive model", () => { describe("instances", () => { it("does not share side attributes between Term instances", async () => { - let a = await createTerm({ width: 12, height: 5 }); - let b = await createTerm({ width: 12, height: 5 }); + let a = await trueColorTerm({ width: 12, height: 5 }); + let b = await trueColorTerm({ width: 12, height: 5 }); let frame = (top: number, bottom: number) => [ open("box", { diff --git a/test/caps.ts b/test/caps.ts new file mode 100644 index 0000000..3fb645e --- /dev/null +++ b/test/caps.ts @@ -0,0 +1,20 @@ +/** + * Test helpers for building TermInfo handles without a filesystem or a + * TTY: streams are non-TTY mocks so queryTermInfo never probes. + */ + +import { queryTermInfo, type QueryTermInfoOptions } from "../terminfo.ts"; + +export function offlineTermInfo(options: Partial = {}) { + return queryTermInfo({ + env: {}, + input: { isTTY: false, on() {}, off() {} }, + output: { isTTY: false, write() {} }, + ...options, + }); +} + +/** A handle with truecolor granted via COLORTERM evidence. */ +export function trueColorTermInfo() { + return offlineTermInfo({ env: { COLORTERM: "truecolor" } }); +} diff --git a/test/color.test.ts b/test/color.test.ts index 9741243..73743c3 100644 --- a/test/color.test.ts +++ b/test/color.test.ts @@ -1,6 +1,8 @@ import { close, fixed, grow, open, rgba, text } from "../ops.ts"; import { createTerm } from "../term.ts"; import { describe, expect, it } from "./suite.ts"; +import { offlineTermInfo, trueColorTermInfo } from "./caps.ts"; +import { CLAYTERM_16 } from "./fixtures.ts"; const decode = (b: Uint8Array) => new TextDecoder().decode(b); @@ -80,9 +82,13 @@ describe("foreground", () => { }); }); +async function trueColorTerm(options: { width: number; height: number }) { + return await createTerm({ ...options, terminfo: await trueColorTermInfo() }); +} + describe("background", () => { it("fills border cells with the requested border-level bg", async () => { - let term = await createTerm({ width: 12, height: 4 }); + let term = await trueColorTerm({ width: 12, height: 4 }); let bg = randomBgColor(); let ansi = decode( term.render([ @@ -112,7 +118,7 @@ describe("background", () => { }); it("leaves existing border-cell bg unchanged when border bg is omitted", async () => { - let term = await createTerm({ width: 12, height: 4 }); + let term = await trueColorTerm({ width: 12, height: 4 }); let bg = randomBgColor(); let ansi = decode( term.render([ @@ -140,7 +146,7 @@ describe("background", () => { }); it("fills glyph cells with the requested text-level bg", async () => { - let term = await createTerm({ width: 20, height: 1 }); + let term = await trueColorTerm({ width: 20, height: 1 }); let bg = randomBgColor(); let ansi = decode( term.render([ @@ -155,7 +161,7 @@ describe("background", () => { }); it("resets border bg on subsequent frames without border bg", async () => { - let term = await createTerm({ width: 12, height: 4 }); + let term = await trueColorTerm({ width: 12, height: 4 }); let bg = randomBgColor(); // Frame 1: border with bg @@ -198,7 +204,7 @@ describe("background", () => { }); it("resets the background before writing trailing cells", async () => { - let term = await createTerm({ width: 20, height: 1 }); + let term = await trueColorTerm({ width: 20, height: 1 }); let bg = randomBgColor(); let ansi = decode( term.render([ @@ -219,3 +225,55 @@ describe("background", () => { expect(afterHi.startsWith("\x1b[0m ")).toBe(true); }); }); + +describe("capability-gated color encoding", () => { + const OPS = [ + open("root", { layout: { width: grow(), height: grow() } }), + text("hi", { color: rgba(255, 0, 0), bg: rgba(0, 0, 255) }), + close(), + ]; + + it("emits 256-color SGR with no terminfo handle (baseline)", async () => { + let term = await createTerm({ width: 12, height: 1 }); + let ansi = decode(term.render(OPS).output); + + expect(ansi).toContain("\x1b[38;5;196m"); + expect(ansi).toContain("\x1b[48;5;21m"); + expect(ansi).not.toContain("38;2"); + expect(ansi).not.toContain("48;2"); + }); + + it("emits truecolor SGR when truecolor evidence is present", async () => { + let terminfo = await trueColorTermInfo(); + let term = await createTerm({ width: 12, height: 1, terminfo }); + let ansi = decode(term.render(OPS).output); + + expect(ansi).toContain("\x1b[38;2;255;0;0m"); + expect(ansi).toContain("\x1b[48;2;0;0;255m"); + }); + + it("emits 16-color SGR when the terminfo entry reports 16 colors", async () => { + let terminfo = await offlineTermInfo({ terminfo: CLAYTERM_16 }); + let term = await createTerm({ width: 12, height: 1, terminfo }); + let ansi = decode(term.render(OPS).output); + + expect(ansi).toContain("\x1b[91m"); + expect(ansi).toContain("\x1b[44m"); + expect(ansi).not.toContain("38;5"); + expect(ansi).not.toContain("38;2"); + }); + + it("maps grays onto the 256-color grayscale ramp", async () => { + let term = await createTerm({ width: 12, height: 1 }); + let ansi = decode( + term.render([ + open("root", { layout: { width: grow(), height: grow() } }), + text("hi", { color: rgba(128, 128, 128) }), + close(), + ]).output, + ); + + // (128,128,128) sits on the grayscale ramp: 232 + (128-8)/10 = 244 + expect(ansi).toContain("\x1b[38;5;244m"); + }); +}); diff --git a/test/fixtures.ts b/test/fixtures.ts new file mode 100644 index 0000000..8030f3a --- /dev/null +++ b/test/fixtures.ts @@ -0,0 +1,18 @@ +// Generated by tasks/gen-fixtures.ts — do not edit by hand. +import { decodeBase64 } from "@std/encoding/base64"; + +export const XTERM_256COLOR: Uint8Array = decodeBase64( + "GgElACYADwCdAcQFeHRlcm0tMjU2Y29sb3J8eHRlcm0gd2l0aCAyNTYgY29sb3JzAAABAAABAAAAAQAAAAABAQAAAAAAAAABAAABAAEBAAAAAAAAAAABAFAACAAYAP//////////////////////////AAH/fwAABAAGAAgAGQAeACYAKgAuAP//OQBKAEwAUABXAP//WQBmAP//agBuAHgAfAD/////gACEAIkAjgD//5cAnAChAP//pgCrALAAtQC+AMIAyQD//9IA1wDdAOMA////////9QD///////8HAf//CwH///////8NAf//EgH//////////xYBGgEgASQBKAEsATIBOAE+AUQBSgFOAf//UwH//1cBXAFhAWUBbAH//3MBdwF/Af////////////////////////////+HAZAB/////5kBogGrAbQBvQHGAc8B2AHhAeoB////////8wH3AfwB//8BAgQC/////xYCGQIkAicCKQIsAokC//+MAv///////////////44C//////////+SAv//xwL/////ywLRAv/////////////////////////////XAtsC///////////////////////////////////////////////////////////////////fAv/////mAv//////////7QL0AvsC/////wID//8JA////////xAD/////////////xcDHQMjAyoDMQM4Az8DRwNPA1cDXwNnA28DdwN/A4YDjQOUA5sDowOrA7MDuwPDA8sD0wPbA+ID6QPwA/cD/wMHBA8EFwQfBCcELwQ3BD4ERQRMBFMEWwRjBGsEcwR7BIMEiwSTBJoEoQSoBP////////////////////////////////////////////////////////////+tBLgEvQTFBMkE///SBP////////////////////////////8wBf///////////////////////zUF////////////////////////////////////////////////////////////////////////////////////////OwX///////8/BX4F/////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////74FwQUbW1oABwANABtbJWklcDElZDslcDIlZHIAG1szZwAbW0gbWzJKABtbSwAbW0oAG1slaSVwMSVkRwAbWyVpJXAxJWQ7JXAyJWRIAAoAG1tIABtbPzI1bAAIABtbPzEybBtbPzI1aAAbW0MAG1tBABtbPzEyOzI1aAAbW1AAG1tNABsoMAAbWzVtABtbMW0AG1s/MTA0OWgAG1sybQAbWzRoABtbOG0AG1s3bQAbWzdtABtbNG0AG1slcDElZFgAGyhCABsoQhtbbQAbWz8xMDQ5bAAbWzRsABtbMjdtABtbMjRtABtbPzVoJDwxMDAvPhtbPzVsABtbIXAbWz8zOzRsG1s0bBs+ABtbTAAIABtbM34AG09CABtPUAAbWzIxfgAbT1EAG09SABtPUwAbWzE1fgAbWzE3fgAbWzE4fgAbWzE5fgAbWzIwfgAbT0gAG1syfgAbT0QAG1s2fgAbWzV+ABtPQwAbWzE7MkIAG1sxOzJBABtPQQAbWz8xbBs+ABtbPzFoGz0AG1s/MTAzNGwAG1s/MTAzNGgAG1slcDElZFAAG1slcDElZE0AG1slcDElZEIAG1slcDElZEAAG1slcDElZFMAG1slcDElZEwAG1slcDElZEQAG1slcDElZEMAG1slcDElZFQAG1slcDElZEEAG1tpABtbNGkAG1s1aQAbYwAbWyFwG1s/Mzs0bBtbNGwbPgAbOAAbWyVpJXAxJWRkABs3AAoAG00AJT8lcDkldBsoMCVlGyhCJTsbWzAlPyVwNiV0OzElOyU/JXA1JXQ7MiU7JT8lcDIldDs0JTslPyVwMSVwMyV8JXQ7NyU7JT8lcDQldDs1JTslPyVwNyV0OzglO20AG0gACQAbT0UAYGBhYWZmZ2dpaWpqa2tsbG1tbm5vb3BwcXFycnNzdHR1dXZ2d3d4eHl5enp7e3x8fX1+fgAbW1oAG1s/N2gAG1s/N2wAG09GABtPTQAbWzM7Mn4AG1sxOzJGABtbMTsySAAbWzI7Mn4AG1sxOzJEABtbNjsyfgAbWzU7Mn4AG1sxOzJDABtbMjN+ABtbMjR+ABtbMTsyUAAbWzE7MlEAG1sxOzJSABtbMTsyUwAbWzE1OzJ+ABtbMTc7Mn4AG1sxODsyfgAbWzE5OzJ+ABtbMjA7Mn4AG1syMTsyfgAbWzIzOzJ+ABtbMjQ7Mn4AG1sxOzVQABtbMTs1UQAbWzE7NVIAG1sxOzVTABtbMTU7NX4AG1sxNzs1fgAbWzE4OzV+ABtbMTk7NX4AG1syMDs1fgAbWzIxOzV+ABtbMjM7NX4AG1syNDs1fgAbWzE7NlAAG1sxOzZRABtbMTs2UgAbWzE7NlMAG1sxNTs2fgAbWzE3OzZ+ABtbMTg7Nn4AG1sxOTs2fgAbWzIwOzZ+ABtbMjE7Nn4AG1syMzs2fgAbWzI0OzZ+ABtbMTszUAAbWzE7M1EAG1sxOzNSABtbMTszUwAbWzE1OzN+ABtbMTc7M34AG1sxODszfgAbWzE5OzN+ABtbMjA7M34AG1syMTszfgAbWzIzOzN+ABtbMjQ7M34AG1sxOzRQABtbMTs0UQAbWzE7NFIAG1sxSwAbWyVpJWQ7JWRSABtbNm4AG1s/MTsyYwAbW2MAG1szOTs0OW0AG100OyVwMSVkO3JnYjolcDIlezI1NX0lKiV7MTAwMH0lLyUyLjJYLyVwMyV7MjU1fSUqJXsxMDAwfSUvJTIuMlgvJXA0JXsyNTV9JSolezEwMDB9JS8lMi4yWBtcABtbM20AG1syM20AG1tNABtbJT8lcDElezh9JTwldDMlcDElZCVlJXAxJXsxNn0lPCV0OSVwMSV7OH0lLSVkJWUzODs1OyVwMSVkJTttABtbJT8lcDElezh9JTwldDQlcDElZCVlJXAxJXsxNn0lPCV0MTAlcDElezh9JS0lZCVlNDg7NTslcDElZCU7bQAbbAAbbQADAAEAQACEAPoCAQABAP//AAAHAP//EwAYAP//KgAwADoAQQBIAE8AVgBdAGQAawByAHkAgACHAI4AlQCcAKMAqgCxALgAvwDGAM0A1ADbAOIA6QDwAPcA/gAFAQwBEwEaASEBKAEvATYBPQFEAUsBUgFZAWABZwFuAXUBfAGDAYoBkQGYAZ8B//////////8AAAMABgAJAAwADwASABUAGAAbAB4AIQAkACkALgAzADgAPQBBAEYASwBQAFUAWgBgAGYAbAByAHgAfgCEAIoAkACWAJsAoAClAKoArwC1ALsAwQDHAM0A0wDZAN8A5QDrAPEA9wD9AAMBCQEPARUBGwEhAScBKwEwATUBOgE/AUQBSAFMAVABG10xMTIHABtdMTI7JXAxJXMHABtbM0oAG101MjslcDElczslcDIlcwcAG1syIHEAG1slcDElZCBxABtbMzszfgAbWzM7NH4AG1szOzV+ABtbMzs2fgAbWzM7N34AG1sxOzJCABtbMTszQgAbWzE7NEIAG1sxOzVCABtbMTs2QgAbWzE7N0IAG1sxOzNGABtbMTs0RgAbWzE7NUYAG1sxOzZGABtbMTs3RgAbWzE7M0gAG1sxOzRIABtbMTs1SAAbWzE7NkgAG1sxOzdIABtbMjszfgAbWzI7NH4AG1syOzV+ABtbMjs2fgAbWzI7N34AG1sxOzNEABtbMTs0RAAbWzE7NUQAG1sxOzZEABtbMTs3RAAbWzY7M34AG1s2OzR+ABtbNjs1fgAbWzY7Nn4AG1s2Ozd+ABtbNTszfgAbWzU7NH4AG1s1OzV+ABtbNTs2fgAbWzU7N34AG1sxOzNDABtbMTs0QwAbWzE7NUMAG1sxOzZDABtbMTs3QwAbWzE7MkEAG1sxOzNBABtbMTs0QQAbWzE7NUEAG1sxOzZBABtbMTs3QQBBWABHMABYVABVOABDcgBDcwBFMABFMwBNcwBTMABTZQBTcwBrREMzAGtEQzQAa0RDNQBrREM2AGtEQzcAa0ROAGtETjMAa0RONABrRE41AGtETjYAa0RONwBrRU5EMwBrRU5ENABrRU5ENQBrRU5ENgBrRU5ENwBrSE9NMwBrSE9NNABrSE9NNQBrSE9NNgBrSE9NNwBrSUMzAGtJQzQAa0lDNQBrSUM2AGtJQzcAa0xGVDMAa0xGVDQAa0xGVDUAa0xGVDYAa0xGVDcAa05YVDMAa05YVDQAa05YVDUAa05YVDYAa05YVDcAa1BSVjMAa1BSVjQAa1BSVjUAa1BSVjYAa1BSVjcAa1JJVDMAa1JJVDQAa1JJVDUAa1JJVDYAa1JJVDcAa1VQAGtVUDMAa1VQNABrVVA1AGtVUDYAa1VQNwBrYTIAa2IxAGtiMwBrYzIA", +); + +export const CLAYTERM_TC: Uint8Array = decodeBase64( + "GgE4AB0ADwBYAC0AY2xheXRlcm0tdGN8Y2xheXRlcm0gdHJ1ZWNvbG9yIGV4dGVuZGVkLWNhcHMgdGVzdCBlbnRyeQAAAQAAAQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAQBQAP//GAD//////////////////////////wAB/3///////////////////////////wAA/////////////////////////////////////////////xEA/////////////////////////////xoA////////////////////////////////////////////////////////////////////////////////IwD///////////////////////////////////////8pABtbJWklcDElZDslcDIlZEgAG1s/MTA0OWgAG1s/MTA0OWwAG1s5OX4AG09aAAACAAAAAQAEABcAAQEAAAAAAwAGABtbNDolcDElZG0AU3UAVGMAU211bHgA", +); + +export const CLAYTERM_16: Uint8Array = decodeBase64( + "GgEuAAUADwALABEAY2xheXRlcm0tMTZ8Y2xheXRlcm0gc2l4dGVlbiBjb2xvciB0ZXN0IGVudHJ5AAABAAABAFAA//8YAP//////////////////////////EAAAAf//////////////////////////AAAbWyVpJXAxJWQ7JXAyJWRIAA==", +); + +export const CLAYTERM_DIRECT: Uint8Array = decodeBase64( + "HgI7AB0ADgAdAAkAY2xheXRlcm0tZGlyZWN0fGNsYXl0ZXJtIGV4dGVuZGVkIG51bWJlciBmb3JtYXQgdGVzdCBlbnRyeQAAAQAAAQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAFAAAAD/////GAAAAP////////////////////////////////////////////////////8AAAAB//////////////////////////////////////////////////////////////////////////8AABtbPzEwNDloAAABAAAAAAABAAQAAQAAAFJHQgA=", +); diff --git a/test/input.test.ts b/test/input.test.ts index 38af941..a52c14e 100644 --- a/test/input.test.ts +++ b/test/input.test.ts @@ -1,5 +1,7 @@ import { beforeEach, describe, expect, it } from "./suite.ts"; import { createInput, type Input } from "../input.ts"; +import { queryTermInfo } from "../terminfo.ts"; +import { CLAYTERM_16, CLAYTERM_TC } from "./fixtures.ts"; function bytes(...values: number[]): Uint8Array { return new Uint8Array(values); @@ -744,3 +746,206 @@ describe("input", () => { }); }); }); + +describe("terminfo integration", () => { + async function attached() { + let terminfo = await queryTermInfo({ + terminfo: CLAYTERM_TC, + env: {}, + input: { isTTY: false, on() {}, off() {} }, + output: { isTTY: false, write() {} }, + }); + let input = await createInput({ terminfo }); + return { terminfo, input }; + } + + describe("key sequences from terminfo", () => { + it("decodes a terminfo-specific arrow sequence", async () => { + // clayterm-tc defines kcuu1=\EOZ + let { input } = await attached(); + let result = input.scan(str("\x1bOZ")); + expect(result.events.length).toBe(1); + expect(result.events[0]).toMatchObject({ + type: "keydown", + key: "ArrowUp", + }); + }); + + it("decodes a terminfo-specific function key", async () => { + // clayterm-tc defines kf5=\E[99~ + let { input } = await attached(); + let result = input.scan(str("\x1b[99~")); + expect(result.events.length).toBe(1); + expect(result.events[0]).toMatchObject({ type: "keydown", key: "F5" }); + }); + + it("keeps the xterm defaults registered", async () => { + let { input } = await attached(); + let result = input.scan(str("\x1bOA")); + expect(result.events.length).toBe(1); + expect(result.events[0]).toMatchObject({ + type: "keydown", + key: "ArrowUp", + }); + }); + }); + + describe("query response recognition", () => { + it("consumes an OSC 10 foreground report (BEL-terminated)", async () => { + let { terminfo, input } = await attached(); + let result = input.scan(str("\x1b]10;rgb:ffff/ffff/ffff\x07")); + expect(result.events).toEqual([]); + expect(terminfo.capabilities.theme.foreground).toEqual({ + r: 255, + g: 255, + b: 255, + }); + }); + + it("consumes an OSC 11 background report (ST-terminated)", async () => { + let { terminfo, input } = await attached(); + let result = input.scan(str("\x1b]11;rgb:1e1e/2a2a/3b3b\x1b\\")); + expect(result.events).toEqual([]); + expect(terminfo.capabilities.theme.background).toEqual({ + r: 0x1e, + g: 0x2a, + b: 0x3b, + }); + }); + + it("consumes an OSC 12 cursor color report", async () => { + let { terminfo, input } = await attached(); + let result = input.scan(str("\x1b]12;#ff8800\x1b\\")); + expect(result.events).toEqual([]); + expect(terminfo.capabilities.theme.cursor).toEqual({ + r: 0xff, + g: 0x88, + b: 0, + }); + }); + + it("consumes an OSC 21 kitty color report", async () => { + let { terminfo, input } = await attached(); + let result = input.scan( + str("\x1b]21;foreground=rgb:ff/00/00;background=\x1b\\"), + ); + expect(result.events).toEqual([]); + let caps = terminfo.capabilities; + expect(caps.kittyColor).toBe(true); + expect(caps.theme.foreground).toEqual({ r: 255, g: 0, b: 0 }); + expect(caps.theme.background).toBeUndefined(); + }); + + it("consumes an OSC 22 pointer shape report", async () => { + let { terminfo, input } = await attached(); + let result = input.scan(str("\x1b]22;default\x1b\\")); + expect(result.events).toEqual([]); + expect(terminfo.capabilities.pointerShape).toBe(true); + }); + + it("confirms truecolor from a valid XTGETTCAP reply", async () => { + let terminfo = await queryTermInfo({ + terminfo: CLAYTERM_16, + env: {}, + input: { isTTY: false, on() {}, off() {} }, + output: { isTTY: false, write() {} }, + }); + let input = await createInput({ terminfo }); + expect(terminfo.capabilities.trueColor).toBe(false); + let result = input.scan(str("\x1bP1+r524742\x1b\\")); + expect(result.events).toEqual([]); + expect(terminfo.capabilities.trueColor).toBe(true); + }); + + it("denies truecolor from an invalid XTGETTCAP reply", async () => { + // probe denial outranks the terminfo entry's Tc grant + let { terminfo, input } = await attached(); + expect(terminfo.capabilities.trueColor).toBe(true); + let result = input.scan(str("\x1bP0+r\x1b\\")); + expect(result.events).toEqual([]); + expect(terminfo.capabilities.trueColor).toBe(false); + }); + + it("confirms synchronized output from a DECRPM report", async () => { + let { terminfo, input } = await attached(); + let result = input.scan(str("\x1b[?2026;2$y")); + expect(result.events).toEqual([]); + expect(terminfo.capabilities.syncOutput).toBe(true); + }); + + it("denies synchronized output from a not-recognized DECRPM report", async () => { + let { terminfo, input } = await attached(); + let result = input.scan(str("\x1b[?2026;0$y")); + expect(result.events).toEqual([]); + expect(terminfo.capabilities.syncOutput).toBe(false); + }); + + it("confirms the kitty keyboard protocol from a flags report", async () => { + let { terminfo, input } = await attached(); + let result = input.scan(str("\x1b[?1u")); + expect(result.events).toEqual([]); + expect(terminfo.capabilities.kittyKeyboard).toBe(true); + }); + + it("confirms kitty graphics from an OK APC reply", async () => { + let { terminfo, input } = await attached(); + let result = input.scan(str("\x1b_Gi=31;OK\x1b\\")); + expect(result.events).toEqual([]); + expect(terminfo.capabilities.kittyGraphics).toBe(true); + }); + + it("denies kitty graphics from an error APC reply", async () => { + let { terminfo, input } = await attached(); + let result = input.scan(str("\x1b_Gi=31;ENOTSUPPORTED:x\x1b\\")); + expect(result.events).toEqual([]); + expect(terminfo.capabilities.kittyGraphics).toBe(false); + }); + + it("consumes a DA1 report silently", async () => { + let { input } = await attached(); + let result = input.scan(str("\x1b[?65;1;9c")); + expect(result.events).toEqual([]); + }); + + it("bumps the generation when a response changes capabilities", async () => { + let { terminfo, input } = await attached(); + let before = terminfo.capabilities.generation; + input.scan(str("\x1b[?2026;1$y")); + expect(terminfo.capabilities.generation).toBeGreaterThan(before); + }); + + it("does not leak response bytes into adjacent events", async () => { + let { terminfo, input } = await attached(); + let result = input.scan( + str("a\x1b]11;rgb:0000/0000/0000\x1b\\b"), + ); + expect(result.events.length).toBe(2); + expect(result.events[0]).toMatchObject({ type: "keydown", key: "a" }); + expect(result.events[1]).toMatchObject({ type: "keydown", key: "b" }); + expect(terminfo.capabilities.theme.background).toEqual({ + r: 0, + g: 0, + b: 0, + }); + }); + + it("buffers a response split across scans", async () => { + let { terminfo, input } = await attached(); + let first = input.scan(str("\x1b]11;rgb:12")); + expect(first.events).toEqual([]); + let second = input.scan(str("34/5678/9abc\x1b\\")); + expect(second.events).toEqual([]); + expect(terminfo.capabilities.theme.background).toEqual({ + r: 0x12, + g: 0x56, + b: 0x9a, + }); + }); + + it("consumes responses on a standalone parser without a handle", async () => { + let input = await createInput(); + let result = input.scan(str("\x1b]11;rgb:0000/0000/0000\x1b\\")); + expect(result.events).toEqual([]); + }); + }); +}); diff --git a/test/term.test.ts b/test/term.test.ts index 121ece2..e6325cd 100644 --- a/test/term.test.ts +++ b/test/term.test.ts @@ -1,5 +1,7 @@ import { beforeEach, describe, expect, it } from "./suite.ts"; import { createTerm, type Term } from "../term.ts"; +import { createInput } from "../input.ts"; +import { offlineTermInfo } from "./caps.ts"; import { close, fixed, @@ -53,9 +55,10 @@ describe("term", () => { ); // the SGR active when "h" is emitted should include the - // parent's red background (48;2;255;0;0), not terminal default + // parent's red background (48;5;196 under baseline 256-color + // capabilities), not terminal default let before = ansi.slice(0, ansi.indexOf("h")); - expect(before).toContain("\x1b[48;2;255;0;0"); + expect(before).toContain("\x1b[48;5;196"); }); it("renders borders and padding", () => { @@ -395,3 +398,79 @@ describe("term", () => { }); }); }); + +describe("capability generation", () => { + const OPS = [ + open("root", { layout: { width: grow(), height: grow() } }), + text("hi", { color: rgba(255, 0, 0) }), + close(), + ]; + + it("emits nothing for an unchanged frame", async () => { + let terminfo = await offlineTermInfo(); + let term = await createTerm({ width: 12, height: 2, terminfo }); + term.render(OPS); + expect(term.render(OPS).output.length).toBe(0); + }); + + it("forces a full redraw when capabilities change between frames", async () => { + let terminfo = await offlineTermInfo(); + let term = await createTerm({ width: 12, height: 2, terminfo }); + let input = await createInput({ terminfo }); + + term.render(OPS); + expect(term.render(OPS).output.length).toBe(0); + + // an XTGETTCAP reply confirms truecolor mid-session + input.scan(new TextEncoder().encode("\x1bP1+r524742\x1b\\")); + + let ansi = new TextDecoder().decode(term.render(OPS).output); + expect(ansi).toContain("hi"); + expect(ansi).toContain("\x1b[38;2;255;0;0m"); + }); +}); + +describe("synchronized output", () => { + const OPS = [ + open("root", { layout: { width: grow(), height: grow() } }), + text("hi", { color: rgba(255, 0, 0) }), + close(), + ]; + + async function syncTerm() { + let terminfo = await offlineTermInfo(); + let term = await createTerm({ width: 12, height: 2, terminfo }); + let input = await createInput({ terminfo }); + input.scan(new TextEncoder().encode("\x1b[?2026;2$y")); + expect(terminfo.capabilities.syncOutput).toBe(true); + return term; + } + + it("wraps non-empty frames when syncOutput is confirmed", async () => { + let term = await syncTerm(); + let ansi = new TextDecoder().decode(term.render(OPS).output); + expect(ansi.startsWith("\x1b[?2026h")).toBe(true); + expect(ansi.endsWith("\x1b[?2026l")).toBe(true); + }); + + it("does not wrap when syncOutput is unconfirmed", async () => { + let terminfo = await offlineTermInfo(); + let term = await createTerm({ width: 12, height: 2, terminfo }); + let ansi = new TextDecoder().decode(term.render(OPS).output); + expect(ansi).not.toContain("2026"); + }); + + it("does not wrap empty frames", async () => { + let term = await syncTerm(); + term.render(OPS); + expect(term.render(OPS).output.length).toBe(0); + }); + + it("does not wrap line-mode output", async () => { + let term = await syncTerm(); + let ansi = new TextDecoder().decode( + term.render(OPS, { mode: "line" }).output, + ); + expect(ansi).not.toContain("2026"); + }); +}); diff --git a/test/terminfo.test.ts b/test/terminfo.test.ts new file mode 100644 index 0000000..c4ac558 --- /dev/null +++ b/test/terminfo.test.ts @@ -0,0 +1,390 @@ +/** + * Tests for specs/terminfo-spec.md: the capability struct, baseline and + * evidence model, terminfo binary parsing, and the queryTermInfo API. + * Probe response recognition is tested against the input spec + * (input.test.ts) and renderer gating against the renderer spec + * (term.test.ts / color.test.ts). + */ + +import { queryTermInfo } from "../terminfo.ts"; +import { createInput } from "../input.ts"; +import { createTerm } from "../term.ts"; +import { + CLAYTERM_16, + CLAYTERM_DIRECT, + CLAYTERM_TC, + XTERM_256COLOR, +} from "./fixtures.ts"; +import { describe, expect, it } from "./suite.ts"; + +const decode = (b: Uint8Array) => new TextDecoder().decode(b); + +function mockOutput(tty = true) { + let written: Uint8Array[] = []; + return { + written, + stream: { + isTTY: tty, + write(bytes: Uint8Array) { + written.push(bytes.slice()); + return true; + }, + }, + }; +} + +function mockInput(tty = true) { + let listeners = new Set<(chunk: Uint8Array) => void>(); + let rawCalls: boolean[] = []; + return { + rawCalls, + feed(bytes: Uint8Array) { + for (let cb of [...listeners]) cb(bytes); + }, + stream: { + isTTY: tty, + isRaw: false, + setRawMode(raw: boolean) { + rawCalls.push(raw); + }, + on(_event: "data", cb: (chunk: Uint8Array) => void) { + listeners.add(cb); + }, + off(_event: "data", cb: (chunk: Uint8Array) => void) { + listeners.delete(cb); + }, + resume() {}, + pause() {}, + isPaused: () => false, + }, + }; +} + +/** queryTermInfo options that skip both the fs lookup and the probe. */ +function offline(overrides: Record = {}) { + return { + env: {}, + input: mockInput(false).stream, + output: mockOutput(false).stream, + ...overrides, + }; +} + +const BASELINE = { + generation: 1, + colors: 256, + trueColor: false, + bce: true, + autoMargin: true, + altScreen: true, + styledUnderline: false, + syncOutput: false, + kittyKeyboard: false, + kittyGraphics: false, + kittyColor: false, + hyperlinks: false, + pointerShape: false, + theme: {}, +}; + +describe("baseline", () => { + it("initializes to the xterm-256color baseline with no evidence", async () => { + let ti = await queryTermInfo(offline()); + expect(ti.capabilities).toEqual(BASELINE); + }); + + it("does not assume truecolor at baseline", async () => { + let ti = await queryTermInfo(offline()); + expect(ti.capabilities.trueColor).toBe(false); + }); +}); + +describe("terminfo parsing", () => { + it("parses a real xterm-256color entry (legacy format)", async () => { + let ti = await queryTermInfo(offline({ terminfo: XTERM_256COLOR })); + let caps = ti.capabilities; + expect(caps.colors).toBe(256); + expect(caps.trueColor).toBe(false); + expect(caps.bce).toBe(true); + expect(caps.autoMargin).toBe(true); + expect(caps.altScreen).toBe(true); + expect(caps.generation).toBeGreaterThan(1); + }); + + it("reads Tc / Su / Smulx from the extended capability table", async () => { + let ti = await queryTermInfo(offline({ terminfo: CLAYTERM_TC })); + let caps = ti.capabilities; + expect(caps.trueColor).toBe(true); + expect(caps.styledUnderline).toBe(true); + expect(caps.colors).toBe(256); + expect(caps.bce).toBe(true); + }); + + it("parses the extended number format (magic 01036)", async () => { + let ti = await queryTermInfo(offline({ terminfo: CLAYTERM_DIRECT })); + let caps = ti.capabilities; + expect(caps.colors).toBe(0x1000000); + expect(caps.trueColor).toBe(true); + expect(caps.altScreen).toBe(true); + expect(caps.bce).toBe(false); + }); + + it("downgrades below baseline on terminfo evidence", async () => { + let ti = await queryTermInfo(offline({ terminfo: CLAYTERM_16 })); + let caps = ti.capabilities; + expect(caps.colors).toBe(16); + expect(caps.altScreen).toBe(false); + expect(caps.bce).toBe(false); + expect(caps.trueColor).toBe(false); + }); + + it("keeps the baseline untouched on truncated input", async () => { + let ti = await queryTermInfo( + offline({ terminfo: XTERM_256COLOR.slice(0, 30) }), + ); + expect(ti.capabilities).toEqual(BASELINE); + }); + + it("keeps the baseline untouched on garbage input", async () => { + let ti = await queryTermInfo(offline({ terminfo: new Uint8Array(128) })); + expect(ti.capabilities).toEqual(BASELINE); + }); + + it("rejects terminfo larger than 32768 bytes", async () => { + await expect( + queryTermInfo(offline({ terminfo: new Uint8Array(32769) })), + ).rejects.toThrow(RangeError); + }); +}); + +describe("environment evidence", () => { + it("grants truecolor from COLORTERM=truecolor", async () => { + let ti = await queryTermInfo(offline({ + terminfo: XTERM_256COLOR, + env: { COLORTERM: "truecolor" }, + })); + expect(ti.capabilities.trueColor).toBe(true); + }); + + it("grants truecolor from COLORTERM=24bit", async () => { + let ti = await queryTermInfo(offline({ env: { COLORTERM: "24bit" } })); + expect(ti.capabilities.trueColor).toBe(true); + }); + + it("ignores other COLORTERM values", async () => { + let ti = await queryTermInfo(offline({ env: { COLORTERM: "yes" } })); + expect(ti.capabilities.trueColor).toBe(false); + }); + + it("bumps the generation when evidence changes capabilities", async () => { + let ti = await queryTermInfo(offline({ env: { COLORTERM: "truecolor" } })); + expect(ti.capabilities.generation).toBeGreaterThan(1); + }); +}); + +describe("filesystem lookup", () => { + async function fixtureDb(layout: "hex" | "letter"): Promise { + let dir = await Deno.makeTempDir(); + let sub = layout === "hex" ? "63" : "c"; + await Deno.mkdir(`${dir}/${sub}`, { recursive: true }); + await Deno.writeFile(`${dir}/${sub}/clayterm-tc`, CLAYTERM_TC); + return dir; + } + + it("finds an entry under $TERMINFO (hex directory layout)", async () => { + let db = await fixtureDb("hex"); + let ti = await queryTermInfo(offline({ + env: { TERM: "clayterm-tc", TERMINFO: db }, + })); + expect(ti.capabilities.trueColor).toBe(true); + }); + + it("finds an entry under $TERMINFO (first-letter layout)", async () => { + let db = await fixtureDb("letter"); + let ti = await queryTermInfo(offline({ + env: { TERM: "clayterm-tc", TERMINFO: db }, + })); + expect(ti.capabilities.trueColor).toBe(true); + }); + + it("finds an entry under $HOME/.terminfo", async () => { + let home = await Deno.makeTempDir(); + await Deno.mkdir(`${home}/.terminfo/63`, { recursive: true }); + await Deno.writeFile(`${home}/.terminfo/63/clayterm-tc`, CLAYTERM_TC); + let ti = await queryTermInfo(offline({ + env: { TERM: "clayterm-tc", HOME: home }, + })); + expect(ti.capabilities.trueColor).toBe(true); + }); + + it("resolves to the baseline when no entry is found", async () => { + let db = await Deno.makeTempDir(); + let ti = await queryTermInfo(offline({ + env: { TERM: "does-not-exist", TERMINFO: db }, + })); + expect(ti.capabilities).toEqual(BASELINE); + }); + + it("rejects terminal names with path separators", async () => { + let db = await Deno.makeTempDir(); + let ti = await queryTermInfo(offline({ + env: { TERM: "../../etc/passwd", TERMINFO: db }, + })); + expect(ti.capabilities).toEqual(BASELINE); + }); +}); + +const PROBE = "\x1b]10;?\x07" + + "\x1b]11;?\x07" + + "\x1b]12;?\x07" + + "\x1b]21;foreground=?;background=?;cursor=?\x1b\\" + + "\x1b]22;?__current__\x1b\\" + + "\x1bP+q524742;5463\x1b\\" + + "\x1b[?2026$p" + + "\x1b[?u" + + "\x1b_Gi=31,s=1,v=1,a=q,t=d,f=24;AAAA\x1b\\" + + "\x1b[c"; + +describe("probe", () => { + it("returns the query batch fenced by DA1", async () => { + let ti = await queryTermInfo(offline()); + expect(decode(ti.probe())).toBe(PROBE); + }); + + it("writes the probe batch once to a TTY output", async () => { + let input = mockInput(); + let output = mockOutput(); + await queryTermInfo({ + env: {}, + input: input.stream, + output: output.stream, + timeout: 10, + }); + expect(output.written.length).toBe(1); + expect(decode(output.written[0])).toBe(PROBE); + }); + + it("does not write the probe to a non-TTY output", async () => { + let input = mockInput(false); + let output = mockOutput(false); + await queryTermInfo({ + env: {}, + input: input.stream, + output: output.stream, + timeout: 10, + }); + expect(output.written.length).toBe(0); + }); + + it("enables raw mode for the probe window and restores it", async () => { + let input = mockInput(); + let output = mockOutput(); + await queryTermInfo({ + env: {}, + input: input.stream, + output: output.stream, + timeout: 10, + }); + expect(input.rawCalls).toEqual([true, false]); + }); + + it("resolves without probing when the signal is already aborted", async () => { + let input = mockInput(); + let output = mockOutput(); + let ti = await queryTermInfo({ + env: {}, + input: input.stream, + output: output.stream, + timeout: 1000, + signal: AbortSignal.abort(), + }); + expect(output.written.length).toBe(0); + expect(ti.capabilities).toEqual(BASELINE); + }); + + it("resolves on timeout when the terminal never answers", async () => { + let input = mockInput(); + let output = mockOutput(); + let before = Date.now(); + let ti = await queryTermInfo({ + env: {}, + input: input.stream, + output: output.stream, + timeout: 10, + }); + expect(Date.now() - before).toBeLessThan(1000); + expect(ti.capabilities).toEqual(BASELINE); + }); +}); + +describe("probe fence", () => { + it("resolves on the DA1 fence and applies probe responses", async () => { + let input = mockInput(); + let output = mockOutput(); + let promise = queryTermInfo({ + env: {}, + input: input.stream, + output: output.stream, + timeout: 5000, + }); + + // wait for the probe write, then answer like a capable terminal + await new Promise((resolve) => setTimeout(resolve, 1)); + expect(output.written.length).toBe(1); + let before = Date.now(); + input.feed(new TextEncoder().encode( + "\x1b]11;rgb:1e1e/2a2a/3b3b\x1b\\" + + "\x1bP1+r524742\x1b\\" + + "\x1b[?2026;2$y" + + "\x1b[?1u" + + "\x1b_Gi=31;OK\x1b\\" + + "\x1b[?65;1;9c", + )); + + let ti = await promise; + expect(Date.now() - before).toBeLessThan(1000); + let caps = ti.capabilities; + expect(caps.theme.background).toEqual({ r: 0x1e, g: 0x2a, b: 0x3b }); + expect(caps.trueColor).toBe(true); + expect(caps.syncOutput).toBe(true); + expect(caps.kittyKeyboard).toBe(true); + expect(caps.kittyGraphics).toBe(true); + }); + + it("keeps unanswered capabilities at their static values", async () => { + let input = mockInput(); + let output = mockOutput(); + let promise = queryTermInfo({ + env: {}, + input: input.stream, + output: output.stream, + timeout: 5000, + }); + + await new Promise((resolve) => setTimeout(resolve, 1)); + // a terminal that only answers DA1 + input.feed(new TextEncoder().encode("\x1b[?1;2c")); + + let ti = await promise; + expect(ti.capabilities.trueColor).toBe(false); + expect(ti.capabilities.syncOutput).toBe(false); + expect(ti.capabilities.theme).toEqual({}); + }); +}); + +describe("attachment", () => { + it("allows at most one Input per handle", async () => { + let ti = await queryTermInfo(offline()); + await createInput({ terminfo: ti }); + await expect(createInput({ terminfo: ti })).rejects.toThrow( + "already attached", + ); + }); + + it("allows at most one Term per handle", async () => { + let ti = await queryTermInfo(offline()); + await createTerm({ width: 4, height: 2, terminfo: ti }); + await expect(createTerm({ width: 4, height: 2, terminfo: ti })) + .rejects.toThrow("already attached"); + }); +});