Skip to content

Instantly share code, notes, and snippets.

@jake-stewart
Last active February 23, 2026 16:07
Show Gist options
  • Select an option

  • Save jake-stewart/1533b798cffcc08508803bdba8b9e878 to your computer and use it in GitHub Desktop.

Select an option

Save jake-stewart/1533b798cffcc08508803bdba8b9e878 to your computer and use it in GitHub Desktop.
Detect and handle 256-color themes
#!/usr/bin/env python3
"""
Detects whether the terminal is using a light or dark theme and automatically
adjusts 256-color palette indices so that colors render consistently regardless
of the active theme. This is especially useful when a terminal (e.g. Ghostty)
generates its 256-color palette to match the current theme — or when it doesn't,
and we need to compensate by flipping the indices ourselves.
"""
import os
import sys
import termios
import tty
# ─────────────────────────────────────────────────────────────────────────────
# Terminal Color Querying
# ─────────────────────────────────────────────────────────────────────────────
# Uses OSC (Operating System Command) escape sequences to ask the terminal
# for its current color values.
#
# References:
# - OSC 4 (palette colors) : https://ghostty.org/docs/vt/osc/4
# - OSC 10 (foreground) : https://ghostty.org/docs/vt/osc/1x
# - OSC 11 (background) : https://ghostty.org/docs/vt/osc/1x
def query_colors(codes):
"""
Query the terminal for one or more color values via OSC sequences.
Each code can be:
- "10" → foreground color
- "11" → background color
- "4;N" → palette color N (0–255)
Returns a list of (R, G, B) tuples with 8-bit channel values (0–255).
"""
fd = sys.stdin.fileno()
old = termios.tcgetattr(fd)
tty.setraw(fd)
# Send an OSC query for each requested color code.
for code in codes:
os.write(fd, f"\x1b]{code};?\x1b\\".encode())
# Send a Device Attributes request (DA1) as a sentinel — the terminal's
# response to this tells us that all prior OSC replies have been flushed.
os.write(fd, "\x1b[c".encode())
# Read until we see the DA1 response (starts with "\x1b[?").
buf = b""
while b"\x1b[?" not in buf:
buf += os.read(fd, 4096)
termios.tcsetattr(fd, termios.TCSADRAIN, old)
# Parse each "rgb:RR/GG/BB" (or "rgb:RRRR/GGGG/BBBB") payload from the
# collected responses, normalising every channel to 8 bits.
results = []
for part in buf.decode().split("rgb:")[1:]:
channels = part.split("\x1b")[0].split("/")
rgb = []
for c in channels:
if len(c) <= 2:
# 8-bit: pad single-digit values by repeating the digit.
rgb.append(int(c.rjust(2, c[0]), 16))
else:
# 16-bit (or wider): take the high-order byte.
rgb.append(int(c[:2], 16))
results.append(tuple(rgb))
return results
# ─────────────────────────────────────────────────────────────────────────────
# Theme Detection
# ─────────────────────────────────────────────────────────────────────────────
# We detect two things independently:
#
# 1. Terminal theme — is the terminal background lighter than its foreground?
# 2. Palette theme — is palette entry 16 (the "black" end of the 6×6×6 cube)
# lighter than entry 231 (the "white" end)?
#
# When both agree we call the setup "harmonious": the 256-color cube already
# matches the terminal's appearance and indices can be used as-is. When they
# disagree we flip / offset the indices so that logical colours still look
# correct.
def lightness(r, g, b):
"""Perceived luminance (ITU-R BT.709 luma coefficients)."""
return 0.2126 * r + 0.7152 * g + 0.0722 * b
# Query the four colours we need for detection.
fg, bg, c16, c231 = query_colors(["10", "11", "4;16", "4;231"])
# Whether a light theme was detected (terminal bg is brighter than fg).
is_term_light_theme = lightness(*bg) > lightness(*fg)
print(f"Terminal light theme: {is_term_light_theme}")
# Whether the 256 palette is light-oriented (entry 16 brighter than 231).
is_palette_light_theme = lightness(*c16) > lightness(*c231)
print(f"Palette light theme: {is_palette_light_theme}")
# The colours are harmonious when the terminal theme matches the palette theme.
is_harmonious = is_term_light_theme == is_palette_light_theme
print(f"Harmonious: {is_harmonious}")
# The palette is *generated* (and thus pretty) if terminal fg/bg matches the
# palette endpoints exactly — meaning the terminal built the 256 palette from
# its own theme colours.
is_generated = bg == c16 and fg == c231
print(f"Generated: {is_generated}")
# ─────────────────────────────────────────────────────────────────────────────
# Theme-Aware Color Helpers
# ─────────────────────────────────────────────────────────────────────────────
# When the palette and terminal theme are *not* harmonious (e.g. a dark palette
# on a light terminal), raw 256-colour indices produce jarring results. The
# helpers below flip the indices so that:
#
# • grey256(0) → always closest to the terminal background
# • grey256(23) → always closest to the terminal foreground
# • color256(0,0,0) → always closest to the terminal background
# • color256(5,5,5) → always closest to the terminal foreground
def grey256(n):
"""
Return a 256-colour grey-ramp index (232–255) that respects the
current theme. `n` ranges from 0 (darkest) to 23 (lightest).
When the palette is non-harmonious the ramp direction is reversed.
"""
return 232 + (n if is_harmonious else 23 - n)
def color256(r, g, b):
"""
Return a 256-colour cube index (16–231) for the given `(r, g, b)`
components, each in 0–5. When the palette is non-harmonious the
values are reflected so that the visual result matches what a
harmonious palette would produce.
"""
if not is_harmonious:
offset = 5 - max(r, g, b) - min(r, g, b)
r, g, b = r + offset, g + offset, b + offset
return 16 + r * 36 + g * 6 + b
# ─────────────────────────────────────────────────────────────────────────────
# Demo
# ─────────────────────────────────────────────────────────────────────────────
def colorise_bg(text, palette_index):
"""Wrap `text` in ANSI escape codes to set its background colour."""
return f"\x1b[48;5;{palette_index}m{text}\x1b[0m"
if __name__ == "__main__":
print()
# Subtle (low-saturation) colours
print(colorise_bg(" subtle red ", color256(1, 0, 0)))
print(colorise_bg(" subtle green ", color256(0, 1, 0)))
print(colorise_bg(" subtle blue ", color256(0, 0, 1)))
print(colorise_bg(" subtle cyan ", color256(0, 1, 1)))
print(colorise_bg(" subtle magenta ", color256(1, 0, 1)))
print(colorise_bg(" subtle yellow ", color256(1, 1, 0)))
print()
# Subtle (low-saturation) colours
print(colorise_bg(" medium red ", color256(3, 0, 0)))
print(colorise_bg(" medium green ", color256(0, 3, 0)))
print(colorise_bg(" medium blue ", color256(0, 0, 3)))
print(colorise_bg(" medium cyan ", color256(0, 3, 3)))
print(colorise_bg(" medium magenta ", color256(3, 0, 3)))
print(colorise_bg(" medium yellow ", color256(3, 3, 0)))
print()
# Full-saturation colours
print(colorise_bg(" full red ", color256(5, 0, 0)))
print(colorise_bg(" full green ", color256(0, 5, 0)))
print(colorise_bg(" full blue ", color256(0, 0, 5)))
print(colorise_bg(" full cyan ", color256(0, 5, 5)))
print(colorise_bg(" full magenta ", color256(5, 0, 5)))
print(colorise_bg(" full yellow ", color256(5, 5, 0)))
print()
# Neutrals
print(colorise_bg(" background ", color256(0, 0, 0)))
print(colorise_bg(" grey ", color256(3, 3, 3)))
print(colorise_bg(" foreground ", color256(5, 5, 5)))
print(colorise_bg(" subtle grey ", grey256(2)))
print(colorise_bg(" strong grey ", grey256(6)))
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment