fix(tui): keep the Apple Terminal dim fallback inside the active palette

Terminal.app ignores SGR 2, so dim is substituted with a literal color.
That color was hardcoded to #6B7280 — a cold slate that belongs to no
theme. Next to themed text on the same line it reads as a second,
foreign foreground: on a light profile, gray words beside near-black
ones.

Make the tone theme-supplied via setDimFallbackColor, fed the active
muted tone from the same effect that already publishes selectionBg.
#6B7280 stays as the pre-theme boot default so the first frame is
unchanged, and terminals that honor SGR 2 are untouched.
This commit is contained in:
Brooklyn Nicholson 2026-07-25 23:00:03 -05:00
parent b8bf368b02
commit 4c03d5bff2
5 changed files with 50 additions and 4 deletions

View file

@ -10,7 +10,7 @@ export { NoSelect } from './ink/components/NoSelect.js'
export { RawAnsi } from './ink/components/RawAnsi.js'
export { default as ScrollBox } from './ink/components/ScrollBox.js'
export { default as Spacer } from './ink/components/Spacer.js'
export { default as Text } from './ink/components/Text.js'
export { setDimFallbackColor, default as Text } from './ink/components/Text.js'
export { default as useApp } from './ink/hooks/use-app.js'
export { useCursorAdvance } from './ink/hooks/use-cursor-advance.js'
export { useDeclaredCursor } from './ink/hooks/use-declared-cursor.js'

View file

@ -1,6 +1,6 @@
import { describe, expect, it } from 'vitest'
import { afterEach, describe, expect, it } from 'vitest'
import { dimColorFallback, shouldUseAnsiDim } from './Text.js'
import { dimColorFallback, setDimFallbackColor, shouldUseAnsiDim } from './Text.js'
describe('shouldUseAnsiDim', () => {
it('disables ANSI dim on VTE terminals by default', () => {
@ -23,6 +23,10 @@ describe('shouldUseAnsiDim', () => {
})
describe('dimColorFallback', () => {
afterEach(() => {
setDimFallbackColor(undefined)
})
it('renders Apple Terminal dim as muted gray by default', () => {
expect(dimColorFallback({ TERM_PROGRAM: 'Apple_Terminal' } as NodeJS.ProcessEnv)).toBe('#6B7280')
})
@ -39,4 +43,23 @@ describe('dimColorFallback', () => {
dimColorFallback({ HERMES_TUI_DIM: '0', TERM_PROGRAM: 'Apple_Terminal' } as NodeJS.ProcessEnv)
).toBeUndefined()
})
it('uses the theme tone once one is supplied, so dim stays in-palette', () => {
setDimFallbackColor('#936e06')
expect(dimColorFallback({ TERM_PROGRAM: 'Apple_Terminal' } as NodeJS.ProcessEnv)).toBe('#936e06')
})
it('falls back to the boot default when the theme tone is cleared', () => {
setDimFallbackColor('#936e06')
setDimFallbackColor(undefined)
expect(dimColorFallback({ TERM_PROGRAM: 'Apple_Terminal' } as NodeJS.ProcessEnv)).toBe('#6B7280')
})
it('stays inert on terminals that honor SGR 2, whatever the theme tone', () => {
setDimFallbackColor('#936e06')
expect(dimColorFallback({ TERM: 'xterm-256color' } as NodeJS.ProcessEnv)).toBeUndefined()
})
})

View file

@ -84,6 +84,19 @@ export function shouldUseAnsiDim(env: NodeJS.ProcessEnv = process.env): boolean
return !env.VTE_VERSION
}
/**
* Terminals that ignore SGR 2 need a literal color instead. The tone is
* theme-supplied (setDimFallbackColor, called from the theme effect) so it
* stays inside the active palette; the slate below is only the pre-theme
* boot default. A hardcoded value here reads as an off-palette foreground
* next to themed spans on the same line cold gray beside warm ink.
*/
let dimFallbackColor: Color = LEGACY_APPLE_DIM_COLOR
export function setDimFallbackColor(color: Color | undefined): void {
dimFallbackColor = color || LEGACY_APPLE_DIM_COLOR
}
export function dimColorFallback(env: NodeJS.ProcessEnv = process.env): Color | undefined {
const override = (env.HERMES_TUI_DIM ?? '').trim()
@ -91,7 +104,7 @@ export function dimColorFallback(env: NodeJS.ProcessEnv = process.env): Color |
return undefined
}
return (env.TERM_PROGRAM ?? '').trim() === 'Apple_Terminal' ? LEGACY_APPLE_DIM_COLOR : undefined
return (env.TERM_PROGRAM ?? '').trim() === 'Apple_Terminal' ? dimFallbackColor : undefined
}
const memoizedStylesForWrap: Record<NonNullable<Styles['textWrap']>, Styles> = {

View file

@ -1,6 +1,7 @@
import {
forceRedraw,
type ScrollBoxHandle,
setDimFallbackColor,
useApp,
useHasSelection,
useSelection,
@ -245,6 +246,14 @@ export function useMainApp(gw: GatewayClient) {
selection.setSelectionBgColor(ui.theme.color.selectionBg)
}, [selection, ui.theme.color.selectionBg])
// Terminals that ignore SGR 2 (Apple_Terminal) get a literal color for
// `dim` instead. Feed it the theme's muted tone so dimmed spans stay in
// the palette — a hardcoded gray renders as a foreign foreground next to
// themed text on the same line.
useEffect(() => {
setDimFallbackColor(ui.theme.color.muted)
}, [ui.theme.color.muted])
// macOS Terminal.app does not forward Cmd+C to fullscreen TUIs that enable
// mouse tracking, so the only reliable native-feeling path is iTerm-style
// copy-on-select: once a drag creates a stable TUI selection, write it to

View file

@ -104,6 +104,7 @@ declare module '@hermes/ink' {
export const NoSelect: React.ComponentType<any>
export const ScrollBox: React.ComponentType<any>
export const Text: React.ComponentType<any>
export function setDimFallbackColor(color: string | undefined): void
export const TextInput: React.ComponentType<any>
export const stringWidth: (s: string) => number
export function isXtermJs(): boolean