From 033bc12df82aa02291ed87d47aa80871d6caf780 Mon Sep 17 00:00:00 2001 From: Nate Moore Date: Thu, 8 Oct 2026 18:16:33 -0500 Subject: [PATCH 1/9] spec(renderer): pointer shape output gated on the pointerShape capability MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds renderer-spec §7.9 and wires it into the existing capability sections instead of a standalone feature spec: pointer shape is one more consumer of RuntimeCapabilities, gated the same way as the rest. - §7.9: open() gains pointerShape (kitty/CSS shape names; anything else is ignored and never reaches output). While capabilities.pointerShape is true, the last declaring element in hit-test order wins (innermost; capture-mode floats hide what is beneath, passthrough floats defer to it). Changes are appended to result.output as OSC 22 ; ST, so the host keeps writing one buffer (cowboyd on #101, replacing #101's separate result.cursor). There is no createTerm opt-in: the capability is the only gate. - Restore (dreyfus92 on #101): set-only OSC 22 cannot pop, so the Term sets "default" when the pointer leaves or is omitted, and update() returns the reset when the capability is withdrawn. Resize keeps the emitted shape since the terminal pointer is unaffected. - §7.3: output "may be" a view, since a frame carrying OSC 22 is assembled in TypeScript. - §7.7, §7.8, §11.2, §12.2 and terminfo-spec §6.3/§9.3 point at §7.9 instead of a "focused feature specification". terminfo-spec §6.3 documents the escape hatch for set-only terminals that never answer the query: term.update([{ type: "capability", key: "pointer-shape", value: true }]). "pointerShape" rather than "cursor": cursor already names the hardware text cursor (§7.6), and pointerShape matches the capability key and RuntimeCapabilities field. --- specs/renderer-spec.md | 86 ++++++++++++++++++++++++++++++++---------- specs/terminfo-spec.md | 10 +++-- 2 files changed, 74 insertions(+), 22 deletions(-) diff --git a/specs/renderer-spec.md b/specs/renderer-spec.md index 0e1d6ff..1c375a7 100644 --- a/specs/renderer-spec.md +++ b/specs/renderer-spec.md @@ -287,10 +287,10 @@ The render transaction produces ANSI bytes as a `Uint8Array`. These bytes: The output reflects the complete visual state of the frame. The caller SHOULD write the output to the terminal without modification. -The output `Uint8Array` is a view over renderer-owned memory. It is valid until -the next `render()` or `update()` call on the same Term instance, at which point -the buffer may be reused or detached (§7.7). Callers who need to retain the -output beyond that point MUST copy it. +The output `Uint8Array` may be a view over renderer-owned memory. It is valid +until the next `render()` or `update()` call on the same Term instance, at which +point the buffer may be reused or detached (§7.7). Callers who need to retain +the output beyond that point MUST copy it. ### 7.4 Lifecycle @@ -449,9 +449,9 @@ be a no-op for that step. **Capability semantics.** A `CapabilityEvent` step folds the event's `key` and `value` into the Term's private `RuntimeCapabilities`. The foundation update -transaction does not emit bytes or alter renderer output as a consequence of a -capability event. A focused capability specification MUST define any output -invalidation or immediate bytes required by its consumer. +transaction emits bytes or alters renderer output as a consequence of a +capability event only where a capability section of this specification says so +(§7.9). **Return value.** `update()` returns a `Uint8Array` of bytes to write to the terminal immediately. An empty array is valid when the update changes no @@ -469,16 +469,61 @@ is valid until the next `render()` **or** `update()` call). ### 7.8 Capability consumption -The renderer may consume the read-only capability snapshot maintained by -`Term.update()`, but this foundation specification does not define any -capability-specific output. Protocols that change emitted bytes MUST add their -own renderer section, capability evidence, invalidation rules, and tests in a -focused feature specification. - -In particular, color encoding, synchronized-output wrapping, pointer-shape -output, Kitty keyboard mode setup, and Kitty graphics emission are deferred to -their respective follow-up PRs. The renderer continues to emit its existing -hardcoded ANSI output until one of those specifications is adopted. +The renderer consumes the read-only capability snapshot maintained by +`Term.update()`. A protocol that changes emitted bytes MUST define its gating +capability, output, and invalidation rules in its own section of this +specification. Pointer shape (§7.9) is the first. + +Color encoding, synchronized-output wrapping, Kitty keyboard mode setup, and +Kitty graphics emission are deferred to their respective follow-up PRs. The +renderer continues to emit its existing hardcoded ANSI output until each is +specified. + +### 7.9 Pointer shape + +An `open()` directive MAY declare a `pointerShape` property naming the mouse +pointer shape to show while the pointer is over the element. Values are the +kitty pointer shape names (the CSS `cursor` keywords): `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`, `zoom-in`, `zoom-out`. Any other value MUST be +treated as absent and MUST NOT reach the output. The property does not affect +layout or cell output. + +The gate is `RuntimeCapabilities.pointerShape` (see +[Terminfo Specification](terminfo-spec.md) §6.3). While it is `false`, the +renderer MUST NOT read `pointerShape` properties and MUST NOT emit OSC 22. + +**Resolved shape.** While the capability is `true`, each render resolves one +shape: + +- `default` when the render options carry no `pointer`. +- Otherwise, the `pointerShape` of the last declaring element in hit-test order + (the order behind `pointerenter` events, §12.4), or `default` when none + declares one. Ancestors precede descendants, so the innermost element wins. A + floating element in `"capture"` mode hides the elements beneath it; one in + `"passthrough"` mode lets them decide. + +Elements inside a `snapshot()` participate as direct directives would. + +**Output.** The Term tracks the shape it last emitted, starting at `default`. +When the resolved shape differs, the render MUST append `ESC ] 22 ; ST` +to `output` after the frame's cell bytes and record the new shape. Otherwise it +MUST NOT emit OSC 22. + +**Restore.** OSC 22 set cannot pop, so the Term restores by setting `default`: + +- A render that resolves `default` emits it per **Output** above. +- An update step that changes `pointerShape` from `true` to `false` while the + emitted shape is not `default` MUST return `ESC ] 22 ; default ST` and record + `default`. +- A resize step leaves the emitted shape unchanged. + +To restore before exit, the caller renders a frame without `pointer`, or passes +`{ type: "capability", key: "pointer-shape", value: false }` to `update()`, and +writes the result. --- @@ -816,8 +861,9 @@ declaration is present, the cursor-positioning and cursor-visibility sequences specified in §7.6. Capability-specific output modes are not terminal-state management owned by the -foundation renderer. A focused feature specification must define their state -boundaries separately. +foundation renderer. Each capability section of §7 defines its own state +boundary. The mouse pointer shape (§7.9) is distinct from the cursor shape +above, which remains caller-managed. ### 11.3 The renderer does not own application lifecycle @@ -903,6 +949,8 @@ 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 +- **`pointerShape`** — mouse pointer shape while hovered (§7.9); not transferred + to the WASM module The `floating` object shape is: diff --git a/specs/terminfo-spec.md b/specs/terminfo-spec.md index d800909..dfff3b0 100644 --- a/specs/terminfo-spec.md +++ b/specs/terminfo-spec.md @@ -284,7 +284,11 @@ per field the reply carries. OSC 21 arriving alongside OSC 10/11/12 replies does not merge; each response yields its own event. **OSC 22.** An OSC 22 reply sets `key: "pointer-shape", value: true`. The shape -name in the reply is discarded in v1; only protocol support is recorded. +name in the reply is discarded in v1; only protocol support is recorded. The +renderer emits pointer shapes only while `pointerShape` is `true` (Renderer +Specification §7.9). Terminals that implement OSC 22 set but not its query never +reply; a caller with outside knowledge supplies the evidence itself with +`term.update([{ type: "capability", key: "pointer-shape", value: true }])`. **DA1.** The DA1 reply is recognized internally as the probe fence and MUST NOT surface as a `CapabilityEvent`. @@ -422,8 +426,8 @@ and mouse events in arrival order. The host loop routes them to `term.update()`. A capability event can arrive after the renderer has already emitted frames. `term.update()` folds it into the runtime snapshot. Any renderer-side output -invalidation or immediate bytes are defined by the focused feature specification -that consumes that capability. +invalidation or immediate bytes are defined by the renderer section that +consumes that capability (Renderer Specification §7.8). --- From 60908220a7433a8c69ee917350263d373ce81ca6 Mon Sep 17 00:00:00 2001 From: Nate Moore Date: Thu, 8 Oct 2026 18:16:46 -0500 Subject: [PATCH 2/9] test(input): pin OSC 22 reply edge cases #101's bespoke parse_osc() drew review for unbounded digit accumulation on `ESC ] 9999...`. The rebuild drops that parser and relies on #132's parse_osc_response(), which already bails once the OSC number passes 22 and caps payloads at MAX_RESPONSE. These tests pin that behavior for OSC 22 so it cannot regress: BEL and ST terminators, the "0" empty-stack reply, byte-at-a-time delivery, a broken ST, a 64-digit OSC number, OSC 220, and an over-long payload (no event, parser recovers for the next reply). --- test/input.test.ts | 66 ++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 66 insertions(+) diff --git a/test/input.test.ts b/test/input.test.ts index 91ce14f..768e8ea 100644 --- a/test/input.test.ts +++ b/test/input.test.ts @@ -996,6 +996,72 @@ describe("terminfo integration", () => { }); }); + it("surfaces a BEL-terminated OSC 22 pointer shape report", async () => { + let { input } = await withTerminfo(); + let result = input.scan(str("\x1b]22;pointer\x07")); + expect(result.events).toEqual([ + { type: "capability", key: "pointer-shape", value: true }, + ]); + }); + + it("surfaces an OSC 22 empty-stack report", async () => { + let { input } = await withTerminfo(); + let result = input.scan(str("\x1b]22;0\x1b\\")); + expect(result.events).toEqual([ + { type: "capability", key: "pointer-shape", value: true }, + ]); + }); + + it("buffers an OSC 22 report fed one byte at a time", async () => { + let { input } = await withTerminfo(); + let reply = str("\x1b]22;default\x1b\\"); + for (let i = 0; i < reply.length - 1; i++) { + expect(input.scan(reply.subarray(i, i + 1)).events).toEqual([]); + } + expect(input.scan(reply.subarray(reply.length - 1)).events).toEqual([ + { type: "capability", key: "pointer-shape", value: true }, + ]); + }); + + it("rejects an OSC 22 report whose ST is split from its ESC by a non-backslash", async () => { + let { input } = await withTerminfo(); + let result = input.scan(str("\x1b]22;default\x1bx")); + expect(result.events.some((e) => e.type === "capability")).toBe(false); + }); + + it("bails on an OSC number longer than any it accepts without overflowing", async () => { + let { input } = await withTerminfo(); + let digits = "9".repeat(64); + let result = input.scan(str(`\x1b]${digits};pointer\x07`)); + expect(result.events.some((e) => e.type === "capability")).toBe(false); + expect(input.scan(str("\x1b]22;default\x1b\\")).events).toEqual([ + { type: "capability", key: "pointer-shape", value: true }, + ]); + }); + + it("does not read OSC 220 as OSC 22", async () => { + let { input } = await withTerminfo(); + let result = input.scan(str("\x1b]220;pointer\x07")); + expect(result.events.some((e) => e.type === "capability")).toBe(false); + }); + + it("drops an OSC 22 report whose payload exceeds the response bound", async () => { + let { input } = await withTerminfo(); + let events = input.scan(str(`\x1b]22;${"x".repeat(2048)}\x07`)).events; + expect(events.some((e) => e.type === "capability")).toBe(false); + events = input.scan(str("\x1b]22;default\x07")).events; + for (let i = 0; i < 32 && events.length > 0; i++) { + let last = events[events.length - 1]; + if (last.type === "capability") break; + events = input.scan().events; + } + expect(events[events.length - 1]).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 }); From 72cc4b24db8db0269253dc6839193375476bcf75 Mon Sep 17 00:00:00 2001 From: Nate Moore Date: Thu, 8 Oct 2026 20:10:57 -0500 Subject: [PATCH 3/9] =?UTF-8?q?feat:=20emit=20OSC=2022=20pointer=20shapes?= =?UTF-8?q?=20in=20output=20when=20the=20terminal=20supports=20them=20(ren?= =?UTF-8?q?derer-spec=20=C2=A77.9)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Elements declare open(id, { pointerShape }). While term.capabilities.pointerShape is true (raised by #132/#133 from the probe's OSC 22 query reply), each render resolves the shape under the pointer and appends `OSC 22 ; ST` to result.output when it changes. The host keeps writing one buffer (cowboyd on #101). The capability is the only gate; there is no createTerm option. Everything lives in TypeScript; the wasm module and the packed encoding are untouched, so there is no size or startup cost (#101 regressed createTerm ~20% and grew the bundle 8.3 KB). Without the capability the render path pays one boolean check and never reads pointerShape (a test pins this with a counting getter). With it, the directive walk runs only when the pointer is over something, and only a frame whose shape changed copies output to append the sequence. Resolution takes the last declaring id in Clay's pointer-over order: pre-order within the topmost layer the pointer reaches, so the innermost element wins and capture-mode floats hide what is beneath while passthrough floats defer to it. snapshot() records its declared shapes beside the packed bytes so pre-packed subtrees still participate. Values outside the kitty/CSS vocabulary are ignored (and rejected by validate()), so arbitrary strings never reach an OSC payload. Restore (dreyfus92 on #101): set-only OSC 22 cannot pop, so the emitted shape is the one piece of cross-frame state. It resets to "default" when the pointer leaves or is omitted, and update() returns the reset when the pointer-shape capability is withdrawn. Resize keeps it, since the terminal's pointer did not change. --- ops.ts | 20 ++- pointer-shape.ts | 81 +++++++++++ term.ts | 33 ++++- test/pointer-shape.test.ts | 279 +++++++++++++++++++++++++++++++++++++ test/validate.test.ts | 14 ++ validate.ts | 4 + 6 files changed, 427 insertions(+), 4 deletions(-) create mode 100644 pointer-shape.ts create mode 100644 test/pointer-shape.test.ts diff --git a/ops.ts b/ops.ts index e89b291..59d6f9a 100644 --- a/ops.ts +++ b/ops.ts @@ -1,3 +1,7 @@ +import type { PointerShape } from "./pointer-shape.ts"; + +export type { PointerShape }; + export type TransitionProperty = | "x" | "y" @@ -410,6 +414,7 @@ export interface OpenElement { zIndex?: number; }; transition?: Transition; + pointerShape?: PointerShape; } export type AttachPoint = @@ -491,6 +496,7 @@ export interface Text { interface Snapshot { directive: typeof OP_SNAPSHOT; data: Uint8Array; + shapes?: readonly (readonly [id: string, shape: string])[]; } export type Op = OpenElement | Text | CloseElement | Snapshot; @@ -551,7 +557,19 @@ export function snapshot(ops: Op[]): Op { let size = packSize(ops); let buf = new ArrayBuffer(size); let words = pack(ops, buf, 0, size); - return { directive: OP_SNAPSHOT, data: new Uint8Array(buf, 0, words * 4) }; + let data = new Uint8Array(buf, 0, words * 4); + let shapes: (readonly [string, string])[] = []; + for (let op of ops) { + if (op.directive === OP_SNAPSHOT) { + if (op.shapes) shapes.push(...op.shapes); + } else if ( + op.directive === OP_OPEN_ELEMENT && op.pointerShape !== undefined + ) { + shapes.push([op.id, op.pointerShape]); + } + } + if (shapes.length === 0) return { directive: OP_SNAPSHOT, data }; + return { directive: OP_SNAPSHOT, data, shapes }; } const TP_X = 1; diff --git a/pointer-shape.ts b/pointer-shape.ts new file mode 100644 index 0000000..3e2641e --- /dev/null +++ b/pointer-shape.ts @@ -0,0 +1,81 @@ +import type { Op } from "./ops.ts"; + +export const POINTER_SHAPES = [ + "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", + "zoom-in", + "zoom-out", +] as const; + +/** + * A mouse pointer shape, named with the CSS `cursor` keyword vocabulary used + * by the OSC 22 pointer shape protocol. + * + * @see {@link https://sw.kovidgoyal.net/kitty/pointer-shapes/ | kitty pointer shapes} + */ +export type PointerShape = typeof POINTER_SHAPES[number]; + +const KNOWN: ReadonlySet = new Set(POINTER_SHAPES); + +export function resolvePointerShape( + ops: readonly Op[], + over: readonly string[], +): PointerShape { + let best = -1; + let shape: PointerShape = "default"; + for (let op of ops) { + if ("data" in op) { + if (!op.shapes) continue; + for (let [id, value] of op.shapes) { + if (!KNOWN.has(value)) continue; + let i = over.lastIndexOf(id); + if (i > best) { + best = i; + shape = value as PointerShape; + } + } + } else if ("pointerShape" in op && op.pointerShape !== undefined) { + if (!KNOWN.has(op.pointerShape)) continue; + let i = over.lastIndexOf(op.id); + if (i > best) { + best = i; + shape = op.pointerShape; + } + } + } + return shape; +} + +const encoder = new TextEncoder(); + +export function osc22(shape: PointerShape): Uint8Array { + return encoder.encode(`\x1b]22;${shape}\x1b\\`); +} diff --git a/term.ts b/term.ts index 5326daa..2c77941 100644 --- a/term.ts +++ b/term.ts @@ -2,6 +2,11 @@ import { type Op, pack } from "./ops.ts"; import { type BoundingBox, createTermNative } from "./term-native.ts"; import type { InputEvent } from "./input.ts"; import type { Capabilities, Rgb, TerminalInfo } from "./terminfo.ts"; +import { + osc22, + type PointerShape, + resolvePointerShape, +} from "./pointer-shape.ts"; export type { BoundingBox }; @@ -163,6 +168,7 @@ export interface Term { export async function createTerm(options: TermOptions): Promise { let { width, height, terminfo } = options; + let emittedShape: PointerShape = "default"; let native = await createTermNative( width, @@ -225,9 +231,22 @@ export async function createTerm(options: TermOptions): Promise { native.length(native.statePtr), ); - let current = new Set( - options?.pointer ? native.getPointerOverIds() : [], - ); + let overIds = options?.pointer ? native.getPointerOverIds() : []; + let current = new Set(overIds); + + if (currentCaps.pointerShape) { + let shape: PointerShape = overIds.length > 0 + ? resolvePointerShape(ops, overIds) + : "default"; + if (shape !== emittedShape) { + emittedShape = shape; + let seq = osc22(shape); + let joined = new Uint8Array(output.length + seq.length); + joined.set(output); + joined.set(seq, output.length); + output = joined; + } + } let down = options?.pointer?.down ?? false; let events: PointerEvent[] = []; @@ -309,6 +328,14 @@ export async function createTerm(options: TermOptions): Promise { } } + if ( + currentCaps.pointerShape && !next.pointerShape && + emittedShape !== "default" + ) { + emittedShape = "default"; + out.push(osc22("default")); + } + currentCaps = next; if (bytes.length) out.push(bytes); } diff --git a/test/pointer-shape.test.ts b/test/pointer-shape.test.ts new file mode 100644 index 0000000..a5429e7 --- /dev/null +++ b/test/pointer-shape.test.ts @@ -0,0 +1,279 @@ +import { beforeEach, describe, expect, it } from "./suite.ts"; +import { createTerm, type Term } from "../term.ts"; +import { + close, + fixed, + grow, + type Op, + open, + type PointerShape, + snapshot, + text, +} from "../ops.ts"; +import type { CapabilityEvent } from "../input.ts"; +import { print } from "./print.ts"; + +const SUPPORTED: CapabilityEvent = { + type: "capability", + key: "pointer-shape", + value: true, +}; + +const WITHDRAWN: CapabilityEvent = { + type: "capability", + key: "pointer-shape", + value: false, +}; + +const osc22 = (shape: string) => `\x1b]22;${shape}\x1b\\`; + +const decode = (bytes: Uint8Array) => new TextDecoder().decode(bytes); + +const trim = (s: string) => s.split("\n").map((l) => l.trimEnd()).join("\n"); + +function sequences(bytes: Uint8Array): string[] { + let [, ...rest] = decode(bytes).split("\x1b]22;"); + return rest.map((tail) => osc22(tail.slice(0, tail.indexOf("\x1b\\")))); +} + +function over(x: number, y: number) { + return { pointer: { x, y, down: false } }; +} + +// ┌─root (40x6, ltr)───────────────────────┐ +// │┌─link (20x6) pointer─┐┌─plain (20x6)──┐│ +// ││┌─field (10x2) text┐ ││P ││ +// │││F │ ││ ││ +// ││└──────────────────┘ ││ ││ +// ││L ││ ││ +// │└─────────────────────┘└───────────────┘│ +// └────────────────────────────────────────┘ +function layout(link: { pointerShape?: PointerShape } = {}): Op[] { + return [ + open("root", { + layout: { width: grow(), height: grow(), direction: "ltr" }, + }), + open("link", { + layout: { width: fixed(20), height: fixed(6), direction: "ttb" }, + pointerShape: "pointer", + ...link, + }), + open("field", { + layout: { width: fixed(10), height: fixed(2) }, + pointerShape: "text", + }), + text("F"), + close(), + text("L"), + close(), + open("plain", { + layout: { width: fixed(20), height: fixed(6) }, + }), + text("P"), + close(), + close(), + ]; +} + +const FIELD = over(2, 0); +const LINK = over(2, 4); +const PLAIN = over(25, 2); + +describe("pointer shape", () => { + let term: Term; + + beforeEach(async () => { + term = await createTerm({ width: 40, height: 6 }); + term.update([SUPPORTED]); + }); + + describe("capability gate", () => { + it("never emits OSC 22 without the pointer-shape capability", async () => { + let unsupported = await createTerm({ width: 40, height: 6 }); + expect(sequences(unsupported.render(layout(), LINK).output)).toEqual([]); + expect(sequences(unsupported.render(layout(), PLAIN).output)).toEqual( + [], + ); + }); + + it("does not read pointerShape without the capability", async () => { + let unsupported = await createTerm({ width: 40, height: 6 }); + let reads = 0; + let ops = layout().map((op) => + "pointerShape" in op + ? Object.defineProperty({ ...op }, "pointerShape", { + get: () => (reads++, "pointer"), + }) + : op + ); + unsupported.render(ops, LINK); + expect(reads).toBe(0); + }); + + it("emits once the capability arrives", async () => { + let late = await createTerm({ width: 40, height: 6 }); + expect(sequences(late.render(layout(), LINK).output)).toEqual([]); + expect(late.update([SUPPORTED])).toEqual(new Uint8Array(0)); + expect(sequences(late.render(layout(), LINK).output)).toEqual([ + osc22("pointer"), + ]); + }); + }); + + describe("output", () => { + it("appends the set sequence after the unchanged frame bytes", async () => { + let plain = await createTerm({ width: 40, height: 6 }); + let frame = decode(plain.render(layout(), LINK).output); + let output = decode(term.render(layout(), LINK).output); + + expect(output).toBe(frame + osc22("pointer")); + expect(trim(print(frame, 40, 6))).toBe(trim( + [ + "F P", + "", + "L", + "", + "", + "", + ].join("\n"), + )); + }); + + it("emits only when the resolved shape changes", () => { + expect(sequences(term.render(layout(), LINK).output)).toEqual([ + osc22("pointer"), + ]); + expect(sequences(term.render(layout(), LINK).output)).toEqual([]); + expect(sequences(term.render(layout(), over(3, 5)).output)).toEqual([]); + }); + + it("emits in line mode", () => { + let result = term.render(layout(), { mode: "line", ...LINK }); + expect(sequences(result.output)).toEqual([osc22("pointer")]); + }); + + it("emits nothing while the pointer only crosses undeclared elements", () => { + expect(sequences(term.render(layout(), PLAIN).output)).toEqual([]); + expect(sequences(term.render(layout()).output)).toEqual([]); + }); + }); + + describe("resolution", () => { + it("lets the innermost declaring element win", () => { + expect(sequences(term.render(layout(), FIELD).output)).toEqual([ + osc22("text"), + ]); + expect(sequences(term.render(layout(), LINK).output)).toEqual([ + osc22("pointer"), + ]); + }); + + it("follows a declared shape that changes under a still pointer", () => { + term.render(layout(), LINK); + expect( + sequences( + term.render(layout({ pointerShape: "progress" }), LINK) + .output, + ), + ).toEqual([osc22("progress")]); + }); + + it("ignores values outside the PointerShape vocabulary", () => { + let bogus = "x\x1b]0;pwned\x07" as PointerShape; + let output = term.render(layout({ pointerShape: bogus }), LINK).output; + expect(decode(output)).not.toContain("pwned"); + expect(sequences(output)).toEqual([]); + }); + + it("sees elements inside a snapshot", () => { + let ops = layout(); + let frozen = snapshot(ops.slice(1, -1)); + let result = term.render([ops[0], frozen, ops[ops.length - 1]], FIELD); + expect(sequences(result.output)).toEqual([osc22("text")]); + }); + + it("lets a capture-mode floating overlay win over what is beneath", () => { + let ops = layout(); + ops.splice( + ops.length - 1, + 0, + open("menu", { + layout: { width: fixed(6), height: fixed(3) }, + floating: { x: 1, y: 3, attachTo: "root" }, + pointerShape: "grab", + }), + close(), + ); + expect(sequences(term.render(ops, LINK).output)).toEqual([ + osc22("grab"), + ]); + }); + + it("lets a passthrough floating overlay defer to what is beneath", () => { + let ops = layout(); + ops.splice( + ops.length - 1, + 0, + open("tooltip", { + layout: { width: fixed(6), height: fixed(3) }, + floating: { + x: 1, + y: 3, + attachTo: "root", + pointerCaptureMode: "passthrough", + }, + pointerShape: "help", + }), + close(), + ); + expect(sequences(term.render(ops, LINK).output)).toEqual([ + osc22("pointer"), + ]); + }); + }); + + describe("restore", () => { + it("resets to default when the pointer moves off declaring elements", () => { + term.render(layout(), LINK); + expect(sequences(term.render(layout(), PLAIN).output)).toEqual([ + osc22("default"), + ]); + }); + + it("resets to default on a render without pointer", () => { + term.render(layout(), LINK); + expect(sequences(term.render(layout()).output)).toEqual([ + osc22("default"), + ]); + }); + + it("returns the reset from update() when the capability is withdrawn", () => { + term.render(layout(), LINK); + expect(decode(term.update([WITHDRAWN]))).toBe(osc22("default")); + expect(sequences(term.render(layout(), LINK).output)).toEqual([]); + }); + + it("returns nothing from update() when withdrawn at default", () => { + term.render(layout(), PLAIN); + expect(term.update([WITHDRAWN])).toEqual(new Uint8Array(0)); + }); + + it("returns the reset once from a batch that withdraws twice", () => { + term.render(layout(), LINK); + expect(decode(term.update([WITHDRAWN, SUPPORTED, WITHDRAWN]))).toBe( + osc22("default"), + ); + }); + + it("keeps the emitted shape across a resize", () => { + term.render(layout(), LINK); + expect(term.update([{ type: "resize", width: 50, height: 8 }])).toEqual( + new Uint8Array(0), + ); + expect(sequences(term.render(layout(), LINK).output)).toEqual([]); + expect(sequences(term.render(layout(), PLAIN).output)).toEqual([ + osc22("default"), + ]); + }); + }); +}); diff --git a/test/validate.test.ts b/test/validate.test.ts index 3cd8896..57b34cb 100644 --- a/test/validate.test.ts +++ b/test/validate.test.ts @@ -33,6 +33,20 @@ describe("validate", () => { ])).toBe(true); }); + it("accepts a pointer shape", () => { + expect(validate([open("x", { pointerShape: "pointer" }), close()])).toBe( + true, + ); + }); + + it("rejects a pointer shape outside the vocabulary", () => { + expect(validate([ + // deno-lint-ignore no-explicit-any + open("x", { pointerShape: "hand" as any }), + close(), + ])).toBe(false); + }); + it("rejects ops with wrong directive", () => { expect(validate([{ directive: 0xff }])).toBe(false); }); diff --git a/validate.ts b/validate.ts index d98c94a..83bbe37 100644 --- a/validate.ts +++ b/validate.ts @@ -1,6 +1,7 @@ import { Type } from "@sinclair/typebox"; import { TypeCompiler } from "@sinclair/typebox/compiler"; import type { Op } from "./ops.ts"; +import { POINTER_SHAPES } from "./pointer-shape.ts"; import type { RenderOptions, RenderResult, Term } from "./term.ts"; /* ── Range helpers (match bit-packing in pack()) ──────────────────── */ @@ -178,6 +179,8 @@ const Transition = Type.Object({ interactive: Type.Optional(Type.Boolean()), }); +const PointerShape = Type.Union(POINTER_SHAPES.map((s) => Type.Literal(s))); + /* ── Op types (discriminated on `directive`) ──────────────────────── */ const CloseElement = Type.Object({ directive: Type.Literal(0x04) }); @@ -192,6 +195,7 @@ const OpenElement = Type.Object({ clip: Type.Optional(Clip), floating: Type.Optional(Floating), transition: Type.Optional(Transition), + pointerShape: Type.Optional(PointerShape), }); const TextOp = Type.Object({ From e13f95b4bc039a3ed6577cfb323715154dcb3b4e Mon Sep 17 00:00:00 2001 From: Nate Moore Date: Thu, 8 Oct 2026 20:11:10 -0500 Subject: [PATCH 4/9] docs: name pointer shape as a renderer-managed exception; emit in line mode MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit AGENTS.md's "renderer MUST NOT manage terminal state" invariant listed only the caret exception. Pointer shape is the second: the renderer owns it only while the pointerShape capability is true (renderer-spec §7.9). renderer-spec §7.9 now says resolution and output are identical in line mode, settling cowboyd's open question on #101. Bytes appear only when the caller passes `pointer`, which already implies an interactive terminal with mouse reporting, so a line-mode carve-out would protect nothing. The "emits in line mode" test in the feat commit pins it. --- AGENTS.md | 4 +++- specs/renderer-spec.md | 1 + 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/AGENTS.md b/AGENTS.md index e229098..b33f979 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -33,7 +33,9 @@ Do not include any agent marketing material (e.g. "Generated with...", - The renderer MUST NOT manage terminal state (alternate buffer, mouse reporting, keyboard protocol modes). Cursor visibility and positioning are renderer-managed only when a `caret` declaration is present on a `text()` - directive (see renderer-spec.md §7.6). + directive (see renderer-spec.md §7.6). The mouse pointer shape is + renderer-managed only while the `pointerShape` capability is true (see + renderer-spec.md §7.9). - Each frame is a complete snapshot. The renderer carries no UI tree state between frames — only cell buffers for diffing. diff --git a/specs/renderer-spec.md b/specs/renderer-spec.md index 1c375a7..1dc9cf8 100644 --- a/specs/renderer-spec.md +++ b/specs/renderer-spec.md @@ -507,6 +507,7 @@ shape: `"passthrough"` mode lets them decide. Elements inside a `snapshot()` participate as direct directives would. +Resolution and output are identical in line mode (§8.2.2). **Output.** The Term tracks the shape it last emitted, starting at `default`. When the resolved shape differs, the render MUST append `ESC ] 22 ; ST` From dbae9f9153717de2c17dd8c6494ca637b92506ac Mon Sep 17 00:00:00 2001 From: Nate Moore Date: Thu, 8 Oct 2026 20:11:10 -0500 Subject: [PATCH 5/9] chore: add changeset for pointer shape output --- .changeset/pointer-shape-osc22.md | 11 +++++++++++ 1 file changed, 11 insertions(+) create mode 100644 .changeset/pointer-shape-osc22.md diff --git a/.changeset/pointer-shape-osc22.md b/.changeset/pointer-shape-osc22.md new file mode 100644 index 0000000..bb133bb --- /dev/null +++ b/.changeset/pointer-shape-osc22.md @@ -0,0 +1,11 @@ +--- +"@bomb.sh/tty": minor +--- + +Adds a `pointerShape` property to `open()`, so the mouse pointer changes shape (OSC 22) as it moves over elements + +When the terminal confirms support through the probe (`term.capabilities.pointerShape`), `render()` appends the shape change to `output`, so writing `output` keeps the pointer in sync. The shape resets to `default` when the pointer leaves, when a frame renders without `pointer`, or when `update()` withdraws the capability. For a terminal that sets shapes but never answers the query, assert support with `term.update([{ type: "capability", key: "pointer-shape", value: true }])`. + +```ts +term.render([open("link", { pointerShape: "pointer" }), text("Docs"), close()], { pointer }); +``` From 770d205673a064ad44e04a7f09e71b88f86b66f0 Mon Sep 17 00:00:00 2001 From: Nate Moore Date: Fri, 9 Oct 2026 13:35:43 -0500 Subject: [PATCH 6/9] feat: seed pointerShape from a known-support table in detectTerminal() MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The OSC 22 query is answered by kitty alone; every other set-supporting terminal (ghostty, foot, xterm ≥ 367) never replies, so detection could never see them and the demos silently degraded in the most popular modern terminal. terminfo-spec §6.1 now carries a known-support table of terminals with primary evidence for OSC 22 set support — ghostty (its own docs), kitty (its own spec), foot (foot-ctlseqs(7)), and xterm (ctlseqs, version floor 367 via XTERM_VERSION). detectTerminal() seeds pointerShape from the environment; the probe reply remains authoritative evidence, and the manual CapabilityEvent opt-in stays for anything the table misses. mintty is deliberately excluded: it implements OSC 22 but its accepted names are Windows cursor names (hand, ibeam), not the CSS names §7.9 emits. Rio and Konsole lack primary evidence (Konsole is contradicted by ucs-detect, which — like our probe — tests the query form). The spec records the accepted false-positive trade: nested contexts that inherit the environment unchanged (Emacs term-mode) are granted by association; muxers and IDE terminals rewrite their identity and are naturally excluded. --- .changeset/pointer-shape-osc22.md | 2 +- specs/terminfo-spec.md | 35 +++++++++++++++++++++-- term.ts | 3 +- terminfo.ts | 36 ++++++++++++++++++++++++ test/terminfo.test.ts | 46 +++++++++++++++++++++++++++++++ 5 files changed, 116 insertions(+), 6 deletions(-) diff --git a/.changeset/pointer-shape-osc22.md b/.changeset/pointer-shape-osc22.md index bb133bb..c74e78a 100644 --- a/.changeset/pointer-shape-osc22.md +++ b/.changeset/pointer-shape-osc22.md @@ -4,7 +4,7 @@ Adds a `pointerShape` property to `open()`, so the mouse pointer changes shape (OSC 22) as it moves over elements -When the terminal confirms support through the probe (`term.capabilities.pointerShape`), `render()` appends the shape change to `output`, so writing `output` keeps the pointer in sync. The shape resets to `default` when the pointer leaves, when a frame renders without `pointer`, or when `update()` withdraws the capability. For a terminal that sets shapes but never answers the query, assert support with `term.update([{ type: "capability", key: "pointer-shape", value: true }])`. +When the terminal confirms support through the probe (`term.capabilities.pointerShape`), `render()` appends the shape change to `output`, so writing `output` keeps the pointer in sync. The shape resets to `default` when the pointer leaves, when a frame renders without `pointer`, or when `update()` withdraws the capability. `detectTerminal()` also seeds the capability from a known-support table for terminals that set shapes without answering the query (ghostty, kitty, foot, xterm ≥ 367), so those work out of the box. For anything the table misses, assert support with `term.update([{ type: "capability", key: "pointer-shape", value: true }])`. ```ts term.render([open("link", { pointerShape: "pointer" }), text("Docs"), close()], { pointer }); diff --git a/specs/terminfo-spec.md b/specs/terminfo-spec.md index dfff3b0..2903c59 100644 --- a/specs/terminfo-spec.md +++ b/specs/terminfo-spec.md @@ -180,6 +180,7 @@ interface Capabilities { readonly xenl: boolean; readonly altScreen: boolean; readonly styledUnderline: boolean; + readonly pointerShape: boolean; } ``` @@ -192,11 +193,36 @@ interface Capabilities { | `xenl` | `xenl` boolean cap | | `altScreen` | `smcup` string present | | `styledUnderline` | `Su` boolean or `Smulx` string | +| `pointerShape` | known OSC 22 support table (below); probe overrides | Key sequences (`key_*` capabilities) are not stored here. The input parser reads them directly from the raw terminfo bytes in `TerminalInfo.keys` at initialization time. +**Known OSC 22 support.** Some terminals implement OSC 22 pointer shape setting +but never answer its support query (Renderer Specification §7.9); silence must +not count as denial, so `detectTerminal()` seeds `pointerShape: true` when the +environment identifies a terminal in this table. Entries require primary +evidence from the terminal's own documentation or specification. + +| Terminal | Environment evidence | Version floor | Evidence | +| -------- | ---------------------------------------------- | ------------- | -------------------------- | +| ghostty | `TERM=xterm-ghostty` or `TERM_PROGRAM=ghostty` | 1.0.0 | ghostty.org/docs/vt/osc/22 | +| kitty | `TERM=xterm-kitty` or `KITTY_WINDOW_ID` set | 0.31.0 | kitty pointer-shapes spec | +| foot | `TERM` starting with `foot` | 1.12.0 | foot-ctlseqs(7) | +| xterm | `XTERM_VERSION` parses as patch ≥ 367 | 367 | xterm ctlseqs (OSC 22) | + +The table names terminals by what they _emit_, not by what they parse: an +implementation that accepts only a non-CSS vocabulary (e.g. Windows cursor +names) MUST NOT be listed, since the renderer emits the kitty CSS names +exclusively (§6.3). This is static evidence with known failure modes: nested +contexts that inherit the environment unchanged (for example Emacs `term-mode` +running inside a listed terminal) are granted by association and will garble +OSC 22. Terminals that rewrite `TERM` (tmux, Zellij) or `TERM_PROGRAM` (IDE +terminals) are naturally excluded; ssh forwards are true positives because the +bytes still reach the real terminal. Stale entries degrade to the same silent +swallowing the gate was built to avoid. + ### 6.2 `ColorDepth` ```ts @@ -287,7 +313,8 @@ not merge; each response yields its own event. name in the reply is discarded in v1; only protocol support is recorded. The renderer emits pointer shapes only while `pointerShape` is `true` (Renderer Specification §7.9). Terminals that implement OSC 22 set but not its query never -reply; a caller with outside knowledge supplies the evidence itself with +reply; `detectTerminal()` supplies that evidence itself from the known-support +table (§6.1), and a caller may still override it with `term.update([{ type: "capability", key: "pointer-shape", value: true }])`. **DA1.** The DA1 reply is recognized internally as the probe fence and MUST NOT @@ -300,7 +327,6 @@ interface RuntimeCapabilities extends Capabilities { readonly syncOutput: boolean; readonly kittyKeyboard: boolean; readonly kittyGraphics: boolean; - readonly pointerShape: boolean; readonly theme: { readonly foreground?: Rgb; readonly background?: Rgb; @@ -310,7 +336,9 @@ interface RuntimeCapabilities extends Capabilities { ``` `RuntimeCapabilities` is the renderer's current merged view: the static -`Capabilities` fields plus all `CapabilityEvent` values folded in so far. The +`Capabilities` fields (including `pointerShape`, which starts from the +known-support table and is raised by a probe reply or a `pointer-shape` +CapabilityEvent) plus all other `CapabilityEvent` values folded in so far. The `trueColor` field reflects the latest evidence at any precedence level; a `colordepth` probe event overrides the static value. `RuntimeCapabilities` is exposed as `term.capabilities` and is a frozen snapshot at the moment of access. @@ -335,6 +363,7 @@ With no terminfo bytes, no environment evidence, and no probe responses, | `xenl` | `true` | | `altScreen` | `true` | | `styledUnderline` | `false` | +| `pointerShape` | `false` | Truecolor is not assumed at baseline. Per TINV-3, it requires positive evidence. diff --git a/term.ts b/term.ts index 2c77941..5b4948f 100644 --- a/term.ts +++ b/term.ts @@ -29,7 +29,6 @@ export interface RuntimeCapabilities extends Capabilities { readonly syncOutput: boolean; readonly kittyKeyboard: boolean; readonly kittyGraphics: boolean; - readonly pointerShape: boolean; readonly theme: { readonly foreground?: Rgb; readonly background?: Rgb; @@ -94,7 +93,6 @@ function runtimeFromStatic(caps: Capabilities): RuntimeCapabilities { syncOutput: false, kittyKeyboard: false, kittyGraphics: false, - pointerShape: false, theme: Object.freeze({}), }); } @@ -185,6 +183,7 @@ export async function createTerm(options: TermOptions): Promise { xenl: true, altScreen: true, styledUnderline: false, + pointerShape: false, }, ); diff --git a/terminfo.ts b/terminfo.ts index bb3c081..5680eda 100644 --- a/terminfo.ts +++ b/terminfo.ts @@ -43,6 +43,7 @@ export interface Capabilities { readonly xenl: boolean; readonly altScreen: boolean; readonly styledUnderline: boolean; + readonly pointerShape: boolean; } /** Opaque type alias for the raw terminfo key-sequence bytes. */ @@ -91,6 +92,40 @@ const PROBE = encoder.encode( const WASM_PAGE_BYTES = 65536; +/** + * The known OSC 22 pointer-shape support table (terminfo-spec §6.1): terminals + * that implement OSC 22 pointer shape setting but never answer its support + * query, identified by environment evidence. Entries require primary evidence + * from the terminal's own documentation. Each entry records the version that + * introduced support; a stale entry degrades to the same silent swallowing the + * capability gate already produces, never to garbage. + */ +function knownPointerShape( + env: Record, +): boolean { + // ghostty: OSC 22 since 1.0.0 (ghostty.org/docs/vt/osc/22); set only — + // the reference documents no query form. + if (env.TERM === "xterm-ghostty" || env.TERM_PROGRAM === "ghostty") { + return true; + } + // kitty: full kitty pointer-shapes protocol since 0.31.0, including the + // query (sw.kovidgoyal.net/kitty/pointer-shapes/). Seeded here so + // term.capabilities is correct before the probe reply arrives. + if (env.TERM === "xterm-kitty" || env.KITTY_WINDOW_ID !== undefined) { + return true; + } + // foot: OSC 22 sets the xcursor pointer (foot-ctlseqs(7)); no query form. + if (env.TERM?.startsWith("foot")) { + return true; + } + // xterm: OSC 22 since patch 367 (ctlseqs); XTERM_VERSION is `xterm(N)`. + let patch = env.XTERM_VERSION?.match(/xterm\((\d+)\)/); + if (patch && Number(patch[1]) >= 367) { + return true; + } + return false; +} + function rgbOf(packed: number): Rgb { return { r: (packed >> 16) & 0xff, @@ -179,6 +214,7 @@ export async function detectTerminal( xenl: !!(flags & FLAG_EAT_NEWLINE_GLITCH), altScreen: !!(flags & FLAG_ALTSCREEN), styledUnderline: !!(flags & FLAG_STYLED_UNDERLINE), + pointerShape: knownPointerShape(env), }); return Object.freeze({ diff --git a/test/terminfo.test.ts b/test/terminfo.test.ts index 007b31e..e9e15ec 100644 --- a/test/terminfo.test.ts +++ b/test/terminfo.test.ts @@ -23,8 +23,54 @@ const BASELINE = { xenl: true, altScreen: true, styledUnderline: false, + pointerShape: false, }; +describe("known OSC 22 pointer shape support (terminfo-spec §6.1)", () => { + it("grants for ghostty by TERM and by TERM_PROGRAM", async () => { + let byTerm = await detectTerminal({ env: { TERM: "xterm-ghostty" } }); + expect(byTerm.capabilities.pointerShape).toBe(true); + let byProgram = await detectTerminal({ env: { TERM_PROGRAM: "ghostty" } }); + expect(byProgram.capabilities.pointerShape).toBe(true); + }); + + it("grants for kitty by TERM and by KITTY_WINDOW_ID", async () => { + let byTerm = await detectTerminal({ env: { TERM: "xterm-kitty" } }); + expect(byTerm.capabilities.pointerShape).toBe(true); + let byWindow = await detectTerminal({ env: { KITTY_WINDOW_ID: "1" } }); + expect(byWindow.capabilities.pointerShape).toBe(true); + }); + + it("grants for foot by TERM prefix", async () => { + let d = await detectTerminal({ env: { TERM: "foot" } }); + expect(d.capabilities.pointerShape).toBe(true); + }); + + it("grants for xterm at the version floor and above", async () => { + let old = await detectTerminal({ env: { XTERM_VERSION: "xterm(366)" } }); + expect(old.capabilities.pointerShape).toBe(false); + let floor = await detectTerminal({ env: { XTERM_VERSION: "xterm(367)" } }); + expect(floor.capabilities.pointerShape).toBe(true); + let newer = await detectTerminal({ env: { XTERM_VERSION: "xterm(388)" } }); + expect(newer.capabilities.pointerShape).toBe(true); + }); + + it("does not grant for terminals that rewrite their identity", async () => { + for ( + let TERM of [ + "tmux-256color", + "screen-256color", + "xterm-256color", + ] + ) { + let d = await detectTerminal({ env: { TERM } }); + expect(d.capabilities.pointerShape).toBe(false); + } + let vscode = await detectTerminal({ env: { TERM_PROGRAM: "vscode" } }); + expect(vscode.capabilities.pointerShape).toBe(false); + }); +}); + describe("baseline", () => { it("initializes to the baseline with no evidence", async () => { let d = await detectTerminal({ env: {} }); From ca32a5612553f9c989d7a2dcb49fcc1db161f891 Mon Sep 17 00:00:00 2001 From: Nate Moore Date: Fri, 9 Oct 2026 15:15:20 -0500 Subject: [PATCH 7/9] chore: add debug-pointer-shape.ts diagnostic MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Walks the full OSC 22 pointer-shape chain in the user's terminal — probe round-trip with per-query breakdown, parser CapabilityEvents, renderer emission — and separates query-less set-supporting terminals from terminals without OSC 22 at all. Built to debug the ghostty detection gap; kept as a general diagnostic for the capability layer. --- debug-pointer-shape.ts | 159 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 159 insertions(+) create mode 100644 debug-pointer-shape.ts diff --git a/debug-pointer-shape.ts b/debug-pointer-shape.ts new file mode 100644 index 0000000..f00650c --- /dev/null +++ b/debug-pointer-shape.ts @@ -0,0 +1,159 @@ +// debug-pointer-shape.ts — pinpoint where the OSC 22 pointer-shape chain +// breaks. Run directly in the terminal you are testing, from the repo root: +// +// deno run -A debug-pointer-shape.ts +// node debug-pointer-shape.ts +// +// Walks the full chain and reports which link fails: environment identity, +// detection, the probe round-trip (with a per-query ✓/✗ breakdown), the +// parser's CapabilityEvents, and the renderer's gated emission. If no OSC 22 +// reply arrives, a set-only check flashes the hand cursor so query-less +// terminals (ghostty) can be told apart from terminals without OSC 22 at all. +import { Buffer } from "node:buffer"; +import process from "node:process"; +import { + type CapabilityEvent, + close, + createInput, + createTerm, + detectTerminal, + open, + text, +} from "./mod.ts"; + +const decode = new TextDecoder(); +const esc = (b: Uint8Array) => JSON.stringify(decode.decode(b)).slice(1, -1); + +console.log("── environment"); +console.log(" TERM:", process.env.TERM ?? "(unset)"); +console.log( + " TERM_PROGRAM:", + process.env.TERM_PROGRAM ?? "(unset)", + process.env.TERM_PROGRAM_VERSION ?? "", +); +console.log(" COLORTERM:", process.env.COLORTERM ?? "(unset)"); +if (process.env.TMUX) { + console.log(" ⚠ running inside tmux — OSC 22 may not pass through"); +} + +const info = await detectTerminal(); +console.log("── detection"); +console.log(" probe bytes:", info.probe.length); + +if (!process.stdin.isTTY) { + console.log(" ✗ stdin is not a TTY; run this directly in your terminal"); + process.exit(1); +} + +console.log("── probing (2s window, raw mode on; type nothing)…"); +if (typeof process.stdin.setRawMode === "function") { + process.stdin.setRawMode(true); +} +process.stdout.write(info.probe); + +const chunks: Buffer[] = []; +const onData = (chunk: Buffer) => chunks.push(chunk); +process.stdin.on("data", onData); +await new Promise((r) => setTimeout(r, 2000)); +process.stdin.removeListener("data", onData); + +const reply = Buffer.concat(chunks); +console.log("── raw reply"); +console.log(" bytes:", reply.length); +console.log( + " escaped:", + reply.length ? esc(reply) : "(nothing — the terminal answered nothing)", +); + +console.log("── which queries were answered"); +const replyText = decode.decode(reply); +const markers: [string, string][] = [ + ["foreground (OSC 10)", "\x1b]10;"], + ["background (OSC 11)", "\x1b]11;"], + ["cursor color (OSC 12)", "\x1b]12;"], + ["colors (OSC 21)", "\x1b]21;"], + ["POINTER SHAPE (OSC 22)", "\x1b]22;"], + ["XTGETTCAP (DCS)", "\x1bP"], + ["sync output (DECRPM 2026)", "?2026"], + ["kitty keyboard (?u)", "?u"], + ["kitty graphics (APC)", "\x1b_G"], +]; +for (const [name, marker] of markers) { + console.log(` ${replyText.includes(marker) ? "✓" : "✗"} ${name}`); +} + +// The query tells us about reply support, not set support. Ghostty sets +// shapes since 1.0 but never answers the query, so give the terminal a +// chance to show what it can do: set the hand for 1.5s, then restore. +if (!replyText.includes("\x1b]22;")) { + console.log("── set-only check (watch your cursor)"); + process.stdout.write("\x1b]22;pointer\x1b\\"); + await new Promise((r) => setTimeout(r, 1500)); + process.stdout.write("\x1b]22;default\x1b\\"); + console.log( + " the cursor should have flashed to a hand just now. If it did," + + " this terminal can SET shapes but never answers the query" + + " (ghostty behaves exactly like this) — the demos opt in with" + + " TTY_POINTER_SHAPES=1. If it did not change, OSC 22 is not" + + " supported at all.", + ); +} + +if (typeof process.stdin.setRawMode === "function") { + process.stdin.setRawMode(false); +} + +console.log("── parser"); +const input = await createInput({ terminfo: info }); +let events = input.scan(new Uint8Array(reply)).events; +events = [...events, ...input.scan(new Uint8Array(0)).events]; +const caps = events.filter((e): e is CapabilityEvent => + e.type === "capability" +); +for (const c of caps) { + console.log(` capability: ${c.key} = ${JSON.stringify(c.value)}`); +} + +console.log("── term"); +const term = await createTerm({ width: 40, height: 6, terminfo: info }); +term.update(caps); +console.log( + " term.capabilities.pointerShape:", + term.capabilities.pointerShape, +); +const result = term.render( + [open("box", { pointerShape: "pointer" }), text("hover"), close()], + { pointer: { x: 2, y: 2, down: false } }, +); +const emitted = decode + .decode(result.output) + .includes("\x1b]22;pointer\x1b\\"); +console.log(" OSC 22 in render output:", emitted); + +console.log("── verdict"); +if (emitted) { + console.log( + " ✓ full chain works in this terminal. If the demos still show nothing," + + " hover a key/button — and note the shape only changes while the pointer" + + " is over an element that declares one.", + ); +} else if (!replyText.includes("\x1b]22;")) { + console.log( + " ✗ your terminal did not answer the OSC 22 query. If the set-only" + + " check above moved your cursor, it supports shapes but not the" + + " query (ghostty) — run the demos with TTY_POINTER_SHAPES=1. If the" + + " cursor never changed, the terminal does not support OSC 22 at all" + + " (iTerm2, Terminal.app, VS Code, tmux without passthrough).", + ); +} else if (caps.length === 0) { + console.log( + " ✗ the terminal replied but the parser produced no capability events." + + " Please report this with the escaped reply above.", + ); +} else { + console.log( + " ✗ capability recognized but render did not emit OSC 22. Please report" + + " this with the escaped reply above.", + ); +} +process.exit(0); From b9f09b44b42073edaae08a1bfd2074f918c141f9 Mon Sep 17 00:00:00 2001 From: Nate Moore Date: Fri, 9 Oct 2026 15:17:17 -0500 Subject: [PATCH 8/9] chore: generalize the diagnostic as debug-terminfo.ts debug-pointer-shape.ts walked the whole capability chain already; only its last section was pointer-shape specific. Renamed and generalized: it now prints the full static Capabilities at detection, the merged RuntimeCapabilities after folding probe events, and which fields the probe raised. The pointer-shape section stays as the end-to-end emission check (the one capability render() consumes today), with the verdict distinguishing probe-confirmed support from a known-support table grant. --- debug-pointer-shape.ts => debug-terminfo.ts | 74 ++++++++++++++------- 1 file changed, 50 insertions(+), 24 deletions(-) rename debug-pointer-shape.ts => debug-terminfo.ts (58%) diff --git a/debug-pointer-shape.ts b/debug-terminfo.ts similarity index 58% rename from debug-pointer-shape.ts rename to debug-terminfo.ts index f00650c..2dbb5cf 100644 --- a/debug-pointer-shape.ts +++ b/debug-terminfo.ts @@ -1,14 +1,16 @@ -// debug-pointer-shape.ts — pinpoint where the OSC 22 pointer-shape chain -// breaks. Run directly in the terminal you are testing, from the repo root: +// debug-terminfo.ts — pinpoint where the terminfo capability chain breaks. +// Run directly in the terminal you are testing, from the repo root: // -// deno run -A debug-pointer-shape.ts -// node debug-pointer-shape.ts +// deno run -A debug-terminfo.ts +// node debug-terminfo.ts // // Walks the full chain and reports which link fails: environment identity, -// detection, the probe round-trip (with a per-query ✓/✗ breakdown), the -// parser's CapabilityEvents, and the renderer's gated emission. If no OSC 22 -// reply arrives, a set-only check flashes the hand cursor so query-less -// terminals (ghostty) can be told apart from terminals without OSC 22 at all. +// detection (static Capabilities), the probe round-trip with a per-query +// ✓/✗ breakdown, the parser's CapabilityEvents, and the term's merged +// RuntimeCapabilities. The pointer shape section at the end exercises the +// one capability render() consumes today; if no OSC 22 reply arrives, a +// set-only check flashes the hand cursor so query-less terminals (ghostty) +// can be told apart from terminals without OSC 22 at all. import { Buffer } from "node:buffer"; import process from "node:process"; import { @@ -23,6 +25,7 @@ import { const decode = new TextDecoder(); const esc = (b: Uint8Array) => JSON.stringify(decode.decode(b)).slice(1, -1); +const json = (v: unknown) => JSON.stringify(v); console.log("── environment"); console.log(" TERM:", process.env.TERM ?? "(unset)"); @@ -33,12 +36,13 @@ console.log( ); console.log(" COLORTERM:", process.env.COLORTERM ?? "(unset)"); if (process.env.TMUX) { - console.log(" ⚠ running inside tmux — OSC 22 may not pass through"); + console.log(" ⚠ running inside tmux — queries may not pass through"); } const info = await detectTerminal(); console.log("── detection"); console.log(" probe bytes:", info.probe.length); +console.log(" static capabilities:", json(info.capabilities)); if (!process.stdin.isTTY) { console.log(" ✗ stdin is not a TTY; run this directly in your terminal"); @@ -93,9 +97,9 @@ if (!replyText.includes("\x1b]22;")) { console.log( " the cursor should have flashed to a hand just now. If it did," + " this terminal can SET shapes but never answers the query" + - " (ghostty behaves exactly like this) — the demos opt in with" + - " TTY_POINTER_SHAPES=1. If it did not change, OSC 22 is not" + - " supported at all.", + " (ghostty behaves exactly like this) — detectTerminal()'s" + + " known-support table should already cover it. If it did not" + + " change, OSC 22 is not supported at all.", ); } @@ -110,17 +114,30 @@ events = [...events, ...input.scan(new Uint8Array(0)).events]; const caps = events.filter((e): e is CapabilityEvent => e.type === "capability" ); +if (caps.length === 0) { + console.log(" (no capability events parsed)"); +} for (const c of caps) { - console.log(` capability: ${c.key} = ${JSON.stringify(c.value)}`); + console.log(` capability: ${c.key} = ${json(c.value)}`); } -console.log("── term"); +console.log("── term (merged view)"); const term = await createTerm({ width: 40, height: 6, terminfo: info }); term.update(caps); +console.log(" merged capabilities:", json(term.capabilities)); +const staticCaps = info.capabilities as unknown as Record; +const mergedCaps = term.capabilities as unknown as Record; +const raised = Object.keys(mergedCaps).filter((k) => + json(mergedCaps[k]) !== json(staticCaps[k]) +); console.log( - " term.capabilities.pointerShape:", - term.capabilities.pointerShape, + " raised by the probe:", + raised.length > 0 ? raised.join(", ") : "(nothing — probe added no evidence)", ); + +// Pointer shape is the one capability render() consumes today, so it doubles +// as the end-to-end emission check. +console.log("── pointer shape (renderer-spec §7.9)"); const result = term.render( [open("box", { pointerShape: "pointer" }), text("hover"), close()], { pointer: { x: 2, y: 2, down: false } }, @@ -133,17 +150,26 @@ console.log(" OSC 22 in render output:", emitted); console.log("── verdict"); if (emitted) { console.log( - " ✓ full chain works in this terminal. If the demos still show nothing," + - " hover a key/button — and note the shape only changes while the pointer" + - " is over an element that declares one.", + " ✓ render emits OSC 22" + + (info.capabilities.pointerShape && !replyText.includes("\x1b]22;") + ? " — statically granted by the known-support table (no query reply," + + " which is expected for set-only terminals like ghostty)" + : " — the probe confirmed support and the parser folded it in") + + ". If the demos still show nothing, hover a key/button — and note the" + + " shape only changes while the pointer is over an element that" + + " declares one.", ); } else if (!replyText.includes("\x1b]22;")) { console.log( - " ✗ your terminal did not answer the OSC 22 query. If the set-only" + - " check above moved your cursor, it supports shapes but not the" + - " query (ghostty) — run the demos with TTY_POINTER_SHAPES=1. If the" + - " cursor never changed, the terminal does not support OSC 22 at all" + - " (iTerm2, Terminal.app, VS Code, tmux without passthrough).", + info.capabilities.pointerShape + ? " ✗ no OSC 22 reply and no emission despite a static grant — please" + + " report this with the escaped reply above." + : " ✗ your terminal did not answer the OSC 22 query and it is not on" + + " the known-support table. If the set-only check above moved your" + + " cursor, it can set shapes but we cannot detect it — run the demos" + + " with TTY_POINTER_SHAPES=1 (or nominate the terminal for the" + + " table). If the cursor never changed, OSC 22 is not supported at" + + " all (iTerm2, Terminal.app, VS Code, tmux without passthrough).", ); } else if (caps.length === 0) { console.log( From 6939c476745ed46fd708ba08c3dd020863ebe551 Mon Sep 17 00:00:00 2001 From: Nate Moore Date: Sun, 11 Oct 2026 13:31:23 -0500 Subject: [PATCH 9/9] feat: name the open() cursor property after CSS (was pointerShape) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CSS names the mouse-pointer property cursor and the insertion-point properties caret-*, and the library already calls the insertion point caret (text()'s caret declaration, §7.6) — so the element property now matches CSS exactly: open(id, { cursor: "grab" }). The capability layer keeps its names: the CapabilityEvent key stays "pointer-shape", RuntimeCapabilities.pointerShape stays, and pointer-shape.ts / PointerShape / POINTER_SHAPES / resolvePointerShape stay — those name the kitty OSC 22 pointer-shape protocol, which is what they are on the wire. Minor changeset: we're 0.x. --- .changeset/pointer-shape-osc22.md | 4 ++-- debug-terminfo.ts | 2 +- ops.ts | 6 +++--- pointer-shape.ts | 6 +++--- specs/renderer-spec.md | 20 ++++++++++---------- test/pointer-shape.test.ts | 20 ++++++++++---------- test/validate.test.ts | 4 ++-- validate.ts | 2 +- 8 files changed, 32 insertions(+), 32 deletions(-) diff --git a/.changeset/pointer-shape-osc22.md b/.changeset/pointer-shape-osc22.md index c74e78a..be374ec 100644 --- a/.changeset/pointer-shape-osc22.md +++ b/.changeset/pointer-shape-osc22.md @@ -2,10 +2,10 @@ "@bomb.sh/tty": minor --- -Adds a `pointerShape` property to `open()`, so the mouse pointer changes shape (OSC 22) as it moves over elements +Adds a `cursor` property to `open()`, so the mouse pointer changes shape (OSC 22) as it moves over elements When the terminal confirms support through the probe (`term.capabilities.pointerShape`), `render()` appends the shape change to `output`, so writing `output` keeps the pointer in sync. The shape resets to `default` when the pointer leaves, when a frame renders without `pointer`, or when `update()` withdraws the capability. `detectTerminal()` also seeds the capability from a known-support table for terminals that set shapes without answering the query (ghostty, kitty, foot, xterm ≥ 367), so those work out of the box. For anything the table misses, assert support with `term.update([{ type: "capability", key: "pointer-shape", value: true }])`. ```ts -term.render([open("link", { pointerShape: "pointer" }), text("Docs"), close()], { pointer }); +term.render([open("link", { cursor: "pointer" }), text("Docs"), close()], { pointer }); ``` diff --git a/debug-terminfo.ts b/debug-terminfo.ts index 2dbb5cf..b8e450f 100644 --- a/debug-terminfo.ts +++ b/debug-terminfo.ts @@ -139,7 +139,7 @@ console.log( // as the end-to-end emission check. console.log("── pointer shape (renderer-spec §7.9)"); const result = term.render( - [open("box", { pointerShape: "pointer" }), text("hover"), close()], + [open("box", { cursor: "pointer" }), text("hover"), close()], { pointer: { x: 2, y: 2, down: false } }, ); const emitted = decode diff --git a/ops.ts b/ops.ts index 59d6f9a..c548687 100644 --- a/ops.ts +++ b/ops.ts @@ -414,7 +414,7 @@ export interface OpenElement { zIndex?: number; }; transition?: Transition; - pointerShape?: PointerShape; + cursor?: PointerShape; } export type AttachPoint = @@ -563,9 +563,9 @@ export function snapshot(ops: Op[]): Op { if (op.directive === OP_SNAPSHOT) { if (op.shapes) shapes.push(...op.shapes); } else if ( - op.directive === OP_OPEN_ELEMENT && op.pointerShape !== undefined + op.directive === OP_OPEN_ELEMENT && op.cursor !== undefined ) { - shapes.push([op.id, op.pointerShape]); + shapes.push([op.id, op.cursor]); } } if (shapes.length === 0) return { directive: OP_SNAPSHOT, data }; diff --git a/pointer-shape.ts b/pointer-shape.ts index 3e2641e..67c93c7 100644 --- a/pointer-shape.ts +++ b/pointer-shape.ts @@ -62,12 +62,12 @@ export function resolvePointerShape( shape = value as PointerShape; } } - } else if ("pointerShape" in op && op.pointerShape !== undefined) { - if (!KNOWN.has(op.pointerShape)) continue; + } else if ("cursor" in op && op.cursor !== undefined) { + if (!KNOWN.has(op.cursor)) continue; let i = over.lastIndexOf(op.id); if (i > best) { best = i; - shape = op.pointerShape; + shape = op.cursor; } } } diff --git a/specs/renderer-spec.md b/specs/renderer-spec.md index 1dc9cf8..487a899 100644 --- a/specs/renderer-spec.md +++ b/specs/renderer-spec.md @@ -481,9 +481,9 @@ specified. ### 7.9 Pointer shape -An `open()` directive MAY declare a `pointerShape` property naming the mouse -pointer shape to show while the pointer is over the element. Values are the -kitty pointer shape names (the CSS `cursor` keywords): `default`, `none`, +An `open()` directive MAY declare a `cursor` property naming the mouse pointer +shape to show while the pointer is over the element. Values are the kitty +pointer shape names (the CSS `cursor` keywords): `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`, @@ -494,16 +494,16 @@ layout or cell output. The gate is `RuntimeCapabilities.pointerShape` (see [Terminfo Specification](terminfo-spec.md) §6.3). While it is `false`, the -renderer MUST NOT read `pointerShape` properties and MUST NOT emit OSC 22. +renderer MUST NOT read `cursor` properties and MUST NOT emit OSC 22. **Resolved shape.** While the capability is `true`, each render resolves one shape: - `default` when the render options carry no `pointer`. -- Otherwise, the `pointerShape` of the last declaring element in hit-test order - (the order behind `pointerenter` events, §12.4), or `default` when none - declares one. Ancestors precede descendants, so the innermost element wins. A - floating element in `"capture"` mode hides the elements beneath it; one in +- Otherwise, the `cursor` of the last declaring element in hit-test order (the + order behind `pointerenter` events, §12.4), or `default` when none declares + one. Ancestors precede descendants, so the innermost element wins. A floating + element in `"capture"` mode hides the elements beneath it; one in `"passthrough"` mode lets them decide. Elements inside a `snapshot()` participate as direct directives would. @@ -950,8 +950,8 @@ 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 -- **`pointerShape`** — mouse pointer shape while hovered (§7.9); not transferred - to the WASM module +- **`cursor`** — mouse pointer shape while hovered (§7.9); not transferred to + the WASM module The `floating` object shape is: diff --git a/test/pointer-shape.test.ts b/test/pointer-shape.test.ts index a5429e7..60b72a4 100644 --- a/test/pointer-shape.test.ts +++ b/test/pointer-shape.test.ts @@ -48,19 +48,19 @@ function over(x: number, y: number) { // ││L ││ ││ // │└─────────────────────┘└───────────────┘│ // └────────────────────────────────────────┘ -function layout(link: { pointerShape?: PointerShape } = {}): Op[] { +function layout(link: { cursor?: PointerShape } = {}): Op[] { return [ open("root", { layout: { width: grow(), height: grow(), direction: "ltr" }, }), open("link", { layout: { width: fixed(20), height: fixed(6), direction: "ttb" }, - pointerShape: "pointer", + cursor: "pointer", ...link, }), open("field", { layout: { width: fixed(10), height: fixed(2) }, - pointerShape: "text", + cursor: "text", }), text("F"), close(), @@ -96,12 +96,12 @@ describe("pointer shape", () => { ); }); - it("does not read pointerShape without the capability", async () => { + it("does not read cursor without the capability", async () => { let unsupported = await createTerm({ width: 40, height: 6 }); let reads = 0; let ops = layout().map((op) => - "pointerShape" in op - ? Object.defineProperty({ ...op }, "pointerShape", { + "cursor" in op + ? Object.defineProperty({ ...op }, "cursor", { get: () => (reads++, "pointer"), }) : op @@ -172,7 +172,7 @@ describe("pointer shape", () => { term.render(layout(), LINK); expect( sequences( - term.render(layout({ pointerShape: "progress" }), LINK) + term.render(layout({ cursor: "progress" }), LINK) .output, ), ).toEqual([osc22("progress")]); @@ -180,7 +180,7 @@ describe("pointer shape", () => { it("ignores values outside the PointerShape vocabulary", () => { let bogus = "x\x1b]0;pwned\x07" as PointerShape; - let output = term.render(layout({ pointerShape: bogus }), LINK).output; + let output = term.render(layout({ cursor: bogus }), LINK).output; expect(decode(output)).not.toContain("pwned"); expect(sequences(output)).toEqual([]); }); @@ -200,7 +200,7 @@ describe("pointer shape", () => { open("menu", { layout: { width: fixed(6), height: fixed(3) }, floating: { x: 1, y: 3, attachTo: "root" }, - pointerShape: "grab", + cursor: "grab", }), close(), ); @@ -222,7 +222,7 @@ describe("pointer shape", () => { attachTo: "root", pointerCaptureMode: "passthrough", }, - pointerShape: "help", + cursor: "help", }), close(), ); diff --git a/test/validate.test.ts b/test/validate.test.ts index 57b34cb..0095584 100644 --- a/test/validate.test.ts +++ b/test/validate.test.ts @@ -34,7 +34,7 @@ describe("validate", () => { }); it("accepts a pointer shape", () => { - expect(validate([open("x", { pointerShape: "pointer" }), close()])).toBe( + expect(validate([open("x", { cursor: "pointer" }), close()])).toBe( true, ); }); @@ -42,7 +42,7 @@ describe("validate", () => { it("rejects a pointer shape outside the vocabulary", () => { expect(validate([ // deno-lint-ignore no-explicit-any - open("x", { pointerShape: "hand" as any }), + open("x", { cursor: "hand" as any }), close(), ])).toBe(false); }); diff --git a/validate.ts b/validate.ts index 83bbe37..b49ee88 100644 --- a/validate.ts +++ b/validate.ts @@ -195,7 +195,7 @@ const OpenElement = Type.Object({ clip: Type.Optional(Clip), floating: Type.Optional(Floating), transition: Type.Optional(Transition), - pointerShape: Type.Optional(PointerShape), + cursor: Type.Optional(PointerShape), }); const TextOp = Type.Object({