From 1f514e0024eb297eb915de9faf2f147053478030 Mon Sep 17 00:00:00 2001 From: Nate Moore Date: Fri, 9 Oct 2026 10:30:45 -0500 Subject: [PATCH 01/16] feat(term): synchronized output (DEC mode 2026) driven by sync-output 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. --- .changeset/sync-output-capability.md | 7 ++++ specs/renderer-spec.md | 38 ++++++++++++++++++++ src/clayterm.c | 39 +++++++++++++++++--- src/clayterm.h | 2 +- term-native.ts | 2 ++ term.ts | 10 +++++- test/term.test.ts | 53 ++++++++++++++++++++++++++++ test/width.test.ts | 2 +- 8 files changed, 146 insertions(+), 7 deletions(-) create mode 100644 .changeset/sync-output-capability.md diff --git a/.changeset/sync-output-capability.md b/.changeset/sync-output-capability.md new file mode 100644 index 0000000..5e7aeb1 --- /dev/null +++ b/.changeset/sync-output-capability.md @@ -0,0 +1,7 @@ +--- +"@bomb.sh/tty": minor +--- + +Adds synchronized output (DEC mode 2026) driven by the `sync-output` capability. + +When `RuntimeCapabilities.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 as one atomic repaint without tearing. There is no new API: the capability stays false until a DECRPM 2026 probe reply reports support (`detectTerminal()` probes `CSI ? 2026 $ p`, replies surface through `input.scan()` and fold in via `term.update()`), and frames render unwrapped when it is false or unknown. Zero-diff frames emit nothing whether or not wrapping is active, and line mode is never wrapped. diff --git a/specs/renderer-spec.md b/specs/renderer-spec.md index 0e1d6ff..fe14c6f 100644 --- a/specs/renderer-spec.md +++ b/specs/renderer-spec.md @@ -480,6 +480,44 @@ 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. +### 7.9 Synchronized output (DEC mode 2026) + +When the `syncOutput` runtime capability is true, each full-screen frame +(§8.2.1) is wrapped in Synchronized Output mode 2026 — Begin Synchronized +Update (`CSI ? 2026 h`) before the frame's first emitted byte and End +Synchronized Update (`CSI ? 2026 l`) after its last. The terminal buffers +everything between the pair and presents it as one atomic repaint, so a frame +never tears mid-update. + +**Capability evidence.** `syncOutput` originates exclusively from the +`sync-output` `CapabilityEvent` (input-spec §5), emitted when the input parser +reads a DECRPM 2026 reply to the probe (`CSI ? 2026 $ p`). Reply values 1, 2, +and 3 (set, reset, permanently set) report support; 0 and 4 report none. The +static `Capabilities` never seed this field: it is false until a probe reply +is folded in through `update()`. + +**Invalidation and timing.** The renderer reads the capability snapshot at +each render transaction, so a capability flip takes effect on the next +rendered frame. No invalidation signal exists, and none is required: a flip +between frames changes only whether the next frame is wrapped. + +**Normative rules:** + +- When `syncOutput` is false, the renderer MUST NOT emit `?2026h` or `?2026l`. +- When `syncOutput` is true, the renderer MUST wrap each full-screen frame in + the BSU/ESU pair. Line mode (§8.2.2) is never wrapped: inline output may be + interleaved with foreign content, and an unterminated synchronized block + would suppress it. +- A full-screen render whose diff produces no cell changes and no caret + transition MUST emit zero bytes, even when `syncOutput` is true. The + renderer decides after the diff, so no-op frames carry neither the wrap + prefix nor the suffix. +- Terminals that receive the sequences without supporting the mode ignore it + by design; the probe still refines behavior for terminals that report mode + 0 or 4. + +This section discharges the synchronized-output deferral recorded in §7.8. + --- ## 8. Public Rendering API diff --git a/src/clayterm.c b/src/clayterm.c index 7e4e62e..b9b395e 100644 --- a/src/clayterm.c +++ b/src/clayterm.c @@ -87,6 +87,9 @@ struct Clayterm { Clay_ErrorData errors[MAX_ERRORS]; int error_count; int animating_count; + /* wrap each full-screen frame in BSU/ESU (DEC mode 2026); driven by the + * RuntimeCapabilities.syncOutput capability, passed per render call */ + int sync; /* Caret state for hardware-cursor management. The renderer records the * first text node carrying a caret declaration per frame, precomputes the * caret's byte offset into that node's content, then places the cell as @@ -116,6 +119,12 @@ struct Clayterm { * to 4 (base SMP char) + 8×4 (combining marks) = 36 bytes of cluster text. */ #define OUT_BYTES_PER_CELL 128 +/* Fixed per-frame slack on top of the per-cell budget, for output that wraps + * the whole frame rather than scaling with cell count — the BSU/ESU + * synchronized-output pair. Keeps the wrap from being dropped on tiny grids + * where the per-cell budget alone is tight. */ +#define OUT_FRAME_OVERHEAD 32 + /* ── Cell buffer ops ──────────────────────────────────────────────── */ static Cell *cell_at(struct Clayterm *ct, Cell *buf, int x, int y) { @@ -261,6 +270,18 @@ static void present_cups(struct Clayterm *ct, int row) { ct->lastx = -1; ct->lasty = -1; + /* Synchronized Output (DEC mode 2026): the terminal buffers everything + * between BSU and ESU and presents it in one atomic repaint, so a frame + * never tears mid-update. Unsupported terminals ignore the private mode; + * whether we emit the wrap at all is decided by the sync-output + * capability, threaded through reduce() each frame. */ + int start = ct->out.length; + if (ct->sync) + buf_str(&ct->out, "\x1b[?2026h"); + /* Body start: the no-op check below compares against this so the BSU + * itself never counts as frame content. */ + int body = ct->out.length; + for (int y = 0; y < ct->h; y++) { for (int x = 0; x < ct->w;) { Cell *back = cell_at(ct, ct->back, x, y); @@ -310,6 +331,14 @@ static void present_cups(struct Clayterm *ct, int row) { buf_str(&ct->out, "\x1b[?25l"); } ct->had_caret_last_frame = ct->has_caret; + + if (ct->out.length == body) { + /* Nothing was emitted — no cell changes and no caret transition. + * Rewind the BSU so a no-op frame still produces zero bytes. */ + ct->out.length = start; + } else if (ct->sync) { + buf_str(&ct->out, "\x1b[?2026l"); /* ESU: end synchronized update */ + } } /** @@ -624,7 +653,7 @@ int clayterm_size(int w, int h) { set_clay_capacity(w, h); int cell_count = w * h; int cell_bytes = cell_count * (int)sizeof(Cell); - int out_bytes = cell_count * OUT_BYTES_PER_CELL; + int out_bytes = cell_count * OUT_BYTES_PER_CELL + OUT_FRAME_OVERHEAD; int clay_bytes = (int)Clay_MinMemorySize(); return align8((int)sizeof(struct Clayterm)) + align8(cell_bytes) /* front */ + align8(cell_bytes) /* back */ @@ -686,7 +715,7 @@ struct Clayterm *init(void *mem, int w, int h) { struct Clayterm *ct = (struct Clayterm *)mem; int cell_count = w * h; int cell_bytes = align8(cell_count * (int)sizeof(Cell)); - int out_bytes = align8(cell_count * OUT_BYTES_PER_CELL); + int out_bytes = align8(cell_count * OUT_BYTES_PER_CELL + OUT_FRAME_OVERHEAD); char *base = (char *)mem + align8((int)sizeof(struct Clayterm)); char *clay_mem = base + cell_bytes * 2 + out_bytes; @@ -701,7 +730,8 @@ struct Clayterm *init(void *mem, int w, int h) { .h = h, .front = (Cell *)base, .back = (Cell *)(base + cell_bytes), - .out = {base + cell_bytes * 2, 0, cell_count * OUT_BYTES_PER_CELL}, + .out = {base + cell_bytes * 2, 0, + cell_count * OUT_BYTES_PER_CELL + OUT_FRAME_OVERHEAD}, .lastfg = 0xffffffff, .lastbg = 0xffffffff, .lastx = -1, @@ -717,9 +747,10 @@ struct Clayterm *init(void *mem, int w, int h) { } void reduce(struct Clayterm *ct, uint32_t *buf, int len, int mode, int row, - float deltaTime) { + float deltaTime, int sync) { int i = 0; ct_active_context = ct; + ct->sync = sync; ct->error_count = 0; ct->animating_count = 0; ct->caret_text_chars = NULL; diff --git a/src/clayterm.h b/src/clayterm.h index 8a24db4..41a190b 100644 --- a/src/clayterm.h +++ b/src/clayterm.h @@ -13,7 +13,7 @@ struct Clayterm; int clayterm_size(int w, int h); struct Clayterm *init(void *mem, int w, int h); void reduce(struct Clayterm *ct, uint32_t *buf, int len, int mode, int row, - float deltaTime); + float deltaTime, int sync); char *output(struct Clayterm *ct); int length(struct Clayterm *ct); int animating(struct Clayterm *ct); diff --git a/term-native.ts b/term-native.ts index b54549d..f9c4315 100644 --- a/term-native.ts +++ b/term-native.ts @@ -42,6 +42,7 @@ export interface Native { mode: number, row: number, deltaTime: number, + sync: number, ): void; output(ct: number): number; length(ct: number): number; @@ -99,6 +100,7 @@ export async function createTermNative( mode: number, row: number, deltaTime: number, + sync: number, ): void; output(ct: number): number; length(ct: number): number; diff --git a/term.ts b/term.ts index 5326daa..8b8c2b2 100644 --- a/term.ts +++ b/term.ts @@ -212,7 +212,15 @@ export async function createTerm(options: TermOptions): Promise { dt = now - lastRenderAt; } lastRenderAt = now; - native.reduce(native.statePtr, native.opsBuf, len, mode, row, dt); + native.reduce( + native.statePtr, + native.opsBuf, + len, + mode, + row, + dt, + currentCaps.syncOutput ? 1 : 0, + ); if (options?.pointer) { let { x, y, down } = options.pointer; diff --git a/test/term.test.ts b/test/term.test.ts index 7190fd6..14ebb5f 100644 --- a/test/term.test.ts +++ b/test/term.test.ts @@ -885,4 +885,57 @@ hi expect(trim(print(decode(large.output), 120, 40))).toContain("Hi"); }); }); + + describe("synchronized output", () => { + let frame: Op[] = [ + open("root", { layout: { width: grow(), height: grow() } }), + text("Hi"), + close(), + ]; + + let syncOn = { + type: "capability", + key: "sync-output", + value: true, + } as const; + let syncOff = { + type: "capability", + key: "sync-output", + value: false, + } as const; + + it("does not wrap frames by default", () => { + let out = decode(term.render(frame).output); + expect(out).not.toContain("\x1b[?2026"); + }); + + it("wraps full-screen frames when the capability is on", () => { + term.update([syncOn]); + let out = decode(term.render(frame).output); + expect(out.startsWith("\x1b[?2026h")).toBe(true); + expect(out.endsWith("\x1b[?2026l")).toBe(true); + }); + + it("emits zero bytes for a no-op frame even when wrapped", () => { + term.update([syncOn]); + term.render(frame); + expect(term.render(frame).output.length).toBe(0); + }); + + it("stops wrapping after the capability turns off", () => { + term.update([syncOn]); + term.render(frame); + term.update([syncOff]); + let out = decode(term.render(frame).output); + expect(out).not.toContain("\x1b[?2026"); + }); + + it("never wraps line mode output", () => { + term.update([syncOn]); + let out = decode( + term.render(frame, { mode: "line", row: 1 }).output, + ); + expect(out).not.toContain("\x1b[?2026"); + }); + }); }); diff --git a/test/width.test.ts b/test/width.test.ts index 1c54399..4475960 100644 --- a/test/width.test.ts +++ b/test/width.test.ts @@ -83,7 +83,7 @@ describe("width", () => { buf[at + 1] = 0xa0; buf[at + 2] = 0x80; - native.reduce(native.statePtr, native.opsBuf, len, 0, 1, 0); + native.reduce(native.statePtr, native.opsBuf, len, 0, 1, 0, 0); let out = new Uint8Array( native.memory.buffer, native.output(native.statePtr), From 033bc12df82aa02291ed87d47aa80871d6caf780 Mon Sep 17 00:00:00 2001 From: Nate Moore Date: Thu, 8 Oct 2026 18:16:33 -0500 Subject: [PATCH 02/16] 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 03/16] 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 04/16] =?UTF-8?q?feat:=20emit=20OSC=2022=20pointer=20shape?= =?UTF-8?q?s=20in=20output=20when=20the=20terminal=20supports=20them=20(re?= =?UTF-8?q?nderer-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 05/16] 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 06/16] 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 07/16] 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 08/16] 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 09/16] 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 1d8ef6e7de6d13223c8e1aa479df64f55918006b Mon Sep 17 00:00:00 2001 From: Nate Moore Date: Sun, 11 Oct 2026 11:19:36 -0500 Subject: [PATCH 10/16] =?UTF-8?q?style:=20reflow=20=C2=A77.9=20prose=20to?= =?UTF-8?q?=20satisfy=20deno=20fmt?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- specs/renderer-spec.md | 38 +++++++++++++++++++------------------- 1 file changed, 19 insertions(+), 19 deletions(-) diff --git a/specs/renderer-spec.md b/specs/renderer-spec.md index fe14c6f..20800ff 100644 --- a/specs/renderer-spec.md +++ b/specs/renderer-spec.md @@ -483,38 +483,38 @@ hardcoded ANSI output until one of those specifications is adopted. ### 7.9 Synchronized output (DEC mode 2026) When the `syncOutput` runtime capability is true, each full-screen frame -(§8.2.1) is wrapped in Synchronized Output mode 2026 — Begin Synchronized -Update (`CSI ? 2026 h`) before the frame's first emitted byte and End -Synchronized Update (`CSI ? 2026 l`) after its last. The terminal buffers -everything between the pair and presents it as one atomic repaint, so a frame -never tears mid-update. +(§8.2.1) is wrapped in Synchronized Output mode 2026 — Begin Synchronized Update +(`CSI ? 2026 h`) before the frame's first emitted byte and End Synchronized +Update (`CSI ? 2026 l`) after its last. The terminal buffers everything between +the pair and presents it as one atomic repaint, so a frame never tears +mid-update. **Capability evidence.** `syncOutput` originates exclusively from the `sync-output` `CapabilityEvent` (input-spec §5), emitted when the input parser reads a DECRPM 2026 reply to the probe (`CSI ? 2026 $ p`). Reply values 1, 2, and 3 (set, reset, permanently set) report support; 0 and 4 report none. The -static `Capabilities` never seed this field: it is false until a probe reply -is folded in through `update()`. +static `Capabilities` never seed this field: it is false until a probe reply is +folded in through `update()`. -**Invalidation and timing.** The renderer reads the capability snapshot at -each render transaction, so a capability flip takes effect on the next -rendered frame. No invalidation signal exists, and none is required: a flip -between frames changes only whether the next frame is wrapped. +**Invalidation and timing.** The renderer reads the capability snapshot at each +render transaction, so a capability flip takes effect on the next rendered +frame. No invalidation signal exists, and none is required: a flip between +frames changes only whether the next frame is wrapped. **Normative rules:** - When `syncOutput` is false, the renderer MUST NOT emit `?2026h` or `?2026l`. - When `syncOutput` is true, the renderer MUST wrap each full-screen frame in the BSU/ESU pair. Line mode (§8.2.2) is never wrapped: inline output may be - interleaved with foreign content, and an unterminated synchronized block - would suppress it. + interleaved with foreign content, and an unterminated synchronized block would + suppress it. - A full-screen render whose diff produces no cell changes and no caret - transition MUST emit zero bytes, even when `syncOutput` is true. The - renderer decides after the diff, so no-op frames carry neither the wrap - prefix nor the suffix. -- Terminals that receive the sequences without supporting the mode ignore it - by design; the probe still refines behavior for terminals that report mode - 0 or 4. + transition MUST emit zero bytes, even when `syncOutput` is true. The renderer + decides after the diff, so no-op frames carry neither the wrap prefix nor the + suffix. +- Terminals that receive the sequences without supporting the mode ignore it by + design; the probe still refines behavior for terminals that report mode 0 + or 4. This section discharges the synchronized-output deferral recorded in §7.8. From 2494d02d2eba2413aad3fbceb8720a30e33e1ee0 Mon Sep 17 00:00:00 2001 From: Nate Moore Date: Sun, 11 Oct 2026 13:26:39 -0500 Subject: [PATCH 11/16] feat: sync-output evidence from XTGETTCAP Sync, terminfo entry, and TMUX MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .changeset/sync-output-capability.md | 2 +- specs/renderer-spec.md | 18 ++++--- specs/terminfo-spec.md | 74 ++++++++++++++++++---------- src/input.c | 38 ++++++++++++-- src/terminfo.c | 12 +++-- tasks/gen-fixtures.ts | 13 ++++- term.ts | 2 +- terminfo.ts | 12 +++++ test/fixtures.ts | 4 ++ test/input.test.ts | 58 ++++++++++++++++++++++ test/term.test.ts | 49 ++++++++++++++++++ test/terminfo.test.ts | 31 ++++++++++++ 12 files changed, 268 insertions(+), 45 deletions(-) diff --git a/.changeset/sync-output-capability.md b/.changeset/sync-output-capability.md index 5e7aeb1..369ee9b 100644 --- a/.changeset/sync-output-capability.md +++ b/.changeset/sync-output-capability.md @@ -4,4 +4,4 @@ Adds synchronized output (DEC mode 2026) driven by the `sync-output` capability. -When `RuntimeCapabilities.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 as one atomic repaint without tearing. There is no new API: the capability stays false until a DECRPM 2026 probe reply reports support (`detectTerminal()` probes `CSI ? 2026 $ p`, replies surface through `input.scan()` and fold in via `term.update()`), and frames render unwrapped when it is false or unknown. Zero-diff frames emit nothing whether or not wrapping is active, and line mode is never wrapped. +When `RuntimeCapabilities.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 as one atomic repaint without tearing. There is no new API: the capability is raised only on positive evidence, resolved from three channels — a DECRPM 2026 probe reply (`CSI ? 2026 $ p`), an XTGETTCAP `Sync` reply (the terminal's own terminfo-declared synchronization sequence, value discarded in favor of the 2026 form), and detection-time seeding from the `Sync` extended capability in the parsed terminfo entry or the `TMUX` environment variable (tmux consumes mode 2026 from inner applications). Probe replies outrank detection-time evidence in both directions, so a DECRPM 0 or 4 reply turns the capability off even when detection seeded it. Frames render unwrapped when the capability is false or unknown. Zero-diff frames emit nothing whether or not wrapping is active, and line mode is never wrapped. diff --git a/specs/renderer-spec.md b/specs/renderer-spec.md index 20800ff..80bf451 100644 --- a/specs/renderer-spec.md +++ b/specs/renderer-spec.md @@ -489,12 +489,18 @@ Update (`CSI ? 2026 l`) after its last. The terminal buffers everything between the pair and presents it as one atomic repaint, so a frame never tears mid-update. -**Capability evidence.** `syncOutput` originates exclusively from the -`sync-output` `CapabilityEvent` (input-spec §5), emitted when the input parser -reads a DECRPM 2026 reply to the probe (`CSI ? 2026 $ p`). Reply values 1, 2, -and 3 (set, reset, permanently set) report support; 0 and 4 report none. The -static `Capabilities` never seed this field: it is false until a probe reply is -folded in through `update()`. +**Capability evidence.** `syncOutput` is raised only on positive evidence. Two +runtime channels feed the `sync-output` `CapabilityEvent` (input-spec §5, +terminfo-spec §6.3): a DECRPM 2026 reply to the probe (`CSI ? 2026 $ p`) — +values 1, 2, and 3 (set, reset, permanently set) report support, 0 and 4 report +none — and an XTGETTCAP `Sync` reply, whose sequence value is discarded in favor +of the mode 2026 form (terminfo-spec §6.3). Detection may also seed the field +positively: static `Capabilities` carry `syncOutput`, raised by the `Sync` +extended capability in the parsed terminfo entry and by the `TMUX` environment +variable (terminfo-spec §7.1, §7.2). Evidence follows TINV-3 precedence: probe +replies outrank detect-time seeding in both directions, so a DECRPM 0 or 4 reply +turns the capability off even when detection seeded it. Without positive +evidence the field stays false and no frame is wrapped. **Invalidation and timing.** The renderer reads the capability snapshot at each render transaction, so a capability flip takes effect on the next rendered diff --git a/specs/terminfo-spec.md b/specs/terminfo-spec.md index d800909..7286e98 100644 --- a/specs/terminfo-spec.md +++ b/specs/terminfo-spec.md @@ -180,18 +180,20 @@ interface Capabilities { readonly xenl: boolean; readonly altScreen: boolean; readonly styledUnderline: boolean; + readonly syncOutput: boolean; } ``` -| Field | Source | -| ----------------- | ------------------------------------------------------------------- | -| `colors` | `max_colors` from terminfo; 256 at baseline | -| `trueColor` | `RGB`/`Tc` extended caps; `COLORTERM` env evidence; probe overrides | -| `bce` | `bce` boolean cap | -| `autoMargin` | `am` boolean cap | -| `xenl` | `xenl` boolean cap | -| `altScreen` | `smcup` string present | -| `styledUnderline` | `Su` boolean or `Smulx` string | +| Field | Source | +| ----------------- | ----------------------------------------------------------------------- | +| `colors` | `max_colors` from terminfo; 256 at baseline | +| `trueColor` | `RGB`/`Tc` extended caps; `COLORTERM` env evidence; probe overrides | +| `bce` | `bce` boolean cap | +| `autoMargin` | `am` boolean cap | +| `xenl` | `xenl` boolean cap | +| `altScreen` | `smcup` string present | +| `styledUnderline` | `Su` boolean or `Smulx` string | +| `syncOutput` | `Sync` extended cap; `TMUX` env evidence (§7.2); probe overrides (§6.3) | Key sequences (`key_*` capabilities) are not stored here. The input parser reads them directly from the raw terminfo bytes in `TerminalInfo.keys` at @@ -252,20 +254,20 @@ type CapabilityEvent = }; ``` -Each variant maps to one probe query. The `key` identifies the capability; the -`value` is the terminal's answer. Capability references per +Each variant maps to at least one probe query. The `key` identifies the +capability; the `value` is the terminal's answer. Capability references per [terminfo.dev](https://terminfo.dev): -| `key` | Query | terminfo.dev slug | -| ------------------ | -------------------- | ----------------------------------------------------------------------------------------------- | -| `foreground-color` | OSC 10 | [`osc-10-fg-color-query`](https://terminfo.dev/extensions/osc-10-fg-color-query) | -| `background-color` | OSC 11 | [`osc-11-bg-color-query`](https://terminfo.dev/extensions/osc-11-bg-color-query) | -| `cursor-color` | OSC 12 or OSC 21 | [`osc-12-cursor-color`](https://terminfo.dev/extensions/osc-12-cursor-color) | -| `colordepth` | XTGETTCAP `RGB`/`Tc` | [`24-bit-truecolor`](https://terminfo.dev/extensions/24-bit-truecolor) | -| `sync-output` | DECRPM mode 2026 | [`decset-2026-synchronized-output`](https://terminfo.dev/modes/decset-2026-synchronized-output) | -| `kitty-keyboard` | `CSI ? u` | [`kitty-keyboard-protocol`](https://terminfo.dev/extensions/kitty-keyboard-protocol) | -| `kitty-graphics` | APC `_G…` | [`kitty-graphics-protocol`](https://terminfo.dev/extensions/kitty-graphics-protocol) | -| `pointer-shape` | OSC 22 | [`osc-22-pointer-shape`](https://terminfo.dev/extensions/osc-22-pointer-shape) | +| `key` | Query | terminfo.dev slug | +| ------------------ | ---------------------------------- | ----------------------------------------------------------------------------------------------- | +| `foreground-color` | OSC 10 | [`osc-10-fg-color-query`](https://terminfo.dev/extensions/osc-10-fg-color-query) | +| `background-color` | OSC 11 | [`osc-11-bg-color-query`](https://terminfo.dev/extensions/osc-11-bg-color-query) | +| `cursor-color` | OSC 12 or OSC 21 | [`osc-12-cursor-color`](https://terminfo.dev/extensions/osc-12-cursor-color) | +| `colordepth` | XTGETTCAP `RGB`/`Tc` | [`24-bit-truecolor`](https://terminfo.dev/extensions/24-bit-truecolor) | +| `sync-output` | DECRPM mode 2026; XTGETTCAP `Sync` | [`decset-2026-synchronized-output`](https://terminfo.dev/modes/decset-2026-synchronized-output) | +| `kitty-keyboard` | `CSI ? u` | [`kitty-keyboard-protocol`](https://terminfo.dev/extensions/kitty-keyboard-protocol) | +| `kitty-graphics` | APC `_G…` | [`kitty-graphics-protocol`](https://terminfo.dev/extensions/kitty-graphics-protocol) | +| `pointer-shape` | OSC 22 | [`osc-22-pointer-shape`](https://terminfo.dev/extensions/osc-22-pointer-shape) | **Color values.** `Rgb` values in probe responses MUST be recognized in at least the `rgb:RR/GG/BB` (1–4 hex digits per channel) and `#`-hash forms. @@ -278,6 +280,16 @@ D }` where `D` is derived from the static preserves TINV-3: the probe response takes precedence over static evidence, including in the negative direction. +**XTGETTCAP `Sync`.** A reply carrying a value for `Sync` emits +`{ key: "sync-output", value: true }`. An invalid-capability reply for `Sync` +emits `{ key: "sync-output", value: false }`. The value carried by a positive +reply is the terminal's own synchronization sequence — the iTerm2 DCS form +(`DCS = 1 s` / `DCS = 2 s`) for some terminals, `CSI ? 2026 h`/`l` for others. +The value is discarded in v1: only support is recorded, and the renderer emits +the DEC mode 2026 form (renderer-spec §7.9). A terminal that advertises only the +DCS form therefore receives 2026-form bytes it ignores; this bounded cost is +accepted. + **OSC 21.** An OSC 21 reply may supply any subset of `foreground-color`, `background-color`, and `cursor-color`. The parser emits one `CapabilityEvent` per field the reply carries. OSC 21 arriving alongside OSC 10/11/12 replies does @@ -331,8 +343,12 @@ With no terminfo bytes, no environment evidence, and no probe responses, | `xenl` | `true` | | `altScreen` | `true` | | `styledUnderline` | `false` | +| `syncOutput` | `false` | Truecolor is not assumed at baseline. Per TINV-3, it requires positive evidence. +The same holds for synchronized output: no frame is wrapped until a `Sync` +extended capability, `TMUX` environment evidence, or a probe response raises it +(§7.2, §6.3). ### 7.2 Environment evidence @@ -340,6 +356,11 @@ Truecolor is not assumed at baseline. Per TINV-3, it requires positive evidence. entry: - `COLORTERM` equal to `truecolor` or `24bit` sets `trueColor: true`. +- `TMUX` set to a non-empty value sets `syncOutput: true`. A running tmux + consumes DEC mode 2026 from applications running inside it and translates it + to the outer terminal's synchronization mechanism (the tmux `Sync` terminfo + capability and `sync` terminal feature), so its presence is positive evidence + even for tmux releases that do not themselves answer the DECRPM 2026 probe. Environment evidence outranks the terminfo entry and is outranked by probe responses (TINV-3). @@ -368,7 +389,7 @@ observe only the resulting `Capabilities`. - Both storage formats MUST be supported: legacy (magic `0432`, 16-bit numbers) and extended number format (magic `01036`, 32-bit numbers). - The extended-capability string table MUST be parsed for the user-defined - capabilities `RGB`, `Tc`, `Su`, and `Smulx`. + capabilities `RGB`, `Tc`, `Su`, `Smulx`, and `Sync`. - All reads are bounds-checked against `len`. Out-of-range offsets, truncated sections, and odd-length string tables yield the parse failure path (TINV-2). - The maximum accepted size is 32 768 bytes, the extended ncurses format limit. @@ -398,13 +419,14 @@ order: | 5 | Pointer shape | `OSC 22 ; ?__current__ ST` | `pointer-shape` | | 6 | Truecolor caps | `DCS + q 524742 ; 5463 ST` (XTGETTCAP `RGB;Tc`) | `colordepth` | | 7 | Synchronized output | `CSI ? 2026 $ p` (DECRQM) | `sync-output` | -| 8 | Kitty keyboard | `CSI ? u` | `kitty-keyboard` | -| 9 | Kitty graphics | `APC _G i=31,s=1,v=1,a=q,t=d,f=24 ; AAAA ST` | `kitty-graphics` | -| 10 | **Fence:** DA1 | `CSI c` | (internal only) | +| 8 | Sync terminfo cap | `DCS + q 53796e63 ST` (XTGETTCAP `Sync`) | `sync-output` | +| 9 | Kitty keyboard | `CSI ? u` | `kitty-keyboard` | +| 10 | Kitty graphics | `APC _G i=31,s=1,v=1,a=q,t=d,f=24 ; AAAA ST` | `kitty-graphics` | +| 11 | **Fence:** DA1 | `CSI c` | (internal only) | Terminals answer queries in order and ignore queries they do not understand. DA1 is answered by every terminal, so its response marks the probe complete. Any of -queries 1–9 not yet answered when the DA1 reply arrives will never be answered, +queries 1–10 not yet answered when the DA1 reply arrives will never be answered, and the renderer keeps their current values. The batch is safe to emit unconditionally: every query is either answered or diff --git a/src/input.c b/src/input.c index 5c16e96..162e530 100644 --- a/src/input.c +++ b/src/input.c @@ -954,10 +954,34 @@ static int parse_osc_response(struct InputState *st) { } /* XTGETTCAP reply: DCS 1 + r … ST (valid) or DCS 0 + r … ST (invalid). - * We only query RGB (524742) and Tc (5463). A valid reply naming either - * confirms truecolor. The static tier is emitted only once both are - * denied without a confirmation since the last DA1 fence; an invalid - * reply naming neither denies both. */ + * We query RGB (524742), Tc (5463), and Sync (53796e63) in separate + * requests. A valid reply naming RGB or Tc confirms truecolor. The + * static tier is emitted only once both are denied without a + * confirmation since the last DA1 fence; an invalid reply naming + * neither denies both. + * + * A reply naming Sync answers the Sync query alone: a value-carrying + * reply (name '=' hex-value) confirms synchronized output; a reply + * naming Sync without a value denies it. Sync replies never touch the + * colordepth tally (terminfo-spec 6.3). */ +/* True when payload contains name, then '=', then at least one hex + * digit — an XTGETTCAP reply carrying a value for the capability. */ +static int payload_contains_value(const char *s, int len, const char *name) { + int n = (int)strlen(name); + for (int i = 0; i + n + 1 < len; i++) { + int j = 0; + while (j < n && s[i + j] == name[j]) + j++; + if (j == n && s[i + n] == '=') { + char v = s[i + n + 1]; + if ((v >= '0' && v <= '9') || (v >= 'a' && v <= 'f') || + (v >= 'A' && v <= 'F')) + return 1; + } + } + return 0; +} + static int parse_dcs_response(struct InputState *st) { if (st->len < 5) return PARSE_NEED_MORE; @@ -980,7 +1004,11 @@ static int parse_dcs_response(struct InputState *st) { int plen = end - 5 - (st->buf[end - 1] == '\x07' ? 1 : 2); int rgb = payload_contains(payload, plen, "524742"); int tc = payload_contains(payload, plen, "5463"); - if (ok == '1') { + int sync = payload_contains(payload, plen, "53796e63"); + if (sync) { + emit_cap_bool(st, CAP_SYNC_OUTPUT, + payload_contains_value(payload, plen, "53796e63")); + } else if (ok == '1') { if (rgb || tc) { st->tcap |= TCAP_CONFIRMED; struct InputEvent *ev = emit(st); diff --git a/src/terminfo.c b/src/terminfo.c index fec4d67..503adc1 100644 --- a/src/terminfo.c +++ b/src/terminfo.c @@ -94,7 +94,8 @@ static int name_is(const uint8_t *table, int table_len, int off, /* Extended capability block (after the standard sections). Returns 0 on * success, nonzero when the declared structure runs out of bounds. - * Grants flag bits into *flags for RGB/Tc/Su booleans and Smulx. */ + * Grants flag bits into *flags for RGB/Tc/Su booleans and Smulx/Sync + * strings. */ static int parse_ext(const uint8_t *b, int len, int off, int numw, uint32_t *flags) { if (off & 1) @@ -158,6 +159,8 @@ static int parse_ext(const uint8_t *b, int len, int off, int numw, continue; if (name_is(table, table_len, noff, "Smulx")) { *flags |= TERMINFO_STYLED_UNDERLINE; + } else if (name_is(table, table_len, noff, "Sync")) { + *flags |= TERMINFO_SYNC; } } } @@ -273,9 +276,10 @@ int terminfo_parse(const uint8_t *bytes, int len, struct TermInfo *ti) { /* The entry describes the terminal completely for the capabilities it * owns: replace them, leave probe-only flags and theme fields alone. */ - uint32_t keep = ~(TERMINFO_TRUECOLOR | TERMINFO_BACK_COLOR_ERASE | - TERMINFO_AUTO_RIGHT_MARGIN | TERMINFO_EAT_NEWLINE_GLITCH | - TERMINFO_ALTSCREEN | TERMINFO_STYLED_UNDERLINE); + uint32_t keep = + ~(TERMINFO_TRUECOLOR | TERMINFO_BACK_COLOR_ERASE | + TERMINFO_AUTO_RIGHT_MARGIN | TERMINFO_EAT_NEWLINE_GLITCH | + TERMINFO_ALTSCREEN | TERMINFO_STYLED_UNDERLINE | TERMINFO_SYNC); ti->flags = (ti->flags & keep) | flags; ti->colors = (uint32_t)colors; ti->generation++; diff --git a/tasks/gen-fixtures.ts b/tasks/gen-fixtures.ts index f03116c..d8eb14c 100644 --- a/tasks/gen-fixtures.ts +++ b/tasks/gen-fixtures.ts @@ -8,8 +8,9 @@ * - xterm-256color copied from the system terminfo database (legacy * format, magic 0432): real-world entry with am/xenl/bce/smcup and * colors#256, no truecolor capabilities. - * - clayterm-tc / clayterm-16 compiled with tic -x from the inline - * source below (legacy format with an extended capability block). + * - clayterm-tc / clayterm-sync / clayterm-16 compiled with tic -x from + * the inline source below (legacy format with an extended capability + * block). * - clayterm-direct hand-assembled in the extended number format * (magic 01036, 32-bit numbers) because macOS ships ncurses 6.0, * which predates that format: colors#0x1000000 plus an extended RGB @@ -26,6 +27,13 @@ const ENTRIES_SRC = `clayterm-tc|clayterm truecolor extended-caps test entry, \tkcuu1=\\EOZ, kf5=\\E[99~, \tSmulx=\\E[4:%p1%dm, +clayterm-sync|clayterm sync extended-caps test entry, +\tam, xenl, bce, +\tcolors#256, pairs#65536, cols#80, lines#24, +\tsmcup=\\E[?1049h, rmcup=\\E[?1049l, +\tcup=\\E[%i%p1%d;%p2%dH, +\tSync=\\E[?2026%p1%dh, + clayterm-16|clayterm sixteen color test entry, \tam, xenl, \tcolors#16, pairs#256, cols#80, lines#24, @@ -145,6 +153,7 @@ import { decodeBase64 } from "@std/encoding/base64"; ${constant("XTERM_256COLOR", xterm256)} ${constant("CLAYTERM_TC", compiled["clayterm-tc"])} +${constant("CLAYTERM_SYNC", compiled["clayterm-sync"])} ${constant("CLAYTERM_16", compiled["clayterm-16"])} ${constant("CLAYTERM_DIRECT", buildDirect())}`; diff --git a/term.ts b/term.ts index 8b8c2b2..1ff1c66 100644 --- a/term.ts +++ b/term.ts @@ -86,7 +86,6 @@ function applyUpdate( function runtimeFromStatic(caps: Capabilities): RuntimeCapabilities { return Object.freeze({ ...caps, - syncOutput: false, kittyKeyboard: false, kittyGraphics: false, pointerShape: false, @@ -179,6 +178,7 @@ export async function createTerm(options: TermOptions): Promise { xenl: true, altScreen: true, styledUnderline: false, + syncOutput: false, }, ); diff --git a/terminfo.ts b/terminfo.ts index bb3c081..c0b2a89 100644 --- a/terminfo.ts +++ b/terminfo.ts @@ -14,6 +14,7 @@ const FLAG_AUTO_RIGHT_MARGIN = 1 << 2; const FLAG_EAT_NEWLINE_GLITCH = 1 << 3; const FLAG_ALTSCREEN = 1 << 4; const FLAG_STYLED_UNDERLINE = 1 << 5; +const FLAG_SYNC = 1 << 6; const TermInfoStruct = struct({ generation: uint32(), @@ -43,6 +44,7 @@ export interface Capabilities { readonly xenl: boolean; readonly altScreen: boolean; readonly styledUnderline: boolean; + readonly syncOutput: boolean; } /** Opaque type alias for the raw terminfo key-sequence bytes. */ @@ -84,6 +86,7 @@ const PROBE = encoder.encode( "\x1b]22;?__current__\x1b\\" + "\x1bP+q524742;5463\x1b\\" + "\x1b[?2026$p" + + "\x1bP+q53796e63\x1b\\" + "\x1b[?u" + "\x1b_Gi=31,s=1,v=1,a=q,t=d,f=24;AAAA\x1b\\" + "\x1b[c", @@ -169,6 +172,14 @@ export async function detectTerminal( exports.terminfo_grant(structPtr, FLAG_TRUECOLOR); } + /* Environment evidence (terminfo-spec §7.2): a running tmux consumes + * DEC mode 2026 from applications inside it, so its presence is + * positive sync-output evidence even for tmux releases that do not + * answer the DECRPM probe. */ + if (env.TMUX) { + exports.terminfo_grant(structPtr, FLAG_SYNC); + } + let view = new DataView(memory.buffer); let flags = view.getUint32(structPtr + TI.flags, true); let capabilities: Capabilities = Object.freeze({ @@ -179,6 +190,7 @@ export async function detectTerminal( xenl: !!(flags & FLAG_EAT_NEWLINE_GLITCH), altScreen: !!(flags & FLAG_ALTSCREEN), styledUnderline: !!(flags & FLAG_STYLED_UNDERLINE), + syncOutput: !!(flags & FLAG_SYNC), }); return Object.freeze({ diff --git a/test/fixtures.ts b/test/fixtures.ts index 8030f3a..a5cb252 100644 --- a/test/fixtures.ts +++ b/test/fixtures.ts @@ -9,6 +9,10 @@ export const CLAYTERM_TC: Uint8Array = decodeBase64( "GgE4AB0ADwBYAC0AY2xheXRlcm0tdGN8Y2xheXRlcm0gdHJ1ZWNvbG9yIGV4dGVuZGVkLWNhcHMgdGVzdCBlbnRyeQAAAQAAAQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAQBQAP//GAD//////////////////////////wAB/3///////////////////////////wAA/////////////////////////////////////////////xEA/////////////////////////////xoA////////////////////////////////////////////////////////////////////////////////IwD///////////////////////////////////////8pABtbJWklcDElZDslcDIlZEgAG1s/MTA0OWgAG1s/MTA0OWwAG1s5OX4AG09aAAACAAAAAQAEABcAAQEAAAAAAwAGABtbNDolcDElZG0AU3UAVGMAU211bHgA", ); +export const CLAYTERM_SYNC: Uint8Array = decodeBase64( + "GgE1AB0ADwApACMAY2xheXRlcm0tc3luY3xjbGF5dGVybSBzeW5jIGV4dGVuZGVkLWNhcHMgdGVzdCBlbnRyeQAAAQAAAQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAVAA//8YAP//////////////////////////AAH/f///////////////////////////AAD/////////////////////////////////////////////EQD/////////////////////////////GgAbWyVpJXAxJWQ7JXAyJWRIABtbPzEwNDloABtbPzEwNDlsAAAAAAAAAQACABMAAAAAABtbPzIwMjYlcDElZGgAU3luYwA=", +); + export const CLAYTERM_16: Uint8Array = decodeBase64( "GgEuAAUADwALABEAY2xheXRlcm0tMTZ8Y2xheXRlcm0gc2l4dGVlbiBjb2xvciB0ZXN0IGVudHJ5AAABAAABAFAA//8YAP//////////////////////////EAAAAf//////////////////////////AAAbWyVpJXAxJWQ7JXAyJWRIAA==", ); diff --git a/test/input.test.ts b/test/input.test.ts index 91ce14f..6ad3bdb 100644 --- a/test/input.test.ts +++ b/test/input.test.ts @@ -1085,6 +1085,59 @@ describe("terminfo integration", () => { ]); }); + it("surfaces sync-output=true from a value-carrying XTGETTCAP Sync reply", async () => { + let { input } = await withTerminfo(); + let result = input.scan(str("\x1bP1+r53796e63=1b5b3f3230323668\x1b\\")); + expect(result.events).toEqual([ + { type: "capability", key: "sync-output", value: true }, + ]); + }); + + it("surfaces sync-output=false from an invalid XTGETTCAP Sync reply", async () => { + let { input } = await withTerminfo(); + let result = input.scan(str("\x1bP0+r53796e63\x1b\\")); + expect(result.events).toEqual([ + { type: "capability", key: "sync-output", value: false }, + ]); + }); + + it("surfaces sync-output=false from a Sync reply without a value", async () => { + let { input } = await withTerminfo(); + let result = input.scan(str("\x1bP1+r53796e63\x1b\\")); + expect(result.events).toEqual([ + { type: "capability", key: "sync-output", value: false }, + ]); + }); + + it("accepts a BEL-terminated XTGETTCAP Sync reply", async () => { + let { input } = await withTerminfo(); + let result = input.scan(str("\x1bP1+r53796e63=31\x07")); + expect(result.events).toEqual([ + { type: "capability", key: "sync-output", value: true }, + ]); + }); + + it("does not let a Sync denial disturb the colordepth tally", async () => { + let { input } = await withTerminfo(); + let result = input.scan( + str("\x1bP0+r524742\x1b\\\x1bP0+r53796e63\x1b\\"), + ); + expect(result.events).toEqual([ + { type: "capability", key: "sync-output", value: false }, + ]); + }); + + it("keeps the colordepth denial working after a Sync reply", async () => { + let { input } = await withTerminfo(); + let result = input.scan( + str("\x1bP1+r53796e63=31\x1b\\\x1bP0+r\x1b\\"), + ); + expect(result.events).toEqual([ + { type: "capability", key: "sync-output", value: true }, + { type: "capability", key: "colordepth", value: "256" }, + ]); + }); + it("surfaces synchronized output from a DECRPM confirm report", async () => { let { input } = await withTerminfo(); let result = input.scan(str("\x1b[?2026;2$y")); @@ -1192,6 +1245,11 @@ describe("terminfo integration", () => { ["\x1b]22;default\x1b\\", [cap("pointer-shape", true)]], ["\x1bP1+r524742=382F382F38\x1b\\", [cap("colordepth", "truecolor")]], ["\x1bP0+r\x1b\\", [cap("colordepth", "256")]], + [ + "\x1bP1+r53796e63=1b5b3f3230323668\x1b\\", + [cap("sync-output", true)], + ], + ["\x1bP0+r53796e63\x1b\\", [cap("sync-output", false)]], ["\x1b[?2026;2$y", [cap("sync-output", true)]], ["\x1b[?1u", [cap("kitty-keyboard", true)]], ["\x1b_Gi=31;OK\x1b\\", [cap("kitty-graphics", true)]], diff --git a/test/term.test.ts b/test/term.test.ts index 14ebb5f..90e5869 100644 --- a/test/term.test.ts +++ b/test/term.test.ts @@ -909,6 +909,55 @@ hi expect(out).not.toContain("\x1b[?2026"); }); + it("wraps frames when seeded from terminfo capabilities", async () => { + let t = await createTerm({ + width: 40, + height: 10, + terminfo: { + capabilities: { + colors: 256, + trueColor: false, + bce: true, + autoMargin: true, + xenl: true, + altScreen: true, + styledUnderline: false, + syncOutput: true, + }, + probe: new Uint8Array(0), + keys: new Uint8Array(0), + }, + }); + expect(t.capabilities.syncOutput).toBe(true); + let out = decode(t.render(frame).output); + expect(out.startsWith("\x1b[?2026h")).toBe(true); + expect(out.endsWith("\x1b[?2026l")).toBe(true); + }); + + it("stops wrapping when a probe reply refutes seeded evidence", async () => { + let t = await createTerm({ + width: 40, + height: 10, + terminfo: { + capabilities: { + colors: 256, + trueColor: false, + bce: true, + autoMargin: true, + xenl: true, + altScreen: true, + styledUnderline: false, + syncOutput: true, + }, + probe: new Uint8Array(0), + keys: new Uint8Array(0), + }, + }); + t.update([syncOff]); + let out = decode(t.render(frame).output); + expect(out).not.toContain("\x1b[?2026"); + }); + it("wraps full-screen frames when the capability is on", () => { term.update([syncOn]); let out = decode(term.render(frame).output); diff --git a/test/terminfo.test.ts b/test/terminfo.test.ts index 007b31e..6912f54 100644 --- a/test/terminfo.test.ts +++ b/test/terminfo.test.ts @@ -8,6 +8,7 @@ import { detectTerminal } from "../terminfo.ts"; import { CLAYTERM_16, CLAYTERM_DIRECT, + CLAYTERM_SYNC, CLAYTERM_TC, XTERM_256COLOR, } from "./fixtures.ts"; @@ -23,6 +24,7 @@ const BASELINE = { xenl: true, altScreen: true, styledUnderline: false, + syncOutput: false, }; describe("baseline", () => { @@ -46,6 +48,14 @@ describe("terminfo parsing", () => { expect(caps.bce).toBe(true); expect(caps.autoMargin).toBe(true); expect(caps.altScreen).toBe(true); + expect(caps.syncOutput).toBe(false); + }); + + it("reads Sync from the extended capability table", async () => { + let d = await detectTerminal({ env: {}, entry: CLAYTERM_SYNC }); + let caps = d.capabilities; + expect(caps.syncOutput).toBe(true); + expect(caps.trueColor).toBe(false); }); it("reads Tc / Su / Smulx from the extended capability table", async () => { @@ -113,6 +123,26 @@ describe("environment evidence", () => { let d = await detectTerminal({ env: { COLORTERM: "yes" } }); expect(d.capabilities.trueColor).toBe(false); }); + + it("grants syncOutput from TMUX", async () => { + let d = await detectTerminal({ + env: { TMUX: "/tmp/tmux-1000/default,12345,0" }, + }); + expect(d.capabilities.syncOutput).toBe(true); + }); + + it("ignores an empty TMUX", async () => { + let d = await detectTerminal({ env: { TMUX: "" } }); + expect(d.capabilities.syncOutput).toBe(false); + }); + + it("TMUX evidence applies even when the entry lacks Sync", async () => { + let d = await detectTerminal({ + env: { TMUX: "/tmp/tmux-1000/default,12345,0" }, + entry: XTERM_256COLOR, + }); + expect(d.capabilities.syncOutput).toBe(true); + }); }); describe("filesystem lookup", () => { @@ -184,6 +214,7 @@ const PROBE = "\x1b]10;?\x07" + "\x1b]22;?__current__\x1b\\" + "\x1bP+q524742;5463\x1b\\" + "\x1b[?2026$p" + + "\x1bP+q53796e63\x1b\\" + "\x1b[?u" + "\x1b_Gi=31,s=1,v=1,a=q,t=d,f=24;AAAA\x1b\\" + "\x1b[c"; From 6939c476745ed46fd708ba08c3dd020863ebe551 Mon Sep 17 00:00:00 2001 From: Nate Moore Date: Sun, 11 Oct 2026 13:31:23 -0500 Subject: [PATCH 12/16] 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({ From 3142ef2746df642a2753ee3c44ea4a44fd497dd3 Mon Sep 17 00:00:00 2001 From: Nate Moore Date: Thu, 15 Oct 2026 23:55:37 -0400 Subject: [PATCH 13/16] refactor: hold sync-output in native renderer state 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. --- Makefile | 1 + src/clayterm.c | 18 ++++++++++-------- src/clayterm.h | 6 +++++- term-native.ts | 32 ++++++++++++++++++++++++++++++-- term.ts | 24 +++++++++--------------- test/term.test.ts | 8 ++++++++ test/width.test.ts | 4 ++-- 7 files changed, 65 insertions(+), 28 deletions(-) diff --git a/Makefile b/Makefile index 2da950f..b9d0c33 100644 --- a/Makefile +++ b/Makefile @@ -14,6 +14,7 @@ EXPORTS = \ -Wl,--export=__heap_base \ -Wl,--export=clayterm_size \ -Wl,--export=init \ + -Wl,--export=set_capabilities \ -Wl,--export=reduce \ -Wl,--export=output \ -Wl,--export=length \ diff --git a/src/clayterm.c b/src/clayterm.c index b9b395e..d3b0290 100644 --- a/src/clayterm.c +++ b/src/clayterm.c @@ -87,9 +87,9 @@ struct Clayterm { Clay_ErrorData errors[MAX_ERRORS]; int error_count; int animating_count; - /* wrap each full-screen frame in BSU/ESU (DEC mode 2026); driven by the - * RuntimeCapabilities.syncOutput capability, passed per render call */ - int sync; + /* CAP_* bits pushed by the host via set_capabilities(); init() zeroes + * them, so the host re-pushes after every re-init */ + uint32_t caps; /* Caret state for hardware-cursor management. The renderer records the * first text node carrying a caret declaration per frame, precomputes the * caret's byte offset into that node's content, then places the cell as @@ -274,9 +274,10 @@ static void present_cups(struct Clayterm *ct, int row) { * between BSU and ESU and presents it in one atomic repaint, so a frame * never tears mid-update. Unsupported terminals ignore the private mode; * whether we emit the wrap at all is decided by the sync-output - * capability, threaded through reduce() each frame. */ + * capability. */ + int sync = ct->caps & CAP_SYNC_OUTPUT; int start = ct->out.length; - if (ct->sync) + if (sync) buf_str(&ct->out, "\x1b[?2026h"); /* Body start: the no-op check below compares against this so the BSU * itself never counts as frame content. */ @@ -336,7 +337,7 @@ static void present_cups(struct Clayterm *ct, int row) { /* Nothing was emitted — no cell changes and no caret transition. * Rewind the BSU so a no-op frame still produces zero bytes. */ ct->out.length = start; - } else if (ct->sync) { + } else if (sync) { buf_str(&ct->out, "\x1b[?2026l"); /* ESU: end synchronized update */ } } @@ -746,11 +747,12 @@ struct Clayterm *init(void *mem, int w, int h) { return ct; } +void set_capabilities(struct Clayterm *ct, uint32_t caps) { ct->caps = caps; } + void reduce(struct Clayterm *ct, uint32_t *buf, int len, int mode, int row, - float deltaTime, int sync) { + float deltaTime) { int i = 0; ct_active_context = ct; - ct->sync = sync; ct->error_count = 0; ct->animating_count = 0; ct->caret_text_chars = NULL; diff --git a/src/clayterm.h b/src/clayterm.h index 41a190b..999a842 100644 --- a/src/clayterm.h +++ b/src/clayterm.h @@ -9,11 +9,15 @@ struct Clayterm; +/* Capability bits for set_capabilities(). */ +#define CAP_SYNC_OUTPUT (1u << 0) + /* WASM exports */ int clayterm_size(int w, int h); struct Clayterm *init(void *mem, int w, int h); +void set_capabilities(struct Clayterm *ct, uint32_t caps); void reduce(struct Clayterm *ct, uint32_t *buf, int len, int mode, int row, - float deltaTime, int sync); + float deltaTime); char *output(struct Clayterm *ct); int length(struct Clayterm *ct); int animating(struct Clayterm *ct); diff --git a/term-native.ts b/term-native.ts index f9c4315..55febe9 100644 --- a/term-native.ts +++ b/term-native.ts @@ -25,6 +25,13 @@ const CLAY_DEFAULT_MAX_ELEMENT_COUNT = 8192; // and snapshot payload bytes live in TEXT_TRANSFER_BUFFER_BYTES. const MAX_FIXED_ELEMENT_WIRE_BYTES = 116; +/** Capability evidence the renderer consumes for emission. */ +export interface RenderCapabilities { + syncOutput: boolean; +} + +const CAP_SYNC_OUTPUT = 1 << 0; + export interface Native { memory: WebAssembly.Memory; statePtr: number; @@ -35,6 +42,12 @@ export interface Native { * opsBuf may change. Growing memory detaches prior buffer views. */ update(w: number, h: number): void; + /** + * Push capability evidence into the renderer. The renderer holds it across + * frames; it is re-pushed internally after any re-init so it survives + * resize updates. + */ + setCapabilities(caps: RenderCapabilities): void; reduce( ct: number, buf: number, @@ -42,7 +55,6 @@ export interface Native { mode: number, row: number, deltaTime: number, - sync: number, ): void; output(ct: number): number; length(ct: number): number; @@ -60,6 +72,7 @@ import { compiled } from "./wasm.ts"; export async function createTermNative( w: number, h: number, + caps: RenderCapabilities, ): Promise { let memory = new WebAssembly.Memory({ initial: 2 }); let exports: Record = {}; @@ -100,10 +113,10 @@ export async function createTermNative( mode: number, row: number, deltaTime: number, - sync: number, ): void; output(ct: number): number; length(ct: number): number; + set_capabilities(ct: number, caps: number): void; Clay_SetPointerState(vec: number, down: number): void; pointer_over_count(): number; pointer_over_id_string_length(index: number): number; @@ -124,6 +137,15 @@ export async function createTermNative( let statePtr!: number; let opsBuf = 0; + let capFlags = 0; + + function pushCaps(): void { + ct.set_capabilities(statePtr, capFlags); + } + + function setCaps(next: RenderCapabilities): void { + capFlags = next.syncOutput ? CAP_SYNC_OUTPUT : 0; + } // Renderer state and the fixed transfer buffer share linear memory as // [heap: state][opsBuf]; opsBuf moves when the state size changes. Memory is @@ -140,7 +162,9 @@ export async function createTermNative( } statePtr = ct.init(heap, lw, lh); opsBuf = (heap + size + 3) & ~3; + pushCaps(); } + setCaps(caps); layout(w, h); return { @@ -154,6 +178,10 @@ export async function createTermNative( update(uw: number, uh: number): void { layout(uw, uh); }, + setCapabilities(next: RenderCapabilities): void { + setCaps(next); + pushCaps(); + }, reduce: ct.reduce, output: ct.output, length: ct.length, diff --git a/term.ts b/term.ts index 1ff1c66..215f9eb 100644 --- a/term.ts +++ b/term.ts @@ -163,12 +163,6 @@ export interface Term { export async function createTerm(options: TermOptions): Promise { let { width, height, terminfo } = options; - let native = await createTermNative( - width, - height, - ); - let { memory } = native; - let currentCaps: RuntimeCapabilities = runtimeFromStatic( terminfo?.capabilities ?? { colors: 256, @@ -182,6 +176,11 @@ export async function createTerm(options: TermOptions): Promise { }, ); + let native = await createTermNative(width, height, { + syncOutput: currentCaps.syncOutput, + }); + let { memory } = native; + let prev = new Set(); let pressed = new Set(); let wasDown = false; @@ -212,15 +211,7 @@ export async function createTerm(options: TermOptions): Promise { dt = now - lastRenderAt; } lastRenderAt = now; - native.reduce( - native.statePtr, - native.opsBuf, - len, - mode, - row, - dt, - currentCaps.syncOutput ? 1 : 0, - ); + native.reduce(native.statePtr, native.opsBuf, len, mode, row, dt); if (options?.pointer) { let { x, y, down } = options.pointer; @@ -317,6 +308,9 @@ export async function createTerm(options: TermOptions): Promise { } } + if (next.syncOutput !== currentCaps.syncOutput) { + native.setCapabilities({ syncOutput: next.syncOutput }); + } currentCaps = next; if (bytes.length) out.push(bytes); } diff --git a/test/term.test.ts b/test/term.test.ts index 90e5869..5bf04b4 100644 --- a/test/term.test.ts +++ b/test/term.test.ts @@ -979,6 +979,14 @@ hi expect(out).not.toContain("\x1b[?2026"); }); + it("keeps wrapping after a resize update", () => { + term.update([syncOn]); + term.update([{ type: "resize", width: 50, height: 12 }]); + let out = decode(term.render(frame).output); + expect(out.startsWith("\x1b[?2026h")).toBe(true); + expect(out.endsWith("\x1b[?2026l")).toBe(true); + }); + it("never wraps line mode output", () => { term.update([syncOn]); let out = decode( diff --git a/test/width.test.ts b/test/width.test.ts index 4475960..592adee 100644 --- a/test/width.test.ts +++ b/test/width.test.ts @@ -57,7 +57,7 @@ describe("width", () => { }); it("replaces surrogate codepoints from malformed UTF-8 with U+FFFD", async () => { - let native = await createTermNative(10, 3); + let native = await createTermNative(10, 3, { syncOutput: false }); // TextEncoder never produces surrogate bytes, so pack a placeholder // (U+1234 = E1 88 B4) and patch it to CESU-8 U+D800 (ED A0 80) to @@ -83,7 +83,7 @@ describe("width", () => { buf[at + 1] = 0xa0; buf[at + 2] = 0x80; - native.reduce(native.statePtr, native.opsBuf, len, 0, 1, 0, 0); + native.reduce(native.statePtr, native.opsBuf, len, 0, 1, 0); let out = new Uint8Array( native.memory.buffer, native.output(native.statePtr), From 34ab9ca596dbc3b33c20beeeaaf15848ab39a9b8 Mon Sep 17 00:00:00 2001 From: Nate Moore Date: Thu, 15 Oct 2026 23:59:43 -0400 Subject: [PATCH 14/16] refactor: prefix renderer capability bits to avoid input.h collision input.h already defines CAP_SYNC_OUTPUT as a CapabilityEvent key (5). Any translation unit including both headers silently redefined it. --- src/clayterm.c | 2 +- src/clayterm.h | 2 +- term-native.ts | 4 ++-- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/clayterm.c b/src/clayterm.c index d3b0290..015bdff 100644 --- a/src/clayterm.c +++ b/src/clayterm.c @@ -275,7 +275,7 @@ static void present_cups(struct Clayterm *ct, int row) { * never tears mid-update. Unsupported terminals ignore the private mode; * whether we emit the wrap at all is decided by the sync-output * capability. */ - int sync = ct->caps & CAP_SYNC_OUTPUT; + int sync = ct->caps & RENDER_CAP_SYNC_OUTPUT; int start = ct->out.length; if (sync) buf_str(&ct->out, "\x1b[?2026h"); diff --git a/src/clayterm.h b/src/clayterm.h index 999a842..8d1c807 100644 --- a/src/clayterm.h +++ b/src/clayterm.h @@ -10,7 +10,7 @@ struct Clayterm; /* Capability bits for set_capabilities(). */ -#define CAP_SYNC_OUTPUT (1u << 0) +#define RENDER_CAP_SYNC_OUTPUT (1u << 0) /* WASM exports */ int clayterm_size(int w, int h); diff --git a/term-native.ts b/term-native.ts index 55febe9..e40981b 100644 --- a/term-native.ts +++ b/term-native.ts @@ -30,7 +30,7 @@ export interface RenderCapabilities { syncOutput: boolean; } -const CAP_SYNC_OUTPUT = 1 << 0; +const RENDER_CAP_SYNC_OUTPUT = 1 << 0; export interface Native { memory: WebAssembly.Memory; @@ -144,7 +144,7 @@ export async function createTermNative( } function setCaps(next: RenderCapabilities): void { - capFlags = next.syncOutput ? CAP_SYNC_OUTPUT : 0; + capFlags = next.syncOutput ? RENDER_CAP_SYNC_OUTPUT : 0; } // Renderer state and the fixed transfer buffer share linear memory as From bcdb4d7c03271cbfdeb678453c88a474d6cf220d Mon Sep 17 00:00:00 2001 From: Nate Moore Date: Fri, 16 Oct 2026 00:02:33 -0400 Subject: [PATCH 15/16] spec(renderer): pointer shape is a wire property resolved by the renderer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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). --- AGENTS.md | 2 +- debug-terminfo.ts | 2 +- specs/renderer-spec.md | 117 ++++++++++++++++++++--------------------- specs/terminfo-spec.md | 8 +-- 4 files changed, 64 insertions(+), 65 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index b33f979..3484d62 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -35,7 +35,7 @@ Do not include any agent marketing material (e.g. "Generated with...", renderer-managed only when a `caret` declaration is present on a `text()` 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). + renderer-spec.md §7.10). - Each frame is a complete snapshot. The renderer carries no UI tree state between frames — only cell buffers for diffing. diff --git a/debug-terminfo.ts b/debug-terminfo.ts index b8e450f..5aaad94 100644 --- a/debug-terminfo.ts +++ b/debug-terminfo.ts @@ -137,7 +137,7 @@ console.log( // 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)"); +console.log("── pointer shape (renderer-spec §7.10)"); const result = term.render( [open("box", { cursor: "pointer" }), text("hover"), close()], { pointer: { x: 2, y: 2, down: false } }, diff --git a/specs/renderer-spec.md b/specs/renderer-spec.md index 52e894d..2b70afd 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` 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. +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. ### 7.4 Lifecycle @@ -451,7 +451,7 @@ be a no-op for that step. `value` into the Term's private `RuntimeCapabilities`. The foundation update 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). +(§7.10). **Return value.** `update()` returns a `Uint8Array` of bytes to write to the terminal immediately. An empty array is valid when the update changes no @@ -472,14 +472,58 @@ is valid until the next `render()` **or** `update()` call). 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. +specification. Synchronized output (§7.9) and pointer shape (§7.10) are +specified so far. -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. +Color encoding, 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 +### 7.9 Synchronized output (DEC mode 2026) + +When the `syncOutput` runtime capability is true, each full-screen frame +(§8.2.1) is wrapped in Synchronized Output mode 2026 — Begin Synchronized Update +(`CSI ? 2026 h`) before the frame's first emitted byte and End Synchronized +Update (`CSI ? 2026 l`) after its last. The terminal buffers everything between +the pair and presents it as one atomic repaint, so a frame never tears +mid-update. + +**Capability evidence.** `syncOutput` is raised only on positive evidence. Two +runtime channels feed the `sync-output` `CapabilityEvent` (input-spec §5, +terminfo-spec §6.3): a DECRPM 2026 reply to the probe (`CSI ? 2026 $ p`) — +values 1, 2, and 3 (set, reset, permanently set) report support, 0 and 4 report +none — and an XTGETTCAP `Sync` reply, whose sequence value is discarded in favor +of the mode 2026 form (terminfo-spec §6.3). Detection may also seed the field +positively: static `Capabilities` carry `syncOutput`, raised by the `Sync` +extended capability in the parsed terminfo entry and by the `TMUX` environment +variable (terminfo-spec §7.1, §7.2). Evidence follows TINV-3 precedence: probe +replies outrank detect-time seeding in both directions, so a DECRPM 0 or 4 reply +turns the capability off even when detection seeded it. Without positive +evidence the field stays false and no frame is wrapped. + +**Invalidation and timing.** The renderer reads the capability snapshot at each +render transaction, so a capability flip takes effect on the next rendered +frame. No invalidation signal exists, and none is required: a flip between +frames changes only whether the next frame is wrapped. + +**Normative rules:** + +- When `syncOutput` is false, the renderer MUST NOT emit `?2026h` or `?2026l`. +- When `syncOutput` is true, the renderer MUST wrap each full-screen frame in + the BSU/ESU pair. Line mode (§8.2.2) is never wrapped: inline output may be + interleaved with foreign content, and an unterminated synchronized block would + suppress it. +- A full-screen render whose diff produces no cell changes and no caret + transition MUST emit zero bytes, even when `syncOutput` is true. The renderer + decides after the diff, so no-op frames carry neither the wrap prefix nor the + suffix. +- Terminals that receive the sequences without supporting the mode ignore it by + design; the probe still refines behavior for terminals that report mode 0 + or 4. + +This section discharges the synchronized-output deferral recorded in §7.8. + +### 7.10 Pointer shape 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 @@ -494,7 +538,7 @@ 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 `cursor` properties and MUST NOT emit OSC 22. +renderer MUST NOT emit OSC 22. **Resolved shape.** While the capability is `true`, each render resolves one shape: @@ -526,50 +570,6 @@ 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. -### 7.9 Synchronized output (DEC mode 2026) - -When the `syncOutput` runtime capability is true, each full-screen frame -(§8.2.1) is wrapped in Synchronized Output mode 2026 — Begin Synchronized Update -(`CSI ? 2026 h`) before the frame's first emitted byte and End Synchronized -Update (`CSI ? 2026 l`) after its last. The terminal buffers everything between -the pair and presents it as one atomic repaint, so a frame never tears -mid-update. - -**Capability evidence.** `syncOutput` is raised only on positive evidence. Two -runtime channels feed the `sync-output` `CapabilityEvent` (input-spec §5, -terminfo-spec §6.3): a DECRPM 2026 reply to the probe (`CSI ? 2026 $ p`) — -values 1, 2, and 3 (set, reset, permanently set) report support, 0 and 4 report -none — and an XTGETTCAP `Sync` reply, whose sequence value is discarded in favor -of the mode 2026 form (terminfo-spec §6.3). Detection may also seed the field -positively: static `Capabilities` carry `syncOutput`, raised by the `Sync` -extended capability in the parsed terminfo entry and by the `TMUX` environment -variable (terminfo-spec §7.1, §7.2). Evidence follows TINV-3 precedence: probe -replies outrank detect-time seeding in both directions, so a DECRPM 0 or 4 reply -turns the capability off even when detection seeded it. Without positive -evidence the field stays false and no frame is wrapped. - -**Invalidation and timing.** The renderer reads the capability snapshot at each -render transaction, so a capability flip takes effect on the next rendered -frame. No invalidation signal exists, and none is required: a flip between -frames changes only whether the next frame is wrapped. - -**Normative rules:** - -- When `syncOutput` is false, the renderer MUST NOT emit `?2026h` or `?2026l`. -- When `syncOutput` is true, the renderer MUST wrap each full-screen frame in - the BSU/ESU pair. Line mode (§8.2.2) is never wrapped: inline output may be - interleaved with foreign content, and an unterminated synchronized block would - suppress it. -- A full-screen render whose diff produces no cell changes and no caret - transition MUST emit zero bytes, even when `syncOutput` is true. The renderer - decides after the diff, so no-op frames carry neither the wrap prefix nor the - suffix. -- Terminals that receive the sequences without supporting the mode ignore it by - design; the probe still refines behavior for terminals that report mode 0 - or 4. - -This section discharges the synchronized-output deferral recorded in §7.8. - --- ## 8. Public Rendering API @@ -907,7 +907,7 @@ specified in §7.6. Capability-specific output modes are not terminal-state management owned by the foundation renderer. Each capability section of §7 defines its own state -boundary. The mouse pointer shape (§7.9) is distinct from the cursor shape +boundary. The mouse pointer shape (§7.10) is distinct from the cursor shape above, which remains caller-managed. ### 11.3 The renderer does not own application lifecycle @@ -994,8 +994,7 @@ The `open()` constructor currently accepts the following property groups in its reference, attach target, structured attach points, pointer capture mode, clip target, z-index) - **`scroll`** — scroll container configuration -- **`cursor`** — mouse pointer shape while hovered (§7.9); not transferred to - the WASM module +- **`cursor`** — mouse pointer shape while hovered (§7.10) The `floating` object shape is: diff --git a/specs/terminfo-spec.md b/specs/terminfo-spec.md index 0565239..6093ba9 100644 --- a/specs/terminfo-spec.md +++ b/specs/terminfo-spec.md @@ -202,7 +202,7 @@ 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 +but never answer its support query (Renderer Specification §7.10); 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. @@ -324,9 +324,9 @@ 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. 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; `detectTerminal()` supplies that evidence itself from the known-support -table (§6.1), and a caller may still override it with +Specification §7.10). Terminals that implement OSC 22 set but not its query +never 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 From 16fcddaaeeef85737e4e90f00d66a82cc6dbfe94 Mon Sep 17 00:00:00 2001 From: Nate Moore Date: Fri, 16 Oct 2026 00:02:33 -0400 Subject: [PATCH 16/16] refactor: resolve and emit pointer shapes in the native renderer 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. --- Makefile | 5 ++- ops.ts | 26 ++++++------- pointer-shape.ts | 43 ++++----------------- src/clayterm.c | 76 +++++++++++++++++++++++++++++++++++--- src/clayterm.h | 7 +++- term-native.ts | 54 ++++++++++++++++----------- term.ts | 69 ++++++++++++++++------------------ test/pointer-shape.test.ts | 42 ++++++++++++++------- test/width.test.ts | 7 +++- 9 files changed, 196 insertions(+), 133 deletions(-) diff --git a/Makefile b/Makefile index b9d0c33..c3e7b24 100644 --- a/Makefile +++ b/Makefile @@ -15,11 +15,14 @@ EXPORTS = \ -Wl,--export=clayterm_size \ -Wl,--export=init \ -Wl,--export=set_capabilities \ + -Wl,--export=pointer_shape \ + -Wl,--export=set_pointer_shape \ + -Wl,--export=pointer_shape \ + -Wl,--export=set_pointer_shape \ -Wl,--export=reduce \ -Wl,--export=output \ -Wl,--export=length \ -Wl,--export=measure \ - -Wl,--export=Clay_SetPointerState \ -Wl,--export=pointer_over_count \ -Wl,--export=pointer_over_id_string_length \ -Wl,--export=pointer_over_id_string_ptr \ diff --git a/ops.ts b/ops.ts index c548687..e34e79d 100644 --- a/ops.ts +++ b/ops.ts @@ -1,4 +1,4 @@ -import type { PointerShape } from "./pointer-shape.ts"; +import { type PointerShape, pointerShapeIndex } from "./pointer-shape.ts"; export type { PointerShape }; @@ -38,6 +38,7 @@ const PROP_BORDER = 0x08; const PROP_CLIP = 0x10; const PROP_FLOATING = 0x20; const PROP_TRANSITION = 0x40; +const PROP_CURSOR = 0x80; const encoder = new TextEncoder(); @@ -156,6 +157,8 @@ export function pack( if (op.clip) mask |= PROP_CLIP; if (op.floating) mask |= PROP_FLOATING; if (op.transition) mask |= PROP_TRANSITION; + let cursor = pointerShapeIndex(op.cursor); + if (cursor !== undefined) mask |= PROP_CURSOR; view.setUint32(o, mask, true); o += 4; @@ -283,6 +286,11 @@ export function pack( view.setUint8(o, t.interactive ? 1 : 0); o += 1; } + + if (cursor !== undefined) { + view.setUint32(o, cursor, true); + o += 4; + } break; } @@ -496,7 +504,6 @@ 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; @@ -541,6 +548,7 @@ function packSize(ops: Op[]): number { // x, y, expand width/height, parent, attach/pointer, clip/z if (op.floating) n += 7 * 4; if (op.transition) n += 8; + if (pointerShapeIndex(op.cursor) !== undefined) n += 4; break; } case OP_TEXT: { @@ -557,19 +565,7 @@ export function snapshot(ops: Op[]): Op { let size = packSize(ops); let buf = new ArrayBuffer(size); let words = pack(ops, buf, 0, size); - 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 }; + return { directive: OP_SNAPSHOT, data: new Uint8Array(buf, 0, words * 4) }; } const TP_X = 1; diff --git a/pointer-shape.ts b/pointer-shape.ts index 67c93c7..8ed5a88 100644 --- a/pointer-shape.ts +++ b/pointer-shape.ts @@ -1,5 +1,3 @@ -import type { Op } from "./ops.ts"; - export const POINTER_SHAPES = [ "default", "none", @@ -43,39 +41,14 @@ export const POINTER_SHAPES = [ */ export type PointerShape = typeof POINTER_SHAPES[number]; -const KNOWN: ReadonlySet = new Set(POINTER_SHAPES); +const INDEX: ReadonlyMap = new Map( + POINTER_SHAPES.map((shape, i) => [shape, i]), +); -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 ("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.cursor; - } - } - } - return shape; +export function pointerShapeIndex(shape: unknown): number | undefined { + return INDEX.get(shape); } -const encoder = new TextEncoder(); - -export function osc22(shape: PointerShape): Uint8Array { - return encoder.encode(`\x1b]22;${shape}\x1b\\`); -} +export const POINTER_SHAPE_RESET: Uint8Array = new TextEncoder().encode( + "\x1b]22;default\x1b\\", +); diff --git a/src/clayterm.c b/src/clayterm.c index 015bdff..dea1c84 100644 --- a/src/clayterm.c +++ b/src/clayterm.c @@ -43,6 +43,7 @@ struct Clayterm *ct_active_context = NULL; #define PROP_CLIP 0x10 #define PROP_FLOATING 0x20 #define PROP_TRANSITION 0x40 +#define PROP_CURSOR 0x80 /* ── Instance state ───────────────────────────────────────────────── */ @@ -90,6 +91,11 @@ struct Clayterm { /* CAP_* bits pushed by the host via set_capabilities(); init() zeroes * them, so the host re-pushes after every re-init */ uint32_t caps; + /* Pointer shape (renderer-spec 7.10): hover_shape is what this frame's + * hit test resolved, pointer_shape is what was last emitted. Both index + * POINTER_SHAPES; 0 is "default". */ + int hover_shape; + int pointer_shape; /* Caret state for hardware-cursor management. The renderer records the * first text node carrying a caret declaration per frame, precomputes the * caret's byte offset into that node's content, then places the cell as @@ -119,11 +125,34 @@ struct Clayterm { * to 4 (base SMP char) + 8×4 (combining marks) = 36 bytes of cluster text. */ #define OUT_BYTES_PER_CELL 128 -/* Fixed per-frame slack on top of the per-cell budget, for output that wraps - * the whole frame rather than scaling with cell count — the BSU/ESU - * synchronized-output pair. Keeps the wrap from being dropped on tiny grids +/* Fixed per-frame slack on top of the per-cell budget, for output emitted + * once per frame rather than per cell: the BSU/ESU synchronized-output pair + * and one OSC 22 pointer shape. Keeps them from being dropped on tiny grids * where the per-cell budget alone is tight. */ -#define OUT_FRAME_OVERHEAD 32 +#define OUT_FRAME_OVERHEAD 64 + +/* Pointer shape names in wire-index order; mirrors POINTER_SHAPES in + * pointer-shape.ts. */ +static const char *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", +}; +#define POINTER_SHAPE_COUNT \ + ((int)(sizeof(POINTER_SHAPES) / sizeof(POINTER_SHAPES[0]))) + +/* Clay calls hover functions in hit-test order, ancestors first, so the + * last call is the innermost declaring element. */ +static void cursor_hover(Clay_ElementId id, Clay_PointerData pointer, + void *shape) { + (void)id; + (void)pointer; + ct_active_context->hover_shape = (int)(uintptr_t)shape; +} /* ── Cell buffer ops ──────────────────────────────────────────────── */ @@ -747,10 +776,24 @@ struct Clayterm *init(void *mem, int w, int h) { return ct; } -void set_capabilities(struct Clayterm *ct, uint32_t caps) { ct->caps = caps; } +int set_capabilities(struct Clayterm *ct, uint32_t caps) { + int withdrawn = (ct->caps & RENDER_CAP_POINTER_SHAPE) && + !(caps & RENDER_CAP_POINTER_SHAPE); + int restore = withdrawn && ct->pointer_shape != 0; + if (restore) + ct->pointer_shape = 0; + ct->caps = caps; + return restore; +} + +int pointer_shape(struct Clayterm *ct) { return ct->pointer_shape; } + +void set_pointer_shape(struct Clayterm *ct, int shape) { + ct->pointer_shape = shape; +} void reduce(struct Clayterm *ct, uint32_t *buf, int len, int mode, int row, - float deltaTime) { + float deltaTime, float px, float py, int pointer) { int i = 0; ct_active_context = ct; ct->error_count = 0; @@ -884,7 +927,14 @@ void reduce(struct Clayterm *ct, uint32_t *buf, int len, int mode, int row, : CLAY_TRANSITION_DISABLE_INTERACTIONS_WHILE_TRANSITIONING_POSITION; } + uint32_t cursor = POINTER_SHAPE_COUNT; + if (mask & PROP_CURSOR) + cursor = rd(buf, len, &i); + Clay__ConfigureOpenElement(decl); + + if (cursor < POINTER_SHAPE_COUNT && (ct->caps & RENDER_CAP_POINTER_SHAPE)) + Clay_OnHover(cursor_hover, (void *)(uintptr_t)cursor); break; } @@ -948,6 +998,12 @@ void reduce(struct Clayterm *ct, uint32_t *buf, int len, int mode, int row, Clay_RenderCommandArray cmds = Clay_EndLayout(deltaTime); + /* pointer: 0 = absent, 1 = up, 2 = down. The hit test runs against this + * frame's layout and fires cursor_hover for declaring elements. */ + ct->hover_shape = 0; + if (pointer) + Clay_SetPointerState((Clay_Vector2){px, py}, pointer == 2); + /* reset output state */ ct->out.length = 0; ct->lastfg = ct->lastbg = 0xffffffff; @@ -1063,6 +1119,14 @@ void reduce(struct Clayterm *ct, uint32_t *buf, int len, int mode, int row, present_cups(ct, row); } + if ((ct->caps & RENDER_CAP_POINTER_SHAPE) && + ct->hover_shape != ct->pointer_shape) { + ct->pointer_shape = ct->hover_shape; + buf_str(&ct->out, "\x1b]22;"); + buf_str(&ct->out, POINTER_SHAPES[ct->pointer_shape]); + buf_str(&ct->out, "\x1b\\"); + } + ct_active_context = NULL; } diff --git a/src/clayterm.h b/src/clayterm.h index 8d1c807..27069f1 100644 --- a/src/clayterm.h +++ b/src/clayterm.h @@ -11,13 +11,16 @@ struct Clayterm; /* Capability bits for set_capabilities(). */ #define RENDER_CAP_SYNC_OUTPUT (1u << 0) +#define RENDER_CAP_POINTER_SHAPE (1u << 1) /* WASM exports */ int clayterm_size(int w, int h); struct Clayterm *init(void *mem, int w, int h); -void set_capabilities(struct Clayterm *ct, uint32_t caps); +int set_capabilities(struct Clayterm *ct, uint32_t caps); +int pointer_shape(struct Clayterm *ct); +void set_pointer_shape(struct Clayterm *ct, int shape); void reduce(struct Clayterm *ct, uint32_t *buf, int len, int mode, int row, - float deltaTime); + float deltaTime, float px, float py, int pointer); char *output(struct Clayterm *ct); int length(struct Clayterm *ct); int animating(struct Clayterm *ct); diff --git a/term-native.ts b/term-native.ts index e40981b..62f11b3 100644 --- a/term-native.ts +++ b/term-native.ts @@ -23,14 +23,21 @@ const CLAY_DEFAULT_MAX_ELEMENT_COUNT = 8192; // Conservative fixed wire-format budget per element. This covers the largest // non-text open/close element encoding we currently support; text, element id, // and snapshot payload bytes live in TEXT_TRANSFER_BUFFER_BYTES. -const MAX_FIXED_ELEMENT_WIRE_BYTES = 116; +const MAX_FIXED_ELEMENT_WIRE_BYTES = 120; /** Capability evidence the renderer consumes for emission. */ export interface RenderCapabilities { syncOutput: boolean; + pointerShape: boolean; } const RENDER_CAP_SYNC_OUTPUT = 1 << 0; +const RENDER_CAP_POINTER_SHAPE = 1 << 1; + +/** Pointer passed to reduce(): absent, up, or down. */ +export const POINTER_ABSENT = 0; +export const POINTER_UP = 1; +export const POINTER_DOWN = 2; export interface Native { memory: WebAssembly.Memory; @@ -45,9 +52,10 @@ export interface Native { /** * Push capability evidence into the renderer. The renderer holds it across * frames; it is re-pushed internally after any re-init so it survives - * resize updates. + * resize updates. Returns true when withdrawing pointerShape leaves a + * non-default shape on screen that the caller must reset. */ - setCapabilities(caps: RenderCapabilities): void; + setCapabilities(caps: RenderCapabilities): boolean; reduce( ct: number, buf: number, @@ -55,10 +63,12 @@ export interface Native { mode: number, row: number, deltaTime: number, + px: number, + py: number, + pointer: number, ): void; output(ct: number): number; length(ct: number): number; - setPointer(x: number, y: number, down: boolean): void; getPointerOverIds(): string[]; getElementBounds(id: string): BoundingBox | undefined; animating(ct: number): number; @@ -113,11 +123,15 @@ export async function createTermNative( mode: number, row: number, deltaTime: number, + px: number, + py: number, + pointer: number, ): void; output(ct: number): number; length(ct: number): number; - set_capabilities(ct: number, caps: number): void; - Clay_SetPointerState(vec: number, down: number): void; + set_capabilities(ct: number, caps: number): number; + pointer_shape(ct: number): number; + set_pointer_shape(ct: number, shape: number): void; pointer_over_count(): number; pointer_over_id_string_length(index: number): number; pointer_over_id_string_ptr(index: number): number; @@ -135,16 +149,17 @@ export async function createTermNative( let transferBytes = TEXT_TRANSFER_BUFFER_BYTES + CLAY_DEFAULT_MAX_ELEMENT_COUNT * MAX_FIXED_ELEMENT_WIRE_BYTES; - let statePtr!: number; + let statePtr: number | undefined; let opsBuf = 0; let capFlags = 0; - function pushCaps(): void { - ct.set_capabilities(statePtr, capFlags); + function pushCaps(ptr: number): boolean { + return ct.set_capabilities(ptr, capFlags) !== 0; } function setCaps(next: RenderCapabilities): void { - capFlags = next.syncOutput ? RENDER_CAP_SYNC_OUTPUT : 0; + capFlags = (next.syncOutput ? RENDER_CAP_SYNC_OUTPUT : 0) | + (next.pointerShape ? RENDER_CAP_POINTER_SHAPE : 0); } // Renderer state and the fixed transfer buffer share linear memory as @@ -160,9 +175,12 @@ export async function createTermNative( if (pages > current) { memory.grow(pages - current); } - statePtr = ct.init(heap, lw, lh); + let shape = statePtr === undefined ? 0 : ct.pointer_shape(statePtr); + let ptr = ct.init(heap, lw, lh); opsBuf = (heap + size + 3) & ~3; - pushCaps(); + pushCaps(ptr); + ct.set_pointer_shape(ptr, shape); + statePtr = ptr; } setCaps(caps); layout(w, h); @@ -170,7 +188,7 @@ export async function createTermNative( return { memory, get statePtr() { - return statePtr; + return statePtr!; }, get opsBuf() { return opsBuf; @@ -178,20 +196,14 @@ export async function createTermNative( update(uw: number, uh: number): void { layout(uw, uh); }, - setCapabilities(next: RenderCapabilities): void { + setCapabilities(next: RenderCapabilities): boolean { setCaps(next); - pushCaps(); + return pushCaps(statePtr!); }, reduce: ct.reduce, output: ct.output, length: ct.length, animating: ct.animating, - setPointer(x: number, y: number, down: boolean) { - let view = new DataView(memory.buffer); - view.setFloat32(opsBuf, x, true); - view.setFloat32(opsBuf + 4, y, true); - ct.Clay_SetPointerState(opsBuf, down ? 1 : 0); - }, getPointerOverIds(): string[] { let decoder = new TextDecoder(); let count = ct.pointer_over_count(); diff --git a/term.ts b/term.ts index c19e283..5cc7443 100644 --- a/term.ts +++ b/term.ts @@ -1,12 +1,14 @@ import { type Op, pack } from "./ops.ts"; -import { type BoundingBox, createTermNative } from "./term-native.ts"; +import { + type BoundingBox, + createTermNative, + POINTER_ABSENT, + POINTER_DOWN, + POINTER_UP, +} 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"; +import { POINTER_SHAPE_RESET } from "./pointer-shape.ts"; export type { BoundingBox }; @@ -165,7 +167,6 @@ export interface Term { export async function createTerm(options: TermOptions): Promise { let { width, height, terminfo } = options; - let emittedShape: PointerShape = "default"; let currentCaps: RuntimeCapabilities = runtimeFromStatic( terminfo?.capabilities ?? { @@ -183,6 +184,7 @@ export async function createTerm(options: TermOptions): Promise { let native = await createTermNative(width, height, { syncOutput: currentCaps.syncOutput, + pointerShape: currentCaps.pointerShape, }); let { memory } = native; @@ -216,12 +218,18 @@ export async function createTerm(options: TermOptions): Promise { dt = now - lastRenderAt; } lastRenderAt = now; - native.reduce(native.statePtr, native.opsBuf, len, mode, row, dt); - - if (options?.pointer) { - let { x, y, down } = options.pointer; - native.setPointer(x, y, down); - } + let pointer = options?.pointer; + native.reduce( + native.statePtr, + native.opsBuf, + len, + mode, + row, + dt, + pointer?.x ?? 0, + pointer?.y ?? 0, + pointer ? (pointer.down ? POINTER_DOWN : POINTER_UP) : POINTER_ABSENT, + ); let output = new Uint8Array( memory.buffer, @@ -229,22 +237,10 @@ export async function createTerm(options: TermOptions): Promise { native.length(native.statePtr), ); - 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 current = new Set( + options?.pointer ? native.getPointerOverIds() : [], + ); + let down = options?.pointer?.down ?? false; let events: PointerEvent[] = []; @@ -327,15 +323,14 @@ export async function createTerm(options: TermOptions): Promise { } if ( - currentCaps.pointerShape && !next.pointerShape && - emittedShape !== "default" + next.syncOutput !== currentCaps.syncOutput || + next.pointerShape !== currentCaps.pointerShape ) { - emittedShape = "default"; - out.push(osc22("default")); - } - - if (next.syncOutput !== currentCaps.syncOutput) { - native.setCapabilities({ syncOutput: next.syncOutput }); + let reset = native.setCapabilities({ + syncOutput: next.syncOutput, + pointerShape: next.pointerShape, + }); + if (reset) out.push(POINTER_SHAPE_RESET.slice()); } currentCaps = next; if (bytes.length) out.push(bytes); diff --git a/test/pointer-shape.test.ts b/test/pointer-shape.test.ts index 60b72a4..c2b918e 100644 --- a/test/pointer-shape.test.ts +++ b/test/pointer-shape.test.ts @@ -11,6 +11,7 @@ import { text, } from "../ops.ts"; import type { CapabilityEvent } from "../input.ts"; +import { POINTER_SHAPES } from "../pointer-shape.ts"; import { print } from "./print.ts"; const SUPPORTED: CapabilityEvent = { @@ -96,20 +97,6 @@ describe("pointer shape", () => { ); }); - 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) => - "cursor" in op - ? Object.defineProperty({ ...op }, "cursor", { - 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([]); @@ -178,6 +165,33 @@ describe("pointer shape", () => { ).toEqual([osc22("progress")]); }); + it("emits every shape in the PointerShape vocabulary by name", () => { + let emitted = POINTER_SHAPES.map((shape) => { + let link = shape === "default" ? "pointer" : shape; + return sequences(term.render(layout({ cursor: link }), LINK).output); + }); + expect(emitted).toEqual( + POINTER_SHAPES.map((shape, i) => + i === 0 ? [osc22("pointer")] : [osc22(shape)] + ), + ); + }); + + it("applies to elements declared without an id", () => { + let ops: Op[] = [ + open("root", { layout: { width: grow(), height: grow() } }), + open("", { + layout: { width: fixed(10), height: fixed(2) }, + cursor: "crosshair", + }), + close(), + close(), + ]; + expect(sequences(term.render(ops, over(1, 1)).output)).toEqual([ + osc22("crosshair"), + ]); + }); + it("ignores values outside the PointerShape vocabulary", () => { let bogus = "x\x1b]0;pwned\x07" as PointerShape; let output = term.render(layout({ cursor: bogus }), LINK).output; diff --git a/test/width.test.ts b/test/width.test.ts index 592adee..94a2083 100644 --- a/test/width.test.ts +++ b/test/width.test.ts @@ -57,7 +57,10 @@ describe("width", () => { }); it("replaces surrogate codepoints from malformed UTF-8 with U+FFFD", async () => { - let native = await createTermNative(10, 3, { syncOutput: false }); + let native = await createTermNative(10, 3, { + syncOutput: false, + pointerShape: false, + }); // TextEncoder never produces surrogate bytes, so pack a placeholder // (U+1234 = E1 88 B4) and patch it to CESU-8 U+D800 (ED A0 80) to @@ -83,7 +86,7 @@ describe("width", () => { buf[at + 1] = 0xa0; buf[at + 2] = 0x80; - native.reduce(native.statePtr, native.opsBuf, len, 0, 1, 0); + native.reduce(native.statePtr, native.opsBuf, len, 0, 1, 0, 0, 0, 0); let out = new Uint8Array( native.memory.buffer, native.output(native.statePtr),