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:
@@ -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
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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(">"));
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user