From 8d11f50ee5e6de0670c3189d91a72c04dea93311 Mon Sep 17 00:00:00 2001 From: Nate Moore Date: Tue, 23 Jun 2026 12:26:45 -0400 Subject: [PATCH 1/5] feat(input): parse OSC 22 pointer-shape replies into PointerShapeEvent MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 ; 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. --- input-native.ts | 13 ++++++++ input.ts | 24 ++++++++++++++ specs/input-spec.md | 59 ++++++++++++++++++++++++++++++++++ src/input.c | 77 +++++++++++++++++++++++++++++++++++++++++++++ src/input.h | 9 ++++++ test/input.test.ts | 60 +++++++++++++++++++++++++++++++++++ 6 files changed, 242 insertions(+) diff --git a/input-native.ts b/input-native.ts index 7ed6d1b..f2258c2 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_POINTERSHAPE = 5; export const MOD_ALT = 1; export const MOD_CTRL = 2; @@ -89,6 +90,7 @@ import { } from "./typedef.ts"; const MAX_TEXT_CODEPOINTS = 8; +const MAX_REPORT_BYTES = 64; const InputEventLayout = struct({ type: uint8(), @@ -104,6 +106,8 @@ const InputEventLayout = struct({ base: uint32(), text: array(uint32(), MAX_TEXT_CODEPOINTS), text_len: uint8(), + report_len: uint16(), + report: array(uint8(), MAX_REPORT_BYTES), }); const { @@ -120,6 +124,8 @@ const { shifted: OFFSET_SHIFTED, base: OFFSET_BASE, text: OFFSET_TEXT, + report_len: OFFSET_REPORT_LEN, + report: OFFSET_REPORT, } = offsets(InputEventLayout); export interface NativeInputEvent { @@ -135,6 +141,7 @@ export interface NativeInputEvent { shifted: number; base: number; text: number[]; + report: number[]; } export function readEvent(view: DataView, ptr: number): NativeInputEvent { @@ -143,6 +150,11 @@ export function readEvent(view: DataView, ptr: number): NativeInputEvent { for (let i = 0; i < len && i < MAX_TEXT_CODEPOINTS; i++) { text.push(view.getUint32(ptr + OFFSET_TEXT + i * 4, true)); } + let reportLen = view.getUint16(ptr + OFFSET_REPORT_LEN, true); + let report: number[] = []; + for (let i = 0; i < reportLen && i < MAX_REPORT_BYTES; i++) { + report.push(view.getUint8(ptr + OFFSET_REPORT + i)); + } return { type: view.getUint8(ptr + OFFSET_TYPE), mod: view.getUint8(ptr + OFFSET_MOD), @@ -156,6 +168,7 @@ export function readEvent(view: DataView, ptr: number): NativeInputEvent { shifted: view.getUint32(ptr + OFFSET_SHIFTED, true), base: view.getUint32(ptr + OFFSET_BASE, true), text, + report, }; } diff --git a/input.ts b/input.ts index 5163e3a..1308638 100644 --- a/input.ts +++ b/input.ts @@ -11,6 +11,7 @@ import { EVENT_CURSOR, EVENT_KEY, EVENT_MOUSE, + EVENT_POINTERSHAPE, EVENT_RESIZE, KEY_ALT_LEFT, KEY_ALT_RIGHT, @@ -371,6 +372,22 @@ export interface CursorEvent { column: number; } +/** + * Reply to an OSC 22 mouse-pointer-shape query. + * + * Emitted when the terminal answers a pointer-shape query (sent by the + * output side) on the input stream. The `report` is the raw payload the + * terminal returned between `OSC 22 ;` and the terminator — for example a + * shape name (`"pointer"`), `"0"` for an empty stack, or a comma-separated + * list of `1`/`0` support flags. The parser does not interpret it; + * correlating a reply with the query that produced it is the caller's + * responsibility. + */ +export interface PointerShapeEvent { + type: "pointershape"; + report: string; +} + import type { PointerEvent } from "./term.ts"; export type InputEvent = @@ -381,6 +398,7 @@ export type InputEvent = | WheelEvent | ResizeEvent | CursorEvent + | PointerShapeEvent | PointerEvent; /** @@ -684,6 +702,12 @@ function mapEvent(native: NativeInputEvent): InputEvent { case EVENT_CURSOR: { return { type: "cursor", row: native.y, column: native.x }; } + case EVENT_POINTERSHAPE: { + return { + type: "pointershape", + report: new TextDecoder().decode(new Uint8Array(native.report)), + }; + } default: { return mapKeyEvent(native); } diff --git a/specs/input-spec.md b/specs/input-spec.md index 3416ebd..d9fd6e7 100644 --- a/specs/input-spec.md +++ b/specs/input-spec.md @@ -31,6 +31,7 @@ surface and guide future stabilization. - The scan API and its return type - The `InputEvent` discriminated union and its variants - The ESC timeout resolution model +- Decoding inbound OSC 22 mouse-pointer-shape replies into events ### Out of scope @@ -128,11 +129,69 @@ current variants are: - **`ResizeEvent`** (`type: "resize"`) — A terminal resize notification. Fields include `columns` and `rows`. +- **`PointerShapeEvent`** (`type: "pointershape"`) — A terminal reply to an OSC + 22 mouse-pointer-shape query. Carries a single `report` field: the raw payload + string the terminal returned between `OSC 22 ;` and the string terminator. The + parser does not interpret the payload and does not correlate it with any + outstanding query; correlation is the caller's responsibility. See Section + 5.1. + The discriminant values and the type splits are deliberate design decisions. However, the field sets within each variant are expected to grow when Kitty progressive enhancement types are surfaced in the TypeScript layer (the C struct has already been extended with fields that are not yet mapped to the TS types). +### 5.1 Pointer shape reports (OSC 22) + +> **Status:** Implemented. Code conforms to the spec, not the reverse (see +> AGENTS.md). + +Some terminals implement the OSC 22 mouse-pointer-shape protocol, under which an +application can _query_ the terminal's current pointer shape or its support for +named shapes. The terminal answers a query with a reply on the input stream. The +input parser recognizes these replies and surfaces them as `PointerShapeEvent`s. + +The parser's role is strictly inbound decoding. It never sends OSC 22 queries +and never sets the pointer shape — emitting OSC 22 is an output concern +specified separately (see [Renderer Specification](renderer-spec.md)). This +preserves the parser's independence from the renderer (INV-8): the parser only +decodes bytes it is given. + +**Recognized reply grammar.** A reply has the form: + +``` +ESC ] 22 ; +``` + +where `` is either ST (`ESC \`, bytes `0x1B 0x5C`) or BEL (`0x07`), +and `` is the run of bytes up to the terminator. The parser emits one +`PointerShapeEvent` per complete reply, with `report` set to the decoded +`` string. Payloads are truncated to 64 bytes; this comfortably fits +any shape name and a support-query reply for a reasonable number of shapes. The +parser does not validate or interpret the payload. Per the kitty pointer-shape +protocol the payload may be: + +- a shape name (reply to `?__current__`, `?__default__`, or `?__grabbed__`), +- `0` (current-shape query when the shape stack is empty), or +- a comma-separated list of `1`/`0` flags (reply to a support query of the form + `?name1,name2,...`). + +Which interpretation applies depends on the query the caller sent; the parser +does not track outstanding queries, so the caller is responsible for that +correlation. + +**Graceful degradation.** Terminals that do not implement the query side of OSC +22 (for example, set-only implementations) never send a reply, and therefore +never produce a `PointerShapeEvent`. A caller that issues a query and receives +no event within a timeout MUST treat the feature as unsupported. Absence of the +event is the contract for unsupported terminals; it is not an error. + +**Incremental bytes.** An OSC 22 reply split across multiple `scan()` calls is +buffered like any other escape sequence and surfaced as a single event once the +terminator arrives. A lone `ESC` does not apply here: the `]` that follows +disambiguates immediately, so OSC 22 replies do not participate in ESC timeout +resolution. + --- ## 6. Deferred / Future Areas diff --git a/src/input.c b/src/input.c index 1f6257c..537f01b 100644 --- a/src/input.c +++ b/src/input.c @@ -616,6 +616,67 @@ static int parse_cursor(struct InputState *st, struct InputEvent *ev) { return PARSE_NEED_MORE; } +/* Parse an OSC 22 mouse-pointer-shape reply: ESC ] 22 ; + * where is ST (ESC \) or BEL (0x07). The payload is surfaced verbatim; + * the parser does not interpret it. Only OSC 22 is recognized here. */ +static int parse_osc(struct InputState *st, struct InputEvent *ev) { + if (st->len < 2) + return PARSE_NEED_MORE; + if (st->buf[0] != '\x1b' || st->buf[1] != ']') + return PARSE_ERR; + + /* numeric OSC code */ + int i = 2; + int code = -1; + while (i < st->len && st->buf[i] >= '0' && st->buf[i] <= '9') { + if (code == -1) + code = 0; + code = code * 10 + (st->buf[i] - '0'); + i++; + } + if (i >= st->len) + return PARSE_NEED_MORE; /* code digits not yet terminated */ + if (code != 22 || st->buf[i] != ';') + return PARSE_ERR; /* only OSC 22 with a payload separator */ + i++; /* skip ';' */ + + /* payload runs until ST (ESC \) or BEL */ + int payload_start = i; + int payload_end = -1; + int term_len = 0; + while (i < st->len) { + uint8_t c = (uint8_t)st->buf[i]; + if (c == 0x07) { + payload_end = i; + term_len = 1; + break; + } + if (c == 0x1b) { + if (i + 1 >= st->len) + return PARSE_NEED_MORE; + if (st->buf[i + 1] != '\\') + return PARSE_ERR; /* ESC not forming ST inside payload */ + payload_end = i; + term_len = 2; + break; + } + i++; + } + if (payload_end == -1) + return PARSE_NEED_MORE; /* terminator not seen yet */ + + int n = payload_end - payload_start; + if (n > MAX_REPORT_BYTES) + n = MAX_REPORT_BYTES; /* truncate overly long payloads */ + for (int j = 0; j < n; j++) + ev->report[j] = (uint8_t)st->buf[payload_start + j]; + ev->report_len = (uint16_t)n; + ev->type = EVENT_POINTERSHAPE; + + shift(st, payload_end + term_len); + return PARSE_OK; +} + /* Parse Kitty-enhanced legacy CSI sequences (non-u terminators). * Format: CSI [number] [; mod[:action]] terminator * Handles A-D, F, H, P, Q, S, ~ terminators with optional :action */ @@ -977,6 +1038,22 @@ int input_scan(struct InputState *st, const char *buf, int len, double now) { return accepted; } + /* try OSC (ESC ]) — pointer-shape replies */ + { + struct InputEvent oev; + memset(&oev, 0, sizeof(oev)); + int rv = parse_osc(st, &oev); + if (rv == PARSE_OK) { + struct InputEvent *ev = emit(st); + *ev = oev; + st->esc_time = 0; + continue; + } + if (rv == PARSE_NEED_MORE) { + return accepted; + } + } + /* try trie match */ { int consumed = 0; diff --git a/src/input.h b/src/input.h index c38404e..adb9521 100644 --- a/src/input.h +++ b/src/input.h @@ -59,6 +59,7 @@ #define EVENT_MOUSE 2 #define EVENT_RESIZE 3 #define EVENT_CURSOR 4 +#define EVENT_POINTERSHAPE 5 /* ── Modifier flags (bitwise) ─────────────────────────────────────── */ @@ -174,9 +175,15 @@ * @field base Base layout key codepoint (Kitty alternate keys). * @field text_len Number of valid codepoints in text[] (0-8). * @field text Associated text codepoints (Kitty enhancement level 16+). + * @field report_len Number of valid bytes in report[]. Only valid for + * EVENT_POINTERSHAPE. + * @field report Raw payload of an OSC 22 pointer-shape reply, the bytes + * between `OSC 22 ;` and the terminator. Truncated to + * MAX_REPORT_BYTES. Only valid for EVENT_POINTERSHAPE. */ #define MAX_TEXT_CODEPOINTS 8 +#define MAX_REPORT_BYTES 64 struct InputEvent { uint8_t type; @@ -192,6 +199,8 @@ struct InputEvent { uint32_t base; uint32_t text[MAX_TEXT_CODEPOINTS]; uint8_t text_len; + uint16_t report_len; + uint8_t report[MAX_REPORT_BYTES]; }; /** diff --git a/test/input.test.ts b/test/input.test.ts index 38af941..4341078 100644 --- a/test/input.test.ts +++ b/test/input.test.ts @@ -715,6 +715,66 @@ describe("input", () => { }); }); + describe("OSC 22 pointer shape reports", () => { + it("parses a current-shape reply terminated by ST", () => { + let result = input.scan(str("\x1b]22;pointer\x1b\\")); + expect(result.events.length).toBe(1); + expect(result.events[0]).toMatchObject({ + type: "pointershape", + report: "pointer", + }); + }); + + it("parses a reply terminated by BEL", () => { + let result = input.scan(str("\x1b]22;text\x07")); + expect(result.events.length).toBe(1); + expect(result.events[0]).toMatchObject({ + type: "pointershape", + report: "text", + }); + }); + + it("parses an empty-stack reply (0)", () => { + let result = input.scan(str("\x1b]22;0\x1b\\")); + expect(result.events.length).toBe(1); + expect(result.events[0]).toMatchObject({ + type: "pointershape", + report: "0", + }); + }); + + it("parses a support-query reply (comma list) verbatim", () => { + let result = input.scan(str("\x1b]22;1,0,1\x1b\\")); + expect(result.events.length).toBe(1); + expect(result.events[0]).toMatchObject({ + type: "pointershape", + report: "1,0,1", + }); + }); + + it("buffers a reply split across scans", () => { + let first = input.scan(str("\x1b]22;poin")); + expect(first.events.length).toBe(0); + let second = input.scan(str("ter\x1b\\")); + expect(second.events.length).toBe(1); + expect(second.events[0]).toMatchObject({ + type: "pointershape", + report: "pointer", + }); + }); + + it("parses a reply interleaved with other input", () => { + let result = input.scan(str("a\x1b]22;default\x1b\\b")); + expect(result.events.length).toBe(3); + expect(result.events[0]).toMatchObject({ type: "keydown", key: "a" }); + expect(result.events[1]).toMatchObject({ + type: "pointershape", + report: "default", + }); + expect(result.events[2]).toMatchObject({ type: "keydown", key: "b" }); + }); + }); + describe("UTF-8", () => { it("parses 2-byte UTF-8 (é)", () => { let result = input.scan(bytes(0xc3, 0xa9)); From e50ae1955fa12be8b6862c4d409af5731abce817 Mon Sep 17 00:00:00 2001 From: Nate Moore Date: Tue, 23 Jun 2026 21:46:54 -0400 Subject: [PATCH 2/5] =?UTF-8?q?spec(renderer):=20add=20opt-in=20OSC=2022?= =?UTF-8?q?=20pointer-shape=20tracking=20(=C2=A712.6)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Lets the mouse pointer change shape over the UI, driven by the renderer's existing hit-testing. Elements declare a CSS-style `cursor` shape on open() (renderer-ignored, not packed to wasm); with `trackCursor: true`, render() returns OSC 22 bytes in a separate `cursor` field for the caller to write — kept out of `output` so render content stays pure. Narrows the §11.2 prohibition to the text caret and carves out a single opt-in exception for the mouse pointer shape, preserving INV-1 (renderer produces bytes, caller writes) and INV-7 (capability replies arrive via the input-side PointerShapeEvent; the caller correlates them). All tracking state lives in the TS term layer, so the wasm core stays frame-stateless. Framed as elastic §12 surface, like the pointer event model it builds on. --- specs/renderer-spec.md | 121 +++++++++++++++++++++++++++++++++++++++-- 1 file changed, 116 insertions(+), 5 deletions(-) diff --git a/specs/renderer-spec.md b/specs/renderer-spec.md index f808abe..d0f474f 100644 --- a/specs/renderer-spec.md +++ b/specs/renderer-spec.md @@ -565,15 +565,28 @@ terminal-management operations: - Entering or leaving the alternate screen buffer - Hiding or showing the cursor -- Setting the cursor shape or blink state +- Setting the text cursor (caret) shape or blink state - Enabling or disabling mouse reporting - Enabling or disabling keyboard protocol modes (e.g., Kitty progressive enhancement) - Enabling or disabling raw mode or similar terminal disciplines -These are the caller's responsibility. The renderer's output contains only the -escape sequences needed to render the frame content (cursor positioning for cell -writes, SGR attributes for styling, and UTF-8 text). +These are the caller's responsibility. The render `output` (Section 7.3) +contains only the escape sequences needed to render the frame content (cursor +positioning for cell writes, SGR attributes for styling, and UTF-8 text). + +**Exception — mouse pointer shape (OSC 22).** The CSS-style mouse _pointer_ +shape (the shape of the mouse cursor as it moves over the UI, set via OSC 22) is +distinct from the text caret named above and is the one piece of +terminal-pointer presentation the renderer MAY participate in, because it is +derived directly from the renderer's own hit-testing. When — and only when — the +caller explicitly opts in (Section 12.6), the renderer MAY compute pointer-shape +transitions and return the corresponding OSC 22 bytes in a dedicated, separate +field of the render result. These bytes MUST NOT appear in `output`, and the +renderer still performs no IO: it produces the bytes and the caller decides +whether to write them, so INV-1 holds. With the opt-in disabled (the default), +this section's prohibition applies in full and the renderer emits nothing +related to pointer shape. ### 11.3 The renderer does not own application lifecycle @@ -653,6 +666,17 @@ The `open()` constructor currently accepts the following property groups in its reference, attach target, structured attach points, pointer capture mode, clip target, z-index) - **`scroll`** — scroll container configuration +- **`cursor`** — the mouse pointer shape to request while the pointer is over + this element (see Section 12.6) + +The `cursor` property names a mouse pointer shape using the CSS `cursor` keyword +vocabulary (for example `"pointer"`, `"text"`, `"default"`, `"not-allowed"`, +`"grab"`, `"progress"`, `"ew-resize"`). It is a pure layout-tree annotation: it +does NOT affect layout, cell output, or the transfer encoding, and it is not +sent to the WASM module. The TS layer reads it directly off the plain directive +objects (Section 9.1) and uses it only when pointer-shape tracking is enabled +(Section 12.6). An element with no `cursor` property contributes no shape +preference. The `floating` object shape is: @@ -726,11 +750,19 @@ prevent overlap. ### 12.3 Render return type The `render()` method currently returns a `RenderResult` object shaped as -`{ output: Uint8Array, events: PointerEvent[], info: RenderInfo, errors: ClayError[] }`. +`{ output: Uint8Array, events: PointerEvent[], info: RenderInfo, errors: ClayError[], cursor?: Uint8Array }`. The `output` field is the ANSI byte output specified normatively in Section 7.3 and Section 8.2. +The `cursor` field is present only when pointer-shape tracking is enabled via +the `trackCursor` render option (Section 12.6). When present and non-empty, it +carries OSC 22 bytes that, when written to the terminal, update the mouse +pointer shape to match the element currently under the pointer. It is kept +strictly separate from `output` so that the render content stream stays pure +(Section 11.2). When tracking is disabled, or when no shape change occurred this +frame, the field is absent. + The `events` field contains pointer events (enter, leave, click) derived from the underlying layout engine's element hit-testing. This field was added during a pointer-events feature implementation. The pointer event model is functional @@ -816,6 +848,74 @@ and used in tests. array into the transfer encoding described in Section 12.1. Currently exported but not public API; its exposure is incidental to the module structure. +### 12.6 Pointer shape tracking (OSC 22) + +> **Status:** Prospective, non-normative. This subsection specifies intended +> behavior to be implemented. Like the pointer event model (Section 12.4) on +> which it builds, it is new and expected to settle; it MAY change without +> constituting a breaking change to the normative core. + +Pointer shape tracking lets the mouse pointer change shape as it moves over the +UI — a hand over a clickable element, an I-beam over a text field, and so on — +driven entirely by the renderer's existing hit-testing (Section 12.4). It is +**opt-in** and, when disabled, the renderer emits nothing related to pointer +shape (Section 11.2). + +**Declaring a shape.** An element declares its desired pointer shape with the +`cursor` property on `open()` (Section 12.2), named with the CSS `cursor` +keyword vocabulary. The renderer ignores this property for layout and output; it +is consumed only by the tracking described here. + +**Enabling tracking.** The caller enables tracking by passing +`trackCursor: true` in the render options, alongside the existing `pointer` +state used for hit-testing: + +```ts +const r = term.render(ops, { pointer: { x, y, down }, trackCursor: true }); +if (r.cursor) stdout.write(r.cursor); +``` + +**Per-frame behavior.** On each render with tracking enabled, the TS term layer: + +1. Reads the `cursor` property off the plain directive objects to build an + `id → shape` map for the frame. +2. Determines the element currently under the pointer from the same hit-test + data that produces `PointerEvent[]`. When the pointer is over nested + elements, the topmost (innermost) element that declares a `cursor` wins. +3. Compares the resulting shape against the shape emitted on the previous frame. + Cross-frame shape state is held in the TS term layer — the same layer that + already tracks pointer enter/leave across frames (Section 12.4) — not in the + WASM core, which remains frame-stateless (Section 4.3). +4. When the shape changed, populates `result.cursor` with the OSC 22 bytes that + effect the transition. When nothing changed, `result.cursor` is absent. + +**Save and restore (kitty stack).** Transitions use the kitty pointer-shape +_stack_ rather than bare set, so the terminal's prior shape is preserved: + +- Entering an element with a declared shape pushes it (`OSC 22 ; >shape ST`). +- Returning to no declared shape pops back to what the terminal had before + (`OSC 22 ; < ST`). + +This means the renderer never needs to know or assume the terminal's base shape; +the stack restores it. + +**Capability detection and graceful degradation.** Before relying on tracking, +the caller MAY query support. The OSC 22 query is sent through the normal output +path (it is a separate, caller-initiated byte sequence, not part of `output`), +and the terminal's reply arrives on the **input** stream, where it is decoded as +a `PointerShapeEvent` (see [Input Specification](input-spec.md), Section 5.1). +Correlating the reply with the query is the caller's responsibility, preserving +the renderer/input independence (INV-7). Terminals that do not implement OSC 22 +(or implement only the set operation, such as Ghostty) never reply and may not +honor push/pop; on these terminals tracking degrades to a no-op or a best-effort +set, and the absence of a reply within a timeout is the unsupported signal. + +**OSC 22 byte helpers.** The byte sequences above are produced by small, +caller-usable helpers (set, push, pop, and query builders). These are the first +concrete instance of the caller-side terminal-control helpers anticipated in +Section 14, and exist independently of the render transaction so that callers +who want manual control can drive pointer shape without `trackCursor`. + --- ## 13. Implementation Notes @@ -865,6 +965,9 @@ renderer. **CSI helper for terminal setup.** A helper for generating paired apply/rollback byte arrays for terminal mode configuration was discussed but not implemented. +The OSC 22 pointer-shape byte helpers (Section 12.6) are a first, narrow +instance of this category; a general apply/rollback helper for the other +terminal modes in Section 11.2 remains unimplemented. **Browser-specific adapter.** The renderer's zero-IO architecture makes browser portability possible. No adapter exists. @@ -946,3 +1049,11 @@ resolution. 7. **What are the validation and error semantics?** How the renderer responds to invalid input is unspecified. Callers SHOULD validate, but the validation model is not yet settled enough to define normatively. + +8. **Is pointer shape tracking part of the rendering contract?** Section 12.6 + adds an opt-in mouse-pointer-shape feature that returns OSC 22 bytes in a + separate `cursor` field, with a narrow normative carve-out in Section 11.2. + Like the pointer event model it builds on, it is currently elastic surface. + Whether pointer-shape tracking, the `cursor` property, and the OSC 22 helpers + belong in the normative core — or should live in a higher-level layer above + the renderer — is unresolved. From 2d504cf0157209db7efc42bd5f6c0ff1a0b6326a Mon Sep 17 00:00:00 2001 From: Nate Moore Date: Tue, 23 Jun 2026 21:53:47 -0400 Subject: [PATCH 3/5] =?UTF-8?q?feat(term):=20opt-in=20OSC=2022=20pointer-s?= =?UTF-8?q?hape=20tracking=20(renderer-spec=20=C2=A712.6)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Elements declare a CSS-style `cursor` shape on open(); it is a pure annotation, never packed to the WASM module. With `trackCursor: true`, render() finds the topmost element under the pointer that declares a cursor and returns the OSC 22 bytes for any change in `result.cursor`, kept separate from `output` so render content stays pure (§11.2). Save/restore uses the kitty pointer-shape stack: enter pushes, leave pops, so the terminal's prior shape is restored without a query. All tracking state lives in the TS term layer alongside the existing pointer-enter/leave bookkeeping; the wasm core is untouched. Pointer-over ids are outermost-first, so topmost-wins scans from the end. Adds set/push/pop/query OSC 22 byte helpers and a CursorShape (CSS cursor keyword) type to termcodes. --- ops.ts | 15 +++++ term.ts | 71 ++++++++++++++++++++-- termcodes.ts | 102 ++++++++++++++++++++++++++++++++ test/cursor.test.ts | 141 ++++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 324 insertions(+), 5 deletions(-) create mode 100644 test/cursor.test.ts diff --git a/ops.ts b/ops.ts index c5d7ca9..a18524d 100644 --- a/ops.ts +++ b/ops.ts @@ -1,3 +1,5 @@ +import type { CursorShape } from "./termcodes.ts"; + /* Command buffer opcodes — mirrors ops.h */ const OP_OPEN_ELEMENT = 0x02; const OP_TEXT = 0x03; @@ -323,6 +325,14 @@ export interface OpenElement { bottom?: number; }; clip?: { horizontal?: boolean; vertical?: boolean }; + /** + * Mouse pointer shape to request while the pointer is over this element. + * + * This is a pure annotation: it does not affect layout or output and is not + * sent to the WASM module. It is consumed only when pointer-shape tracking is + * enabled via the `trackCursor` render option. + */ + cursor?: CursorShape; floating?: { x?: number; y?: number; @@ -436,6 +446,11 @@ export function close(): CloseElement { return { directive: OP_CLOSE_ELEMENT }; } +/** Narrow an `Op` to an element-open directive. */ +export function isOpen(op: Op): op is OpenElement { + return op.directive === OP_OPEN_ELEMENT; +} + function packSize(ops: Op[]): number { let n = 0; for (let op of ops) { diff --git a/term.ts b/term.ts index 12517d0..5248a96 100644 --- a/term.ts +++ b/term.ts @@ -1,5 +1,10 @@ -import { type Op, pack } from "./ops.ts"; +import { isOpen, type Op, pack } from "./ops.ts"; import { type BoundingBox, createTermNative } from "./term-native.ts"; +import { + type CursorShape, + POPPOINTERSHAPE, + PUSHPOINTERSHAPE, +} from "./termcodes.ts"; export interface TermOptions { height: number; @@ -25,6 +30,15 @@ export interface RenderOptions { y: number; down: boolean; }; + + /** + * Track the mouse pointer shape across frames. When enabled, the element + * currently under the pointer that declares a `cursor` shape drives the + * terminal's mouse pointer, and {@link RenderResult.cursor} carries the OSC 22 + * bytes for any change. Requires `pointer` to be provided for the shape to + * follow the cursor. See the renderer specification, Section 12.6. + */ + trackCursor?: boolean; } export type PointerEvent = @@ -64,6 +78,14 @@ export interface RenderResult { events: PointerEvent[]; info: RenderInfo; errors: ClayError[]; + + /** + * OSC 22 bytes that update the terminal's mouse pointer shape this frame. + * Present only when `trackCursor` is enabled and the shape changed; write it + * to the terminal separately from `output`. See the renderer specification, + * Section 12.6. + */ + cursor?: Uint8Array; } export interface Term { @@ -78,6 +100,7 @@ export async function createTerm(options: TermOptions): Promise { let prev = new Set(); let pressed = new Set(); let wasDown = false; + let cursorShape: CursorShape | null = null; return { render(ops: Op[], options?: RenderOptions): RenderResult { @@ -97,9 +120,8 @@ export async function createTerm(options: TermOptions): Promise { native.length(statePtr), ); - let current = new Set( - options?.pointer ? native.getPointerOverIds() : [], - ); + let overIds = options?.pointer ? native.getPointerOverIds() : []; + let current = new Set(overIds); let down = options?.pointer?.down ?? false; let events: PointerEvent[] = []; @@ -132,6 +154,33 @@ export async function createTerm(options: TermOptions): Promise { prev = current; wasDown = down; + let cursor: Uint8Array | undefined; + if (options?.trackCursor) { + let active: CursorShape | null = null; + if (overIds.length > 0) { + let shapes = new Map(); + for (let op of ops) { + if (isOpen(op) && op.cursor) shapes.set(op.id, op.cursor); + } + // pointerOverIds is outermost-first; the innermost (topmost) + // declaring element wins, so scan from the end. + for (let i = overIds.length - 1; i >= 0; i--) { + let shape = shapes.get(overIds[i]); + if (shape) { + active = shape; + break; + } + } + } + if (active !== cursorShape) { + let parts: Uint8Array[] = []; + if (cursorShape !== null) parts.push(POPPOINTERSHAPE()); + if (active !== null) parts.push(PUSHPOINTERSHAPE(active)); + cursor = concat(parts); + cursorShape = active; + } + } + let info: RenderInfo = { get(id: string): ElementInfo | undefined { let bounds = native.getElementBounds(id); @@ -152,7 +201,19 @@ export async function createTerm(options: TermOptions): Promise { }); } - return { output, events, info, errors }; + return { output, events, info, errors, cursor }; }, }; } + +function concat(parts: Uint8Array[]): Uint8Array { + let total = 0; + for (let part of parts) total += part.length; + let out = new Uint8Array(total); + let offset = 0; + for (let part of parts) { + out.set(part, offset); + offset += part.length; + } + return out; +} diff --git a/termcodes.ts b/termcodes.ts index bc8534c..373a5e5 100644 --- a/termcodes.ts +++ b/termcodes.ts @@ -85,6 +85,108 @@ export function MAINSCREEN(): Uint8Array { return CSI("?1049l"); } +/** + * A mouse pointer shape, named with the CSS `cursor` keyword vocabulary. + * + * These are the values understood by terminals implementing the OSC 22 + * pointer-shape protocol (kitty, Ghostty). Terminals that do not recognize a + * given shape ignore it. + * + * @see {@link https://developer.mozilla.org/en-US/docs/Web/CSS/cursor | CSS cursor} + * @see {@link https://sw.kovidgoyal.net/kitty/pointer-shapes/ | kitty pointer shapes} + */ +export type CursorShape = + | "default" + | "none" + | "context-menu" + | "help" + | "pointer" + | "progress" + | "wait" + | "cell" + | "crosshair" + | "text" + | "vertical-text" + | "alias" + | "copy" + | "move" + | "no-drop" + | "not-allowed" + | "grab" + | "grabbing" + | "e-resize" + | "n-resize" + | "ne-resize" + | "nw-resize" + | "s-resize" + | "se-resize" + | "sw-resize" + | "w-resize" + | "ew-resize" + | "ns-resize" + | "nesw-resize" + | "nwse-resize" + | "col-resize" + | "row-resize" + | "all-scroll" + | "zoom-in" + | "zoom-out"; + +/** + * Encode an Operating System Command (OSC). + * + * Wraps the given string as `ESC ] str ST`, where ST is the String Terminator + * (`ESC \`). + * + * @see {@link https://www.ecma-international.org/publications-and-standards/standards/ecma-48/ | ECMA-48} + */ +export function OSC(str: string): Uint8Array { + return encode(`\x1b]${str}\x1b\\`); +} + +/** + * Set the mouse pointer shape (OSC 22). + * + * Replaces the current pointer shape. Prefer {@link PUSHPOINTERSHAPE} / + * {@link POPPOINTERSHAPE} when you want the terminal's prior shape restored. + * + * @see {@link https://sw.kovidgoyal.net/kitty/pointer-shapes/ | kitty pointer shapes} + */ +export function POINTERSHAPE(shape: CursorShape): Uint8Array { + return OSC(`22;${shape}`); +} + +/** + * Push a mouse pointer shape onto the terminal's pointer-shape stack (OSC 22). + * + * The pushed shape becomes current; {@link POPPOINTERSHAPE} restores whatever + * was current before. This is the kitty stack extension and is how shapes are + * saved and restored without querying the terminal's prior shape. + */ +export function PUSHPOINTERSHAPE(shape: CursorShape): Uint8Array { + return OSC(`22;>${shape}`); +} + +/** + * Pop the top mouse pointer shape off the stack (OSC 22), restoring the shape + * that was current before the matching {@link PUSHPOINTERSHAPE}. + */ +export function POPPOINTERSHAPE(): Uint8Array { + return OSC("22;<"); +} + +/** + * Query the terminal's mouse pointer shape support (OSC 22). + * + * With no arguments, asks for the current shape (`?__current__`). With one or + * more shape names, asks which are supported. The terminal replies on the + * input stream; the reply is decoded as a `PointerShapeEvent` (see the input + * parser). Terminals without query support never reply. + */ +export function QUERYPOINTERSHAPE(...shapes: CursorShape[]): Uint8Array { + return OSC(`22;?${shapes.length > 0 ? shapes.join(",") : "__current__"}`); +} + const encoder = new TextEncoder(); function encode(str: string): Uint8Array { diff --git a/test/cursor.test.ts b/test/cursor.test.ts new file mode 100644 index 0000000..f71b750 --- /dev/null +++ b/test/cursor.test.ts @@ -0,0 +1,141 @@ +import { beforeEach, describe, expect, it } from "./suite.ts"; +import { createTerm, type Term } from "../term.ts"; +import { close, fixed, grow, open, text } from "../ops.ts"; + +const decoder = new TextDecoder(); + +function shown(bytes: Uint8Array | undefined): string | undefined { + return bytes === undefined ? undefined : decoder.decode(bytes); +} + +const PUSH = (shape: string) => `\x1b]22;>${shape}\x1b\\`; +const POP = `\x1b]22;<\x1b\\`; + +// ┌─root (40x10, ltr)──────────────────┐ +// │┌─btn (20x10)──┐┌─field (20x10)───┐│ +// ││ cursor:pointer ││ cursor:text ││ +// │└───────────────┘└────────────────┘│ +// └───────────────────────────────────┘ +function layout() { + return [ + open("root", { + layout: { width: grow(), height: grow(), direction: "ltr" }, + }), + open("btn", { + layout: { width: fixed(20), height: fixed(10) }, + cursor: "pointer", + }), + text("B"), + close(), + open("field", { + layout: { width: fixed(20), height: fixed(10) }, + cursor: "text", + }), + text("F"), + close(), + close(), + ]; +} + +describe("pointer shape tracking", () => { + let term: Term; + + beforeEach(async () => { + term = await createTerm({ width: 40, height: 10 }); + }); + + it("emits no cursor field when trackCursor is not enabled", () => { + let result = term.render(layout(), { + pointer: { x: 5, y: 5, down: false }, + }); + expect(result.cursor).toBeUndefined(); + }); + + it("pushes the shape when the pointer enters a declaring element", () => { + let result = term.render(layout(), { + pointer: { x: 5, y: 5, down: false }, + trackCursor: true, + }); + expect(shown(result.cursor)).toBe(PUSH("pointer")); + }); + + it("emits nothing on a subsequent frame over the same element", () => { + term.render(layout(), { + pointer: { x: 5, y: 5, down: false }, + trackCursor: true, + }); + let result = term.render(layout(), { + pointer: { x: 6, y: 5, down: false }, + trackCursor: true, + }); + expect(result.cursor).toBeUndefined(); + }); + + it("pops then pushes when moving between elements of different shapes", () => { + term.render(layout(), { + pointer: { x: 5, y: 5, down: false }, + trackCursor: true, + }); + let result = term.render(layout(), { + pointer: { x: 25, y: 5, down: false }, + trackCursor: true, + }); + expect(shown(result.cursor)).toBe(POP + PUSH("text")); + }); + + it("pops when the pointer leaves all declaring elements", () => { + term.render(layout(), { + pointer: { x: 5, y: 5, down: false }, + trackCursor: true, + }); + let result = term.render(layout(), { + pointer: { x: 100, y: 100, down: false }, + trackCursor: true, + }); + expect(shown(result.cursor)).toBe(POP); + }); + + it("pops when the pointer is removed entirely", () => { + term.render(layout(), { + pointer: { x: 5, y: 5, down: false }, + trackCursor: true, + }); + let result = term.render(layout(), { trackCursor: true }); + expect(shown(result.cursor)).toBe(POP); + }); + + it("uses the topmost (innermost) declaring element's shape", () => { + // root declares "default"; the inner box declares "pointer". + let nested = () => [ + open("root", { + layout: { width: grow(), height: grow() }, + cursor: "default", + }), + open("inner", { + layout: { width: fixed(10), height: fixed(5) }, + cursor: "pointer", + }), + text("x"), + close(), + close(), + ]; + let result = term.render(nested(), { + pointer: { x: 2, y: 2, down: false }, + trackCursor: true, + }); + expect(shown(result.cursor)).toBe(PUSH("pointer")); + }); + + it("emits nothing when the hovered element declares no shape", () => { + let plain = () => [ + open("root", { layout: { width: grow(), height: grow() } }), + text("x"), + close(), + ]; + let result = term.render(plain(), { + pointer: { x: 2, y: 2, down: false }, + trackCursor: true, + }); + expect(result.cursor).toBeUndefined(); + }); +}); From 219beb41a0e3cac95749a8a275d45ab849313b54 Mon Sep 17 00:00:00 2001 From: Nate Moore Date: Tue, 23 Jun 2026 21:53:47 -0400 Subject: [PATCH 4/5] example(keyboard): show a pointer cursor when hovering keys Declares cursor: "pointer" on each key and enables trackCursor, writing the returned OSC 22 bytes so the mouse pointer turns into a hand over the keys and reverts elsewhere. --- examples/keyboard/index.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/examples/keyboard/index.ts b/examples/keyboard/index.ts index fffa499..f92b3fb 100644 --- a/examples/keyboard/index.ts +++ b/examples/keyboard/index.ts @@ -137,8 +137,9 @@ await main(function* () { pointer.state = undefined; } - let { output, events } = term.render(keyboard(context), { + let { output, events, cursor } = term.render(keyboard(context), { pointer: pointer.state, + trackCursor: true, }); for (let event of events) { @@ -146,6 +147,9 @@ await main(function* () { } writeStdout(output); + if (cursor) { + writeStdout(cursor); + } yield* each.next(); } @@ -229,6 +233,7 @@ function key(ops: Op[], k: KeyDef, ctx: AppContext): void { alignY: "center", }, bg, + cursor: "pointer", border: hover ? { color: highlight, left: 1, right: 1, top: 1, bottom: 1 } : undefined, From f07a9edf773499b5034e9bacee1ac30c4c864526 Mon Sep 17 00:00:00 2001 From: Nate Moore Date: Tue, 23 Jun 2026 22:13:46 -0400 Subject: [PATCH 5/5] fix(term): use bare set OSC 22 so pointer shapes work on Ghostty MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The tracker emitted the kitty push/pop stack form (ESC]22;>shape and ESC]22;<). Ghostty's OSC 22 parser treats the whole payload after "22;" as a literal shape name, so ">shape" is not a valid shape and is dropped — the pointer never changed on Ghostty (and any other set-only terminal). Switch to the portable bare set form: set the shape on enter (ESC]22;shape) and restore the base by setting "default" on leave. kitty and Ghostty both honor this. The trade-off is that we assume the base shape is "default" rather than restoring a non-default prior shape; the push/pop helpers remain exported for callers that target kitty and want exact save/restore. --- specs/renderer-spec.md | 26 +++++++++++++++----------- term.ts | 30 +++++++----------------------- test/cursor.test.ts | 21 ++++++++++----------- 3 files changed, 32 insertions(+), 45 deletions(-) diff --git a/specs/renderer-spec.md b/specs/renderer-spec.md index d0f474f..5427c4c 100644 --- a/specs/renderer-spec.md +++ b/specs/renderer-spec.md @@ -889,15 +889,19 @@ if (r.cursor) stdout.write(r.cursor); 4. When the shape changed, populates `result.cursor` with the OSC 22 bytes that effect the transition. When nothing changed, `result.cursor` is absent. -**Save and restore (kitty stack).** Transitions use the kitty pointer-shape -_stack_ rather than bare set, so the terminal's prior shape is preserved: +**Setting and restoring.** Transitions use the bare _set_ form of OSC 22 for +portability — kitty and Ghostty both honor it, whereas the kitty push/pop +_stack_ extension is silently ignored by set-only terminals (Ghostty parses the +whole payload after `22;` as a literal shape name, so a `>`/`<` prefix is not a +valid shape and is dropped): -- Entering an element with a declared shape pushes it (`OSC 22 ; >shape ST`). -- Returning to no declared shape pops back to what the terminal had before - (`OSC 22 ; < ST`). +- Entering an element with a declared shape sets it (`OSC 22 ; shape ST`). +- Returning to no declared shape restores the base by setting `default` + (`OSC 22 ; default ST`). -This means the renderer never needs to know or assume the terminal's base shape; -the stack restores it. +The base shape is assumed to be `default` (the ordinary pointer); the renderer +does not attempt to restore a non-default prior shape. Callers targeting kitty +exclusively who want exact save/restore can drive the push/pop helpers manually. **Capability detection and graceful degradation.** Before relying on tracking, the caller MAY query support. The OSC 22 query is sent through the normal output @@ -905,10 +909,10 @@ path (it is a separate, caller-initiated byte sequence, not part of `output`), and the terminal's reply arrives on the **input** stream, where it is decoded as a `PointerShapeEvent` (see [Input Specification](input-spec.md), Section 5.1). Correlating the reply with the query is the caller's responsibility, preserving -the renderer/input independence (INV-7). Terminals that do not implement OSC 22 -(or implement only the set operation, such as Ghostty) never reply and may not -honor push/pop; on these terminals tracking degrades to a no-op or a best-effort -set, and the absence of a reply within a timeout is the unsupported signal. +the renderer/input independence (INV-7). Because tracking uses the bare set +form, it works on set-only terminals (such as Ghostty) as well as kitty; only +terminals that do not implement OSC 22 at all ignore it entirely, and the +absence of a reply within a timeout is the unsupported signal. **OSC 22 byte helpers.** The byte sequences above are produced by small, caller-usable helpers (set, push, pop, and query builders). These are the first diff --git a/term.ts b/term.ts index 5248a96..03c1be9 100644 --- a/term.ts +++ b/term.ts @@ -1,10 +1,6 @@ import { isOpen, type Op, pack } from "./ops.ts"; import { type BoundingBox, createTermNative } from "./term-native.ts"; -import { - type CursorShape, - POPPOINTERSHAPE, - PUSHPOINTERSHAPE, -} from "./termcodes.ts"; +import { type CursorShape, POINTERSHAPE } from "./termcodes.ts"; export interface TermOptions { height: number; @@ -100,7 +96,7 @@ export async function createTerm(options: TermOptions): Promise { let prev = new Set(); let pressed = new Set(); let wasDown = false; - let cursorShape: CursorShape | null = null; + let cursorShape: CursorShape = "default"; return { render(ops: Op[], options?: RenderOptions): RenderResult { @@ -156,7 +152,10 @@ export async function createTerm(options: TermOptions): Promise { let cursor: Uint8Array | undefined; if (options?.trackCursor) { - let active: CursorShape | null = null; + // Set-only OSC 22: the base is "default" (kitty and Ghostty both honor + // a bare set; the kitty push/pop stack is ignored by set-only terminals + // like Ghostty). + let active: CursorShape = "default"; if (overIds.length > 0) { let shapes = new Map(); for (let op of ops) { @@ -173,10 +172,7 @@ export async function createTerm(options: TermOptions): Promise { } } if (active !== cursorShape) { - let parts: Uint8Array[] = []; - if (cursorShape !== null) parts.push(POPPOINTERSHAPE()); - if (active !== null) parts.push(PUSHPOINTERSHAPE(active)); - cursor = concat(parts); + cursor = POINTERSHAPE(active); cursorShape = active; } } @@ -205,15 +201,3 @@ export async function createTerm(options: TermOptions): Promise { }, }; } - -function concat(parts: Uint8Array[]): Uint8Array { - let total = 0; - for (let part of parts) total += part.length; - let out = new Uint8Array(total); - let offset = 0; - for (let part of parts) { - out.set(part, offset); - offset += part.length; - } - return out; -} diff --git a/test/cursor.test.ts b/test/cursor.test.ts index f71b750..84726b2 100644 --- a/test/cursor.test.ts +++ b/test/cursor.test.ts @@ -8,8 +8,7 @@ function shown(bytes: Uint8Array | undefined): string | undefined { return bytes === undefined ? undefined : decoder.decode(bytes); } -const PUSH = (shape: string) => `\x1b]22;>${shape}\x1b\\`; -const POP = `\x1b]22;<\x1b\\`; +const SET = (shape: string) => `\x1b]22;${shape}\x1b\\`; // ┌─root (40x10, ltr)──────────────────┐ // │┌─btn (20x10)──┐┌─field (20x10)───┐│ @@ -51,12 +50,12 @@ describe("pointer shape tracking", () => { expect(result.cursor).toBeUndefined(); }); - it("pushes the shape when the pointer enters a declaring element", () => { + it("sets the shape when the pointer enters a declaring element", () => { let result = term.render(layout(), { pointer: { x: 5, y: 5, down: false }, trackCursor: true, }); - expect(shown(result.cursor)).toBe(PUSH("pointer")); + expect(shown(result.cursor)).toBe(SET("pointer")); }); it("emits nothing on a subsequent frame over the same element", () => { @@ -71,7 +70,7 @@ describe("pointer shape tracking", () => { expect(result.cursor).toBeUndefined(); }); - it("pops then pushes when moving between elements of different shapes", () => { + it("sets the new shape when moving between elements of different shapes", () => { term.render(layout(), { pointer: { x: 5, y: 5, down: false }, trackCursor: true, @@ -80,10 +79,10 @@ describe("pointer shape tracking", () => { pointer: { x: 25, y: 5, down: false }, trackCursor: true, }); - expect(shown(result.cursor)).toBe(POP + PUSH("text")); + expect(shown(result.cursor)).toBe(SET("text")); }); - it("pops when the pointer leaves all declaring elements", () => { + it("restores default when the pointer leaves all declaring elements", () => { term.render(layout(), { pointer: { x: 5, y: 5, down: false }, trackCursor: true, @@ -92,16 +91,16 @@ describe("pointer shape tracking", () => { pointer: { x: 100, y: 100, down: false }, trackCursor: true, }); - expect(shown(result.cursor)).toBe(POP); + expect(shown(result.cursor)).toBe(SET("default")); }); - it("pops when the pointer is removed entirely", () => { + it("restores default when the pointer is removed entirely", () => { term.render(layout(), { pointer: { x: 5, y: 5, down: false }, trackCursor: true, }); let result = term.render(layout(), { trackCursor: true }); - expect(shown(result.cursor)).toBe(POP); + expect(shown(result.cursor)).toBe(SET("default")); }); it("uses the topmost (innermost) declaring element's shape", () => { @@ -123,7 +122,7 @@ describe("pointer shape tracking", () => { pointer: { x: 2, y: 2, down: false }, trackCursor: true, }); - expect(shown(result.cursor)).toBe(PUSH("pointer")); + expect(shown(result.cursor)).toBe(SET("pointer")); }); it("emits nothing when the hovered element declares no shape", () => {