diff --git a/CHANGELOG.md b/CHANGELOG.md index 905f285df51..72ac0fab2ce 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/docs/help/environment.md b/docs/help/environment.md index 7fa1fdfa6c5..860129bde37 100644 --- a/docs/help/environment.md +++ b/docs/help/environment.md @@ -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: diff --git a/docs/web/tui.md b/docs/web/tui.md index 1553fd5d668..0c09cb1f877 100644 --- a/docs/web/tui.md +++ b/docs/web/tui.md @@ -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). diff --git a/src/tui/theme/syntax-theme.ts b/src/tui/theme/syntax-theme.ts index ba29d5012db..d0aea2d5a9c 100644 --- a/src/tui/theme/syntax-theme.ts +++ b/src/tui/theme/syntax-theme.ts @@ -6,7 +6,55 @@ type HighlightTheme = Record 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. diff --git a/src/tui/theme/theme.test.ts b/src/tui/theme/theme.test.ts index dd692304599..50aa349b689 100644 --- a/src/tui/theme/theme.test.ts +++ b/src/tui/theme/theme.test.ts @@ -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) { + 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(">")); diff --git a/src/tui/theme/theme.ts b/src/tui/theme/theme.ts index 9b2f1ad27c7..1af4154095e 100644 --- a/src/tui/theme/theme.ts +++ b/src/tui/theme/theme.ts @@ -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.