Skip to content

Commit 4b618f3

Browse files
committed
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.
1 parent 472e78b commit 4b618f3

3 files changed

Lines changed: 32 additions & 45 deletions

File tree

specs/renderer-spec.md

Lines changed: 15 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -974,26 +974,30 @@ if (r.cursor) stdout.write(r.cursor);
974974
4. When the shape changed, populates `result.cursor` with the OSC 22 bytes that
975975
effect the transition. When nothing changed, `result.cursor` is absent.
976976

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):
979982

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`).
983986

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

987991
**Capability detection and graceful degradation.** Before relying on tracking,
988992
the caller MAY query support. The OSC 22 query is sent through the normal output
989993
path (it is a separate, caller-initiated byte sequence, not part of `output`),
990994
and the terminal's reply arrives on the **input** stream, where it is decoded as
991995
a `PointerShapeEvent` (see [Input Specification](input-spec.md), Section 5.1).
992996
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.
9971001

9981002
**OSC 22 byte helpers.** The byte sequences above are produced by small,
9991003
caller-usable helpers (set, push, pop, and query builders). These are the first

term.ts

Lines changed: 7 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,6 @@
11
import { isOpen, type Op, pack } from "./ops.ts";
22
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";
84

95
export interface TermOptions {
106
height: number;
@@ -105,7 +101,7 @@ export async function createTerm(options: TermOptions): Promise<Term> {
105101
let wasDown = false;
106102
let lastRenderAt: number | undefined;
107103
let wasAnimating = false;
108-
let cursorShape: CursorShape | null = null;
104+
let cursorShape: CursorShape = "default";
109105

110106
return {
111107
render(ops: Op[], options?: RenderOptions): RenderResult {
@@ -171,7 +167,10 @@ export async function createTerm(options: TermOptions): Promise<Term> {
171167

172168
let cursor: Uint8Array | undefined;
173169
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";
175174
if (overIds.length > 0) {
176175
let shapes = new Map<string, CursorShape>();
177176
for (let op of ops) {
@@ -188,10 +187,7 @@ export async function createTerm(options: TermOptions): Promise<Term> {
188187
}
189188
}
190189
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);
195191
cursorShape = active;
196192
}
197193
}
@@ -222,15 +218,3 @@ export async function createTerm(options: TermOptions): Promise<Term> {
222218
},
223219
};
224220
}
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-
}

test/cursor.test.ts

Lines changed: 10 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -8,8 +8,7 @@ function shown(bytes: Uint8Array | undefined): string | undefined {
88
return bytes === undefined ? undefined : decoder.decode(bytes);
99
}
1010

11-
const PUSH = (shape: string) => `\x1b]22;>${shape}\x1b\\`;
12-
const POP = `\x1b]22;<\x1b\\`;
11+
const SET = (shape: string) => `\x1b]22;${shape}\x1b\\`;
1312

1413
// ┌─root (40x10, ltr)──────────────────┐
1514
// │┌─btn (20x10)──┐┌─field (20x10)───┐│
@@ -51,12 +50,12 @@ describe("pointer shape tracking", () => {
5150
expect(result.cursor).toBeUndefined();
5251
});
5352

54-
it("pushes the shape when the pointer enters a declaring element", () => {
53+
it("sets the shape when the pointer enters a declaring element", () => {
5554
let result = term.render(layout(), {
5655
pointer: { x: 5, y: 5, down: false },
5756
trackCursor: true,
5857
});
59-
expect(shown(result.cursor)).toBe(PUSH("pointer"));
58+
expect(shown(result.cursor)).toBe(SET("pointer"));
6059
});
6160

6261
it("emits nothing on a subsequent frame over the same element", () => {
@@ -71,7 +70,7 @@ describe("pointer shape tracking", () => {
7170
expect(result.cursor).toBeUndefined();
7271
});
7372

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", () => {
7574
term.render(layout(), {
7675
pointer: { x: 5, y: 5, down: false },
7776
trackCursor: true,
@@ -80,10 +79,10 @@ describe("pointer shape tracking", () => {
8079
pointer: { x: 25, y: 5, down: false },
8180
trackCursor: true,
8281
});
83-
expect(shown(result.cursor)).toBe(POP + PUSH("text"));
82+
expect(shown(result.cursor)).toBe(SET("text"));
8483
});
8584

86-
it("pops when the pointer leaves all declaring elements", () => {
85+
it("restores default when the pointer leaves all declaring elements", () => {
8786
term.render(layout(), {
8887
pointer: { x: 5, y: 5, down: false },
8988
trackCursor: true,
@@ -92,16 +91,16 @@ describe("pointer shape tracking", () => {
9291
pointer: { x: 100, y: 100, down: false },
9392
trackCursor: true,
9493
});
95-
expect(shown(result.cursor)).toBe(POP);
94+
expect(shown(result.cursor)).toBe(SET("default"));
9695
});
9796

98-
it("pops when the pointer is removed entirely", () => {
97+
it("restores default when the pointer is removed entirely", () => {
9998
term.render(layout(), {
10099
pointer: { x: 5, y: 5, down: false },
101100
trackCursor: true,
102101
});
103102
let result = term.render(layout(), { trackCursor: true });
104-
expect(shown(result.cursor)).toBe(POP);
103+
expect(shown(result.cursor)).toBe(SET("default"));
105104
});
106105

107106
it("uses the topmost (innermost) declaring element's shape", () => {
@@ -123,7 +122,7 @@ describe("pointer shape tracking", () => {
123122
pointer: { x: 2, y: 2, down: false },
124123
trackCursor: true,
125124
});
126-
expect(shown(result.cursor)).toBe(PUSH("pointer"));
125+
expect(shown(result.cursor)).toBe(SET("pointer"));
127126
});
128127

129128
it("emits nothing when the hovered element declares no shape", () => {

0 commit comments

Comments
 (0)