From 613a84550f85c355aca50d950b2e73cf96323891 Mon Sep 17 00:00:00 2001 From: h0x91B Date: Wed, 10 Jun 2026 10:30:57 +0300 Subject: [PATCH 1/7] Fix unreadable terminal colors in light theme AI agents emit pale 256-color SGR codes and dim attributes tuned for dark backgrounds. ghostty-web resolves 256-color indexes inside WASM (theme palette cannot remap them) and renders dim as 50% alpha, which washes text out on white. Add a light-mode stream filter that drops SGR dim and darkens pale indexed/truecolor foregrounds by luminance before term.write(), and darken the light theme ANSI palette (white, brightBlack, yellows). --- .../06/10/fix-light-theme-terminal-colors.md | 1 + .../066-light-theme-ansi-stream-rewrite.md | 23 +++ src/mainview/TerminalView.tsx | 17 ++- .../utils/__tests__/ansi-light-adapt.test.ts | 128 ++++++++++++++++ src/mainview/utils/ansi-light-adapt.ts | 138 ++++++++++++++++++ 5 files changed, 302 insertions(+), 5 deletions(-) create mode 100644 change-logs/2026/06/10/fix-light-theme-terminal-colors.md create mode 100644 decisions/066-light-theme-ansi-stream-rewrite.md create mode 100644 src/mainview/utils/__tests__/ansi-light-adapt.test.ts create mode 100644 src/mainview/utils/ansi-light-adapt.ts 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..d3e269bf --- /dev/null +++ b/change-logs/2026/06/10/fix-light-theme-terminal-colors.md @@ -0,0 +1 @@ +Fixed unreadable terminal colors in the light theme. AI agents (e.g. Claude Code) emit pale 256-color codes and SGR dim tuned for dark backgrounds; these now get rewritten on the fly in light mode — dim is dropped and pale foregrounds are darkened to a readable luminance. 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..3bbbc27d --- /dev/null +++ b/decisions/066-light-theme-ansi-stream-rewrite.md @@ -0,0 +1,23 @@ +# Light theme: rewrite pale ANSI colors 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). + +## 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-light-adapt.ts` — a stateful stream filter applied in `TerminalView.tsx` (`enqueueTermWrite` flush) only when the light theme is active. It drops standalone `SGR 2`, and 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). 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. + +## 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. Backgrounds are intentionally untouched — darkening pale backgrounds would invert intent. + +## 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. diff --git a/src/mainview/TerminalView.tsx b/src/mainview/TerminalView.tsx index 94afb5a7..e98b3f8c 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 { createAnsiLightFilter } from "./utils/ansi-light-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 pale 256-color / dim SGR codes that are unreadable on a + // light background. No-op in dark mode. See utils/ansi-light-adapt.ts. + const lightFilter = createAnsiLightFilter(); 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 = lightFilter(pendingWrite, resolvedThemeRef.current === "light"); pendingWrite = ""; + if (!batch) return; try { batchTerm.write(batch); } catch { diff --git a/src/mainview/utils/__tests__/ansi-light-adapt.test.ts b/src/mainview/utils/__tests__/ansi-light-adapt.test.ts new file mode 100644 index 00000000..001e78f7 --- /dev/null +++ b/src/mainview/utils/__tests__/ansi-light-adapt.test.ts @@ -0,0 +1,128 @@ +import { describe, expect, it } from "vitest"; +import { createAnsiLightFilter, darkenPaleRgb } from "../ansi-light-adapt"; + +const ESC = "\x1b"; + +function filterAll(chunks: string[], light = true): string { + const filter = createAnsiLightFilter(); + return chunks.map((c) => filter(c, light)).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("createAnsiLightFilter — 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("createAnsiLightFilter — 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("createAnsiLightFilter — 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); + }); +}); + +describe("createAnsiLightFilter — dark mode passthrough", () => { + it("leaves everything untouched in dark mode", () => { + const input = `${ESC}[2m${ESC}[38;5;226mfoo`; + expect(filterAll([input], false)).toBe(input); + }); + + it("still joins sequences split across chunks in dark mode", () => { + const out = filterAll([`${ESC}[38;5;2`, `26mfoo`], false); + expect(out).toBe(`${ESC}[38;5;226mfoo`); + }); +}); diff --git a/src/mainview/utils/ansi-light-adapt.ts b/src/mainview/utils/ansi-light-adapt.ts new file mode 100644 index 00000000..c9b853d8 --- /dev/null +++ b/src/mainview/utils/ansi-light-adapt.ts @@ -0,0 +1,138 @@ +/** + * Light-theme readability filter for the terminal PTY stream. + * + * Terminal apps (notably Claude Code) emit colors tuned for dark backgrounds: + * pale 256-color indexes (38;5;226 yellow, 38;5;183 plum, …) and SGR dim. + * 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. + * + * In light mode this filter rewrites the stream before term.write(): + * - standalone SGR `2` (dim) is dropped + * - pale indexed (38;5;N, N>=16) and truecolor (38;2;R;G;B) foregrounds are + * darkened to a luminance-capped truecolor equivalent + * Backgrounds and theme-mapped indexes (0-15) are left untouched. + */ + +// Darken if relative luminance exceeds this (pale on white = unreadable) +const LUMINANCE_THRESHOLD = 0.55; +// Scale pale colors down to roughly this luminance +const LUMINANCE_TARGET = 0.42; + +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 <= LUMINANCE_THRESHOLD) return null; + const factor = LUMINANCE_TARGET / lum; + return [Math.round(r * factor), Math.round(g * factor), Math.round(b * factor)]; +} + +/** 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]; +} + +/** + * Rewrites a single SGR parameter string for light mode. + * Returns the new parameter string, or null if the whole sequence + * should be dropped (every parameter was removed). + */ +function transformSgrParams(raw: string): string | null { + if (raw === "") 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[] = []; + let i = 0; + while (i < tokens.length) { + const token = tokens[i]; + if (token === "2") { + // SGR dim — ghostty renders it as 50% alpha, unreadable on white + i++; + continue; + } + if (token === "38" || token === "48" || token === "58") { + const mode = tokens[i + 1]; + if (mode === "5" && tokens[i + 2] !== undefined) { + const index = Number(tokens[i + 2]); + if (token === "38" && index >= 16 && index <= 255) { + const [r, g, b] = color256ToRgb(index); + const darker = darkenPaleRgb(r, g, b); + if (darker) { + out.push("38", "2", String(darker[0]), String(darker[1]), String(darker[2])); + i += 3; + continue; + } + } + out.push(token, tokens[i + 1], tokens[i + 2]); + i += 3; + continue; + } + if (mode === "2" && tokens[i + 4] !== undefined) { + if (token === "38") { + const r = Number(tokens[i + 2]); + const g = Number(tokens[i + 3]); + const b = Number(tokens[i + 4]); + const darker = darkenPaleRgb(r, g, b); + if (darker) { + out.push("38", "2", String(darker[0]), String(darker[1]), String(darker[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 so rewriting never misses + * a fragmented SGR sequence. When `light` is false the chunk passes + * through unmodified (carry management still applies). + */ +export function createAnsiLightFilter(): (chunk: string, light: boolean) => string { + let carry = ""; + return (chunk, light) => { + 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 (!light || !data) return data; + return data.replace(SGR_RE, (full, params: string) => { + const next = transformSgrParams(params); + if (next === null) return ""; + if (next === params) return full; + return `\x1b[${next}m`; + }); + }; +} From 1cb8c4d7a619ce0106cf8d0aff80f5204e65bb2a Mon Sep 17 00:00:00 2001 From: h0x91B Date: Wed, 10 Jun 2026 10:43:18 +0300 Subject: [PATCH 2/7] Remap white ANSI backgrounds to light gray in light theme Claude Code's light-ansi theme paints message bars with ansi:white as a background (SGR 47) plus dark 30/90 foregrounds. Our light palette maps white to a dark gray so 37 stays legible as text, which turned those bars dark-on-dark. Split the roles: 37/97 text stays dark, 47/107 and 48;5;7 / 48;5;15 backgrounds become light gray truecolor. --- .../06/10/fix-light-theme-terminal-colors.md | 2 +- .../066-light-theme-ansi-stream-rewrite.md | 2 ++ .../utils/__tests__/ansi-light-adapt.test.ts | 30 +++++++++++++++++++ src/mainview/utils/ansi-light-adapt.ts | 26 +++++++++++++++- 4 files changed, 58 insertions(+), 2 deletions(-) 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 index d3e269bf..624149e6 100644 --- a/change-logs/2026/06/10/fix-light-theme-terminal-colors.md +++ b/change-logs/2026/06/10/fix-light-theme-terminal-colors.md @@ -1 +1 @@ -Fixed unreadable terminal colors in the light theme. AI agents (e.g. Claude Code) emit pale 256-color codes and SGR dim tuned for dark backgrounds; these now get rewritten on the fly in light mode — dim is dropped and pale foregrounds are darkened to a readable luminance. Also darkened the light theme's 16-color ANSI palette (white, bright black, both yellows) so diffs and muted text are legible on white. +Fixed unreadable terminal colors in the light theme. AI agents (e.g. Claude Code) emit pale 256-color codes and SGR dim tuned for dark backgrounds; these now get rewritten on the fly in light mode — dim is dropped, pale foregrounds are darkened to a readable luminance, and white backgrounds (Claude Code's message-history and "new message" bars) become light gray so dark text on them stays legible. 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 index 3bbbc27d..adc8c414 100644 --- a/decisions/066-light-theme-ansi-stream-rewrite.md +++ b/decisions/066-light-theme-ansi-stream-rewrite.md @@ -12,6 +12,8 @@ ghostty-web's `ITheme` only covers the 16 ANSI colors; 256-color indexes are res Added `src/mainview/utils/ansi-light-adapt.ts` — a stateful stream filter applied in `TerminalView.tsx` (`enqueueTermWrite` flush) only when the light theme is active. It drops standalone `SGR 2`, and 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). 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: 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. + ## 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. Backgrounds are intentionally untouched — darkening pale backgrounds would invert intent. diff --git a/src/mainview/utils/__tests__/ansi-light-adapt.test.ts b/src/mainview/utils/__tests__/ansi-light-adapt.test.ts index 001e78f7..60d6d57a 100644 --- a/src/mainview/utils/__tests__/ansi-light-adapt.test.ts +++ b/src/mainview/utils/__tests__/ansi-light-adapt.test.ts @@ -98,6 +98,36 @@ describe("createAnsiLightFilter — 256-color foregrounds (light)", () => { }); }); +describe("createAnsiLightFilter — 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("createAnsiLightFilter — chunk boundaries", () => { it("rewrites a sequence split across two chunks", () => { const out = filterAll([`${ESC}[38;5;2`, `26mfoo`]); diff --git a/src/mainview/utils/ansi-light-adapt.ts b/src/mainview/utils/ansi-light-adapt.ts index c9b853d8..089ccc99 100644 --- a/src/mainview/utils/ansi-light-adapt.ts +++ b/src/mainview/utils/ansi-light-adapt.ts @@ -11,7 +11,11 @@ * - standalone SGR `2` (dim) is dropped * - pale indexed (38;5;N, N>=16) and truecolor (38;2;R;G;B) foregrounds are * darkened to a luminance-capped truecolor equivalent - * Backgrounds and theme-mapped indexes (0-15) are left untouched. + * - 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. + * Other backgrounds and theme-mapped foreground indexes (0-15) are untouched. */ // Darken if relative luminance exceeds this (pale on white = unreadable) @@ -51,6 +55,11 @@ function color256ToRgb(n: number): [number, number, number] { * Returns the new parameter string, or null if the whole sequence * should be dropped (every parameter was removed). */ +// Replacement backgrounds for "white" bars (matches Claude Code's own +// non-ansi light theme bar colors: rgb(220,220,220) / rgb(240,240,240)). +const WHITE_BG = ["48", "2", "220", "220", "220"]; +const BRIGHT_WHITE_BG = ["48", "2", "240", "240", "240"]; + function transformSgrParams(raw: string): string | null { if (raw === "") return raw; // Normalize colon sub-parameter form (38:5:226) to semicolons so the @@ -65,10 +74,25 @@ function transformSgrParams(raw: string): string | null { i++; continue; } + if (token === "47") { + out.push(...WHITE_BG); + i++; + continue; + } + if (token === "107") { + out.push(...BRIGHT_WHITE_BG); + i++; + continue; + } if (token === "38" || token === "48" || token === "58") { const mode = tokens[i + 1]; if (mode === "5" && tokens[i + 2] !== undefined) { const index = Number(tokens[i + 2]); + if (token === "48" && (index === 7 || index === 15)) { + out.push(...(index === 7 ? WHITE_BG : BRIGHT_WHITE_BG)); + i += 3; + continue; + } if (token === "38" && index >= 16 && index <= 255) { const [r, g, b] = color256ToRgb(index); const darker = darkenPaleRgb(r, g, b); From 4f70cfad590acddb02d8e15541379c405717c5ae Mon Sep 17 00:00:00 2001 From: h0x91B Date: Wed, 10 Jun 2026 11:29:08 +0300 Subject: [PATCH 3/7] Brighten too-dark foregrounds in dark theme; gate fg fixes by bg/reverse Codex emits GitHub-light ink truecolors (#333333, #183691, ...) regardless of the terminal background, making its output unreadable on the dark theme. Extend the PTY stream filter (renamed ansi-light-adapt -> ansi-theme-adapt) with a dark mode that blends foregrounds below 0.25 luminance toward white. Foreground adjustment in both modes is now skipped while an explicit SGR background or reverse video is active, preserving intentional contrast in vim themes and highlight bars; the gate state persists across chunks. --- .../06/10/fix-light-theme-terminal-colors.md | 2 +- .../066-light-theme-ansi-stream-rewrite.md | 13 +- src/mainview/TerminalView.tsx | 10 +- ...adapt.test.ts => ansi-theme-adapt.test.ts} | 152 +++++++++-- src/mainview/utils/ansi-light-adapt.ts | 162 ----------- src/mainview/utils/ansi-theme-adapt.ts | 257 ++++++++++++++++++ 6 files changed, 403 insertions(+), 193 deletions(-) rename src/mainview/utils/__tests__/{ansi-light-adapt.test.ts => ansi-theme-adapt.test.ts} (50%) delete mode 100644 src/mainview/utils/ansi-light-adapt.ts create mode 100644 src/mainview/utils/ansi-theme-adapt.ts 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 index 624149e6..3212e037 100644 --- a/change-logs/2026/06/10/fix-light-theme-terminal-colors.md +++ b/change-logs/2026/06/10/fix-light-theme-terminal-colors.md @@ -1 +1 @@ -Fixed unreadable terminal colors in the light theme. AI agents (e.g. Claude Code) emit pale 256-color codes and SGR dim tuned for dark backgrounds; these now get rewritten on the fly in light mode — dim is dropped, pale foregrounds are darkened to a readable luminance, and white backgrounds (Claude Code's message-history and "new message" bars) become light gray so dark text on them stays legible. Also darkened the light theme's 16-color ANSI palette (white, bright black, both yellows) so diffs and muted text are legible on white. +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. Foreground fixes are skipped while an explicit background or reverse video is active, so vim themes and highlight bars keep their intended contrast. 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 index adc8c414..9ef01e9f 100644 --- a/decisions/066-light-theme-ansi-stream-rewrite.md +++ b/decisions/066-light-theme-ansi-stream-rewrite.md @@ -1,8 +1,8 @@ -# Light theme: rewrite pale ANSI colors in the PTY stream +# 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). +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 @@ -10,16 +10,19 @@ ghostty-web's `ITheme` only covers the 16 ANSI colors; 256-color indexes are res ## Decision -Added `src/mainview/utils/ansi-light-adapt.ts` — a stateful stream filter applied in `TerminalView.tsx` (`enqueueTermWrite` flush) only when the light theme is active. It drops standalone `SGR 2`, and 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). 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. +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: 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. +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: while an explicit background (40-47, 100-107, 48;…) or reverse video (SGR 7) is active, foregrounds pass through untouched. Apps pick those foregrounds *for that background* (vim themes, selection bars), so "fixing" them would break intentional contrast. The gate state persists across chunk boundaries. ## 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. Backgrounds are intentionally untouched — darkening pale backgrounds would invert intent. +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 e98b3f8c..96dd651e 100644 --- a/src/mainview/TerminalView.tsx +++ b/src/mainview/TerminalView.tsx @@ -10,7 +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 { createAnsiLightFilter } from "./utils/ansi-light-adapt"; +import { createAnsiThemeFilter } from "./utils/ansi-theme-adapt"; const DARK_TERMINAL_THEME = { background: "#1a1b26", @@ -707,9 +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 pale 256-color / dim SGR codes that are unreadable on a - // light background. No-op in dark mode. See utils/ansi-light-adapt.ts. - const lightFilter = createAnsiLightFilter(); + // 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; @@ -717,7 +717,7 @@ function TerminalView({ ptyUrl, taskId, projectId, onReady }: TerminalViewProps) writeRafId = requestAnimationFrame(() => { writeRafId = null; if (disposed || !pendingWrite || !batchTerm) return; - const batch = lightFilter(pendingWrite, resolvedThemeRef.current === "light"); + const batch = themeFilter(pendingWrite, resolvedThemeRef.current); pendingWrite = ""; if (!batch) return; try { diff --git a/src/mainview/utils/__tests__/ansi-light-adapt.test.ts b/src/mainview/utils/__tests__/ansi-theme-adapt.test.ts similarity index 50% rename from src/mainview/utils/__tests__/ansi-light-adapt.test.ts rename to src/mainview/utils/__tests__/ansi-theme-adapt.test.ts index 60d6d57a..22f5dfd6 100644 --- a/src/mainview/utils/__tests__/ansi-light-adapt.test.ts +++ b/src/mainview/utils/__tests__/ansi-theme-adapt.test.ts @@ -1,11 +1,16 @@ import { describe, expect, it } from "vitest"; -import { createAnsiLightFilter, darkenPaleRgb } from "../ansi-light-adapt"; +import { + brightenDarkRgb, + createAnsiThemeFilter, + darkenPaleRgb, + type ThemeMode, +} from "../ansi-theme-adapt"; const ESC = "\x1b"; -function filterAll(chunks: string[], light = true): string { - const filter = createAnsiLightFilter(); - return chunks.map((c) => filter(c, light)).join(""); +function filterAll(chunks: string[], mode: ThemeMode = "light"): string { + const filter = createAnsiThemeFilter(); + return chunks.map((c) => filter(c, mode)).join(""); } describe("darkenPaleRgb", () => { @@ -34,7 +39,37 @@ describe("darkenPaleRgb", () => { }); }); -describe("createAnsiLightFilter — dim handling (light)", () => { +describe("brightenDarkRgb", () => { + it("brightens GitHub-light ink gray (#333333)", () => { + expect(brightenDarkRgb(51, 51, 51)).toEqual([97, 97, 97]); + }); + + it("brightens pure black without division issues", () => { + const result = brightenDarkRgb(0, 0, 0); + expect(result).not.toBeNull(); + const [r, g, b] = result!; + expect(r).toBe(g); + expect(g).toBe(b); + expect(r).toBeGreaterThan(80); + }); + + 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"); }); @@ -56,7 +91,7 @@ describe("createAnsiLightFilter — dim handling (light)", () => { }); }); -describe("createAnsiLightFilter — 256-color foregrounds (light)", () => { +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$/); @@ -98,7 +133,7 @@ describe("createAnsiLightFilter — 256-color foregrounds (light)", () => { }); }); -describe("createAnsiLightFilter — white backgrounds (light)", () => { +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. @@ -128,7 +163,96 @@ describe("createAnsiLightFilter — white backgrounds (light)", () => { }); }); -describe("createAnsiLightFilter — chunk boundaries", () => { +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;97;97;97mfoo`, + ); + }); + + 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 white backgrounds untouched in dark mode", () => { + expect(filterAll([`${ESC}[47mfoo`], "dark")).toBe(`${ESC}[47mfoo`); + expect(filterAll([`${ESC}[48;5;7mfoo`], "dark")).toBe(`${ESC}[48;5;7mfoo`); + }); + + it("keeps theme-mapped indices (0-15) untouched", () => { + const input = `${ESC}[38;5;0mfoo`; + expect(filterAll([input], "dark")).toBe(input); + }); +}); + +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;97;97;97mfoo`); + }); + + 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;97;97;97mfoo`); + }); + + it("gates fg within the same compound sequence", () => { + const input = `${ESC}[44;38;2;51;51;51mfoo`; + expect(filterAll([input], "dark")).toBe(input); + }); + + 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;97;97;97mfoo`); + }); +}); + +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$/); @@ -144,15 +268,3 @@ describe("createAnsiLightFilter — chunk boundaries", () => { expect(filterAll([input])).toBe(input); }); }); - -describe("createAnsiLightFilter — dark mode passthrough", () => { - it("leaves everything untouched in dark mode", () => { - const input = `${ESC}[2m${ESC}[38;5;226mfoo`; - expect(filterAll([input], false)).toBe(input); - }); - - it("still joins sequences split across chunks in dark mode", () => { - const out = filterAll([`${ESC}[38;5;2`, `26mfoo`], false); - expect(out).toBe(`${ESC}[38;5;226mfoo`); - }); -}); diff --git a/src/mainview/utils/ansi-light-adapt.ts b/src/mainview/utils/ansi-light-adapt.ts deleted file mode 100644 index 089ccc99..00000000 --- a/src/mainview/utils/ansi-light-adapt.ts +++ /dev/null @@ -1,162 +0,0 @@ -/** - * Light-theme readability filter for the terminal PTY stream. - * - * Terminal apps (notably Claude Code) emit colors tuned for dark backgrounds: - * pale 256-color indexes (38;5;226 yellow, 38;5;183 plum, …) and SGR dim. - * 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. - * - * In light mode this filter rewrites the stream before term.write(): - * - standalone SGR `2` (dim) is dropped - * - pale indexed (38;5;N, N>=16) and truecolor (38;2;R;G;B) foregrounds are - * darkened to a luminance-capped truecolor equivalent - * - 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. - * Other backgrounds and theme-mapped foreground indexes (0-15) are untouched. - */ - -// Darken if relative luminance exceeds this (pale on white = unreadable) -const LUMINANCE_THRESHOLD = 0.55; -// Scale pale colors down to roughly this luminance -const LUMINANCE_TARGET = 0.42; - -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 <= LUMINANCE_THRESHOLD) return null; - const factor = LUMINANCE_TARGET / lum; - return [Math.round(r * factor), Math.round(g * factor), Math.round(b * factor)]; -} - -/** 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]; -} - -/** - * Rewrites a single SGR parameter string for light mode. - * Returns the new parameter string, or null if the whole sequence - * should be dropped (every parameter was removed). - */ -// Replacement backgrounds for "white" bars (matches Claude Code's own -// non-ansi light theme bar colors: rgb(220,220,220) / rgb(240,240,240)). -const WHITE_BG = ["48", "2", "220", "220", "220"]; -const BRIGHT_WHITE_BG = ["48", "2", "240", "240", "240"]; - -function transformSgrParams(raw: string): string | null { - if (raw === "") 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[] = []; - let i = 0; - while (i < tokens.length) { - const token = tokens[i]; - if (token === "2") { - // SGR dim — ghostty renders it as 50% alpha, unreadable on white - i++; - continue; - } - if (token === "47") { - out.push(...WHITE_BG); - i++; - continue; - } - if (token === "107") { - out.push(...BRIGHT_WHITE_BG); - i++; - continue; - } - if (token === "38" || token === "48" || token === "58") { - const mode = tokens[i + 1]; - if (mode === "5" && tokens[i + 2] !== undefined) { - const index = Number(tokens[i + 2]); - if (token === "48" && (index === 7 || index === 15)) { - out.push(...(index === 7 ? WHITE_BG : BRIGHT_WHITE_BG)); - i += 3; - continue; - } - if (token === "38" && index >= 16 && index <= 255) { - const [r, g, b] = color256ToRgb(index); - const darker = darkenPaleRgb(r, g, b); - if (darker) { - out.push("38", "2", String(darker[0]), String(darker[1]), String(darker[2])); - i += 3; - continue; - } - } - out.push(token, tokens[i + 1], tokens[i + 2]); - i += 3; - continue; - } - if (mode === "2" && tokens[i + 4] !== undefined) { - if (token === "38") { - const r = Number(tokens[i + 2]); - const g = Number(tokens[i + 3]); - const b = Number(tokens[i + 4]); - const darker = darkenPaleRgb(r, g, b); - if (darker) { - out.push("38", "2", String(darker[0]), String(darker[1]), String(darker[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 so rewriting never misses - * a fragmented SGR sequence. When `light` is false the chunk passes - * through unmodified (carry management still applies). - */ -export function createAnsiLightFilter(): (chunk: string, light: boolean) => string { - let carry = ""; - return (chunk, light) => { - 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 (!light || !data) return data; - return data.replace(SGR_RE, (full, params: string) => { - const next = transformSgrParams(params); - if (next === null) return ""; - if (next === params) return full; - return `\x1b[${next}m`; - }); - }; -} diff --git a/src/mainview/utils/ansi-theme-adapt.ts b/src/mainview/utils/ansi-theme-adapt.ts new file mode 100644 index 00000000..cfbc3d66 --- /dev/null +++ b/src/mainview/utils/ansi-theme-adapt.ts @@ -0,0 +1,257 @@ +/** + * 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). + * Foreground adjustment is gated: while an explicit background (40-47, + * 100-107, 48;…) or reverse video (SGR 7) is active, foregrounds pass + * through untouched — the app picked that fg *for that bg* (vim themes, + * highlight bars), so "fixing" it would break intentional contrast. + */ + +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; + +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; + // Blend toward white: works for pure black too (no division by lum). + const t = (DARK_LUMINANCE_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 in light mode (matches Claude +// Code's own non-ansi light theme bar colors: rgb(220,220,220) / rgb(240,240,240)). +const WHITE_BG = ["48", "2", "220", "220", "220"]; +const BRIGHT_WHITE_BG = ["48", "2", "240", "240", "240"]; + +interface GateState { + bgActive: boolean; + reverseActive: boolean; +} + +/** + * 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.bgActive = false; + gate.reverseActive = false; + 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[] = []; + let i = 0; + while (i < tokens.length) { + const token = tokens[i]; + if (token === "" || token === "0") { + gate.bgActive = false; + gate.reverseActive = false; + 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.bgActive = false; + 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; + } + const code = Number(token); + if ((code >= 40 && code <= 47) || (code >= 100 && code <= 107)) { + gate.bgActive = true; + if (mode === "light" && code === 47) { + out.push(...WHITE_BG); + i++; + continue; + } + if (mode === "light" && code === 107) { + out.push(...BRIGHT_WHITE_BG); + i++; + continue; + } + 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") { + gate.bgActive = true; + if (mode === "light" && (index === 7 || index === 15)) { + out.push(...(index === 7 ? WHITE_BG : BRIGHT_WHITE_BG)); + i += 3; + continue; + } + } + if ( + token === "38" && + index >= 16 && + index <= 255 && + !gate.bgActive && + !gate.reverseActive + ) { + 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.bgActive = true; + if (token === "38" && !gate.bgActive && !gate.reverseActive) { + 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 = { bgActive: false, reverseActive: false }; + 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`; + }); + }; +} From a5a521fdf24cd19b5733cd1405bdc4d0db770e10 Mon Sep 17 00:00:00 2001 From: h0x91B Date: Wed, 10 Jun 2026 11:36:21 +0300 Subject: [PATCH 4/7] Remap white backgrounds to dark grays in dark theme MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Claude Code paints message bars and the history-select highlight with ansi:white/whiteBright (SGR 47/107); the dark palette resolves those to pale lavender, leaving default-fg text unreadable. Rewrite them to Claude Code's own dark theme bar colors (55/70 gray) and flip explicit dark ANSI fg (30/90) on those bars to light grays, tracking the last dark fg across sequences since Claude emits fg before bg. Remapped white bars no longer gate fg adjustment — they sit near the theme background after remapping. --- .../06/10/fix-light-theme-terminal-colors.md | 2 +- .../066-light-theme-ansi-stream-rewrite.md | 4 +- .../utils/__tests__/ansi-theme-adapt.test.ts | 58 ++++++- src/mainview/utils/ansi-theme-adapt.ts | 141 ++++++++++++------ 4 files changed, 151 insertions(+), 54 deletions(-) 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 index 3212e037..b188764c 100644 --- a/change-logs/2026/06/10/fix-light-theme-terminal-colors.md +++ b/change-logs/2026/06/10/fix-light-theme-terminal-colors.md @@ -1 +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. Foreground fixes are skipped while an explicit background or reverse video is active, so vim themes and highlight bars keep their intended contrast. Also darkened the light theme's 16-color ANSI palette (white, bright black, both yellows) so diffs and muted text are legible on white. +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 while an explicit colored background or reverse video is active, so vim themes and highlight bars keep their intended contrast. 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 index 9ef01e9f..7b008bf3 100644 --- a/decisions/066-light-theme-ansi-stream-rewrite.md +++ b/decisions/066-light-theme-ansi-stream-rewrite.md @@ -14,7 +14,9 @@ Added `src/mainview/utils/ansi-theme-adapt.ts` — a stateful stream filter appl 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: while an explicit background (40-47, 100-107, 48;…) or reverse video (SGR 7) is active, foregrounds pass through untouched. Apps pick those foregrounds *for that background* (vim themes, selection bars), so "fixing" them would break intentional contrast. The gate state persists across chunk boundaries. +Foreground adjustment in both modes is gated by cross-sequence state: while an explicit *colored* background (40-46, 100-106, 48;…) or reverse video (SGR 7) is active, foregrounds pass through untouched. Apps pick those foregrounds *for that background* (vim themes, selection bars), so "fixing" them would break intentional contrast. 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 diff --git a/src/mainview/utils/__tests__/ansi-theme-adapt.test.ts b/src/mainview/utils/__tests__/ansi-theme-adapt.test.ts index 22f5dfd6..de2c1f0d 100644 --- a/src/mainview/utils/__tests__/ansi-theme-adapt.test.ts +++ b/src/mainview/utils/__tests__/ansi-theme-adapt.test.ts @@ -196,17 +196,60 @@ describe("createAnsiThemeFilter — dark foregrounds (dark)", () => { expect(filterAll([input], "dark")).toBe(input); }); - it("keeps white backgrounds untouched in dark mode", () => { - expect(filterAll([`${ESC}[47mfoo`], "dark")).toBe(`${ESC}[47mfoo`); - expect(filterAll([`${ESC}[48;5;7mfoo`], "dark")).toBe(`${ESC}[48;5;7mfoo`); - }); - 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;97;97;97mfoo`, + ); + }); + + 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`; @@ -241,6 +284,11 @@ describe("createAnsiThemeFilter — bg/reverse gating", () => { 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`); diff --git a/src/mainview/utils/ansi-theme-adapt.ts b/src/mainview/utils/ansi-theme-adapt.ts index cfbc3d66..588ea3b4 100644 --- a/src/mainview/utils/ansi-theme-adapt.ts +++ b/src/mainview/utils/ansi-theme-adapt.ts @@ -19,11 +19,19 @@ * 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). - * Foreground adjustment is gated: while an explicit background (40-47, - * 100-107, 48;…) or reverse video (SGR 7) is active, foregrounds pass - * through untouched — the app picked that fg *for that bg* (vim themes, - * highlight bars), so "fixing" it would break intentional contrast. + * 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: while an explicit *colored* background + * (40-46, 100-106, 48;…) or reverse video (SGR 7) is active, foregrounds + * pass through untouched — the app picked that fg *for that bg* (vim themes, + * highlight bars), so "fixing" it would break intentional contrast. White + * backgrounds are exempt from the gate: after remapping they sit close to + * the theme background, so the normal fg adjustment stays correct. */ export type ThemeMode = "light" | "dark"; @@ -86,14 +94,30 @@ function color256ToRgb(n: number): [number, number, number] { return [level, level, level]; } -// Replacement backgrounds for "white" bars in light mode (matches Claude -// Code's own non-ansi light theme bar colors: rgb(220,220,220) / rgb(240,240,240)). -const WHITE_BG = ["48", "2", "220", "220", "220"]; -const BRIGHT_WHITE_BG = ["48", "2", "240", "240", "240"]; +// 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; +} interface GateState { - bgActive: boolean; + // "white" = a remapped white bar (fg adjustment stays on); "other" = any + // other explicit background (fg adjustment gated off) + bg: "none" | "white" | "other"; 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; } /** @@ -104,20 +128,32 @@ interface GateState { */ function transformSgrParams(raw: string, mode: ThemeMode, gate: GateState): string | null { if (raw === "") { - gate.bgActive = false; + 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 !== "other"; let i = 0; while (i < tokens.length) { const token = tokens[i]; if (token === "" || token === "0") { - gate.bgActive = false; + gate.bg = "none"; gate.reverseActive = false; + gate.darkFg = null; out.push(token); i++; continue; @@ -135,7 +171,7 @@ function transformSgrParams(raw: string, mode: ThemeMode, gate: GateState): stri continue; } if (token === "49") { - gate.bgActive = false; + gate.bg = "none"; out.push(token); i++; continue; @@ -151,19 +187,30 @@ function transformSgrParams(raw: string, mode: ThemeMode, gate: GateState): stri i++; continue; } - const code = Number(token); - if ((code >= 40 && code <= 47) || (code >= 100 && code <= 107)) { - gate.bgActive = true; - if (mode === "light" && code === 47) { - out.push(...WHITE_BG); + 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; } - if (mode === "light" && code === 107) { - out.push(...BRIGHT_WHITE_BG); + 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 = "other"; out.push(token); i++; continue; @@ -173,26 +220,23 @@ function transformSgrParams(raw: string, mode: ThemeMode, gate: GateState): stri if (introducer === "5" && tokens[i + 2] !== undefined) { const index = Number(tokens[i + 2]); if (token === "48") { - gate.bgActive = true; - if (mode === "light" && (index === 7 || index === 15)) { - out.push(...(index === 7 ? WHITE_BG : BRIGHT_WHITE_BG)); + if (index === 7 || index === 15) { + pushWhiteBg(index === 15); i += 3; continue; } + gate.bg = "other"; } - if ( - token === "38" && - index >= 16 && - index <= 255 && - !gate.bgActive && - !gate.reverseActive - ) { - 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; + 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]); @@ -200,16 +244,19 @@ function transformSgrParams(raw: string, mode: ThemeMode, gate: GateState): stri continue; } if (introducer === "2" && tokens[i + 4] !== undefined) { - if (token === "48") gate.bgActive = true; - if (token === "38" && !gate.bgActive && !gate.reverseActive) { - 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; + if (token === "48") gate.bg = "other"; + 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]); @@ -237,7 +284,7 @@ const MAX_CARRY = 64; */ export function createAnsiThemeFilter(): (chunk: string, mode: ThemeMode) => string { let carry = ""; - const gate: GateState = { bgActive: false, reverseActive: false }; + const gate: GateState = { bg: "none", reverseActive: false, darkFg: null }; return (chunk, mode) => { let data = carry + chunk; carry = ""; From 1ba04f9f21548e1f1bead597643e61af949b2dec Mon Sep 17 00:00:00 2001 From: h0x91B Date: Wed, 10 Jun 2026 12:04:59 +0300 Subject: [PATCH 5/7] Boost brightening for near-black foregrounds in dark theme Pure black brightened to the flat 0.38 luminance target lands on a chroma-less #616161 gray that still reads as nearly invisible on a dark background (e.g. Codex paints its model name with #000000). Ramp the blend target up as input luminance approaches zero (0.38 at the threshold, 0.50 for pure black), so near-black ink gets a stronger lift while already-colored dark foregrounds barely shift. --- .../utils/__tests__/ansi-theme-adapt.test.ts | 23 ++++++++----------- src/mainview/utils/ansi-theme-adapt.ts | 8 ++++++- 2 files changed, 17 insertions(+), 14 deletions(-) diff --git a/src/mainview/utils/__tests__/ansi-theme-adapt.test.ts b/src/mainview/utils/__tests__/ansi-theme-adapt.test.ts index de2c1f0d..4815c714 100644 --- a/src/mainview/utils/__tests__/ansi-theme-adapt.test.ts +++ b/src/mainview/utils/__tests__/ansi-theme-adapt.test.ts @@ -41,16 +41,13 @@ describe("darkenPaleRgb", () => { describe("brightenDarkRgb", () => { it("brightens GitHub-light ink gray (#333333)", () => { - expect(brightenDarkRgb(51, 51, 51)).toEqual([97, 97, 97]); + expect(brightenDarkRgb(51, 51, 51)).toEqual([103, 103, 103]); }); - it("brightens pure black without division issues", () => { - const result = brightenDarkRgb(0, 0, 0); - expect(result).not.toBeNull(); - const [r, g, b] = result!; - expect(r).toBe(g); - expect(g).toBe(b); - expect(r).toBeGreaterThan(80); + 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([128, 128, 128]); }); it("brightens GitHub-light navy (#183691) keeping hue order", () => { @@ -166,7 +163,7 @@ describe("createAnsiThemeFilter — white backgrounds (light)", () => { 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;97;97;97mfoo`, + `${ESC}[38;2;103;103;103mfoo`, ); }); @@ -234,7 +231,7 @@ describe("createAnsiThemeFilter — white backgrounds (dark)", () => { 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;97;97;97mfoo`, + `${ESC}[48;2;55;55;55m${ESC}[38;2;103;103;103mfoo`, ); }); @@ -268,7 +265,7 @@ describe("createAnsiThemeFilter — bg/reverse gating", () => { 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;97;97;97mfoo`); + expect(out).toBe(`${ESC}[44m${ESC}[0m${ESC}[38;2;103;103;103mfoo`); }); it("resumes adjusting after bg-clear (49) and reverse-off (27)", () => { @@ -276,7 +273,7 @@ describe("createAnsiThemeFilter — bg/reverse gating", () => { [`${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;97;97;97mfoo`); + expect(out).toBe(`${ESC}[44;49m${ESC}[7;27m${ESC}[38;2;103;103;103mfoo`); }); it("gates fg within the same compound sequence", () => { @@ -296,7 +293,7 @@ describe("createAnsiThemeFilter — bg/reverse gating", () => { 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;97;97;97mfoo`); + expect(out).toBe(`${ESC}[44m${ESC}[m${ESC}[38;2;103;103;103mfoo`); }); }); diff --git a/src/mainview/utils/ansi-theme-adapt.ts b/src/mainview/utils/ansi-theme-adapt.ts index 588ea3b4..cbb83927 100644 --- a/src/mainview/utils/ansi-theme-adapt.ts +++ b/src/mainview/utils/ansi-theme-adapt.ts @@ -44,6 +44,10 @@ const LIGHT_LUMINANCE_TARGET = 0.42; 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.50, lum at threshold → 0.38. +const DARK_NEAR_BLACK_BOOST = 0.12; function luminance(r: number, g: number, b: number): number { return (0.2126 * r + 0.7152 * g + 0.0722 * b) / 255; @@ -61,8 +65,10 @@ export function darkenPaleRgb(r: number, g: number, b: number): [number, number, 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 = (DARK_LUMINANCE_TARGET - lum) / (1 - lum); + const t = (target - lum) / (1 - lum); return [ Math.round(r + t * (255 - r)), Math.round(g + t * (255 - g)), From 4dddf59fc6fe32f23460f015103c63d2e53b9c0d Mon Sep 17 00:00:00 2001 From: h0x91B Date: Wed, 10 Jun 2026 12:33:19 +0300 Subject: [PATCH 6/7] Raise near-black brightening target to 0.60 The 0.50 target still rendered pure black as a gray too dark to read comfortably on the dark background (Codex model name in its status bar). Pure black now maps to #999999; colors near the threshold barely shift. --- .../utils/__tests__/ansi-theme-adapt.test.ts | 14 +++++++------- src/mainview/utils/ansi-theme-adapt.ts | 5 +++-- 2 files changed, 10 insertions(+), 9 deletions(-) diff --git a/src/mainview/utils/__tests__/ansi-theme-adapt.test.ts b/src/mainview/utils/__tests__/ansi-theme-adapt.test.ts index 4815c714..0241a49a 100644 --- a/src/mainview/utils/__tests__/ansi-theme-adapt.test.ts +++ b/src/mainview/utils/__tests__/ansi-theme-adapt.test.ts @@ -41,13 +41,13 @@ describe("darkenPaleRgb", () => { describe("brightenDarkRgb", () => { it("brightens GitHub-light ink gray (#333333)", () => { - expect(brightenDarkRgb(51, 51, 51)).toEqual([103, 103, 103]); + 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([128, 128, 128]); + expect(brightenDarkRgb(0, 0, 0)).toEqual([153, 153, 153]); }); it("brightens GitHub-light navy (#183691) keeping hue order", () => { @@ -163,7 +163,7 @@ describe("createAnsiThemeFilter — white backgrounds (light)", () => { 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;103;103;103mfoo`, + `${ESC}[38;2;108;108;108mfoo`, ); }); @@ -231,7 +231,7 @@ describe("createAnsiThemeFilter — white backgrounds (dark)", () => { 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;103;103;103mfoo`, + `${ESC}[48;2;55;55;55m${ESC}[38;2;108;108;108mfoo`, ); }); @@ -265,7 +265,7 @@ describe("createAnsiThemeFilter — bg/reverse gating", () => { 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;103;103;103mfoo`); + expect(out).toBe(`${ESC}[44m${ESC}[0m${ESC}[38;2;108;108;108mfoo`); }); it("resumes adjusting after bg-clear (49) and reverse-off (27)", () => { @@ -273,7 +273,7 @@ describe("createAnsiThemeFilter — bg/reverse gating", () => { [`${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;103;103;103mfoo`); + expect(out).toBe(`${ESC}[44;49m${ESC}[7;27m${ESC}[38;2;108;108;108mfoo`); }); it("gates fg within the same compound sequence", () => { @@ -293,7 +293,7 @@ describe("createAnsiThemeFilter — bg/reverse gating", () => { 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;103;103;103mfoo`); + expect(out).toBe(`${ESC}[44m${ESC}[m${ESC}[38;2;108;108;108mfoo`); }); }); diff --git a/src/mainview/utils/ansi-theme-adapt.ts b/src/mainview/utils/ansi-theme-adapt.ts index cbb83927..bc17728e 100644 --- a/src/mainview/utils/ansi-theme-adapt.ts +++ b/src/mainview/utils/ansi-theme-adapt.ts @@ -46,8 +46,9 @@ const DARK_LUMINANCE_THRESHOLD = 0.25; 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.50, lum at threshold → 0.38. -const DARK_NEAR_BLACK_BOOST = 0.12; +// 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; From 26c9cebe3e1290111e60e700c57c322c829649e9 Mon Sep 17 00:00:00 2001 From: h0x91B Date: Wed, 10 Jun 2026 12:59:55 +0300 Subject: [PATCH 7/7] Make fg-adjustment gate luminance-aware for explicit backgrounds A binary gate skipped fg fixes under any explicit background, so Codex's pure-black model name on its own dark truecolor bg (48;2;30;30;46) was never brightened. Explicit truecolor/indexed backgrounds are now classified by luminance: same-polarity bgs keep fg adjustment on, opposite-polarity and unknown (named ANSI, reverse video) still gate. --- .../06/10/fix-light-theme-terminal-colors.md | 2 +- .../066-light-theme-ansi-stream-rewrite.md | 2 +- .../utils/__tests__/ansi-theme-adapt.test.ts | 28 +++++++++ src/mainview/utils/ansi-theme-adapt.ts | 58 +++++++++++++++---- 4 files changed, 76 insertions(+), 14 deletions(-) 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 index b188764c..463e4efc 100644 --- a/change-logs/2026/06/10/fix-light-theme-terminal-colors.md +++ b/change-logs/2026/06/10/fix-light-theme-terminal-colors.md @@ -1 +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 while an explicit colored background or reverse video is active, so vim themes and highlight bars keep their intended contrast. Also darkened the light theme's 16-color ANSI palette (white, bright black, both yellows) so diffs and muted text are legible on white. +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 index 7b008bf3..999300ec 100644 --- a/decisions/066-light-theme-ansi-stream-rewrite.md +++ b/decisions/066-light-theme-ansi-stream-rewrite.md @@ -14,7 +14,7 @@ Added `src/mainview/utils/ansi-theme-adapt.ts` — a stateful stream filter appl 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: while an explicit *colored* background (40-46, 100-106, 48;…) or reverse video (SGR 7) is active, foregrounds pass through untouched. Apps pick those foregrounds *for that background* (vim themes, selection bars), so "fixing" them would break intentional contrast. The gate state persists across chunk boundaries. +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. diff --git a/src/mainview/utils/__tests__/ansi-theme-adapt.test.ts b/src/mainview/utils/__tests__/ansi-theme-adapt.test.ts index 0241a49a..d9062f43 100644 --- a/src/mainview/utils/__tests__/ansi-theme-adapt.test.ts +++ b/src/mainview/utils/__tests__/ansi-theme-adapt.test.ts @@ -295,6 +295,34 @@ describe("createAnsiThemeFilter — bg/reverse gating", () => { 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", () => { diff --git a/src/mainview/utils/ansi-theme-adapt.ts b/src/mainview/utils/ansi-theme-adapt.ts index bc17728e..f53fd698 100644 --- a/src/mainview/utils/ansi-theme-adapt.ts +++ b/src/mainview/utils/ansi-theme-adapt.ts @@ -26,11 +26,14 @@ * 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: while an explicit *colored* background - * (40-46, 100-106, 48;…) or reverse video (SGR 7) is active, foregrounds - * pass through untouched — the app picked that fg *for that bg* (vim themes, - * highlight bars), so "fixing" it would break intentional contrast. White - * backgrounds are exempt from the gate: after remapping they sit close to + * 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. */ @@ -117,10 +120,26 @@ function whiteBgReplacement(bright: boolean, mode: ThemeMode): string[] { 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); "other" = any - // other explicit background (fg adjustment gated off) - bg: "none" | "white" | "other"; + // "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) @@ -153,7 +172,11 @@ function transformSgrParams(raw: string, mode: ThemeMode, gate: GateState): stri out.push(...(gate.darkFg === "30" ? DARK_BAR_FG_30 : DARK_BAR_FG_90)); } }; - const fgAdjustable = () => !gate.reverseActive && gate.bg !== "other"; + 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]; @@ -217,7 +240,7 @@ function transformSgrParams(raw: string, mode: ThemeMode, gate: GateState): stri i++; continue; } - gate.bg = "other"; + gate.bg = "unknown"; out.push(token); i++; continue; @@ -232,7 +255,12 @@ function transformSgrParams(raw: string, mode: ThemeMode, gate: GateState): stri i += 3; continue; } - gate.bg = "other"; + 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; @@ -251,7 +279,13 @@ function transformSgrParams(raw: string, mode: ThemeMode, gate: GateState): stri continue; } if (introducer === "2" && tokens[i + 4] !== undefined) { - if (token === "48") gate.bg = "other"; + 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()) {