Skip to content

OSC 22 cursor support - #137

Open
natemoo-re wants to merge 17 commits into
mainfrom
feat/pointer-shape
Open

natemoo-re wants to merge 17 commits into
mainfrom
feat/pointer-shape

Conversation

@natemoo-re

@natemoo-re natemoo-re commented Oct 9, 2026 •

Copy link
Copy Markdown
Member

Supercedes #101.

Adds support for the OSC 22 cursor shapes, which supports a subset of the CSS cursor keywords.

The public API is an element that accepts the pointer shape: open(id, { cursor: "pointer" }). The renderer writes the escape sequence directly into result.output. This works in line mode and alt screens.

Review comments from #101 have been addressed and regression tests added.

OSC 22 bytes are only emitted for terminals that report support (using the probe infrastructure added in #132) or have been documented to support the escape sequence without supporting the query form (ghostty).

Demos updated in #144.

@bombshell-cooper

bombshell-cooper Bot commented Oct 9, 2026 •

Copy link
Copy Markdown
Contributor

Changeset suggestion

The current changeset no longer matches this PR. This review contains a corrected replacement.

Why: Changeset package scope does not match the affected packages confidently. Changeset contains implementation or roadmap noise.

View the proposed changeset
---
'@bomb.sh/tty': minor
---

Adds `cursor` to `open()` and synchronized output wrapping to `render()`.

#### `cursor` — mouse pointer shape (OSC 22)

Pass any CSS `cursor` keyword to an `open()` directive to change the mouse pointer shape while it hovers over that element:

```ts
term.render([open("link", { cursor: "pointer" }), text("Docs"), close()], { pointer });
```

`render()` appends `ESC ] 22 ; <shape> ST` to `output` and resets to `default` when the pointer leaves or no element declares a shape. The shape is only emitted while `term.capabilities.pointerShape` is `true`. `detectTerminal()` seeds that field from a known-support table (ghostty, kitty, foot, xterm ≥ 367) so those terminals work without a probe reply. For anything the table misses, assert support manually:

```ts
term.update([{ type: "capability", key: "pointer-shape", value: true }]);
```

Withdrawing the capability mid-session via `update()` returns the OSC 22 reset bytes when the shape is not already `default`.

#### Synchronized output (DEC mode 2026)

When `term.capabilities.syncOutput` is `true`, every full-screen `render()` is wrapped in Begin/End Synchronized Update (`CSI ? 2026 h` / `CSI ? 2026 l`) so the terminal presents the frame atomically. No-op frames (zero cell changes, no caret transition) emit nothing even when wrapping is active. Line-mode renders are never wrapped.

`syncOutput` is raised by the `Sync` extended terminfo capability, a non-empty `TMUX` environment variable, a DECRPM 2026 probe reply, or an XTGETTCAP `Sync` reply. Probe replies outrank detection-time evidence in both directions, so a DECRPM 0 or 4 reply turns wrapping off even when the terminfo entry seeded it.

#### Migration

`RuntimeCapabilities.pointerShape` moves from `RuntimeCapabilities` to the base `Capabilities` interface (it is now seeded at detection time rather than only after a probe reply). If you were reading it as a runtime-only field, no call-site change is needed — it remains accessible on `term.capabilities`. The `render()` → `reduce()` internal signature gains `px`, `py`, and `pointer` arguments; callers using the public `Term` API are unaffected.

Review this changeset manually

If this draft is correct, react with 🚀 and Cooper will commit it to this branch.

@@ -0,0 +1,11 @@
---

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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Changeset needs revision.

Changeset package scope does not match the affected packages confidently. Changeset contains implementation or roadmap noise.

View the proposed replacement
---
'@bomb.sh/tty': minor
---

Adds `cursor` to `open()` and synchronized output wrapping to `render()`.

#### `cursor` — mouse pointer shape (OSC 22)

Pass any CSS `cursor` keyword to an `open()` directive to change the mouse pointer shape while it hovers over that element:

```ts
term.render([open("link", { cursor: "pointer" }), text("Docs"), close()], { pointer });
```

`render()` appends `ESC ] 22 ; <shape> ST` to `output` and resets to `default` when the pointer leaves or no element declares a shape. The shape is only emitted while `term.capabilities.pointerShape` is `true`. `detectTerminal()` seeds that field from a known-support table (ghostty, kitty, foot, xterm ≥ 367) so those terminals work without a probe reply. For anything the table misses, assert support manually:

```ts
term.update([{ type: "capability", key: "pointer-shape", value: true }]);
```

Withdrawing the capability mid-session via `update()` returns the OSC 22 reset bytes when the shape is not already `default`.

#### Synchronized output (DEC mode 2026)

When `term.capabilities.syncOutput` is `true`, every full-screen `render()` is wrapped in Begin/End Synchronized Update (`CSI ? 2026 h` / `CSI ? 2026 l`) so the terminal presents the frame atomically. No-op frames (zero cell changes, no caret transition) emit nothing even when wrapping is active. Line-mode renders are never wrapped.

`syncOutput` is raised by the `Sync` extended terminfo capability, a non-empty `TMUX` environment variable, a DECRPM 2026 probe reply, or an XTGETTCAP `Sync` reply. Probe replies outrank detection-time evidence in both directions, so a DECRPM 0 or 4 reply turns wrapping off even when the terminfo entry seeded it.

#### Migration

`RuntimeCapabilities.pointerShape` moves from `RuntimeCapabilities` to the base `Capabilities` interface (it is now seeded at detection time rather than only after a probe reply). If you were reading it as a runtime-only field, no call-site change is needed — it remains accessible on `term.capabilities`. The `render()` → `reduce()` internal signature gains `px`, `py`, and `pointer` arguments; callers using the public `Term` API are unaffected.

@pkg-pr-new

pkg-pr-new Bot commented Oct 9, 2026 •

Copy link
Copy Markdown

Open in StackBlitz

npm i https://pkg.pr.new/@bomb.sh/tty@137

commit: 16fcdda

@github-actions

github-actions Bot commented Oct 9, 2026 •

Copy link
Copy Markdown

Size Increased — +6.8 KB

129.4 KB unpacked

@natemoo-re natemoo-re mentioned this pull request Oct 9, 2026
5 of 12 tasks
Base automatically changed from feat/term-capability-sink to main October 9, 2026 15:24
… capability

Full-screen frames wrap in BSU/ESU when RuntimeCapabilities.syncOutput is
true; the flag rides reduce() per frame so a capability flip takes effect
on the next rendered frame with no invalidation signal. Zero-diff frames
emit zero bytes even when wrapped, and line mode is never wrapped.

Renderer-spec 7.9 discharges the deferral recorded in 7.8.
@codspeed

codspeed Bot commented Oct 9, 2026 •

Copy link
Copy Markdown
Contributor

Merging this PR will not alter performance

✅ 10 untouched benchmarks


Comparing feat/pointer-shape (16fcdda) with main (2c6215c)

Open in CodSpeed

@natemoo-re natemoo-re changed the title feat: OSC 22 pointer shape output gated on terminal capability @bomb.sh/tty ▪ cursor support Oct 9, 2026
@natemoo-re natemoo-re changed the title @bomb.sh/tty ▪ cursor support OSC 22 cursor support Oct 9, 2026
…lity

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 ; <shape> 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.
#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).
… them (renderer-spec §7.9)

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 ; <shape> 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.
…e mode

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.
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.
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 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.
Per the approved spec amendment (terminfo-spec §6.1/§6.3/§7.1/§7.2/§8/§9.1,
renderer-spec §7.9): syncOutput is raised only on positive evidence from
three channels.

- Probe row 8: XTGETTCAP Sync (DCS + q 53796e63) as its own request —
  some terminals honor only one capability per XTGETTCAP request, so it
  must not merge into the RGB;Tc query. A value-carrying reply confirms;
  a reply naming Sync without a value denies. Sync replies never touch
  the colordepth tally.
- Static Capabilities gains syncOutput, seeded from the Sync extended
  capability in the parsed terminfo entry and from TMUX environment
  evidence (tmux consumes mode 2026 from inner applications).
- TINV-3 precedence: probe replies outrank detect-time seeding in both
  directions; runtimeFromStatic now flows the seeded field through.

The reply's sequence value is discarded in v1: emission remains the
DEC mode 2026 form, unchanged from §7.9's normative rules.
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.
@natemoo-re
natemoo-re marked this pull request as ready for review October 12, 2026 01:00
Comment thread ops.ts Outdated
Comment on lines +560 to +572
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.cursor !== undefined
) {
shapes.push([op.id, op.cursor]);
}
}
if (shapes.length === 0) return { directive: OP_SNAPSHOT, data };
return { directive: OP_SNAPSHOT, data, shapes };

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

