From 4426d57a8420aaed0984e28b8b469aff94a8e5ed Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Mon, 20 Jul 2026 17:37:14 -0500 Subject: [PATCH] feat(ui-tui): OSC-10 foreground polarity tiebreaker for transparent terminals MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Transparent profiles make OSC-11 useless (xterm reports the unset default, pure black, regardless of the composited surface) so polarity detection lagged editor theme flips. OSC-10 reports the theme's REAL foreground on those hosts — its luminance reveals the pole. hermes-ink grows a foreground slot (shared reportedColorSlot factory), App.tsx queries both in the startup batch (background first so a trusted answer wins without churn), and the app commits an inferred pole only when the background was distrusted AND the foreground is decisive (bright=dark theme, dark=light; mid-grays and #000/#fff defaults commit nothing). User pins still outrank. --- .../packages/hermes-ink/src/entry-exports.ts | 9 +- .../hermes-ink/src/ink/components/App.tsx | 66 ++++++++----- .../src/ink/terminal-background.test.ts | 14 +++ .../packages/hermes-ink/src/ink/terminal.ts | 96 ++++++++++++------- .../createGatewayEventHandler.test.ts | 14 +++ ui-tui/src/app/createGatewayEventHandler.ts | 53 +++++++++- ui-tui/src/types/hermes-ink.d.ts | 2 + 7 files changed, 195 insertions(+), 59 deletions(-) diff --git a/ui-tui/packages/hermes-ink/src/entry-exports.ts b/ui-tui/packages/hermes-ink/src/entry-exports.ts index 57968a60e63..49bbf21179f 100644 --- a/ui-tui/packages/hermes-ink/src/entry-exports.ts +++ b/ui-tui/packages/hermes-ink/src/entry-exports.ts @@ -26,7 +26,14 @@ export { default as measureElement } from './ink/measure-element.js' export { scrollFastPathStats, type ScrollFastPathStats } from './ink/render-node-to-output.js' export { createRoot, forceRedraw, default as render, renderSync } from './ink/root.js' export { stringWidth } from './ink/stringWidth.js' -export { isXtermJs, onTerminalBackground, parseOscColor, terminalBackgroundHex } from './ink/terminal.js' +export { + isXtermJs, + onTerminalBackground, + onTerminalForeground, + parseOscColor, + terminalBackgroundHex, + terminalForegroundHex +} from './ink/terminal.js' export type { MouseTrackingMode } from './ink/termio/dec.js' export { wrapAnsi } from './ink/wrapAnsi.js' diff --git a/ui-tui/packages/hermes-ink/src/ink/components/App.tsx b/ui-tui/packages/hermes-ink/src/ink/components/App.tsx index 8602a5ba5e4..3d732b0fb87 100644 --- a/ui-tui/packages/hermes-ink/src/ink/components/App.tsx +++ b/ui-tui/packages/hermes-ink/src/ink/components/App.tsx @@ -22,7 +22,14 @@ import reconciler from '../reconciler.js' import { clearSelection, finishSelection, hasSelection, type SelectionState, startSelection } from '../selection.js' import { getTerminalFocused, setTerminalFocused } from '../terminal-focus-state.js' import { decrqm, oscColor, TerminalQuerier, xtversion } from '../terminal-querier.js' -import { isXtermJs, parseOscColor, setTerminalBackgroundHex, setXtversionName, supportsExtendedKeys } from '../terminal.js' +import { + isXtermJs, + parseOscColor, + setTerminalBackgroundHex, + setTerminalForegroundHex, + setXtversionName, + supportsExtendedKeys +} from '../terminal.js' import { DISABLE_KITTY_KEYBOARD, DISABLE_MODIFY_OTHER_KEYS, @@ -336,28 +343,43 @@ export default class App extends PureComponent { // init sequence completes — avoids interleaving with alt-screen/mouse // tracking enable writes that may happen in the same render cycle. setImmediate(() => { - // OSC 11 rides the same batch: the terminal's actual background - // color drives light/dark theme detection where env heuristics - // (COLORFGBG, TERM_PROGRAM) are blind — notably xterm.js hosts. - void Promise.all([this.querier.send(xtversion()), this.querier.send(oscColor(11)), this.querier.flush()]).then( - ([r, bg]) => { - if (r) { - setXtversionName(r.name) - logForDebugging(`XTVERSION: terminal identified as "${r.name}"`) - } else { - logForDebugging('XTVERSION: no reply (terminal ignored query)') - } - - const bgHex = bg ? parseOscColor(bg.data) : undefined - - if (bgHex) { - setTerminalBackgroundHex(bgHex) - logForDebugging(`OSC11: terminal background is ${bgHex}`) - } else { - logForDebugging('OSC11: no reply (terminal ignored query)') - } + // OSC 11 + OSC 10 ride the same batch: the terminal's actual + // background drives light/dark theme detection where env heuristics + // (COLORFGBG, TERM_PROGRAM) are blind — notably xterm.js hosts. The + // FOREGROUND is the polarity tiebreaker for transparent profiles: + // those report the unset-default background (pure black) but the + // theme's real foreground, whose luminance reveals the pole. + void Promise.all([ + this.querier.send(xtversion()), + this.querier.send(oscColor(11)), + this.querier.send(oscColor(10)), + this.querier.flush() + ]).then(([r, bg, fg]) => { + if (r) { + setXtversionName(r.name) + logForDebugging(`XTVERSION: terminal identified as "${r.name}"`) + } else { + logForDebugging('XTVERSION: no reply (terminal ignored query)') } - ) + + const bgHex = bg ? parseOscColor(bg.data) : undefined + const fgHex = fg ? parseOscColor(fg.data) : undefined + + // Background first: a trusted OSC-11 answer settles polarity + // outright, so the foreground listener (the transparent-profile + // tiebreaker) sees it already resolved and stays silent. + if (bgHex) { + setTerminalBackgroundHex(bgHex) + logForDebugging(`OSC11: terminal background is ${bgHex}`) + } else { + logForDebugging('OSC11: no reply (terminal ignored query)') + } + + if (fgHex) { + setTerminalForegroundHex(fgHex) + logForDebugging(`OSC10: terminal foreground is ${fgHex}`) + } + }) }) // Re-assert mouse tracking on raw-mode re-entry. diff --git a/ui-tui/packages/hermes-ink/src/ink/terminal-background.test.ts b/ui-tui/packages/hermes-ink/src/ink/terminal-background.test.ts index 0cd1d0a9fef..54c6286feeb 100644 --- a/ui-tui/packages/hermes-ink/src/ink/terminal-background.test.ts +++ b/ui-tui/packages/hermes-ink/src/ink/terminal-background.test.ts @@ -75,4 +75,18 @@ describe('terminal background storage', () => { t.setTerminalBackgroundHex('#000000') expect(t.terminalBackgroundHex()).toBe('#ffffff') }) + + it('foreground (OSC 10) is an independent slot with the same semantics', async () => { + const t = await freshTerminal() + const seen: string[] = [] + + t.onTerminalForeground(hex => seen.push(hex)) + t.setTerminalForegroundHex('#cccccc') + t.setTerminalForegroundHex('#000000') + + expect(seen).toEqual(['#cccccc']) + expect(t.terminalForegroundHex()).toBe('#cccccc') + // The background slot is untouched by foreground writes. + expect(t.terminalBackgroundHex()).toBeUndefined() + }) }) diff --git a/ui-tui/packages/hermes-ink/src/ink/terminal.ts b/ui-tui/packages/hermes-ink/src/ink/terminal.ts index e3318e0fded..5a3ae3906d0 100644 --- a/ui-tui/packages/hermes-ink/src/ink/terminal.ts +++ b/ui-tui/packages/hermes-ink/src/ink/terminal.ts @@ -172,50 +172,80 @@ export function needsAltScreenResizeScrollbackClear(env: NodeJS.ProcessEnv = pro return (env.TERM_PROGRAM ?? '').trim() === 'Apple_Terminal' } -// -- OSC-11-detected terminal background (populated async at startup) -- +// -- OSC-detected terminal colors (populated async at startup) -- // // Env heuristics (COLORFGBG, TERM_PROGRAM allow-lists) can't see the actual -// terminal background — xterm.js hosts (VS Code / Cursor) set neither, so a +// terminal colors — xterm.js hosts (VS Code / Cursor) set neither, so a // light-themed editor terminal reads as "dark" and gets an unreadable -// palette. OSC 11 asks the terminal directly; App.tsx fires the query in the -// same startup batch as XTVERSION and calls setTerminalBackgroundHex() when -// the reply lands. Readers treat undefined as "not yet known / unsupported". +// palette. OSC 11 (background) and OSC 10 (foreground) ask the terminal +// directly; App.tsx fires both in the same startup batch as XTVERSION. +// The foreground matters because transparent profiles LIE about the +// background (xterm reports the unset default, pure black) while reporting +// the theme's real foreground — its luminance is the only trustworthy +// polarity signal on such hosts. Readers treat undefined as "not yet +// known / unsupported". -let terminalBackground: string | undefined -const terminalBackgroundListeners = new Set<(hex: string) => void>() - -/** Record the OSC 11 response. First writer wins (defend against re-probe). */ -export function setTerminalBackgroundHex(hex: string): void { - if (terminalBackground !== undefined) { - return - } - - terminalBackground = hex - - for (const listener of terminalBackgroundListeners) { - listener(hex) - } - - terminalBackgroundListeners.clear() +interface ReportedColorSlot { + set(hex: string): void + get(): string | undefined + on(listener: (hex: string) => void): void } +function reportedColorSlot(): ReportedColorSlot { + let value: string | undefined + const listeners = new Set<(hex: string) => void>() + + return { + // First writer wins (defend against re-probe). + set(hex) { + if (value !== undefined) { + return + } + + value = hex + + for (const listener of listeners) { + listener(hex) + } + + listeners.clear() + }, + get: () => value, + // Fires immediately when already known, otherwise once on the reply. + on(listener) { + if (value !== undefined) { + listener(value) + + return + } + + listeners.add(listener) + } + } +} + +const background = reportedColorSlot() +const foreground = reportedColorSlot() + +/** Record the OSC 11 response. */ +export const setTerminalBackgroundHex = (hex: string): void => background.set(hex) + /** The terminal's reported background as `#rrggbb`, or undefined if the * reply hasn't arrived (or the terminal ignored the query). */ -export function terminalBackgroundHex(): string | undefined { - return terminalBackground -} +export const terminalBackgroundHex = (): string | undefined => background.get() -/** Subscribe to the background color. Fires immediately when already known, - * otherwise once when the OSC 11 reply arrives. */ -export function onTerminalBackground(listener: (hex: string) => void): void { - if (terminalBackground !== undefined) { - listener(terminalBackground) +/** Subscribe to the background color. */ +export const onTerminalBackground = (listener: (hex: string) => void): void => background.on(listener) - return - } +/** Record the OSC 10 response. */ +export const setTerminalForegroundHex = (hex: string): void => foreground.set(hex) - terminalBackgroundListeners.add(listener) -} +/** The terminal's reported foreground as `#rrggbb`, or undefined if the + * reply hasn't arrived (or the terminal ignored the query). */ +export const terminalForegroundHex = (): string | undefined => foreground.get() + +/** Subscribe to the foreground color. */ +export const onTerminalForeground = (listener: (hex: string) => void): void => foreground.on(listener) /** * Parse an OSC color reply payload into `#rrggbb`. diff --git a/ui-tui/src/__tests__/createGatewayEventHandler.test.ts b/ui-tui/src/__tests__/createGatewayEventHandler.test.ts index ff584695a8f..5a4b75db7cb 100644 --- a/ui-tui/src/__tests__/createGatewayEventHandler.test.ts +++ b/ui-tui/src/__tests__/createGatewayEventHandler.test.ts @@ -743,6 +743,20 @@ describe('createGatewayEventHandler', () => { vi.unstubAllEnvs() }) + it('infers polarity from the OSC-10 foreground only when the answer is decisive', async () => { + const { polarityBackgroundFromForeground } = await import('../app/createGatewayEventHandler.js') + + // Bright foreground = dark theme; dark foreground = light theme. + expect(polarityBackgroundFromForeground('#cccccc')).toBe('#1e1e1e') + expect(polarityBackgroundFromForeground('#333333')).toBe('#ffffff') + + // Unset-default fingerprints and ambiguous mid-grays commit nothing. + expect(polarityBackgroundFromForeground('#000000')).toBeUndefined() + expect(polarityBackgroundFromForeground('#ffffff')).toBeUndefined() + expect(polarityBackgroundFromForeground('#808080')).toBeUndefined() + expect(polarityBackgroundFromForeground('not-a-color')).toBeUndefined() + }) + it('on gateway.ready with no STARTUP_RESUME_ID and auto_resume off, forges a new session', async () => { const appended: Msg[] = [] const newSession = vi.fn() diff --git a/ui-tui/src/app/createGatewayEventHandler.ts b/ui-tui/src/app/createGatewayEventHandler.ts index 1a20dd2473f..3ba6558b5d7 100644 --- a/ui-tui/src/app/createGatewayEventHandler.ts +++ b/ui-tui/src/app/createGatewayEventHandler.ts @@ -1,6 +1,6 @@ import { execFile } from 'child_process' -import { forceRedraw, onTerminalBackground } from '@hermes/ink' +import { forceRedraw, onTerminalBackground, onTerminalForeground } from '@hermes/ink' import { STARTUP_IMAGE, STARTUP_QUERY } from '../config/env.js' import { STREAM_BATCH_MS } from '../config/timing.js' @@ -13,6 +13,7 @@ import type { GatewaySkin, SessionMostRecentResponse } from '../gatewayTypes.js' +import { relativeLuminance } from '../lib/color.js' import { isTodoDone } from '../lib/liveProgress.js' import { openExternalUrl } from '../lib/openExternalUrl.js' import { rpcErrorMessage } from '../lib/rpc.js' @@ -149,6 +150,31 @@ let themeBackgroundSyncStarted = false * HERMES_TUI_LIGHT / HERMES_TUI_THEME overrides still win inside * detectLightMode, so users can pin a mode regardless of the probe. */ +/** Infer the terminal's polarity from its reported FOREGROUND (OSC 10). + * Transparent profiles lie about the background (unset default = pure + * black) but report the theme's real foreground — a bright foreground + * means a dark theme and vice versa. Returns a representative background + * for the inferred pole, or undefined when the answer is unusable + * (mid-gray foregrounds are ambiguous; #000000/#ffffff can be unset + * defaults themselves, so only clearly-toned answers count). */ +export function polarityBackgroundFromForeground(hex: string): string | undefined { + const luminance = relativeLuminance(hex) + + if (luminance === null || hex === '#000000' || hex === '#ffffff') { + return undefined + } + + if (luminance >= 0.45) { + return '#1e1e1e' + } + + if (luminance <= 0.2) { + return '#ffffff' + } + + return undefined +} + export function syncThemeToTerminalBackground(): void { if (themeBackgroundSyncStarted) { return @@ -165,8 +191,9 @@ export function syncThemeToTerminalBackground(): void { // answers OSC 11 with its own black fallback regardless of the outer // terminal — and tmux also strips TERM_PROGRAM, so no host allow-list // can catch it. Real dark themes report their actual surface (#1e1e1e, - // #282828, …). Distrusting pure black universally is safe: on a truly - // pure-black terminal the fall-through detection lands on dark anyway. + // #282828, …). Distrusting pure black universally is safe: the OSC-10 + // foreground below resolves the pole for transparent hosts, and a truly + // pure-black terminal lands on dark either way. if (hex === '#000000') { return } @@ -176,6 +203,26 @@ export function syncThemeToTerminalBackground(): void { reapplyTheme() }) + // Foreground tiebreaker for the distrusted-background case. The two OSC + // replies arrive in the same startup batch; this listener only commits when + // the background didn't (first-writer-wins via `resolved`), and an explicit + // user pin still outranks it inside detectLightMode. + onTerminalForeground(hex => { + if (resolved || process.env.HERMES_TUI_THEME || process.env.HERMES_TUI_LIGHT) { + return + } + + const inferred = polarityBackgroundFromForeground(hex) + + if (!inferred) { + return + } + + resolved = true + process.env.HERMES_TUI_BACKGROUND = inferred + reapplyTheme() + }) + // Last-resort inference when the probe never answers (or answered with the // untrusted default): on macOS, editor themes overwhelmingly track the // system appearance, so `AppleInterfaceStyle` is a strong prior. Runs only diff --git a/ui-tui/src/types/hermes-ink.d.ts b/ui-tui/src/types/hermes-ink.d.ts index ea73d338d42..0e92c307650 100644 --- a/ui-tui/src/types/hermes-ink.d.ts +++ b/ui-tui/src/types/hermes-ink.d.ts @@ -109,6 +109,8 @@ declare module '@hermes/ink' { export function isXtermJs(): boolean export function onTerminalBackground(listener: (hex: string) => void): void export function terminalBackgroundHex(): string | undefined + export function onTerminalForeground(listener: (hex: string) => void): void + export function terminalForegroundHex(): string | undefined export function parseOscColor(data: string): string | undefined export type ScrollFastPathStats = {