Merge pull request #71741 from NousResearch/bb/tui-dim-fallback

fix(tui): keep assistant body text inside the theme palette
This commit is contained in:
brooklyn! 2026-07-25 23:12:18 -05:00 committed by GitHub
commit 2ea1ea0894
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 156 additions and 12 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,13 +1,14 @@
import { PassThrough } from 'stream'
import { Box, renderSync } from '@hermes/ink'
import chalk from 'chalk'
import React from 'react'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { AUDIO_DIRECTIVE_RE, INLINE_RE, Md, MEDIA_LINE_RE, stripInlineMarkup } from '../components/markdown.js'
import { __resetLinkTitleCache, fetchLinkTitle } from '../lib/externalLink.js'
import { stripAnsi } from '../lib/text.js'
import { DEFAULT_THEME } from '../theme.js'
import { DEFAULT_THEME, LIGHT_THEME } from '../theme.js'
afterEach(() => {
__resetLinkTitleCache()
@ -369,3 +370,89 @@ describe('renderTable CJK width alignment', () => {
expect(qwenCol2).toBe(headerCol2)
})
})
describe('body prose stays in the theme palette', () => {
// Prose used to render in the terminal's DEFAULT foreground while inline
// tokens beside it carried a theme color, so one line mixed two inks.
// Because an inline token can match mid-word, so could a single word.
// LIGHT_THEME is the vehicle here because every tone in it is hex, so
// emitted SGR maps back to palette entries without format juggling.
const foregroundRuns = (text: string): string[] => {
// chalk is a singleton and defaults to level 0 under vitest (no TTY),
// which would emit no SGR at all and make every assertion here vacuous.
const savedLevel = chalk.level
chalk.level = 3
const stdout = new PassThrough()
const stdin = new PassThrough()
const stderr = new PassThrough()
let output = ''
Object.assign(stdout, { columns: 80, isTTY: true, rows: 24 })
Object.assign(stdin, { isTTY: false })
Object.assign(stderr, { isTTY: false })
stdout.on('data', chunk => {
output += chunk.toString()
})
const instance = renderSync(
React.createElement(Box, { width: 70 }, React.createElement(Md, { cols: 68, t: LIGHT_THEME, text })),
{
patchConsole: false,
stderr: stderr as NodeJS.WriteStream,
stdin: stdin as NodeJS.ReadStream,
stdout: stdout as NodeJS.WriteStream
}
)
instance.unmount()
instance.cleanup()
chalk.level = savedLevel
return [...output.matchAll(new RegExp(`${ESC}\\[38;2;(\\d+);(\\d+);(\\d+)m`, 'g'))].map(
m =>
'#' +
m
.slice(1, 4)
.map(v => Number(v).toString(16).padStart(2, '0'))
.join('')
)
}
const PALETTE = new Set(
Object.values(LIGHT_THEME.color)
.filter((v): v is string => typeof v === 'string' && v.startsWith('#'))
.map(v => v.toLowerCase())
)
const INK = LIGHT_THEME.color.text.toLowerCase()
it('opens a paragraph with the theme ink, not the terminal default', () => {
expect(foregroundRuns('plain prose line')[0]).toBe(INK)
})
it('keeps every foreground on a mixed-token line inside the palette', () => {
// `render_terminal_output` trips the underscore-italic token mid-word —
// the exact shape that split one word across two inks.
const fg = foregroundRuns('set the `flag` and re-render_terminal_output for the run')
expect(fg.length).toBeGreaterThan(0)
for (const c of fg) {
expect(PALETTE.has(c)).toBe(true)
}
})
it('returns to the theme ink after an inline token, not to the terminal default', () => {
const fg = foregroundRuns('before `code` after')
expect(fg[0]).toBe(INK)
expect(fg.at(-1)).toBe(INK)
})
it('themes list-item prose too', () => {
for (const text of ['- a bullet item', '1. a numbered item']) {
expect(foregroundRuns(text)).toContain(INK)
}
})
})

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

@ -541,7 +541,14 @@ const renderTable = (k: number, rows: string[][], t: Theme, cols?: number) => {
)
}
function MdInline({ t, text }: { t: Theme; text: string }) {
// `color` anchors the prose runs to a palette tone. Block callers that
// already wrap MdInline in a colored <Text> (headings, quotes, footnotes)
// leave it unset and inherit that parent; body-prose callers pass
// `t.color.text` so plain words are themed instead of falling through to
// the terminal's default foreground. Without it a single line mixes
// themed spans (code, links, math) with unthemed prose — and because an
// inline token can match mid-word, so can a single word.
function MdInline({ color, t, text }: { color?: string; t: Theme; text: string }) {
const parts: ReactNode[] = []
let last = 0
@ -652,7 +659,11 @@ function MdInline({ t, text }: { t: Theme; text: string }) {
parts.push(<Text key={parts.length}>{text.slice(last)}</Text>)
}
return <Text wrap="wrap-trim">{parts.length ? parts : text}</Text>
return (
<Text {...(color ? { color } : {})} wrap="wrap-trim">
{parts.length ? parts : text}
</Text>
)
}
// Cross-instance parsed-children cache: useMemo's per-instance cache dies
@ -883,7 +894,7 @@ function MdImpl({ cols, compact, t, text }: MdProps) {
if (closeIdx < 0) {
start('paragraph')
nodes.push(<MdInline key={key} t={t} text={line} />)
nodes.push(<MdInline color={t.color.text} key={key} t={t} text={line} />)
i++
continue
@ -1000,7 +1011,7 @@ function MdImpl({ cols, compact, t, text }: MdProps) {
nodes.push(
<Text key={`${key}-def-${i}`} wrap="wrap-trim">
<Text color={t.color.muted}> · </Text>
<MdInline t={t} text={def} />
<MdInline color={t.color.text} t={t} text={def} />
</Text>
)
i++
@ -1021,7 +1032,7 @@ function MdImpl({ cols, compact, t, text }: MdProps) {
<Box key={key} paddingLeft={indentDepth(bullet[1]!) * 2}>
<Text wrap="wrap-trim">
<Text color={t.color.muted}>{marker} </Text>
<MdInline t={t} text={task ? task[2]! : bullet[2]!} />
<MdInline color={t.color.text} t={t} text={task ? task[2]! : bullet[2]!} />
</Text>
</Box>
)
@ -1038,7 +1049,7 @@ function MdImpl({ cols, compact, t, text }: MdProps) {
<Box key={key} paddingLeft={indentDepth(numbered[1]!) * 2}>
<Text wrap="wrap-trim">
<Text color={t.color.muted}>{numbered[2]}. </Text>
<MdInline t={t} text={numbered[3]!} />
<MdInline color={t.color.text} t={t} text={numbered[3]!} />
</Text>
</Box>
)
@ -1143,7 +1154,7 @@ function MdImpl({ cols, compact, t, text }: MdProps) {
}
start('paragraph')
nodes.push(<MdInline key={key} t={t} text={line} />)
nodes.push(<MdInline color={t.color.text} key={key} t={t} text={line} />)
i++
}

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