diff --git a/ui-tui/src/__tests__/themeBoot.test.ts b/ui-tui/src/__tests__/themeBoot.test.ts new file mode 100644 index 000000000000..92bb500f0a4f --- /dev/null +++ b/ui-tui/src/__tests__/themeBoot.test.ts @@ -0,0 +1,130 @@ +import { describe, expect, it } from 'vitest' + +import { invalidateBootBackground, seedBootEnvironment, type BootTheme } from '../lib/themeBoot.js' +import { defaultTheme } from '../theme.js' + +// Review on #20379 (finding 2): the boot cache seeds the previous session's +// background into HERMES_TUI_BACKGROUND, which detectLightMode treats as a +// CURRENT signal. Without provenance, a stale light cache pins a now-dark +// terminal to light indefinitely (the current probe's pure-black answer is +// distrusted, the pure-white foreground is distrusted, and the macOS +// fallback refuses to run while the slot is occupied). These tests cover +// the seed/invalidate contract the gateway handler drives. + +const cache = (over: Partial = {}): BootTheme => ({ theme: defaultTheme, ...over }) + +describe('seedBootEnvironment', () => { + it('seeds the cached background when no explicit signal outranks it', () => { + const env: NodeJS.ProcessEnv = {} + + const seeded = seedBootEnvironment(cache({ background: '#ffffff' }), env) + + expect(env.HERMES_TUI_BACKGROUND).toBe('#ffffff') + expect(seeded).toEqual({ seededBackground: '#ffffff', seededPin: false }) + }) + + it('never seeds over explicit user signals', () => { + for (const preset of [ + { HERMES_TUI_THEME: 'dark' }, + { HERMES_TUI_LIGHT: '1' } + ] as NodeJS.ProcessEnv[]) { + const env = { ...preset } + const seeded = seedBootEnvironment(cache({ background: '#ffffff', mode: 'light' }), env) + + expect(env.HERMES_TUI_BACKGROUND).toBeUndefined() + expect(seeded).toEqual({ seededBackground: null, seededPin: false }) + } + + // A user-exported background keeps its value; only the pin may seed. + const env: NodeJS.ProcessEnv = { HERMES_TUI_BACKGROUND: '#123456' } + + expect(seedBootEnvironment(cache({ background: '#ffffff' }), env).seededBackground).toBeNull() + expect(env.HERMES_TUI_BACKGROUND).toBe('#123456') + }) + + it('never seeds the untrusted pure-black fingerprint', () => { + const env: NodeJS.ProcessEnv = {} + + const seeded = seedBootEnvironment(cache({ background: '#000000' }), env) + + expect(env.HERMES_TUI_BACKGROUND).toBeUndefined() + expect(seeded.seededBackground).toBeNull() + }) + + it('replays a cached config pin coherently with the physical background', () => { + // "/theme light" pinned while the physical terminal is dark: the cache + // stores BOTH — replaying only the background would resolve the first + // skin dark and recreate the light → dark → light flash. + const env: NodeJS.ProcessEnv = {} + + const seeded = seedBootEnvironment(cache({ background: '#1e1e1e', mode: 'light' }), env) + + expect(env.HERMES_TUI_THEME).toBe('light') + expect(env.HERMES_TUI_BACKGROUND).toBe('#1e1e1e') + expect(seeded).toEqual({ seededBackground: '#1e1e1e', seededPin: true }) + }) + + it('replays a pinned dark on a light physical background too', () => { + const env: NodeJS.ProcessEnv = {} + + const seeded = seedBootEnvironment(cache({ background: '#ffffff', mode: 'dark' }), env) + + expect(env.HERMES_TUI_THEME).toBe('dark') + expect(env.HERMES_TUI_BACKGROUND).toBe('#ffffff') + expect(seeded.seededPin).toBe(true) + }) + + it('is a no-op without a cache', () => { + const env: NodeJS.ProcessEnv = {} + + expect(seedBootEnvironment(null, env)).toEqual({ seededBackground: null, seededPin: false }) + expect(env).toEqual({}) + }) +}) + +describe('invalidateBootBackground', () => { + it('clears the slot while it still holds the seeded value (stale light cache, current dark terminal)', () => { + const env: NodeJS.ProcessEnv = {} + + seedBootEnvironment(cache({ background: '#ffffff' }), env) + + // Current terminal answers OSC-11 with distrusted #000000 → the handler + // invalidates: the slot must clear so foreground / COLORFGBG / macOS + // appearance / the default get their turn. + expect(invalidateBootBackground(env)).toBe(true) + expect(env.HERMES_TUI_BACKGROUND).toBeUndefined() + + // Idempotent: a second distrusted answer has nothing left to clear. + expect(invalidateBootBackground(env)).toBe(false) + }) + + it('clears a stale dark cache on a current ambiguous terminal the same way', () => { + const env: NodeJS.ProcessEnv = {} + + seedBootEnvironment(cache({ background: '#1e1e1e' }), env) + + expect(invalidateBootBackground(env)).toBe(true) + expect(env.HERMES_TUI_BACKGROUND).toBeUndefined() + }) + + it('leaves a trusted OSC answer that overwrote the seed alone', () => { + const env: NodeJS.ProcessEnv = {} + + seedBootEnvironment(cache({ background: '#ffffff' }), env) + + // A real OSC-11 measurement replaced the hint — it is authoritative. + env.HERMES_TUI_BACKGROUND = '#282828' + + expect(invalidateBootBackground(env)).toBe(false) + expect(env.HERMES_TUI_BACKGROUND).toBe('#282828') + }) + + it('is a no-op when nothing was seeded', () => { + const env: NodeJS.ProcessEnv = { HERMES_TUI_BACKGROUND: '#ffffff' } + + seedBootEnvironment(null, env) + + expect(invalidateBootBackground(env)).toBe(false) + expect(env.HERMES_TUI_BACKGROUND).toBe('#ffffff') + }) +}) diff --git a/ui-tui/src/app/createGatewayEventHandler.ts b/ui-tui/src/app/createGatewayEventHandler.ts index 320d7441064c..a4c86b601190 100644 --- a/ui-tui/src/app/createGatewayEventHandler.ts +++ b/ui-tui/src/app/createGatewayEventHandler.ts @@ -19,7 +19,7 @@ import { openExternalUrl } from '../lib/openExternalUrl.js' import { rpcErrorMessage } from '../lib/rpc.js' import { topLevelSubagents } from '../lib/subagentTree.js' import { formatAbandonedClarify, formatToolCall, stripAnsi } from '../lib/text.js' -import { writeBootTheme } from '../lib/themeBoot.js' +import { bootSeededPin, invalidateBootBackground, writeBootTheme } from '../lib/themeBoot.js' import { defaultThemeForCurrentBackground, detectLightMode, fromSkin, type Theme } from '../theme.js' import type { Msg, SubagentProgress, SubagentStatus } from '../types.js' @@ -83,7 +83,14 @@ const commitTheme = (theme: Theme) => { lastCommittedTheme = theme patchUiState({ theme }) - writeBootTheme(theme, process.env.HERMES_TUI_BACKGROUND) + // Persist the config pin alongside the resolved theme + physical + // background: a pinned session's resolved polarity intentionally + // disagrees with the background, and caching one without the other + // recreates the multi-stage flash on the next launch (light first frame → + // dark skin resolve against the cached background → light config pin). + const pin = configPinnedTheme ? process.env.HERMES_TUI_THEME : undefined + + writeBootTheme(theme, process.env.HERMES_TUI_BACKGROUND, pin === 'light' || pin === 'dark' ? pin : undefined) if (changed) { setTimeout(() => forceRedraw(process.stdout), 40).unref?.() @@ -130,8 +137,11 @@ export function reapplyTheme(): void { * terminal background unset. */ // True once CONFIG (via light/dark) owns the HERMES_TUI_THEME env pin, so an -// 'auto' hydrate knows not to clobber a user's shell-exported pin. -let configPinnedTheme = false +// 'auto' hydrate knows not to clobber a user's shell-exported pin. A pin the +// boot cache replayed counts as config-owned — it originated from +// display.tui_theme last session, and treating it as a shell export would +// make a stale cached pin unclearable by 'auto'. +let configPinnedTheme = bootSeededPin export function applyConfiguredTuiTheme(raw: unknown): void { const mode = String(raw ?? '') @@ -224,6 +234,21 @@ export function syncThemeToTerminalBackground(): void { // foreground below resolves the pole for transparent hosts, and a truly // pure-black terminal lands on dark either way. if (hex === '#000000') { + // The CURRENT terminal answered with an untrusted value — a background + // the boot cache seeded is from another era and must not keep + // outranking the live fallback chain (previous light session + new + // pure-black terminal stayed light forever: OSC-10 pure-white is also + // rejected, and the macOS fallback refuses to run while the slot is + // occupied). Clear the stale hint, give OSC-10 (same startup batch) + // first claim, then settle via env heuristics if nothing answered. + if (invalidateBootBackground()) { + setTimeout(() => { + if (!resolved) { + reapplyTheme() + } + }, 250).unref?.() + } + return } diff --git a/ui-tui/src/lib/themeBoot.ts b/ui-tui/src/lib/themeBoot.ts index 6062ac2c103d..76872f457c59 100644 --- a/ui-tui/src/lib/themeBoot.ts +++ b/ui-tui/src/lib/themeBoot.ts @@ -23,6 +23,12 @@ import type { Theme } from '../theme.js' interface BootThemeFile { /** The resolved background hex that detection settled on, if any. */ background?: string + /** The config mode pin (`display.tui_theme`) active when this cache was + * written. Without it a pinned theme caches incoherently — a light + * resolved theme next to the dark PHYSICAL background — and the next + * launch flashes light → dark (skin resolves against the seeded + * background) → light (config pin rehydrates). */ + mode?: 'dark' | 'light' /** The fully-resolved Theme (palette + brand) from the last session. */ theme?: Theme version: 1 @@ -55,8 +61,14 @@ const looksLikeTheme = (value: unknown): value is Theme => { ) } +export interface BootTheme { + background?: string + mode?: 'dark' | 'light' + theme: Theme +} + /** Read the cached boot theme. Null on first launch / damage / test runs. */ -export function readBootTheme(): { background?: string; theme: Theme } | null { +export function readBootTheme(): BootTheme | null { if (isTestRun()) { return null } @@ -68,7 +80,11 @@ export function readBootTheme(): { background?: string; theme: Theme } | null { return null } - return { background: typeof raw.background === 'string' ? raw.background : undefined, theme: raw.theme } + return { + background: typeof raw.background === 'string' ? raw.background : undefined, + mode: raw.mode === 'light' || raw.mode === 'dark' ? raw.mode : undefined, + theme: raw.theme + } } catch { return null } @@ -77,7 +93,7 @@ export function readBootTheme(): { background?: string; theme: Theme } | null { let writeTimer: NodeJS.Timeout | null = null /** Persist the resolved theme (debounced, atomic, fire-and-forget). */ -export function writeBootTheme(theme: Theme, background?: string): void { +export function writeBootTheme(theme: Theme, background?: string, mode?: 'dark' | 'light'): void { if (isTestRun()) { return } @@ -90,7 +106,7 @@ export function writeBootTheme(theme: Theme, background?: string): void { writeTimer = null try { - const payload: BootThemeFile = { background, theme, version: 1 } + const payload: BootThemeFile = { background, mode, theme, version: 1 } const path = bootFilePath() const tmp = `${path}.tmp` @@ -104,25 +120,90 @@ export function writeBootTheme(theme: Theme, background?: string): void { writeTimer.unref?.() } +// ── Boot-time seeding (with provenance) ────────────────────────────── +// +// The cache seeds env slots so the first skin resolution matches the last +// session — but a previous-session background must NOT occupy the same +// authoritative slot as a current OSC answer forever. Provenance is +// recorded so that once the CURRENT terminal answers with a distrusted +// value (pure-black OSC-11, unusable OSC-10), the stale hint can be +// invalidated and detection falls through to foreground / COLORFGBG / +// macOS appearance / the default — instead of a light cache pinning a now- +// dark terminal to light indefinitely (review on #20379, finding 2). + +export interface BootSeedResult { + /** The background hex this boot seeded into env, or null. */ + seededBackground: null | string + /** True when the cached config pin was seeded into HERMES_TUI_THEME. */ + seededPin: boolean +} + +// Provenance of the last seeding pass — read by invalidateBootBackground. +// Module-load seeds process.env; tests re-seed against a fake env. +let seeded: BootSeedResult = { seededBackground: null, seededPin: false } + +/** Seeding step (exported for tests — module-load runs it on process.env). + * Explicit user signals always outrank the cache. Records provenance so + * invalidateBootBackground can later demote the hint. */ +export function seedBootEnvironment(boot: BootTheme | null, env: NodeJS.ProcessEnv): BootSeedResult { + const result: BootSeedResult = { seededBackground: null, seededPin: false } + + seeded = result + + if (!boot || env.HERMES_TUI_THEME || env.HERMES_TUI_LIGHT) { + return result + } + + // Replay the config mode pin first: a pinned session caches a resolved + // theme whose polarity intentionally disagrees with the physical + // background, and without the pin the first skin resolution flips to the + // physical pole before config hydration flips it back (multi-stage flash). + if (boot.mode) { + env.HERMES_TUI_THEME = boot.mode + result.seededPin = true + } + + if ( + boot.background && + // Never seed the untrusted "unset default" fingerprint — a cache written + // before the distrust rule existed must not poison this session's + // detection (it would also suppress the macOS-appearance fallback). + boot.background.toLowerCase() !== '#000000' && + !env.HERMES_TUI_BACKGROUND + ) { + env.HERMES_TUI_BACKGROUND = boot.background + result.seededBackground = boot.background + } + + return result +} + /** - * Boot-time seeding, run once at module load (imported by uiStore before the - * first render): make the cached background visible to `detectLightMode` - * unless an explicit signal already outranks it. + * Invalidate the cache-seeded background: called when the CURRENT terminal + * answered the background probe with an untrusted value, meaning the seeded + * hint is from another era and must not outrank the live fallback chain. + * Only clears the slot while it still holds the seeded value — a trusted + * OSC answer or explicit export that overwrote it is left alone. + * Returns true when the slot was cleared. */ +export function invalidateBootBackground(env: NodeJS.ProcessEnv = process.env): boolean { + if (!seeded.seededBackground || env.HERMES_TUI_BACKGROUND !== seeded.seededBackground) { + return false + } + + delete env.HERMES_TUI_BACKGROUND + seeded.seededBackground = null + + return true +} + const boot = readBootTheme() -if ( - boot?.background && - // Never seed the untrusted "unset default" fingerprint — a cache written - // before the distrust rule existed must not poison this session's - // detection (it would also suppress the macOS-appearance fallback). - boot.background.toLowerCase() !== '#000000' && - !process.env.HERMES_TUI_BACKGROUND && - !process.env.HERMES_TUI_THEME && - !process.env.HERMES_TUI_LIGHT -) { - process.env.HERMES_TUI_BACKGROUND = boot.background -} +/** True when this boot replayed a cached config pin into HERMES_TUI_THEME. + * applyConfiguredTuiTheme treats it as config-owned so a later 'auto' can + * clear it — otherwise a stale cached pin masquerades as a user shell + * export and becomes unclearable. */ +export const bootSeededPin: boolean = seedBootEnvironment(boot, process.env).seededPin /** The cached theme for the first frame, or null on first launch. */ export const bootTheme: Theme | null = boot?.theme ?? null