Skip to content

Commit d8f2a24

Browse files
feat: input parser probe-response recognition → CapabilityEvent (#132)
Co-authored-by: bombshell-cooper[bot] <300334852+bombshell-cooper[bot]@users.noreply.github.com>
1 parent 666546e commit d8f2a24

13 files changed

Lines changed: 1497 additions & 112 deletions

‎.changeset/cooper-approved-132.md‎

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
---
2+
"@bomb.sh/tty": minor
3+
---
4+
5+
Adds `CapabilityEvent` to `InputEvent` and changes `InputOptions.terminfo` to accept a `TerminalInfo`.
6+
7+
`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`.
8+
9+
**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 })`.
10+
11+
#### Migration
12+
13+
```diff
14+
- import { createInput } from "@bomb.sh/tty";
15+
+ import { createInput, detectTerminal } from "@bomb.sh/tty";
16+
17+
- const input = await createInput({ terminfo: myTerminfoBinary });
18+
+ const terminfo = await detectTerminal({ env: process.env, entry: myTerminfoBinary });
19+
+ const input = await createInput({ terminfo });
20+
```
21+
22+
Omit `terminfo` entirely to keep the xterm default key sequences.

‎input-native.ts‎

Lines changed: 66 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -2,13 +2,29 @@ export const EVENT_KEY = 1;
22
export const EVENT_MOUSE = 2;
33
export const EVENT_RESIZE = 3;
44
export const EVENT_CURSOR = 4;
5+
export const EVENT_CAPABILITY = 5;
56

67
export const MOD_ALT = 1;
78
export const MOD_CTRL = 2;
89
export const MOD_SHIFT = 4;
910
export const MOD_MOTION = 8;
1011
export const MOD_RELEASE = 16;
1112

13+
/* Capability key constants — must match CAP_* in src/input.h */
14+
export const CAP_FOREGROUND_COLOR = 1;
15+
export const CAP_BACKGROUND_COLOR = 2;
16+
export const CAP_CURSOR_COLOR = 3;
17+
export const CAP_COLORDEPTH = 4;
18+
export const CAP_SYNC_OUTPUT = 5;
19+
export const CAP_KITTY_KEYBOARD = 6;
20+
export const CAP_KITTY_GRAPHICS = 7;
21+
export const CAP_POINTER_SHAPE = 8;
22+
23+
/* CAP_COLORDEPTH ch values — must match COLORDEPTH_* in src/input.c */
24+
export const COLORDEPTH_16 = 0;
25+
export const COLORDEPTH_256 = 1;
26+
export const COLORDEPTH_TRUECOLOR = 2;
27+
1228
export const KEY_F1 = 0xFFFF;
1329
export const KEY_F2 = 0xFFFE;
1430
export const KEY_F3 = 0xFFFD;
@@ -169,10 +185,20 @@ export interface InputNative {
169185
delay(st: number): number;
170186
}
171187

188+
// Must match SCAN_BUFFER_SIZE in input.c — the maximum bytes input_scan()
189+
// can accept in a single call.
190+
export const SCAN_BUFFER_SIZE = 4096;
191+
192+
function align8(n: number): number {
193+
return (n + 7) & ~7;
194+
}
195+
172196
import { compiled } from "./wasm.ts";
173197

174198
export async function createInputNative(
175199
escLatency: number,
200+
keys?: Uint8Array,
201+
initialColors?: number,
176202
): Promise<InputNative> {
177203
let memory = new WebAssembly.Memory({ initial: 4 });
178204

@@ -191,17 +217,52 @@ export async function createInputNative(
191217
let exports = instance.exports as unknown as {
192218
__heap_base: WebAssembly.Global;
193219
input_size(): number;
194-
input_init(mem: number, escLatency: number): number;
220+
input_init(
221+
mem: number,
222+
escLatency: number,
223+
terminfo: number,
224+
terminfoLen: number,
225+
initialColors: number,
226+
): number;
195227
input_scan(st: number, buf: number, len: number, now: number): number;
196228
input_count(st: number): number;
197229
input_event(st: number, index: number): number;
198230
input_delay(st: number): number;
199231
};
200232

201-
let heap = exports.__heap_base.value as number;
202-
let size = exports.input_size();
203-
let state = exports.input_init(heap, escLatency);
204-
let buffer = (heap + size + 7) & ~7;
233+
// Linear memory layout, each region 8-byte aligned:
234+
//
235+
// [0, __heap_base) wasm data and stack
236+
// keys terminfo key table (keys.byteLength, may be empty)
237+
// InputState parser state (input_size())
238+
// scan buffer input_scan() transfer buffer (SCAN_BUFFER_SIZE)
239+
//
240+
// Memory is grown once to cover the end of the scan buffer.
241+
let keysLen = keys?.byteLength ?? 0;
242+
243+
let top = align8(exports.__heap_base.value as number);
244+
let keysPtr = keysLen > 0 ? top : 0;
245+
top = align8(top + keysLen);
246+
let statePtr = top;
247+
top = align8(top + exports.input_size());
248+
let buffer = top;
249+
top += SCAN_BUFFER_SIZE;
250+
251+
let pages = Math.ceil(top / 65536);
252+
let current = memory.buffer.byteLength / 65536;
253+
if (pages > current) memory.grow(pages - current);
254+
255+
if (keys && keysLen > 0) {
256+
new Uint8Array(memory.buffer).set(keys, keysPtr);
257+
}
258+
259+
let state = exports.input_init(
260+
statePtr,
261+
escLatency,
262+
keysPtr,
263+
keysLen,
264+
initialColors ?? 256,
265+
);
205266

206267
return {
207268
memory,
@@ -213,12 +274,3 @@ export async function createInputNative(
213274
delay: exports.input_delay,
214275
};
215276
}
216-
217-
// Compiled terminfo entries are limited to 4096 bytes (legacy) or 32768
218-
// bytes (extended ncurses format). We use the extended limit as our upper
219-
// bound. See https://man7.org/linux/man-pages/man5/term.5.html
220-
export const MAX_TERMINFO = 32768;
221-
222-
// Must match SCAN_BUFFER_SIZE in input.c — the maximum bytes input_scan()
223-
// can accept in a single call.
224-
export const SCAN_BUFFER_SIZE = 4096;

‎input.ts‎

Lines changed: 144 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,17 @@
77
*/
88

99
import {
10+
CAP_BACKGROUND_COLOR,
11+
CAP_COLORDEPTH,
12+
CAP_CURSOR_COLOR,
13+
CAP_FOREGROUND_COLOR,
14+
CAP_KITTY_GRAPHICS,
15+
CAP_KITTY_KEYBOARD,
16+
CAP_POINTER_SHAPE,
17+
CAP_SYNC_OUTPUT,
18+
COLORDEPTH_TRUECOLOR,
1019
createInputNative,
20+
EVENT_CAPABILITY,
1121
EVENT_CURSOR,
1222
EVENT_KEY,
1323
EVENT_MOUSE,
@@ -77,7 +87,6 @@ import {
7787
KEY_SUPER_LEFT,
7888
KEY_SUPER_RIGHT,
7989
KEY_TAB,
80-
MAX_TERMINFO,
8190
MOD_ALT,
8291
MOD_CTRL,
8392
MOD_MOTION,
@@ -87,6 +96,8 @@ import {
8796
readEvent,
8897
SCAN_BUFFER_SIZE,
8998
} from "./input-native.ts";
99+
import type { Rgb, TerminalInfo } from "./terminfo.ts";
100+
import { rgbOf } from "./terminfo.ts";
90101

91102
/**
92103
* Modifier keys held during a key or mouse event.
@@ -371,6 +382,57 @@ export interface CursorEvent {
371382
column: number;
372383
}
373384

385+
/** Color depth tier reported by the terminal's XTGETTCAP probe response. */
386+
export type ColorDepth = "truecolor" | "256" | "16";
387+
388+
/**
389+
* A probe-response event emitted by scan() when the terminal answers one
390+
* of the capability queries in TerminalInfo.probe. Route to term.update().
391+
*/
392+
export type CapabilityEvent =
393+
| {
394+
readonly type: "capability";
395+
readonly key: "foreground-color";
396+
readonly value: Rgb;
397+
}
398+
| {
399+
readonly type: "capability";
400+
readonly key: "background-color";
401+
readonly value: Rgb;
402+
}
403+
| {
404+
readonly type: "capability";
405+
readonly key: "cursor-color";
406+
readonly value: Rgb;
407+
}
408+
| {
409+
readonly type: "capability";
410+
readonly key: "colordepth";
411+
readonly value: ColorDepth;
412+
}
413+
| {
414+
readonly type: "capability";
415+
readonly key: "sync-output";
416+
readonly value: boolean;
417+
}
418+
| {
419+
readonly type: "capability";
420+
readonly key: "kitty-keyboard";
421+
readonly value: boolean;
422+
}
423+
| {
424+
readonly type: "capability";
425+
readonly key: "kitty-graphics";
426+
readonly value: boolean;
427+
}
428+
| {
429+
readonly type: "capability";
430+
readonly key: "pointer-shape";
431+
readonly value: boolean;
432+
};
433+
434+
export type { Rgb };
435+
374436
import type { PointerEvent } from "./term.ts";
375437

376438
export type InputEvent =
@@ -381,6 +443,7 @@ export type InputEvent =
381443
| WheelEvent
382444
| ResizeEvent
383445
| CursorEvent
446+
| CapabilityEvent
384447
| PointerEvent;
385448

386449
/**
@@ -426,38 +489,31 @@ export interface Input {
426489
export interface InputOptions {
427490
/**
428491
* Milliseconds to wait before resolving a lone ESC byte as the Escape
429-
* key rather than the start of an escape sequence. Lower values feel
430-
* snappier but risk misinterpreting sequences on slow connections.
431-
*
432-
* For reference, Vim's `ttimeoutlen` defaults to 100ms and ncurses
433-
* `ESCDELAY` defaults to 1000ms. The default of 25ms is tuned for
434-
* local terminals where escape sequences arrive within microseconds.
492+
* key rather than the start of an escape sequence.
435493
*
436494
* @default 25
437495
*/
438496
escLatency?: number;
439497

440498
/**
441-
* Compiled terminfo binary to load terminal-specific escape sequences.
442-
*
443-
* This is the format used by files like /usr/lib/terminfo/78/xterm-256color
444-
* and they can be directly loaded from disk into this option.
445-
*
446-
* If no terminfo is provided it will use xterm capabilities as the default
499+
* Terminal detection from detectTerminal(). Seeds the key trie from
500+
* terminfo.keys and uses terminfo.capabilities.colors for
501+
* colordepth denial events. When omitted, the parser uses xterm
502+
* default key sequences and a 256-color baseline.
447503
*/
448-
terminfo?: Uint8Array;
504+
terminfo?: TerminalInfo;
449505
}
450506

451507
export async function createInput(options: InputOptions = {}): Promise<Input> {
452508
let { escLatency = 25, terminfo } = options;
453509

454-
if (terminfo && terminfo.byteLength > MAX_TERMINFO) {
455-
throw new RangeError(
456-
`terminfo exceeds ${MAX_TERMINFO} byte limit (got ${terminfo.byteLength})`,
457-
);
458-
}
510+
let native = await createInputNative(
511+
escLatency,
512+
terminfo?.keys,
513+
terminfo?.capabilities.colors,
514+
);
459515

460-
let native = await createInputNative(escLatency);
516+
let initialColors = terminfo?.capabilities.colors ?? 256;
461517

462518
return {
463519
scan(bytes: Uint8Array = new Uint8Array(0)): ScanResult {
@@ -482,8 +538,11 @@ export async function createInput(options: InputOptions = {}): Promise<Input> {
482538
let view = new DataView(native.memory.buffer);
483539
for (let i = 0; i < count; i++) {
484540
let ptr = native.event(native.state, i);
485-
if (ptr !== 0) {
486-
events.push(mapEvent(readEvent(view, ptr)));
541+
let event = ptr !== 0
542+
? mapEvent(readEvent(view, ptr), initialColors)
543+
: undefined;
544+
if (event) {
545+
events.push(event);
487546
}
488547
}
489548

@@ -632,8 +691,70 @@ function mapKeyEvent(native: NativeInputEvent): KeyEvent {
632691
return ev;
633692
}
634693

635-
function mapEvent(native: NativeInputEvent): InputEvent {
694+
function mapCapEvent(
695+
native: NativeInputEvent,
696+
initialColors: number,
697+
): CapabilityEvent | undefined {
698+
switch (native.key) {
699+
case CAP_FOREGROUND_COLOR:
700+
return {
701+
type: "capability",
702+
key: "foreground-color",
703+
value: rgbOf(native.ch),
704+
};
705+
case CAP_BACKGROUND_COLOR:
706+
return {
707+
type: "capability",
708+
key: "background-color",
709+
value: rgbOf(native.ch),
710+
};
711+
case CAP_CURSOR_COLOR:
712+
return {
713+
type: "capability",
714+
key: "cursor-color",
715+
value: rgbOf(native.ch),
716+
};
717+
case CAP_COLORDEPTH: {
718+
let value: ColorDepth = native.ch === COLORDEPTH_TRUECOLOR
719+
? "truecolor"
720+
: initialColors <= 16
721+
? "16"
722+
: "256";
723+
return { type: "capability", key: "colordepth", value };
724+
}
725+
case CAP_SYNC_OUTPUT:
726+
return { type: "capability", key: "sync-output", value: native.ch !== 0 };
727+
case CAP_KITTY_KEYBOARD:
728+
return {
729+
type: "capability",
730+
key: "kitty-keyboard",
731+
value: native.ch !== 0,
732+
};
733+
case CAP_KITTY_GRAPHICS:
734+
return {
735+
type: "capability",
736+
key: "kitty-graphics",
737+
value: native.ch !== 0,
738+
};
739+
case CAP_POINTER_SHAPE:
740+
return {
741+
type: "capability",
742+
key: "pointer-shape",
743+
value: native.ch !== 0,
744+
};
745+
default:
746+
return undefined;
747+
}
748+
}
749+
750+
function mapEvent(
751+
native: NativeInputEvent,
752+
initialColors: number,
753+
): InputEvent | undefined {
636754
switch (native.type) {
755+
case EVENT_CAPABILITY: {
756+
return mapCapEvent(native, initialColors);
757+
}
637758
case EVENT_KEY: {
638759
return mapKeyEvent(native);
639760
}

‎mod.ts‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,10 +5,10 @@ export * from "./settings.ts";
55
export * from "./termcodes.ts";
66
export {
77
type Capabilities,
8-
type Detection,
98
type DetectOptions,
109
detectTerminal,
1110
type KeyTable,
12-
MAX_TERMINFO,
11+
MAX_TERMINFO_ENTRY,
1312
type Rgb,
13+
type TerminalInfo,
1414
} from "./terminfo.ts";

0 commit comments

Comments
 (0)