diff --git a/.changeset/pointer-shape-osc22.md b/.changeset/pointer-shape-osc22.md new file mode 100644 index 0000000..be374ec --- /dev/null +++ b/.changeset/pointer-shape-osc22.md @@ -0,0 +1,11 @@ +--- +"@bomb.sh/tty": minor +--- + +Adds a `cursor` property to `open()`, so the mouse pointer changes shape (OSC 22) as it moves over elements + +When the terminal confirms support through the probe (`term.capabilities.pointerShape`), `render()` appends the shape change to `output`, so writing `output` keeps the pointer in sync. The shape resets to `default` when the pointer leaves, when a frame renders without `pointer`, or when `update()` withdraws the capability. `detectTerminal()` also seeds the capability from a known-support table for terminals that set shapes without answering the query (ghostty, kitty, foot, xterm ≥ 367), so those work out of the box. For anything the table misses, assert support with `term.update([{ type: "capability", key: "pointer-shape", value: true }])`. + +```ts +term.render([open("link", { cursor: "pointer" }), text("Docs"), close()], { pointer }); +``` diff --git a/AGENTS.md b/AGENTS.md index e229098..b33f979 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -33,7 +33,9 @@ Do not include any agent marketing material (e.g. "Generated with...", - The renderer MUST NOT manage terminal state (alternate buffer, mouse reporting, keyboard protocol modes). Cursor visibility and positioning are renderer-managed only when a `caret` declaration is present on a `text()` - directive (see renderer-spec.md §7.6). + directive (see renderer-spec.md §7.6). The mouse pointer shape is + renderer-managed only while the `pointerShape` capability is true (see + renderer-spec.md §7.9). - Each frame is a complete snapshot. The renderer carries no UI tree state between frames — only cell buffers for diffing. diff --git a/debug-terminfo.ts b/debug-terminfo.ts new file mode 100644 index 0000000..b8e450f --- /dev/null +++ b/debug-terminfo.ts @@ -0,0 +1,185 @@ +// debug-terminfo.ts — pinpoint where the terminfo capability chain breaks. +// Run directly in the terminal you are testing, from the repo root: +// +// deno run -A debug-terminfo.ts +// node debug-terminfo.ts +// +// Walks the full chain and reports which link fails: environment identity, +// detection (static Capabilities), the probe round-trip with a per-query +// ✓/✗ breakdown, the parser's CapabilityEvents, and the term's merged +// RuntimeCapabilities. The pointer shape section at the end exercises the +// one capability render() consumes today; if no OSC 22 reply arrives, a +// set-only check flashes the hand cursor so query-less terminals (ghostty) +// can be told apart from terminals without OSC 22 at all. +import { Buffer } from "node:buffer"; +import process from "node:process"; +import { + type CapabilityEvent, + close, + createInput, + createTerm, + detectTerminal, + open, + text, +} from "./mod.ts"; + +const decode = new TextDecoder(); +const esc = (b: Uint8Array) => JSON.stringify(decode.decode(b)).slice(1, -1); +const json = (v: unknown) => JSON.stringify(v); + +console.log("── environment"); +console.log(" TERM:", process.env.TERM ?? "(unset)"); +console.log( + " TERM_PROGRAM:", + process.env.TERM_PROGRAM ?? "(unset)", + process.env.TERM_PROGRAM_VERSION ?? "", +); +console.log(" COLORTERM:", process.env.COLORTERM ?? "(unset)"); +if (process.env.TMUX) { + console.log(" ⚠ running inside tmux — queries may not pass through"); +} + +const info = await detectTerminal(); +console.log("── detection"); +console.log(" probe bytes:", info.probe.length); +console.log(" static capabilities:", json(info.capabilities)); + +if (!process.stdin.isTTY) { + console.log(" ✗ stdin is not a TTY; run this directly in your terminal"); + process.exit(1); +} + +console.log("── probing (2s window, raw mode on; type nothing)…"); +if (typeof process.stdin.setRawMode === "function") { + process.stdin.setRawMode(true); +} +process.stdout.write(info.probe); + +const chunks: Buffer[] = []; +const onData = (chunk: Buffer) => chunks.push(chunk); +process.stdin.on("data", onData); +await new Promise((r) => setTimeout(r, 2000)); +process.stdin.removeListener("data", onData); + +const reply = Buffer.concat(chunks); +console.log("── raw reply"); +console.log(" bytes:", reply.length); +console.log( + " escaped:", + reply.length ? esc(reply) : "(nothing — the terminal answered nothing)", +); + +console.log("── which queries were answered"); +const replyText = decode.decode(reply); +const markers: [string, string][] = [ + ["foreground (OSC 10)", "\x1b]10;"], + ["background (OSC 11)", "\x1b]11;"], + ["cursor color (OSC 12)", "\x1b]12;"], + ["colors (OSC 21)", "\x1b]21;"], + ["POINTER SHAPE (OSC 22)", "\x1b]22;"], + ["XTGETTCAP (DCS)", "\x1bP"], + ["sync output (DECRPM 2026)", "?2026"], + ["kitty keyboard (?u)", "?u"], + ["kitty graphics (APC)", "\x1b_G"], +]; +for (const [name, marker] of markers) { + console.log(` ${replyText.includes(marker) ? "✓" : "✗"} ${name}`); +} + +// The query tells us about reply support, not set support. Ghostty sets +// shapes since 1.0 but never answers the query, so give the terminal a +// chance to show what it can do: set the hand for 1.5s, then restore. +if (!replyText.includes("\x1b]22;")) { + console.log("── set-only check (watch your cursor)"); + process.stdout.write("\x1b]22;pointer\x1b\\"); + await new Promise((r) => setTimeout(r, 1500)); + process.stdout.write("\x1b]22;default\x1b\\"); + console.log( + " the cursor should have flashed to a hand just now. If it did," + + " this terminal can SET shapes but never answers the query" + + " (ghostty behaves exactly like this) — detectTerminal()'s" + + " known-support table should already cover it. If it did not" + + " change, OSC 22 is not supported at all.", + ); +} + +if (typeof process.stdin.setRawMode === "function") { + process.stdin.setRawMode(false); +} + +console.log("── parser"); +const input = await createInput({ terminfo: info }); +let events = input.scan(new Uint8Array(reply)).events; +events = [...events, ...input.scan(new Uint8Array(0)).events]; +const caps = events.filter((e): e is CapabilityEvent => + e.type === "capability" +); +if (caps.length === 0) { + console.log(" (no capability events parsed)"); +} +for (const c of caps) { + console.log(` capability: ${c.key} = ${json(c.value)}`); +} + +console.log("── term (merged view)"); +const term = await createTerm({ width: 40, height: 6, terminfo: info }); +term.update(caps); +console.log(" merged capabilities:", json(term.capabilities)); +const staticCaps = info.capabilities as unknown as Record; +const mergedCaps = term.capabilities as unknown as Record; +const raised = Object.keys(mergedCaps).filter((k) => + json(mergedCaps[k]) !== json(staticCaps[k]) +); +console.log( + " raised by the probe:", + raised.length > 0 ? raised.join(", ") : "(nothing — probe added no evidence)", +); + +// Pointer shape is the one capability render() consumes today, so it doubles +// as the end-to-end emission check. +console.log("── pointer shape (renderer-spec §7.9)"); +const result = term.render( + [open("box", { cursor: "pointer" }), text("hover"), close()], + { pointer: { x: 2, y: 2, down: false } }, +); +const emitted = decode + .decode(result.output) + .includes("\x1b]22;pointer\x1b\\"); +console.log(" OSC 22 in render output:", emitted); + +console.log("── verdict"); +if (emitted) { + console.log( + " ✓ render emits OSC 22" + + (info.capabilities.pointerShape && !replyText.includes("\x1b]22;") + ? " — statically granted by the known-support table (no query reply," + + " which is expected for set-only terminals like ghostty)" + : " — the probe confirmed support and the parser folded it in") + + ". If the demos still show nothing, hover a key/button — and note the" + + " shape only changes while the pointer is over an element that" + + " declares one.", + ); +} else if (!replyText.includes("\x1b]22;")) { + console.log( + info.capabilities.pointerShape + ? " ✗ no OSC 22 reply and no emission despite a static grant — please" + + " report this with the escaped reply above." + : " ✗ your terminal did not answer the OSC 22 query and it is not on" + + " the known-support table. If the set-only check above moved your" + + " cursor, it can set shapes but we cannot detect it — run the demos" + + " with TTY_POINTER_SHAPES=1 (or nominate the terminal for the" + + " table). If the cursor never changed, OSC 22 is not supported at" + + " all (iTerm2, Terminal.app, VS Code, tmux without passthrough).", + ); +} else if (caps.length === 0) { + console.log( + " ✗ the terminal replied but the parser produced no capability events." + + " Please report this with the escaped reply above.", + ); +} else { + console.log( + " ✗ capability recognized but render did not emit OSC 22. Please report" + + " this with the escaped reply above.", + ); +} +process.exit(0); diff --git a/ops.ts b/ops.ts index e89b291..c548687 100644 --- a/ops.ts +++ b/ops.ts @@ -1,3 +1,7 @@ +import type { PointerShape } from "./pointer-shape.ts"; + +export type { PointerShape }; + export type TransitionProperty = | "x" | "y" @@ -410,6 +414,7 @@ export interface OpenElement { zIndex?: number; }; transition?: Transition; + cursor?: PointerShape; } export type AttachPoint = @@ -491,6 +496,7 @@ export interface Text { interface Snapshot { directive: typeof OP_SNAPSHOT; data: Uint8Array; + shapes?: readonly (readonly [id: string, shape: string])[]; } export type Op = OpenElement | Text | CloseElement | Snapshot; @@ -551,7 +557,19 @@ export function snapshot(ops: Op[]): Op { let size = packSize(ops); let buf = new ArrayBuffer(size); let words = pack(ops, buf, 0, size); - return { directive: OP_SNAPSHOT, data: new Uint8Array(buf, 0, words * 4) }; + let data = new Uint8Array(buf, 0, words * 4); + let shapes: (readonly [string, string])[] = []; + for (let op of ops) { + if (op.directive === OP_SNAPSHOT) { + if (op.shapes) shapes.push(...op.shapes); + } else if ( + op.directive === OP_OPEN_ELEMENT && op.cursor !== undefined + ) { + shapes.push([op.id, op.cursor]); + } + } + if (shapes.length === 0) return { directive: OP_SNAPSHOT, data }; + return { directive: OP_SNAPSHOT, data, shapes }; } const TP_X = 1; diff --git a/pointer-shape.ts b/pointer-shape.ts new file mode 100644 index 0000000..67c93c7 --- /dev/null +++ b/pointer-shape.ts @@ -0,0 +1,81 @@ +import type { Op } from "./ops.ts"; + +export const POINTER_SHAPES = [ + "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", + "zoom-in", + "zoom-out", +] as const; + +/** + * A mouse pointer shape, named with the CSS `cursor` keyword vocabulary used + * by the OSC 22 pointer shape protocol. + * + * @see {@link https://sw.kovidgoyal.net/kitty/pointer-shapes/ | kitty pointer shapes} + */ +export type PointerShape = typeof POINTER_SHAPES[number]; + +const KNOWN: ReadonlySet = new Set(POINTER_SHAPES); + +export function resolvePointerShape( + ops: readonly Op[], + over: readonly string[], +): PointerShape { + let best = -1; + let shape: PointerShape = "default"; + for (let op of ops) { + if ("data" in op) { + if (!op.shapes) continue; + for (let [id, value] of op.shapes) { + if (!KNOWN.has(value)) continue; + let i = over.lastIndexOf(id); + if (i > best) { + best = i; + shape = value as PointerShape; + } + } + } else if ("cursor" in op && op.cursor !== undefined) { + if (!KNOWN.has(op.cursor)) continue; + let i = over.lastIndexOf(op.id); + if (i > best) { + best = i; + shape = op.cursor; + } + } + } + return shape; +} + +const encoder = new TextEncoder(); + +export function osc22(shape: PointerShape): Uint8Array { + return encoder.encode(`\x1b]22;${shape}\x1b\\`); +} diff --git a/specs/renderer-spec.md b/specs/renderer-spec.md index 0e1d6ff..487a899 100644 --- a/specs/renderer-spec.md +++ b/specs/renderer-spec.md @@ -287,10 +287,10 @@ The render transaction produces ANSI bytes as a `Uint8Array`. These bytes: The output reflects the complete visual state of the frame. The caller SHOULD write the output to the terminal without modification. -The output `Uint8Array` is a view over renderer-owned memory. It is valid until -the next `render()` or `update()` call on the same Term instance, at which point -the buffer may be reused or detached (§7.7). Callers who need to retain the -output beyond that point MUST copy it. +The output `Uint8Array` may be a view over renderer-owned memory. It is valid +until the next `render()` or `update()` call on the same Term instance, at which +point the buffer may be reused or detached (§7.7). Callers who need to retain +the output beyond that point MUST copy it. ### 7.4 Lifecycle @@ -449,9 +449,9 @@ be a no-op for that step. **Capability semantics.** A `CapabilityEvent` step folds the event's `key` and `value` into the Term's private `RuntimeCapabilities`. The foundation update -transaction does not emit bytes or alter renderer output as a consequence of a -capability event. A focused capability specification MUST define any output -invalidation or immediate bytes required by its consumer. +transaction emits bytes or alters renderer output as a consequence of a +capability event only where a capability section of this specification says so +(§7.9). **Return value.** `update()` returns a `Uint8Array` of bytes to write to the terminal immediately. An empty array is valid when the update changes no @@ -469,16 +469,62 @@ is valid until the next `render()` **or** `update()` call). ### 7.8 Capability consumption -The renderer may consume the read-only capability snapshot maintained by -`Term.update()`, but this foundation specification does not define any -capability-specific output. Protocols that change emitted bytes MUST add their -own renderer section, capability evidence, invalidation rules, and tests in a -focused feature specification. - -In particular, color encoding, synchronized-output wrapping, pointer-shape -output, Kitty keyboard mode setup, and Kitty graphics emission are deferred to -their respective follow-up PRs. The renderer continues to emit its existing -hardcoded ANSI output until one of those specifications is adopted. +The renderer consumes the read-only capability snapshot maintained by +`Term.update()`. A protocol that changes emitted bytes MUST define its gating +capability, output, and invalidation rules in its own section of this +specification. Pointer shape (§7.9) is the first. + +Color encoding, synchronized-output wrapping, Kitty keyboard mode setup, and +Kitty graphics emission are deferred to their respective follow-up PRs. The +renderer continues to emit its existing hardcoded ANSI output until each is +specified. + +### 7.9 Pointer shape + +An `open()` directive MAY declare a `cursor` property naming the mouse pointer +shape to show while the pointer is over the element. Values are the kitty +pointer shape names (the CSS `cursor` keywords): `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`, `zoom-in`, `zoom-out`. Any other value MUST be +treated as absent and MUST NOT reach the output. The property does not affect +layout or cell output. + +The gate is `RuntimeCapabilities.pointerShape` (see +[Terminfo Specification](terminfo-spec.md) §6.3). While it is `false`, the +renderer MUST NOT read `cursor` properties and MUST NOT emit OSC 22. + +**Resolved shape.** While the capability is `true`, each render resolves one +shape: + +- `default` when the render options carry no `pointer`. +- Otherwise, the `cursor` of the last declaring element in hit-test order (the + order behind `pointerenter` events, §12.4), or `default` when none declares + one. Ancestors precede descendants, so the innermost element wins. A floating + element in `"capture"` mode hides the elements beneath it; one in + `"passthrough"` mode lets them decide. + +Elements inside a `snapshot()` participate as direct directives would. +Resolution and output are identical in line mode (§8.2.2). + +**Output.** The Term tracks the shape it last emitted, starting at `default`. +When the resolved shape differs, the render MUST append `ESC ] 22 ; ST` +to `output` after the frame's cell bytes and record the new shape. Otherwise it +MUST NOT emit OSC 22. + +**Restore.** OSC 22 set cannot pop, so the Term restores by setting `default`: + +- A render that resolves `default` emits it per **Output** above. +- An update step that changes `pointerShape` from `true` to `false` while the + emitted shape is not `default` MUST return `ESC ] 22 ; default ST` and record + `default`. +- A resize step leaves the emitted shape unchanged. + +To restore before exit, the caller renders a frame without `pointer`, or passes +`{ type: "capability", key: "pointer-shape", value: false }` to `update()`, and +writes the result. --- @@ -816,8 +862,9 @@ declaration is present, the cursor-positioning and cursor-visibility sequences specified in §7.6. Capability-specific output modes are not terminal-state management owned by the -foundation renderer. A focused feature specification must define their state -boundaries separately. +foundation renderer. Each capability section of §7 defines its own state +boundary. The mouse pointer shape (§7.9) is distinct from the cursor shape +above, which remains caller-managed. ### 11.3 The renderer does not own application lifecycle @@ -903,6 +950,8 @@ The `open()` constructor currently accepts the following property groups in its reference, attach target, structured attach points, pointer capture mode, clip target, z-index) - **`scroll`** — scroll container configuration +- **`cursor`** — mouse pointer shape while hovered (§7.9); not transferred to + the WASM module The `floating` object shape is: diff --git a/specs/terminfo-spec.md b/specs/terminfo-spec.md index d800909..2903c59 100644 --- a/specs/terminfo-spec.md +++ b/specs/terminfo-spec.md @@ -180,6 +180,7 @@ interface Capabilities { readonly xenl: boolean; readonly altScreen: boolean; readonly styledUnderline: boolean; + readonly pointerShape: boolean; } ``` @@ -192,11 +193,36 @@ interface Capabilities { | `xenl` | `xenl` boolean cap | | `altScreen` | `smcup` string present | | `styledUnderline` | `Su` boolean or `Smulx` string | +| `pointerShape` | known OSC 22 support table (below); probe overrides | Key sequences (`key_*` capabilities) are not stored here. The input parser reads them directly from the raw terminfo bytes in `TerminalInfo.keys` at initialization time. +**Known OSC 22 support.** Some terminals implement OSC 22 pointer shape setting +but never answer its support query (Renderer Specification §7.9); silence must +not count as denial, so `detectTerminal()` seeds `pointerShape: true` when the +environment identifies a terminal in this table. Entries require primary +evidence from the terminal's own documentation or specification. + +| Terminal | Environment evidence | Version floor | Evidence | +| -------- | ---------------------------------------------- | ------------- | -------------------------- | +| ghostty | `TERM=xterm-ghostty` or `TERM_PROGRAM=ghostty` | 1.0.0 | ghostty.org/docs/vt/osc/22 | +| kitty | `TERM=xterm-kitty` or `KITTY_WINDOW_ID` set | 0.31.0 | kitty pointer-shapes spec | +| foot | `TERM` starting with `foot` | 1.12.0 | foot-ctlseqs(7) | +| xterm | `XTERM_VERSION` parses as patch ≥ 367 | 367 | xterm ctlseqs (OSC 22) | + +The table names terminals by what they _emit_, not by what they parse: an +implementation that accepts only a non-CSS vocabulary (e.g. Windows cursor +names) MUST NOT be listed, since the renderer emits the kitty CSS names +exclusively (§6.3). This is static evidence with known failure modes: nested +contexts that inherit the environment unchanged (for example Emacs `term-mode` +running inside a listed terminal) are granted by association and will garble +OSC 22. Terminals that rewrite `TERM` (tmux, Zellij) or `TERM_PROGRAM` (IDE +terminals) are naturally excluded; ssh forwards are true positives because the +bytes still reach the real terminal. Stale entries degrade to the same silent +swallowing the gate was built to avoid. + ### 6.2 `ColorDepth` ```ts @@ -284,7 +310,12 @@ per field the reply carries. OSC 21 arriving alongside OSC 10/11/12 replies does not merge; each response yields its own event. **OSC 22.** An OSC 22 reply sets `key: "pointer-shape", value: true`. The shape -name in the reply is discarded in v1; only protocol support is recorded. +name in the reply is discarded in v1; only protocol support is recorded. The +renderer emits pointer shapes only while `pointerShape` is `true` (Renderer +Specification §7.9). Terminals that implement OSC 22 set but not its query never +reply; `detectTerminal()` supplies that evidence itself from the known-support +table (§6.1), and a caller may still override it with +`term.update([{ type: "capability", key: "pointer-shape", value: true }])`. **DA1.** The DA1 reply is recognized internally as the probe fence and MUST NOT surface as a `CapabilityEvent`. @@ -296,7 +327,6 @@ interface RuntimeCapabilities extends Capabilities { readonly syncOutput: boolean; readonly kittyKeyboard: boolean; readonly kittyGraphics: boolean; - readonly pointerShape: boolean; readonly theme: { readonly foreground?: Rgb; readonly background?: Rgb; @@ -306,7 +336,9 @@ interface RuntimeCapabilities extends Capabilities { ``` `RuntimeCapabilities` is the renderer's current merged view: the static -`Capabilities` fields plus all `CapabilityEvent` values folded in so far. The +`Capabilities` fields (including `pointerShape`, which starts from the +known-support table and is raised by a probe reply or a `pointer-shape` +CapabilityEvent) plus all other `CapabilityEvent` values folded in so far. The `trueColor` field reflects the latest evidence at any precedence level; a `colordepth` probe event overrides the static value. `RuntimeCapabilities` is exposed as `term.capabilities` and is a frozen snapshot at the moment of access. @@ -331,6 +363,7 @@ With no terminfo bytes, no environment evidence, and no probe responses, | `xenl` | `true` | | `altScreen` | `true` | | `styledUnderline` | `false` | +| `pointerShape` | `false` | Truecolor is not assumed at baseline. Per TINV-3, it requires positive evidence. @@ -422,8 +455,8 @@ and mouse events in arrival order. The host loop routes them to `term.update()`. A capability event can arrive after the renderer has already emitted frames. `term.update()` folds it into the runtime snapshot. Any renderer-side output -invalidation or immediate bytes are defined by the focused feature specification -that consumes that capability. +invalidation or immediate bytes are defined by the renderer section that +consumes that capability (Renderer Specification §7.8). --- diff --git a/term.ts b/term.ts index 5326daa..5b4948f 100644 --- a/term.ts +++ b/term.ts @@ -2,6 +2,11 @@ import { type Op, pack } from "./ops.ts"; import { type BoundingBox, createTermNative } from "./term-native.ts"; import type { InputEvent } from "./input.ts"; import type { Capabilities, Rgb, TerminalInfo } from "./terminfo.ts"; +import { + osc22, + type PointerShape, + resolvePointerShape, +} from "./pointer-shape.ts"; export type { BoundingBox }; @@ -24,7 +29,6 @@ export interface RuntimeCapabilities extends Capabilities { readonly syncOutput: boolean; readonly kittyKeyboard: boolean; readonly kittyGraphics: boolean; - readonly pointerShape: boolean; readonly theme: { readonly foreground?: Rgb; readonly background?: Rgb; @@ -89,7 +93,6 @@ function runtimeFromStatic(caps: Capabilities): RuntimeCapabilities { syncOutput: false, kittyKeyboard: false, kittyGraphics: false, - pointerShape: false, theme: Object.freeze({}), }); } @@ -163,6 +166,7 @@ export interface Term { export async function createTerm(options: TermOptions): Promise { let { width, height, terminfo } = options; + let emittedShape: PointerShape = "default"; let native = await createTermNative( width, @@ -179,6 +183,7 @@ export async function createTerm(options: TermOptions): Promise { xenl: true, altScreen: true, styledUnderline: false, + pointerShape: false, }, ); @@ -225,9 +230,22 @@ export async function createTerm(options: TermOptions): Promise { native.length(native.statePtr), ); - let current = new Set( - options?.pointer ? native.getPointerOverIds() : [], - ); + let overIds = options?.pointer ? native.getPointerOverIds() : []; + let current = new Set(overIds); + + if (currentCaps.pointerShape) { + let shape: PointerShape = overIds.length > 0 + ? resolvePointerShape(ops, overIds) + : "default"; + if (shape !== emittedShape) { + emittedShape = shape; + let seq = osc22(shape); + let joined = new Uint8Array(output.length + seq.length); + joined.set(output); + joined.set(seq, output.length); + output = joined; + } + } let down = options?.pointer?.down ?? false; let events: PointerEvent[] = []; @@ -309,6 +327,14 @@ export async function createTerm(options: TermOptions): Promise { } } + if ( + currentCaps.pointerShape && !next.pointerShape && + emittedShape !== "default" + ) { + emittedShape = "default"; + out.push(osc22("default")); + } + currentCaps = next; if (bytes.length) out.push(bytes); } diff --git a/terminfo.ts b/terminfo.ts index bb3c081..5680eda 100644 --- a/terminfo.ts +++ b/terminfo.ts @@ -43,6 +43,7 @@ export interface Capabilities { readonly xenl: boolean; readonly altScreen: boolean; readonly styledUnderline: boolean; + readonly pointerShape: boolean; } /** Opaque type alias for the raw terminfo key-sequence bytes. */ @@ -91,6 +92,40 @@ const PROBE = encoder.encode( const WASM_PAGE_BYTES = 65536; +/** + * The known OSC 22 pointer-shape support table (terminfo-spec §6.1): terminals + * that implement OSC 22 pointer shape setting but never answer its support + * query, identified by environment evidence. Entries require primary evidence + * from the terminal's own documentation. Each entry records the version that + * introduced support; a stale entry degrades to the same silent swallowing the + * capability gate already produces, never to garbage. + */ +function knownPointerShape( + env: Record, +): boolean { + // ghostty: OSC 22 since 1.0.0 (ghostty.org/docs/vt/osc/22); set only — + // the reference documents no query form. + if (env.TERM === "xterm-ghostty" || env.TERM_PROGRAM === "ghostty") { + return true; + } + // kitty: full kitty pointer-shapes protocol since 0.31.0, including the + // query (sw.kovidgoyal.net/kitty/pointer-shapes/). Seeded here so + // term.capabilities is correct before the probe reply arrives. + if (env.TERM === "xterm-kitty" || env.KITTY_WINDOW_ID !== undefined) { + return true; + } + // foot: OSC 22 sets the xcursor pointer (foot-ctlseqs(7)); no query form. + if (env.TERM?.startsWith("foot")) { + return true; + } + // xterm: OSC 22 since patch 367 (ctlseqs); XTERM_VERSION is `xterm(N)`. + let patch = env.XTERM_VERSION?.match(/xterm\((\d+)\)/); + if (patch && Number(patch[1]) >= 367) { + return true; + } + return false; +} + function rgbOf(packed: number): Rgb { return { r: (packed >> 16) & 0xff, @@ -179,6 +214,7 @@ export async function detectTerminal( xenl: !!(flags & FLAG_EAT_NEWLINE_GLITCH), altScreen: !!(flags & FLAG_ALTSCREEN), styledUnderline: !!(flags & FLAG_STYLED_UNDERLINE), + pointerShape: knownPointerShape(env), }); return Object.freeze({ diff --git a/test/input.test.ts b/test/input.test.ts index 91ce14f..768e8ea 100644 --- a/test/input.test.ts +++ b/test/input.test.ts @@ -996,6 +996,72 @@ describe("terminfo integration", () => { }); }); + it("surfaces a BEL-terminated OSC 22 pointer shape report", async () => { + let { input } = await withTerminfo(); + let result = input.scan(str("\x1b]22;pointer\x07")); + expect(result.events).toEqual([ + { type: "capability", key: "pointer-shape", value: true }, + ]); + }); + + it("surfaces an OSC 22 empty-stack report", async () => { + let { input } = await withTerminfo(); + let result = input.scan(str("\x1b]22;0\x1b\\")); + expect(result.events).toEqual([ + { type: "capability", key: "pointer-shape", value: true }, + ]); + }); + + it("buffers an OSC 22 report fed one byte at a time", async () => { + let { input } = await withTerminfo(); + let reply = str("\x1b]22;default\x1b\\"); + for (let i = 0; i < reply.length - 1; i++) { + expect(input.scan(reply.subarray(i, i + 1)).events).toEqual([]); + } + expect(input.scan(reply.subarray(reply.length - 1)).events).toEqual([ + { type: "capability", key: "pointer-shape", value: true }, + ]); + }); + + it("rejects an OSC 22 report whose ST is split from its ESC by a non-backslash", async () => { + let { input } = await withTerminfo(); + let result = input.scan(str("\x1b]22;default\x1bx")); + expect(result.events.some((e) => e.type === "capability")).toBe(false); + }); + + it("bails on an OSC number longer than any it accepts without overflowing", async () => { + let { input } = await withTerminfo(); + let digits = "9".repeat(64); + let result = input.scan(str(`\x1b]${digits};pointer\x07`)); + expect(result.events.some((e) => e.type === "capability")).toBe(false); + expect(input.scan(str("\x1b]22;default\x1b\\")).events).toEqual([ + { type: "capability", key: "pointer-shape", value: true }, + ]); + }); + + it("does not read OSC 220 as OSC 22", async () => { + let { input } = await withTerminfo(); + let result = input.scan(str("\x1b]220;pointer\x07")); + expect(result.events.some((e) => e.type === "capability")).toBe(false); + }); + + it("drops an OSC 22 report whose payload exceeds the response bound", async () => { + let { input } = await withTerminfo(); + let events = input.scan(str(`\x1b]22;${"x".repeat(2048)}\x07`)).events; + expect(events.some((e) => e.type === "capability")).toBe(false); + events = input.scan(str("\x1b]22;default\x07")).events; + for (let i = 0; i < 32 && events.length > 0; i++) { + let last = events[events.length - 1]; + if (last.type === "capability") break; + events = input.scan().events; + } + expect(events[events.length - 1]).toEqual({ + type: "capability", + key: "pointer-shape", + value: true, + }); + }); + it("surfaces truecolor colordepth from a valid XTGETTCAP reply", async () => { let terminfo = await detectTerminal({ env: {}, entry: CLAYTERM_16 }); let input = await createInput({ terminfo }); diff --git a/test/pointer-shape.test.ts b/test/pointer-shape.test.ts new file mode 100644 index 0000000..60b72a4 --- /dev/null +++ b/test/pointer-shape.test.ts @@ -0,0 +1,279 @@ +import { beforeEach, describe, expect, it } from "./suite.ts"; +import { createTerm, type Term } from "../term.ts"; +import { + close, + fixed, + grow, + type Op, + open, + type PointerShape, + snapshot, + text, +} from "../ops.ts"; +import type { CapabilityEvent } from "../input.ts"; +import { print } from "./print.ts"; + +const SUPPORTED: CapabilityEvent = { + type: "capability", + key: "pointer-shape", + value: true, +}; + +const WITHDRAWN: CapabilityEvent = { + type: "capability", + key: "pointer-shape", + value: false, +}; + +const osc22 = (shape: string) => `\x1b]22;${shape}\x1b\\`; + +const decode = (bytes: Uint8Array) => new TextDecoder().decode(bytes); + +const trim = (s: string) => s.split("\n").map((l) => l.trimEnd()).join("\n"); + +function sequences(bytes: Uint8Array): string[] { + let [, ...rest] = decode(bytes).split("\x1b]22;"); + return rest.map((tail) => osc22(tail.slice(0, tail.indexOf("\x1b\\")))); +} + +function over(x: number, y: number) { + return { pointer: { x, y, down: false } }; +} + +// ┌─root (40x6, ltr)───────────────────────┐ +// │┌─link (20x6) pointer─┐┌─plain (20x6)──┐│ +// ││┌─field (10x2) text┐ ││P ││ +// │││F │ ││ ││ +// ││└──────────────────┘ ││ ││ +// ││L ││ ││ +// │└─────────────────────┘└───────────────┘│ +// └────────────────────────────────────────┘ +function layout(link: { cursor?: PointerShape } = {}): Op[] { + return [ + open("root", { + layout: { width: grow(), height: grow(), direction: "ltr" }, + }), + open("link", { + layout: { width: fixed(20), height: fixed(6), direction: "ttb" }, + cursor: "pointer", + ...link, + }), + open("field", { + layout: { width: fixed(10), height: fixed(2) }, + cursor: "text", + }), + text("F"), + close(), + text("L"), + close(), + open("plain", { + layout: { width: fixed(20), height: fixed(6) }, + }), + text("P"), + close(), + close(), + ]; +} + +const FIELD = over(2, 0); +const LINK = over(2, 4); +const PLAIN = over(25, 2); + +describe("pointer shape", () => { + let term: Term; + + beforeEach(async () => { + term = await createTerm({ width: 40, height: 6 }); + term.update([SUPPORTED]); + }); + + describe("capability gate", () => { + it("never emits OSC 22 without the pointer-shape capability", async () => { + let unsupported = await createTerm({ width: 40, height: 6 }); + expect(sequences(unsupported.render(layout(), LINK).output)).toEqual([]); + expect(sequences(unsupported.render(layout(), PLAIN).output)).toEqual( + [], + ); + }); + + it("does not read cursor without the capability", async () => { + let unsupported = await createTerm({ width: 40, height: 6 }); + let reads = 0; + let ops = layout().map((op) => + "cursor" in op + ? Object.defineProperty({ ...op }, "cursor", { + get: () => (reads++, "pointer"), + }) + : op + ); + unsupported.render(ops, LINK); + expect(reads).toBe(0); + }); + + it("emits once the capability arrives", async () => { + let late = await createTerm({ width: 40, height: 6 }); + expect(sequences(late.render(layout(), LINK).output)).toEqual([]); + expect(late.update([SUPPORTED])).toEqual(new Uint8Array(0)); + expect(sequences(late.render(layout(), LINK).output)).toEqual([ + osc22("pointer"), + ]); + }); + }); + + describe("output", () => { + it("appends the set sequence after the unchanged frame bytes", async () => { + let plain = await createTerm({ width: 40, height: 6 }); + let frame = decode(plain.render(layout(), LINK).output); + let output = decode(term.render(layout(), LINK).output); + + expect(output).toBe(frame + osc22("pointer")); + expect(trim(print(frame, 40, 6))).toBe(trim( + [ + "F P", + "", + "L", + "", + "", + "", + ].join("\n"), + )); + }); + + it("emits only when the resolved shape changes", () => { + expect(sequences(term.render(layout(), LINK).output)).toEqual([ + osc22("pointer"), + ]); + expect(sequences(term.render(layout(), LINK).output)).toEqual([]); + expect(sequences(term.render(layout(), over(3, 5)).output)).toEqual([]); + }); + + it("emits in line mode", () => { + let result = term.render(layout(), { mode: "line", ...LINK }); + expect(sequences(result.output)).toEqual([osc22("pointer")]); + }); + + it("emits nothing while the pointer only crosses undeclared elements", () => { + expect(sequences(term.render(layout(), PLAIN).output)).toEqual([]); + expect(sequences(term.render(layout()).output)).toEqual([]); + }); + }); + + describe("resolution", () => { + it("lets the innermost declaring element win", () => { + expect(sequences(term.render(layout(), FIELD).output)).toEqual([ + osc22("text"), + ]); + expect(sequences(term.render(layout(), LINK).output)).toEqual([ + osc22("pointer"), + ]); + }); + + it("follows a declared shape that changes under a still pointer", () => { + term.render(layout(), LINK); + expect( + sequences( + term.render(layout({ cursor: "progress" }), LINK) + .output, + ), + ).toEqual([osc22("progress")]); + }); + + it("ignores values outside the PointerShape vocabulary", () => { + let bogus = "x\x1b]0;pwned\x07" as PointerShape; + let output = term.render(layout({ cursor: bogus }), LINK).output; + expect(decode(output)).not.toContain("pwned"); + expect(sequences(output)).toEqual([]); + }); + + it("sees elements inside a snapshot", () => { + let ops = layout(); + let frozen = snapshot(ops.slice(1, -1)); + let result = term.render([ops[0], frozen, ops[ops.length - 1]], FIELD); + expect(sequences(result.output)).toEqual([osc22("text")]); + }); + + it("lets a capture-mode floating overlay win over what is beneath", () => { + let ops = layout(); + ops.splice( + ops.length - 1, + 0, + open("menu", { + layout: { width: fixed(6), height: fixed(3) }, + floating: { x: 1, y: 3, attachTo: "root" }, + cursor: "grab", + }), + close(), + ); + expect(sequences(term.render(ops, LINK).output)).toEqual([ + osc22("grab"), + ]); + }); + + it("lets a passthrough floating overlay defer to what is beneath", () => { + let ops = layout(); + ops.splice( + ops.length - 1, + 0, + open("tooltip", { + layout: { width: fixed(6), height: fixed(3) }, + floating: { + x: 1, + y: 3, + attachTo: "root", + pointerCaptureMode: "passthrough", + }, + cursor: "help", + }), + close(), + ); + expect(sequences(term.render(ops, LINK).output)).toEqual([ + osc22("pointer"), + ]); + }); + }); + + describe("restore", () => { + it("resets to default when the pointer moves off declaring elements", () => { + term.render(layout(), LINK); + expect(sequences(term.render(layout(), PLAIN).output)).toEqual([ + osc22("default"), + ]); + }); + + it("resets to default on a render without pointer", () => { + term.render(layout(), LINK); + expect(sequences(term.render(layout()).output)).toEqual([ + osc22("default"), + ]); + }); + + it("returns the reset from update() when the capability is withdrawn", () => { + term.render(layout(), LINK); + expect(decode(term.update([WITHDRAWN]))).toBe(osc22("default")); + expect(sequences(term.render(layout(), LINK).output)).toEqual([]); + }); + + it("returns nothing from update() when withdrawn at default", () => { + term.render(layout(), PLAIN); + expect(term.update([WITHDRAWN])).toEqual(new Uint8Array(0)); + }); + + it("returns the reset once from a batch that withdraws twice", () => { + term.render(layout(), LINK); + expect(decode(term.update([WITHDRAWN, SUPPORTED, WITHDRAWN]))).toBe( + osc22("default"), + ); + }); + + it("keeps the emitted shape across a resize", () => { + term.render(layout(), LINK); + expect(term.update([{ type: "resize", width: 50, height: 8 }])).toEqual( + new Uint8Array(0), + ); + expect(sequences(term.render(layout(), LINK).output)).toEqual([]); + expect(sequences(term.render(layout(), PLAIN).output)).toEqual([ + osc22("default"), + ]); + }); + }); +}); diff --git a/test/terminfo.test.ts b/test/terminfo.test.ts index 007b31e..e9e15ec 100644 --- a/test/terminfo.test.ts +++ b/test/terminfo.test.ts @@ -23,8 +23,54 @@ const BASELINE = { xenl: true, altScreen: true, styledUnderline: false, + pointerShape: false, }; +describe("known OSC 22 pointer shape support (terminfo-spec §6.1)", () => { + it("grants for ghostty by TERM and by TERM_PROGRAM", async () => { + let byTerm = await detectTerminal({ env: { TERM: "xterm-ghostty" } }); + expect(byTerm.capabilities.pointerShape).toBe(true); + let byProgram = await detectTerminal({ env: { TERM_PROGRAM: "ghostty" } }); + expect(byProgram.capabilities.pointerShape).toBe(true); + }); + + it("grants for kitty by TERM and by KITTY_WINDOW_ID", async () => { + let byTerm = await detectTerminal({ env: { TERM: "xterm-kitty" } }); + expect(byTerm.capabilities.pointerShape).toBe(true); + let byWindow = await detectTerminal({ env: { KITTY_WINDOW_ID: "1" } }); + expect(byWindow.capabilities.pointerShape).toBe(true); + }); + + it("grants for foot by TERM prefix", async () => { + let d = await detectTerminal({ env: { TERM: "foot" } }); + expect(d.capabilities.pointerShape).toBe(true); + }); + + it("grants for xterm at the version floor and above", async () => { + let old = await detectTerminal({ env: { XTERM_VERSION: "xterm(366)" } }); + expect(old.capabilities.pointerShape).toBe(false); + let floor = await detectTerminal({ env: { XTERM_VERSION: "xterm(367)" } }); + expect(floor.capabilities.pointerShape).toBe(true); + let newer = await detectTerminal({ env: { XTERM_VERSION: "xterm(388)" } }); + expect(newer.capabilities.pointerShape).toBe(true); + }); + + it("does not grant for terminals that rewrite their identity", async () => { + for ( + let TERM of [ + "tmux-256color", + "screen-256color", + "xterm-256color", + ] + ) { + let d = await detectTerminal({ env: { TERM } }); + expect(d.capabilities.pointerShape).toBe(false); + } + let vscode = await detectTerminal({ env: { TERM_PROGRAM: "vscode" } }); + expect(vscode.capabilities.pointerShape).toBe(false); + }); +}); + describe("baseline", () => { it("initializes to the baseline with no evidence", async () => { let d = await detectTerminal({ env: {} }); diff --git a/test/validate.test.ts b/test/validate.test.ts index 3cd8896..0095584 100644 --- a/test/validate.test.ts +++ b/test/validate.test.ts @@ -33,6 +33,20 @@ describe("validate", () => { ])).toBe(true); }); + it("accepts a pointer shape", () => { + expect(validate([open("x", { cursor: "pointer" }), close()])).toBe( + true, + ); + }); + + it("rejects a pointer shape outside the vocabulary", () => { + expect(validate([ + // deno-lint-ignore no-explicit-any + open("x", { cursor: "hand" as any }), + close(), + ])).toBe(false); + }); + it("rejects ops with wrong directive", () => { expect(validate([{ directive: 0xff }])).toBe(false); }); diff --git a/validate.ts b/validate.ts index d98c94a..b49ee88 100644 --- a/validate.ts +++ b/validate.ts @@ -1,6 +1,7 @@ import { Type } from "@sinclair/typebox"; import { TypeCompiler } from "@sinclair/typebox/compiler"; import type { Op } from "./ops.ts"; +import { POINTER_SHAPES } from "./pointer-shape.ts"; import type { RenderOptions, RenderResult, Term } from "./term.ts"; /* ── Range helpers (match bit-packing in pack()) ──────────────────── */ @@ -178,6 +179,8 @@ const Transition = Type.Object({ interactive: Type.Optional(Type.Boolean()), }); +const PointerShape = Type.Union(POINTER_SHAPES.map((s) => Type.Literal(s))); + /* ── Op types (discriminated on `directive`) ──────────────────────── */ const CloseElement = Type.Object({ directive: Type.Literal(0x04) }); @@ -192,6 +195,7 @@ const OpenElement = Type.Object({ clip: Type.Optional(Clip), floating: Type.Optional(Floating), transition: Type.Optional(Transition), + cursor: Type.Optional(PointerShape), }); const TextOp = Type.Object({