[READ-ONLY] Mirror of https://github.com/bombshell-dev/tty. Platform independent 2D layout engine for terminal applications based on Clay
5

Configure Feed

Select the types of activity you want to include in your feed.

feat(input): parse OSC 22 pointer-shape replies into PointerShapeEvent

Terminals implementing the kitty mouse-pointer-shape protocol answer an
OSC 22 query on the input stream. The parser now recognizes these replies
(ESC ] 22 ; <payload> ST|BEL) and surfaces them as a new InputEvent
variant carrying the raw payload string.

The payload is surfaced verbatim, not interpreted: the same reply shape
covers a current-shape name, "0" for an empty stack, and a support-query
list ("1,0,1"). Which applies depends on the query the caller sent, so
correlation is the caller's responsibility — keeping the parser stateless
and independent of the renderer (INV-8). Emitting the queries and setting
shapes is an output concern and lives elsewhere.

Set-only terminals (e.g. Ghostty) never reply, so they never produce the
event; absence within a timeout is the unsupported contract, not an error.

Nate Moore (Jul 1, 2026, 4:07 PM EDT) edad10ee 7a28d5d6

+242
+13
input-native.ts
··· 2 2 export const EVENT_MOUSE = 2; 3 3 export const EVENT_RESIZE = 3; 4 4 export const EVENT_CURSOR = 4; 5 + export const EVENT_POINTERSHAPE = 5; 5 6 6 7 export const MOD_ALT = 1; 7 8 export const MOD_CTRL = 2; ··· 89 90 } from "./typedef.ts"; 90 91 91 92 const MAX_TEXT_CODEPOINTS = 8; 93 + const MAX_REPORT_BYTES = 64; 92 94 93 95 const InputEventLayout = struct({ 94 96 type: uint8(), ··· 104 106 base: uint32(), 105 107 text: array(uint32(), MAX_TEXT_CODEPOINTS), 106 108 text_len: uint8(), 109 + report_len: uint16(), 110 + report: array(uint8(), MAX_REPORT_BYTES), 107 111 }); 108 112 109 113 const { ··· 120 124 shifted: OFFSET_SHIFTED, 121 125 base: OFFSET_BASE, 122 126 text: OFFSET_TEXT, 127 + report_len: OFFSET_REPORT_LEN, 128 + report: OFFSET_REPORT, 123 129 } = offsets(InputEventLayout); 124 130 125 131 export interface NativeInputEvent { ··· 135 141 shifted: number; 136 142 base: number; 137 143 text: number[]; 144 + report: number[]; 138 145 } 139 146 140 147 export function readEvent(view: DataView, ptr: number): NativeInputEvent { ··· 143 150 for (let i = 0; i < len && i < MAX_TEXT_CODEPOINTS; i++) { 144 151 text.push(view.getUint32(ptr + OFFSET_TEXT + i * 4, true)); 145 152 } 153 + let reportLen = view.getUint16(ptr + OFFSET_REPORT_LEN, true); 154 + let report: number[] = []; 155 + for (let i = 0; i < reportLen && i < MAX_REPORT_BYTES; i++) { 156 + report.push(view.getUint8(ptr + OFFSET_REPORT + i)); 157 + } 146 158 return { 147 159 type: view.getUint8(ptr + OFFSET_TYPE), 148 160 mod: view.getUint8(ptr + OFFSET_MOD), ··· 156 168 shifted: view.getUint32(ptr + OFFSET_SHIFTED, true), 157 169 base: view.getUint32(ptr + OFFSET_BASE, true), 158 170 text, 171 + report, 159 172 }; 160 173 } 161 174
+24
input.ts
··· 11 11 EVENT_CURSOR, 12 12 EVENT_KEY, 13 13 EVENT_MOUSE, 14 + EVENT_POINTERSHAPE, 14 15 EVENT_RESIZE, 15 16 KEY_ALT_LEFT, 16 17 KEY_ALT_RIGHT, ··· 371 372 column: number; 372 373 } 373 374 375 + /** 376 + * Reply to an OSC 22 mouse-pointer-shape query. 377 + * 378 + * Emitted when the terminal answers a pointer-shape query (sent by the 379 + * output side) on the input stream. The `report` is the raw payload the 380 + * terminal returned between `OSC 22 ;` and the terminator — for example a 381 + * shape name (`"pointer"`), `"0"` for an empty stack, or a comma-separated 382 + * list of `1`/`0` support flags. The parser does not interpret it; 383 + * correlating a reply with the query that produced it is the caller's 384 + * responsibility. 385 + */ 386 + export interface PointerShapeEvent { 387 + type: "pointershape"; 388 + report: string; 389 + } 390 + 374 391 import type { PointerEvent } from "./term.ts"; 375 392 376 393 export type InputEvent = ··· 381 398 | WheelEvent 382 399 | ResizeEvent 383 400 | CursorEvent 401 + | PointerShapeEvent 384 402 | PointerEvent; 385 403 386 404 /** ··· 683 701 } 684 702 case EVENT_CURSOR: { 685 703 return { type: "cursor", row: native.y, column: native.x }; 704 + } 705 + case EVENT_POINTERSHAPE: { 706 + return { 707 + type: "pointershape", 708 + report: new TextDecoder().decode(new Uint8Array(native.report)), 709 + }; 686 710 } 687 711 default: { 688 712 return mapKeyEvent(native);
+59
specs/input-spec.md
··· 31 31 - The scan API and its return type 32 32 - The `InputEvent` discriminated union and its variants 33 33 - The ESC timeout resolution model 34 + - Decoding inbound OSC 22 mouse-pointer-shape replies into events 34 35 35 36 ### Out of scope 36 37 ··· 128 129 - **`ResizeEvent`** (`type: "resize"`) — A terminal resize notification. Fields 129 130 include `columns` and `rows`. 130 131 132 + - **`PointerShapeEvent`** (`type: "pointershape"`) — A terminal reply to an OSC 133 + 22 mouse-pointer-shape query. Carries a single `report` field: the raw payload 134 + string the terminal returned between `OSC 22 ;` and the string terminator. The 135 + parser does not interpret the payload and does not correlate it with any 136 + outstanding query; correlation is the caller's responsibility. See Section 137 + 5.1. 138 + 131 139 The discriminant values and the type splits are deliberate design decisions. 132 140 However, the field sets within each variant are expected to grow when Kitty 133 141 progressive enhancement types are surfaced in the TypeScript layer (the C struct 134 142 has already been extended with fields that are not yet mapped to the TS types). 143 + 144 + ### 5.1 Pointer shape reports (OSC 22) 145 + 146 + > **Status:** Implemented. Code conforms to the spec, not the reverse (see 147 + > AGENTS.md). 148 + 149 + Some terminals implement the OSC 22 mouse-pointer-shape protocol, under which an 150 + application can _query_ the terminal's current pointer shape or its support for 151 + named shapes. The terminal answers a query with a reply on the input stream. The 152 + input parser recognizes these replies and surfaces them as `PointerShapeEvent`s. 153 + 154 + The parser's role is strictly inbound decoding. It never sends OSC 22 queries 155 + and never sets the pointer shape — emitting OSC 22 is an output concern 156 + specified separately (see [Renderer Specification](renderer-spec.md)). This 157 + preserves the parser's independence from the renderer (INV-8): the parser only 158 + decodes bytes it is given. 159 + 160 + **Recognized reply grammar.** A reply has the form: 161 + 162 + ``` 163 + ESC ] 22 ; <payload> <terminator> 164 + ``` 165 + 166 + where `<terminator>` is either ST (`ESC \`, bytes `0x1B 0x5C`) or BEL (`0x07`), 167 + and `<payload>` is the run of bytes up to the terminator. The parser emits one 168 + `PointerShapeEvent` per complete reply, with `report` set to the decoded 169 + `<payload>` string. Payloads are truncated to 64 bytes; this comfortably fits 170 + any shape name and a support-query reply for a reasonable number of shapes. The 171 + parser does not validate or interpret the payload. Per the kitty pointer-shape 172 + protocol the payload may be: 173 + 174 + - a shape name (reply to `?__current__`, `?__default__`, or `?__grabbed__`), 175 + - `0` (current-shape query when the shape stack is empty), or 176 + - a comma-separated list of `1`/`0` flags (reply to a support query of the form 177 + `?name1,name2,...`). 178 + 179 + Which interpretation applies depends on the query the caller sent; the parser 180 + does not track outstanding queries, so the caller is responsible for that 181 + correlation. 182 + 183 + **Graceful degradation.** Terminals that do not implement the query side of OSC 184 + 22 (for example, set-only implementations) never send a reply, and therefore 185 + never produce a `PointerShapeEvent`. A caller that issues a query and receives 186 + no event within a timeout MUST treat the feature as unsupported. Absence of the 187 + event is the contract for unsupported terminals; it is not an error. 188 + 189 + **Incremental bytes.** An OSC 22 reply split across multiple `scan()` calls is 190 + buffered like any other escape sequence and surfaced as a single event once the 191 + terminator arrives. A lone `ESC` does not apply here: the `]` that follows 192 + disambiguates immediately, so OSC 22 replies do not participate in ESC timeout 193 + resolution. 135 194 136 195 --- 137 196
+77
src/input.c
··· 616 616 return PARSE_NEED_MORE; 617 617 } 618 618 619 + /* Parse an OSC 22 mouse-pointer-shape reply: ESC ] 22 ; <payload> <term> 620 + * where <term> is ST (ESC \) or BEL (0x07). The payload is surfaced verbatim; 621 + * the parser does not interpret it. Only OSC 22 is recognized here. */ 622 + static int parse_osc(struct InputState *st, struct InputEvent *ev) { 623 + if (st->len < 2) 624 + return PARSE_NEED_MORE; 625 + if (st->buf[0] != '\x1b' || st->buf[1] != ']') 626 + return PARSE_ERR; 627 + 628 + /* numeric OSC code */ 629 + int i = 2; 630 + int code = -1; 631 + while (i < st->len && st->buf[i] >= '0' && st->buf[i] <= '9') { 632 + if (code == -1) 633 + code = 0; 634 + code = code * 10 + (st->buf[i] - '0'); 635 + i++; 636 + } 637 + if (i >= st->len) 638 + return PARSE_NEED_MORE; /* code digits not yet terminated */ 639 + if (code != 22 || st->buf[i] != ';') 640 + return PARSE_ERR; /* only OSC 22 with a payload separator */ 641 + i++; /* skip ';' */ 642 + 643 + /* payload runs until ST (ESC \) or BEL */ 644 + int payload_start = i; 645 + int payload_end = -1; 646 + int term_len = 0; 647 + while (i < st->len) { 648 + uint8_t c = (uint8_t)st->buf[i]; 649 + if (c == 0x07) { 650 + payload_end = i; 651 + term_len = 1; 652 + break; 653 + } 654 + if (c == 0x1b) { 655 + if (i + 1 >= st->len) 656 + return PARSE_NEED_MORE; 657 + if (st->buf[i + 1] != '\\') 658 + return PARSE_ERR; /* ESC not forming ST inside payload */ 659 + payload_end = i; 660 + term_len = 2; 661 + break; 662 + } 663 + i++; 664 + } 665 + if (payload_end == -1) 666 + return PARSE_NEED_MORE; /* terminator not seen yet */ 667 + 668 + int n = payload_end - payload_start; 669 + if (n > MAX_REPORT_BYTES) 670 + n = MAX_REPORT_BYTES; /* truncate overly long payloads */ 671 + for (int j = 0; j < n; j++) 672 + ev->report[j] = (uint8_t)st->buf[payload_start + j]; 673 + ev->report_len = (uint16_t)n; 674 + ev->type = EVENT_POINTERSHAPE; 675 + 676 + shift(st, payload_end + term_len); 677 + return PARSE_OK; 678 + } 679 + 619 680 /* Parse Kitty-enhanced legacy CSI sequences (non-u terminators). 620 681 * Format: CSI [number] [; mod[:action]] terminator 621 682 * Handles A-D, F, H, P, Q, S, ~ terminators with optional :action */ ··· 975 1036 } 976 1037 /* pending — caller should retry after timeout */ 977 1038 return accepted; 1039 + } 1040 + 1041 + /* try OSC (ESC ]) — pointer-shape replies */ 1042 + { 1043 + struct InputEvent oev; 1044 + memset(&oev, 0, sizeof(oev)); 1045 + int rv = parse_osc(st, &oev); 1046 + if (rv == PARSE_OK) { 1047 + struct InputEvent *ev = emit(st); 1048 + *ev = oev; 1049 + st->esc_time = 0; 1050 + continue; 1051 + } 1052 + if (rv == PARSE_NEED_MORE) { 1053 + return accepted; 1054 + } 978 1055 } 979 1056 980 1057 /* try trie match */
+9
src/input.h
··· 59 59 #define EVENT_MOUSE 2 60 60 #define EVENT_RESIZE 3 61 61 #define EVENT_CURSOR 4 62 + #define EVENT_POINTERSHAPE 5 62 63 63 64 /* ── Modifier flags (bitwise) ─────────────────────────────────────── */ 64 65 ··· 174 175 * @field base Base layout key codepoint (Kitty alternate keys). 175 176 * @field text_len Number of valid codepoints in text[] (0-8). 176 177 * @field text Associated text codepoints (Kitty enhancement level 16+). 178 + * @field report_len Number of valid bytes in report[]. Only valid for 179 + * EVENT_POINTERSHAPE. 180 + * @field report Raw payload of an OSC 22 pointer-shape reply, the bytes 181 + * between `OSC 22 ;` and the terminator. Truncated to 182 + * MAX_REPORT_BYTES. Only valid for EVENT_POINTERSHAPE. 177 183 */ 178 184 179 185 #define MAX_TEXT_CODEPOINTS 8 186 + #define MAX_REPORT_BYTES 64 180 187 181 188 struct InputEvent { 182 189 uint8_t type; ··· 192 199 uint32_t base; 193 200 uint32_t text[MAX_TEXT_CODEPOINTS]; 194 201 uint8_t text_len; 202 + uint16_t report_len; 203 + uint8_t report[MAX_REPORT_BYTES]; 195 204 }; 196 205 197 206 /**
+60
test/input.test.ts
··· 715 715 }); 716 716 }); 717 717 718 + describe("OSC 22 pointer shape reports", () => { 719 + it("parses a current-shape reply terminated by ST", () => { 720 + let result = input.scan(str("\x1b]22;pointer\x1b\\")); 721 + expect(result.events.length).toBe(1); 722 + expect(result.events[0]).toMatchObject({ 723 + type: "pointershape", 724 + report: "pointer", 725 + }); 726 + }); 727 + 728 + it("parses a reply terminated by BEL", () => { 729 + let result = input.scan(str("\x1b]22;text\x07")); 730 + expect(result.events.length).toBe(1); 731 + expect(result.events[0]).toMatchObject({ 732 + type: "pointershape", 733 + report: "text", 734 + }); 735 + }); 736 + 737 + it("parses an empty-stack reply (0)", () => { 738 + let result = input.scan(str("\x1b]22;0\x1b\\")); 739 + expect(result.events.length).toBe(1); 740 + expect(result.events[0]).toMatchObject({ 741 + type: "pointershape", 742 + report: "0", 743 + }); 744 + }); 745 + 746 + it("parses a support-query reply (comma list) verbatim", () => { 747 + let result = input.scan(str("\x1b]22;1,0,1\x1b\\")); 748 + expect(result.events.length).toBe(1); 749 + expect(result.events[0]).toMatchObject({ 750 + type: "pointershape", 751 + report: "1,0,1", 752 + }); 753 + }); 754 + 755 + it("buffers a reply split across scans", () => { 756 + let first = input.scan(str("\x1b]22;poin")); 757 + expect(first.events.length).toBe(0); 758 + let second = input.scan(str("ter\x1b\\")); 759 + expect(second.events.length).toBe(1); 760 + expect(second.events[0]).toMatchObject({ 761 + type: "pointershape", 762 + report: "pointer", 763 + }); 764 + }); 765 + 766 + it("parses a reply interleaved with other input", () => { 767 + let result = input.scan(str("a\x1b]22;default\x1b\\b")); 768 + expect(result.events.length).toBe(3); 769 + expect(result.events[0]).toMatchObject({ type: "keydown", key: "a" }); 770 + expect(result.events[1]).toMatchObject({ 771 + type: "pointershape", 772 + report: "default", 773 + }); 774 + expect(result.events[2]).toMatchObject({ type: "keydown", key: "b" }); 775 + }); 776 + }); 777 + 718 778 describe("UTF-8", () => { 719 779 it("parses 2-byte UTF-8 (é)", () => { 720 780 let result = input.scan(bytes(0xc3, 0xa9));