Skip to content
22 changes: 22 additions & 0 deletions .changeset/cooper-approved-132.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
---

@bombshell-cooper bombshell-cooper Bot Oct 6, 2026 •

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Changeset needs revision.

Changeset package scope does not match the affected packages confidently. Changeset bump does not match the consumer-visible impact.

View the proposed replacement
---
'@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`.

`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.

Comment thread
natemoo-re marked this conversation as resolved.
"@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.
56 changes: 48 additions & 8 deletions input-native.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,29 @@ 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;
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;
Expand Down Expand Up @@ -173,6 +189,8 @@ import { compiled } from "./wasm.ts";

export async function createInputNative(
escLatency: number,
keys?: Uint8Array,
initialColors?: number,
): Promise<InputNative> {
let memory = new WebAssembly.Memory({ initial: 4 });

Expand All @@ -191,7 +209,13 @@ 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;
Expand All @@ -200,8 +224,29 @@ export async function createInputNative(

let heap = exports.__heap_base.value as number;
let size = exports.input_size();
let state = exports.input_init(heap, escLatency);
let buffer = (heap + size + 7) & ~7;

let keysPtr = 0;
let keysLen = 0;
let top = (heap + 7) & ~7;
if (keys && keys.byteLength > 0) {
top = (top + 7) & ~7;
keysPtr = top;
keysLen = keys.byteLength;
top += (keysLen + 7) & ~7;
let pages = Math.ceil(top / 65536);
let current = memory.buffer.byteLength / 65536;
if (pages > current) memory.grow(pages - current);
new Uint8Array(memory.buffer).set(keys, keysPtr);
}

let state = exports.input_init(
top,
escLatency,
keysPtr,
keysLen,
initialColors ?? 256,
);
let buffer = (top + size + 7) & ~7;

return {
memory,
Expand All @@ -214,11 +259,6 @@ export async function createInputNative(
};
}

// Compiled terminfo entries are limited to 4096 bytes (legacy) or 32768
// bytes (extended ncurses format). We use the extended limit as our upper
// bound. See https://man7.org/linux/man-pages/man5/term.5.html
export const MAX_TERMINFO = 32768;

// Must match SCAN_BUFFER_SIZE in input.c — the maximum bytes input_scan()
// can accept in a single call.
export const SCAN_BUFFER_SIZE = 4096;
162 changes: 140 additions & 22 deletions input.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -77,7 +87,6 @@ import {
KEY_SUPER_LEFT,
KEY_SUPER_RIGHT,
KEY_TAB,
MAX_TERMINFO,
MOD_ALT,
MOD_CTRL,
MOD_MOTION,
Expand All @@ -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.
Expand Down Expand Up @@ -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 =
Expand All @@ -381,6 +443,7 @@ export type InputEvent =
| WheelEvent
| ResizeEvent
| CursorEvent
| CapabilityEvent
| PointerEvent;

/**
Expand Down Expand Up @@ -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<Input> {
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 {
Expand All @@ -483,7 +539,7 @@ export async function createInput(options: InputOptions = {}): Promise<Input> {
for (let i = 0; i < count; i++) {
let ptr = native.event(native.state, i);
if (ptr !== 0) {
events.push(mapEvent(readEvent(view, ptr)));
events.push(mapEvent(readEvent(view, ptr), initialColors));
}
}

Expand Down Expand Up @@ -632,8 +688,70 @@ function mapKeyEvent(native: NativeInputEvent): KeyEvent {
return ev;
}

function mapEvent(native: NativeInputEvent): InputEvent {
function mapCapEvent(
native: NativeInputEvent,
initialColors: number,
): CapabilityEvent {
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 { type: "capability", key: "pointer-shape", value: false };
}
}

function mapEvent(
native: NativeInputEvent,
initialColors: number,
): InputEvent {
switch (native.type) {
case EVENT_CAPABILITY: {
return mapCapEvent(native, initialColors);
}
case EVENT_KEY: {
return mapKeyEvent(native);
}
Expand Down
4 changes: 2 additions & 2 deletions mod.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
8 changes: 4 additions & 4 deletions specs/input-spec.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down Expand Up @@ -149,7 +149,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
Expand All @@ -175,7 +175,7 @@ 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.

---
Expand Down
Loading
Loading