diff --git a/ui-tui/src/__tests__/theme.test.ts b/ui-tui/src/__tests__/theme.test.ts index fb7f7867025a..5496ae6117d0 100644 --- a/ui-tui/src/__tests__/theme.test.ts +++ b/ui-tui/src/__tests__/theme.test.ts @@ -653,3 +653,31 @@ describe('background-aware adaptation (OSC-11 light terminals)', () => { expect(color.statusCritical).toBe(fromSkin({ ui_error: '#dd2222' }, {}).color.error) }) }) + +describe('themeToneHex', () => { + it('resolves a tone to the literal color it paints as', async () => { + const { themeToneHex } = await importThemeWithCleanEnv() + + // 232+ is the grayscale ramp (8 + (n-232)*10); 16-231 is the 6x6x6 cube. + expect(themeToneHex('ansi256(238)')).toBe('#444444') + expect(themeToneHex('ansi256(161)')).toBe('#d7005f') + // An authored hex is already literal. + expect(themeToneHex('#e77fa3')).toBe('#e77fa3') + // No paintable color ⇒ '', which releases the terminal default. + expect(themeToneHex('')).toBe('') + expect(themeToneHex('ansi256(999)')).toBe('') + expect(themeToneHex('inherit')).toBe('') + }) + + it('makes every tone paintable on a quantizing terminal', async () => { + // The contract OSC-10 depends on: whatever the palette normalizer does to + // a tone, themeToneHex still yields a literal `#rrggbb`. Asserted over the + // whole palette so a new tone can't silently regress the default paint. + const { fromSkin, themeToneHex } = await importThemeWithEnv({ TERM_PROGRAM: 'Apple_Terminal' }) + const { color } = fromSkin({ background: '#f6f9fd', ui_text: '#4a4550' }, {}) + + for (const tone of Object.values(color)) { + expect(themeToneHex(tone)).toMatch(/^#[0-9a-f]{6}$/i) + } + }) +}) diff --git a/ui-tui/src/app/createGatewayEventHandler.ts b/ui-tui/src/app/createGatewayEventHandler.ts index d8630e216736..bbd826138607 100644 --- a/ui-tui/src/app/createGatewayEventHandler.ts +++ b/ui-tui/src/app/createGatewayEventHandler.ts @@ -22,7 +22,7 @@ import { topLevelSubagents } from '../lib/subagentTree.js' import { isPaintableHex, setTerminalBackground, setTerminalForeground } from '../lib/terminalModes.js' import { formatAbandonedClarify, formatToolCall, stripAnsi } from '../lib/text.js' import { bootSeededPin, invalidateBootBackground, writeBootTheme } from '../lib/themeBoot.js' -import { defaultThemeForCurrentBackground, fromSkin, skinIsLight, type Theme } from '../theme.js' +import { defaultThemeForCurrentBackground, fromSkin, skinIsLight, type Theme, themeToneHex } from '../theme.js' import type { Msg, SubagentProgress, SubagentStatus } from '../types.js' import { applyDelegationStatus, getDelegationState } from './delegationStore.js' @@ -126,11 +126,13 @@ const themesEqual = (a: Theme, b: Theme) => { // the theme's text color. Without the pair, a dark skin on a light terminal // leaves default-fg text at the HOST's near-black: invisible. Opt-in stays // intact: no `background` ⇒ both defaults restore to the terminal's own. +// The text tone resolves through themeToneHex because a limited-palette +// terminal quantizes it to `ansi256(N)`, which OSC-10 cannot speak. const paintTerminalDefaults = (theme: Theme) => { const background = lastSkin?.colors?.background ?? '' setTerminalBackground(background) - setTerminalForeground(isPaintableHex(background) ? theme.color.text : '') + setTerminalForeground(isPaintableHex(background) ? themeToneHex(theme.color.text) : '') } const applySkin = (s: GatewaySkin) => { diff --git a/ui-tui/src/theme.ts b/ui-tui/src/theme.ts index 7ddd75a4e61d..11ef94131567 100644 --- a/ui-tui/src/theme.ts +++ b/ui-tui/src/theme.ts @@ -222,6 +222,31 @@ function normalizeAnsiForeground(color: string): string { return `ansi256(${ansi})` } +const ANSI256_RE = /^ansi256\((\d{1,3})\)$/ + +/** + * The literal `#rrggbb` a theme tone paints as, or '' when it has none. + * + * The inverse of `normalizeAnsiForeground`: tones are not uniformly hex, since + * a limited-palette terminal rewrites the foregrounds to `ansi256(N)`. + * Consumers needing a real color — OSC 10/11, which only speak `#rrggbb` — + * resolve through here rather than hex-testing the tone, which would silently + * skip exactly the terminals that did the quantizing. + */ +export function themeToneHex(tone: string): string { + const ansi = ANSI256_RE.exec(tone.trim()) + + if (ansi) { + const n = Number(ansi[1]) + + return n <= 255 ? toHex(xtermEightBitRgb(n)) : '' + } + + const rgb = parseColor(tone) + + return rgb ? toHex(rgb) : '' +} + // ── Defaults ───────────────────────────────────────────────────────── const BRAND: ThemeBrand = {