fix(terminal): honor Bold Text color override (#8595)

Pin xterm + addon-webgl to resolve theme.bold for bold cells that still
use the default foreground (Ghostty bold-color / iTerm Bold Color). DOM
path sets an inline color when min-contrast leaves the cell unstyled so
high-contrast bold overrides still paint. Explicit ANSI/RGB foregrounds
and inverse cells keep prior behavior.

Credit: @rodboev (PR #8952) for the core ThemeService/WebGL approach.
This commit is contained in:
Neil
2026-07-16 17:37:13 -07:00
parent 1cdc8e85b3
commit 9617f3230d
9 changed files with 508 additions and 58 deletions
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+2 -1
View File
@@ -272,7 +272,8 @@
"node-pty@1.1.0": "config/patches/node-pty@1.1.0.patch",
"@xterm/addon-ligatures@0.11.0-beta.287": "config/patches/@xterm__addon-ligatures@0.11.0-beta.287.patch",
"@xterm/addon-webgl@0.20.0-beta.286": "config/patches/@xterm__addon-webgl@0.20.0-beta.286.patch",
"@xterm/addon-serialize@0.15.0-beta.287": "config/patches/@xterm__addon-serialize@0.15.0-beta.287.patch"
"@xterm/addon-serialize@0.15.0-beta.287": "config/patches/@xterm__addon-serialize@0.15.0-beta.287.patch",
"@xterm/xterm@6.1.0-beta.287": "config/patches/@xterm__xterm@6.1.0-beta.287.patch"
}
},
"reactDoctor": {
+27 -24
View File
@@ -15,8 +15,11 @@ patchedDependencies:
hash: 81575700d58b62f9262d302aa4ae43b445a6273d579cd75dd6729c8883011ab9
path: config/patches/@xterm__addon-serialize@0.15.0-beta.287.patch
'@xterm/addon-webgl@0.20.0-beta.286':
hash: cf737f93f193124ffc7f302bbbf81c2290b29b562335f24263209274caaf8e64
hash: 3bcf13573a2c994edbeeac7f32e1b9249f0d6721ba031a61f8f8c446a17f360c
path: config/patches/@xterm__addon-webgl@0.20.0-beta.286.patch
'@xterm/xterm@6.1.0-beta.287':
hash: d57bfbffd34019dfcbc803b418ee7e2d27998439aa763e821b5dc70778bc99dc
path: config/patches/@xterm__xterm@6.1.0-beta.287.patch
node-pty@1.1.0:
hash: 407ae07e1e0e2ff2e8b58696449c54c31e51d87535bc6aa4a7a7b0b561407282
path: config/patches/node-pty@1.1.0.patch
@@ -42,7 +45,7 @@ importers:
version: 2.5.6
'@xterm/addon-serialize':
specifier: 0.15.0-beta.287
version: 0.15.0-beta.287(patch_hash=81575700d58b62f9262d302aa4ae43b445a6273d579cd75dd6729c8883011ab9)(@xterm/xterm@6.1.0-beta.287)
version: 0.15.0-beta.287(patch_hash=81575700d58b62f9262d302aa4ae43b445a6273d579cd75dd6729c8883011ab9)(@xterm/xterm@6.1.0-beta.287(patch_hash=d57bfbffd34019dfcbc803b418ee7e2d27998439aa763e821b5dc70778bc99dc))
'@xterm/headless':
specifier: 6.1.0-beta.287
version: 6.1.0-beta.287
@@ -202,25 +205,25 @@ importers:
version: 5.2.0(vite@7.3.6(@types/node@25.6.0)(jiti@2.7.0)(lightningcss@1.32.0)(yaml@2.8.4))
'@xterm/addon-fit':
specifier: 0.12.0-beta.287
version: 0.12.0-beta.287(@xterm/xterm@6.1.0-beta.287)
version: 0.12.0-beta.287(@xterm/xterm@6.1.0-beta.287(patch_hash=d57bfbffd34019dfcbc803b418ee7e2d27998439aa763e821b5dc70778bc99dc))
'@xterm/addon-ligatures':
specifier: 0.11.0-beta.287
version: 0.11.0-beta.287(patch_hash=47405b9994b5acf1b4e90b49250358c1ca03649854d59560e7732b72fe336920)(@xterm/xterm@6.1.0-beta.287)
version: 0.11.0-beta.287(patch_hash=47405b9994b5acf1b4e90b49250358c1ca03649854d59560e7732b72fe336920)(@xterm/xterm@6.1.0-beta.287(patch_hash=d57bfbffd34019dfcbc803b418ee7e2d27998439aa763e821b5dc70778bc99dc))
'@xterm/addon-search':
specifier: 0.17.0-beta.287
version: 0.17.0-beta.287(@xterm/xterm@6.1.0-beta.287)
version: 0.17.0-beta.287(@xterm/xterm@6.1.0-beta.287(patch_hash=d57bfbffd34019dfcbc803b418ee7e2d27998439aa763e821b5dc70778bc99dc))
'@xterm/addon-unicode11':
specifier: 0.10.0-beta.287
version: 0.10.0-beta.287(@xterm/xterm@6.1.0-beta.287)
version: 0.10.0-beta.287(@xterm/xterm@6.1.0-beta.287(patch_hash=d57bfbffd34019dfcbc803b418ee7e2d27998439aa763e821b5dc70778bc99dc))
'@xterm/addon-web-links':
specifier: 0.13.0-beta.287
version: 0.13.0-beta.287(@xterm/xterm@6.1.0-beta.287)
version: 0.13.0-beta.287(@xterm/xterm@6.1.0-beta.287(patch_hash=d57bfbffd34019dfcbc803b418ee7e2d27998439aa763e821b5dc70778bc99dc))
'@xterm/addon-webgl':
specifier: 0.20.0-beta.286
version: 0.20.0-beta.286(patch_hash=cf737f93f193124ffc7f302bbbf81c2290b29b562335f24263209274caaf8e64)(@xterm/xterm@6.1.0-beta.287)
version: 0.20.0-beta.286(patch_hash=3bcf13573a2c994edbeeac7f32e1b9249f0d6721ba031a61f8f8c446a17f360c)(@xterm/xterm@6.1.0-beta.287(patch_hash=d57bfbffd34019dfcbc803b418ee7e2d27998439aa763e821b5dc70778bc99dc))
'@xterm/xterm':
specifier: 6.1.0-beta.287
version: 6.1.0-beta.287
version: 6.1.0-beta.287(patch_hash=d57bfbffd34019dfcbc803b418ee7e2d27998439aa763e821b5dc70778bc99dc)
class-variance-authority:
specifier: ^0.7.1
version: 0.7.1
@@ -9577,39 +9580,39 @@ snapshots:
'@xmldom/xmldom@0.9.10': {}
'@xterm/addon-fit@0.12.0-beta.287(@xterm/xterm@6.1.0-beta.287)':
'@xterm/addon-fit@0.12.0-beta.287(@xterm/xterm@6.1.0-beta.287(patch_hash=d57bfbffd34019dfcbc803b418ee7e2d27998439aa763e821b5dc70778bc99dc))':
dependencies:
'@xterm/xterm': 6.1.0-beta.287
'@xterm/xterm': 6.1.0-beta.287(patch_hash=d57bfbffd34019dfcbc803b418ee7e2d27998439aa763e821b5dc70778bc99dc)
'@xterm/addon-ligatures@0.11.0-beta.287(patch_hash=47405b9994b5acf1b4e90b49250358c1ca03649854d59560e7732b72fe336920)(@xterm/xterm@6.1.0-beta.287)':
'@xterm/addon-ligatures@0.11.0-beta.287(patch_hash=47405b9994b5acf1b4e90b49250358c1ca03649854d59560e7732b72fe336920)(@xterm/xterm@6.1.0-beta.287(patch_hash=d57bfbffd34019dfcbc803b418ee7e2d27998439aa763e821b5dc70778bc99dc))':
dependencies:
'@xterm/xterm': 6.1.0-beta.287
'@xterm/xterm': 6.1.0-beta.287(patch_hash=d57bfbffd34019dfcbc803b418ee7e2d27998439aa763e821b5dc70778bc99dc)
lru-cache: 11.5.1
opentype.js: 2.0.0
'@xterm/addon-search@0.17.0-beta.287(@xterm/xterm@6.1.0-beta.287)':
'@xterm/addon-search@0.17.0-beta.287(@xterm/xterm@6.1.0-beta.287(patch_hash=d57bfbffd34019dfcbc803b418ee7e2d27998439aa763e821b5dc70778bc99dc))':
dependencies:
'@xterm/xterm': 6.1.0-beta.287
'@xterm/xterm': 6.1.0-beta.287(patch_hash=d57bfbffd34019dfcbc803b418ee7e2d27998439aa763e821b5dc70778bc99dc)
'@xterm/addon-serialize@0.15.0-beta.287(patch_hash=81575700d58b62f9262d302aa4ae43b445a6273d579cd75dd6729c8883011ab9)(@xterm/xterm@6.1.0-beta.287)':
'@xterm/addon-serialize@0.15.0-beta.287(patch_hash=81575700d58b62f9262d302aa4ae43b445a6273d579cd75dd6729c8883011ab9)(@xterm/xterm@6.1.0-beta.287(patch_hash=d57bfbffd34019dfcbc803b418ee7e2d27998439aa763e821b5dc70778bc99dc))':
dependencies:
'@xterm/xterm': 6.1.0-beta.287
'@xterm/xterm': 6.1.0-beta.287(patch_hash=d57bfbffd34019dfcbc803b418ee7e2d27998439aa763e821b5dc70778bc99dc)
'@xterm/addon-unicode11@0.10.0-beta.287(@xterm/xterm@6.1.0-beta.287)':
'@xterm/addon-unicode11@0.10.0-beta.287(@xterm/xterm@6.1.0-beta.287(patch_hash=d57bfbffd34019dfcbc803b418ee7e2d27998439aa763e821b5dc70778bc99dc))':
dependencies:
'@xterm/xterm': 6.1.0-beta.287
'@xterm/xterm': 6.1.0-beta.287(patch_hash=d57bfbffd34019dfcbc803b418ee7e2d27998439aa763e821b5dc70778bc99dc)
'@xterm/addon-web-links@0.13.0-beta.287(@xterm/xterm@6.1.0-beta.287)':
'@xterm/addon-web-links@0.13.0-beta.287(@xterm/xterm@6.1.0-beta.287(patch_hash=d57bfbffd34019dfcbc803b418ee7e2d27998439aa763e821b5dc70778bc99dc))':
dependencies:
'@xterm/xterm': 6.1.0-beta.287
'@xterm/xterm': 6.1.0-beta.287(patch_hash=d57bfbffd34019dfcbc803b418ee7e2d27998439aa763e821b5dc70778bc99dc)
'@xterm/addon-webgl@0.20.0-beta.286(patch_hash=cf737f93f193124ffc7f302bbbf81c2290b29b562335f24263209274caaf8e64)(@xterm/xterm@6.1.0-beta.287)':
'@xterm/addon-webgl@0.20.0-beta.286(patch_hash=3bcf13573a2c994edbeeac7f32e1b9249f0d6721ba031a61f8f8c446a17f360c)(@xterm/xterm@6.1.0-beta.287(patch_hash=d57bfbffd34019dfcbc803b418ee7e2d27998439aa763e821b5dc70778bc99dc))':
dependencies:
'@xterm/xterm': 6.1.0-beta.287
'@xterm/xterm': 6.1.0-beta.287(patch_hash=d57bfbffd34019dfcbc803b418ee7e2d27998439aa763e821b5dc70778bc99dc)
'@xterm/headless@6.1.0-beta.287': {}
'@xterm/xterm@6.1.0-beta.287': {}
'@xterm/xterm@6.1.0-beta.287(patch_hash=d57bfbffd34019dfcbc803b418ee7e2d27998439aa763e821b5dc70778bc99dc)': {}
abbrev@4.0.0: {}
@@ -48,6 +48,18 @@ describe('composeActiveTerminalTheme', () => {
expect(result!.background).toBe('#101010')
})
it('preserves the Bold Text override for the pinned xterm theme.bold slot', () => {
// Why: #8595 — overrides.bold used to be spread into options.theme and
// then ignored by xterm; composition must keep shipping the key.
const base = { background: '#101010', foreground: '#fafafa' }
const result = composeActiveTerminalTheme(
base,
settingsWith({ terminalColorOverrides: { bold: '#ffc600' } })
)
expect(result!.bold).toBe('#ffc600')
expect(result!.foreground).toBe('#fafafa')
})
it('applies background opacity by converting the hex background to rgba', () => {
const base = { background: '#112233' }
const result = composeActiveTerminalTheme(
@@ -0,0 +1,116 @@
import { readFileSync } from 'node:fs'
import { createRequire } from 'node:module'
import { dirname, join } from 'node:path'
import { describe, expect, it } from 'vitest'
import type { ITheme } from '@xterm/xterm'
import { Terminal } from '@xterm/xterm'
import { composeActiveTerminalTheme } from './terminal-appearance'
import type { GlobalSettings } from '../../../../shared/types'
/**
* Regression for #8595: Settings "Bold Text" / Ghostty bold-color was stored
* on terminalColorOverrides.bold and spread into options.theme, but upstream
* xterm ITheme had no `bold` key — renderers always used theme.foreground for
* bold default-FG cells.
*
* The pinned xterm + addon-webgl patches accept theme.bold and use it as the
* default foreground for bold, non-inverse cells. Explicit ANSI/RGB
* foregrounds stay on their own palette path.
*/
describe('issue #8595 bold theme color', () => {
const require = createRequire(import.meta.url)
function settingsWith(partial: Partial<GlobalSettings>): GlobalSettings {
return {
terminalColorOverrides: undefined,
terminalCursorOpacity: undefined,
terminalBackgroundOpacity: undefined,
...partial
} as GlobalSettings
}
function packageRoot(specifier: string): string {
return dirname(require.resolve(`${specifier}/package.json`))
}
it('ITheme accepts bold and Terminal options preserve the override', () => {
const theme: ITheme = {
foreground: '#fafafa',
bold: '#ffc600',
background: '#101010'
}
// Why: if the pin regresses and drops ITheme.bold, this assignment fails
// typecheck / the runtime options bag no longer carries the key.
expect(theme.bold).toBe('#ffc600')
const term = new Terminal({ allowProposedApi: true, theme })
try {
expect(term.options.theme?.bold).toBe('#ffc600')
term.options.theme = {
foreground: '#eeeeee',
background: '#111111',
bold: '#00ffaa'
}
expect(term.options.theme?.bold).toBe('#00ffaa')
term.options.theme = {
foreground: '#eeeeee',
background: '#111111'
}
expect(term.options.theme?.bold).toBeUndefined()
} finally {
term.dispose()
}
})
it('composeActiveTerminalTheme keeps terminalColorOverrides.bold on the live theme', () => {
const composed = composeActiveTerminalTheme(
{ foreground: '#fafafa', background: '#101010' },
settingsWith({ terminalColorOverrides: { bold: '#ff00aa' } })
)
expect(composed?.bold).toBe('#ff00aa')
expect(composed?.foreground).toBe('#fafafa')
const term = new Terminal({ allowProposedApi: true, theme: composed ?? undefined })
try {
expect(term.options.theme?.bold).toBe('#ff00aa')
} finally {
term.dispose()
}
})
it('pinned xterm package resolves theme.bold into the ThemeService color set', () => {
const root = packageRoot('@xterm/xterm')
const typings = readFileSync(join(root, 'typings/xterm.d.ts'), 'utf8')
const themeService = readFileSync(join(root, 'src/browser/services/ThemeService.ts'), 'utf8')
const domRowFactory = readFileSync(
join(root, 'src/browser/renderer/dom/DomRendererRowFactory.ts'),
'utf8'
)
const lib = readFileSync(join(root, 'lib/xterm.js'), 'utf8')
expect(typings).toMatch(/export interface ITheme \{[\s\S]*?bold\?: string/)
// Why: fallback to foreground when bold is omitted matches Ghostty/iTerm
// "no bold color configured" behavior.
expect(themeService).toContain('colors.bold = parseColor(theme.bold, colors.foreground)')
// Why: default FG inherits the row container color; bold default FG must
// still paint when min-contrast does not emit an inline color.
expect(domRowFactory).toContain('cell.isBold() && !isInverse ? colors.bold : colors.foreground')
expect(domRowFactory).toContain('color:${colors.bold.css}')
expect(lib).toContain('bold.css')
})
it('pinned webgl atlas uses colors.bold only for bold default-FG (not explicit ANSI)', () => {
const root = packageRoot('@xterm/addon-webgl')
const atlas = readFileSync(join(root, 'src/TextureAtlas.ts'), 'utf8')
const charAtlas = readFileSync(join(root, 'src/CharAtlasUtils.ts'), 'utf8')
expect(charAtlas).toContain('bold: colors.bold')
expect(charAtlas).toContain('a.colors.bold.rgba === b.colors.bold.rgba')
// Default-FG branch selects bold when the bold attribute is set.
expect(atlas).toMatch(/else if \(bold\) \{\s*result = this\._config\.colors\.bold/)
expect(atlas).toMatch(/if \(bold\) \{\s*return this\._config\.colors\.bold\.rgba/)
// Explicit palette path still uses ANSI indices (drawBoldTextInBrightColors),
// not the theme bold color.
expect(atlas).toContain('if (this._config.drawBoldTextInBrightColors && bold && fgColor < 8)')
})
})
+2 -3
View File
@@ -2437,9 +2437,8 @@ export type TerminalColorOverrides = {
brightMagenta?: string
brightCyan?: string
brightWhite?: string
// Why: xterm.js ITheme does not expose a `bold` key, but Ghostty users
// expect the setting to be preserved so a future renderer CSS override
// or xterm upgrade can honour it without a migration.
// Why: pinned xterm ThemeService accepts `bold` as the default FG for
// bold cells (Ghostty bold-color / iTerm "Bold Color" semantics).
bold?: string
}
@@ -0,0 +1 @@
printf '\033[1mBOLD DEFAULT FG\033[0m\n'
@@ -0,0 +1,179 @@
import { readFileSync } from 'node:fs'
import path from 'node:path'
import { PNG } from 'pngjs'
import type { Page } from '@stablyai/playwright-test'
import { test, expect } from './helpers/orca-app'
import { ensureTerminalVisible, waitForActiveWorktree, waitForSessionReady } from './helpers/store'
import { nodeTerminalCommand } from './terminal-node-command'
import {
sendToTerminal,
waitForActivePanePtyId,
waitForActiveTerminalManager,
waitForTerminalOutput
} from './helpers/terminal'
function countPixels(
buffer: Buffer,
matcher: (red: number, green: number, blue: number, alpha: number) => boolean
): number {
const image = PNG.sync.read(buffer)
let count = 0
for (let y = 0; y < image.height; y += 1) {
for (let x = 0; x < image.width; x += 1) {
const offset = (y * image.width + x) * 4
if (
matcher(
image.data[offset] ?? 0,
image.data[offset + 1] ?? 0,
image.data[offset + 2] ?? 0,
image.data[offset + 3] ?? 0
)
) {
count += 1
}
}
}
return count
}
function isNormalForeground(red: number, green: number, blue: number, alpha: number): boolean {
return (
alpha > 180 &&
red > 180 &&
green > 180 &&
blue > 180 &&
Math.abs(red - green) <= 28 &&
Math.abs(red - blue) <= 28 &&
Math.abs(green - blue) <= 28
)
}
function isBoldOverrideMagenta(red: number, green: number, blue: number, alpha: number): boolean {
return alpha > 180 && red > 180 && green < 20 && blue > 120
}
function isBoldOverrideCyan(red: number, green: number, blue: number, alpha: number): boolean {
return alpha > 180 && red < 20 && green > 120 && blue > 70 && blue < 110
}
function isExplicitAnsiRed(red: number, green: number, blue: number, alpha: number): boolean {
return alpha > 180 && red > 180 && green < 80 && blue < 80
}
async function configureRenderer(page: Page, mode: 'dom' | 'webgl'): Promise<void> {
await page.evaluate((renderer) => {
const state = window.__store?.getState()
const manager = state?.activeTabId ? window.__paneManagers?.get(state.activeTabId) : null
manager?.setTerminalGpuAcceleration?.(renderer === 'webgl' ? 'on' : 'off')
}, mode)
await expect
.poll(
() =>
page.evaluate((renderer) => {
const state = window.__store?.getState()
const manager = state?.activeTabId ? window.__paneManagers?.get(state.activeTabId) : null
return (
manager
?.getRenderingDiagnostics?.()
.some((entry) => entry.hasWebgl === (renderer === 'webgl')) ?? false
)
}, mode),
{ timeout: 10_000 }
)
.toBe(true)
}
async function setBoldOverride(page: Page, bold: string | undefined): Promise<void> {
await page.evaluate(async (boldColor) => {
const store = window.__store
const state = store?.getState()
if (!store || !state) {
throw new Error('Store unavailable')
}
const terminalColorOverrides = { ...state.settings.terminalColorOverrides }
if (boldColor) {
terminalColorOverrides.bold = boldColor
} else {
delete terminalColorOverrides.bold
}
await state.updateSettings({
terminalColorOverrides:
Object.keys(terminalColorOverrides).length > 0 ? terminalColorOverrides : undefined
})
}, bold)
await expect
.poll(
() =>
page.evaluate(() => {
const state = window.__store?.getState()
const manager = state?.activeTabId ? window.__paneManagers?.get(state.activeTabId) : null
const pane = manager?.getActivePane?.() ?? manager?.getPanes?.()[0]
return {
setting: state?.settings.terminalColorOverrides?.bold,
theme: pane?.terminal.options.theme.bold
}
}, bold),
{ timeout: 10_000 }
)
.toEqual({ setting: bold, theme: bold })
}
test.describe('Bold Text default foreground', () => {
test('honors the override in DOM and WebGL while preserving explicit colors', async ({
orcaPage
}, testInfo) => {
await waitForSessionReady(orcaPage)
await waitForActiveWorktree(orcaPage)
await ensureTerminalVisible(orcaPage)
await waitForActiveTerminalManager(orcaPage, 30_000)
const reproPath = process.env.ORCA_BOLD_REPRO_PATH
const fixturePath = path.resolve(
process.cwd(),
'tests/e2e/fixtures/terminal-bold-default-foreground.txt'
)
const repro = readFileSync(reproPath ?? fixturePath)
const fixture = readFileSync(fixturePath)
expect(repro.equals(fixture), 'reproduction artifact must match the committed fixture').toBe(
true
)
const ptyId = await waitForActivePanePtyId(orcaPage)
const renderScript = `process.stdout.write(${JSON.stringify(
'\u001b[2J\u001b[H\u001b[?25l\u001b[1mBOLD DEFAULT FG\u001b[0m\n\u001b[1;31mEXPLICIT BOLD FG\u001b[0m\nNON-BOLD DEFAULT FG\n'
)})`
await sendToTerminal(orcaPage, ptyId, `${nodeTerminalCommand(['-e', renderScript])}\r`)
await waitForTerminalOutput(orcaPage, 'NON-BOLD DEFAULT FG')
for (const mode of ['dom', 'webgl'] as const) {
await configureRenderer(orcaPage, mode)
for (const [state, color, matcher] of [
['add', '#ff00aa', isBoldOverrideMagenta],
['change', '#00ffaa', isBoldOverrideCyan],
['remove', undefined, isNormalForeground]
] as const) {
await setBoldOverride(orcaPage, color)
await orcaPage.waitForTimeout(250)
const screenshotPath = process.env.ORCA_BOLD_SCREENSHOT_PATH
? `${process.env.ORCA_BOLD_SCREENSHOT_PATH}-${mode}-${state}.png`
: testInfo.outputPath(`terminal-bold-default-foreground-${mode}-${state}.png`)
const screenshot = await orcaPage.locator('.xterm-screen').first().screenshot({
path: screenshotPath
})
expect(
countPixels(screenshot, matcher),
`${mode} ${state} screenshot should contain the expected bold-default color`
).toBeGreaterThan(0)
expect(
countPixels(screenshot, isNormalForeground),
`${mode} ${state} screenshot should still contain normal foreground text`
).toBeGreaterThan(0)
expect(
countPixels(screenshot, isExplicitAnsiRed),
`${mode} ${state} screenshot should keep the explicit ANSI red row`
).toBeGreaterThan(0)
}
}
})
})