Skip to content

Commit a3dc4b5

Browse files
vincentkocademczuk
andauthored
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 <[email protected]> * Update CHANGELOG.md --------- Co-authored-by: ademczuk <[email protected]> Co-authored-by: ademczuk <[email protected]>
1 parent 211f68f commit a3dc4b5

File tree

6 files changed

+381
-5
lines changed

6 files changed

+381
-5
lines changed

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@ Docs: https://docs.openclaw.ai
4242
- 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.
4343
- 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.
4444
- 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.
45+
- 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.
4546

4647
## 2026.3.7
4748

docs/help/environment.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,12 @@ OpenClaw also injects context markers into spawned child processes:
6868
These are runtime markers (not required user config). They can be used in shell/profile logic
6969
to apply context-specific rules.
7070

71+
## UI env vars
72+
73+
- `OPENCLAW_THEME=light`: force the light TUI palette when your terminal has a light background.
74+
- `OPENCLAW_THEME=dark`: force the dark TUI palette.
75+
- `COLORFGBG`: if your terminal exports it, OpenClaw uses the background color hint to auto-pick the TUI palette.
76+
7177
## Env var substitution in config
7278

7379
You can reference env vars directly in config string values using `${VAR_NAME}` syntax:

docs/web/tui.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -122,6 +122,12 @@ Other Gateway slash commands (for example, `/context`) are forwarded to the Gate
122122
- Ctrl+O toggles between collapsed/expanded views.
123123
- While tools run, partial updates stream into the same card.
124124

125+
## Terminal colors
126+
127+
- The TUI keeps assistant body text in your terminal's default foreground so dark and light terminals both stay readable.
128+
- If your terminal uses a light background and auto-detection is wrong, set `OPENCLAW_THEME=light` before launching `openclaw tui`.
129+
- To force the original dark palette instead, set `OPENCLAW_THEME=dark`.
130+
125131
## History + streaming
126132

127133
- On connect, the TUI loads the latest history (default 200 messages).

src/tui/theme/syntax-theme.ts

