diff --git a/.changeset/cooper-approved-132.md b/.changeset/cooper-approved-132.md new file mode 100644 index 0000000..3089006 --- /dev/null +++ b/.changeset/cooper-approved-132.md @@ -0,0 +1,22 @@ +--- +"@bomb.sh/tty": minor +--- + +Adds `CapabilityEvent` to `InputEvent` and changes `InputOptions.terminfo` to accept a `TerminalInfo`. + +`scan()` now parses terminal probe responses — OSC 10/11/12 theme colors, OSC 21 kitty color protocol, OSC 22 pointer shape, XTGETTCAP (`DCS`), kitty graphics (`APC`), kitty keyboard (`CSI ?…u`), synchronized output (`DECRPM`), and DA1 — and surfaces them as typed `CapabilityEvent` objects with keys `foreground-color`, `background-color`, `cursor-color`, `colordepth`, `sync-output`, `kitty-keyboard`, `kitty-graphics`, and `pointer-shape`. + +**Breaking:** `InputOptions.terminfo` now takes the `TerminalInfo` returned by `detectTerminal()` instead of raw compiled terminfo bytes. It seeds the key-sequence trie from `terminfo.keys` and uses `terminfo.capabilities.colors` to resolve colordepth denial events to the correct tier (`"16"` vs `"256"`). Raw bytes now go to `detectTerminal({ entry })`. + +#### Migration + +```diff +- import { createInput } from "@bomb.sh/tty"; ++ import { createInput, detectTerminal } from "@bomb.sh/tty"; + +- const input = await createInput({ terminfo: myTerminfoBinary }); ++ const terminfo = await detectTerminal({ env: process.env, entry: myTerminfoBinary }); ++ const input = await createInput({ terminfo }); +``` + +Omit `terminfo` entirely to keep the xterm default key sequences. diff --git a/input-native.ts b/input-native.ts index 7ed6d1b..2c540fa 100644 --- a/input-native.ts +++ b/input-native.ts @@ -2,6 +2,7 @@ export const EVENT_KEY = 1; export const EVENT_MOUSE = 2; export const EVENT_RESIZE = 3; export const EVENT_CURSOR = 4; +export const EVENT_CAPABILITY = 5; export const MOD_ALT = 1; export const MOD_CTRL = 2; @@ -9,6 +10,21 @@ export const MOD_SHIFT = 4; export const MOD_MOTION = 8; export const MOD_RELEASE = 16; +/* Capability key constants — must match CAP_* in src/input.h */ +export const CAP_FOREGROUND_COLOR = 1; +export const CAP_BACKGROUND_COLOR = 2; +export const CAP_CURSOR_COLOR = 3; +export const CAP_COLORDEPTH = 4; +export const CAP_SYNC_OUTPUT = 5; +export const CAP_KITTY_KEYBOARD = 6; +export const CAP_KITTY_GRAPHICS = 7; +export const CAP_POINTER_SHAPE = 8; + +/* CAP_COLORDEPTH ch values — must match COLORDEPTH_* in src/input.c */ +export const COLORDEPTH_16 = 0; +export const COLORDEPTH_256 = 1; +export const COLORDEPTH_TRUECOLOR = 2; + export const KEY_F1 = 0xFFFF; export const KEY_F2 = 0xFFFE; export const KEY_F3 = 0xFFFD; @@ -169,10 +185,20 @@ export interface InputNative { delay(st: number): number; } +// 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; + +function align8(n: number): number { + return (n + 7) & ~7; +} + import { compiled } from "./wasm.ts"; export async function createInputNative( escLatency: number, + keys?: Uint8Array, + initialColors?: number, ): Promise { let memory = new WebAssembly.Memory({ initial: 4 }); @@ -191,17 +217,52 @@ export async function createInputNative( let exports = instance.exports as unknown 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, + initialColors: 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; + // Linear memory layout, each region 8-byte aligned: + // + // [0, __heap_base) wasm data and stack + // keys terminfo key table (keys.byteLength, may be empty) + // InputState parser state (input_size()) + // scan buffer input_scan() transfer buffer (SCAN_BUFFER_SIZE) + // + // Memory is grown once to cover the end of the scan buffer. + let keysLen = keys?.byteLength ?? 0; + + let top = align8(exports.__heap_base.value as number); + let keysPtr = keysLen > 0 ? top : 0; + top = align8(top + keysLen); + let statePtr = top; + top = align8(top + exports.input_size()); + let buffer = top; + top += SCAN_BUFFER_SIZE; + + let pages = Math.ceil(top / 65536); + let current = memory.buffer.byteLength / 65536; + if (pages > current) memory.grow(pages - current); + + if (keys && keysLen > 0) { + new Uint8Array(memory.buffer).set(keys, keysPtr); + } + + let state = exports.input_init( + statePtr, + escLatency, + keysPtr, + keysLen, + initialColors ?? 256, + ); return { memory, @@ -213,12 +274,3 @@ export async function createInputNative( delay: exports.input_delay, }; } - -// 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..e08c90d 100644 --- a/input.ts +++ b/input.ts @@ -7,7 +7,17 @@ */ import { + CAP_BACKGROUND_COLOR, + CAP_COLORDEPTH, + CAP_CURSOR_COLOR, + CAP_FOREGROUND_COLOR, + CAP_KITTY_GRAPHICS, + CAP_KITTY_KEYBOARD, + CAP_POINTER_SHAPE, + CAP_SYNC_OUTPUT, + COLORDEPTH_TRUECOLOR, createInputNative, + EVENT_CAPABILITY, EVENT_CURSOR, EVENT_KEY, EVENT_MOUSE, @@ -77,7 +87,6 @@ import { KEY_SUPER_LEFT, KEY_SUPER_RIGHT, KEY_TAB, - MAX_TERMINFO, MOD_ALT, MOD_CTRL, MOD_MOTION, @@ -87,6 +96,8 @@ import { readEvent, SCAN_BUFFER_SIZE, } from "./input-native.ts"; +import type { Rgb, TerminalInfo } from "./terminfo.ts"; +import { rgbOf } from "./terminfo.ts"; /** * Modifier keys held during a key or mouse event. @@ -371,6 +382,57 @@ export interface CursorEvent { column: number; } +/** Color depth tier reported by the terminal's XTGETTCAP probe response. */ +export type ColorDepth = "truecolor" | "256" | "16"; + +/** + * A probe-response event emitted by scan() when the terminal answers one + * of the capability queries in TerminalInfo.probe. Route to term.update(). + */ +export type CapabilityEvent = + | { + readonly type: "capability"; + readonly key: "foreground-color"; + readonly value: Rgb; + } + | { + readonly type: "capability"; + readonly key: "background-color"; + readonly value: Rgb; + } + | { + readonly type: "capability"; + readonly key: "cursor-color"; + readonly value: Rgb; + } + | { + readonly type: "capability"; + readonly key: "colordepth"; + readonly value: ColorDepth; + } + | { + readonly type: "capability"; + readonly key: "sync-output"; + readonly value: boolean; + } + | { + readonly type: "capability"; + readonly key: "kitty-keyboard"; + readonly value: boolean; + } + | { + readonly type: "capability"; + readonly key: "kitty-graphics"; + readonly value: boolean; + } + | { + readonly type: "capability"; + readonly key: "pointer-shape"; + readonly value: boolean; + }; + +export type { Rgb }; + import type { PointerEvent } from "./term.ts"; export type InputEvent = @@ -381,6 +443,7 @@ export type InputEvent = | WheelEvent | ResizeEvent | CursorEvent + | CapabilityEvent | PointerEvent; /** @@ -426,38 +489,31 @@ export interface Input { export interface InputOptions { /** * Milliseconds to wait before resolving a lone ESC byte as the Escape - * key rather than the start of an escape sequence. Lower values feel - * snappier but risk misinterpreting sequences on slow connections. - * - * For reference, Vim's `ttimeoutlen` defaults to 100ms and ncurses - * `ESCDELAY` defaults to 1000ms. The default of 25ms is tuned for - * local terminals where escape sequences arrive within microseconds. + * key rather than the start of an escape sequence. * * @default 25 */ escLatency?: number; /** - * Compiled terminfo binary to load terminal-specific escape sequences. - * - * 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 + * Terminal detection from detectTerminal(). Seeds the key trie from + * terminfo.keys and uses terminfo.capabilities.colors for + * colordepth denial events. When omitted, the parser uses xterm + * default key sequences and a 256-color baseline. */ - terminfo?: Uint8Array; + terminfo?: TerminalInfo; } 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 native = await createInputNative( + escLatency, + terminfo?.keys, + terminfo?.capabilities.colors, + ); - let native = await createInputNative(escLatency); + let initialColors = terminfo?.capabilities.colors ?? 256; return { scan(bytes: Uint8Array = new Uint8Array(0)): ScanResult { @@ -482,8 +538,11 @@ export async function createInput(options: InputOptions = {}): Promise { let view = new DataView(native.memory.buffer); for (let i = 0; i < count; i++) { let ptr = native.event(native.state, i); - if (ptr !== 0) { - events.push(mapEvent(readEvent(view, ptr))); + let event = ptr !== 0 + ? mapEvent(readEvent(view, ptr), initialColors) + : undefined; + if (event) { + events.push(event); } } @@ -632,8 +691,70 @@ function mapKeyEvent(native: NativeInputEvent): KeyEvent { return ev; } -function mapEvent(native: NativeInputEvent): InputEvent { +function mapCapEvent( + native: NativeInputEvent, + initialColors: number, +): CapabilityEvent | undefined { + switch (native.key) { + case CAP_FOREGROUND_COLOR: + return { + type: "capability", + key: "foreground-color", + value: rgbOf(native.ch), + }; + case CAP_BACKGROUND_COLOR: + return { + type: "capability", + key: "background-color", + value: rgbOf(native.ch), + }; + case CAP_CURSOR_COLOR: + return { + type: "capability", + key: "cursor-color", + value: rgbOf(native.ch), + }; + case CAP_COLORDEPTH: { + let value: ColorDepth = native.ch === COLORDEPTH_TRUECOLOR + ? "truecolor" + : initialColors <= 16 + ? "16" + : "256"; + return { type: "capability", key: "colordepth", value }; + } + case CAP_SYNC_OUTPUT: + return { type: "capability", key: "sync-output", value: native.ch !== 0 }; + case CAP_KITTY_KEYBOARD: + return { + type: "capability", + key: "kitty-keyboard", + value: native.ch !== 0, + }; + case CAP_KITTY_GRAPHICS: + return { + type: "capability", + key: "kitty-graphics", + value: native.ch !== 0, + }; + case CAP_POINTER_SHAPE: + return { + type: "capability", + key: "pointer-shape", + value: native.ch !== 0, + }; + default: + return undefined; + } +} + +function mapEvent( + native: NativeInputEvent, + initialColors: number, +): InputEvent | undefined { switch (native.type) { + case EVENT_CAPABILITY: { + return mapCapEvent(native, initialColors); + } case EVENT_KEY: { return mapKeyEvent(native); } diff --git a/mod.ts b/mod.ts index 02e2f77..05cee79 100644 --- a/mod.ts +++ b/mod.ts @@ -5,10 +5,10 @@ export * from "./settings.ts"; export * from "./termcodes.ts"; export { type Capabilities, - type Detection, type DetectOptions, detectTerminal, type KeyTable, - MAX_TERMINFO, + MAX_TERMINFO_ENTRY, type Rgb, + type TerminalInfo, } from "./terminfo.ts"; diff --git a/specs/input-spec.md b/specs/input-spec.md index ddc35ef..5107963 100644 --- a/specs/input-spec.md +++ b/specs/input-spec.md @@ -78,9 +78,9 @@ Options: responsiveness (lower values) and correct disambiguation of ESC-prefixed sequences (higher values). -- **`detection`** — A `Detection` value from `detectTerminal()` (see +- **`terminfo`** — A `TerminalInfo` value from `detectTerminal()` (see [Terminfo Specification](terminfo-spec.md) §10.1). Terminal-specific key - sequences from `detection.keys` are loaded into the parser's escape sequence + sequences from `terminfo.keys` are loaded into the parser's escape sequence trie at initialization (Section 6.1). When omitted, the parser uses built-in xterm default sequences. @@ -113,6 +113,13 @@ returns immediately. relative duration in milliseconds. The `deadline` field is an absolute timestamp (milliseconds since epoch) for the same point in time. +An ESC followed by exactly one introducer byte — `[`, `O`, `]`, `P`, or `_` — +and nothing else is ambiguous in the same way: it is either an Alt-modified key +or the start of an escape sequence or probe response. The parser MUST treat it +as pending and report `pending`. If no further bytes arrive within `escLatency`, +the rescan MUST resolve it as a key event for the introducer character with +`alt: true`. + --- ## 5. InputEvent Types @@ -149,7 +156,7 @@ capability layer specified by the [Terminfo Specification](terminfo-spec.md)._ ### 6.1 Key sequences from terminfo -When given a `detection` value whose `keys` field is present, the parser MUST +When given a `terminfo` value whose `keys` field is 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 @@ -159,7 +166,7 @@ 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. +ignored. Terminfo key sequences longer than 16 bytes are ignored. ### 6.2 Query response recognition @@ -175,9 +182,24 @@ For each recognized response the parser MUST emit a `CapabilityEvent` in the 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 `detection`), responses are still recognized +When the parser is standalone (no `terminfo`), responses are still recognized and consumed so stray replies never corrupt the event stream. +### 6.3 Response termination + +OSC, DCS, and APC responses are terminated by BEL (`0x07`) or ST (`ESC \`). Once +the parser has recognized a response header — `ESC ] Ps ;` with `Ps` one of 10, +11, 12, 21, or 22; `ESC P 0 + r` or `ESC P 1 + r`; or `ESC _ G` — the following +bytes end the response early instead: + +- any other C0 control: `0x00`–`0x06`, `0x08`–`0x1A`, `0x1C`–`0x1F`; +- DEL (`0x7F`); +- ESC followed by any byte other than `\`. + +When a response ends early, the parser MUST discard the bytes received for it +and MUST NOT emit a `CapabilityEvent` for it. Parsing resumes at the byte that +ended the response, so the key or sequence it begins is delivered normally. + --- ## 7. Deferred / Future Areas diff --git a/specs/renderer-spec.md b/specs/renderer-spec.md index ffa780e..836fc45 100644 --- a/specs/renderer-spec.md +++ b/specs/renderer-spec.md @@ -493,7 +493,7 @@ included. See Section 5 for what this section does and does not freeze._ createTerm(options: { width: number; height: number; - detection?: Detection; + terminfo?: TerminalInfo; }): Promise ``` @@ -501,7 +501,7 @@ 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 `detection` value (from `detectTerminal()`; see +The optional `terminfo` value (from `detectTerminal()`; see [Terminfo Specification](terminfo-spec.md) §10.1) initializes the Term's private `RuntimeCapabilities` from the static `Capabilities` it carries. When omitted, the Term uses the §7.1 baseline. diff --git a/specs/terminfo-spec.md b/specs/terminfo-spec.md index f91fa83..8c8c870 100644 --- a/specs/terminfo-spec.md +++ b/specs/terminfo-spec.md @@ -27,7 +27,7 @@ event stream. ### In scope (normative) - The `Capabilities` interface: its fields and the rules for how they are set -- The `Detection` value returned by `detectTerminal()` +- The `TerminalInfo` value returned by `detectTerminal()` - The `CapabilityEvent` discriminated union and its `key`/`value` shapes - Compiled terminfo binary parsing (legacy and extended formats) - The probe model: query batch, DA1 completion fence, and sans-IO contract @@ -52,7 +52,7 @@ event stream. state of one terminal, resolved at detection time. Frozen after `detectTerminal` returns. No WASM backing. -**`Detection`.** The value returned by `detectTerminal()`. Carries the frozen +**`TerminalInfo`.** The value returned by `detectTerminal()`. Carries the frozen `Capabilities`, the probe query batch (`probe`), and the raw terminfo key-sequence bytes the input parser needs to seed its trie (`keys`). @@ -117,9 +117,9 @@ probe bytes ──▶ terminal ──▶ stdin │ ### 4.3 Standalone operation -`createTerm` and `createInput` remain usable without a `Detection`. When no -`detection` option is provided, each factory initializes from the §7.1 baseline. -Behavior is identical to a `Detection` with no terminfo bytes, no environment +`createTerm` and `createInput` remain usable without a `TerminalInfo`. When no +`terminfo` option is provided, each factory initializes from the §7.1 baseline. +Behavior is identical to a `TerminalInfo` with no terminfo bytes, no environment evidence, and no probe responses. --- @@ -133,9 +133,11 @@ _This section is normative._ it. **TINV-2. Pure parsing.** `terminfo_parse` performs no IO, allocates no memory, -and never traps on malformed input. Input larger than 32 768 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. +and never traps on malformed input. Input larger than `MAX_TERMINFO_ENTRY` +(32768 bytes) never reaches the parser: an oversized `entry` rejects at the +TypeScript boundary (§10.1), and an oversized file found on the search path is +skipped. Malformed or truncated binaries yield the §7.1 baseline and a nonzero +parse-result code. They MUST NOT partially apply. **TINV-3. Progressive enhancement.** The capability layer starts from the conservative §7.1 baseline and raises a capability only on positive evidence. @@ -145,10 +147,12 @@ responses. A capability no evidence supports keeps its baseline value. Higher-precedence evidence overrides lower-precedence evidence in both directions: a probe denial overrides a statically-set capability. -**TINV-4. Sans-IO probe.** `Detection.probe` is a `Uint8Array` produced by +**TINV-4. Sans-IO probe.** `TerminalInfo.probe` is a `Uint8Array` produced by `detectTerminal()` without touching any stream. The host writes the bytes. `detectTerminal()` resolves — never rejects — on timeout, non-TTY streams, a -missing terminfo file, or abort. +missing, oversized, or malformed terminfo file, or abort. Environmental +conditions degrade to the §7.1 baseline. The only rejection is caller error +(§10.1). **TINV-5. Immediate update output.** `term.update()` returns a `Uint8Array` of bytes to write now. It MUST NOT defer output to the next `render()` call. An @@ -190,8 +194,8 @@ interface Capabilities { | `styledUnderline` | `Su` boolean or `Smulx` string | Key sequences (`key_*` capabilities) are not stored here. The input parser reads -them directly from the raw terminfo bytes in `Detection.keys` at initialization -time. +them directly from the raw terminfo bytes in `TerminalInfo.keys` at +initialization time. ### 6.2 `ColorDepth` @@ -382,7 +386,8 @@ Specification §6.1 for the key set). _This section is normative._ -`Detection.probe` contains the following queries as one `Uint8Array`, in order: +`TerminalInfo.probe` contains the following queries as one `Uint8Array`, in +order: | # | Query | Bytes | `CapabilityEvent` key | | -- | ------------------- | ---------------------------------------------------- | --------------------- | @@ -429,16 +434,16 @@ _This section is normative for the shapes shown._ ### 10.1 `detectTerminal` ```ts -function detectTerminal(options?: DetectOptions): Promise; +function detectTerminal(options?: DetectOptions): Promise; interface DetectOptions { term?: string; env?: Record; - terminfo?: Uint8Array; + entry?: Uint8Array; signal?: AbortSignal; } -interface Detection { +interface TerminalInfo { readonly capabilities: Capabilities; /** * Write to stdout immediately after detection. Responses arrive as @@ -454,20 +459,24 @@ type KeyTable = Uint8Array; `detectTerminal()`: -1. Locates and reads the compiled terminfo entry for the terminal (unless raw - bytes are provided via `terminfo`), following the ncurses search path: +1. Rejects with a `RangeError` if `entry` is provided and exceeds + `MAX_TERMINFO_ENTRY` bytes. This is the only rejection: it reports invalid + caller input, not an environmental condition. +2. Locates and reads the compiled terminfo entry for the terminal (unless raw + bytes are provided via `entry`), following the ncurses search path: `$TERMINFO`, `$HOME/.terminfo`, `$TERMINFO_DIRS` (empty entry = compiled-in defaults), then `/usr/share/terminfo`, `/etc/terminfo`, `/lib/terminfo`, `/usr/lib/terminfo`. Both directory layouts are probed: first-letter (Linux) and two-hex-digit (macOS). Names with path separators, NUL, or a leading `.` - are rejected. Files are validated by magic number. -2. Parses the bytes into `Capabilities` and extracts key-sequence bytes into + are rejected. Files are validated by magic number and skipped when larger + than `MAX_TERMINFO_ENTRY`. +3. Parses the bytes into `Capabilities` and extracts key-sequence bytes into `keys`. -3. Applies environment evidence (§7.2) from the injectable `env`. -4. Constructs `probe` without performing any IO. -5. Resolves the `Detection`. It never rejects. +4. Applies environment evidence (§7.2) from the injectable `env`. +5. Constructs `probe` without performing any IO. +6. Resolves the `TerminalInfo`. Apart from step 1, it never rejects. -Every environmental dependency is injectable (`env`, `terminfo`), making the +Every environmental dependency is injectable (`env`, `entry`), making the function fully testable without a TTY or real terminfo files. ### 10.2 `createTerm` @@ -476,12 +485,12 @@ function fully testable without a TTY or real terminfo files. function createTerm(options: { width: number; height: number; - detection?: Detection; + terminfo?: TerminalInfo; }): Promise; ``` -When `detection` is provided, the renderer initializes its private -`RuntimeCapabilities` from `detection.capabilities` and `RuntimeCapabilities` +When `terminfo` is provided, the renderer initializes its private +`RuntimeCapabilities` from `terminfo.capabilities` and `RuntimeCapabilities` dynamic fields at their baseline values. When omitted, the renderer uses the §7.1 baseline for all fields. @@ -490,12 +499,12 @@ dynamic fields at their baseline values. When omitted, the renderer uses the ```ts function createInput(options?: { escLatency?: number; - detection?: Detection; + terminfo?: TerminalInfo; }): Promise; ``` -When `detection` is provided, the parser loads its key trie from -`detection.keys`. When omitted, the parser uses built-in xterm key sequences. +When `terminfo` is provided, the parser loads its key trie from `terminfo.keys`. +When omitted, the parser uses built-in xterm key sequences. ### 10.4 `Term.update` @@ -528,11 +537,11 @@ import { detectTerminal } from "./terminfo.ts"; import { createTerm } from "./term.ts"; import { createInput } from "./input.ts"; -const detection = await detectTerminal({ env: process.env }); -const term = await createTerm({ width: 80, height: 24, detection }); -const input = await createInput({ detection }); +const terminfo = await detectTerminal({ env: process.env }); +const term = await createTerm({ width: 80, height: 24, terminfo }); +const input = await createInput({ terminfo }); -process.stdout.write(detection.probe); +process.stdout.write(terminfo.probe); process.stdin.on("data", (bytes: Uint8Array) => { const { events } = input.scan(bytes); @@ -556,9 +565,9 @@ process.on("SIGWINCH", () => { }); ``` -`createTerm` and `createInput` each take `detection` and build their own private -state from it. Passing the same `Detection` to both passes the same plain value -twice. There is no shared memory and no attachment guard. +`createTerm` and `createInput` each take `terminfo` and build their own private +state from it. Passing the same `TerminalInfo` to both passes the same plain +value twice. There is no shared memory and no attachment guard. --- @@ -575,8 +584,8 @@ dark/light switching is not tracked. **XTVERSION / DA2 / DA3 identity parsing.** The DA1 reply is used purely as a fence; terminal identification is not extracted. -**Re-probing after suspend/resume.** `Detection.probe` may be written again by -the host at any time; responses arrive as events as normal. The spec does not +**Re-probing after suspend/resume.** `TerminalInfo.probe` may be written again +by the host at any time; responses arrive as events as normal. The spec does not define a managed re-probe lifecycle. **Pixel mouse (1016) and in-band resize (2048) probing.** Candidates for the diff --git a/src/input.c b/src/input.c index 1f6257c..5c16e96 100644 --- a/src/input.c +++ b/src/input.c @@ -13,12 +13,27 @@ */ #include "input.h" +#include "terminfo.h" #include "trie.h" + +/* CAP_COLORDEPTH value encoding (must match input-native.ts): */ +#define COLORDEPTH_16 0 +#define COLORDEPTH_256 1 +#define COLORDEPTH_TRUECOLOR 2 #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 +#define MAX_CSI_PARAM 99999 +/* Longest terminfo key sequence loaded into the trie (input-spec 6.1). */ +#define MAX_TERMINFO_KEY 16 + +#define TCAP_RGB_DENIED 1 +#define TCAP_TC_DENIED 2 +#define TCAP_CONFIRMED 4 /* ── State ────────────────────────────────────────────────────────── */ @@ -30,6 +45,8 @@ struct InputState { struct InputEvent events[MAX_EVENTS]; int count; int trie_len; + int colors; + uint8_t tcap; Trie trie; }; @@ -689,6 +706,409 @@ static int parse_csi_legacy(struct InputState *st, struct InputEvent *ev) { return PARSE_OK; } +/* ── Capability query responses (terminfo-spec section 9) ─────────── */ + +/* Emit a boolean capability event (type=EVENT_CAPABILITY, key=cap_key, + * ch=1 for true / 0 for false). */ +static void emit_cap_bool(struct InputState *st, uint8_t cap_key, int on) { + struct InputEvent *ev = emit(st); + ev->type = EVENT_CAPABILITY; + ev->key = cap_key; + ev->ch = on ? 1 : 0; +} + +/* Emit an Rgb color capability event (ch = packed 0x00RRGGBB). */ +static void emit_cap_color(struct InputState *st, uint8_t cap_key, + uint32_t color) { + struct InputEvent *ev = emit(st); + ev->type = EVENT_CAPABILITY; + ev->key = cap_key; + ev->ch = color; +} + +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; + int alpha = i < len && s[i] == 'a'; + if (alpha) + 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++; + } + } + if (alpha) { + if (i >= len || s[i] != '/') + return 0; + int start = ++i; + while (i < len && hexval(s[i]) >= 0) + i++; + uint32_t ignored; + if (!color_channel(s + start, i - start, &ignored)) + return 0; + } + if (i != len) + return 0; + *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 (1 + n != len || 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; +} + +#define ST_NEED_MORE 0 +#define ST_TOO_LONG -1 +#define ST_ENDED_EARLY -2 + +static int ends_response(char c) { + uint8_t b = (uint8_t)c; + return (b < 0x20 && b != 0x07 && b != 0x1b) || b == 0x7f; +} + +/* Find the BEL or ST terminator of a response payload starting at + * `start` (input-spec section 6.3). Returns the index one past the + * terminator, ST_NEED_MORE, ST_TOO_LONG past MAX_RESPONSE, or + * ST_ENDED_EARLY with *at set to the byte that cut the response off. */ +static int find_st(struct InputState *st, int start, int *at) { + 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 ST_NEED_MORE; + if (st->buf[i + 1] == '\\') + return i + 2; + *at = i; + return ST_ENDED_EARLY; + } + if (ends_response(c)) { + *at = i; + return ST_ENDED_EARLY; + } + if (i - start > MAX_RESPONSE) + return ST_TOO_LONG; + } + return ST_NEED_MORE; +} + +/* OSC 21 payload: ";"-separated key=value pairs. */ +static void osc21_apply(struct InputState *st, const char *s, int len) { + 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")) { + emit_cap_color(st, CAP_FOREGROUND_COLOR, color); + } else if (klen == 10 && payload_contains(key, klen, "background")) { + emit_cap_color(st, CAP_BACKGROUND_COLOR, color); + } else if (klen == 6 && payload_contains(key, klen, "cursor")) { + emit_cap_color(st, CAP_CURSOR_COLOR, 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 at = 0; + int end = find_st(st, i, &at); + if (end == ST_NEED_MORE) + return PARSE_NEED_MORE; + if (end == ST_ENDED_EARLY) { + shift(st, at); + return PARSE_OK; + } + if (end == ST_TOO_LONG) + 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)) + emit_cap_color(st, CAP_FOREGROUND_COLOR, color); + break; + case 11: + if (parse_color_spec(payload, plen, &color)) + emit_cap_color(st, CAP_BACKGROUND_COLOR, color); + break; + case 12: + if (parse_color_spec(payload, plen, &color)) + emit_cap_color(st, CAP_CURSOR_COLOR, color); + break; + case 21: + osc21_apply(st, payload, plen); + break; + case 22: + emit_cap_bool(st, CAP_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). A valid reply naming either + * confirms truecolor. The static tier is emitted only once both are + * denied without a confirmation since the last DA1 fence; an invalid + * reply naming neither denies both. */ +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 at = 0; + int end = find_st(st, 5, &at); + if (end == ST_NEED_MORE) + return PARSE_NEED_MORE; + if (end == ST_ENDED_EARLY) { + shift(st, at); + return PARSE_OK; + } + if (end == ST_TOO_LONG) + return PARSE_ERR; + + const char *payload = st->buf + 5; + int plen = end - 5 - (st->buf[end - 1] == '\x07' ? 1 : 2); + int rgb = payload_contains(payload, plen, "524742"); + int tc = payload_contains(payload, plen, "5463"); + if (ok == '1') { + if (rgb || tc) { + st->tcap |= TCAP_CONFIRMED; + struct InputEvent *ev = emit(st); + ev->type = EVENT_CAPABILITY; + ev->key = CAP_COLORDEPTH; + ev->ch = COLORDEPTH_TRUECOLOR; + } + } else if (!(st->tcap & TCAP_CONFIRMED)) { + uint8_t before = st->tcap; + if (rgb || !tc) + st->tcap |= TCAP_RGB_DENIED; + if (tc || !rgb) + st->tcap |= TCAP_TC_DENIED; + uint8_t both = TCAP_RGB_DENIED | TCAP_TC_DENIED; + if ((st->tcap & both) == both && (before & both) != both) { + struct InputEvent *ev = emit(st); + ev->type = EVENT_CAPABILITY; + ev->key = CAP_COLORDEPTH; + ev->ch = (st->colors <= 16) ? COLORDEPTH_16 : COLORDEPTH_256; + } + } + + 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 at = 0; + int end = find_st(st, 3, &at); + if (end == ST_NEED_MORE) + return PARSE_NEED_MORE; + if (end == ST_ENDED_EARLY) { + shift(st, at); + return PARSE_OK; + } + if (end == ST_TOO_LONG) + return PARSE_ERR; + + const char *payload = st->buf + 3; + int plen = end - 3 - (st->buf[end - 1] == '\x07' ? 1 : 2); + emit_cap_bool(st, CAP_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) { + if (i - 3 > MAX_RESPONSE) + return PARSE_ERR; + char c = st->buf[i]; + if (c >= '0' && c <= '9') { + if (cur == -1) + cur = 0; + if (cur <= MAX_CSI_PARAM) + 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) { + emit_cap_bool(st, CAP_KITTY_KEYBOARD, 1); + } else if (c == 'y' && intermediate == '$') { + if (nums[0] == 2026 && ni >= 2) { + int v = nums[1]; + emit_cap_bool(st, CAP_SYNC_OUTPUT, v == 1 || v == 2 || v == 3); + } + /* other modes: consumed silently */ + } else if (c == 'c' && intermediate == 0) { + /* DA1 fence: consumed silently, MUST NOT surface as CapabilityEvent */ + st->tcap = 0; + } 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 +1347,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, + int initial_colors) { struct InputState *st = (struct InputState *)mem; memset(st, 0, sizeof(struct InputState)); st->esc_latency_ms = esc_latency_ms; + st->colors = initial_colors > 0 ? initial_colors : 256; - /* 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 && n <= MAX_TERMINFO_KEY) + 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); @@ -946,6 +1414,10 @@ struct InputState *input_init(void *mem, int esc_latency_ms) { return st; } +static int is_introducer(char c) { + return c == '[' || c == 'O' || c == ']' || c == 'P' || c == '_'; +} + int input_scan(struct InputState *st, const char *buf, int len, double now) { /* append incoming bytes (may be partial if buffer is full) */ @@ -977,6 +1449,22 @@ int input_scan(struct InputState *st, const char *buf, int len, double now) { return accepted; } + /* ESC + lone introducer: Alt+key or the start of a sequence */ + if (st->len == 2 && is_introducer(st->buf[1])) { + if (st->esc_time == 0) + st->esc_time = now; + if (now - st->esc_time >= (double)st->esc_latency_ms) { + struct InputEvent *ev = emit(st); + ev->type = EVENT_KEY; + ev->mod = MOD_ALT; + ev->ch = (uint8_t)st->buf[1]; + shift(st, 2); + st->esc_time = 0; + continue; + } + return accepted; + } + /* try trie match */ { int consumed = 0; @@ -1061,6 +1549,18 @@ int input_scan(struct InputState *st, const char *buf, int len, double now) { } } + /* try capability query responses (OSC/DCS/APC/CSI ?) */ + { + 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..fe1da50 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); @@ -59,6 +60,21 @@ #define EVENT_MOUSE 2 #define EVENT_RESIZE 3 #define EVENT_CURSOR 4 +#define EVENT_CAPABILITY 5 + +/* ── Capability keys (EVENT_CAPABILITY) ───────────────────────────── */ + +/* key field values for EVENT_CAPABILITY events. The value field (ch) + * carries the payload: for colors, packed 0x00RRGGBB; for booleans, + * 1=true/0=false; for colordepth, 0="16", 1="256", 2="truecolor". */ +#define CAP_FOREGROUND_COLOR 1 +#define CAP_BACKGROUND_COLOR 2 +#define CAP_CURSOR_COLOR 3 +#define CAP_COLORDEPTH 4 +#define CAP_SYNC_OUTPUT 5 +#define CAP_KITTY_KEYBOARD 6 +#define CAP_KITTY_GRAPHICS 7 +#define CAP_POINTER_SHAPE 8 /* ── Modifier flags (bitwise) ─────────────────────────────────────── */ @@ -212,9 +228,19 @@ 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 initial_colors The terminal's static max_colors value, used + * when emitting a colordepth denial event to pick + * the right tier ("16" vs "256"). Pass 0 for the + * 256-color baseline. * @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, + int initial_colors); /** * Feed raw bytes into the parser and produce events. diff --git a/terminfo.ts b/terminfo.ts index a3c4699..bb3c081 100644 --- a/terminfo.ts +++ b/terminfo.ts @@ -5,7 +5,7 @@ import process from "node:process"; import { compiled } from "./wasm.ts"; import { offsets, struct, uint32 } from "./typedef.ts"; -export const MAX_TERMINFO = 32768; +export const MAX_TERMINFO_ENTRY = 32768; /* Flag bits — must match src/terminfo.h. */ const FLAG_TRUECOLOR = 1 << 0; @@ -49,7 +49,7 @@ export interface Capabilities { export type KeyTable = Uint8Array; /** Result of detectTerminal(). */ -export interface Detection { +export interface TerminalInfo { /** Static capabilities, frozen at detection time. */ readonly capabilities: Capabilities; /** @@ -70,7 +70,7 @@ export interface DetectOptions { */ env?: Record; /** Raw compiled terminfo bytes; skips the filesystem lookup. */ - terminfo?: Uint8Array; + entry?: Uint8Array; signal?: AbortSignal; } @@ -102,17 +102,18 @@ function rgbOf(packed: number): Rgb { /** * Detect the terminal's capabilities from the compiled terminfo entry and * process environment. Pure: performs no IO beyond reading the terminfo file. - * Never rejects. See specs/terminfo-spec.md. + * Rejects only when `entry` exceeds MAX_TERMINFO_ENTRY (caller error); + * environmental failures resolve to the baseline. See specs/terminfo-spec.md. */ export async function detectTerminal( options: DetectOptions = {}, -): Promise { +): Promise { let env = options.env ?? process.env; - let bytes = options.terminfo; + let bytes = options.entry; - if (bytes && bytes.byteLength > MAX_TERMINFO) { + if (bytes && bytes.byteLength > MAX_TERMINFO_ENTRY) { throw new RangeError( - `terminfo exceeds ${MAX_TERMINFO} byte limit (got ${bytes.byteLength})`, + `terminfo entry exceeds ${MAX_TERMINFO_ENTRY} byte limit (got ${bytes.byteLength})`, ); } @@ -156,7 +157,7 @@ export async function detectTerminal( let keys: KeyTable = new Uint8Array(0); if (bytes) { - let bytesPtr = alloc(MAX_TERMINFO); + let bytesPtr = alloc(MAX_TERMINFO_ENTRY); new Uint8Array(memory.buffer).set(bytes, bytesPtr); if (exports.terminfo_parse(bytesPtr, bytes.byteLength, structPtr) === 0) { keys = bytes.slice(); @@ -180,7 +181,7 @@ export async function detectTerminal( styledUnderline: !!(flags & FLAG_STYLED_UNDERLINE), }); - return Object.freeze({ + return Object.freeze({ capabilities, probe: PROBE.slice(), keys, @@ -266,7 +267,7 @@ async function loadTerminfo( for (let base of searchPath(options.env)) { for (let url of candidates(base, name)) { let b = await tryRead(url); - if (b && b.byteLength <= MAX_TERMINFO && hasTerminfoMagic(b)) { + if (b && b.byteLength <= MAX_TERMINFO_ENTRY && hasTerminfoMagic(b)) { return b; } } diff --git a/test/caps.ts b/test/caps.ts new file mode 100644 index 0000000..ef81681 --- /dev/null +++ b/test/caps.ts @@ -0,0 +1,10 @@ +import { type DetectOptions, detectTerminal } from "../terminfo.ts"; + +export function offlineDetect(options: Partial = {}) { + return detectTerminal({ env: {}, ...options }); +} + +/** A TerminalInfo with truecolor granted via COLORTERM evidence. */ +export function trueColorDetect() { + return offlineDetect({ env: { COLORTERM: "truecolor" } }); +} diff --git a/test/input.test.ts b/test/input.test.ts index 38af941..91ce14f 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 { detectTerminal } from "../terminfo.ts"; +import { CLAYTERM_16, CLAYTERM_TC } from "./fixtures.ts"; function bytes(...values: number[]): Uint8Array { return new Uint8Array(values); @@ -373,6 +375,50 @@ describe("input", () => { }); }); + describe("ESC + introducer timeout", () => { + for (let introducer of ["[", "O", "]", "P", "_"]) { + it(`reports ESC ${introducer} as pending`, () => { + let result = input.scan(str(`\x1b${introducer}`)); + expect(result.events).toEqual([]); + expect(result.pending?.delay).toBe(25); + }); + + it(`resolves ESC ${introducer} to Alt+${introducer} after the timeout`, async () => { + input.scan(str(`\x1b${introducer}`)); + await new Promise((r) => setTimeout(r, 30)); + let result = input.scan(); + expect(result.events).toEqual([ + expect.objectContaining({ + type: "keydown", + key: introducer, + alt: true, + }), + ]); + expect(result.pending).toBeUndefined(); + }); + } + + it("stays pending when the introducer arrives after a lone ESC", () => { + input.scan(bytes(0x1b)); + let result = input.scan(str("P")); + expect(result.events).toEqual([]); + expect(result.pending?.delay).toBe(25); + }); + + it("parses the sequence when more bytes arrive before the timeout", () => { + input.scan(str("\x1b]")); + let result = input.scan(str("11;rgb:00/00/00\x07")); + expect(result.events).toEqual([ + { + type: "capability", + key: "background-color", + value: { r: 0, g: 0, b: 0 }, + }, + ]); + expect(result.pending).toBeUndefined(); + }); + }); + describe("Alt combinations", () => { it("parses Alt+a as unrecognized ESC sequence", () => { let result = input.scan(bytes(0x1b, 0x61)); @@ -744,3 +790,567 @@ describe("input", () => { }); }); }); + +function terminfoEntry(strings: Record): Uint8Array { + let names = str("synthetic|generated key-length test entry\0"); + let count = Math.max(...Object.keys(strings).map(Number)) + 1; + let offsets = new Int16Array(count).fill(-1); + let table: number[] = []; + for (let [index, value] of Object.entries(strings)) { + offsets[Number(index)] = table.length; + table.push(...str(value), 0); + } + let header = new Int16Array([0o432, names.length, 0, 0, count, table.length]); + let pad = names.length % 2; + let out = new Uint8Array(12 + names.length + pad + count * 2 + table.length); + let view = new DataView(out.buffer); + header.forEach((v, i) => view.setInt16(i * 2, v, true)); + out.set(names, 12); + let at = 12 + names.length + pad; + offsets.forEach((v, i) => view.setInt16(at + i * 2, v, true)); + out.set(table, at + count * 2); + return out; +} + +describe("terminfo integration", () => { + async function withTerminfo() { + let terminfo = await detectTerminal({ env: {}, entry: CLAYTERM_TC }); + 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 withTerminfo(); + 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 withTerminfo(); + let result = input.scan(str("\x1b[99~")); + expect(result.events.length).toBe(1); + expect(result.events[0]).toMatchObject({ type: "keydown", key: "F5" }); + }); + + it("accepts a terminfo key sequence of exactly 16 bytes", async () => { + let seq = "\x1bO" + "z".repeat(13) + "Z"; + let terminfo = await detectTerminal({ + env: {}, + entry: terminfoEntry({ 87: seq }), + }); + let input = await createInput({ terminfo }); + expect(input.scan(str(seq)).events).toEqual([ + expect.objectContaining({ type: "keydown", key: "ArrowUp" }), + ]); + }); + + it("ignores terminfo key sequences longer than 16 bytes", async () => { + let seq = "\x1bO" + "z".repeat(14) + "Z"; + let terminfo = await detectTerminal({ + env: {}, + entry: terminfoEntry({ 87: seq }), + }); + let input = await createInput({ terminfo }); + let events = input.scan(str(seq)).events; + expect(events.some((e) => "key" in e && e.key === "ArrowUp")).toBe(false); + }); + + it("keeps the default keys when every terminfo key is oversized", async () => { + let caps = [ + 59, + 61, + 66, + 67, + 68, + 69, + 70, + 71, + 72, + 73, + 74, + 75, + 76, + 77, + 79, + 81, + 82, + 83, + 87, + 148, + 164, + 216, + 217, + ]; + let strings: Record = {}; + caps.forEach((cap, i) => { + strings[cap] = "\x1bX" + String.fromCharCode(0x61 + i) + + "y".repeat(997); + }); + let terminfo = await detectTerminal({ + env: {}, + entry: terminfoEntry(strings), + }); + expect(terminfo.keys.byteLength).toBeGreaterThan(0); + let input = await createInput({ terminfo }); + for ( + let [seq, key] of [ + ["\x1bOA", "ArrowUp"], + ["\x1bOP", "F1"], + ["\x1bOH", "Home"], + ["\x1bOF", "End"], + ["\x1b[3~", "Delete"], + ] + ) { + expect(input.scan(str(seq)).events).toEqual([ + expect.objectContaining({ type: "keydown", key }), + ]); + } + }); + + it("fits a key table larger than the initial wasm memory", async () => { + let terminfo = await detectTerminal({ env: {}, entry: CLAYTERM_TC }); + let keys = new Uint8Array(300_000); + keys.set(terminfo.keys); + let input = await createInput({ terminfo: { ...terminfo, keys } }); + let result = input.scan(str("\x1bOZ")); + expect(result.events.length).toBe(1); + expect(result.events[0]).toMatchObject({ + type: "keydown", + key: "ArrowUp", + }); + }); + + it("keeps the xterm defaults registered", async () => { + let { input } = await withTerminfo(); + 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("surfaces an OSC 10 foreground report as CapabilityEvent", async () => { + let { input } = await withTerminfo(); + let result = input.scan(str("\x1b]10;rgb:ffff/ffff/ffff\x07")); + expect(result.events.length).toBe(1); + expect(result.events[0]).toEqual({ + type: "capability", + key: "foreground-color", + value: { r: 255, g: 255, b: 255 }, + }); + }); + + it("surfaces an OSC 11 background report (ST-terminated)", async () => { + let { input } = await withTerminfo(); + let result = input.scan(str("\x1b]11;rgb:1e1e/2a2a/3b3b\x1b\\")); + expect(result.events.length).toBe(1); + expect(result.events[0]).toEqual({ + type: "capability", + key: "background-color", + value: { r: 0x1e, g: 0x2a, b: 0x3b }, + }); + }); + + it("surfaces an OSC 12 cursor color report", async () => { + let { input } = await withTerminfo(); + let result = input.scan(str("\x1b]12;#ff8800\x1b\\")); + expect(result.events.length).toBe(1); + expect(result.events[0]).toEqual({ + type: "capability", + key: "cursor-color", + value: { r: 0xff, g: 0x88, b: 0 }, + }); + }); + + it("surfaces per-field events from an OSC 21 kitty color report", async () => { + let { input } = await withTerminfo(); + let result = input.scan( + str("\x1b]21;foreground=rgb:ff/00/00;background=\x1b\\"), + ); + expect(result.events.length).toBe(1); + expect(result.events[0]).toEqual({ + type: "capability", + key: "foreground-color", + value: { r: 255, g: 0, b: 0 }, + }); + }); + + it("surfaces an OSC 22 pointer shape report", async () => { + let { input } = await withTerminfo(); + let result = input.scan(str("\x1b]22;default\x1b\\")); + expect(result.events.length).toBe(1); + expect(result.events[0]).toEqual({ + type: "capability", + key: "pointer-shape", + value: true, + }); + }); + + it("surfaces truecolor colordepth from a valid XTGETTCAP reply", async () => { + let terminfo = await detectTerminal({ env: {}, entry: CLAYTERM_16 }); + let input = await createInput({ terminfo }); + let result = input.scan(str("\x1bP1+r524742\x1b\\")); + expect(result.events.length).toBe(1); + expect(result.events[0]).toEqual({ + type: "capability", + key: "colordepth", + value: "truecolor", + }); + }); + + it("surfaces 256-color colordepth denial from an invalid XTGETTCAP reply", async () => { + let { input } = await withTerminfo(); + let result = input.scan(str("\x1bP0+r\x1b\\")); + expect(result.events.length).toBe(1); + expect(result.events[0]).toEqual({ + type: "capability", + key: "colordepth", + value: "256", + }); + }); + + it("surfaces 16-color colordepth denial when colors <= 16", async () => { + let terminfo = await detectTerminal({ env: {}, entry: CLAYTERM_16 }); + let input = await createInput({ terminfo }); + let result = input.scan(str("\x1bP0+r\x1b\\")); + expect(result.events.length).toBe(1); + expect(result.events[0]).toEqual({ + type: "capability", + key: "colordepth", + value: "16", + }); + }); + + it("preserves truecolor when RGB succeeds and Tc fails, in either order", async () => { + let truecolor = { + type: "capability", + key: "colordepth", + value: "truecolor", + }; + let rgbOk = "\x1bP1+r524742\x1b\\"; + let tcDenied = "\x1bP0+r5463\x1b\\"; + + let { input: together } = await withTerminfo(); + expect(together.scan(str(rgbOk + tcDenied)).events).toEqual([truecolor]); + + let { input: reversed } = await withTerminfo(); + expect(reversed.scan(str(tcDenied + rgbOk)).events).toEqual([truecolor]); + + let { input: split } = await withTerminfo(); + expect(split.scan(str(rgbOk)).events).toEqual([truecolor]); + expect(split.scan(str(tcDenied)).events).toEqual([]); + + let { input: splitReversed } = await withTerminfo(); + expect(splitReversed.scan(str(tcDenied)).events).toEqual([]); + expect(splitReversed.scan(str(rgbOk)).events).toEqual([truecolor]); + }); + + it("emits the static color tier only when both RGB and Tc are denied", async () => { + for ( + let [entry, tier] of [[CLAYTERM_TC, "256"], [ + CLAYTERM_16, + "16", + ]] as const + ) { + let terminfo = await detectTerminal({ env: {}, entry }); + let input = await createInput({ terminfo }); + expect(input.scan(str("\x1bP0+r524742\x1b\\")).events).toEqual([]); + expect(input.scan(str("\x1bP0+r5463\x1b\\")).events).toEqual([ + { type: "capability", key: "colordepth", value: tier }, + ]); + } + }); + + it("starts a fresh colordepth tally after the DA1 fence", async () => { + let { input } = await withTerminfo(); + let result = input.scan( + str( + "\x1bP1+r524742\x1b\\\x1b[?65;1c" + + "\x1bP0+r524742\x1b\\\x1bP0+r5463\x1b\\", + ), + ); + expect(result.events).toEqual([ + { type: "capability", key: "colordepth", value: "truecolor" }, + { type: "capability", key: "colordepth", value: "256" }, + ]); + }); + + it("surfaces synchronized output from a DECRPM confirm report", async () => { + let { input } = await withTerminfo(); + let result = input.scan(str("\x1b[?2026;2$y")); + expect(result.events.length).toBe(1); + expect(result.events[0]).toEqual({ + type: "capability", + key: "sync-output", + value: true, + }); + }); + + it("surfaces sync-output=false from a not-recognized DECRPM report", async () => { + let { input } = await withTerminfo(); + let result = input.scan(str("\x1b[?2026;0$y")); + expect(result.events.length).toBe(1); + expect(result.events[0]).toEqual({ + type: "capability", + key: "sync-output", + value: false, + }); + }); + + it("surfaces kitty-keyboard from a flags report", async () => { + let { input } = await withTerminfo(); + let result = input.scan(str("\x1b[?1u")); + expect(result.events.length).toBe(1); + expect(result.events[0]).toEqual({ + type: "capability", + key: "kitty-keyboard", + value: true, + }); + }); + + it("surfaces kitty-graphics=true from an OK APC reply", async () => { + let { input } = await withTerminfo(); + let result = input.scan(str("\x1b_Gi=31;OK\x1b\\")); + expect(result.events.length).toBe(1); + expect(result.events[0]).toEqual({ + type: "capability", + key: "kitty-graphics", + value: true, + }); + }); + + it("surfaces kitty-graphics=false from an error APC reply", async () => { + let { input } = await withTerminfo(); + let result = input.scan(str("\x1b_Gi=31;ENOTSUPPORTED:x\x1b\\")); + expect(result.events.length).toBe(1); + expect(result.events[0]).toEqual({ + type: "capability", + key: "kitty-graphics", + value: false, + }); + }); + + it("consumes a DA1 report without emitting any event", async () => { + let { input } = await withTerminfo(); + let result = input.scan(str("\x1b[?65;1;9c")); + expect(result.events).toEqual([]); + }); + + it("interleaves CapabilityEvents with key events correctly", async () => { + let { input } = await withTerminfo(); + let result = input.scan( + str("a\x1b]11;rgb:0000/0000/0000\x1b\\b"), + ); + expect(result.events.length).toBe(3); + expect(result.events[0]).toMatchObject({ type: "keydown", key: "a" }); + expect(result.events[1]).toEqual({ + type: "capability", + key: "background-color", + value: { r: 0, g: 0, b: 0 }, + }); + expect(result.events[2]).toMatchObject({ type: "keydown", key: "b" }); + }); + + it("buffers a response split across scans", async () => { + let { input } = await withTerminfo(); + 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.length).toBe(1); + expect(second.events[0]).toEqual({ + type: "capability", + key: "background-color", + value: { r: 0x12, g: 0x56, b: 0x9a }, + }); + }); + + it("recognizes every supported response split at every byte boundary", async () => { + let cap = (key: string, value: unknown) => ({ + type: "capability", + key, + value, + }); + let white = { r: 255, g: 255, b: 255 }; + let cases: [string, unknown[]][] = [ + ["\x1b]10;rgb:ffff/ffff/ffff\x07", [cap("foreground-color", white)]], + ["\x1b]11;rgb:ff/ff/ff\x1b\\", [cap("background-color", white)]], + ["\x1b]12;#ffffff\x1b\\", [cap("cursor-color", white)]], + [ + "\x1b]21;foreground=#ffffff;cursor=rgb:f/f/f\x1b\\", + [cap("foreground-color", white), cap("cursor-color", white)], + ], + ["\x1b]22;default\x1b\\", [cap("pointer-shape", true)]], + ["\x1bP1+r524742=382F382F38\x1b\\", [cap("colordepth", "truecolor")]], + ["\x1bP0+r\x1b\\", [cap("colordepth", "256")]], + ["\x1b[?2026;2$y", [cap("sync-output", true)]], + ["\x1b[?1u", [cap("kitty-keyboard", true)]], + ["\x1b_Gi=31;OK\x1b\\", [cap("kitty-graphics", true)]], + ["\x1b[?65;1;9c", []], + ]; + let x = expect.objectContaining({ type: "keydown", key: "x" }); + let y = expect.objectContaining({ type: "keydown", key: "y" }); + + for (let [response, expected] of cases) { + let data = str("x" + response + "y"); + for (let at = 1; at < data.length; at++) { + let input = await createInput({ escLatency: 60_000 }); + let events = [ + ...input.scan(data.subarray(0, at)).events, + ...input.scan(data.subarray(at)).events, + ]; + expect({ response, at, events }).toEqual({ + response, + at, + events: [x, ...expected, y], + }); + } + + let input = await createInput({ escLatency: 60_000 }); + let events = [...data].flatMap((b) => input.scan(bytes(b)).events); + expect({ response, events }).toEqual({ + response, + events: [x, ...expected, y], + }); + } + }); + + it("surfaces CapabilityEvents on a standalone parser without terminfo", async () => { + let input = await createInput(); + let result = input.scan(str("\x1b]11;rgb:0000/0000/0000\x1b\\")); + expect(result.events.length).toBe(1); + expect(result.events[0]).toMatchObject({ + type: "capability", + key: "background-color", + }); + }); + }); + + describe("response recovery", () => { + function drain(input: Input, data: Uint8Array) { + let events = input.scan(data).events; + for ( + let more = input.scan().events; + more.length; + more = input.scan().events + ) { + events.push(...more); + } + return events; + } + + it("rejects malformed color payloads and preserves the following key", async () => { + for ( + let payload of [ + "\x1b]10;#fffjunk\x07", + "\x1b]11;rgb:ff/ff/ffzz\x1b\\", + "\x1b]12;rgb:ff/ff\x07", + "\x1b]11;rgba:ff/ff/ff/\x07", + "\x1b]21;foreground=#fffjunk\x1b\\", + ] + ) { + let input = await createInput(); + let events = input.scan(str(payload + "a")).events; + expect(events).toEqual([ + expect.objectContaining({ type: "keydown", key: "a" }), + ]); + } + }); + + it("accepts an rgba payload and ignores its alpha channel", async () => { + let input = await createInput(); + expect(input.scan(str("\x1b]11;rgba:10/20/30/ff\x07")).events).toEqual([ + { + type: "capability", + key: "background-color", + value: { r: 0x10, g: 0x20, b: 0x30 }, + }, + ]); + }); + + it("ends a cut-off response at a control byte and keeps the typed keys", async () => { + let truncated = [ + "\x1b]11;rgb:ff", + "\x1b]21;foreground=#ff", + "\x1b]22;def", + "\x1bP1+r5247", + "\x1b_Gi=31", + ]; + let tails = ["\rab", "\nab", "\x7fab", "\x03", "\x1b[Aab", "\x1bxab"]; + for (let response of truncated) { + for (let tail of tails) { + let reference = await createInput(); + let expected = reference.scan(str(tail)).events; + + let together = await createInput(); + expect({ + response, + tail, + events: together.scan(str(response + tail)).events, + }) + .toEqual({ response, tail, events: expected }); + + let split = await createInput(); + expect(split.scan(str(response)).events).toEqual([]); + expect({ response, tail, events: split.scan(str(tail)).events }) + .toEqual({ response, tail, events: expected }); + } + } + }); + + it("parses a complete response that follows a cut-off one", async () => { + let input = await createInput(); + let result = input.scan( + str("\x1b]11;rgb:ff\x1b]10;rgb:00/00/00\x07"), + ); + expect(result.events).toEqual([ + { + type: "capability", + key: "foreground-color", + value: { r: 0, g: 0, b: 0 }, + }, + ]); + }); + + it("recovers from oversized OSC, DCS, and APC responses", async () => { + let filler = "x".repeat(2000); + for ( + let response of [ + `\x1b]11;${filler}\x07`, + `\x1bP1+r${filler}\x1b\\`, + `\x1b_G${filler}\x1b\\`, + ] + ) { + let input = await createInput(); + let events = drain(input, str(response)); + events.push(...drain(input, str("a"))); + expect(events.filter((e) => e.type === "capability")).toEqual([]); + expect(events.at(-1)).toMatchObject({ type: "keydown", key: "a" }); + } + }); + + it("recovers when an incomplete private CSI response fills the input buffer", async () => { + let input = await createInput(); + let events = drain(input, str("\x1b[?" + ";".repeat(4093))); + events.push(...drain(input, str("a"))); + expect(events.at(-1)).toMatchObject({ type: "keydown", key: "a" }); + }); + + it("does not wrap an overflowing private CSI parameter into a known mode", async () => { + let input = await createInput(); + // 4294969322 = 2026 + 2^32 + let result = input.scan(str("\x1b[?4294969322;1$ya")); + expect(result.events.filter((e) => e.type === "capability")).toEqual([]); + expect(result.events.at(-1)).toMatchObject({ type: "keydown", key: "a" }); + }); + }); +}); diff --git a/test/terminfo.test.ts b/test/terminfo.test.ts index 05e4f96..007b31e 100644 --- a/test/terminfo.test.ts +++ b/test/terminfo.test.ts @@ -39,7 +39,7 @@ describe("baseline", () => { describe("terminfo parsing", () => { it("parses a real xterm-256color entry (legacy format)", async () => { - let d = await detectTerminal({ env: {}, terminfo: XTERM_256COLOR }); + let d = await detectTerminal({ env: {}, entry: XTERM_256COLOR }); let caps = d.capabilities; expect(caps.colors).toBe(256); expect(caps.trueColor).toBe(false); @@ -49,7 +49,7 @@ describe("terminfo parsing", () => { }); it("reads Tc / Su / Smulx from the extended capability table", async () => { - let d = await detectTerminal({ env: {}, terminfo: CLAYTERM_TC }); + let d = await detectTerminal({ env: {}, entry: CLAYTERM_TC }); let caps = d.capabilities; expect(caps.trueColor).toBe(true); expect(caps.styledUnderline).toBe(true); @@ -58,7 +58,7 @@ describe("terminfo parsing", () => { }); it("parses the extended number format (magic 01036)", async () => { - let d = await detectTerminal({ env: {}, terminfo: CLAYTERM_DIRECT }); + let d = await detectTerminal({ env: {}, entry: CLAYTERM_DIRECT }); let caps = d.capabilities; expect(caps.colors).toBe(0x1000000); expect(caps.trueColor).toBe(true); @@ -67,7 +67,7 @@ describe("terminfo parsing", () => { }); it("downgrades below baseline on terminfo evidence", async () => { - let d = await detectTerminal({ env: {}, terminfo: CLAYTERM_16 }); + let d = await detectTerminal({ env: {}, entry: CLAYTERM_16 }); let caps = d.capabilities; expect(caps.colors).toBe(16); expect(caps.altScreen).toBe(false); @@ -78,19 +78,19 @@ describe("terminfo parsing", () => { it("keeps the baseline untouched on truncated input", async () => { let d = await detectTerminal({ env: {}, - terminfo: XTERM_256COLOR.slice(0, 30), + entry: XTERM_256COLOR.slice(0, 30), }); expect(d.capabilities).toEqual(BASELINE); }); it("keeps the baseline untouched on garbage input", async () => { - let d = await detectTerminal({ env: {}, terminfo: new Uint8Array(128) }); + let d = await detectTerminal({ env: {}, entry: new Uint8Array(128) }); expect(d.capabilities).toEqual(BASELINE); }); - it("rejects terminfo larger than 32768 bytes", async () => { + it("rejects a terminfo entry larger than 32768 bytes", async () => { await expect( - detectTerminal({ env: {}, terminfo: new Uint8Array(32769) }), + detectTerminal({ env: {}, entry: new Uint8Array(32769) }), ).rejects.toThrow(RangeError); }); }); @@ -99,7 +99,7 @@ describe("environment evidence", () => { it("grants truecolor from COLORTERM=truecolor", async () => { let d = await detectTerminal({ env: { COLORTERM: "truecolor" }, - terminfo: XTERM_256COLOR, + entry: XTERM_256COLOR, }); expect(d.capabilities.trueColor).toBe(true); }); @@ -156,6 +156,18 @@ describe("filesystem lookup", () => { expect(d.capabilities).toEqual(BASELINE); }); + it("skips an oversized entry and resolves to the baseline", async () => { + let db = await Deno.makeTempDir(); + await Deno.mkdir(`${db}/63`, { recursive: true }); + let oversized = new Uint8Array(32769); + oversized.set(CLAYTERM_TC); + await Deno.writeFile(`${db}/63/clayterm-tc`, oversized); + let d = await detectTerminal({ + env: { TERM: "clayterm-tc", TERMINFO: db }, + }); + expect(d.capabilities).toEqual(BASELINE); + }); + it("rejects terminal names with path separators", async () => { let db = await Deno.makeTempDir(); let d = await detectTerminal({ @@ -188,7 +200,7 @@ describe("probe", () => { }); }); -describe("Detection shape", () => { +describe("TerminalInfo shape", () => { it("is frozen", async () => { let d = await detectTerminal({ env: {} }); expect(Object.isFrozen(d)).toBe(true); @@ -201,7 +213,7 @@ describe("Detection shape", () => { }); it("keys holds the parsed terminfo bytes when an entry is found", async () => { - let d = await detectTerminal({ env: {}, terminfo: CLAYTERM_TC }); + let d = await detectTerminal({ env: {}, entry: CLAYTERM_TC }); expect(d.keys.byteLength).toBe(CLAYTERM_TC.byteLength); }); });