feat(ui-tui): OSC-10 foreground polarity tiebreaker for transparent terminals

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.
This commit is contained in:
Brooklyn Nicholson 2026-07-20 17:37:14 -05:00
parent 3c135abea5
commit 4426d57a84
7 changed files with 195 additions and 59 deletions

View file

@ -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'

View file

@ -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<Props, State> {
// 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. <AlternateScreen>

View file

@ -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()
})
})

View file

@ -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`.

View file

@ -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()

View file

@ -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

View file

@ -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 = {