mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-31 19:16:29 +00:00
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:
parent
3c135abea5
commit
4426d57a84
7 changed files with 195 additions and 59 deletions
|
|
@ -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'
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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`.
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
2
ui-tui/src/types/hermes-ink.d.ts
vendored
2
ui-tui/src/types/hermes-ink.d.ts
vendored
|
|
@ -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 = {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue