🚀 Socket Launch Week Day 5:Introducing Repository Access Permissions and Custom Roles.Learn more
Sign In

os-theme

Package Overview
Dependencies
Maintainers
1
Versions
7
Alerts
File Explorer

Advanced tools

Socket logo

Install Socket

Detect and block malicious and high-risk dependencies

Install

os-theme - npm Package Compare versions

Comparing version
0.0.3
to
0.0.4
+17
src/demo-terminal.ts
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);
});
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": {

# os-theme
[![npm version](https://img.shields.io/npm/v/os-theme)](https://www.npmjs.com/package/os-theme)
[![CI](https://github.com/basiclines/os-theme/actions/workflows/ci.yml/badge.svg)](https://github.com/basiclines/os-theme/actions/workflows/ci.yml)
[![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](https://opensource.org/licenses/MIT)
[![npm downloads](https://img.shields.io/npm/dm/os-theme)](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 @@

@@ -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 {

@@ -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 {