Skip to content

feat(input): OSC22#101

Open
natemoo-re wants to merge 5 commits into
mainfrom
nm/feat/cursor
Open

feat(input): OSC22#101
natemoo-re wants to merge 5 commits into
mainfrom
nm/feat/cursor

Conversation

@natemoo-re

Copy link
Copy Markdown
Member

What does this PR do?

Adds OSC22 support (pointerShape). Because the specs dictate that renderer/input must be separated (INV-7/INV-8), this is surfaced by the host loop writing result.cursor to stdin.

  • Renderer—hitbox tracking diffs Clay_GetPointerOverIds() against a new element-level cursor prop and emits OSC 22 set bytes in a new result.cursor. There is no write, this is a pure annotation.
  • Input—new parse_osc() decodes the terminal's reply to a shape query (ESC]22;<payload>ST|BEL) into a PointerShapeEvent{ report }

Ps = 2 2 ⇒ Change pointer cursor shape to Pt. The parameter Pt sets the pointerShape resource. If Pt is empty, or does not match any of the standard names, xterm uses the resource's default "xterm" shape.
https://invisible-island.net/xterm/ctlseqs/ctlseqs.html

If there's another architecture or a more generic way to handle OSC sequences (like a dedicated OSC event), I am totally up for adjusting the approach.

Type of change

  • Bug fix
  • Feature
  • Refactor (no behavior change)
  • Documentation
  • Performance improvement
  • Tests
  • Chore (dependencies, CI, tooling)

Checklist

  • All tests pass (pnpm test)
  • Files are formatted (pnpm format)
  • I have added/updated tests for my changes (if applicable)
  • I have added a changeset

AI-generated code disclosure

  • This PR includes AI-generated code

Terminals implementing the kitty mouse-pointer-shape protocol answer an
OSC 22 query on the input stream. The parser now recognizes these replies
(ESC ] 22 ; <payload> ST|BEL) and surfaces them as a new InputEvent
variant carrying the raw payload string.

The payload is surfaced verbatim, not interpreted: the same reply shape
covers a current-shape name, "0" for an empty stack, and a support-query
list ("1,0,1"). Which applies depends on the query the caller sent, so
correlation is the caller's responsibility — keeping the parser stateless
and independent of the renderer (INV-8). Emitting the queries and setting
shapes is an output concern and lives elsewhere.

Set-only terminals (e.g. Ghostty) never reply, so they never produce the
event; absence within a timeout is the unsupported contract, not an error.
Lets the mouse pointer change shape over the UI, driven by the renderer's
existing hit-testing. Elements declare a CSS-style `cursor` shape on
open() (renderer-ignored, not packed to wasm); with `trackCursor: true`,
render() returns OSC 22 bytes in a separate `cursor` field for the caller
to write — kept out of `output` so render content stays pure.

Narrows the §11.2 prohibition to the text caret and carves out a single
opt-in exception for the mouse pointer shape, preserving INV-1 (renderer
produces bytes, caller writes) and INV-7 (capability replies arrive via
the input-side PointerShapeEvent; the caller correlates them). All
tracking state lives in the TS term layer, so the wasm core stays
frame-stateless. Framed as elastic §12 surface, like the pointer event
model it builds on.
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.
Declares cursor: "pointer" on each key and enables trackCursor, writing
the returned OSC 22 bytes so the mouse pointer turns into a hand over the
keys and reverts elsewhere.
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.
@github-actions

Copy link
Copy Markdown

Size Increased — +8.4 KB

121.0 KB unpacked

@pkg-pr-new

pkg-pr-new Bot commented Jun 25, 2026

Copy link
Copy Markdown

Open in StackBlitz

npm i https://pkg.pr.new/@bomb.sh/tty@101

commit: f07a9ed

@dreyfus92 dreyfus92 left a comment

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.

awesome job nate 😁 left a few comments are minimal, just wanna make sure they're intended by design or something wasn't considered while working on this 👀

Comment thread src/input.c
Comment on lines +630 to +635
int code = -1;
while (i < st->len && st->buf[i] >= '0' && st->buf[i] <= '9') {
if (code == -1)
code = 0;
code = code * 10 + (st->buf[i] - '0');
i++;

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 loop accumulates digits with no cap before the code != 22. feed it ESC ] 99999999999... and code overflows int. simplest guard is to clamp or bail inside the loop, e.g. break out once code > 22 (or some small ceilling) since the only code you accept is 22 anyway anything is already a PARSE_ERR.

Comment thread term.ts
Comment on lines +153 to +179
let cursor: Uint8Array | undefined;
if (options?.trackCursor) {
// Set-only OSC 22: the base is "default" (kitty and Ghostty both honor
// a bare set; the kitty push/pop stack is ignored by set-only terminals
// like Ghostty).
let active: CursorShape = "default";
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) {
cursor = POINTERSHAPE(active);
cursorShape = active;
}
}

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.

@cowboyd cowboyd left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Keeping the pointer edits out of band is definitely safe, but after having a sit with it, I'm wondering what the downside would be of just making the pointer edits part of the main output? In other words, if you declare up front in createTerm() that you want to emit pointer glyph edits, then it keeps the write simple. process.stdout.writeSync(result.output)

You'll still have to do the work to decide whether your terminal emulator supports it, but if you front load that complexity, then it cuts down considerably on the complexity of each paint.

We could also add some sort of probe() function which lets you pass in terminfo and it will emit a query and parse results to see what is supported for a session. If we had that, then we could even add an autoprobe? option to createTerm()

Open Questions

  • Do we support pointer edits when rendering in line mode? probably not? But worth asking.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants