From e8a442dc79baafefef59c3d94df551c2c2be8edc Mon Sep 17 00:00:00 2001 From: Nate Moore Date: Fri, 9 Oct 2026 23:50:15 -0500 Subject: [PATCH] feat(input): color-scheme events via DECDSR 996 + mode 2031 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The probe now subscribes to mode 2031 (CSI ? 2031 h) and queries the current scheme (CSI ? 996 n); both the reply and the unsolicited push are CSI ? 997 ; Ps n (1=dark, 2=light), which the parser recognizes as a new CapabilityEvent key "color-scheme" — folded into RuntimeCapabilities.theme.scheme. Live dark/light switching is now tracked, resolving terminfo-spec §11's deferral (DECSET 2510 tracked color queries deferred in its place). The compositing demo folds scheme events and re-sends the OSC 10/11 queries on each scheme change (the report carries no colors), so the UI polarity flip and compositing destinations follow OS appearance changes live. --- examples/compositing-demo/index.ts | 11 ++++++++ input-native.ts | 1 + input.ts | 13 ++++++++++ specs/input-spec.md | 4 +-- specs/terminfo-spec.md | 40 ++++++++++++++++++++++++------ src/input.c | 8 ++++++ src/input.h | 3 +++ term.ts | 9 +++++++ terminfo.ts | 4 +++ test/input.test.ts | 22 ++++++++++++++++ test/term.test.ts | 16 ++++++++++++ test/terminfo.test.ts | 3 +++ 12 files changed, 124 insertions(+), 10 deletions(-) diff --git a/examples/compositing-demo/index.ts b/examples/compositing-demo/index.ts index daa5b73..a898284 100644 --- a/examples/compositing-demo/index.ts +++ b/examples/compositing-demo/index.ts @@ -224,7 +224,18 @@ await main(function* () { // compositing destinations; a bare-mode frame then composites over // the terminal's real background color. if (event.type === "capability") { + let changedScheme = term.capabilities.theme.scheme !== + (event as { value: string }).value; term.update([event]); + // A scheme change means the OS appearance flipped: the 997 + // report carries no colors, so re-query them (opencode's + // approach) and fold the fresh OSC 10/11 replies. The + // compositing destinations and the UI polarity both follow. + if ( + changedScheme && (event as { key: string }).key === "color-scheme" + ) { + writeStdout(new TextEncoder().encode("\x1b]10;?\x07\x1b]11;?\x07")); + } } } diff --git a/input-native.ts b/input-native.ts index 2c540fa..339c6fe 100644 --- a/input-native.ts +++ b/input-native.ts @@ -19,6 +19,7 @@ export const CAP_SYNC_OUTPUT = 5; export const CAP_KITTY_KEYBOARD = 6; export const CAP_KITTY_GRAPHICS = 7; export const CAP_POINTER_SHAPE = 8; +export const CAP_COLOR_SCHEME = 9; /* CAP_COLORDEPTH ch values — must match COLORDEPTH_* in src/input.c */ export const COLORDEPTH_16 = 0; diff --git a/input.ts b/input.ts index e08c90d..032c6e5 100644 --- a/input.ts +++ b/input.ts @@ -8,6 +8,7 @@ import { CAP_BACKGROUND_COLOR, + CAP_COLOR_SCHEME, CAP_COLORDEPTH, CAP_CURSOR_COLOR, CAP_FOREGROUND_COLOR, @@ -410,6 +411,11 @@ export type CapabilityEvent = readonly key: "colordepth"; readonly value: ColorDepth; } + | { + readonly type: "capability"; + readonly key: "color-scheme"; + readonly value: "dark" | "light"; + } | { readonly type: "capability"; readonly key: "sync-output"; @@ -722,6 +728,13 @@ function mapCapEvent( : "256"; return { type: "capability", key: "colordepth", value }; } + case CAP_COLOR_SCHEME: + return { + type: "capability", + key: "color-scheme", + // DECDSR 997 Ps: 1 = dark, 2 = light (src/input.h). + value: native.ch === 2 ? "light" : "dark", + }; case CAP_SYNC_OUTPUT: return { type: "capability", key: "sync-output", value: native.ch !== 0 }; case CAP_KITTY_KEYBOARD: diff --git a/specs/input-spec.md b/specs/input-spec.md index 5107963..c6395ad 100644 --- a/specs/input-spec.md +++ b/specs/input-spec.md @@ -174,8 +174,8 @@ During a normal scan — with responses potentially interleaved with user input the parser MUST recognize and consume the probe responses listed in Terminfo Specification §9.1: OSC 10/11/12 theme color reports, OSC 21 kitty color reports, OSC 22 pointer shape reports, XTGETTCAP DCS replies, DECRPM mode-2026 -reports, kitty keyboard flag reports, kitty graphics APC replies, and the DA1 -device attributes report. +reports, DECDSR color-scheme reports (`CSI ? 997 ; Ps n`), kitty keyboard flag +reports, kitty graphics APC replies, and the DA1 device attributes report. For each recognized response the parser MUST emit a `CapabilityEvent` in the `scan()` return value, per Terminfo Specification §6.3 and TINV-6. Responses are diff --git a/specs/terminfo-spec.md b/specs/terminfo-spec.md index 1b2bd51..cb9ea10 100644 --- a/specs/terminfo-spec.md +++ b/specs/terminfo-spec.md @@ -230,6 +230,11 @@ type CapabilityEvent = readonly key: "colordepth"; readonly value: ColorDepth; } + | { + readonly type: "capability"; + readonly key: "color-scheme"; + readonly value: "dark" | "light"; + } | { readonly type: "capability"; readonly key: "sync-output"; @@ -262,6 +267,7 @@ Each variant maps to one probe query. The `key` identifies the capability; the | `background-color` | OSC 11 | [`osc-11-bg-color-query`](https://terminfo.dev/extensions/osc-11-bg-color-query) | | `cursor-color` | OSC 12 or OSC 21 | [`osc-12-cursor-color`](https://terminfo.dev/extensions/osc-12-cursor-color) | | `colordepth` | XTGETTCAP `RGB`/`Tc` | [`24-bit-truecolor`](https://terminfo.dev/extensions/24-bit-truecolor) | +| `color-scheme` | DECDSR 996 | [`mode-2031-color-scheme`](https://terminfo.dev/modes/mode-2031-color-scheme) | | `sync-output` | DECRPM mode 2026 | [`decset-2026-synchronized-output`](https://terminfo.dev/modes/decset-2026-synchronized-output) | | `kitty-keyboard` | `CSI ? u` | [`kitty-keyboard-protocol`](https://terminfo.dev/extensions/kitty-keyboard-protocol) | | `kitty-graphics` | APC `_G…` | [`kitty-graphics-protocol`](https://terminfo.dev/extensions/kitty-graphics-protocol) | @@ -278,6 +284,13 @@ D }` where `D` is derived from the static preserves TINV-3: the probe response takes precedence over static evidence, including in the negative direction. +**`color-scheme` reports.** The response to DECDSR 996 (`CSI ? 996 n`) and the +unsolicited notification pushed under mode 2031 are the same sequence: +`CSI ? 997 ; Ps n`, where `Ps = 1` is dark and `Ps = 2` is light. The parser +MUST emit `{ key: "color-scheme", value: "dark" | "light" }` for both, in +arrival order, so a one-shot query and a live push are indistinguishable +downstream. Reports with any other `Ps` value are consumed without an event. + **OSC 21.** An OSC 21 reply may supply any subset of `foreground-color`, `background-color`, and `cursor-color`. The parser emits one `CapabilityEvent` per field the reply carries. OSC 21 arriving alongside OSC 10/11/12 replies does @@ -301,6 +314,7 @@ interface RuntimeCapabilities extends Capabilities { readonly foreground?: Rgb; readonly background?: Rgb; readonly cursor?: Rgb; + readonly scheme?: "dark" | "light"; }; } ``` @@ -400,15 +414,21 @@ order: | 7 | Synchronized output | `CSI ? 2026 $ p` (DECRQM) | `sync-output` | | 8 | Kitty keyboard | `CSI ? u` | `kitty-keyboard` | | 9 | Kitty graphics | `APC _G i=31,s=1,v=1,a=q,t=d,f=24 ; AAAA ST` | `kitty-graphics` | -| 10 | **Fence:** DA1 | `CSI c` | (internal only) | +| 10 | Scheme subscribe | `CSI ? 2031 h` (DECSET) | (none — enables pushes) | +| 11 | Color scheme | `CSI ? 996 n` (DECDSR) | `color-scheme` | +| 12 | **Fence:** DA1 | `CSI c` | (internal only) | -Terminals answer queries in order and ignore queries they do not understand. DA1 -is answered by every terminal, so its response marks the probe complete. Any of -queries 1–9 not yet answered when the DA1 reply arrives will never be answered, -and the renderer keeps their current values. +Terminals answer queries in order and ignore queries they do not understand. +DA1 is answered by every terminal, so its response marks the probe complete. Any +of queries 1–11 not yet answered when the DA1 reply arrives will never be +answered, and the renderer keeps their current values. The batch is safe to emit unconditionally: every query is either answered or -ignored; none changes terminal state. +ignored; none changes terminal state destructively. Query 10 does change +terminal state — it subscribes to mode-2031 notifications so later scheme +changes arrive as `CSI ? 997 ; Ps n` reports on the input stream (same event as +the one-shot reply) — but unsupported terminals ignore it, and the subscription +is the intended behavior, not a side effect. ### 9.2 Response path @@ -567,8 +587,12 @@ _Non-normative._ **OSC 4 palette queries.** The 256-entry palette is not probed; the theme group covers foreground, background, and cursor (OSC 10/11/12) only. -**Theme-change notification (mode 2031).** The probe captures a snapshot; live -dark/light switching is not tracked. +**Unsolicited color reports (DECSET 2510).** Mode 2031 notifies dark/light +polarity only; the tracked-query spec (one-time OSC queries re-report their new +values when the color changes, e.g. iTerm2 3.6.6+) would deliver fresh colors +without a re-query. Deferred until a consumer needs value-level change +notifications; the host may simply re-send `OSC 10/11 ?` on a scheme change +today. **XTVERSION / DA2 / DA3 identity parsing.** The DA1 reply is used purely as a fence; terminal identification is not extracted. diff --git a/src/input.c b/src/input.c index 5c16e96..e8ab6c4 100644 --- a/src/input.c +++ b/src/input.c @@ -1075,6 +1075,14 @@ static int parse_csi_private(struct InputState *st) { emit_cap_bool(st, CAP_SYNC_OUTPUT, v == 1 || v == 2 || v == 3); } /* other modes: consumed silently */ + } else if (c == 'n' && intermediate == 0) { + /* DECDSR 996 reply / mode-2031 push: CSI ? 997 ; Ps n, where + * Ps=1 is dark and Ps=2 is light (terminfo-spec §6.3). Other + * DSR numbers are consumed silently. Uses emit_cap_color's raw + * ch (emit_cap_bool would booleanize Ps to 1). */ + if (nums[0] == 997 && ni >= 2) { + emit_cap_color(st, CAP_COLOR_SCHEME, nums[1] == 2 ? 2 : 1); + } } else if (c == 'c' && intermediate == 0) { /* DA1 fence: consumed silently, MUST NOT surface as CapabilityEvent */ st->tcap = 0; diff --git a/src/input.h b/src/input.h index fe1da50..2aecd6e 100644 --- a/src/input.h +++ b/src/input.h @@ -75,6 +75,9 @@ #define CAP_KITTY_KEYBOARD 6 #define CAP_KITTY_GRAPHICS 7 #define CAP_POINTER_SHAPE 8 +#define CAP_COLOR_SCHEME 9 + +/* CAP_COLOR_SCHEME ch values (DECDSR 997 Ps): 1=dark, 2=light. */ /* ── Modifier flags (bitwise) ─────────────────────────────────────── */ diff --git a/term.ts b/term.ts index c14d179..f33a358 100644 --- a/term.ts +++ b/term.ts @@ -40,6 +40,9 @@ export interface RuntimeCapabilities extends Capabilities { readonly foreground?: Rgb; readonly background?: Rgb; readonly cursor?: Rgb; + /** The terminal's reported dark/light scheme (DECDSR 996 reply, or + * a mode-2031 push when the OS appearance changes). */ + readonly scheme?: "dark" | "light"; }; } @@ -76,6 +79,12 @@ function applyUpdate( next = { ...current, trueColor }; break; } + case "color-scheme": + next = { + ...current, + theme: { ...current.theme, scheme: event.value }, + }; + break; case "sync-output": next = { ...current, syncOutput: event.value }; break; diff --git a/terminfo.ts b/terminfo.ts index bb3c081..5df2323 100644 --- a/terminfo.ts +++ b/terminfo.ts @@ -86,6 +86,10 @@ const PROBE = encoder.encode( "\x1b[?2026$p" + "\x1b[?u" + "\x1b_Gi=31,s=1,v=1,a=q,t=d,f=24;AAAA\x1b\\" + + // Mode 2031: subscribe to color-scheme change pushes, then query the + // current scheme (DECDSR 996). Responses are CSI ? 997 ; Ps n. + "\x1b[?2031h" + + "\x1b[?996n" + "\x1b[c", ); diff --git a/test/input.test.ts b/test/input.test.ts index 91ce14f..2694380 100644 --- a/test/input.test.ts +++ b/test/input.test.ts @@ -1008,6 +1008,28 @@ describe("terminfo integration", () => { }); }); + it("surfaces a DECDSR 996 color-scheme reply", async () => { + let { input } = await withTerminfo(); + let result = input.scan(str("\x1b[?997;2n")); + expect(result.events.length).toBe(1); + expect(result.events[0]).toEqual({ + type: "capability", + key: "color-scheme", + value: "light", + }); + }); + + it("surfaces a mode-2031 dark push and stays silent on other DSRs", async () => { + let { input } = await withTerminfo(); + let result = input.scan(str("\x1b[?997;1n\x1b[?998;1n")); + expect(result.events.length).toBe(1); + expect(result.events[0]).toEqual({ + type: "capability", + key: "color-scheme", + value: "dark", + }); + }); + it("surfaces 256-color colordepth denial from an invalid XTGETTCAP reply", async () => { let { input } = await withTerminfo(); let result = input.scan(str("\x1bP0+r\x1b\\")); diff --git a/test/term.test.ts b/test/term.test.ts index 93809a0..2e458c5 100644 --- a/test/term.test.ts +++ b/test/term.test.ts @@ -760,6 +760,22 @@ hi expect(seeded.capabilities).toHaveProperty(key, value); } }); + + it("folds a color-scheme event into the theme", async () => { + expect(term.capabilities.theme.scheme).toBeUndefined(); + term.update([{ + type: "capability", + key: "color-scheme", + value: "light", + }]); + expect(term.capabilities.theme.scheme).toBe("light"); + term.update([{ + type: "capability", + key: "color-scheme", + value: "dark", + }]); + expect(term.capabilities.theme.scheme).toBe("dark"); + }); }); describe("update", () => { diff --git a/test/terminfo.test.ts b/test/terminfo.test.ts index 007b31e..40486ba 100644 --- a/test/terminfo.test.ts +++ b/test/terminfo.test.ts @@ -186,6 +186,9 @@ const PROBE = "\x1b]10;?\x07" + "\x1b[?2026$p" + "\x1b[?u" + "\x1b_Gi=31,s=1,v=1,a=q,t=d,f=24;AAAA\x1b\\" + + // Mode 2031: subscribe to scheme pushes, then query the current scheme. + "\x1b[?2031h" + + "\x1b[?996n" + "\x1b[c"; describe("probe", () => { -- 2.51.2