import { Ansi, Box, NoSelect, Text } from '@hermes/ink' import { memo } from 'react' import { sectionMode } from '../domain/details.js' import { LONG_MSG } from '../config/limits.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' ? ( ) : 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 ( {hasAnsi(msg.text) ? ( {msg.text} ) : ( {preview} )} ) } 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 {msg.text} } if (msg.role !== 'user' && hasAnsi(msg.text)) { return {msg.text} } if (msg.role === 'assistant') { return isStreaming ? {msg.text} : } if (msg.role === 'user' && msg.text.length > LONG_MSG && isPasteBackedText(msg.text)) { const [head, ...rest] = userDisplay(msg.text).split('[long message]') return ( {head} [long message] {rest.join('')} ) } return {msg.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 ( {showDetails && ( )} {glyph}{' '} {content} ) }) interface MessageLineProps { cols: number compact?: boolean detailsMode?: DetailsMode isStreaming?: boolean msg: Msg sections?: SectionVisibility t: Theme }