[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.

fix(term): use bare set OSC 22 so pointer shapes work on Ghostty

The tracker emitted the kitty push/pop stack form (ESC]22;>shape and
ESC]22;<). Ghostty's OSC 22 parser treats the whole payload after "22;"
as a literal shape name, so ">shape" is not a valid shape and is dropped
— the pointer never changed on Ghostty (and any other set-only terminal).

Switch to the portable bare set form: set the shape on enter
(ESC]22;shape) and restore the base by setting "default" on leave. kitty
and Ghostty both honor this. The trade-off is that we assume the base
shape is "default" rather than restoring a non-default prior shape; the
push/pop helpers remain exported for callers that target kitty and want
exact save/restore.

Nate Moore (Jul 1, 2026, 4:09 PM EDT) 4b618f30 472e78b7

+32 -45
+15 -11
specs/renderer-spec.md
··· 974 974 4. When the shape changed, populates `result.cursor` with the OSC 22 bytes that 975 975 effect the transition. When nothing changed, `result.cursor` is absent. 976 976 977 - **Save and restore (kitty stack).** Transitions use the kitty pointer-shape 978 - _stack_ rather than bare set, so the terminal's prior shape is preserved: 977 + **Setting and restoring.** Transitions use the bare _set_ form of OSC 22 for 978 + portability — kitty and Ghostty both honor it, whereas the kitty push/pop 979 + _stack_ extension is silently ignored by set-only terminals (Ghostty parses the 980 + whole payload after `22;` as a literal shape name, so a `>`/`<` prefix is not a 981 + valid shape and is dropped): 979 982 980 - - Entering an element with a declared shape pushes it (`OSC 22 ; >shape ST`). 981 - - Returning to no declared shape pops back to what the terminal had before 982 - (`OSC 22 ; < ST`). 983 + - Entering an element with a declared shape sets it (`OSC 22 ; shape ST`). 984 + - Returning to no declared shape restores the base by setting `default` 985 + (`OSC 22 ; default ST`). 983 986 984 - This means the renderer never needs to know or assume the terminal's base shape; 985 - the stack restores it. 987 + The base shape is assumed to be `default` (the ordinary pointer); the renderer 988 + does not attempt to restore a non-default prior shape. Callers targeting kitty 989 + exclusively who want exact save/restore can drive the push/pop helpers manually. 986 990 987 991 **Capability detection and graceful degradation.** Before relying on tracking, 988 992 the caller MAY query support. The OSC 22 query is sent through the normal output ··· 990 994 and the terminal's reply arrives on the **input** stream, where it is decoded as 991 995 a `PointerShapeEvent` (see [Input Specification](input-spec.md), Section 5.1). 992 996 Correlating the reply with the query is the caller's responsibility, preserving 993 - the renderer/input independence (INV-7). Terminals that do not implement OSC 22 994 - (or implement only the set operation, such as Ghostty) never reply and may not 995 - honor push/pop; on these terminals tracking degrades to a no-op or a best-effort 996 - set, and the absence of a reply within a timeout is the unsupported signal. 997 + the renderer/input independence (INV-7). Because tracking uses the bare set 998 + form, it works on set-only terminals (such as Ghostty) as well as kitty; only 999 + terminals that do not implement OSC 22 at all ignore it entirely, and the 1000 + absence of a reply within a timeout is the unsupported signal. 997 1001 998 1002 **OSC 22 byte helpers.** The byte sequences above are produced by small, 999 1003 caller-usable helpers (set, push, pop, and query builders). These are the first
+7 -23
term.ts
··· 1 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 + import { type CursorShape, POINTERSHAPE } from "./termcodes.ts"; 8 4 9 5 export interface TermOptions { 10 6 height: number; ··· 105 101 let wasDown = false; 106 102 let lastRenderAt: number | undefined; 107 103 let wasAnimating = false; 108 - let cursorShape: CursorShape | null = null; 104 + let cursorShape: CursorShape = "default"; 109 105 110 106 return { 111 107 render(ops: Op[], options?: RenderOptions): RenderResult { ··· 171 167 172 168 let cursor: Uint8Array | undefined; 173 169 if (options?.trackCursor) { 174 - let active: CursorShape | null = null; 170 + // Set-only OSC 22: the base is "default" (kitty and Ghostty both honor 171 + // a bare set; the kitty push/pop stack is ignored by set-only terminals 172 + // like Ghostty). 173 + let active: CursorShape = "default"; 175 174 if (overIds.length > 0) { 176 175 let shapes = new Map<string, CursorShape>(); 177 176 for (let op of ops) { ··· 188 187 } 189 188 } 190 189 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); 190 + cursor = POINTERSHAPE(active); 195 191 cursorShape = active; 196 192 } 197 193 } ··· 222 218 }, 223 219 }; 224 220 } 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 - }
+10 -11
test/cursor.test.ts
··· 8 8 return bytes === undefined ? undefined : decoder.decode(bytes); 9 9 } 10 10 11 - const PUSH = (shape: string) => `\x1b]22;>${shape}\x1b\\`; 12 - const POP = `\x1b]22;<\x1b\\`; 11 + const SET = (shape: string) => `\x1b]22;${shape}\x1b\\`; 13 12 14 13 // ┌─root (40x10, ltr)──────────────────┐ 15 14 // │┌─btn (20x10)──┐┌─field (20x10)───┐│ ··· 51 50 expect(result.cursor).toBeUndefined(); 52 51 }); 53 52 54 - it("pushes the shape when the pointer enters a declaring element", () => { 53 + it("sets the shape when the pointer enters a declaring element", () => { 55 54 let result = term.render(layout(), { 56 55 pointer: { x: 5, y: 5, down: false }, 57 56 trackCursor: true, 58 57 }); 59 - expect(shown(result.cursor)).toBe(PUSH("pointer")); 58 + expect(shown(result.cursor)).toBe(SET("pointer")); 60 59 }); 61 60 62 61 it("emits nothing on a subsequent frame over the same element", () => { ··· 71 70 expect(result.cursor).toBeUndefined(); 72 71 }); 73 72 74 - it("pops then pushes when moving between elements of different shapes", () => { 73 + it("sets the new shape when moving between elements of different shapes", () => { 75 74 term.render(layout(), { 76 75 pointer: { x: 5, y: 5, down: false }, 77 76 trackCursor: true, ··· 80 79 pointer: { x: 25, y: 5, down: false }, 81 80 trackCursor: true, 82 81 }); 83 - expect(shown(result.cursor)).toBe(POP + PUSH("text")); 82 + expect(shown(result.cursor)).toBe(SET("text")); 84 83 }); 85 84 86 - it("pops when the pointer leaves all declaring elements", () => { 85 + it("restores default when the pointer leaves all declaring elements", () => { 87 86 term.render(layout(), { 88 87 pointer: { x: 5, y: 5, down: false }, 89 88 trackCursor: true, ··· 92 91 pointer: { x: 100, y: 100, down: false }, 93 92 trackCursor: true, 94 93 }); 95 - expect(shown(result.cursor)).toBe(POP); 94 + expect(shown(result.cursor)).toBe(SET("default")); 96 95 }); 97 96 98 - it("pops when the pointer is removed entirely", () => { 97 + it("restores default when the pointer is removed entirely", () => { 99 98 term.render(layout(), { 100 99 pointer: { x: 5, y: 5, down: false }, 101 100 trackCursor: true, 102 101 }); 103 102 let result = term.render(layout(), { trackCursor: true }); 104 - expect(shown(result.cursor)).toBe(POP); 103 + expect(shown(result.cursor)).toBe(SET("default")); 105 104 }); 106 105 107 106 it("uses the topmost (innermost) declaring element's shape", () => { ··· 123 122 pointer: { x: 2, y: 2, down: false }, 124 123 trackCursor: true, 125 124 }); 126 - expect(shown(result.cursor)).toBe(PUSH("pointer")); 125 + expect(shown(result.cursor)).toBe(SET("pointer")); 127 126 }); 128 127 129 128 it("emits nothing when the hovered element declares no shape", () => {