[READ-ONLY] Mirror of https://github.com/bombshell-dev/tty. Platform independent 2D layout engine for terminal applications based on Clay
5

Configure Feed

Select the types of activity you want to include in your feed.

feat(term): opt-in OSC 22 pointer-shape tracking (renderer-spec §12.6)

Elements declare a CSS-style `cursor` shape on open(); it is a pure
annotation, never packed to the WASM module. With `trackCursor: true`,
render() finds the topmost element under the pointer that declares a
cursor and returns the OSC 22 bytes for any change in `result.cursor`,
kept separate from `output` so render content stays pure (§11.2).

Save/restore uses the kitty pointer-shape stack: enter pushes, leave
pops, so the terminal's prior shape is restored without a query. All
tracking state lives in the TS term layer alongside the existing
pointer-enter/leave bookkeeping; the wasm core is untouched. Pointer-over
ids are outermost-first, so topmost-wins scans from the end.

Adds set/push/pop/query OSC 22 byte helpers and a CursorShape (CSS
cursor keyword) type to termcodes.

Nate Moore (Jul 1, 2026, 4:09 PM EDT) 6f6e0be5 9d123590

+324 -5
+15
ops.ts
··· 1 + import type { CursorShape } from "./termcodes.ts"; 2 + 1 3 export type TransitionProperty = 2 4 | "x" 3 5 | "y" ··· 394 396 bottom?: BorderSide; 395 397 }; 396 398 clip?: { horizontal?: boolean; vertical?: boolean }; 399 + /** 400 + * Mouse pointer shape to request while the pointer is over this element. 401 + * 402 + * This is a pure annotation: it does not affect layout or output and is not 403 + * sent to the WASM module. It is consumed only when pointer-shape tracking is 404 + * enabled via the `trackCursor` render option. 405 + */ 406 + cursor?: CursorShape; 397 407 floating?: { 398 408 x?: number; 399 409 y?: number; ··· 506 516 507 517 export function close(): CloseElement { 508 518 return { directive: OP_CLOSE_ELEMENT }; 519 + } 520 + 521 + /** Narrow an `Op` to an element-open directive. */ 522 + export function isOpen(op: Op): op is OpenElement { 523 + return op.directive === OP_OPEN_ELEMENT; 509 524 } 510 525 511 526 function packSize(ops: Op[]): number {
+66 -5
term.ts
··· 1 - import { type Op, pack } from "./ops.ts"; 1 + import { isOpen, type Op, pack } from "./ops.ts"; 2 2 import { type BoundingBox, createTermNative } from "./term-native.ts"; 3 + import { 4 + type CursorShape, 5 + POPPOINTERSHAPE, 6 + PUSHPOINTERSHAPE, 7 + } from "./termcodes.ts"; 3 8 4 9 export interface TermOptions { 5 10 height: number; ··· 26 31 down: boolean; 27 32 }; 28 33 deltaTime?: number; 34 + 35 + /** 36 + * Track the mouse pointer shape across frames. When enabled, the element 37 + * currently under the pointer that declares a `cursor` shape drives the 38 + * terminal's mouse pointer, and {@link RenderResult.cursor} carries the OSC 22 39 + * bytes for any change. Requires `pointer` to be provided for the shape to 40 + * follow the cursor. See the renderer specification, Section 12.6. 41 + */ 42 + trackCursor?: boolean; 29 43 } 30 44 31 45 export type PointerEvent = ··· 67 81 info: RenderInfo; 68 82 errors: ClayError[]; 69 83 animating: boolean; 84 + 85 + /** 86 + * OSC 22 bytes that update the terminal's mouse pointer shape this frame. 87 + * Present only when `trackCursor` is enabled and the shape changed; write it 88 + * to the terminal separately from `output`. See the renderer specification, 89 + * Section 12.6. 90 + */ 91 + cursor?: Uint8Array; 70 92 } 71 93 72 94 export interface Term { ··· 83 105 let wasDown = false; 84 106 let lastRenderAt: number | undefined; 85 107 let wasAnimating = false; 108 + let cursorShape: CursorShape | null = null; 86 109 87 110 return { 88 111 render(ops: Op[], options?: RenderOptions): RenderResult { ··· 112 135 native.length(statePtr), 113 136 ); 114 137 115 - let current = new Set( 116 - options?.pointer ? native.getPointerOverIds() : [], 117 - ); 138 + let overIds = options?.pointer ? native.getPointerOverIds() : []; 139 + let current = new Set(overIds); 118 140 let down = options?.pointer?.down ?? false; 119 141 let events: PointerEvent[] = []; 120 142 ··· 147 169 prev = current; 148 170 wasDown = down; 149 171 172 + let cursor: Uint8Array | undefined; 173 + if (options?.trackCursor) { 174 + let active: CursorShape | null = null; 175 + if (overIds.length > 0) { 176 + let shapes = new Map<string, CursorShape>(); 177 + for (let op of ops) { 178 + if (isOpen(op) && op.cursor) shapes.set(op.id, op.cursor); 179 + } 180 + // pointerOverIds is outermost-first; the innermost (topmost) 181 + // declaring element wins, so scan from the end. 182 + for (let i = overIds.length - 1; i >= 0; i--) { 183 + let shape = shapes.get(overIds[i]); 184 + if (shape) { 185 + active = shape; 186 + break; 187 + } 188 + } 189 + } 190 + if (active !== cursorShape) { 191 + let parts: Uint8Array[] = []; 192 + if (cursorShape !== null) parts.push(POPPOINTERSHAPE()); 193 + if (active !== null) parts.push(PUSHPOINTERSHAPE(active)); 194 + cursor = concat(parts); 195 + cursorShape = active; 196 + } 197 + } 198 + 150 199 let info: RenderInfo = { 151 200 get(id: string): ElementInfo | undefined { 152 201 let bounds = native.getElementBounds(id); ··· 169 218 170 219 let animating = native.animating(statePtr) > 0; 171 220 wasAnimating = animating; 172 - return { output, events, info, errors, animating }; 221 + return { output, events, info, errors, animating, cursor }; 173 222 }, 174 223 }; 175 224 } 225 + 226 + function concat(parts: Uint8Array[]): Uint8Array { 227 + let total = 0; 228 + for (let part of parts) total += part.length; 229 + let out = new Uint8Array(total); 230 + let offset = 0; 231 + for (let part of parts) { 232 + out.set(part, offset); 233 + offset += part.length; 234 + } 235 + return out; 236 + }
+102
termcodes.ts
··· 85 85 return CSI("?1049l"); 86 86 } 87 87 88 + /** 89 + * A mouse pointer shape, named with the CSS `cursor` keyword vocabulary. 90 + * 91 + * These are the values understood by terminals implementing the OSC 22 92 + * pointer-shape protocol (kitty, Ghostty). Terminals that do not recognize a 93 + * given shape ignore it. 94 + * 95 + * @see {@link https://developer.mozilla.org/en-US/docs/Web/CSS/cursor | CSS cursor} 96 + * @see {@link https://sw.kovidgoyal.net/kitty/pointer-shapes/ | kitty pointer shapes} 97 + */ 98 + export type CursorShape = 99 + | "default" 100 + | "none" 101 + | "context-menu" 102 + | "help" 103 + | "pointer" 104 + | "progress" 105 + | "wait" 106 + | "cell" 107 + | "crosshair" 108 + | "text" 109 + | "vertical-text" 110 + | "alias" 111 + | "copy" 112 + | "move" 113 + | "no-drop" 114 + | "not-allowed" 115 + | "grab" 116 + | "grabbing" 117 + | "e-resize" 118 + | "n-resize" 119 + | "ne-resize" 120 + | "nw-resize" 121 + | "s-resize" 122 + | "se-resize" 123 + | "sw-resize" 124 + | "w-resize" 125 + | "ew-resize" 126 + | "ns-resize" 127 + | "nesw-resize" 128 + | "nwse-resize" 129 + | "col-resize" 130 + | "row-resize" 131 + | "all-scroll" 132 + | "zoom-in" 133 + | "zoom-out"; 134 + 135 + /** 136 + * Encode an Operating System Command (OSC). 137 + * 138 + * Wraps the given string as `ESC ] str ST`, where ST is the String Terminator 139 + * (`ESC \`). 140 + * 141 + * @see {@link https://www.ecma-international.org/publications-and-standards/standards/ecma-48/ | ECMA-48} 142 + */ 143 + export function OSC(str: string): Uint8Array { 144 + return encode(`\x1b]${str}\x1b\\`); 145 + } 146 + 147 + /** 148 + * Set the mouse pointer shape (OSC 22). 149 + * 150 + * Replaces the current pointer shape. Prefer {@link PUSHPOINTERSHAPE} / 151 + * {@link POPPOINTERSHAPE} when you want the terminal's prior shape restored. 152 + * 153 + * @see {@link https://sw.kovidgoyal.net/kitty/pointer-shapes/ | kitty pointer shapes} 154 + */ 155 + export function POINTERSHAPE(shape: CursorShape): Uint8Array { 156 + return OSC(`22;${shape}`); 157 + } 158 + 159 + /** 160 + * Push a mouse pointer shape onto the terminal's pointer-shape stack (OSC 22). 161 + * 162 + * The pushed shape becomes current; {@link POPPOINTERSHAPE} restores whatever 163 + * was current before. This is the kitty stack extension and is how shapes are 164 + * saved and restored without querying the terminal's prior shape. 165 + */ 166 + export function PUSHPOINTERSHAPE(shape: CursorShape): Uint8Array { 167 + return OSC(`22;>${shape}`); 168 + } 169 + 170 + /** 171 + * Pop the top mouse pointer shape off the stack (OSC 22), restoring the shape 172 + * that was current before the matching {@link PUSHPOINTERSHAPE}. 173 + */ 174 + export function POPPOINTERSHAPE(): Uint8Array { 175 + return OSC("22;<"); 176 + } 177 + 178 + /** 179 + * Query the terminal's mouse pointer shape support (OSC 22). 180 + * 181 + * With no arguments, asks for the current shape (`?__current__`). With one or 182 + * more shape names, asks which are supported. The terminal replies on the 183 + * input stream; the reply is decoded as a `PointerShapeEvent` (see the input 184 + * parser). Terminals without query support never reply. 185 + */ 186 + export function QUERYPOINTERSHAPE(...shapes: CursorShape[]): Uint8Array { 187 + return OSC(`22;?${shapes.length > 0 ? shapes.join(",") : "__current__"}`); 188 + } 189 + 88 190 const encoder = new TextEncoder(); 89 191 90 192 function encode(str: string): Uint8Array {
+141
test/cursor.test.ts
··· 1 + import { beforeEach, describe, expect, it } from "./suite.ts"; 2 + import { createTerm, type Term } from "../term.ts"; 3 + import { close, fixed, grow, open, text } from "../ops.ts"; 4 + 5 + const decoder = new TextDecoder(); 6 + 7 + function shown(bytes: Uint8Array | undefined): string | undefined { 8 + return bytes === undefined ? undefined : decoder.decode(bytes); 9 + } 10 + 11 + const PUSH = (shape: string) => `\x1b]22;>${shape}\x1b\\`; 12 + const POP = `\x1b]22;<\x1b\\`; 13 + 14 + // ┌─root (40x10, ltr)──────────────────┐ 15 + // │┌─btn (20x10)──┐┌─field (20x10)───┐│ 16 + // ││ cursor:pointer ││ cursor:text ││ 17 + // │└───────────────┘└────────────────┘│ 18 + // └───────────────────────────────────┘ 19 + function layout() { 20 + return [ 21 + open("root", { 22 + layout: { width: grow(), height: grow(), direction: "ltr" }, 23 + }), 24 + open("btn", { 25 + layout: { width: fixed(20), height: fixed(10) }, 26 + cursor: "pointer", 27 + }), 28 + text("B"), 29 + close(), 30 + open("field", { 31 + layout: { width: fixed(20), height: fixed(10) }, 32 + cursor: "text", 33 + }), 34 + text("F"), 35 + close(), 36 + close(), 37 + ]; 38 + } 39 + 40 + describe("pointer shape tracking", () => { 41 + let term: Term; 42 + 43 + beforeEach(async () => { 44 + term = await createTerm({ width: 40, height: 10 }); 45 + }); 46 + 47 + it("emits no cursor field when trackCursor is not enabled", () => { 48 + let result = term.render(layout(), { 49 + pointer: { x: 5, y: 5, down: false }, 50 + }); 51 + expect(result.cursor).toBeUndefined(); 52 + }); 53 + 54 + it("pushes the shape when the pointer enters a declaring element", () => { 55 + let result = term.render(layout(), { 56 + pointer: { x: 5, y: 5, down: false }, 57 + trackCursor: true, 58 + }); 59 + expect(shown(result.cursor)).toBe(PUSH("pointer")); 60 + }); 61 + 62 + it("emits nothing on a subsequent frame over the same element", () => { 63 + term.render(layout(), { 64 + pointer: { x: 5, y: 5, down: false }, 65 + trackCursor: true, 66 + }); 67 + let result = term.render(layout(), { 68 + pointer: { x: 6, y: 5, down: false }, 69 + trackCursor: true, 70 + }); 71 + expect(result.cursor).toBeUndefined(); 72 + }); 73 + 74 + it("pops then pushes when moving between elements of different shapes", () => { 75 + term.render(layout(), { 76 + pointer: { x: 5, y: 5, down: false }, 77 + trackCursor: true, 78 + }); 79 + let result = term.render(layout(), { 80 + pointer: { x: 25, y: 5, down: false }, 81 + trackCursor: true, 82 + }); 83 + expect(shown(result.cursor)).toBe(POP + PUSH("text")); 84 + }); 85 + 86 + it("pops when the pointer leaves all declaring elements", () => { 87 + term.render(layout(), { 88 + pointer: { x: 5, y: 5, down: false }, 89 + trackCursor: true, 90 + }); 91 + let result = term.render(layout(), { 92 + pointer: { x: 100, y: 100, down: false }, 93 + trackCursor: true, 94 + }); 95 + expect(shown(result.cursor)).toBe(POP); 96 + }); 97 + 98 + it("pops when the pointer is removed entirely", () => { 99 + term.render(layout(), { 100 + pointer: { x: 5, y: 5, down: false }, 101 + trackCursor: true, 102 + }); 103 + let result = term.render(layout(), { trackCursor: true }); 104 + expect(shown(result.cursor)).toBe(POP); 105 + }); 106 + 107 + it("uses the topmost (innermost) declaring element's shape", () => { 108 + // root declares "default"; the inner box declares "pointer". 109 + let nested = () => [ 110 + open("root", { 111 + layout: { width: grow(), height: grow() }, 112 + cursor: "default", 113 + }), 114 + open("inner", { 115 + layout: { width: fixed(10), height: fixed(5) }, 116 + cursor: "pointer", 117 + }), 118 + text("x"), 119 + close(), 120 + close(), 121 + ]; 122 + let result = term.render(nested(), { 123 + pointer: { x: 2, y: 2, down: false }, 124 + trackCursor: true, 125 + }); 126 + expect(shown(result.cursor)).toBe(PUSH("pointer")); 127 + }); 128 + 129 + it("emits nothing when the hovered element declares no shape", () => { 130 + let plain = () => [ 131 + open("root", { layout: { width: grow(), height: grow() } }), 132 + text("x"), 133 + close(), 134 + ]; 135 + let result = term.render(plain(), { 136 + pointer: { x: 2, y: 2, down: false }, 137 + trackCursor: true, 138 + }); 139 + expect(result.cursor).toBeUndefined(); 140 + }); 141 + });