From b8bf368b02c67f3b4e28299f908860dab809bfb6 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Sat, 25 Jul 2026 22:59:58 -0500 Subject: [PATCH 1/2] fix(tui): anchor markdown body prose to the theme foreground MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Plain prose rendered with no color at all, so it fell through to the terminal's default foreground while inline tokens on the same line (code, links, math, muted markers) carried a theme tone. One rendered line therefore mixed two foregrounds — and because an inline token can match mid-word (`re-render_terminal_output` trips underscore-italic), so could a single word. MdInline now takes an optional color and the body-prose callers (paragraphs, bullets, numbered items, definitions) pass t.color.text. Callers that already wrap it in a colored parent — headings, quotes, footnotes — keep inheriting and are untouched. --- ui-tui/src/__tests__/markdown.test.ts | 89 ++++++++++++++++++++++++++- ui-tui/src/components/markdown.tsx | 25 +++++--- 2 files changed, 106 insertions(+), 8 deletions(-) diff --git a/ui-tui/src/__tests__/markdown.test.ts b/ui-tui/src/__tests__/markdown.test.ts index 0c2b2c5d28e15..214fd5e7ca038 100644 --- a/ui-tui/src/__tests__/markdown.test.ts +++ b/ui-tui/src/__tests__/markdown.test.ts @@ -1,12 +1,13 @@ import { PassThrough } from 'stream' import { Box, renderSync } from '@hermes/ink' +import chalk from 'chalk' import React from 'react' import { describe, expect, it } from 'vitest' import { AUDIO_DIRECTIVE_RE, INLINE_RE, Md, MEDIA_LINE_RE, stripInlineMarkup } from '../components/markdown.js' import { stripAnsi } from '../lib/text.js' -import { DEFAULT_THEME } from '../theme.js' +import { DEFAULT_THEME, LIGHT_THEME } from '../theme.js' const matches = (text: string) => [...text.matchAll(INLINE_RE)].map(m => m[0]) const BEL = String.fromCharCode(7) @@ -329,3 +330,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/components/markdown.tsx b/ui-tui/src/components/markdown.tsx index fb7fafd73c582..65067179b4bfb 100644 --- a/ui-tui/src/components/markdown.tsx +++ b/ui-tui/src/components/markdown.tsx @@ -539,7 +539,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 @@ -650,7 +657,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 @@ -881,7 +892,7 @@ function MdImpl({ cols, compact, t, text }: MdProps) { if (closeIdx < 0) { start('paragraph') - nodes.push() + nodes.push() i++ continue @@ -998,7 +1009,7 @@ function MdImpl({ cols, compact, t, text }: MdProps) { nodes.push( · - + ) i++ @@ -1019,7 +1030,7 @@ function MdImpl({ cols, compact, t, text }: MdProps) { {marker} - + ) @@ -1036,7 +1047,7 @@ function MdImpl({ cols, compact, t, text }: MdProps) { {numbered[2]}. - + ) @@ -1141,7 +1152,7 @@ function MdImpl({ cols, compact, t, text }: MdProps) { } start('paragraph') - nodes.push() + nodes.push() i++ } From 4c03d5bff2a9a2cafce6cdc3e73ecd80262a257c Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Sat, 25 Jul 2026 23:00:03 -0500 Subject: [PATCH 2/2] fix(tui): keep the Apple Terminal dim fallback inside the active palette MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../packages/hermes-ink/src/entry-exports.ts | 2 +- .../src/ink/components/Text.test.ts | 27 +++++++++++++++++-- .../hermes-ink/src/ink/components/Text.tsx | 15 ++++++++++- ui-tui/src/app/useMainApp.ts | 9 +++++++ ui-tui/src/types/hermes-ink.d.ts | 1 + 5 files changed, 50 insertions(+), 4 deletions(-) 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/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/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