import React, { useMemo } from 'react'
import { Box, Text } from 'ink'
import { useI18n } from '../i18n-context'
import type { ChatMessage } from './app'
import { homedir } from 'node:os'
interface ChatPanelProps {
messages: ChatMessage[]
focusMode?: boolean
}
/** Format cwd for display: replace HOME with ~, truncate if too long */
function displayCwd(): string {
const cwd = process.cwd()
const home = homedir()
if (home && cwd.startsWith(home)) {
return '~' + cwd.slice(home.length)
}
return cwd
}
/** Map tool names to dot colors — Claude Code parity */
function toolColor(name: string): string {
switch (name) {
case 'Bash':
case 'Read':
case 'Glob':
case 'Grep':
return 'white'
case 'Write':
case 'Edit':
case 'Update':
return 'red'
case 'Agent':
case 'Task':
case 'Skill':
case 'Workflow':
return 'magenta'
case 'WebSearch':
case 'WebFetch':
return 'blue'
default:
return 'white'
}
}
/**
* Memoized single message row — skips re-render when content hasn't changed.
* This prevents O(n) re-renders of the entire chat history on every streaming chunk.
* The custom comparator returns true (skip render) when content, role, and collapse
* state are identical to the previous render.
*/
const MessageRow = React.memo(
function MessageRow({ msg }: { msg: ChatMessage; isLast: boolean }) {
const { t } = useI18n()
return (
{msg.toolMeta ? (
{/* Tool call line: ⏺ ToolName(detail) — Claude Code parity */}
{msg.toolMeta.name ? (
{msg.toolMeta.collapsed ? '⏺' : '⏺ ▼'} {msg.toolMeta.name}
{msg.toolMeta.input ? ` (${msg.toolMeta.input.slice(0, 120)})` : ''}
) : null}
{/* Tool result line: ⎿ summary text */}
{msg.toolMeta.output ? (
{' ⎿ '}
{msg.toolMeta.output.slice(0, 300)}
) : msg.content && !msg.toolMeta.collapsed ? (
{msg.content}
) : null}
) : (
<>
{msg.role === 'user' ? (
▸ {displayCwd()}:
) : msg.role === 'assistant' ? (
◆ {t('ui.assistant.role_label')}:
) : msg.role === 'system' ? (
⚠ {t('ui.system.role_label')}:
) : null}
{msg.content}
>
)}
)
},
(prev, next) =>
prev.msg.content === next.msg.content &&
prev.msg.role === next.msg.role &&
prev.msg.toolMeta?.collapsed === next.msg.toolMeta?.collapsed,
)
export function ChatPanel({ messages, focusMode }: ChatPanelProps) {
const { t } = useI18n()
// Memoize display message computation to avoid O(n) compact on every render.
// During streaming, messages changes on every chunk; without memoization,
// compactToolGroups/compactForFocus re-iterates the full message list on each
// chunk, saturating the event loop at 20-50 chunks/sec.
const displayMessages = useMemo(
() => (focusMode ? compactForFocus(messages) : compactToolGroups(messages)),
[messages, focusMode],
)
return (
{focusMode && messages.length > 0 && (
🔍 {t('ui.chat.focus_hint')} — {countHidden(messages, displayMessages, t)} ·{' '}
{t('ui.chat.toggle_focus_hint')} · {t('ui.chat.expand_hint')}
)}
{messages.length === 0 && (
{t('ui.banner.title')}
— {t('ui.banner.subtitle')}
{t('ui.banner.tagline')}
{t('ui.banner.start_message')}
{t('ui.banner.controls_hint')}
{t('ui.banner.tip_clear')}
)}
{displayMessages.map((msg, i) => (
))}
)
}
/**
* Pair each tool_use message with its following tool_result message(s),
* showing every tool individually with its name, detail, and output.
*
* Claude Code parity: each tool renders as:
* ⏺ ToolName (detail) ← colored per tool type
* ⎿ output summary ← dimmed below
*
* Only true duplicates (same tool name + same detail) are collapsed
* into a single entry with a count.
*/
function compactToolGroups(messages: ChatMessage[]): ChatMessage[] {
const result: ChatMessage[] = []
let current: ChatMessage | null = null
for (const msg of messages) {
// Tool call with a name → flush previous, start a new one
if (msg.toolMeta?.name && !msg.toolMeta.name.startsWith('tools (')) {
if (current) result.push(current)
current = { ...msg }
continue
}
// Tool result (no name, has output) → merge output into current tool
if (msg.toolMeta && !msg.toolMeta.name && msg.toolMeta.output) {
if (current) {
current = {
...current,
content: msg.content,
toolMeta: {
...current.toolMeta!,
output: msg.toolMeta.output,
},
}
}
// Orphan tool result (no preceding tool_use) → skip
continue
}
// Already-grouped message (from a previous compact cycle) or non-tool
if (current) {
result.push(current)
current = null
}
// Skip orphan tool-result messages without a preceding tool_use
if (msg.toolMeta && !msg.toolMeta.name && msg.toolMeta.output) {
continue
}
result.push(msg)
}
// Flush any remaining tool
if (current) result.push(current)
return result
}
/**
* Compact messages for focus view:
* - Groups consecutive tool calls into [▶ N tools] ↳ name1 ↳ name2 summary lines
* - Keeps all user, assistant, and system messages intact
*/
function compactForFocus(messages: ChatMessage[]): ChatMessage[] {
const result: ChatMessage[] = []
let toolGroup: ChatMessage[] = []
for (const msg of messages) {
if (msg.toolMeta) {
toolGroup.push(msg)
} else {
// Flush pending tool group
if (toolGroup.length > 0) {
result.push(summarizeToolGroup(toolGroup))
toolGroup = []
}
result.push(msg)
}
}
// Flush remaining
if (toolGroup.length > 0) {
result.push(summarizeToolGroup(toolGroup))
}
return result
}
/** Summarize a group of tool calls into a single compact message. */
function summarizeToolGroup(tools: ChatMessage[]): ChatMessage {
const names = tools.map((t) => t.toolMeta!.name)
const uniqueNames = [...new Set(names)]
const toolList = uniqueNames.map((n) => `↳ ${n}`).join(' ')
return {
role: 'system',
content: `[▶ ${tools.length} tool${tools.length > 1 ? 's' : ''}] ${toolList}`,
toolMeta: {
name: `tools (${tools.length})`,
input: '',
output: tools.map((t) => t.content).join('\n'),
collapsed: true,
},
}
}
/** Count how many messages are hidden in focus mode. */
function countHidden(
all: ChatMessage[],
compacted: ChatMessage[],
t: (key: string, params?: Record) => string,
): string {
const toolCount = all.filter((m) => m.toolMeta).length
const hidden = all.length - compacted.length
if (hidden <= 0) return t('ui.chat.showing_all', { count: String(all.length) })
return t('ui.chat.tool_outputs_folded', { hidden: String(hidden), toolCount: String(toolCount) })
}