| import { terminal } from "./terminal"; | ||
| console.log("Querying terminal theme via OSC 11..."); | ||
| const theme = await terminal.current(); | ||
| console.log(`Terminal theme: ${theme ?? "unknown (not a TTY or unsupported)"}`); | ||
| console.log("\nListening for terminal theme changes via Mode 2031..."); | ||
| console.log("(Change your terminal theme to see events. Press Ctrl+C to exit)\n"); | ||
| terminal.on("change", (mode) => { | ||
| console.log(`Terminal theme changed: ${mode}`); | ||
| }); | ||
| process.on("SIGINT", () => { | ||
| terminal.dispose(); | ||
| process.exit(0); | ||
| }); |
+195
| import type { ThemeMode } from "./types"; | ||
| export interface Terminal { | ||
| /** Get the current terminal theme by querying background color via OSC 11 */ | ||
| current(): Promise<ThemeMode | null>; | ||
| /** Listen for terminal theme changes via Mode 2031 (event-driven, no polling) */ | ||
| on(event: "change", listener: (mode: ThemeMode) => void): void; | ||
| /** Remove a specific listener */ | ||
| off(event: "change", listener: (mode: ThemeMode) => void): void; | ||
| /** Stop listening and clean up */ | ||
| dispose(): void; | ||
| } | ||
| const ESC = "\x1b"; | ||
| const BEL = "\x07"; | ||
| // Mode 2031: enable/disable terminal theme change notifications | ||
| const MODE_2031_ENABLE = `${ESC}[?2031h`; | ||
| const MODE_2031_DISABLE = `${ESC}[?2031l`; | ||
| // OSC 11: query terminal background color | ||
| const OSC_11_QUERY = `${ESC}]11;?${BEL}`; | ||
| // Mode 2031 response pattern: ESC [ ? 997 ; {1=dark, 2=light} n | ||
| const MODE_2031_REGEX = /\x1b\[\?997;([12])n/; | ||
| // OSC 11 response pattern: ESC ] 11 ; rgb:RRRR/GGGG/BBBB (terminated by BEL or ST) | ||
| const OSC_11_REGEX = /\x1b\]11;rgb:([0-9a-fA-F]+)\/([0-9a-fA-F]+)\/([0-9a-fA-F]+)/; | ||
| /** | ||
| * Parse luminance from RGB hex values (each 1-4 hex digits, representing 8-16 bit color). | ||
| * Returns a value between 0 (black) and 1 (white). | ||
| */ | ||
| function rgbLuminance(rHex: string, gHex: string, bHex: string): number { | ||
| // Normalize to 0-1 range regardless of hex digit count | ||
| const maxVal = (1 << (rHex.length * 4)) - 1; | ||
| const r = parseInt(rHex, 16) / maxVal; | ||
| const g = parseInt(gHex, 16) / maxVal; | ||
| const b = parseInt(bHex, 16) / maxVal; | ||
| // Relative luminance (ITU-R BT.709) | ||
| return 0.2126 * r + 0.7152 * g + 0.0722 * b; | ||
| } | ||
| class TerminalImpl implements Terminal { | ||
| private listeners: Set<(mode: ThemeMode) => void> = new Set(); | ||
| private stdinHandler: ((data: Buffer) => void) | null = null; | ||
| private wasRaw = false; | ||
| private mode2031Supported: boolean | null = null; | ||
| private probeTimeout: ReturnType<typeof setTimeout> | null = null; | ||
| async current(): Promise<ThemeMode | null> { | ||
| if (!process.stdin.isTTY || !process.stdout.isTTY) return null; | ||
| return new Promise<ThemeMode | null>((resolve) => { | ||
| const timeout = setTimeout(() => { | ||
| cleanup(); | ||
| resolve(null); | ||
| }, 500); | ||
| const wasRaw = process.stdin.isRaw; | ||
| process.stdin.setRawMode(true); | ||
| process.stdin.resume(); | ||
| const onData = (data: Buffer) => { | ||
| const str = data.toString(); | ||
| const match = str.match(OSC_11_REGEX); | ||
| if (match) { | ||
| cleanup(); | ||
| const lum = rgbLuminance(match[1], match[2], match[3]); | ||
| resolve(lum < 0.5 ? "dark" : "light"); | ||
| } | ||
| }; | ||
| const cleanup = () => { | ||
| clearTimeout(timeout); | ||
| process.stdin.off("data", onData); | ||
| process.stdin.setRawMode(wasRaw); | ||
| if (!wasRaw) process.stdin.pause(); | ||
| }; | ||
| process.stdin.on("data", onData); | ||
| process.stdout.write(OSC_11_QUERY); | ||
| }); | ||
| } | ||
| on(event: "change", listener: (mode: ThemeMode) => void): void { | ||
| if (event !== "change") return; | ||
| if (!process.stdin.isTTY || !process.stdout.isTTY) return; | ||
| this.listeners.add(listener); | ||
| if (!this.stdinHandler) { | ||
| this.wasRaw = process.stdin.isRaw; | ||
| process.stdin.setRawMode(true); | ||
| process.stdin.resume(); | ||
| process.stdin.unref(); | ||
| this.stdinHandler = (data: Buffer) => { | ||
| const str = data.toString(); | ||
| // Check for Mode 2031 probe response (DECRPM: CSI ? 2031 ; Ps $ y) | ||
| // Ps=1 means set (supported), Ps=2 means reset, anything else = unsupported | ||
| if (this.mode2031Supported === null) { | ||
| const decrpm = str.match(/\x1b\[\?2031;(\d)\$y/); | ||
| if (decrpm) { | ||
| this.mode2031Supported = decrpm[1] === "1" || decrpm[1] === "2"; | ||
| if (this.probeTimeout) { | ||
| clearTimeout(this.probeTimeout); | ||
| this.probeTimeout = null; | ||
| } | ||
| if (!this.mode2031Supported) { | ||
| this.emitFallbackWarning(); | ||
| } | ||
| return; | ||
| } | ||
| } | ||
| const match = str.match(MODE_2031_REGEX); | ||
| if (match) { | ||
| // If we were still probing, the terminal clearly supports it | ||
| if (this.mode2031Supported === null) { | ||
| this.mode2031Supported = true; | ||
| if (this.probeTimeout) { | ||
| clearTimeout(this.probeTimeout); | ||
| this.probeTimeout = null; | ||
| } | ||
| } | ||
| const mode: ThemeMode = match[1] === "1" ? "dark" : "light"; | ||
| for (const fn of this.listeners) { | ||
| try { | ||
| fn(mode); | ||
| } catch (_) { | ||
| // Don't let one listener break others | ||
| } | ||
| } | ||
| } | ||
| }; | ||
| process.stdin.on("data", this.stdinHandler); | ||
| process.stdout.write(MODE_2031_ENABLE); | ||
| // Query Mode 2031 support via DECRQM (Device Control Request Mode) | ||
| process.stdout.write(`${ESC}[?2031$p`); | ||
| // If no DECRPM response within 200ms, terminal doesn't support Mode 2031 | ||
| this.probeTimeout = setTimeout(() => { | ||
| this.probeTimeout = null; | ||
| if (this.mode2031Supported === null) { | ||
| this.mode2031Supported = false; | ||
| this.emitFallbackWarning(); | ||
| } | ||
| }, 200); | ||
| } | ||
| } | ||
| off(event: "change", listener: (mode: ThemeMode) => void): void { | ||
| if (event !== "change") return; | ||
| this.listeners.delete(listener); | ||
| if (this.listeners.size === 0) { | ||
| this.dispose(); | ||
| } | ||
| } | ||
| dispose(): void { | ||
| if (this.probeTimeout) { | ||
| clearTimeout(this.probeTimeout); | ||
| this.probeTimeout = null; | ||
| } | ||
| if (this.stdinHandler) { | ||
| process.stdout.write(MODE_2031_DISABLE); | ||
| process.stdin.off("data", this.stdinHandler); | ||
| process.stdin.setRawMode(this.wasRaw); | ||
| if (!this.wasRaw) process.stdin.pause(); | ||
| this.stdinHandler = null; | ||
| } | ||
| this.listeners.clear(); | ||
| this.mode2031Supported = null; | ||
| } | ||
| private emitFallbackWarning(): void { | ||
| console.warn( | ||
| "[os-theme] Terminal does not support Mode 2031 (theme change notifications).\n" + | ||
| " terminal.on(\"change\") will not fire in this terminal.\n" + | ||
| " Use appearance.on(\"change\") for OS-level theme detection instead." | ||
| ); | ||
| } | ||
| } | ||
| /** Singleton terminal theme instance */ | ||
| export const terminal: Terminal = new TerminalImpl(); |
+5
-4
| { | ||
| "name": "os-theme", | ||
| "version": "0.0.3", | ||
| "version": "0.0.4", | ||
| "description": "Cross-platform OS theme detection (dark/light mode) with event-driven change notifications for Node.js and Bun", | ||
@@ -18,2 +18,3 @@ "module": "src/index.ts", | ||
| "dev": "bun run src/demo.ts", | ||
| "dev:terminal": "bun run src/demo-terminal.ts", | ||
| "benchmark": "./scripts/benchmark.sh", | ||
@@ -42,5 +43,5 @@ "version:sync": "node scripts/version-sync.js" | ||
| "optionalDependencies": { | ||
| "@os-theme/darwin-arm64": "0.0.3", | ||
| "@os-theme/linux-x64": "0.0.3", | ||
| "@os-theme/win32-x64": "0.0.3" | ||
| "@os-theme/darwin-arm64": "0.0.4", | ||
| "@os-theme/linux-x64": "0.0.4", | ||
| "@os-theme/win32-x64": "0.0.4" | ||
| }, | ||
@@ -47,0 +48,0 @@ "devDependencies": { |
+62
-0
| # os-theme | ||
| [](https://www.npmjs.com/package/os-theme) | ||
| [](https://github.com/basiclines/os-theme/actions/workflows/ci.yml) | ||
| [](https://opensource.org/licenses/MIT) | ||
| [](https://www.npmjs.com/package/os-theme) | ||
| Cross-platform OS theme detection (dark/light mode) with change notifications for Node.js and Bun. | ||
@@ -12,2 +17,3 @@ | ||
| - Zero JS dependencies | ||
| - Terminal-level theme detection via [Mode 2031](https://contour-terminal.org/vt-extensions/color-palette-update-notifications/) and OSC 11 (no native code needed) | ||
@@ -132,2 +138,56 @@ ## Install | ||
| ## Terminal Theme Detection | ||
| For terminal applications, os-theme can detect the **terminal's** theme directly — independent of the OS setting. This is useful when a user runs a dark terminal on a light OS, or vice versa. | ||
| Two mechanisms are available, both pure JS (no native code): | ||
| ### `terminal.current(): Promise<ThemeMode | null>` | ||
| Query the terminal's background color via [OSC 11](https://invisible-island.net/xterm/ctlseqs/ctlseqs.html#h3-Operating-System-Commands) and classify it as dark or light based on luminance. Returns `null` if not running in a TTY or the terminal doesn't respond. | ||
| ```typescript | ||
| import { terminal } from "os-theme"; | ||
| const theme = await terminal.current(); // "dark", "light", or null | ||
| ``` | ||
| This is a one-shot query — no polling. | ||
| ### `terminal.on("change", listener): void` | ||
| Listen for terminal theme changes via [Mode 2031](https://contour-terminal.org/vt-extensions/color-palette-update-notifications/). The terminal pushes a notification when its color scheme changes — no polling needed. | ||
| ```typescript | ||
| import { terminal } from "os-theme"; | ||
| terminal.on("change", (mode) => { | ||
| console.log(`Terminal theme changed: ${mode}`); | ||
| }); | ||
| // Clean up when done | ||
| terminal.dispose(); | ||
| ``` | ||
| ### Terminal support | ||
| | Terminal | `current()` (OSC 11) | `on("change")` (Mode 2031) | | ||
| |----------|:--------------------:|:--------------------------:| | ||
| | Ghostty | Yes | Yes | | ||
| | Kitty (>=0.38.1) | Yes | Yes | | ||
| | Contour (>=0.4.0) | Yes | Yes | | ||
| | VTE (>=0.82) | Yes | Yes | | ||
| | GNOME Terminal | Yes | Via VTE | | ||
| | iTerm2 | Yes | No | | ||
| | Terminal.app | Yes | No | | ||
| | Windows Terminal (>=1.22) | Yes | No | | ||
| | Alacritty | Yes | No | | ||
| | WezTerm | Yes | No | | ||
| | Konsole | Yes | No | | ||
| | foot | Yes | No | | ||
| | xterm | Yes | No | | ||
| | tmux | Cached | No | | ||
| When Mode 2031 is not supported, `terminal.on("change")` won't fire — fall back to `appearance.on("change")` for OS-level change detection. | ||
| ## Platform Details | ||
@@ -279,2 +339,3 @@ | ||
| bun run dev # interactive demo — toggle your OS theme to see events | ||
| bun run dev:terminal # terminal theme demo — toggle your terminal theme | ||
| bun run benchmark # measure resource usage and event latency | ||
@@ -304,2 +365,3 @@ ``` | ||
| - [x] CI/CD with GitHub Actions matrix builds (macOS, Windows, Linux) | ||
| - [x] Terminal-level theme detection via Mode 2031 and OSC 11 | ||
| - [ ] `bun build --compile` for single-executable distribution | ||
@@ -306,0 +368,0 @@ |
+2
-1
@@ -5,3 +5,4 @@ import { dlopen, FFIType, suffix, JSCallback } from "bun:ffi"; | ||
| const NATIVE_LIB_NAME = `libos_theme.${suffix}`; | ||
| const IS_WINDOWS = process.platform === "win32"; | ||
| const NATIVE_LIB_NAME = IS_WINDOWS ? `os_theme.${suffix}` : `libos_theme.${suffix}`; | ||
@@ -8,0 +9,0 @@ function findNativeLib(): string { |
+2
-0
@@ -10,2 +10,4 @@ import type { Appearance, ThemeMode } from "./types"; | ||
| export type { ThemeMode, Appearance } from "./types"; | ||
| export type { Terminal } from "./terminal"; | ||
| export { terminal } from "./terminal"; | ||
@@ -12,0 +14,0 @@ class AppearanceImpl implements Appearance { |
AI-detected potential code anomaly
Supply chain riskAI has identified unusual behaviors that may pose a security risk.
Found 1 instance in 1 package
31559
50.7%11
22.22%423
73.36%368
20.26%2
-33.33%