Files
hermes-agent/ui-tui/src/components/messageLine.tsx

115 lines
3.2 KiB
TypeScript
Raw Normal View History

2026-04-13 21:20:55 -05:00
import { Ansi, Box, NoSelect, Text } from '@hermes/ink'
2026-04-06 18:40:21 -05:00
import { memo } from 'react'
2026-04-02 20:39:52 -05:00
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'
2026-04-02 20:39:52 -05:00
import type { Theme } from '../theme.js'
2026-04-13 21:20:55 -05:00
import type { DetailsMode, Msg } from '../types.js'
2026-04-14 11:49:32 -05:00
2026-04-02 20:39:52 -05:00
import { Md } from './markdown.js'
import { ToolTrail } from './thinking.js'
2026-04-02 20:39:52 -05:00
2026-04-07 20:30:22 -05:00
export const MessageLine = memo(function MessageLine({
cols,
compact,
2026-04-13 21:20:55 -05:00
detailsMode = 'collapsed',
2026-04-14 11:49:32 -05:00
isStreaming = false,
2026-04-07 20:30:22 -05:00
msg,
t
refactor(tui): /clean pass across ui-tui — 49 files, −217 LOC Full codebase pass using the /clean doctrine (KISS/DRY, no one-off helpers, no variables-used-once, pure functional where natural, inlined obvious one-liners, killed dead exports, narrowed types, spaced JSX). All contracts preserved — no RPC method, event name, or exported type shape changed. app/ — 15 files, -134 LOC - inlined 4 one-off helpers (titleCase, isLong, statusToneFrom, focusOutside predicate) - stores to arrow-const style (buildUiState, buildTurnState, buildOverlayState plus get/patch/reset triplets) - functional slash/registry byName map (flatMap over for-loops) - dropped dead param `live` in cancelOverlayFromCtrlC - DRY'd duplicate shift() call in scrollWithSelection - consolidated sections.push calls in /help components/ — 12 files, -40 LOC - extracted inline prop types to interfaces at file bottom (13×) - inlined 6 one-off vars (pctLabel, logoW, heroW, cwd, title, hint) - promoted HEART_COLORS + OPTS/LABELS to module scope - JSX sibling spacing across 9 files - un-shadowed `raw` in textInput - components/thinking.tsx + components/markdown.tsx untouched (structurally load-bearing / edge-case-heavy) config content domain protocol/ — 8 files, -77 LOC - tightened 3 regexes (MOUSE_TRACKING, looksLikeSlashCommand, hasInterpolation — dropped stateful lastIndex dance) - dead export ParsedSlashCommand removed - MODES narrowed to `as const`, `.find(m => m === s)` replaces `.includes() ? (as cast) : null` - fortunes.ts hash via reduce - fmtDuration ternary chain - inlined aboveViewport predicate in viewport.ts hooks/ + lib/ — 9 files, -38 LOC - ANSI_RE via String.fromCharCode(27) + WS_RE lifted to module scope (no more eslint-disable no-control-regex) - compactPreview/edgePreview/thinkingPreview → ternary arrows - useCompletion: hoisted pathReplace, moved stale-ref guard earlier - useInputHistory: dropped useCallback wrapper (append is stable) - useVirtualHistory: replaced 4× any with unknown + narrow MeasuredNode interface + one cast site root TS — 3 files, -63 LOC - banner.ts: parseRichMarkup via matchAll instead of exec/lastIndex, artWidth via reduce - gatewayClient.ts: resolvePython candidate list collapse, inlined one-branch guards in dispatch/pushLog/drain/request - types.ts: alpha-sorted ActiveTool / Msg / SudoReq / SecretReq members eslint config - disabled react-hooks/exhaustive-deps on packages/hermes-ink/** (compiled by react/compiler, deps live in $[N] memo arrays that eslint can't introspect) and removed the now-orphan in-file disable directive in ScrollBox.tsx fixes (not from the cleaner pass) - useComposerState: unlinkSync(file) + try/catch → rmSync(file, { force: true }) — kills the no-empty lint error and is more idiomatic - useConfigSync: added setBellOnComplete + setVoiceEnabled to the two useEffect dep arrays (they're stable React setState setters; adding is safe and silences exhaustive-deps) verification - npx eslint src/ packages/ → 0 errors, 0 warnings - npm run type-check → clean - npm test → 50/50 - npm run build → 394.8kb ink-bundle.js, 11ms esbuild - pytest tests/tui_gateway/ tests/test_tui_gateway_server.py tests/hermes_cli/test_tui_resume_flow.py tests/hermes_cli/test_tui_npm_install.py → 57/57
2026-04-16 22:32:53 -05:00
}: MessageLineProps) {
2026-04-12 16:31:30 -05:00
if (msg.kind === 'trail' && msg.tools?.length) {
2026-04-13 21:20:55 -05:00
return detailsMode === 'hidden' ? null : (
2026-04-12 16:31:30 -05:00
<Box flexDirection="column" marginTop={1}>
2026-04-13 21:20:55 -05:00
<ToolTrail detailsMode={detailsMode} t={t} trail={msg.tools} />
2026-04-12 16:31:30 -05:00
</Box>
)
}
2026-04-07 20:10:33 -05:00
if (msg.role === 'tool') {
return (
2026-04-08 10:35:07 -05:00
<Box alignSelf="flex-start" borderColor={t.color.dim} borderStyle="round" marginLeft={3} paddingX={1}>
2026-04-11 11:29:08 -05:00
<Text color={t.color.dim} wrap="truncate-end">
2026-04-14 11:49:32 -05:00
{compactPreview(hasAnsi(msg.text) ? stripAnsi(msg.text) : msg.text, Math.max(24, cols - 14)) ||
'(empty tool result)'}
2026-04-11 11:29:08 -05:00
</Text>
2026-04-07 20:24:46 -05:00
</Box>
2026-04-07 20:10:33 -05:00
)
}
const { body, glyph, prefix } = ROLE[msg.role](t)
const thinking = msg.thinking?.trim() ?? ''
2026-04-13 21:20:55 -05:00
const showDetails = detailsMode !== 'hidden' && (Boolean(msg.tools?.length) || Boolean(thinking))
2026-04-07 20:10:33 -05:00
const content = (() => {
2026-04-14 11:49:32 -05:00
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} />
}
2026-04-02 20:39:52 -05:00
2026-04-11 11:29:08 -05:00
if (msg.role === 'user' && msg.text.length > LONG_MSG && isPasteBackedText(msg.text)) {
2026-04-07 20:10:33 -05:00
const [head, ...rest] = userDisplay(msg.text).split('[long message]')
2026-04-14 11:49:32 -05:00
2026-04-02 20:39:52 -05:00
return (
<Text color={body}>
{head}
2026-04-14 11:49:32 -05:00
<Text color={t.color.dim} dimColor>
[long message]
</Text>
2026-04-02 20:39:52 -05:00
{rest.join('')}
</Text>
)
}
2026-04-07 20:10:33 -05:00
return <Text {...(body ? { color: body } : {})}>{msg.text}</Text>
2026-04-02 20:39:52 -05:00
})()
return (
2026-04-11 14:02:36 -05:00
<Box
flexDirection="column"
marginBottom={msg.role === 'user' ? 1 : 0}
marginTop={msg.role === 'user' || msg.kind === 'slash' ? 1 : 0}
>
2026-04-13 21:20:55 -05:00
{showDetails && (
<Box flexDirection="column" marginBottom={1}>
<ToolTrail
detailsMode={detailsMode}
reasoning={thinking}
reasoningTokens={msg.thinkingTokens}
t={t}
toolTokens={msg.toolTokens}
trail={msg.tools}
/>
</Box>
)}
2026-04-07 20:10:33 -05:00
<Box>
2026-04-13 21:20:55 -05:00
<NoSelect flexShrink={0} fromLeftEdge width={3}>
2026-04-14 11:49:32 -05:00
<Text bold={msg.role === 'user'} color={prefix}>
{glyph}{' '}
</Text>
2026-04-13 21:20:55 -05:00
</NoSelect>
2026-04-07 20:10:33 -05:00
2026-04-08 14:18:37 -05:00
<Box width={Math.max(20, cols - 5)}>{content}</Box>
2026-04-02 20:39:52 -05:00
</Box>
</Box>
)
2026-04-06 18:40:21 -05:00
})
refactor(tui): /clean pass across ui-tui — 49 files, −217 LOC Full codebase pass using the /clean doctrine (KISS/DRY, no one-off helpers, no variables-used-once, pure functional where natural, inlined obvious one-liners, killed dead exports, narrowed types, spaced JSX). All contracts preserved — no RPC method, event name, or exported type shape changed. app/ — 15 files, -134 LOC - inlined 4 one-off helpers (titleCase, isLong, statusToneFrom, focusOutside predicate) - stores to arrow-const style (buildUiState, buildTurnState, buildOverlayState plus get/patch/reset triplets) - functional slash/registry byName map (flatMap over for-loops) - dropped dead param `live` in cancelOverlayFromCtrlC - DRY'd duplicate shift() call in scrollWithSelection - consolidated sections.push calls in /help components/ — 12 files, -40 LOC - extracted inline prop types to interfaces at file bottom (13×) - inlined 6 one-off vars (pctLabel, logoW, heroW, cwd, title, hint) - promoted HEART_COLORS + OPTS/LABELS to module scope - JSX sibling spacing across 9 files - un-shadowed `raw` in textInput - components/thinking.tsx + components/markdown.tsx untouched (structurally load-bearing / edge-case-heavy) config content domain protocol/ — 8 files, -77 LOC - tightened 3 regexes (MOUSE_TRACKING, looksLikeSlashCommand, hasInterpolation — dropped stateful lastIndex dance) - dead export ParsedSlashCommand removed - MODES narrowed to `as const`, `.find(m => m === s)` replaces `.includes() ? (as cast) : null` - fortunes.ts hash via reduce - fmtDuration ternary chain - inlined aboveViewport predicate in viewport.ts hooks/ + lib/ — 9 files, -38 LOC - ANSI_RE via String.fromCharCode(27) + WS_RE lifted to module scope (no more eslint-disable no-control-regex) - compactPreview/edgePreview/thinkingPreview → ternary arrows - useCompletion: hoisted pathReplace, moved stale-ref guard earlier - useInputHistory: dropped useCallback wrapper (append is stable) - useVirtualHistory: replaced 4× any with unknown + narrow MeasuredNode interface + one cast site root TS — 3 files, -63 LOC - banner.ts: parseRichMarkup via matchAll instead of exec/lastIndex, artWidth via reduce - gatewayClient.ts: resolvePython candidate list collapse, inlined one-branch guards in dispatch/pushLog/drain/request - types.ts: alpha-sorted ActiveTool / Msg / SudoReq / SecretReq members eslint config - disabled react-hooks/exhaustive-deps on packages/hermes-ink/** (compiled by react/compiler, deps live in $[N] memo arrays that eslint can't introspect) and removed the now-orphan in-file disable directive in ScrollBox.tsx fixes (not from the cleaner pass) - useComposerState: unlinkSync(file) + try/catch → rmSync(file, { force: true }) — kills the no-empty lint error and is more idiomatic - useConfigSync: added setBellOnComplete + setVoiceEnabled to the two useEffect dep arrays (they're stable React setState setters; adding is safe and silences exhaustive-deps) verification - npx eslint src/ packages/ → 0 errors, 0 warnings - npm run type-check → clean - npm test → 50/50 - npm run build → 394.8kb ink-bundle.js, 11ms esbuild - pytest tests/tui_gateway/ tests/test_tui_gateway_server.py tests/hermes_cli/test_tui_resume_flow.py tests/hermes_cli/test_tui_npm_install.py → 57/57
2026-04-16 22:32:53 -05:00
interface MessageLineProps {
cols: number
compact?: boolean
detailsMode?: DetailsMode
isStreaming?: boolean
msg: Msg
t: Theme
}