I hate to be the "to the WASM!" guy 😄 , but It seems like having to re-scan snapshots for cursors wouldn't be necessary if we just included the cursor as a native property in the binary format and then did the pointer shape resolution there.

FWIW, this is how caret's work today. You pack text({ caret }) into the transfer buffer, then we decode the declaration and resolve the screen position during rendering. We directly append the caret to the output from C, and then track the last caret state so that we can hide it if the declaration disappears.

Nice thing about it is that a snapshot of ops remains a snapshot, and the system stays portable.

Capabilities were copied into reduce() on every render. The renderer now
owns them: set_capabilities(ct, flags) stores a CAP_* bitfield on the
Clayterm instance, and reduce() reads it from there.

The host pushes flags at creation and again from term.update() only when
a folded event changes them. init() zeroes instance state, so the native
wrapper re-pushes after every re-init; a resize keeps the wrap.
input.h already defines CAP_SYNC_OUTPUT as a CapabilityEvent key (5).
Any translation unit including both headers silently redefined it.
…erer

Synchronized output (§7.9) landed first, so pointer shape moves to §7.10
and §7.8 lists both as specified.

cursor is now transferred to the WASM module like every other open()
property, so a snapshot carries it in its packed bytes. That means
pack() reads cursor regardless of capability; the gate is on emission
only. With no post-hoc concatenation, render output is again always a
view over renderer-owned memory (§7.3).
cursor was carried beside the packed ops (a shapes side-array on
snapshots) and resolved in JS by rescanning ops against hovered ids.
It now follows the caret model: pack() encodes it as PROP_CURSOR (one
word, the shape's vocabulary index; unknown values omit the bit), and
reduce() registers it with Clay_OnHover while decoding.

Clay calls hover functions during Clay_SetPointerState in hit-test
order, ancestors first, so the last call is the innermost declaring
element and capture/passthrough floating behave as before. The pointer
now enters through reduce() so the hit test runs after EndLayout and
before the frame's bytes are finalized; OSC 22 is appended from C after
the frame (after ESU when synchronized output wraps it).

The emitted shape lives on the Clayterm instance. set_capabilities()
reports when withdrawing pointerShape leaves a non-default shape on
screen, and update() returns the reset. The native wrapper carries the
emitted shape across re-init so a resize leaves it unchanged.

Anonymous elements can now declare a cursor; the JS resolver matched by
string id and missed them.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants