* 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>
232 lines
7.2 KiB
TypeScript
232 lines
7.2 KiB
TypeScript
import type {
|
|
EditorTheme,
|
|
MarkdownTheme,
|
|
SelectListTheme,
|
|
SettingsListTheme,
|
|
} from "@mariozechner/pi-tui";
|
|
import chalk from "chalk";
|
|
import { highlight, supportsLanguage } from "cli-highlight";
|
|
import type { SearchableSelectListTheme } from "../components/searchable-select-list.js";
|
|
import { createSyntaxTheme } from "./syntax-theme.js";
|
|
|
|
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",
|
|
accentSoft: "#F2A65A",
|
|
border: "#3C414B",
|
|
userBg: "#2B2F36",
|
|
userText: "#F3EEE0",
|
|
systemText: "#9BA3B2",
|
|
toolPendingBg: "#1F2A2F",
|
|
toolSuccessBg: "#1E2D23",
|
|
toolErrorBg: "#2F1F1F",
|
|
toolTitle: "#F6C453",
|
|
toolOutput: "#E1DACB",
|
|
quote: "#8CC8FF",
|
|
quoteBorder: "#3B4D6B",
|
|
code: "#F0C987",
|
|
codeBlock: "#1E232A",
|
|
codeBorder: "#343A45",
|
|
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), lightMode);
|
|
|
|
/**
|
|
* Highlight code with syntax coloring.
|
|
* Returns an array of lines with ANSI escape codes.
|
|
*/
|
|
function highlightCode(code: string, lang?: string): string[] {
|
|
try {
|
|
// Auto-detect can be slow for very large blocks; prefer explicit language when available.
|
|
// Check if language is supported, fall back to auto-detect
|
|
const language = lang && supportsLanguage(lang) ? lang : undefined;
|
|
const highlighted = highlight(code, {
|
|
language,
|
|
theme: syntaxTheme,
|
|
ignoreIllegals: true,
|
|
});
|
|
return highlighted.split("\n");
|
|
} catch {
|
|
// If highlighting fails, return plain code
|
|
return code.split("\n").map((line) => fg(palette.code)(line));
|
|
}
|
|
}
|
|
|
|
export const theme = {
|
|
fg: fg(palette.text),
|
|
assistantText: (text: string) => text,
|
|
dim: fg(palette.dim),
|
|
accent: fg(palette.accent),
|
|
accentSoft: fg(palette.accentSoft),
|
|
success: fg(palette.success),
|
|
error: fg(palette.error),
|
|
header: (text: string) => chalk.bold(fg(palette.accent)(text)),
|
|
system: fg(palette.systemText),
|
|
userBg: bg(palette.userBg),
|
|
userText: fg(palette.userText),
|
|
toolTitle: fg(palette.toolTitle),
|
|
toolOutput: fg(palette.toolOutput),
|
|
toolPendingBg: bg(palette.toolPendingBg),
|
|
toolSuccessBg: bg(palette.toolSuccessBg),
|
|
toolErrorBg: bg(palette.toolErrorBg),
|
|
border: fg(palette.border),
|
|
bold: (text: string) => chalk.bold(text),
|
|
italic: (text: string) => chalk.italic(text),
|
|
};
|
|
|
|
export const markdownTheme: MarkdownTheme = {
|
|
heading: (text) => chalk.bold(fg(palette.accent)(text)),
|
|
link: (text) => fg(palette.link)(text),
|
|
linkUrl: (text) => chalk.dim(text),
|
|
code: (text) => fg(palette.code)(text),
|
|
codeBlock: (text) => fg(palette.code)(text),
|
|
codeBlockBorder: (text) => fg(palette.codeBorder)(text),
|
|
quote: (text) => fg(palette.quote)(text),
|
|
quoteBorder: (text) => fg(palette.quoteBorder)(text),
|
|
hr: (text) => fg(palette.border)(text),
|
|
listBullet: (text) => fg(palette.accentSoft)(text),
|
|
bold: (text) => chalk.bold(text),
|
|
italic: (text) => chalk.italic(text),
|
|
strikethrough: (text) => chalk.strikethrough(text),
|
|
underline: (text) => chalk.underline(text),
|
|
highlightCode,
|
|
};
|
|
|
|
const baseSelectListTheme: SelectListTheme = {
|
|
selectedPrefix: (text) => fg(palette.accent)(text),
|
|
selectedText: (text) => chalk.bold(fg(palette.accent)(text)),
|
|
description: (text) => fg(palette.dim)(text),
|
|
scrollInfo: (text) => fg(palette.dim)(text),
|
|
noMatch: (text) => fg(palette.dim)(text),
|
|
};
|
|
|
|
export const selectListTheme: SelectListTheme = baseSelectListTheme;
|
|
|
|
export const filterableSelectListTheme = {
|
|
...baseSelectListTheme,
|
|
filterLabel: (text: string) => fg(palette.dim)(text),
|
|
};
|
|
|
|
export const settingsListTheme: SettingsListTheme = {
|
|
label: (text, selected) =>
|
|
selected ? chalk.bold(fg(palette.accent)(text)) : fg(palette.text)(text),
|
|
value: (text, selected) => (selected ? fg(palette.accentSoft)(text) : fg(palette.dim)(text)),
|
|
description: (text) => fg(palette.systemText)(text),
|
|
cursor: fg(palette.accent)("→ "),
|
|
hint: (text) => fg(palette.dim)(text),
|
|
};
|
|
|
|
export const editorTheme: EditorTheme = {
|
|
borderColor: (text) => fg(palette.border)(text),
|
|
selectList: selectListTheme,
|
|
};
|
|
|
|
export const searchableSelectListTheme: SearchableSelectListTheme = {
|
|
...baseSelectListTheme,
|
|
searchPrompt: (text) => fg(palette.accentSoft)(text),
|
|
searchInput: (text) => fg(palette.text)(text),
|
|
matchHighlight: (text) => chalk.bold(fg(palette.accent)(text)),
|
|
};
|