From 466ff62011d17841f23c22ec5f6de953142358d8 Mon Sep 17 00:00:00 2001 From: Nate Moore Date: Fri, 9 Oct 2026 13:35:43 -0500 Subject: [PATCH] feat: seed pointerShape from a known-support table in detectTerminal() MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The OSC 22 query is answered by kitty alone; every other set-supporting terminal (ghostty, foot, xterm ≥ 367) never replies, so detection could never see them and the demos silently degraded in the most popular modern terminal. terminfo-spec §6.1 now carries a known-support table of terminals with primary evidence for OSC 22 set support — ghostty (its own docs), kitty (its own spec), foot (foot-ctlseqs(7)), and xterm (ctlseqs, version floor 367 via XTERM_VERSION). detectTerminal() seeds pointerShape from the environment; the probe reply remains authoritative evidence, and the manual CapabilityEvent opt-in stays for anything the table misses. mintty is deliberately excluded: it implements OSC 22 but its accepted names are Windows cursor names (hand, ibeam), not the CSS names §7.9 emits. Rio and Konsole lack primary evidence (Konsole is contradicted by ucs-detect, which — like our probe — tests the query form). The spec records the accepted false-positive trade: nested contexts that inherit the environment unchanged (Emacs term-mode) are granted by association; muxers and IDE terminals rewrite their identity and are naturally excluded. --- .changeset/pointer-shape-osc22.md | 2 +- specs/terminfo-spec.md | 35 +++++++++++++++++++++-- term.ts | 3 +- terminfo.ts | 36 ++++++++++++++++++++++++ test/terminfo.test.ts | 46 +++++++++++++++++++++++++++++++ 5 files changed, 116 insertions(+), 6 deletions(-) diff --git a/.changeset/pointer-shape-osc22.md b/.changeset/pointer-shape-osc22.md index bb133bb..c74e78a 100644 --- a/.changeset/pointer-shape-osc22.md +++ b/.changeset/pointer-shape-osc22.md @@ -4,7 +4,7 @@ Adds a `pointerShape` 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. For a terminal that sets shapes but never answers the query, assert support with `term.update([{ type: "capability", key: "pointer-shape", value: true }])`. +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", { pointerShape: "pointer" }), text("Docs"), close()], { pointer }); diff --git a/specs/terminfo-spec.md b/specs/terminfo-spec.md index dfff3b0..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 @@ -287,7 +313,8 @@ not merge; each response yields its own event. 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; a caller with outside knowledge supplies the evidence itself with +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 @@ -300,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; @@ -310,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. @@ -335,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. diff --git a/term.ts b/term.ts index 2c77941..5b4948f 100644 --- a/term.ts +++ b/term.ts @@ -29,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; @@ -94,7 +93,6 @@ function runtimeFromStatic(caps: Capabilities): RuntimeCapabilities { syncOutput: false, kittyKeyboard: false, kittyGraphics: false, - pointerShape: false, theme: Object.freeze({}), }); } @@ -185,6 +183,7 @@ export async function createTerm(options: TermOptions): Promise { xenl: true, altScreen: true, styledUnderline: false, + pointerShape: false, }, ); 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/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: {} }); -- 2.51.2