From 6d4091be31ba7c793af0c450adf56eea0a12fdf6 Mon Sep 17 00:00:00 2001 From: Nate Moore Date: Sat, 19 Sep 2026 00:26:11 -0400 Subject: [PATCH 01/10] =?UTF-8?q?feat:=20term.update()=20folds=20Capabilit?= =?UTF-8?q?yEvents=20into=20runtime=20capabilities=20(renderer-spec=20?= =?UTF-8?q?=C2=A77.7,=20=C2=A78.6)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .changeset/terminfo-capability-layer.md | 32 ++++ term.ts | 222 +++++++++++++++++------- test/term.test.ts | 25 ++- validate.ts | 3 + 4 files changed, 204 insertions(+), 78 deletions(-) create mode 100644 .changeset/terminfo-capability-layer.md diff --git a/.changeset/terminfo-capability-layer.md b/.changeset/terminfo-capability-layer.md new file mode 100644 index 0000000..5c37187 --- /dev/null +++ b/.changeset/terminfo-capability-layer.md @@ -0,0 +1,32 @@ +--- +"@bomb.sh/tty": minor +--- + +Adds `detectTerminal`, `Capabilities`, `Detection`, `CapabilityEvent`, and `KeyTable` to the public API. + +`detectTerminal()` reads a compiled terminfo binary (from disk or injected bytes), applies environment evidence (`COLORTERM`), and returns a frozen `Detection` carrying static `Capabilities`, a `probe` query batch to write to stdout, and opaque `keys` bytes for the input parser. + +Pass `detection` to `createInput` to seed its escape-sequence trie with terminal-specific `key_*` sequences. Pass capability events from `scan()` to `term.update()` to keep the renderer's runtime capability snapshot current. + +`scan()` now recognizes probe responses — OSC 10/11/12/21/22, XTGETTCAP, DECRPM mode 2026, kitty keyboard, kitty graphics APC, and the DA1 fence — and surfaces them as `CapabilityEvent` values interleaved with key and mouse events. Route these to `term.update()`, which returns a `Uint8Array` of bytes to write immediately (empty when no output is needed). + +`InputOptions.terminfo` is replaced by `InputOptions.detection`. Both parsers remain usable without a `Detection`; the 256-color baseline and xterm default key sequences apply when it is omitted. + +#### Migration + +```diff +-const input = await createInput({ terminfo: await Deno.readFile(terminfoPath) }); ++const detection = await detectTerminal({ env: process.env }); ++const input = await createInput({ detection }); ++process.stdout.write(detection.probe); +``` + +```diff +-term.update({ events: resizeEvents }); ++for (const event of input.scan(bytes).events) { ++ if (event.type === "resize" || event.type === "capability") { ++ const out = term.update(event); ++ if (out.length) process.stdout.write(out); ++ } ++} +``` diff --git a/term.ts b/term.ts index 174f311..5eedb06 100644 --- a/term.ts +++ b/term.ts @@ -1,44 +1,107 @@ import { type Op, pack } from "./ops.ts"; import { type BoundingBox, createTermNative } from "./term-native.ts"; +import type { CapabilityEvent, ColorDepth, InputEvent } from "./input.ts"; +import type { Capabilities, Detection, Rgb } from "./terminfo.ts"; + +export type { BoundingBox }; export interface TermOptions { height: number; width: number; + /** + * Detection from detectTerminal(). Initializes the renderer with the + * static capabilities from the detection and seeds the private TermInfo + * struct. When omitted, the renderer uses the 256-color baseline. + */ + detection?: Detection; } /** - * Structural resize event accepted by update() (renderer-spec 7.7). - * The input parser's ResizeEvent is assignable to this shape. + * The renderer's merged view of capabilities: the static Capabilities fields + * plus all CapabilityEvent values folded in by update() calls. */ -export interface TermResizeEvent { - type: "resize"; - width: number; - height: number; +export interface RuntimeCapabilities extends Capabilities { + readonly syncOutput: boolean; + readonly kittyKeyboard: boolean; + readonly kittyGraphics: boolean; + readonly pointerShape: boolean; + readonly theme: { + readonly foreground?: Rgb; + readonly background?: Rgb; + readonly cursor?: Rgb; + }; } /** - * Options bag for update() (renderer-spec 8.6): explicit dimensions, - * or an event array from which resize events are read (last one wins; - * non-resize events are ignored). + * One change accepted by term.update(). Either a structural resize or a + * CapabilityEvent routed from scan(). Non-capability InputEvents are silently + * ignored, so the full events array from scan() can be passed without filtering. */ -export type UpdateOptions = - | { width: number; height: number } - | { events: ReadonlyArray }; +export type Update = { width: number; height: number } | InputEvent; + +/** + * Apply one Update to the current RuntimeCapabilities and return the next + * snapshot plus any bytes to write now. Pure: performs no IO, no WASM calls. + */ +export function applyUpdate( + current: RuntimeCapabilities, + change: Update, +): { readonly next: RuntimeCapabilities; readonly bytes: Uint8Array } { + if ("width" in change) { + return { next: current, bytes: new Uint8Array(0) }; + } + if ((change as { type?: string }).type !== "capability") { + return { next: current, bytes: new Uint8Array(0) }; + } + let cap = change as CapabilityEvent; + let next: RuntimeCapabilities; + switch (cap.key) { + case "foreground-color": + next = { ...current, theme: { ...current.theme, foreground: cap.value } }; + break; + case "background-color": + next = { ...current, theme: { ...current.theme, background: cap.value } }; + break; + case "cursor-color": + next = { ...current, theme: { ...current.theme, cursor: cap.value } }; + break; + case "colordepth": { + let trueColor = (cap.value as ColorDepth) === "truecolor"; + next = { ...current, trueColor }; + break; + } + case "sync-output": + next = { ...current, syncOutput: cap.value as boolean }; + break; + case "kitty-keyboard": + next = { ...current, kittyKeyboard: cap.value as boolean }; + break; + case "kitty-graphics": + next = { ...current, kittyGraphics: cap.value as boolean }; + break; + case "pointer-shape": + next = { ...current, pointerShape: cap.value as boolean }; + break; + default: + next = current; + } + return { next: Object.freeze(next), bytes: new Uint8Array(0) }; +} + +function runtimeFromStatic(caps: Capabilities): RuntimeCapabilities { + return Object.freeze({ + ...caps, + syncOutput: false, + kittyKeyboard: false, + kittyGraphics: false, + pointerShape: false, + theme: Object.freeze({}), + }); +} export interface RenderOptions { mode?: "line"; - - /** - * Row where to begin rendering. This should only be used when - * rendering into a region as part of the CLI main screen. For - * interfaces that use the entire screen, leave unset which will - * default to 0. This is 1-based which which is the DSR native - * format. - * - * https://www.ecma-international.org/publications-and-standards/standards/ecma-48/ - */ row?: number; - pointer?: { x: number; y: number; @@ -52,8 +115,6 @@ export type PointerEvent = | { type: "pointerleave"; id: string } | { type: "pointerclick"; id: string }; -export type { BoundingBox }; - export interface ElementInfo { bounds: BoundingBox; } @@ -93,18 +154,39 @@ export interface Term { render(ops: Op[], options?: RenderOptions): RenderResult; /** - * Change dimensions in place (renderer-spec 7.7). Synchronous. The - * next render() after a non-no-op update emits a complete redraw, - * and output views from prior renders become invalid. + * Apply one change or a batch of changes. Returns bytes to write now. + * An empty array is valid when no immediate output is needed (TINV-5). + * + * Route CapabilityEvents from scan() here. For resize, pass + * { width, height }. */ - update(options: UpdateOptions): void; + update(change: Update | readonly Update[]): Uint8Array; + + /** Frozen snapshot of the current merged capability state. */ + readonly capabilities: RuntimeCapabilities; } export async function createTerm(options: TermOptions): Promise { - let { width, height } = options; - let native = await createTermNative(width, height); + let { width, height, detection } = options; + + let native = await createTermNative( + width, + height, + ); let { memory } = native; + let currentCaps: RuntimeCapabilities = runtimeFromStatic( + detection?.capabilities ?? { + colors: 256, + trueColor: false, + bce: true, + autoMargin: true, + xenl: true, + altScreen: true, + styledUnderline: false, + }, + ); + let prev = new Set(); let pressed = new Set(); let wasDown = false; @@ -112,6 +194,10 @@ export async function createTerm(options: TermOptions): Promise { let wasAnimating = false; return { + get capabilities(): RuntimeCapabilities { + return currentCaps; + }, + render(ops: Op[], options?: RenderOptions): RenderResult { let len = pack( ops, @@ -182,9 +268,7 @@ export async function createTerm(options: TermOptions): Promise { let info: RenderInfo = { get(id: string): ElementInfo | undefined { let bounds = native.getElementBounds(id); - if (bounds) { - return { bounds }; - } + if (bounds) return { bounds }; return undefined; }, }; @@ -203,40 +287,50 @@ export async function createTerm(options: TermOptions): Promise { wasAnimating = animating; return { output, events, info, errors, animating }; }, - update(options: UpdateOptions): void { - let w: number | undefined; - let h: number | undefined; - if ("events" in options) { - for (let e of options.events) { - if (e.type === "resize") { - let r = e as TermResizeEvent; - w = r.width; - h = r.height; + + update(change: Update | readonly Update[]): Uint8Array { + let changes = Array.isArray(change) ? change : [change]; + let out: Uint8Array[] = []; + + for (let c of changes as Update[]) { + let { next, bytes } = applyUpdate(currentCaps, c); + + if ("width" in c) { + let w = c.width; + let h = c.height; + if ( + !Number.isInteger(w) || !Number.isInteger(h) || w <= 0 || h <= 0 + ) { + throw new RangeError(`invalid terminal dimensions ${w}x${h}`); } + if (w !== width || h !== height) { + width = w; + height = h; + native.update(width, height); + prev = new Set(); + pressed = new Set(); + wasDown = false; + lastRenderAt = undefined; + wasAnimating = false; + } + } else { + // Capability events update the foundation snapshot. Feature PRs own + // any renderer-side output or invalidation for those capabilities. } - if (w === undefined || h === undefined) { - return; - } - } else { - w = options.width; - h = options.height; - } - if ( - !Number.isInteger(w) || !Number.isInteger(h) || w <= 0 || h <= 0 - ) { - throw new RangeError(`invalid terminal dimensions ${w}x${h}`); + + currentCaps = next; + if (bytes.length) out.push(bytes); } - if (w === width && h === height) { - return; + + if (out.length === 0) return new Uint8Array(0); + let total = out.reduce((n, b) => n + b.length, 0); + let result = new Uint8Array(total); + let offset = 0; + for (let b of out) { + result.set(b, offset); + offset += b.length; } - width = w; - height = h; - native.update(w, h); - prev = new Set(); - pressed = new Set(); - wasDown = false; - lastRenderAt = undefined; - wasAnimating = false; + return result; }, }; } diff --git a/test/term.test.ts b/test/term.test.ts index fe72c6c..f17cf12 100644 --- a/test/term.test.ts +++ b/test/term.test.ts @@ -53,8 +53,8 @@ describe("term", () => { ]).output, ); - // the SGR active when "h" is emitted should include the - // parent's red background (48;2;255;0;0), not terminal default + // The SGR active when "h" is emitted should include the parent's red + // background, not the terminal default. let before = ansi.slice(0, ansi.indexOf("h")); expect(before).toContain("\x1b[48;2;255;0;0"); }); @@ -769,15 +769,12 @@ hi ); }); - it("accepts an event array, last resize wins, non-resize ignored", () => { - term.update({ - events: [ - { type: "key" }, - { type: "resize", width: 30, height: 8 }, - { type: "paste" }, - { type: "resize", width: 12, height: 4 }, - ], - }); + it("accepts an update array, last resize wins, non-resize updates applied", () => { + term.update([ + { width: 30, height: 8 }, + { type: "capability", key: "sync-output", value: false }, + { width: 12, height: 4 }, + ]); let result = term.render(frame); expect(result.info.get("root")?.bounds).toEqual({ x: 0, @@ -787,11 +784,11 @@ hi }); }); - it("treats an event array with no resize events as a no-op", () => { + it("treats an update array with no resize as a no-op for layout", () => { term.render(frame); - term.update({ events: [{ type: "key" }, { type: "paste" }] }); + term.update([{ type: "capability", key: "sync-output", value: false }]); expect(term.render(frame).output.length).toBe(0); - term.update({ events: [] }); + term.update([]); expect(term.render(frame).output.length).toBe(0); }); diff --git a/validate.ts b/validate.ts index e92c1e6..baec38e 100644 --- a/validate.ts +++ b/validate.ts @@ -230,6 +230,9 @@ export function assert(ops: unknown): asserts ops is Op[] { export function validated(term: Term): Term { return { + get capabilities() { + return term.capabilities; + }, render(ops: Op[], options?: RenderOptions): RenderResult { assert(ops); return term.render(ops, options); From b1b3a19d0fbe300f491864d5314aeebfb1cb8f9b Mon Sep 17 00:00:00 2001 From: Nate Moore Date: Tue, 29 Sep 2026 21:45:51 -0400 Subject: [PATCH 02/10] refactor(term): make applyUpdate module-private The terminfo spec no longer lists applyUpdate as public API; update() is the only entry point, and tests already exercise it through term.update(). --- term.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/term.ts b/term.ts index 5eedb06..a8c4f23 100644 --- a/term.ts +++ b/term.ts @@ -43,7 +43,7 @@ export type Update = { width: number; height: number } | InputEvent; * Apply one Update to the current RuntimeCapabilities and return the next * snapshot plus any bytes to write now. Pure: performs no IO, no WASM calls. */ -export function applyUpdate( +function applyUpdate( current: RuntimeCapabilities, change: Update, ): { readonly next: RuntimeCapabilities; readonly bytes: Uint8Array } { From 7cdd38102460fbce6b2204734c5911ce7c5118a1 Mon Sep 17 00:00:00 2001 From: Nate Moore Date: Tue, 29 Sep 2026 21:53:11 -0400 Subject: [PATCH 03/10] test(term): cover no-op InputEvent steps in update() The renderer spec now accepts any InputEvent as an Update and requires non-resize, non-capability events to change no state and emit no bytes. --- test/term.test.ts | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/test/term.test.ts b/test/term.test.ts index f17cf12..a942a4a 100644 --- a/test/term.test.ts +++ b/test/term.test.ts @@ -792,6 +792,18 @@ hi expect(term.render(frame).output.length).toBe(0); }); + it("ignores input events that are neither resize nor capability", () => { + term.render(frame); + let before = term.capabilities; + let out = term.update([ + { type: "keydown", key: "a", code: "a", text: "a" }, + { type: "mousemove", button: "left", x: 1, y: 1 }, + ]); + expect(out).toEqual(new Uint8Array(0)); + expect(term.capabilities).toEqual(before); + expect(term.render(frame).output.length).toBe(0); + }); + it("discards pointer interaction state on resize", () => { let pointer = { x: 1, y: 0, down: false }; let first = term.render(frame, { pointer }); From 3b6a1ef53c2c516c2d65928914c1a0160397e477 Mon Sep 17 00:00:00 2001 From: Nate Moore Date: Mon, 5 Oct 2026 22:02:59 -0500 Subject: [PATCH 04/10] refactor!: createTerm({ terminfo }) takes a TerminalInfo MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Match createInput: the renderer option is `terminfo`, typed `TerminalInfo`. Adds the first test that seeds createTerm from a detected TerminalInfo (previously untested; test/caps.ts helpers were unused). The stack changeset now covers only what this PR and #131 add — detectTerminal/TerminalInfo, createTerm's option, term.capabilities, and the update() signature change — since #132 carries its own changeset for the input side. --- .changeset/terminfo-capability-layer.md | 28 ++++++++++--------------- term.ts | 12 +++++------ test/term.test.ts | 17 +++++++++++++++ 3 files changed, 34 insertions(+), 23 deletions(-) diff --git a/.changeset/terminfo-capability-layer.md b/.changeset/terminfo-capability-layer.md index 5c37187..1e55fa8 100644 --- a/.changeset/terminfo-capability-layer.md +++ b/.changeset/terminfo-capability-layer.md @@ -2,31 +2,25 @@ "@bomb.sh/tty": minor --- -Adds `detectTerminal`, `Capabilities`, `Detection`, `CapabilityEvent`, and `KeyTable` to the public API. +Adds `detectTerminal()`, `TerminalInfo`, `Capabilities`, `DetectOptions`, `KeyTable`, and `MAX_TERMINFO_ENTRY` to the public API, and a `terminfo` option to `createTerm`. -`detectTerminal()` reads a compiled terminfo binary (from disk or injected bytes), applies environment evidence (`COLORTERM`), and returns a frozen `Detection` carrying static `Capabilities`, a `probe` query batch to write to stdout, and opaque `keys` bytes for the input parser. +`detectTerminal()` reads the compiled terminfo entry for the current terminal (from the ncurses search path, or from bytes passed as `entry`), applies environment evidence (`COLORTERM`), and resolves a frozen `TerminalInfo` carrying static `capabilities`, a `probe` query batch to write to stdout, and opaque `keys` for the input parser. -Pass `detection` to `createInput` to seed its escape-sequence trie with terminal-specific `key_*` sequences. Pass capability events from `scan()` to `term.update()` to keep the renderer's runtime capability snapshot current. +Pass the same `TerminalInfo` as `terminfo` to `createTerm` and `createInput`. `term.capabilities` exposes the renderer's current capability snapshot, seeded from `terminfo.capabilities` (or the 256-color baseline when omitted). -`scan()` now recognizes probe responses — OSC 10/11/12/21/22, XTGETTCAP, DECRPM mode 2026, kitty keyboard, kitty graphics APC, and the DA1 fence — and surfaces them as `CapabilityEvent` values interleaved with key and mouse events. Route these to `term.update()`, which returns a `Uint8Array` of bytes to write immediately (empty when no output is needed). - -`InputOptions.terminfo` is replaced by `InputOptions.detection`. Both parsers remain usable without a `Detection`; the 256-color baseline and xterm default key sequences apply when it is omitted. +**Breaking:** `term.update()` now takes one change or an array of changes — a `{ width, height }` resize or any `InputEvent` — instead of `{ events }`. Capability events from `scan()` are folded into `term.capabilities`; other input events are ignored, so the whole `events` array can be passed through. `update()` returns a `Uint8Array` of bytes to write immediately (empty when there are none). #### Migration ```diff --const input = await createInput({ terminfo: await Deno.readFile(terminfoPath) }); -+const detection = await detectTerminal({ env: process.env }); -+const input = await createInput({ detection }); -+process.stdout.write(detection.probe); +-term.update({ events }); ++const out = term.update(events); ++if (out.length) process.stdout.write(out); ``` ```diff --term.update({ events: resizeEvents }); -+for (const event of input.scan(bytes).events) { -+ if (event.type === "resize" || event.type === "capability") { -+ const out = term.update(event); -+ if (out.length) process.stdout.write(out); -+ } -+} ++const terminfo = await detectTerminal({ env: process.env }); ++const term = await createTerm({ width, height, terminfo }); ++const input = await createInput({ terminfo }); ++process.stdout.write(terminfo.probe); ``` diff --git a/term.ts b/term.ts index a8c4f23..7be05bc 100644 --- a/term.ts +++ b/term.ts @@ -1,7 +1,7 @@ import { type Op, pack } from "./ops.ts"; import { type BoundingBox, createTermNative } from "./term-native.ts"; import type { CapabilityEvent, ColorDepth, InputEvent } from "./input.ts"; -import type { Capabilities, Detection, Rgb } from "./terminfo.ts"; +import type { Capabilities, Rgb, TerminalInfo } from "./terminfo.ts"; export type { BoundingBox }; @@ -9,11 +9,11 @@ export interface TermOptions { height: number; width: number; /** - * Detection from detectTerminal(). Initializes the renderer with the - * static capabilities from the detection and seeds the private TermInfo + * Terminal info from detectTerminal(). Initializes the renderer with + * its static capabilities and seeds the private TermInfo * struct. When omitted, the renderer uses the 256-color baseline. */ - detection?: Detection; + terminfo?: TerminalInfo; } /** @@ -167,7 +167,7 @@ export interface Term { } export async function createTerm(options: TermOptions): Promise { - let { width, height, detection } = options; + let { width, height, terminfo } = options; let native = await createTermNative( width, @@ -176,7 +176,7 @@ export async function createTerm(options: TermOptions): Promise { let { memory } = native; let currentCaps: RuntimeCapabilities = runtimeFromStatic( - detection?.capabilities ?? { + terminfo?.capabilities ?? { colors: 256, trueColor: false, bce: true, diff --git a/test/term.test.ts b/test/term.test.ts index a942a4a..0ba5b42 100644 --- a/test/term.test.ts +++ b/test/term.test.ts @@ -12,6 +12,7 @@ import { text, } from "../ops.ts"; import { print } from "./print.ts"; +import { trueColorDetect } from "./caps.ts"; const decode = (bytes: Uint8Array) => new TextDecoder().decode(bytes); const trim = (s: string) => s.split("\n").map((l) => l.trimEnd()).join("\n"); @@ -737,6 +738,22 @@ hi }); }); + describe("capabilities", () => { + it("starts from the 256-color baseline without terminfo", () => { + expect(term.capabilities.colors).toBe(256); + expect(term.capabilities.trueColor).toBe(false); + }); + + it("seeds static capabilities from terminfo", async () => { + let terminfo = await trueColorDetect(); + let seeded = await createTerm({ width: 40, height: 10, terminfo }); + expect(seeded.capabilities.trueColor).toBe(true); + for (let [key, value] of Object.entries(terminfo.capabilities)) { + expect(seeded.capabilities).toHaveProperty(key, value); + } + }); + }); + describe("update", () => { let frame: Op[] = [ open("root", { From 9b7cbacb5efa5dc358d11bb290ec9f1260f34372 Mon Sep 17 00:00:00 2001 From: Nate Moore Date: Thu, 8 Oct 2026 15:22:07 -0500 Subject: [PATCH 05/10] spec: term.update() takes an array of InputEvents MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the `Update | readonly Update[]` overload with a single `update(events: readonly InputEvent[])` signature (renderer-spec §7.7, §8.6; terminfo-spec §10.4, §10.5). ResizeEvent is already a member of InputEvent, so a separate `Update` union adds nothing. Discriminating on `type` instead of a structural `{ width, height }` shape makes every step a tagged variant. The host loop collapses to passing `scan().events` straight through, and out-of-band resizes (SIGWINCH) pass a constructed ResizeEvent. The bare `{ width, height }` shape shipped in 0.9.0 is removed in the same release that already breaks `update({ events })`, so callers migrate once. --- specs/renderer-spec.md | 57 +++++++++++++++++++----------------------- specs/terminfo-spec.md | 29 ++++++--------------- 2 files changed, 34 insertions(+), 52 deletions(-) diff --git a/specs/renderer-spec.md b/specs/renderer-spec.md index 836fc45..35d2121 100644 --- a/specs/renderer-spec.md +++ b/specs/renderer-spec.md @@ -418,22 +418,22 @@ The update transaction changes a Term instance's dimensions or capability state in place. Like the render transaction (§7.2), it is synchronous: it MUST NOT yield, suspend, or require callbacks during execution. -**Inputs.** The update transaction accepts one `Update` or an ordered array of -`Update` values. `Update` is a discriminated union: +**Inputs.** The update transaction accepts an ordered array of `InputEvent` +values (see [Input Specification](input-spec.md) §5), discriminated by `type`: -- `{ width: number; height: number }` — a resize to the given character-cell - dimensions. Both MUST be positive integers; the transaction MUST throw - otherwise. +- A `ResizeEvent` (`{ type: "resize"; width: number; height: number }`) — a + resize to the given character-cell dimensions. Both MUST be positive integers; + the transaction MUST throw otherwise. - A `CapabilityEvent` (see [Terminfo Specification](terminfo-spec.md) §6.3) — a capability value delivered by the input parser from a probe response. -- Any other `InputEvent` (see [Input Specification](input-spec.md) §5) — a no-op - step. It changes no state and contributes no bytes. +- Any other `InputEvent` — a no-op step. It changes no state and contributes no + bytes. -When a batch is provided, the Term folds each `Update` in order. The returned -bytes are the concatenation of each fold's output. +The Term folds each event in array order. The returned bytes are the +concatenation of each fold's output. An empty array is a no-op. -A resize `Update` whose target dimensions equal the Term's current dimensions -MUST be a no-op for that step. +A resize event whose target dimensions equal the Term's current dimensions MUST +be a no-op for that step. **Resize semantics.** A non-no-op resize step: @@ -672,39 +672,34 @@ wherever the directive model expects a color. ### 8.6 Term update ``` -term.update(change: Update | readonly Update[]): Uint8Array - -type Update = - | { width: number; height: number } - | InputEvent +term.update(events: readonly InputEvent[]): Uint8Array ``` Performs an update transaction as defined in §7.7. `update()` is the universal sink for both resize and capability change. -**`Update` shapes.** A resize step is `{ width, height }`. A capability step is -any `CapabilityEvent` value (see [Terminfo Specification](terminfo-spec.md) -§6.3). The two shapes are structurally distinct and MUST NOT be combined in a -single object. Every other `InputEvent` is accepted and ignored, so the full -`events` array from `input.scan()` can be passed without filtering. Pass an -array to apply multiple updates in one call; they are folded in order. +**Events.** A resize step is a `ResizeEvent` +(`{ type: "resize", width, height }`). A capability step is any +`CapabilityEvent` value (see [Terminfo Specification](terminfo-spec.md) §6.3). +Every other `InputEvent` is accepted and ignored, so the full `events` array +from `input.scan()` can be passed without filtering. Events are folded in array +order. **Return value.** `update()` always returns a `Uint8Array`. Write it to the terminal immediately when non-empty. Do not wait for the next `render()`. An empty array means the update changed no rendered state. -**Resize shape.** The `{ width, height }` shape is defined structurally by this -specification. It is intentionally assignable from the input specification's -`ResizeEvent`, so events from `input.scan()` pass through directly: +**Host usage.** Events from `input.scan()` pass through directly. Resizes +observed outside the input stream (e.g. `SIGWINCH`) are passed as a constructed +`ResizeEvent`: ``` const { events } = input.scan(bytes); -for (const event of events) { - if (event.type === "resize" || event.type === "capability") { - const out = term.update(event); - if (out.length) stdout.write(out); - } -} +const out = term.update(events); +if (out.length) stdout.write(out); + +// on SIGWINCH +term.update([{ type: "resize", width: cols(), height: rows() }]); ``` A resize to the Term's current dimensions is a no-op for that step. diff --git a/specs/terminfo-spec.md b/specs/terminfo-spec.md index 8c8c870..d800909 100644 --- a/specs/terminfo-spec.md +++ b/specs/terminfo-spec.md @@ -511,19 +511,15 @@ When omitted, the parser uses built-in xterm key sequences. ```ts interface Term { render(ops: Op[], options?: RenderOptions): RenderResult; - update(change: Update | readonly Update[]): Uint8Array; + update(events: readonly InputEvent[]): Uint8Array; readonly capabilities: RuntimeCapabilities; } - -type Update = - | { width: number; height: number } - | InputEvent; ``` -`update()` accepts one change or a batch. `InputEvent` values other than +`update()` accepts an array of `InputEvent` values. Events other than `ResizeEvent` and `CapabilityEvent` are no-op steps, so the full `events` array -from `scan()` can be passed without filtering. A batch is folded in order: each -`Update` produces the next `RuntimeCapabilities` and any bytes defined by the +from `scan()` can be passed without filtering. Events are folded in order: each +event produces the next `RuntimeCapabilities` and any bytes defined by the consuming feature, and the returned bytes are concatenated. The foundation defines no such bytes. The return value is always a `Uint8Array`; callers write it to their output stream when non-empty (TINV-5). @@ -545,22 +541,13 @@ process.stdout.write(terminfo.probe); process.stdin.on("data", (bytes: Uint8Array) => { const { events } = input.scan(bytes); - for (const event of events) { - switch (event.type) { - case "capability": - case "resize": { - const out = term.update(event); - if (out.length) process.stdout.write(out); - break; - } - default: - dispatch(event); - } - } + const out = term.update(events); + if (out.length) process.stdout.write(out); + for (const event of events) dispatch(event); }); process.on("SIGWINCH", () => { - const out = term.update({ width: cols(), height: rows() }); + const out = term.update([{ type: "resize", width: cols(), height: rows() }]); if (out.length) process.stdout.write(out); }); ``` From 460f7e138069e50a17a308b72ff32003a2d86fee Mon Sep 17 00:00:00 2001 From: Nate Moore Date: Thu, 8 Oct 2026 15:23:03 -0500 Subject: [PATCH 06/10] refactor!: term.update() takes readonly InputEvent[] Implements the spec change in the previous commit. The `Update` type and the single-or-array overload are gone; update() takes `readonly InputEvent[]` and folds each event in order. Steps now discriminate on `type`: `"resize"` resizes, `"capability"` folds into RuntimeCapabilities, everything else is a no-op. That drops the structural `"width" in` check and every cast in applyUpdate, since CapabilityEvent narrows by `key`. A new test pins the tag-based discrimination: an untagged `{ width, height }` no longer resizes. validated() forwards the new signature unchanged. --- term.ts | 63 ++++++++++++++++++++--------------------------- test/term.test.ts | 37 ++++++++++++++++++---------- validate.ts | 4 +-- 3 files changed, 53 insertions(+), 51 deletions(-) diff --git a/term.ts b/term.ts index 7be05bc..4fbd96e 100644 --- a/term.ts +++ b/term.ts @@ -1,6 +1,6 @@ import { type Op, pack } from "./ops.ts"; import { type BoundingBox, createTermNative } from "./term-native.ts"; -import type { CapabilityEvent, ColorDepth, InputEvent } from "./input.ts"; +import type { InputEvent } from "./input.ts"; import type { Capabilities, Rgb, TerminalInfo } from "./terminfo.ts"; export type { BoundingBox }; @@ -33,54 +33,49 @@ export interface RuntimeCapabilities extends Capabilities { } /** - * One change accepted by term.update(). Either a structural resize or a - * CapabilityEvent routed from scan(). Non-capability InputEvents are silently - * ignored, so the full events array from scan() can be passed without filtering. - */ -export type Update = { width: number; height: number } | InputEvent; - -/** - * Apply one Update to the current RuntimeCapabilities and return the next + * Fold one InputEvent into the current RuntimeCapabilities and return the next * snapshot plus any bytes to write now. Pure: performs no IO, no WASM calls. */ function applyUpdate( current: RuntimeCapabilities, - change: Update, + event: InputEvent, ): { readonly next: RuntimeCapabilities; readonly bytes: Uint8Array } { - if ("width" in change) { + if (event.type !== "capability") { return { next: current, bytes: new Uint8Array(0) }; } - if ((change as { type?: string }).type !== "capability") { - return { next: current, bytes: new Uint8Array(0) }; - } - let cap = change as CapabilityEvent; let next: RuntimeCapabilities; - switch (cap.key) { + switch (event.key) { case "foreground-color": - next = { ...current, theme: { ...current.theme, foreground: cap.value } }; + next = { + ...current, + theme: { ...current.theme, foreground: event.value }, + }; break; case "background-color": - next = { ...current, theme: { ...current.theme, background: cap.value } }; + next = { + ...current, + theme: { ...current.theme, background: event.value }, + }; break; case "cursor-color": - next = { ...current, theme: { ...current.theme, cursor: cap.value } }; + next = { ...current, theme: { ...current.theme, cursor: event.value } }; break; case "colordepth": { - let trueColor = (cap.value as ColorDepth) === "truecolor"; + let trueColor = event.value === "truecolor"; next = { ...current, trueColor }; break; } case "sync-output": - next = { ...current, syncOutput: cap.value as boolean }; + next = { ...current, syncOutput: event.value }; break; case "kitty-keyboard": - next = { ...current, kittyKeyboard: cap.value as boolean }; + next = { ...current, kittyKeyboard: event.value }; break; case "kitty-graphics": - next = { ...current, kittyGraphics: cap.value as boolean }; + next = { ...current, kittyGraphics: event.value }; break; case "pointer-shape": - next = { ...current, pointerShape: cap.value as boolean }; + next = { ...current, pointerShape: event.value }; break; default: next = current; @@ -154,13 +149,13 @@ export interface Term { render(ops: Op[], options?: RenderOptions): RenderResult; /** - * Apply one change or a batch of changes. Returns bytes to write now. - * An empty array is valid when no immediate output is needed (TINV-5). + * Fold InputEvents in order. Returns bytes to write now. An empty array is + * valid when no immediate output is needed (TINV-5). * - * Route CapabilityEvents from scan() here. For resize, pass - * { width, height }. + * Pass the events array from scan() directly. For an out-of-band resize, + * pass [{ type: "resize", width, height }]. */ - update(change: Update | readonly Update[]): Uint8Array; + update(events: readonly InputEvent[]): Uint8Array; /** Frozen snapshot of the current merged capability state. */ readonly capabilities: RuntimeCapabilities; @@ -288,14 +283,13 @@ export async function createTerm(options: TermOptions): Promise { return { output, events, info, errors, animating }; }, - update(change: Update | readonly Update[]): Uint8Array { - let changes = Array.isArray(change) ? change : [change]; + update(events: readonly InputEvent[]): Uint8Array { let out: Uint8Array[] = []; - for (let c of changes as Update[]) { + for (let c of events) { let { next, bytes } = applyUpdate(currentCaps, c); - if ("width" in c) { + if (c.type === "resize") { let w = c.width; let h = c.height; if ( @@ -313,9 +307,6 @@ export async function createTerm(options: TermOptions): Promise { lastRenderAt = undefined; wasAnimating = false; } - } else { - // Capability events update the foundation snapshot. Feature PRs own - // any renderer-side output or invalidation for those capabilities. } currentCaps = next; diff --git a/test/term.test.ts b/test/term.test.ts index 0ba5b42..7190fd6 100644 --- a/test/term.test.ts +++ b/test/term.test.ts @@ -1,6 +1,7 @@ // deno-lint-ignore-file no-control-regex import { beforeEach, describe, expect, it } from "./suite.ts"; import { createTerm, type Term } from "../term.ts"; +import type { InputEvent } from "../input.ts"; import { close, fixed, @@ -766,7 +767,7 @@ hi it("emits a complete redraw on the first render after update", () => { term.render(frame); expect(term.render(frame).output.length).toBe(0); - term.update({ width: 20, height: 5 }); + term.update([{ type: "resize", width: 20, height: 5 }]); let out = decode(term.render(frame).output); expect(trim(print(out, 20, 5))).toContain("Hi"); expect(out.length).toBeGreaterThan(0); @@ -774,23 +775,26 @@ hi it("is a no-op when dimensions are unchanged", () => { term.render(frame); - term.update({ width: 40, height: 10 }); + term.update([{ type: "resize", width: 40, height: 10 }]); expect(term.render(frame).output.length).toBe(0); }); it("throws on non-positive or non-integer dimensions", () => { - expect(() => term.update({ width: 0, height: 10 })).toThrow(RangeError); - expect(() => term.update({ width: 40, height: -1 })).toThrow(RangeError); - expect(() => term.update({ width: 40.5, height: 10 })).toThrow( - RangeError, - ); + expect(() => term.update([{ type: "resize", width: 0, height: 10 }])) + .toThrow(RangeError); + expect(() => term.update([{ type: "resize", width: 40, height: -1 }])) + .toThrow(RangeError); + expect(() => term.update([{ type: "resize", width: 40.5, height: 10 }])) + .toThrow( + RangeError, + ); }); it("accepts an update array, last resize wins, non-resize updates applied", () => { term.update([ - { width: 30, height: 8 }, + { type: "resize", width: 30, height: 8 }, { type: "capability", key: "sync-output", value: false }, - { width: 12, height: 4 }, + { type: "resize", width: 12, height: 4 }, ]); let result = term.render(frame); expect(result.info.get("root")?.bounds).toEqual({ @@ -809,6 +813,13 @@ hi expect(term.render(frame).output.length).toBe(0); }); + it('resizes only on events tagged type: "resize"', () => { + term.render(frame); + let untagged = { width: 12, height: 4 } as unknown as InputEvent; + term.update([untagged]); + expect(term.render(frame).output.length).toBe(0); + }); + it("ignores input events that are neither resize nor capability", () => { term.render(frame); let before = term.capabilities; @@ -826,14 +837,14 @@ hi let first = term.render(frame, { pointer }); expect(first.events).toContainEqual({ type: "pointerenter", id: "root" }); expect(term.render(frame, { pointer }).events).toEqual([]); - term.update({ width: 20, height: 5 }); + term.update([{ type: "resize", width: 20, height: 5 }]); let after = term.render(frame, { pointer }); expect(after.events).toContainEqual({ type: "pointerenter", id: "root" }); }); it("resizes in place and lays out at the new dimensions", () => { term.render(frame); - term.update({ width: 12, height: 4 }); + term.update([{ type: "resize", width: 12, height: 4 }]); let result = term.render(frame); expect(result.info.get("root")?.bounds).toEqual({ x: 0, @@ -855,7 +866,7 @@ hi it("survives downsize then upsize past the original size", () => { term.render(frame); - term.update({ width: 10, height: 3 }); + term.update([{ type: "resize", width: 10, height: 3 }]); let small = term.render(frame); expect(small.info.get("root")?.bounds).toEqual({ x: 0, @@ -863,7 +874,7 @@ hi width: 10, height: 3, }); - term.update({ width: 120, height: 40 }); + term.update([{ type: "resize", width: 120, height: 40 }]); let large = term.render(frame); expect(large.info.get("root")?.bounds).toEqual({ x: 0, diff --git a/validate.ts b/validate.ts index baec38e..d98c94a 100644 --- a/validate.ts +++ b/validate.ts @@ -237,8 +237,8 @@ export function validated(term: Term): Term { assert(ops); return term.render(ops, options); }, - update(options) { - return term.update(options); + update(events) { + return term.update(events); }, }; } From 6d9cc3c551a367d8f567f1f85bc04a3198d116ad Mon Sep 17 00:00:00 2001 From: Nate Moore Date: Thu, 8 Oct 2026 15:23:16 -0500 Subject: [PATCH 07/10] chore: update changeset for array-only term.update() The migration now covers both shapes 0.9.0 shipped in #113, `update({ events })` and `update({ width, height })`. Both become an array of InputEvents, with resizes expressed as `{ type: "resize", width, height }`. --- .changeset/terminfo-capability-layer.md | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/.changeset/terminfo-capability-layer.md b/.changeset/terminfo-capability-layer.md index 1e55fa8..41916e7 100644 --- a/.changeset/terminfo-capability-layer.md +++ b/.changeset/terminfo-capability-layer.md @@ -8,7 +8,7 @@ Adds `detectTerminal()`, `TerminalInfo`, `Capabilities`, `DetectOptions`, `KeyTa Pass the same `TerminalInfo` as `terminfo` to `createTerm` and `createInput`. `term.capabilities` exposes the renderer's current capability snapshot, seeded from `terminfo.capabilities` (or the 256-color baseline when omitted). -**Breaking:** `term.update()` now takes one change or an array of changes — a `{ width, height }` resize or any `InputEvent` — instead of `{ events }`. Capability events from `scan()` are folded into `term.capabilities`; other input events are ignored, so the whole `events` array can be passed through. `update()` returns a `Uint8Array` of bytes to write immediately (empty when there are none). +**Breaking:** Changes `term.update()` to take an array of `InputEvent`s instead of `{ events }` or `{ width, height }`. Resizes are now `ResizeEvent`s tagged `type: "resize"`; capability events from `scan()` are folded into `term.capabilities`; every other input event is ignored, so the `events` array from `scan()` can be passed straight through. `update()` returns a `Uint8Array` of bytes to write immediately (empty when there are none). #### Migration @@ -18,6 +18,13 @@ Pass the same `TerminalInfo` as `terminfo` to `createTerm` and `createInput`. `t +if (out.length) process.stdout.write(out); ``` +```diff +-term.update({ width, height }); ++term.update([{ type: "resize", width, height }]); +``` + +To opt in to terminfo-based capability detection: + ```diff +const terminfo = await detectTerminal({ env: process.env }); +const term = await createTerm({ width, height, terminfo }); From e25c039e925f76fe1eed4f49b4162d41a93a7ce8 Mon Sep 17 00:00:00 2001 From: Nate Moore Date: Thu, 8 Oct 2026 15:23:34 -0500 Subject: [PATCH 08/10] spec(renderer): note where capability state lives (non-normative) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Records the current design and the expected direction in §13, so the question of where the authoritative copy lives has an answer on record. Today RuntimeCapabilities lives only in the TypeScript Term closure: no renderer output reads it, so nothing crosses into WASM. Nearly every planned consumer (color encoding in #60, erase strategy from bce/autoMargin/xenl, sync-output wrapping) lives in C. The expected direction is to make WASM authoritative so render() reads capabilities without per-frame transfer. The public contract (update() folds, term.capabilities is a frozen snapshot) is the same either way, so the storage decision is deferred to #60, where the first consumer lands. --- specs/renderer-spec.md | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/specs/renderer-spec.md b/specs/renderer-spec.md index 35d2121..0e1d6ff 100644 --- a/specs/renderer-spec.md +++ b/specs/renderer-spec.md @@ -1139,6 +1139,14 @@ generated module and instantiated per Term or Input with fresh memory. renderer state struct and the transfer buffer are allocated in WASM linear memory. The specific layout is an implementation detail. +**Capability state.** `RuntimeCapabilities` is currently held in TypeScript and +folded by `update()`; the WASM renderer state carries no capability fields +because no renderer output depends on them yet (§7.8). When a +capability-consuming feature lands, the authoritative state is expected to move +into WASM linear memory alongside the renderer state, so that `render()` reads +it without per-frame transfer; `term.capabilities` remains a frozen snapshot +decoded on access. + **Layout engine.** The underlying layout engine is Clay, included as a dependency. Clay provides flexbox-like layout computation with support for fixed, grow, and fit sizing; padding; alignment; direction; gap; floating From 3272810abf607175a7a3246964a44ab125266e6f Mon Sep 17 00:00:00 2001 From: Nate Moore Date: Thu, 8 Oct 2026 15:23:58 -0500 Subject: [PATCH 09/10] docs: align capability comments with the share-no-memory design MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The TermOptions.terminfo docstring said createTerm "seeds the private TermInfo struct", but createTerm never touches a C struct: it seeds term.capabilities in TypeScript. The terminfo.h header described the #106 design, where the renderer read a shared struct and the input parser wrote probe responses into it. terminfo-spec §4.2 and TINV-6 replaced that: the two share no memory, and the struct is only used by detectTerminal(). The TERMINFO_DA1 comment named a queryTermInfo probe window that no longer exists. terminfo_confirm and TERMINFO_DA1 stay: if #60 makes WASM authoritative for capabilities (renderer-spec §13), they are the natural fold path. --- src/terminfo.c | 2 +- src/terminfo.h | 12 ++++++------ term.ts | 6 +++--- 3 files changed, 10 insertions(+), 10 deletions(-) diff --git a/src/terminfo.c b/src/terminfo.c index d2f4a9f..fec4d67 100644 --- a/src/terminfo.c +++ b/src/terminfo.c @@ -1,4 +1,4 @@ -/* terminfo.c — shared terminal capability layer */ +/* terminfo.c — terminal capability layer */ #include "terminfo.h" diff --git a/src/terminfo.h b/src/terminfo.h index 1fb233a..0b7c173 100644 --- a/src/terminfo.h +++ b/src/terminfo.h @@ -1,9 +1,10 @@ -/* terminfo.h — shared terminal capability layer +/* terminfo.h — 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. + * by specs/terminfo-spec.md. detectTerminal() uses the struct to resolve + * static capabilities; the renderer and the input parser share no + * memory through it (terminfo-spec 4.2, TINV-6). This module owns the + * baseline and the parse path. */ #ifndef TERMINFO_H @@ -29,8 +30,7 @@ #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. */ + * device attributes report is recognized. */ #define TERMINFO_DA1 (1u << 31) struct TermInfo { diff --git a/term.ts b/term.ts index 4fbd96e..5326daa 100644 --- a/term.ts +++ b/term.ts @@ -9,9 +9,9 @@ export interface TermOptions { height: number; width: number; /** - * Terminal info from detectTerminal(). Initializes the renderer with - * its static capabilities and seeds the private TermInfo - * struct. When omitted, the renderer uses the 256-color baseline. + * Terminal info from detectTerminal(). Seeds term.capabilities from + * terminfo.capabilities. When omitted, the renderer uses the 256-color + * baseline. */ terminfo?: TerminalInfo; } From 33c2ceb39b60bb0c3fba15c731f9ea17f358931a Mon Sep 17 00:00:00 2001 From: Nate Moore Date: Fri, 9 Oct 2026 10:21:05 -0500 Subject: [PATCH 10/10] chore: update changeset --- .changeset/terminfo-capability-layer.md | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/.changeset/terminfo-capability-layer.md b/.changeset/terminfo-capability-layer.md index 41916e7..b18711b 100644 --- a/.changeset/terminfo-capability-layer.md +++ b/.changeset/terminfo-capability-layer.md @@ -1,14 +1,14 @@ --- -"@bomb.sh/tty": minor +'@bomb.sh/tty': minor --- Adds `detectTerminal()`, `TerminalInfo`, `Capabilities`, `DetectOptions`, `KeyTable`, and `MAX_TERMINFO_ENTRY` to the public API, and a `terminfo` option to `createTerm`. `detectTerminal()` reads the compiled terminfo entry for the current terminal (from the ncurses search path, or from bytes passed as `entry`), applies environment evidence (`COLORTERM`), and resolves a frozen `TerminalInfo` carrying static `capabilities`, a `probe` query batch to write to stdout, and opaque `keys` for the input parser. -Pass the same `TerminalInfo` as `terminfo` to `createTerm` and `createInput`. `term.capabilities` exposes the renderer's current capability snapshot, seeded from `terminfo.capabilities` (or the 256-color baseline when omitted). +Pass the returned `TerminalInfo` as `terminfo` to `createTerm` and `createInput`. `term.capabilities` exposes the renderer's current capability snapshot, seeded from `terminfo.capabilities` (or the 256-color baseline when omitted). -**Breaking:** Changes `term.update()` to take an array of `InputEvent`s instead of `{ events }` or `{ width, height }`. Resizes are now `ResizeEvent`s tagged `type: "resize"`; capability events from `scan()` are folded into `term.capabilities`; every other input event is ignored, so the `events` array from `scan()` can be passed straight through. `update()` returns a `Uint8Array` of bytes to write immediately (empty when there are none). +Changes `term.update()` to accept an array of `InputEvent` values instead of `{ events }` or `{ width, height }`. Resizes are now `ResizeEvent`s tagged `type: "resize"`; capability events from `scan()` are folded into `term.capabilities`; all other input events are no-ops, so the full `events` array from `scan()` can be passed straight through. `update()` now returns a `Uint8Array` of bytes to write immediately (empty when there are none). #### Migration @@ -27,7 +27,7 @@ To opt in to terminfo-based capability detection: ```diff +const terminfo = await detectTerminal({ env: process.env }); -+const term = await createTerm({ width, height, terminfo }); -+const input = await createInput({ terminfo }); + const term = await createTerm({ width, height, terminfo }); + const input = await createInput({ terminfo }); +process.stdout.write(terminfo.probe); ```