diff --git a/change-logs/2026/06/10/fix-light-theme-terminal-colors.md b/change-logs/2026/06/10/fix-light-theme-terminal-colors.md new file mode 100644 index 00000000..463e4efc --- /dev/null +++ b/change-logs/2026/06/10/fix-light-theme-terminal-colors.md @@ -0,0 +1 @@ +Fixed unreadable terminal colors in both themes. AI agents emit colors tuned for the opposite background: Claude Code sends pale 256-color codes and SGR dim (washed out in light mode), Codex sends GitHub-light ink truecolors like #333333 (invisible in dark mode). The PTY stream is now rewritten on the fly: in light mode dim is dropped, pale foregrounds are darkened, and white backgrounds (Claude Code's message bars) become light gray; in dark mode too-dark foregrounds are brightened toward white, and white backgrounds (Claude Code's message bars and history-select highlight, which the dark palette resolved to a pale lavender block) become dark gray with dark fg on them flipped to light. Foreground fixes are skipped only while an explicit opposite-polarity or unknown background (named ANSI colors, reverse video) is active, so vim themes and highlight bars keep their intended contrast — but bad contrast on a same-polarity background is still fixed (Codex draws its model name in pure black on its own dark truecolor background). Also darkened the light theme's 16-color ANSI palette (white, bright black, both yellows) so diffs and muted text are legible on white. diff --git a/decisions/066-light-theme-ansi-stream-rewrite.md b/decisions/066-light-theme-ansi-stream-rewrite.md new file mode 100644 index 00000000..999300ec --- /dev/null +++ b/decisions/066-light-theme-ansi-stream-rewrite.md @@ -0,0 +1,30 @@ +# Theme-adaptive ANSI color rewrite in the PTY stream + +## Context + +In the light theme, Claude Code output was unreadable: removed diff lines, file paths, and spinners washed out on the white background. Captured SGR codes via `tmux capture-pane -e`: pale 256-color indexes (`38;5;183`, `38;5;226`, `38;5;51`, `38;5;114`) and `SGR 2` (dim) on `37` (white). The mirror problem appeared in the dark theme with Codex: it emits GitHub-light syntax truecolors (`38;2;51;51;51`, `38;2;24;54;145`, `38;2;167;29;93`) regardless of the terminal background — the terminal answers Codex's OSC 11 background query correctly (verified), Codex simply does not adapt. + +## Investigation + +ghostty-web's `ITheme` only covers the 16 ANSI colors; 256-color indexes are resolved to RGB inside the WASM terminal, so the theme palette cannot remap them. Dim is rendered as `globalAlpha = 0.5`, which on a white background blends even pure black to `#808080`+ — removed diff lines (`2;37`) were hopeless regardless of palette values. + +## Decision + +Added `src/mainview/utils/ansi-theme-adapt.ts` — a stateful stream filter applied in `TerminalView.tsx` (`enqueueTermWrite` flush) with the resolved theme as mode. Light mode: drops standalone `SGR 2`, rewrites pale foregrounds (`38;5;N` with N≥16, `38;2;R;G;B`) whose relative luminance exceeds 0.55 to a darkened truecolor (~0.42 luminance). Dark mode: keeps dim, brightens foregrounds below 0.25 luminance by blending toward white (~0.38 target; blend handles pure black without division by zero). Sequences split across WS chunks are carried over to the next flush. Also darkened `LIGHT_TERMINAL_THEME` entries (white, brightBlack, yellow, brightYellow) to GitHub Primer light fg values. + +Darkening `white` created a follow-up conflict: Claude Code's light-ansi theme paints message bars with `ansi:white` as a *background* (`SGR 47`) and dark fg (30/90) on top — a dark-on-dark bar. The filter therefore splits the roles of index 7/15 in light mode: as text (37/97) they stay dark, as backgrounds (47/107, 48;5;7, 48;5;15) they are rewritten to light gray truecolor (220/240), matching Claude Code's own non-ansi light theme bar colors. + +Foreground adjustment in both modes is gated by cross-sequence state, but the gate is luminance-aware, not binary: explicit truecolor/indexed backgrounds are classified by relative luminance (dark < 0.35, light > 0.55, mid-tones unknown). Foregrounds pass through untouched only when the active background's polarity is *opposite* to the theme (vim themes, selection bars — the app picked that fg for that bg) or unknown (named ANSI bgs 40-46/100-106 resolve theme-side, reverse video SGR 7). Same-polarity backgrounds keep fg adjustment on: Codex paints its entire UI on an explicit dark bg (`48;2;30;30;46`, Catppuccin base) and writes its model name with `38;2;0;0;0` on top — with a binary gate that black stayed black. The gate state persists across chunk boundaries. + +White backgrounds get the same role-split in dark mode, mirrored: Claude Code paints message bars and the history-select highlight with `ansi:white`/`ansi:whiteBright` (47/107, `userMessageBackground`/`…Hover` in cli.js), which the dark palette resolves to pale lavender (#a9b1d6/#c0caf5) — default-fg text on it is unreadable. They are remapped to Claude Code's own dark theme bar colors (55/70 gray). Because Claude draws dark fg (30/90) on those bars (emitting fg *before* bg), the filter tracks the last dark ANSI fg across sequences and flips it to light gray when a white bar opens (and when 30/90 is set while one is active). Remapped white bars do not gate fg adjustment — after remapping they sit near the theme background, so the normal pale/dark fg fix stays correct on them. + +## Risks + +Dropping dim loses the muted-vs-normal distinction in light mode (diff add/remove still differ by their red/green markers). Colors written while one theme is active stay resolved in scrollback after a theme switch until the app repaints. Light-mode pale backgrounds are intentionally untouched — darkening them would invert intent. The gate only sees SGR; an app that sets a background via OSC or DEC private modes would not trip it (not observed in practice). + +## Alternatives considered + +- Remapping via theme palette: impossible for 256-color indexes (resolved in WASM). +- OSC 4 palette redefinition: not supported by ghostty-web. +- Stateful dim→blended-color emulation (tracking fg across sequences): better fidelity but significantly more complex; dropping dim is predictable and readable. +- Asking Codex to adapt (it queries OSC 11 and gets a correct dark reply): upstream behavior we cannot control; symptom must be fixed on our side. diff --git a/src/mainview/TerminalView.tsx b/src/mainview/TerminalView.tsx index 94afb5a7..96dd651e 100644 --- a/src/mainview/TerminalView.tsx +++ b/src/mainview/TerminalView.tsx @@ -10,6 +10,7 @@ import { installTerminalCopyDiagnostics } from "./terminal-copy-diagnostics"; import { getZoom, ZOOM_CHANGED_EVENT } from "./zoom"; import { TERMINAL_KEYMAPS, getKeymapPreset, KEYMAP_CHANGED_EVENT } from "./terminal-keymaps"; import { uploadDroppedFile } from "./utils/uploadDroppedFile"; +import { createAnsiThemeFilter } from "./utils/ansi-theme-adapt"; const DARK_TERMINAL_THEME = { background: "#1a1b26", @@ -42,15 +43,15 @@ const LIGHT_TERMINAL_THEME = { black: "#24292e", red: "#d73a49", green: "#28a745", - yellow: "#dbab09", + yellow: "#9a6700", blue: "#005cc5", magenta: "#5a32a3", cyan: "#0598bc", - white: "#6a737d", - brightBlack: "#959da5", + white: "#57606a", + brightBlack: "#6e7781", brightRed: "#cb2431", brightGreen: "#22863a", - brightYellow: "#f9c513", + brightYellow: "#b08800", brightBlue: "#0366d6", brightMagenta: "#6f42c1", brightCyan: "#3192aa", @@ -137,6 +138,8 @@ function TerminalView({ ptyUrl, taskId, projectId, onReady }: TerminalViewProps) const [resolvedTheme, setResolvedTheme] = useState<"dark" | "light">( () => (document.documentElement.dataset.theme as "dark" | "light") || "dark", ); + const resolvedThemeRef = useRef(resolvedTheme); + resolvedThemeRef.current = resolvedTheme; function logCopyEvent( level: "debug" | "info" | "warn" | "error", @@ -704,6 +707,9 @@ function TerminalView({ ptyUrl, taskId, projectId, onReady }: TerminalViewProps) let writeRafId: number | null = null; // Reference to the terminal for batched writes (set by connectPty) let batchTerm: Terminal | null = null; + // Rewrites SGR colors unreadable on the current background: pale/dim + // in light mode, too-dark ink in dark mode. See utils/ansi-theme-adapt.ts. + const themeFilter = createAnsiThemeFilter(); function enqueueTermWrite(data: string) { pendingWrite += data; @@ -711,8 +717,9 @@ function TerminalView({ ptyUrl, taskId, projectId, onReady }: TerminalViewProps) writeRafId = requestAnimationFrame(() => { writeRafId = null; if (disposed || !pendingWrite || !batchTerm) return; - const batch = pendingWrite; + const batch = themeFilter(pendingWrite, resolvedThemeRef.current); pendingWrite = ""; + if (!batch) return; try { batchTerm.write(batch); } catch { diff --git a/src/mainview/utils/__tests__/ansi-theme-adapt.test.ts b/src/mainview/utils/__tests__/ansi-theme-adapt.test.ts new file mode 100644 index 00000000..d9062f43 --- /dev/null +++ b/src/mainview/utils/__tests__/ansi-theme-adapt.test.ts @@ -0,0 +1,343 @@ +import { describe, expect, it } from "vitest"; +import { + brightenDarkRgb, + createAnsiThemeFilter, + darkenPaleRgb, + type ThemeMode, +} from "../ansi-theme-adapt"; + +const ESC = "\x1b"; + +function filterAll(chunks: string[], mode: ThemeMode = "light"): string { + const filter = createAnsiThemeFilter(); + return chunks.map((c) => filter(c, mode)).join(""); +} + +describe("darkenPaleRgb", () => { + it("darkens pure yellow to an olive tone", () => { + const result = darkenPaleRgb(255, 255, 0); + expect(result).not.toBeNull(); + const [r, g, b] = result!; + expect(r).toBeLessThan(160); + expect(g).toBeLessThan(160); + expect(b).toBe(0); + expect(r).toBe(g); + }); + + it("keeps dark colors untouched", () => { + expect(darkenPaleRgb(88, 88, 88)).toBeNull(); + expect(darkenPaleRgb(0, 0, 0)).toBeNull(); + expect(darkenPaleRgb(36, 41, 47)).toBeNull(); + }); + + it("darkens pale cyan", () => { + const result = darkenPaleRgb(0, 255, 255); + expect(result).not.toBeNull(); + const [, g, b] = result!; + expect(g).toBeLessThan(180); + expect(b).toBeLessThan(180); + }); +}); + +describe("brightenDarkRgb", () => { + it("brightens GitHub-light ink gray (#333333)", () => { + expect(brightenDarkRgb(51, 51, 51)).toEqual([108, 108, 108]); + }); + + it("brightens pure black extra (near-black boost, no division issues)", () => { + // Pure black gets the highest target — Codex paints its model name + // with #000000, which must stay clearly visible on a dark background. + expect(brightenDarkRgb(0, 0, 0)).toEqual([153, 153, 153]); + }); + + it("brightens GitHub-light navy (#183691) keeping hue order", () => { + const result = brightenDarkRgb(24, 54, 145); + expect(result).not.toBeNull(); + const [r, g, b] = result!; + expect(b).toBeGreaterThan(g); + expect(g).toBeGreaterThan(r); + expect(b).toBeGreaterThan(145); + }); + + it("keeps already-readable colors untouched", () => { + expect(brightenDarkRgb(0, 134, 179)).toBeNull(); + expect(brightenDarkRgb(169, 177, 214)).toBeNull(); + expect(brightenDarkRgb(255, 255, 0)).toBeNull(); + }); +}); + +describe("createAnsiThemeFilter — dim handling (light)", () => { + it("drops a standalone dim sequence", () => { + expect(filterAll([`${ESC}[2mfoo`])).toBe("foo"); + }); + + it("removes dim from compound params but keeps the rest", () => { + expect(filterAll([`${ESC}[0;2mfoo`])).toBe(`${ESC}[0mfoo`); + expect(filterAll([`${ESC}[1;2;31mbar`])).toBe(`${ESC}[1;31mbar`); + }); + + it("does not confuse dim with the 2 in truecolor introducers", () => { + const input = `${ESC}[38;2;10;20;30mx`; + expect(filterAll([input])).toBe(input); + }); + + it("keeps reset and bold-off sequences", () => { + expect(filterAll([`${ESC}[0mfoo`])).toBe(`${ESC}[0mfoo`); + expect(filterAll([`${ESC}[22mfoo`])).toBe(`${ESC}[22mfoo`); + expect(filterAll([`${ESC}[mfoo`])).toBe(`${ESC}[mfoo`); + }); +}); + +describe("createAnsiThemeFilter — 256-color foregrounds (light)", () => { + it("rewrites pale indexed foreground to a darker truecolor", () => { + const out = filterAll([`${ESC}[38;5;226mfoo`]); + expect(out).toMatch(/^\x1b\[38;2;\d+;\d+;\d+mfoo$/); + const [r, g, b] = out.match(/38;2;(\d+);(\d+);(\d+)/)!.slice(1).map(Number); + expect(r).toBeLessThan(160); + expect(g).toBeLessThan(160); + expect(b).toBe(0); + }); + + it("keeps dark indexed foregrounds untouched", () => { + const input = `${ESC}[38;5;240mfoo`; + expect(filterAll([input])).toBe(input); + }); + + it("keeps theme-mapped indices (0-15) untouched", () => { + const input = `${ESC}[38;5;7mfoo`; + expect(filterAll([input])).toBe(input); + }); + + it("does not touch indexed backgrounds", () => { + const input = `${ESC}[48;5;226mfoo`; + expect(filterAll([input])).toBe(input); + }); + + it("darkens pale truecolor foregrounds", () => { + const out = filterAll([`${ESC}[38;2;215;175;255mfoo`]); + expect(out).toMatch(/^\x1b\[38;2;\d+;\d+;\d+mfoo$/); + expect(out).not.toBe(`${ESC}[38;2;215;175;255mfoo`); + }); + + it("preserves surrounding params when rewriting", () => { + const out = filterAll([`${ESC}[1;38;5;226;4mfoo`]); + expect(out).toMatch(/^\x1b\[1;38;2;\d+;\d+;\d+;4mfoo$/); + }); + + it("handles colon-form indexed colors", () => { + const out = filterAll([`${ESC}[38:5:226mfoo`]); + expect(out).toMatch(/^\x1b\[38;2;\d+;\d+;\d+mfoo$/); + }); +}); + +describe("createAnsiThemeFilter — white backgrounds (light)", () => { + // Claude Code's light-ansi theme paints message bars with "ansi:white" + // (SGR 47) and dark fg (30/90) on top. Our palette `white` is a dark gray + // (legible as 37 text), so as a background it must become light gray. + it("rewrites SGR 47 to a light gray truecolor background", () => { + expect(filterAll([`${ESC}[47mfoo`])).toBe(`${ESC}[48;2;220;220;220mfoo`); + }); + + it("rewrites SGR 107 to a near-white truecolor background", () => { + expect(filterAll([`${ESC}[107mfoo`])).toBe(`${ESC}[48;2;240;240;240mfoo`); + }); + + it("rewrites indexed white backgrounds (48;5;7 and 48;5;15)", () => { + expect(filterAll([`${ESC}[48;5;7mfoo`])).toBe(`${ESC}[48;2;220;220;220mfoo`); + expect(filterAll([`${ESC}[48;5;15mfoo`])).toBe(`${ESC}[48;2;240;240;240mfoo`); + }); + + it("preserves surrounding params (Claude message bar pattern)", () => { + expect(filterAll([`${ESC}[90m${ESC}[47mbar`])).toBe( + `${ESC}[90m${ESC}[48;2;220;220;220mbar`, + ); + }); + + it("leaves other background codes untouched", () => { + expect(filterAll([`${ESC}[40mfoo`])).toBe(`${ESC}[40mfoo`); + expect(filterAll([`${ESC}[44mfoo`])).toBe(`${ESC}[44mfoo`); + expect(filterAll([`${ESC}[48;5;28mfoo`])).toBe(`${ESC}[48;5;28mfoo`); + }); +}); + +describe("createAnsiThemeFilter — dark foregrounds (dark)", () => { + it("brightens Codex ink-gray truecolor foreground", () => { + expect(filterAll([`${ESC}[38;2;51;51;51mfoo`], "dark")).toBe( + `${ESC}[38;2;108;108;108mfoo`, + ); + }); + + it("brightens pure black truecolor foreground", () => { + const out = filterAll([`${ESC}[38;2;0;0;0mfoo`], "dark"); + expect(out).toMatch(/^\x1b\[38;2;(\d+);\1;\1mfoo$/); + expect(out).not.toBe(`${ESC}[38;2;0;0;0mfoo`); + }); + + it("brightens dark indexed foregrounds (grayscale ramp)", () => { + const out = filterAll([`${ESC}[38;5;232mfoo`], "dark"); + expect(out).toMatch(/^\x1b\[38;2;\d+;\d+;\d+mfoo$/); + }); + + it("keeps readable foregrounds untouched", () => { + const input = `${ESC}[38;2;0;134;179mfoo`; + expect(filterAll([input], "dark")).toBe(input); + }); + + it("keeps pale foregrounds untouched in dark mode", () => { + const input = `${ESC}[38;5;226mfoo`; + expect(filterAll([input], "dark")).toBe(input); + }); + + it("keeps dim sequences in dark mode", () => { + const input = `${ESC}[2mfoo`; + expect(filterAll([input], "dark")).toBe(input); + }); + + it("keeps theme-mapped indices (0-15) untouched", () => { + const input = `${ESC}[38;5;0mfoo`; + expect(filterAll([input], "dark")).toBe(input); + }); +}); + +describe("createAnsiThemeFilter — white backgrounds (dark)", () => { + // Claude Code paints message bars and the history-select highlight with + // "ansi:white"/"ansi:whiteBright"; the dark palette resolves those to pale + // lavender, so default-fg text on them washes out. Remap to Claude Code's + // own dark theme bar colors. + it("rewrites SGR 47 to a dark gray truecolor background", () => { + expect(filterAll([`${ESC}[47mfoo`], "dark")).toBe(`${ESC}[48;2;55;55;55mfoo`); + }); + + it("rewrites SGR 107 to a slightly lighter dark gray", () => { + expect(filterAll([`${ESC}[107mfoo`], "dark")).toBe(`${ESC}[48;2;70;70;70mfoo`); + }); + + it("rewrites indexed white backgrounds (48;5;7 and 48;5;15)", () => { + expect(filterAll([`${ESC}[48;5;7mfoo`], "dark")).toBe(`${ESC}[48;2;55;55;55mfoo`); + expect(filterAll([`${ESC}[48;5;15mfoo`], "dark")).toBe(`${ESC}[48;2;70;70;70mfoo`); + }); + + it("flips a dark fg set before the bar (Claude fg-then-bg pattern)", () => { + expect(filterAll([`${ESC}[90m${ESC}[47mbar`], "dark")).toBe( + `${ESC}[90m${ESC}[48;2;55;55;55;38;2;160;160;160mbar`, + ); + }); + + it("flips a dark fg set after the bar opens", () => { + expect(filterAll([`${ESC}[47m${ESC}[30mfoo`], "dark")).toBe( + `${ESC}[48;2;55;55;55m${ESC}[38;2;220;220;220mfoo`, + ); + }); + + it("still brightens too-dark truecolor fg on the remapped bar", () => { + expect(filterAll([`${ESC}[47m${ESC}[38;2;51;51;51mfoo`], "dark")).toBe( + `${ESC}[48;2;55;55;55m${ESC}[38;2;108;108;108mfoo`, + ); + }); + + it("does not flip fg after a reset cleared the dark-fg track", () => { + expect(filterAll([`${ESC}[90m${ESC}[0m${ESC}[47mfoo`], "dark")).toBe( + `${ESC}[90m${ESC}[0m${ESC}[48;2;55;55;55mfoo`, + ); + }); + + it("tracks the dark fg across chunk boundaries", () => { + const out = filterAll([`${ESC}[90mfoo`, `${ESC}[47mbar`], "dark"); + expect(out).toBe(`${ESC}[90mfoo${ESC}[48;2;55;55;55;38;2;160;160;160mbar`); + }); +}); + +describe("createAnsiThemeFilter — bg/reverse gating", () => { + it("does not adjust fg while an explicit background is active (dark)", () => { + const input = `${ESC}[44m${ESC}[38;2;51;51;51mfoo`; + expect(filterAll([input], "dark")).toBe(input); + }); + + it("does not adjust fg while an explicit background is active (light)", () => { + const input = `${ESC}[48;5;28m${ESC}[38;5;226mfoo`; + expect(filterAll([input], "light")).toBe(input); + }); + + it("does not adjust fg while reverse video is active", () => { + const input = `${ESC}[7m${ESC}[38;2;51;51;51mfoo`; + expect(filterAll([input], "dark")).toBe(input); + }); + + it("resumes adjusting after SGR 0 reset", () => { + const out = filterAll([`${ESC}[44m${ESC}[0m${ESC}[38;2;51;51;51mfoo`], "dark"); + expect(out).toBe(`${ESC}[44m${ESC}[0m${ESC}[38;2;108;108;108mfoo`); + }); + + it("resumes adjusting after bg-clear (49) and reverse-off (27)", () => { + const out = filterAll( + [`${ESC}[44;49m${ESC}[7;27m${ESC}[38;2;51;51;51mfoo`], + "dark", + ); + expect(out).toBe(`${ESC}[44;49m${ESC}[7;27m${ESC}[38;2;108;108;108mfoo`); + }); + + it("gates fg within the same compound sequence", () => { + const input = `${ESC}[44;38;2;51;51;51mfoo`; + expect(filterAll([input], "dark")).toBe(input); + }); + + it("white bars do not gate fg adjustment (light)", () => { + const out = filterAll([`${ESC}[47m${ESC}[38;5;226mfoo`], "light"); + expect(out).toMatch(/^\x1b\[48;2;220;220;220m\x1b\[38;2;\d+;\d+;\d+mfoo$/); + }); + + it("persists gate state across chunks", () => { + const out = filterAll([`${ESC}[44mfoo`, `${ESC}[38;2;51;51;51mbar`], "dark"); + expect(out).toBe(`${ESC}[44mfoo${ESC}[38;2;51;51;51mbar`); + }); + + it("empty SGR (ESC[m) resets the gate", () => { + const out = filterAll([`${ESC}[44m${ESC}[m${ESC}[38;2;51;51;51mfoo`], "dark"); + expect(out).toBe(`${ESC}[44m${ESC}[m${ESC}[38;2;108;108;108mfoo`); + }); + + it("brightens black fg on an explicit dark truecolor bg (dark)", () => { + // Codex paints its whole UI on 48;2;30;30;46 and writes the model + // name with 38;2;0;0;0 on top — black on dark must still be fixed. + const out = filterAll([`${ESC}[48;2;30;30;46m${ESC}[38;2;0;0;0mgpt`], "dark"); + expect(out).toBe(`${ESC}[48;2;30;30;46m${ESC}[38;2;153;153;153mgpt`); + }); + + it("brightens indexed fg on an explicit dark indexed bg (dark)", () => { + const out = filterAll([`${ESC}[48;5;16m${ESC}[38;5;16mfoo`], "dark"); + expect(out).toBe(`${ESC}[48;5;16m${ESC}[38;2;153;153;153mfoo`); + }); + + it("keeps dark fg on an explicit light truecolor bg (dark)", () => { + const input = `${ESC}[48;2;180;190;254m${ESC}[38;2;0;0;0mfoo`; + expect(filterAll([input], "dark")).toBe(input); + }); + + it("darkens pale fg on an explicit light truecolor bg (light)", () => { + const out = filterAll([`${ESC}[48;2;255;255;230m${ESC}[38;2;255;255;0mfoo`], "light"); + expect(out).toMatch(/^\x1b\[48;2;255;255;230m\x1b\[38;2;\d+;\d+;0mfoo$/); + expect(out).not.toContain("38;2;255;255;0"); + }); + + it("keeps pale fg on an explicit dark truecolor bg (light)", () => { + const input = `${ESC}[48;2;30;30;46m${ESC}[38;5;226mfoo`; + expect(filterAll([input], "light")).toBe(input); + }); +}); + +describe("createAnsiThemeFilter — chunk boundaries", () => { + it("rewrites a sequence split across two chunks", () => { + const out = filterAll([`${ESC}[38;5;2`, `26mfoo`]); + expect(out).toMatch(/^\x1b\[38;2;\d+;\d+;\d+mfoo$/); + }); + + it("holds back a bare trailing ESC", () => { + const out = filterAll([`foo${ESC}`, `[2mbar`]); + expect(out).toBe("foobar"); + }); + + it("passes through unrelated escape sequences", () => { + const input = `${ESC}[2J${ESC}[H${ESC}]0;title\x07foo`; + expect(filterAll([input])).toBe(input); + }); +}); diff --git a/src/mainview/utils/ansi-theme-adapt.ts b/src/mainview/utils/ansi-theme-adapt.ts new file mode 100644 index 00000000..f53fd698 --- /dev/null +++ b/src/mainview/utils/ansi-theme-adapt.ts @@ -0,0 +1,345 @@ +/** + * Theme readability filter for the terminal PTY stream. + * + * Terminal apps emit colors tuned for the *opposite* background: + * - Claude Code (tuned for dark): pale 256-color indexes (38;5;226 yellow, + * 38;5;183 plum, …) and SGR dim — unreadable on a white background. + * - Codex (tuned for light): GitHub-light syntax truecolors (#333333, + * #183691, …) — unreadable on a dark background. + * ghostty-web resolves 256-color indexes inside WASM — the 16-color theme + * palette cannot remap them — and renders dim as globalAlpha 0.5, which on a + * white background washes any color into unreadable gray. + * + * The filter rewrites the stream before term.write(): + * - light mode: standalone SGR `2` (dim) is dropped; pale foregrounds + * (indexed N>=16 and truecolor) are darkened to a luminance-capped + * truecolor; white backgrounds (47/107, 48;5;7, 48;5;15) become light + * gray. The light palette maps `white` to a dark gray so it stays legible + * as 37 *text*, but Claude Code's light-ansi theme paints message bars + * with "ansi:white" as a *background* and dark fg on top — a dark-on-dark + * bar without this remap. + * - dark mode: too-dark foregrounds are brightened by blending toward white. + * Dim is kept (fine on dark backgrounds). White backgrounds become dark + * gray: Claude Code paints message bars and the history-select highlight + * with "ansi:white"/"ansi:whiteBright", which the dark palette resolves to + * pale lavender — default-fg text on it is unreadable. The remap targets + * Claude Code's own dark theme bar colors (55/70), and explicit dark ANSI + * foregrounds (30/90) on those bars are flipped to light grays so + * dark-text-on-white bars stay legible as light-text-on-dark bars. + * Foreground adjustment is gated by the *luminance* of the active explicit + * background: a fg chosen for an opposite-polarity bg (vim themes, highlight + * bars) passes through untouched, but a bad-contrast fg on a same-polarity + * bg is still fixed — Codex paints its whole UI on an explicit dark truecolor + * bg (48;2;30;30;46) and writes pure-black text on top of it. Named ANSI + * backgrounds (40-46, 100-106) resolve theme-side, so their luminance is + * unknown and they gate fg adjustment off entirely, as does reverse video + * (SGR 7). White backgrounds are exempt: after remapping they sit close to + * the theme background, so the normal fg adjustment stays correct. + */ + +export type ThemeMode = "light" | "dark"; + +// Light mode: darken if relative luminance exceeds this (pale on white) +const LIGHT_LUMINANCE_THRESHOLD = 0.55; +// Scale pale colors down to roughly this luminance +const LIGHT_LUMINANCE_TARGET = 0.42; +// Dark mode: brighten if relative luminance is below this (ink on dark) +const DARK_LUMINANCE_THRESHOLD = 0.25; +// Blend dark colors toward white up to roughly this luminance +const DARK_LUMINANCE_TARGET = 0.38; +// Near-black colors lose chroma when brightened (gray on dark reads worse +// than a color of equal luminance), so the target ramps up as the input +// approaches pure black: lum 0 → target 0.60 (#999 gray), lum at +// threshold → 0.38. +const DARK_NEAR_BLACK_BOOST = 0.22; + +function luminance(r: number, g: number, b: number): number { + return (0.2126 * r + 0.7152 * g + 0.0722 * b) / 255; +} + +/** Returns a darkened [r, g, b] if the color is too pale for a light background, else null. */ +export function darkenPaleRgb(r: number, g: number, b: number): [number, number, number] | null { + const lum = luminance(r, g, b); + if (lum <= LIGHT_LUMINANCE_THRESHOLD) return null; + const factor = LIGHT_LUMINANCE_TARGET / lum; + return [Math.round(r * factor), Math.round(g * factor), Math.round(b * factor)]; +} + +/** Returns a brightened [r, g, b] if the color is too dark for a dark background, else null. */ +export function brightenDarkRgb(r: number, g: number, b: number): [number, number, number] | null { + const lum = luminance(r, g, b); + if (lum >= DARK_LUMINANCE_THRESHOLD) return null; + const target = + DARK_LUMINANCE_TARGET + DARK_NEAR_BLACK_BOOST * (1 - lum / DARK_LUMINANCE_THRESHOLD); + // Blend toward white: works for pure black too (no division by lum). + const t = (target - lum) / (1 - lum); + return [ + Math.round(r + t * (255 - r)), + Math.round(g + t * (255 - g)), + Math.round(b + t * (255 - b)), + ]; +} + +function adjustFgRgb( + r: number, + g: number, + b: number, + mode: ThemeMode, +): [number, number, number] | null { + return mode === "light" ? darkenPaleRgb(r, g, b) : brightenDarkRgb(r, g, b); +} + +/** xterm 256-color index → [r, g, b] (only meaningful for N >= 16). */ +function color256ToRgb(n: number): [number, number, number] { + if (n < 232) { + const idx = n - 16; + const channel = (v: number) => (v === 0 ? 0 : 55 + v * 40); + return [ + channel(Math.floor(idx / 36)), + channel(Math.floor((idx % 36) / 6)), + channel(idx % 6), + ]; + } + const level = 8 + (n - 232) * 10; + return [level, level, level]; +} + +// Replacement backgrounds for "white" bars (matches Claude Code's own +// non-ansi theme bar colors: light rgb(220,220,220)/rgb(240,240,240), +// dark rgb(55,55,55)/rgb(70,70,70)). +const LIGHT_WHITE_BG = ["48", "2", "220", "220", "220"]; +const LIGHT_BRIGHT_WHITE_BG = ["48", "2", "240", "240", "240"]; +const DARK_WHITE_BG = ["48", "2", "55", "55", "55"]; +const DARK_BRIGHT_WHITE_BG = ["48", "2", "70", "70", "70"]; +// Light replacements for dark ANSI fg (30/90) on a dark-remapped white bar +const DARK_BAR_FG_30 = ["38", "2", "220", "220", "220"]; +const DARK_BAR_FG_90 = ["38", "2", "160", "160", "160"]; + +function whiteBgReplacement(bright: boolean, mode: ThemeMode): string[] { + if (mode === "light") return bright ? LIGHT_BRIGHT_WHITE_BG : LIGHT_WHITE_BG; + return bright ? DARK_BRIGHT_WHITE_BG : DARK_WHITE_BG; +} + +// Explicit backgrounds below this luminance count as "dark" (fg brightening +// stays on in dark mode), above BG_LIGHT_MIN as "light" (fg darkening stays +// on in light mode); mid-tones and named ANSI bgs gate fg adjustment off. +const BG_DARK_MAX_LUMINANCE = 0.35; +const BG_LIGHT_MIN_LUMINANCE = 0.55; + +type BgClass = "none" | "white" | "dark" | "light" | "unknown"; + +function classifyBgRgb(r: number, g: number, b: number): BgClass { + const lum = luminance(r, g, b); + if (lum < BG_DARK_MAX_LUMINANCE) return "dark"; + if (lum > BG_LIGHT_MIN_LUMINANCE) return "light"; + return "unknown"; +} + +interface GateState { + // "white" = a remapped white bar (fg adjustment stays on); "dark"/"light" = + // explicit bg of known luminance (fg adjustment stays on only for the + // matching mode); "unknown" = named ANSI or mid-tone bg (gated off) + bg: BgClass; + reverseActive: boolean; + // Last explicit dark ANSI fg (30/90) — needed when a white bar opens + // *after* the fg was set (Claude emits fg first, then bg) + darkFg: "30" | "90" | null; +} + +/** + * Rewrites a single SGR parameter string for the given mode, updating the + * cross-sequence gate state as it walks the tokens. Returns the new parameter + * string, or null if the whole sequence should be dropped (every parameter + * was removed). + */ +function transformSgrParams(raw: string, mode: ThemeMode, gate: GateState): string | null { + if (raw === "") { + gate.bg = "none"; + gate.reverseActive = false; + gate.darkFg = null; + return raw; + } + // Normalize colon sub-parameter form (38:5:226) to semicolons so the + // token walk below handles both encodings uniformly. + const tokens = raw.replaceAll(":", ";").split(";"); + const out: string[] = []; + // In dark mode a white bar becomes dark gray; if a dark ANSI fg (set now + // or earlier) sits on it, append/replace it with a light gray. + const pushWhiteBg = (bright: boolean) => { + gate.bg = "white"; + out.push(...whiteBgReplacement(bright, mode)); + if (mode === "dark" && gate.darkFg !== null) { + out.push(...(gate.darkFg === "30" ? DARK_BAR_FG_30 : DARK_BAR_FG_90)); + } + }; + const fgAdjustable = () => + !gate.reverseActive && + (gate.bg === "none" || + gate.bg === "white" || + gate.bg === (mode === "dark" ? "dark" : "light")); + let i = 0; + while (i < tokens.length) { + const token = tokens[i]; + if (token === "" || token === "0") { + gate.bg = "none"; + gate.reverseActive = false; + gate.darkFg = null; + out.push(token); + i++; + continue; + } + if (token === "7") { + gate.reverseActive = true; + out.push(token); + i++; + continue; + } + if (token === "27") { + gate.reverseActive = false; + out.push(token); + i++; + continue; + } + if (token === "49") { + gate.bg = "none"; + out.push(token); + i++; + continue; + } + if (token === "2") { + // SGR dim — ghostty renders it as 50% alpha, unreadable on white. + // On dark backgrounds dim is fine, keep it. + if (mode === "light") { + i++; + continue; + } + out.push(token); + i++; + continue; + } + if (token === "30" || token === "90") { + gate.darkFg = token; + if (mode === "dark" && gate.bg === "white" && !gate.reverseActive) { + out.push(...(token === "30" ? DARK_BAR_FG_30 : DARK_BAR_FG_90)); + i++; + continue; + } + out.push(token); + i++; + continue; + } + const code = Number(token); + if ((code >= 31 && code <= 39) || (code >= 91 && code <= 97)) { + // Any other explicit fg (incl. 39 default) clears the dark-fg track; + // 38-extended is handled below. + if (token !== "38") gate.darkFg = null; + } + if ((code >= 40 && code <= 47) || (code >= 100 && code <= 107)) { + if (code === 47 || code === 107) { + pushWhiteBg(code === 107); + i++; + continue; + } + gate.bg = "unknown"; + out.push(token); + i++; + continue; + } + if (token === "38" || token === "48" || token === "58") { + const introducer = tokens[i + 1]; + if (introducer === "5" && tokens[i + 2] !== undefined) { + const index = Number(tokens[i + 2]); + if (token === "48") { + if (index === 7 || index === 15) { + pushWhiteBg(index === 15); + i += 3; + continue; + } + if (index >= 16 && index <= 255) { + const [r, g, b] = color256ToRgb(index); + gate.bg = classifyBgRgb(r, g, b); + } else { + gate.bg = "unknown"; + } + } + if (token === "38") { + gate.darkFg = null; + if (index >= 16 && index <= 255 && fgAdjustable()) { + const [r, g, b] = color256ToRgb(index); + const adjusted = adjustFgRgb(r, g, b, mode); + if (adjusted) { + out.push("38", "2", String(adjusted[0]), String(adjusted[1]), String(adjusted[2])); + i += 3; + continue; + } + } + } + out.push(token, tokens[i + 1], tokens[i + 2]); + i += 3; + continue; + } + if (introducer === "2" && tokens[i + 4] !== undefined) { + if (token === "48") { + gate.bg = classifyBgRgb( + Number(tokens[i + 2]), + Number(tokens[i + 3]), + Number(tokens[i + 4]), + ); + } + if (token === "38") { + gate.darkFg = null; + if (fgAdjustable()) { + const r = Number(tokens[i + 2]); + const g = Number(tokens[i + 3]); + const b = Number(tokens[i + 4]); + const adjusted = adjustFgRgb(r, g, b, mode); + if (adjusted) { + out.push("38", "2", String(adjusted[0]), String(adjusted[1]), String(adjusted[2])); + i += 5; + continue; + } + } + } + out.push(token, tokens[i + 1], tokens[i + 2], tokens[i + 3], tokens[i + 4]); + i += 5; + continue; + } + } + out.push(token); + i++; + } + if (out.length === 0) return null; + return out.join(";"); +} + +const SGR_RE = /\x1b\[([0-9;:]*)m/g; +// A trailing ESC, or ESC[ followed only by parameter bytes (no final byte yet) +const INCOMPLETE_CSI_RE = /\x1b(?:\[[0-9;:]*)?$/; +const MAX_CARRY = 64; + +/** + * Creates a stateful chunk filter. Escape sequences split across chunk + * boundaries are carried over to the next call, and the bg/reverse gate + * state persists across chunks, so rewriting never misses a fragmented + * SGR sequence or mis-gates a foreground set in a later chunk. + */ +export function createAnsiThemeFilter(): (chunk: string, mode: ThemeMode) => string { + let carry = ""; + const gate: GateState = { bg: "none", reverseActive: false, darkFg: null }; + return (chunk, mode) => { + let data = carry + chunk; + carry = ""; + const match = INCOMPLETE_CSI_RE.exec(data); + if (match && data.length - match.index <= MAX_CARRY) { + carry = data.slice(match.index); + data = data.slice(0, match.index); + } + if (!data) return data; + return data.replace(SGR_RE, (full, params: string) => { + const next = transformSgrParams(params, mode, gate); + if (next === null) return ""; + if (next === params) return full; + return `\x1b[${next}m`; + }); + }; +}