fix(tui): improve color contrast for light-background terminals (#40345)

* fix(tui): improve colour contrast for light-background terminals (#38636)

Detect light terminal backgrounds via COLORFGBG and apply a WCAG
AA-compliant light palette. Adds OPENCLAW_THEME=light|dark env var
override for terminals without auto-detection.

Uses proper sRGB linearisation and WCAG 2.1 contrast ratios to pick
whichever text palette (dark or light) has higher contrast against
the detected background colour.

Co-authored-by: ademczuk <ademczuk@users.noreply.github.com>

* Update CHANGELOG.md

---------

Co-authored-by: ademczuk <andrew.demczuk@gmail.com>
Co-authored-by: ademczuk <ademczuk@users.noreply.github.com>
This commit is contained in:
Vincent Koc
2026-03-08 16:17:28 -07:00
committed by GitHub
parent 211f68f8ad
commit a3dc4b5a57
6 changed files with 381 additions and 5 deletions

View File

@@ -42,6 +42,7 @@ Docs: https://docs.openclaw.ai
- macOS/Tailscale gateway discovery: keep Tailscale Serve probing alive when other remote gateways are already discovered, prefer direct transport for resolved `.ts.net` and Tailscale Serve gateways, and set `TERM=dumb` for GUI-launched Tailscale CLI discovery. (#40167) thanks @ngutman.
- Podman/setup: fix `cannot chdir: Permission denied` in `run_as_user` when `setup-podman.sh` is invoked from a directory the target user cannot access, by wrapping user-switch calls in a subshell that cd's to `/tmp` with `/` fallback. (#39435) Thanks @langdon and @jlcbk.
- Podman/SELinux: auto-detect SELinux enforcing/permissive mode and add `:Z` relabel to bind mounts in `run-openclaw-podman.sh` and the Quadlet template, fixing `EACCES` on Fedora/RHEL hosts. Supports `OPENCLAW_BIND_MOUNT_OPTIONS` override. (#39449) Thanks @langdon and @githubbzxs.
- TUI/theme: detect light terminal backgrounds via `COLORFGBG` and pick a WCAG AA-compliant light palette, with `OPENCLAW_THEME=light|dark` override for terminals without auto-detection. (#38636) Thanks @ademczuk and @vincentkoc.
## 2026.3.7

View File

@@ -68,6 +68,12 @@ OpenClaw also injects context markers into spawned child processes:
These are runtime markers (not required user config). They can be used in shell/profile logic
to apply context-specific rules.
## UI env vars
- `OPENCLAW_THEME=light`: force the light TUI palette when your terminal has a light background.
- `OPENCLAW_THEME=dark`: force the dark TUI palette.
- `COLORFGBG`: if your terminal exports it, OpenClaw uses the background color hint to auto-pick the TUI palette.
## Env var substitution in config
You can reference env vars directly in config string values using `${VAR_NAME}` syntax:

View File

@@ -122,6 +122,12 @@ Other Gateway slash commands (for example, `/context`) are forwarded to the Gate
- Ctrl+O toggles between collapsed/expanded views.
- While tools run, partial updates stream into the same card.
## Terminal colors
- The TUI keeps assistant body text in your terminal's default foreground so dark and light terminals both stay readable.
- If your terminal uses a light background and auto-detection is wrong, set `OPENCLAW_THEME=light` before launching `openclaw tui`.
- To force the original dark palette instead, set `OPENCLAW_THEME=dark`.
## History + streaming
- On connect, the TUI loads the latest history (default 200 messages).

View File

@@ -6,7 +6,55 @@ type HighlightTheme = Record<string, (text: string) => string>;
* Syntax highlighting theme for code blocks.
* Uses chalk functions to style different token types.
*/
export function createSyntaxTheme(fallback: (text: string) => string): HighlightTheme {
export function createSyntaxTheme(
fallback: (text: string) => string,
light = false,
): HighlightTheme {
if (light) {
return {
keyword: chalk.hex("#AF00DB"),
built_in: chalk.hex("#267F99"),
type: chalk.hex("#267F99"),
literal: chalk.hex("#0000FF"),
number: chalk.hex("#098658"),
string: chalk.hex("#A31515"),
regexp: chalk.hex("#811F3F"),
symbol: chalk.hex("#098658"),
class: chalk.hex("#267F99"),
function: chalk.hex("#795E26"),
title: chalk.hex("#795E26"),
params: chalk.hex("#001080"),
comment: chalk.hex("#008000"),
doctag: chalk.hex("#008000"),
meta: chalk.hex("#001080"),
"meta-keyword": chalk.hex("#AF00DB"),
"meta-string": chalk.hex("#A31515"),
section: chalk.hex("#795E26"),
tag: chalk.hex("#800000"),
name: chalk.hex("#001080"),
attr: chalk.hex("#C50000"),
attribute: chalk.hex("#C50000"),
variable: chalk.hex("#001080"),
bullet: chalk.hex("#795E26"),
code: chalk.hex("#A31515"),
emphasis: chalk.italic,
strong: chalk.bold,
formula: chalk.hex("#AF00DB"),
link: chalk.hex("#267F99"),
quote: chalk.hex("#008000"),
addition: chalk.hex("#098658"),
deletion: chalk.hex("#A31515"),
"selector-tag": chalk.hex("#800000"),
"selector-id": chalk.hex("#800000"),
"selector-class": chalk.hex("#800000"),
"selector-attr": chalk.hex("#800000"),
"selector-pseudo": chalk.hex("#800000"),
"template-tag": chalk.hex("#AF00DB"),
"template-variable": chalk.hex("#001080"),
default: fallback,
};
}
return {
keyword: chalk.hex("#C586C0"), // purple - if, const, function, etc.
built_in: chalk.hex("#4EC9B0"), // teal - console, Math, etc.

View File

@@ -1,4 +1,4 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
const cliHighlightMocks = vi.hoisted(() => ({
highlight: vi.fn((code: string) => code),
@@ -13,6 +13,25 @@ const { markdownTheme, searchableSelectListTheme, selectListTheme, theme } =
const stripAnsi = (str: string) =>
str.replace(new RegExp(`${String.fromCharCode(27)}\\[[0-9;]*m`, "g"), "");
function relativeLuminance(hex: string): number {
const channels = hex
.replace("#", "")
.match(/.{2}/g)
?.map((part) => Number.parseInt(part, 16) / 255)
.map((channel) => (channel <= 0.03928 ? channel / 12.92 : ((channel + 0.055) / 1.055) ** 2.4));
if (!channels || channels.length !== 3) {
throw new Error(`invalid color: ${hex}`);
}
return 0.2126 * channels[0] + 0.7152 * channels[1] + 0.0722 * channels[2];
}
function contrastRatio(foreground: string, background: string): number {
const [lighter, darker] = [relativeLuminance(foreground), relativeLuminance(background)].toSorted(
(a, b) => b - a,
);
return (lighter + 0.05) / (darker + 0.05);
}
describe("markdownTheme", () => {
describe("highlightCode", () => {
beforeEach(() => {
@@ -61,6 +80,207 @@ describe("theme", () => {
});
});
describe("light background detection", () => {
const originalEnv = { ...process.env };
afterEach(() => {
process.env = { ...originalEnv };
vi.resetModules();
});
async function importThemeWithEnv(env: Record<string, string | undefined>) {
vi.resetModules();
for (const [key, value] of Object.entries(env)) {
if (value === undefined) {
delete process.env[key];
} else {
process.env[key] = value;
}
}
return import("./theme.js");
}
it("uses dark palette by default", async () => {
const mod = await importThemeWithEnv({
OPENCLAW_THEME: undefined,
COLORFGBG: undefined,
});
expect(mod.lightMode).toBe(false);
});
it("selects light palette when OPENCLAW_THEME=light", async () => {
const mod = await importThemeWithEnv({ OPENCLAW_THEME: "light" });
expect(mod.lightMode).toBe(true);
});
it("selects dark palette when OPENCLAW_THEME=dark", async () => {
const mod = await importThemeWithEnv({ OPENCLAW_THEME: "dark" });
expect(mod.lightMode).toBe(false);
});
it("treats OPENCLAW_THEME case-insensitively", async () => {
const mod = await importThemeWithEnv({ OPENCLAW_THEME: "LiGhT" });
expect(mod.lightMode).toBe(true);
});
it("detects light background from COLORFGBG", async () => {
const mod = await importThemeWithEnv({
OPENCLAW_THEME: undefined,
COLORFGBG: "0;15",
});
expect(mod.lightMode).toBe(true);
});
it("treats COLORFGBG bg=7 (silver) as light", async () => {
const mod = await importThemeWithEnv({
OPENCLAW_THEME: undefined,
COLORFGBG: "0;7",
});
expect(mod.lightMode).toBe(true);
});
it("treats COLORFGBG bg=8 (bright black / dark gray) as dark", async () => {
const mod = await importThemeWithEnv({
OPENCLAW_THEME: undefined,
COLORFGBG: "15;8",
});
expect(mod.lightMode).toBe(false);
});
it("treats COLORFGBG bg < 7 as dark", async () => {
const mod = await importThemeWithEnv({
OPENCLAW_THEME: undefined,
COLORFGBG: "15;0",
});
expect(mod.lightMode).toBe(false);
});
it("treats 256-color COLORFGBG bg=232 (near-black greyscale) as dark", async () => {
const mod = await importThemeWithEnv({
OPENCLAW_THEME: undefined,
COLORFGBG: "15;232",
});
expect(mod.lightMode).toBe(false);
});
it("treats 256-color COLORFGBG bg=255 (near-white greyscale) as light", async () => {
const mod = await importThemeWithEnv({
OPENCLAW_THEME: undefined,
COLORFGBG: "0;255",
});
expect(mod.lightMode).toBe(true);
});
it("treats 256-color COLORFGBG bg=231 (white cube entry) as light", async () => {
const mod = await importThemeWithEnv({
OPENCLAW_THEME: undefined,
COLORFGBG: "0;231",
});
expect(mod.lightMode).toBe(true);
});
it("treats 256-color COLORFGBG bg=16 (black cube entry) as dark", async () => {
const mod = await importThemeWithEnv({
OPENCLAW_THEME: undefined,
COLORFGBG: "15;16",
});
expect(mod.lightMode).toBe(false);
});
it("treats bright 256-color green backgrounds as light when dark text contrasts better", async () => {
const mod = await importThemeWithEnv({
OPENCLAW_THEME: undefined,
COLORFGBG: "15;34",
});
expect(mod.lightMode).toBe(true);
});
it("treats bright 256-color cyan backgrounds as light when dark text contrasts better", async () => {
const mod = await importThemeWithEnv({
OPENCLAW_THEME: undefined,
COLORFGBG: "15;39",
});
expect(mod.lightMode).toBe(true);
});
it("falls back to dark mode for invalid COLORFGBG values", async () => {
const mod = await importThemeWithEnv({
OPENCLAW_THEME: undefined,
COLORFGBG: "garbage",
});
expect(mod.lightMode).toBe(false);
});
it("ignores pathological COLORFGBG values", async () => {
const mod = await importThemeWithEnv({
OPENCLAW_THEME: undefined,
COLORFGBG: "0;".repeat(40),
});
expect(mod.lightMode).toBe(false);
});
it("OPENCLAW_THEME overrides COLORFGBG", async () => {
const mod = await importThemeWithEnv({
OPENCLAW_THEME: "dark",
COLORFGBG: "0;15",
});
expect(mod.lightMode).toBe(false);
});
it("keeps assistantText as identity in both modes", async () => {
const lightMod = await importThemeWithEnv({ OPENCLAW_THEME: "light" });
const darkMod = await importThemeWithEnv({ OPENCLAW_THEME: "dark" });
expect(lightMod.theme.assistantText("hello")).toBe("hello");
expect(darkMod.theme.assistantText("hello")).toBe("hello");
});
});
describe("light palette accessibility", () => {
it("keeps light theme text colors at WCAG AA contrast or better", async () => {
vi.resetModules();
process.env.OPENCLAW_THEME = "light";
const mod = await import("./theme.js");
const backgrounds = {
page: "#FFFFFF",
user: mod.lightPalette.userBg,
pending: mod.lightPalette.toolPendingBg,
success: mod.lightPalette.toolSuccessBg,
error: mod.lightPalette.toolErrorBg,
code: mod.lightPalette.codeBlock,
};
const textPairs = [
[mod.lightPalette.text, backgrounds.page],
[mod.lightPalette.dim, backgrounds.page],
[mod.lightPalette.accent, backgrounds.page],
[mod.lightPalette.accentSoft, backgrounds.page],
[mod.lightPalette.systemText, backgrounds.page],
[mod.lightPalette.link, backgrounds.page],
[mod.lightPalette.quote, backgrounds.page],
[mod.lightPalette.error, backgrounds.page],
[mod.lightPalette.success, backgrounds.page],
[mod.lightPalette.userText, backgrounds.user],
[mod.lightPalette.dim, backgrounds.pending],
[mod.lightPalette.dim, backgrounds.success],
[mod.lightPalette.dim, backgrounds.error],
[mod.lightPalette.toolTitle, backgrounds.pending],
[mod.lightPalette.toolTitle, backgrounds.success],
[mod.lightPalette.toolTitle, backgrounds.error],
[mod.lightPalette.toolOutput, backgrounds.pending],
[mod.lightPalette.toolOutput, backgrounds.success],
[mod.lightPalette.toolOutput, backgrounds.error],
[mod.lightPalette.code, backgrounds.code],
[mod.lightPalette.border, backgrounds.page],
[mod.lightPalette.quoteBorder, backgrounds.page],
[mod.lightPalette.codeBorder, backgrounds.page],
] as const;
for (const [foreground, background] of textPairs) {
expect(contrastRatio(foreground, background)).toBeGreaterThanOrEqual(4.5);
}
});
});
describe("list themes", () => {
it("reuses shared select-list styles in searchable list theme", () => {
expect(searchableSelectListTheme.selectedPrefix(">")).toBe(selectListTheme.selectedPrefix(">"));

View File

@@ -9,7 +9,76 @@ import { highlight, supportsLanguage } from "cli-highlight";
import type { SearchableSelectListTheme } from "../components/searchable-select-list.js";
import { createSyntaxTheme } from "./syntax-theme.js";
const palette = {
const DARK_TEXT = "#E8E3D5";
const LIGHT_TEXT = "#1E1E1E";
const XTERM_LEVELS = [0, 95, 135, 175, 215, 255] as const;
function channelToSrgb(value: number): number {
const normalized = value / 255;
return normalized <= 0.03928 ? normalized / 12.92 : ((normalized + 0.055) / 1.055) ** 2.4;
}
function relativeLuminanceRgb(r: number, g: number, b: number): number {
const red = channelToSrgb(r);
const green = channelToSrgb(g);
const blue = channelToSrgb(b);
return 0.2126 * red + 0.7152 * green + 0.0722 * blue;
}
function relativeLuminanceHex(hex: string): number {
return relativeLuminanceRgb(
Number.parseInt(hex.slice(1, 3), 16),
Number.parseInt(hex.slice(3, 5), 16),
Number.parseInt(hex.slice(5, 7), 16),
);
}
function contrastRatio(background: number, foregroundHex: string): number {
const foreground = relativeLuminanceHex(foregroundHex);
const lighter = Math.max(background, foreground);
const darker = Math.min(background, foreground);
return (lighter + 0.05) / (darker + 0.05);
}
function pickHigherContrastText(r: number, g: number, b: number): boolean {
const background = relativeLuminanceRgb(r, g, b);
return contrastRatio(background, LIGHT_TEXT) >= contrastRatio(background, DARK_TEXT);
}
function isLightBackground(): boolean {
const explicit = process.env.OPENCLAW_THEME?.toLowerCase();
if (explicit === "light") {
return true;
}
if (explicit === "dark") {
return false;
}
const colorfgbg = process.env.COLORFGBG;
if (colorfgbg && colorfgbg.length <= 64) {
const sep = colorfgbg.lastIndexOf(";");
const bg = Number.parseInt(sep >= 0 ? colorfgbg.slice(sep + 1) : colorfgbg, 10);
if (bg >= 0 && bg <= 255) {
if (bg <= 15) {
return bg === 7 || bg === 15;
}
if (bg >= 232) {
return bg >= 244;
}
const cubeIndex = bg - 16;
const bVal = XTERM_LEVELS[cubeIndex % 6];
const gVal = XTERM_LEVELS[Math.floor(cubeIndex / 6) % 6];
const rVal = XTERM_LEVELS[Math.floor(cubeIndex / 36)];
return pickHigherContrastText(rVal, gVal, bVal);
}
}
return false;
}
/** Whether the terminal has a light background. Exported for testing only. */
export const lightMode = isLightBackground();
export const darkPalette = {
text: "#E8E3D5",
dim: "#7B7F87",
accent: "#F6C453",
@@ -31,12 +100,38 @@ const palette = {
link: "#7DD3A5",
error: "#F97066",
success: "#7DD3A5",
};
} as const;
export const lightPalette = {
text: "#1E1E1E",
dim: "#5B6472",
accent: "#B45309",
accentSoft: "#C2410C",
border: "#5B6472",
userBg: "#F3F0E8",
userText: "#1E1E1E",
systemText: "#4B5563",
toolPendingBg: "#EFF6FF",
toolSuccessBg: "#ECFDF5",
toolErrorBg: "#FEF2F2",
toolTitle: "#B45309",
toolOutput: "#374151",
quote: "#1D4ED8",
quoteBorder: "#2563EB",
code: "#92400E",
codeBlock: "#F9FAFB",
codeBorder: "#92400E",
link: "#047857",
error: "#DC2626",
success: "#047857",
} as const;
export const palette = lightMode ? lightPalette : darkPalette;
const fg = (hex: string) => (text: string) => chalk.hex(hex)(text);
const bg = (hex: string) => (text: string) => chalk.bgHex(hex)(text);
const syntaxTheme = createSyntaxTheme(fg(palette.code));
const syntaxTheme = createSyntaxTheme(fg(palette.code), lightMode);
/**
* Highlight code with syntax coloring.