Skip to content

Commit f07a9ed

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 219beb4 commit f07a9ed

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
@@ -889,26 +889,30 @@ if (r.cursor) stdout.write(r.cursor);
889889
4. When the shape changed, populates `result.cursor` with the OSC 22 bytes that
890890
effect the transition. When nothing changed, `result.cursor` is absent.
891891

892-
**Save and restore (kitty stack).** Transitions use the kitty pointer-shape
893-
_stack_ rather than bare set, so the terminal's prior shape is preserved:
892+
**Setting and restoring.** Transitions use the bare _set_ form of OSC 22 for
893+
portability — kitty and Ghostty both honor it, whereas the kitty push/pop
894+
_stack_ extension is silently ignored by set-only terminals (Ghostty parses the
895+
whole payload after `22;` as a literal shape name, so a `>`/`<` prefix is not a
896+
valid shape and is dropped):
894897

895-
- Entering an element with a declared shape pushes it (`OSC 22 ; >shape ST`).
896-
- Returning to no declared shape pops back to what the terminal had before
897-
(`OSC 22 ; < ST`).
898+
- Entering an element with a declared shape sets it (`OSC 22 ; shape ST`).
899+
- Returning to no declared shape restores the base by setting `default`
900+
(`OSC 22 ; default ST`).
898901

899-
This means the renderer never needs to know or assume the terminal's base shape;
900-
the stack restores it.
902+
The base shape is assumed to be `default` (the ordinary pointer); the renderer
903+
does not attempt to restore a non-default prior shape. Callers targeting kitty
904+
exclusively who want exact save/restore can drive the push/pop helpers manually.
901905

902906
**Capability detection and graceful degradation.** Before relying on tracking,
903907
the caller MAY query support. The OSC 22 query is sent through the normal output
904908
path (it is a separate, caller-initiated byte sequence, not part of `output`),
905909
and the terminal's reply arrives on the **input** stream, where it is decoded as
906910
a `PointerShapeEvent` (see [Input Specification](input-spec.md), Section 5.1).
907911
Correlating the reply with the query is the caller's responsibility, preserving
908-
the renderer/input independence (INV-7). Terminals that do not implement OSC 22
909-
(or implement only the set operation, such as Ghostty) never reply and may not
910-
honor push/pop; on these terminals tracking degrades to a no-op or a best-effort
911-
set, and the absence of a reply within a timeout is the unsupported signal.
912+
the renderer/input independence (INV-7). Because tracking uses the bare set
913+
form, it works on set-only terminals (such as Ghostty) as well as kitty; only
914+
terminals that do not implement OSC 22 at all ignore it entirely, and the
915+
absence of a reply within a timeout is the unsupported signal.
912916

913917
**OSC 22 byte helpers.** The byte sequences above are produced by small,
914918
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;
@@ -100,7 +96,7 @@ export async function createTerm(options: TermOptions): Promise<Term> {
10096
let prev = new Set<string>();
10197
let pressed = new Set<string>();
10298
let wasDown = false;
103-
let cursorShape: CursorShape | null = null;
99+
let cursorShape: CursorShape = "default";
104100

105101
return {
106102
render(ops: Op[], options?: RenderOptions): RenderResult {
@@ -156,7 +152,10 @@ export async function createTerm(options: TermOptions): Promise<Term> {
156152

157153
let cursor: Uint8Array | undefined;
158154
if (options?.trackCursor) {
159-
let active: CursorShape | null = null;
155+
// Set-only OSC 22: the base is "default" (kitty and Ghostty both honor
156+
// a bare set; the kitty push/pop stack is ignored by set-only terminals
157+
// like Ghostty).
158+
let active: CursorShape = "default";
160159
if (overIds.length > 0) {
161160
let shapes = new Map<string, CursorShape>();
162161
for (let op of ops) {
@@ -173,10 +172,7 @@ export async function createTerm(options: TermOptions): Promise<Term> {
173172
}
174173
}
175174
if (active !== cursorShape) {
176-
let parts: Uint8Array[] = [];
177-
if (cursorShape !== null) parts.push(POPPOINTERSHAPE());
178-
if (active !== null) parts.push(PUSHPOINTERSHAPE(active));
179-
cursor = concat(parts);
175+
cursor = POINTERSHAPE(active);
180176
cursorShape = active;
181177
}
182178
}
@@ -205,15 +201,3 @@ export async function createTerm(options: TermOptions): Promise<Term> {
205201
},
206202
};
207203
}
208-
209-
function concat(parts: Uint8Array[]): Uint8Array {
210-
let total = 0;
211-
for (let part of parts) total += part.length;
212-
let out = new Uint8Array(total);
213-
let offset = 0;
214-
for (let part of parts) {
215-
out.set(part, offset);
216-
offset += part.length;
217-
}
218-
return out;
219-
}

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)