Repository navigation
OSC 22 cursor support - #137
natemoo-re wants to merge 17 commits into
Conversation
Changeset suggestionThe 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 @@ | |||
| --- | |||
There was a problem hiding this comment.
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.
commit: |
|
Size Increased — +6.8 KB 129.4 KB unpacked |
… 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.
50be391 to
926c307
Compare
af52e23 to
082e1e9
Compare
…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.
082e1e9 to
b9f09b4
Compare
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.
| 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 }; |
There was a problem hiding this comment.
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.
Supercedes #101.
Adds support for the OSC 22 cursor shapes, which supports a subset of the CSS
cursorkeywords.The public API is an element that accepts the pointer shape:
open(id, { cursor: "pointer" }). The renderer writes the escape sequence directly intoresult.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.