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:
commit
2ea1ea0894
|
|
@ -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'
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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> = {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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++
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in New Issue