hermes-agent/ui-tui/src/components/messageLine.tsx
Brooklyn Nicholson 48bdd2445e fix(tui): apply ui-tui fix pass and restore type-check
- run the requested ui-tui lint+format pass and include resulting formatting updates
- guard text-measure cache eviction key in hermes-ink so ui-tui type-check stays green
2026-04-25 14:08:54 -05:00

153 lines
4.8 KiB
TypeScript

import { Ansi, Box, NoSelect, Text } from '@hermes/ink'
import { memo } from 'react'
import { LONG_MSG } from '../config/limits.js'
import { sectionMode } from '../domain/details.js'
import { userDisplay } from '../domain/messages.js'
import { ROLE } from '../domain/roles.js'
import { compactPreview, hasAnsi, isPasteBackedText, stripAnsi } from '../lib/text.js'
import type { Theme } from '../theme.js'
import type { DetailsMode, Msg, SectionVisibility } from '../types.js'
import { Md } from './markdown.js'
import { ToolTrail } from './thinking.js'
export const MessageLine = memo(function MessageLine({
cols,
compact,
detailsMode = 'collapsed',
isStreaming = false,
msg,
sections,
t
}: MessageLineProps) {
// Per-section overrides win over the global mode, so resolve each section
// we might consume here once and gate visibility on the *content-bearing*
// sections only — never on the global mode. A `trail` message feeds Tool
// calls + Activity; an assistant message with thinking/tools metadata
// feeds Thinking + Tool calls. Gating on every section would let
// `thinking` (expanded by default) keep an empty wrapper alive when only
// `tools` is hidden — exactly the empty-Box bug Copilot caught.
const thinkingMode = sectionMode('thinking', detailsMode, sections)
const toolsMode = sectionMode('tools', detailsMode, sections)
const activityMode = sectionMode('activity', detailsMode, sections)
const thinking = msg.thinking?.trim() ?? ''
if (msg.kind === 'trail' && (msg.tools?.length || thinking)) {
return thinkingMode !== 'hidden' || toolsMode !== 'hidden' || activityMode !== 'hidden' ? (
<Box flexDirection="column" marginTop={1}>
<ToolTrail
detailsMode={detailsMode}
reasoning={thinking}
reasoningTokens={msg.thinkingTokens}
sections={sections}
t={t}
toolTokens={msg.toolTokens}
trail={msg.tools ?? []}
/>
</Box>
) : null
}
if (msg.role === 'tool') {
const maxChars = Math.max(24, cols - 14)
const stripped = hasAnsi(msg.text) ? stripAnsi(msg.text) : msg.text
const preview = compactPreview(stripped, maxChars) || '(empty tool result)'
return (
<Box alignSelf="flex-start" borderColor={t.color.dim} borderStyle="round" marginLeft={3} paddingX={1}>
{hasAnsi(msg.text) ? (
<Text wrap="truncate-end">
<Ansi>{msg.text}</Ansi>
</Text>
) : (
<Text color={t.color.dim} wrap="truncate-end">
{preview}
</Text>
)}
</Box>
)
}
const { body, glyph, prefix } = ROLE[msg.role](t)
const showDetails =
(toolsMode !== 'hidden' && Boolean(msg.tools?.length)) || (thinkingMode !== 'hidden' && Boolean(thinking))
const content = (() => {
if (msg.kind === 'slash') {
return <Text color={t.color.dim}>{msg.text}</Text>
}
if (msg.role !== 'user' && hasAnsi(msg.text)) {
return <Ansi>{msg.text}</Ansi>
}
if (msg.role === 'assistant') {
return isStreaming ? <Text color={body}>{msg.text}</Text> : <Md compact={compact} t={t} text={msg.text} />
}
if (msg.role === 'user' && msg.text.length > LONG_MSG && isPasteBackedText(msg.text)) {
const [head, ...rest] = userDisplay(msg.text).split('[long message]')
return (
<Text color={body}>
{head}
<Text color={t.color.dim} dimColor>
[long message]
</Text>
{rest.join('')}
</Text>
)
}
return <Text {...(body ? { color: body } : {})}>{msg.text}</Text>
})()
// Diff segments (emitted by pushInlineDiffSegment between narration
// segments) need a blank line on both sides so the patch doesn't butt up
// against the prose around it.
const isDiffSegment = msg.kind === 'diff'
return (
<Box
flexDirection="column"
marginBottom={msg.role === 'user' || isDiffSegment ? 1 : 0}
marginTop={msg.role === 'user' || msg.kind === 'slash' || isDiffSegment ? 1 : 0}
>
{showDetails && (
<Box flexDirection="column" marginBottom={1}>
<ToolTrail
detailsMode={detailsMode}
reasoning={thinking}
reasoningTokens={msg.thinkingTokens}
sections={sections}
t={t}
toolTokens={msg.toolTokens}
trail={msg.tools}
/>
</Box>
)}
<Box>
<NoSelect flexShrink={0} fromLeftEdge width={3}>
<Text bold={msg.role === 'user'} color={prefix}>
{glyph}{' '}
</Text>
</NoSelect>
<Box width={Math.max(20, cols - 5)}>{content}</Box>
</Box>
</Box>
)
})
interface MessageLineProps {
cols: number
compact?: boolean
detailsMode?: DetailsMode
isStreaming?: boolean
msg: Msg
sections?: SectionVisibility
t: Theme
}