Lines changed: 49 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,55 @@ type HighlightTheme = Record<string, (text: string) => string>;
66
* Syntax highlighting theme for code blocks.
77
* Uses chalk functions to style different token types.
88
*/
9-
export function createSyntaxTheme(fallback: (text: string) => string): HighlightTheme {
9+
export function createSyntaxTheme(
10+
fallback: (text: string) => string,
11+
light = false,
12+
): HighlightTheme {
13+
if (light) {
14+
return {
15+
keyword: chalk.hex("#AF00DB"),
16+
built_in: chalk.hex("#267F99"),
17+
type: chalk.hex("#267F99"),
18+
literal: chalk.hex("#0000FF"),
19+
number: chalk.hex("#098658"),
20+
string: chalk.hex("#A31515"),
21+
regexp: chalk.hex("#811F3F"),
22+
symbol: chalk.hex("#098658"),
23+
class: chalk.hex("#267F99"),
24+
function: chalk.hex("#795E26"),
25+
title: chalk.hex("#795E26"),
26+
params: chalk.hex("#001080"),
27+
comment: chalk.hex("#008000"),
28+
doctag: chalk.hex("#008000"),
29+
meta: chalk.hex("#001080"),
30+
"meta-keyword": chalk.hex("#AF00DB"),
31+
"meta-string": chalk.hex("#A31515"),
32+
section: chalk.hex("#795E26"),
33+
tag: chalk.hex("#800000"),
34+
name: chalk.hex("#001080"),
35+
attr: chalk.hex("#C50000"),
36+
attribute: chalk.hex("#C50000"),
37+
variable: chalk.hex("#001080"),
38+
bullet: chalk.hex("#795E26"),
39+
code: chalk.hex("#A31515"),
40+
emphasis: chalk.italic,
41+
strong: chalk.bold,
42+
formula: chalk.hex("#AF00DB"),
43+
link: chalk.hex("#267F99"),
44+
quote: chalk.hex("#008000"),
45+
addition: chalk.hex("#098658"),
46+
deletion: chalk.hex("#A31515"),
47+
"selector-tag": chalk.hex("#800000"),
48+
"selector-id": chalk.hex("#800000"),
49+
"selector-class": chalk.hex("#800000"),
50+
"selector-attr": chalk.hex("#800000"),
51+
"selector-pseudo": chalk.hex("#800000"),
52+
"template-tag": chalk.hex("#AF00DB"),
53+
"template-variable": chalk.hex("#001080"),
54+
default: fallback,
55+
};
56+
}
57+
1058
return {
1159
keyword: chalk.hex("#C586C0"), // purple - if, const, function, etc.
1260
built_in: chalk.hex("#4EC9B0"), // teal - console, Math, etc.

src/tui/theme/theme.test.ts

Lines changed: 221 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { beforeEach, describe, expect, it, vi } from "vitest";
1+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
22

33
const cliHighlightMocks = vi.hoisted(() => ({
44
highlight: vi.fn((code: string) => code),
@@ -13,6 +13,25 @@ const { markdownTheme, searchableSelectListTheme, selectListTheme, theme } =
1313
const stripAnsi = (str: string) =>
1414
str.replace(new RegExp(`${String.fromCharCode(27)}\\[[0-9;]*m`, "g"), "");
1515

16+
function relativeLuminance(hex: string): number {
17+
const channels = hex
18+
.replace("#", "")
19+
.match(/.{2}/g)
20+
?.map((part) => Number.parseInt(part, 16) / 255)
21+
.map((channel) => (channel <= 0.03928 ? channel / 12.92 : ((channel + 0.055) / 1.055) ** 2.4));
22+
if (!channels || channels.length !== 3) {
23+
throw new Error(`invalid color: ${hex}`);
24+
}
25+
return 0.2126 * channels[0] + 0.7152 * channels[1] + 0.0722 * channels[2];
26+
}
27+
28+
function contrastRatio(foreground: string, background: string): number {
29+
const [lighter, darker] = [relativeLuminance(foreground), relativeLuminance(background)].toSorted(
30+
(a, b) => b - a,
31+
);
32+
return (lighter + 0.05) / (darker + 0.05);
33+
}
34+
1635
describe("markdownTheme", () => {
1736
describe("highlightCode", () => {
1837
beforeEach(() => {
@@ -61,6 +80,207 @@ describe("theme", () => {
6180
});
6281
});
6382

83+
describe("light background detection", () => {
84+
const originalEnv = { ...process.env };
85+
86+
afterEach(() => {
87+
process.env = { ...originalEnv };
88+
vi.resetModules();
89+
});
90+
91+
async function importThemeWithEnv(env: Record<string, string | undefined>) {
92+
vi.resetModules();
93+
for (const [key, value] of Object.entries(env)) {
94+
if (value === undefined) {
95+
delete process.env[key];
96+
} else {
97+
process.env[key] = value;
98+
}
99+
}
100+
return import("./theme.js");
101+
}
102+
103+
it("uses dark palette by default", async () => {
104+
const mod = await importThemeWithEnv({
105+
OPENCLAW_THEME: undefined,
106+
COLORFGBG: undefined,
107+
});
108+
expect(mod.lightMode).toBe(false);
109+
});
110+
111+
it("selects light palette when OPENCLAW_THEME=light", async () => {
112+
const mod = await importThemeWithEnv({ OPENCLAW_THEME: "light" });
113+
expect(mod.lightMode).toBe(true);
114+
});
115+
116+
it("selects dark palette when OPENCLAW_THEME=dark", async () => {
117+
const mod = await importThemeWithEnv({ OPENCLAW_THEME: "dark" });
118+
expect(mod.lightMode).toBe(false);
119+
});
120+
121+
it("treats OPENCLAW_THEME case-insensitively", async () => {
122+
const mod = await importThemeWithEnv({ OPENCLAW_THEME: "LiGhT" });
123+
expect(mod.lightMode).toBe(true);
124+
});
125+
126+
it("detects light background from COLORFGBG", async () => {
127+
const mod = await importThemeWithEnv({
128+
OPENCLAW_THEME: undefined,
129+
COLORFGBG: "0;15",
130+
});
131+
expect(mod.lightMode).toBe(true);
132+
});
133+
134+
it("treats COLORFGBG bg=7 (silver) as light", async () => {
135+
const mod = await importThemeWithEnv({
136+
OPENCLAW_THEME: undefined,
137+
COLORFGBG: "0;7",
138+
});
139+
expect(mod.lightMode).toBe(true);
140+
});
141+
142+
it("treats COLORFGBG bg=8 (bright black / dark gray) as dark", async () => {
143+
const mod = await importThemeWithEnv({
144+
OPENCLAW_THEME: undefined,
145+
COLORFGBG: "15;8",
146+
});
147+
expect(mod.lightMode).toBe(false);
148+
});
149+
150+
it("treats COLORFGBG bg < 7 as dark", async () => {
151+
const mod = await importThemeWithEnv({
152+
OPENCLAW_THEME: undefined,
153+
COLORFGBG: "15;0",
154+
});
155+
expect(mod.lightMode).toBe(false);
156+
});
157+
158+
it("treats 256-color COLORFGBG bg=232 (near-black greyscale) as dark", async () => {
159+
const mod = await importThemeWithEnv({
160+
OPENCLAW_THEME: undefined,
161+
COLORFGBG: "15;232",
162+
});
163+
expect(mod.lightMode).toBe(false);
164+
});
165+
166+
it("treats 256-color COLORFGBG bg=255 (near-white greyscale) as light", async () => {
167+
const mod = await importThemeWithEnv({
168+
OPENCLAW_THEME: undefined,
169+
COLORFGBG: "0;255",
170+
});
171+
expect(mod.lightMode).toBe(true);
172+
});
173+
174+
it("treats 256-color COLORFGBG bg=231 (white cube entry) as light", async () => {
175+
const mod = await importThemeWithEnv({
176+
OPENCLAW_THEME: undefined,
177+
COLORFGBG: "0;231",
178+
});
179+
expect(mod.lightMode).toBe(true);
180+
});
181+
182+
it("treats 256-color COLORFGBG bg=16 (black cube entry) as dark", async () => {
183+
const mod = await importThemeWithEnv({
184+
OPENCLAW_THEME: undefined,
185+
COLORFGBG: "15;16",
186+
});
187+
expect(mod.lightMode).toBe(false);
188+
});
189+
190+
it("treats bright 256-color green backgrounds as light when dark text contrasts better", async () => {
191+
const mod = await importThemeWithEnv({
192+
OPENCLAW_THEME: undefined,
193+
COLORFGBG: "15;34",
194+
});
195+
expect(mod.lightMode).toBe(true);
196+
});
197+
198+
it("treats bright 256-color cyan backgrounds as light when dark text contrasts better", async () => {
199+
const mod = await importThemeWithEnv({
200+
OPENCLAW_THEME: undefined,
201+
COLORFGBG: "15;39",
202+
});
203+
expect(mod.lightMode).toBe(true);
204+
});
205+
206+
it("falls back to dark mode for invalid COLORFGBG values", async () => {
207+
const mod = await importThemeWithEnv({
208+
OPENCLAW_THEME: undefined,
209+
COLORFGBG: "garbage",
210+
});
211+
expect(mod.lightMode).toBe(false);
212+
});
213+
214+
it("ignores pathological COLORFGBG values", async () => {
215+
const mod = await importThemeWithEnv({
216+
OPENCLAW_THEME: undefined,
217+
COLORFGBG: "0;".repeat(40),
218+
});
219+
expect(mod.lightMode).toBe(false);
220+
});
221+
222+
it("OPENCLAW_THEME overrides COLORFGBG", async () => {
223+
const mod = await importThemeWithEnv({
224+
OPENCLAW_THEME: "dark",
225+
COLORFGBG: "0;15",
226+
});
227+
expect(mod.lightMode).toBe(false);
228+
});
229+
230+
it("keeps assistantText as identity in both modes", async () => {
231+
const lightMod = await importThemeWithEnv({ OPENCLAW_THEME: "light" });
232+
const darkMod = await importThemeWithEnv({ OPENCLAW_THEME: "dark" });
233+
expect(lightMod.theme.assistantText("hello")).toBe("hello");
234+
expect(darkMod.theme.assistantText("hello")).toBe("hello");
235+
});
236+
});
237+
238+
describe("light palette accessibility", () => {
239+
it("keeps light theme text colors at WCAG AA contrast or better", async () => {
240+
vi.resetModules();
241+
process.env.OPENCLAW_THEME = "light";
242+
const mod = await import("./theme.js");
243+
const backgrounds = {
244+
page: "#FFFFFF",
245+
user: mod.lightPalette.userBg,
246+
pending: mod.lightPalette.toolPendingBg,
247+
success: mod.lightPalette.toolSuccessBg,
248+
error: mod.lightPalette.toolErrorBg,
249+
code: mod.lightPalette.codeBlock,
250+
};
251+
252+
const textPairs = [
253+
[mod.lightPalette.text, backgrounds.page],
254+
[mod.lightPalette.dim, backgrounds.page],
255+
[mod.lightPalette.accent, backgrounds.page],
256+
[mod.lightPalette.accentSoft, backgrounds.page],
257+
[mod.lightPalette.systemText, backgrounds.page],
258+
[mod.lightPalette.link, backgrounds.page],
259+
[mod.lightPalette.quote, backgrounds.page],
260+
[mod.lightPalette.error, backgrounds.page],
261+
[mod.lightPalette.success, backgrounds.page],
262+
[mod.lightPalette.userText, backgrounds.user],
263+
[mod.lightPalette.dim, backgrounds.pending],
264+
[mod.lightPalette.dim, backgrounds.success],
265+
[mod.lightPalette.dim, backgrounds.error],
266+
[mod.lightPalette.toolTitle, backgrounds.pending],
267+
[mod.lightPalette.toolTitle, backgrounds.success],
268+
[mod.lightPalette.toolTitle, backgrounds.error],
269+
[mod.lightPalette.toolOutput, backgrounds.pending],
270+
[mod.lightPalette.toolOutput, backgrounds.success],
271+
[mod.lightPalette.toolOutput, backgrounds.error],
272+
[mod.lightPalette.code, backgrounds.code],
273+
[mod.lightPalette.border, backgrounds.page],
274+
[mod.lightPalette.quoteBorder, backgrounds.page],
275+
[mod.lightPalette.codeBorder, backgrounds.page],
276+
] as const;
277+
278+
for (const [foreground, background] of textPairs) {
279+
expect(contrastRatio(foreground, background)).toBeGreaterThanOrEqual(4.5);
280+
}
281+
});
282+
});
283+
64284
describe("list themes", () => {
65285
it("reuses shared select-list styles in searchable list theme", () => {
66286
expect(searchableSelectListTheme.selectedPrefix(">")).toBe(selectListTheme.selectedPrefix(">"));

0 commit comments

Comments
 (0)