diff --git a/ui-tui/packages/hermes-ink/src/entry-exports.ts b/ui-tui/packages/hermes-ink/src/entry-exports.ts index 49bbf21179f1..1488124034d2 100644 --- a/ui-tui/packages/hermes-ink/src/entry-exports.ts +++ b/ui-tui/packages/hermes-ink/src/entry-exports.ts @@ -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' diff --git a/ui-tui/packages/hermes-ink/src/ink/components/Text.test.ts b/ui-tui/packages/hermes-ink/src/ink/components/Text.test.ts index c94f6349d8fb..f6bbfe136227 100644 --- a/ui-tui/packages/hermes-ink/src/ink/components/Text.test.ts +++ b/ui-tui/packages/hermes-ink/src/ink/components/Text.test.ts @@ -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() + }) }) diff --git a/ui-tui/packages/hermes-ink/src/ink/components/Text.tsx b/ui-tui/packages/hermes-ink/src/ink/components/Text.tsx index 4eb4bc7b9630..491cb545cb2c 100644 --- a/ui-tui/packages/hermes-ink/src/ink/components/Text.tsx +++ b/ui-tui/packages/hermes-ink/src/ink/components/Text.tsx @@ -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, Styles> = { diff --git a/ui-tui/src/app/useMainApp.ts b/ui-tui/src/app/useMainApp.ts index 5149850c69a0..cc3cea4a3b79 100644 --- a/ui-tui/src/app/useMainApp.ts +++ b/ui-tui/src/app/useMainApp.ts @@ -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 diff --git a/ui-tui/src/types/hermes-ink.d.ts b/ui-tui/src/types/hermes-ink.d.ts index 0e92c307650c..94df7504f508 100644 --- a/ui-tui/src/types/hermes-ink.d.ts +++ b/ui-tui/src/types/hermes-ink.d.ts @@ -104,6 +104,7 @@ declare module '@hermes/ink' { export const NoSelect: React.ComponentType export const ScrollBox: React.ComponentType export const Text: React.ComponentType + export function setDimFallbackColor(color: string | undefined): void export const TextInput: React.ComponentType export const stringWidth: (s: string) => number export function isXtermJs(): boolean