diff --git a/ui-tui/packages/hermes-ink/src/entry-exports.ts b/ui-tui/packages/hermes-ink/src/entry-exports.ts index 49bbf21179f1e..1488124034d22 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 c94f6349d8fb3..f6bbfe1362276 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 4eb4bc7b96303..491cb545cb2ce 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/__tests__/markdown.test.ts b/ui-tui/src/__tests__/markdown.test.ts index 386a0e138eecc..9c4f0eaed6dc9 100644 --- a/ui-tui/src/__tests__/markdown.test.ts +++ b/ui-tui/src/__tests__/markdown.test.ts @@ -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) + } + }) +}) diff --git a/ui-tui/src/app/useMainApp.ts b/ui-tui/src/app/useMainApp.ts index 5149850c69a0f..cc3cea4a3b798 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/components/markdown.tsx b/ui-tui/src/components/markdown.tsx index 1c24a8d7bffa7..2aa421822b40a 100644 --- a/ui-tui/src/components/markdown.tsx +++ b/ui-tui/src/components/markdown.tsx @@ -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 (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.slice(last)}) } - return {parts.length ? parts : text} + return ( + + {parts.length ? parts : 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() + nodes.push() i++ continue @@ -1000,7 +1011,7 @@ function MdImpl({ cols, compact, t, text }: MdProps) { nodes.push( · - + ) i++ @@ -1021,7 +1032,7 @@ function MdImpl({ cols, compact, t, text }: MdProps) { {marker} - + ) @@ -1038,7 +1049,7 @@ function MdImpl({ cols, compact, t, text }: MdProps) { {numbered[2]}. - + ) @@ -1143,7 +1154,7 @@ function MdImpl({ cols, compact, t, text }: MdProps) { } start('paragraph') - nodes.push() + nodes.push() i++ } diff --git a/ui-tui/src/types/hermes-ink.d.ts b/ui-tui/src/types/hermes-ink.d.ts index 0e92c307650ca..94df7504f5083 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