Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions change-logs/2026/06/10/fix-light-theme-terminal-colors.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Fixed unreadable terminal colors in both themes. AI agents emit colors tuned for the opposite background: Claude Code sends pale 256-color codes and SGR dim (washed out in light mode), Codex sends GitHub-light ink truecolors like #333333 (invisible in dark mode). The PTY stream is now rewritten on the fly: in light mode dim is dropped, pale foregrounds are darkened, and white backgrounds (Claude Code's message bars) become light gray; in dark mode too-dark foregrounds are brightened toward white, and white backgrounds (Claude Code's message bars and history-select highlight, which the dark palette resolved to a pale lavender block) become dark gray with dark fg on them flipped to light. Foreground fixes are skipped only while an explicit opposite-polarity or unknown background (named ANSI colors, reverse video) is active, so vim themes and highlight bars keep their intended contrast — but bad contrast on a same-polarity background is still fixed (Codex draws its model name in pure black on its own dark truecolor background). Also darkened the light theme's 16-color ANSI palette (white, bright black, both yellows) so diffs and muted text are legible on white.
30 changes: 30 additions & 0 deletions decisions/066-light-theme-ansi-stream-rewrite.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
# Theme-adaptive ANSI color rewrite in the PTY stream

## Context

In the light theme, Claude Code output was unreadable: removed diff lines, file paths, and spinners washed out on the white background. Captured SGR codes via `tmux capture-pane -e`: pale 256-color indexes (`38;5;183`, `38;5;226`, `38;5;51`, `38;5;114`) and `SGR 2` (dim) on `37` (white). The mirror problem appeared in the dark theme with Codex: it emits GitHub-light syntax truecolors (`38;2;51;51;51`, `38;2;24;54;145`, `38;2;167;29;93`) regardless of the terminal background — the terminal answers Codex's OSC 11 background query correctly (verified), Codex simply does not adapt.

## Investigation

ghostty-web's `ITheme` only covers the 16 ANSI colors; 256-color indexes are resolved to RGB inside the WASM terminal, so the theme palette cannot remap them. Dim is rendered as `globalAlpha = 0.5`, which on a white background blends even pure black to `#808080`+ — removed diff lines (`2;37`) were hopeless regardless of palette values.

## Decision

Added `src/mainview/utils/ansi-theme-adapt.ts` — a stateful stream filter applied in `TerminalView.tsx` (`enqueueTermWrite` flush) with the resolved theme as mode. Light mode: drops standalone `SGR 2`, rewrites pale foregrounds (`38;5;N` with N≥16, `38;2;R;G;B`) whose relative luminance exceeds 0.55 to a darkened truecolor (~0.42 luminance). Dark mode: keeps dim, brightens foregrounds below 0.25 luminance by blending toward white (~0.38 target; blend handles pure black without division by zero). Sequences split across WS chunks are carried over to the next flush. Also darkened `LIGHT_TERMINAL_THEME` entries (white, brightBlack, yellow, brightYellow) to GitHub Primer light fg values.

Darkening `white` created a follow-up conflict: Claude Code's light-ansi theme paints message bars with `ansi:white` as a *background* (`SGR 47`) and dark fg (30/90) on top — a dark-on-dark bar. The filter therefore splits the roles of index 7/15 in light mode: as text (37/97) they stay dark, as backgrounds (47/107, 48;5;7, 48;5;15) they are rewritten to light gray truecolor (220/240), matching Claude Code's own non-ansi light theme bar colors.

Foreground adjustment in both modes is gated by cross-sequence state, but the gate is luminance-aware, not binary: explicit truecolor/indexed backgrounds are classified by relative luminance (dark < 0.35, light > 0.55, mid-tones unknown). Foregrounds pass through untouched only when the active background's polarity is *opposite* to the theme (vim themes, selection bars — the app picked that fg for that bg) or unknown (named ANSI bgs 40-46/100-106 resolve theme-side, reverse video SGR 7). Same-polarity backgrounds keep fg adjustment on: Codex paints its entire UI on an explicit dark bg (`48;2;30;30;46`, Catppuccin base) and writes its model name with `38;2;0;0;0` on top — with a binary gate that black stayed black. The gate state persists across chunk boundaries.

White backgrounds get the same role-split in dark mode, mirrored: Claude Code paints message bars and the history-select highlight with `ansi:white`/`ansi:whiteBright` (47/107, `userMessageBackground`/`…Hover` in cli.js), which the dark palette resolves to pale lavender (#a9b1d6/#c0caf5) — default-fg text on it is unreadable. They are remapped to Claude Code's own dark theme bar colors (55/70 gray). Because Claude draws dark fg (30/90) on those bars (emitting fg *before* bg), the filter tracks the last dark ANSI fg across sequences and flips it to light gray when a white bar opens (and when 30/90 is set while one is active). Remapped white bars do not gate fg adjustment — after remapping they sit near the theme background, so the normal pale/dark fg fix stays correct on them.

## Risks

Dropping dim loses the muted-vs-normal distinction in light mode (diff add/remove still differ by their red/green markers). Colors written while one theme is active stay resolved in scrollback after a theme switch until the app repaints. Light-mode pale backgrounds are intentionally untouched — darkening them would invert intent. The gate only sees SGR; an app that sets a background via OSC or DEC private modes would not trip it (not observed in practice).

## Alternatives considered

- Remapping via theme palette: impossible for 256-color indexes (resolved in WASM).
- OSC 4 palette redefinition: not supported by ghostty-web.
- Stateful dim→blended-color emulation (tracking fg across sequences): better fidelity but significantly more complex; dropping dim is predictable and readable.
- Asking Codex to adapt (it queries OSC 11 and gets a correct dark reply): upstream behavior we cannot control; symptom must be fixed on our side.
17 changes: 12 additions & 5 deletions src/mainview/TerminalView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import { installTerminalCopyDiagnostics } from "./terminal-copy-diagnostics";
import { getZoom, ZOOM_CHANGED_EVENT } from "./zoom";
import { TERMINAL_KEYMAPS, getKeymapPreset, KEYMAP_CHANGED_EVENT } from "./terminal-keymaps";
import { uploadDroppedFile } from "./utils/uploadDroppedFile";
import { createAnsiThemeFilter } from "./utils/ansi-theme-adapt";

const DARK_TERMINAL_THEME = {
background: "#1a1b26",
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -704,15 +707,19 @@ function TerminalView({ ptyUrl, taskId, projectId, onReady }: TerminalViewProps)
let writeRafId: number | null = null;
// Reference to the terminal for batched writes (set by connectPty)
let batchTerm: Terminal | null = null;
// Rewrites SGR colors unreadable on the current background: pale/dim
// in light mode, too-dark ink in dark mode. See utils/ansi-theme-adapt.ts.
const themeFilter = createAnsiThemeFilter();

function enqueueTermWrite(data: string) {
pendingWrite += data;
if (writeRafId === null) {
writeRafId = requestAnimationFrame(() => {
writeRafId = null;
if (disposed || !pendingWrite || !batchTerm) return;
const batch = pendingWrite;
const batch = themeFilter(pendingWrite, resolvedThemeRef.current);
pendingWrite = "";
if (!batch) return;
try {
batchTerm.write(batch);
} catch {
Expand Down
Loading
Loading