Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Prev Previous commit
Next Next commit
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.
  • Loading branch information
natemoo-re committed Jul 1, 2026
commit 6f6e0be59ac307fcffe64dac869702c4b2254ac8
15 changes: 15 additions & 0 deletions ops.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import type { CursorShape } from "./termcodes.ts";

export type TransitionProperty =
| "x"
| "y"
Expand Down Expand Up @@ -394,6 +396,14 @@ export interface OpenElement {
bottom?: BorderSide;
};
clip?: { horizontal?: boolean; vertical?: boolean };
/**
* Mouse pointer shape to request while the pointer is over this element.
*
* This is a pure annotation: it does not affect layout or output and is not
* sent to the WASM module. It is consumed only when pointer-shape tracking is
* enabled via the `trackCursor` render option.
*/
cursor?: CursorShape;
floating?: {
x?: number;
y?: number;
Expand Down Expand Up @@ -508,6 +518,11 @@ export function close(): CloseElement {
return { directive: OP_CLOSE_ELEMENT };
}

/** Narrow an `Op` to an element-open directive. */
export function isOpen(op: Op): op is OpenElement {
return op.directive === OP_OPEN_ELEMENT;
}

function packSize(ops: Op[]): number {
let n = 0;
for (let op of ops) {
Expand Down
71 changes: 66 additions & 5 deletions term.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,10 @@
import { type Op, pack } from "./ops.ts";
import { isOpen, type Op, pack } from "./ops.ts";
import { type BoundingBox, createTermNative } from "./term-native.ts";
import {
type CursorShape,
POPPOINTERSHAPE,
PUSHPOINTERSHAPE,
} from "./termcodes.ts";

export interface TermOptions {
height: number;
Expand All @@ -26,6 +31,15 @@ export interface RenderOptions {
down: boolean;
};
deltaTime?: number;

/**
* Track the mouse pointer shape across frames. When enabled, the element
* currently under the pointer that declares a `cursor` shape drives the
* terminal's mouse pointer, and {@link RenderResult.cursor} carries the OSC 22
* bytes for any change. Requires `pointer` to be provided for the shape to
* follow the cursor. See the renderer specification, Section 12.6.
*/
trackCursor?: boolean;
}

export type PointerEvent =
Expand Down Expand Up @@ -67,6 +81,14 @@ export interface RenderResult {
info: RenderInfo;
errors: ClayError[];
animating: boolean;

/**
* OSC 22 bytes that update the terminal's mouse pointer shape this frame.
* Present only when `trackCursor` is enabled and the shape changed; write it
* to the terminal separately from `output`. See the renderer specification,
* Section 12.6.
*/
cursor?: Uint8Array;
}

export interface Term {
Expand All @@ -83,6 +105,7 @@ export async function createTerm(options: TermOptions): Promise<Term> {
let wasDown = false;
let lastRenderAt: number | undefined;
let wasAnimating = false;
let cursorShape: CursorShape | null = null;

return {
render(ops: Op[], options?: RenderOptions): RenderResult {
Expand Down Expand Up @@ -112,9 +135,8 @@ export async function createTerm(options: TermOptions): Promise<Term> {
native.length(statePtr),
);

let current = new Set(
options?.pointer ? native.getPointerOverIds() : [],
);
let overIds = options?.pointer ? native.getPointerOverIds() : [];
let current = new Set(overIds);
let down = options?.pointer?.down ?? false;
let events: PointerEvent[] = [];

Expand Down Expand Up @@ -147,6 +169,33 @@ export async function createTerm(options: TermOptions): Promise<Term> {
prev = current;
wasDown = down;

let cursor: Uint8Array | undefined;
if (options?.trackCursor) {
let active: CursorShape | null = null;
if (overIds.length > 0) {
let shapes = new Map<string, CursorShape>();
for (let op of ops) {
if (isOpen(op) && op.cursor) shapes.set(op.id, op.cursor);
}
// pointerOverIds is outermost-first; the innermost (topmost)
// declaring element wins, so scan from the end.
for (let i = overIds.length - 1; i >= 0; i--) {
let shape = shapes.get(overIds[i]);
if (shape) {
active = shape;
break;
}
}
}
if (active !== cursorShape) {
let parts: Uint8Array[] = [];
if (cursorShape !== null) parts.push(POPPOINTERSHAPE());
if (active !== null) parts.push(PUSHPOINTERSHAPE(active));
cursor = concat(parts);
cursorShape = active;
}
}

Comment on lines +168 to +194

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

the reset only happens inside this block, so if a caller stops passing trackCursor while cursorShape is non def, nothing emits POINTERSHAPE("default") and the terminal keeps the custom pointer. same on exit, set-only can't self restore, so a non def shape just lingers.

could track whether trackCursor was on last frame and emit one POINTERSHAPE("default") on the on off flip. the exit case probably just needs a doc note that the caller resets before teardown. fine as a follow-up if you want to keep this scoped.

let info: RenderInfo = {
get(id: string): ElementInfo | undefined {
let bounds = native.getElementBounds(id);
Expand All @@ -169,7 +218,19 @@ export async function createTerm(options: TermOptions): Promise<Term> {

let animating = native.animating(statePtr) > 0;
wasAnimating = animating;
return { output, events, info, errors, animating };
return { output, events, info, errors, animating, cursor };
},
};
}

function concat(parts: Uint8Array[]): Uint8Array {
let total = 0;
for (let part of parts) total += part.length;
let out = new Uint8Array(total);
let offset = 0;
for (let part of parts) {
out.set(part, offset);
offset += part.length;
}
return out;
}
102 changes: 102 additions & 0 deletions termcodes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,108 @@ export function MAINSCREEN(): Uint8Array {
return CSI("?1049l");
}

/**
* A mouse pointer shape, named with the CSS `cursor` keyword vocabulary.
*
* These are the values understood by terminals implementing the OSC 22
* pointer-shape protocol (kitty, Ghostty). Terminals that do not recognize a
* given shape ignore it.
*
* @see {@link https://developer.mozilla.org/en-US/docs/Web/CSS/cursor | CSS cursor}
* @see {@link https://sw.kovidgoyal.net/kitty/pointer-shapes/ | kitty pointer shapes}
*/
export type CursorShape =
| "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"
| "col-resize"
| "row-resize"
| "all-scroll"
| "zoom-in"
| "zoom-out";

/**
* Encode an Operating System Command (OSC).
*
* Wraps the given string as `ESC ] str ST`, where ST is the String Terminator
* (`ESC \`).
*
* @see {@link https://www.ecma-international.org/publications-and-standards/standards/ecma-48/ | ECMA-48}
*/
export function OSC(str: string): Uint8Array {
return encode(`\x1b]${str}\x1b\\`);
}

/**
* Set the mouse pointer shape (OSC 22).
*
* Replaces the current pointer shape. Prefer {@link PUSHPOINTERSHAPE} /
* {@link POPPOINTERSHAPE} when you want the terminal's prior shape restored.
*
* @see {@link https://sw.kovidgoyal.net/kitty/pointer-shapes/ | kitty pointer shapes}
*/
export function POINTERSHAPE(shape: CursorShape): Uint8Array {
return OSC(`22;${shape}`);
}

/**
* Push a mouse pointer shape onto the terminal's pointer-shape stack (OSC 22).
*
* The pushed shape becomes current; {@link POPPOINTERSHAPE} restores whatever
* was current before. This is the kitty stack extension and is how shapes are
* saved and restored without querying the terminal's prior shape.
*/
export function PUSHPOINTERSHAPE(shape: CursorShape): Uint8Array {
return OSC(`22;>${shape}`);
}

/**
* Pop the top mouse pointer shape off the stack (OSC 22), restoring the shape
* that was current before the matching {@link PUSHPOINTERSHAPE}.
*/
export function POPPOINTERSHAPE(): Uint8Array {
return OSC("22;<");
}

/**
* Query the terminal's mouse pointer shape support (OSC 22).
*
* With no arguments, asks for the current shape (`?__current__`). With one or
* more shape names, asks which are supported. The terminal replies on the
* input stream; the reply is decoded as a `PointerShapeEvent` (see the input
* parser). Terminals without query support never reply.
*/
export function QUERYPOINTERSHAPE(...shapes: CursorShape[]): Uint8Array {
return OSC(`22;?${shapes.length > 0 ? shapes.join(",") : "__current__"}`);
}

const encoder = new TextEncoder();

function encode(str: string): Uint8Array {
Expand Down
Loading