hermes-agent / ui-tui /src /components /messageLine.tsx
SaylorTwift's picture
SaylorTwift HF Staff
Add files using upload-large-folder tool
5655a42 verified
Raw History Blame Contribute Delete
12.8 kB
import { Ansi, Box, NoSelect, Text } from '@hermes/ink'
import { hasAnsi, sanitizeAnsiForRender, stripAnsi } from '@hermes/shared/ansi'
import { memo, useState } from 'react'
import { TERMUX_TUI_MODE } from '../config/env.js'
import { LONG_MSG } from '../config/limits.js'
import { hasLeadGap } from '../domain/blockLayout.js'
import { splitComposerHighlights } from '../domain/composerHighlights.js'
import { sectionMode } from '../domain/details.js'
import { userDisplay } from '../domain/messages.js'
import { ROLE } from '../domain/roles.js'
import { transcriptBodyWidth, transcriptGutterWidth } from '../lib/inputMetrics.js'
import { boundedLiveRenderText, compactPreview, isPasteBackedText } from '../lib/text.js'
import type { Theme } from '../theme.js'
import type { ActiveTool, DetailsMode, Msg, SectionVisibility } from '../types.js'
import { Md } from './markdown.js'
import { StreamingMd } from './streamingMarkdown.js'
import { ToolTrail } from './thinking.js'
import { TodoPanel } from './todoPanel.js'
// Collapse threshold for long system messages (system prompt etc.)
const SYSTEM_COLLAPSE_CHARS = 400
// `display.timestamps` label β€” same HH:MM shape the classic CLI's default
// `display.timestamp_format` ("%H:%M") produces on its message labels, so
// one config key reads identically across surfaces (#41531).
export const fmtMsgTimestamp = (createdAt: number | undefined): null | string => {
if (typeof createdAt !== 'number' || !Number.isFinite(createdAt) || createdAt <= 0) {
return null
}
const date = new Date(createdAt * 1000)
if (Number.isNaN(date.getTime())) {
return null
}
const hh = String(date.getHours()).padStart(2, '0')
const mm = String(date.getMinutes()).padStart(2, '0')
return `[${hh}:${mm}]`
}
export const MessageLine = memo(function MessageLine({
cols,
compact,
detailsMode = 'collapsed',
detailsModeCommandOverride = false,
isStreaming = false,
liveDetails = false,
msg,
prev,
reasoningActive = false,
sections,
t,
timestamps = false,
tools = []
}: 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, detailsModeCommandOverride)
const toolsMode = sectionMode('tools', detailsMode, sections, detailsModeCommandOverride)
const activityMode = sectionMode('activity', detailsMode, sections, detailsModeCommandOverride)
const thinking = msg.thinking?.trim() ?? ''
// One blank line above this block iff it opens a new visual group relative
// to the block directly above it (`prev`) β€” the flex-grouping rule. Applied
// intrinsically on each *rendered* element (not via an outer wrapper) so a
// block that renders nothing β€” e.g. a tool trail hidden by /details β€” emits
// no floating gap. Streaming-safe: the gap is derived from the stable
// predecessor, never this block's own live content. See domain/blockLayout.
const leadGap = hasLeadGap(prev, msg)
// Collapse toggle for long system messages
const systemIsLong = msg.role === 'system' && msg.text.length > SYSTEM_COLLAPSE_CHARS
const [systemOpen, setSystemOpen] = useState(false)
if (msg.kind === 'trail' && msg.todos?.length) {
return (
<TodoPanel
defaultCollapsed={msg.todoCollapsedByDefault}
incomplete={msg.todoIncomplete}
t={t}
todos={msg.todos}
/>
)
}
if (msg.kind === 'trail' && (msg.tools?.length || tools.length || thinking)) {
return shouldShowThinkingTrail(msg, thinkingMode, toolsMode, activityMode) ? (
<Box flexDirection="column" marginTop={leadGap ? 1 : 0}>
<ToolTrail
commandOverride={detailsModeCommandOverride}
detailsMode={detailsMode}
preferExpandedThinking={liveDetails}
reasoning={thinking}
reasoningActive={reasoningActive}
reasoningAlwaysVisible={msg.isMoaReference}
reasoningTokens={msg.thinkingTokens}
sections={sections}
t={t}
tools={tools}
toolTokens={msg.toolTokens}
trail={msg.tools ?? []}
/>
</Box>
) : null
}
// A trail with no reasoning, tools, or todos to show (e.g. the finalDetails
// segment message.complete appends carrying only a token tally) has nothing
// to draw β€” render nothing instead of an empty gutter row. blockRenders()
// agrees, so it also stays transparent to grouping and never opens a gap.
if (msg.kind === 'trail') {
return null
}
if (msg.role === 'tool') {
const maxChars = Math.max(24, cols - 14)
const stripped = hasAnsi(msg.text) ? stripAnsi(msg.text) : msg.text
const safeAnsi = hasAnsi(msg.text) ? sanitizeAnsiForRender(msg.text) : msg.text
const preview = compactPreview(stripped, maxChars) || '(empty tool result)'
return (
<Box alignSelf="flex-start" borderColor={t.color.muted} borderStyle="round" marginLeft={3} paddingX={1}>
{hasAnsi(msg.text) ? (
<Text wrap="truncate-end">
<Ansi>{safeAnsi}</Ansi>
</Text>
) : (
<Text color={t.color.muted} wrap="truncate-end">
{preview}
</Text>
)}
</Box>
)
}
// Timeline events (model switches, delegation completions) render as
// dim β—ˆ markers with no gutter β€” not as opaque user messages.
if (msg.kind === 'event') {
const eventGutterWidth = transcriptGutterWidth('system', t.brand.prompt)
return (
<Box marginBottom={1} marginTop={leadGap ? 1 : 0}>
<NoSelect flexShrink={0} fromLeftEdge width={eventGutterWidth}>
<Text> </Text>
</NoSelect>
<Text color={t.color.muted} dimColor>
β—ˆ {msg.text}
</Text>
</Box>
)
}
const { body, glyph, prefix } = ROLE[msg.role](t)
const gutterWidth = transcriptGutterWidth(msg.role, t.brand.prompt)
const showDetails =
(toolsMode !== 'hidden' && Boolean(msg.tools?.length)) || (thinkingMode !== 'hidden' && Boolean(thinking))
const showResponseSeparator = shouldShowResponseSeparator(msg, showDetails)
const content = (() => {
if (msg.kind === 'slash') {
return <Text color={t.color.muted}>{msg.text}</Text>
}
// ── Collapsible long system message (system prompt, AGENTS.md, etc.) ──
// MUST come before the hasAnsi check β€” system messages from the backend
// contain Rich markup escape codes that would otherwise hit <Ansi> full render.
if (systemIsLong) {
const firstLine = (msg.text.split('\n')[0] ?? '').trim().slice(0, 120) || '(system message)'
return (
<Box flexDirection="column">
<Box onClick={() => setSystemOpen(v => !v)}>
<Text color={t.color.accent}>{systemOpen ? 'β–Ύ ' : 'β–Έ '}</Text>
<Text color={t.color.muted}>{firstLine}</Text>
<Text color={t.color.muted} dimColor>
{' β€” '}
{msg.text.length.toLocaleString()} chars
</Text>
</Box>
{systemOpen && <Ansi>{sanitizeAnsiForRender(msg.text)}</Ansi>}
</Box>
)
}
if (msg.role !== 'user' && hasAnsi(msg.text)) {
return <Ansi>{sanitizeAnsiForRender(msg.text)}</Ansi>
}
if (msg.role === 'assistant') {
const bodyWidth = transcriptBodyWidth(cols, msg.role, t.brand.prompt, TERMUX_TUI_MODE)
return isStreaming ? (
// Incremental markdown: split at the last stable block boundary so
// only the in-flight tail re-tokenizes per delta. See
// streamingMarkdown.tsx for the cost model.
<StreamingMd cols={bodyWidth} compact={compact} t={t} text={boundedLiveRenderText(msg.text)} />
) : (
<Md cols={bodyWidth} 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.muted} dimColor>
[long message]
</Text>
{rest.join('')}
</Text>
)
}
// A skill, `@ref`, or attachment token the user put in the message keeps
// the accent it wore in the composer, instead of flattening back into the
// body text.
if (msg.role === 'user') {
const segments = splitComposerHighlights(msg.text)
return (
<Text {...(body ? { color: body } : {})}>
{segments.map((segment, i) =>
segment.ref ? (
<Text color={t.color.accent} key={i}>
{segment.text}
</Text>
) : (
segment.text
)
)}
</Text>
)
}
return <Text {...(body ? { color: body } : {})}>{msg.text}</Text>
})()
// Diff segments (emitted by pushInlineDiffSegment between narration
// segments) keep a blank line on both sides so the patch doesn't butt up
// against the prose around it.
const isDiffSegment = msg.kind === 'diff'
// `display.timestamps`: dim [HH:MM] beside the gutter glyph on user and
// assistant rows only β€” event/trail/system chrome stays unstamped, matching
// the classic CLI which stamps its user/assistant labels (#41531).
const stamp =
timestamps && (msg.role === 'user' || msg.role === 'assistant') && !msg.kind ? fmtMsgTimestamp(msg.createdAt) : null
return (
<Box
flexDirection="column"
marginBottom={msg.role === 'user' || isDiffSegment ? 1 : 0}
marginTop={msg.role === 'user' || msg.kind === 'slash' || isDiffSegment || leadGap ? 1 : 0}
>
{showDetails && (
<Box flexDirection="column" marginBottom={1}>
<ToolTrail
commandOverride={detailsModeCommandOverride}
detailsMode={detailsMode}
preferExpandedThinking={liveDetails}
reasoning={thinking}
reasoningActive={reasoningActive}
reasoningTokens={msg.thinkingTokens}
sections={sections}
t={t}
toolTokens={msg.toolTokens}
trail={msg.tools}
/>
</Box>
)}
{showResponseSeparator && (
<Box marginBottom={1}>
<NoSelect flexShrink={0} fromLeftEdge width={gutterWidth}>
<Text color={t.color.border}>└─ </Text>
</NoSelect>
<Text color={t.color.muted} dim>
Response
</Text>
</Box>
)}
{stamp && (
<Box>
<NoSelect flexShrink={0} fromLeftEdge width={gutterWidth}>
<Text> </Text>
</NoSelect>
<Text color={t.color.muted} dim>
{stamp}
</Text>
</Box>
)}
<Box>
<NoSelect flexShrink={0} fromLeftEdge width={gutterWidth}>
<Text bold={msg.role === 'user'} color={prefix}>
{glyph}{' '}
</Text>
</NoSelect>
<Box width={transcriptBodyWidth(cols, msg.role, t.brand.prompt, TERMUX_TUI_MODE)}>{content}</Box>
</Box>
</Box>
)
})
export const shouldShowResponseSeparator = (msg: Msg, showDetails: boolean): boolean =>
msg.role === 'assistant' && showDetails && /\S/.test(msg.text)
// A MoA reference block (msg.isMoaReference) is the user-facing
// mixture-of-agents process the user opted into, not private model
// reasoning β€” it must stay visible even when every other trail section is
// hidden (#64657).
export const shouldShowThinkingTrail = (
msg: Msg,
thinkingMode: DetailsMode,
toolsMode: DetailsMode,
activityMode: DetailsMode
): boolean =>
Boolean(msg.isMoaReference) || thinkingMode !== 'hidden' || toolsMode !== 'hidden' || activityMode !== 'hidden'
interface MessageLineProps {
cols: number
compact?: boolean
detailsMode?: DetailsMode
detailsModeCommandOverride?: boolean
isStreaming?: boolean
liveDetails?: boolean
msg: Msg
// The block rendered directly above this one. Drives the group-boundary
// lead gap (see domain/blockLayout.ts::hasLeadGap). Undefined at the top of
// the transcript or when spacing is irrelevant.
prev?: Msg
reasoningActive?: boolean
sections?: SectionVisibility
t: Theme
/** `display.timestamps` β€” dim [HH:MM] label on user/assistant rows. */
timestamps?: boolean
tools?: ActiveTool[]
}