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 ( ) } if (msg.kind === 'trail' && (msg.tools?.length || tools.length || thinking)) { return shouldShowThinkingTrail(msg, thinkingMode, toolsMode, activityMode) ? ( ) : 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 ( {hasAnsi(msg.text) ? ( {safeAnsi} ) : ( {preview} )} ) } // 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 ( ◈ {msg.text} ) } 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 {msg.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 full render. if (systemIsLong) { const firstLine = (msg.text.split('\n')[0] ?? '').trim().slice(0, 120) || '(system message)' return ( setSystemOpen(v => !v)}> {systemOpen ? '▾ ' : '▸ '} {firstLine} {' — '} {msg.text.length.toLocaleString()} chars {systemOpen && {sanitizeAnsiForRender(msg.text)}} ) } if (msg.role !== 'user' && hasAnsi(msg.text)) { return {sanitizeAnsiForRender(msg.text)} } 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. ) : ( ) } 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('')} ) } // 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 ( {segments.map((segment, i) => segment.ref ? ( {segment.text} ) : ( segment.text ) )} ) } return {msg.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 ( {showDetails && ( )} {showResponseSeparator && ( └─ Response )} {stamp && ( {stamp} )} {glyph}{' '} {content} ) }) 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[] }