"use client"; import { memo, useState, useRef, useEffect, useMemo } from "react"; import { MarkdownBody } from "./MarkdownBody"; import { ImagePreview } from "./ImagePreview"; import { copyText } from "@/lib/clipboard"; import { useI18n } from "@/hooks/useI18n"; import { parseCompactionSummary } from "@/lib/compaction-summary"; import { getAssistantErrorMessage, isEmptyThinkingBlock } from "@/lib/message-display"; import { parseUnifiedPatch, type SplitDiffCell } from "@/lib/patch"; import { isEditToolName } from "@/lib/tool-names"; import { TurnWrittenFiles } from "./TurnWrittenFiles"; import type { WrittenFile } from "@/lib/turn-written-files"; import { skillExpansionToCommand } from "@/lib/slash-display"; import type { AgentMessage, UserMessage, AssistantMessage, CustomMessage, ToolResultMessage, BashExecutionMessage, AssistantContentBlock, TextContent, ImageContent, ToolCallContent, ThinkingContent, } from "@/lib/types"; // CJK chars ~1 token each (GLM/DeepSeek/GPT-o200k); other chars ~4 chars/token. const CJK_PATTERN = /[\u3000-\u30ff\u3400-\u9fff\uf900-\ufaff\u{20000}-\u{2fa1f}\uac00-\ud7af]/u; function estimateTokens(text: string): number { let cjk = 0; let rest = 0; for (const ch of text) { if (CJK_PATTERN.test(ch)) cjk++; else rest++; } return cjk + rest / 4; } interface TokenEstimateCacheEntry { text: string; tokens: number; } function getTokenEstimateText(block: AssistantContentBlock): string | null { if (block.type === "text") return block.text; if (block.type === "thinking") return block.thinking; if (block.type === "toolCall") return JSON.stringify(block.input ?? {}) ?? ""; return null; } function isHighSurrogate(codeUnit: number): boolean { return codeUnit >= 0xd800 && codeUnit <= 0xdbff; } function isLowSurrogate(codeUnit: number): boolean { return codeUnit >= 0xdc00 && codeUnit <= 0xdfff; } function estimateUpdatedTokens(previous: TokenEstimateCacheEntry | undefined, text: string): number { if (!previous || !text.startsWith(previous.text)) return estimateTokens(text); let baseTokens = previous.tokens; let suffixStart = previous.text.length; // A streamed delta can complete a surrogate pair that was counted as two // non-CJK code points in the previous update. if ( suffixStart > 0 && suffixStart < text.length && isHighSurrogate(previous.text.charCodeAt(suffixStart - 1)) && isLowSurrogate(text.charCodeAt(suffixStart)) ) { baseTokens -= 1 / 4; suffixStart--; } return baseTokens + estimateTokens(text.slice(suffixStart)); } const MAX_THINKING_CACHE_ENTRIES = 100; const thinkingContentCache = new Map>(); // Messages larger than this skip markdown rendering entirely. react-markdown + // KaTeX + syntax highlighting on multi-hundred-KB payloads (e.g. pasted HAR or // log dumps) freezes the browser main thread. const MAX_MARKDOWN_CHARS = 100_000; function formatMessageBytes(n: number): string { if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)} MB`; if (n >= 1_000) return `${Math.round(n / 1_000)} KB`; return `${n} B`; } /** * MarkdownBody with an oversized-content guard: huge messages render as a * click-to-reveal plain-text
 instead of running the markdown pipeline.
 */
function SafeMarkdownBody({ children, className, ...props }: React.ComponentProps) {
  const { t } = useI18n();
  const [showRaw, setShowRaw] = useState(false);

  if (children.length <= MAX_MARKDOWN_CHARS) {
    return {children};
  }
  if (!showRaw) {
    return (
      
    );
  }
  return (
    
        {children}
      
); } // Cap the user "sent" bubble's height so an abnormally long message does not // push the conversation off screen; overflow scrolls inside the bubble. const USER_BUBBLE_MAX_HEIGHT = 300; function loadThinkingContent(sessionId: string, entryId: string, blockIndex: number): Promise { const key = `${sessionId}:${entryId}:${blockIndex}`; const cached = thinkingContentCache.get(key); if (cached) { thinkingContentCache.delete(key); thinkingContentCache.set(key, cached); return cached; } const request = fetch( `/api/sessions/${encodeURIComponent(sessionId)}/entries/${encodeURIComponent(entryId)}/thinking?blockIndex=${blockIndex}`, ).then(async (response) => { if (!response.ok) throw new Error(`HTTP ${response.status}`); const data = await response.json() as { thinking?: unknown }; if (typeof data.thinking !== "string") throw new Error("Invalid thinking response"); return data.thinking; }).catch((error) => { thinkingContentCache.delete(key); throw error; }); thinkingContentCache.set(key, request); if (thinkingContentCache.size > MAX_THINKING_CACHE_ENTRIES) { const oldestKey = thinkingContentCache.keys().next().value; if (oldestKey) thinkingContentCache.delete(oldestKey); } return request; } interface Props { message: AgentMessage; isStreaming?: boolean; toolResults?: Map; modelNames?: Record; cwd?: string; onOpenFile?: (filePath: string) => void; entryId?: string; onFork?: (entryId: string) => void; forking?: boolean; onNavigate?: (entryId: string) => void; prevAssistantEntryId?: string; onEditContent?: (message: UserMessage) => void; showTimestamp?: boolean; prevTimestamp?: number; sessionId?: string; /** * Files this turn wrote, derived by the caller from the whole turn's * successful write/edit tool calls. ChatWindow computes this because the * saved-message path splits tool calls into their own entries, leaving the * final answer text-only. */ writtenFiles?: WrittenFile[]; } function formatTime(ts?: number): string | null { if (!ts) return null; const d = new Date(ts); const now = new Date(); const isToday = d.getFullYear() === now.getFullYear() && d.getMonth() === now.getMonth() && d.getDate() === now.getDate(); const time = d.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" }); if (isToday) return time; const date = d.toLocaleDateString([], { month: "short", day: "numeric", year: d.getFullYear() !== now.getFullYear() ? "numeric" : undefined }); return `${date} ${time}`; } export function replaceUserMessageText(message: UserMessage, text: string): UserMessage { if (typeof message.content === "string") return { ...message, content: text }; const content: Array = []; let replaced = false; for (const block of message.content) { if (block.type !== "text") { content.push(block); continue; } if (!replaced) { content.push({ ...block, text }); replaced = true; } } if (!replaced) content.unshift({ type: "text", text }); return { ...message, content }; } function haveSameRelevantToolResults( message: AgentMessage, previous: Map | undefined, next: Map | undefined, ): boolean { if (previous === next || message.role !== "assistant") return true; for (const block of (message as AssistantMessage).content ?? []) { if (block.type === "toolCall" && previous?.get(block.toolCallId) !== next?.get(block.toolCallId)) { return false; } } return true; } export const MessageView = memo(function MessageView({ message, isStreaming, toolResults, modelNames, cwd, onOpenFile, entryId, onFork, forking, onNavigate, prevAssistantEntryId, onEditContent, showTimestamp, prevTimestamp, sessionId, writtenFiles }: Props) { if (message.role === "user") { return ; } if (message.role === "assistant") { return ; } if (message.role === "toolResult") { // Rendered inline under its toolCall — skip standalone rendering if paired return null; } if (message.role === "custom") { if ((message as CustomMessage).customType === "compaction") { return ; } return ; } if (message.role === "bashExecution") { return ; } return null; }, (prev, next) => { return prev.message === next.message && prev.isStreaming === next.isStreaming && haveSameRelevantToolResults(prev.message, prev.toolResults, next.toolResults) && prev.modelNames === next.modelNames && prev.cwd === next.cwd && prev.onOpenFile === next.onOpenFile && prev.entryId === next.entryId && prev.onFork === next.onFork && prev.forking === next.forking && prev.onNavigate === next.onNavigate && prev.prevAssistantEntryId === next.prevAssistantEntryId && prev.onEditContent === next.onEditContent && prev.showTimestamp === next.showTimestamp && prev.prevTimestamp === next.prevTimestamp && prev.sessionId === next.sessionId; }); function UserMessageView({ message, cwd, onOpenFile, entryId, onFork, forking, onNavigate, prevAssistantEntryId, onEditContent }: { message: UserMessage; cwd?: string; onOpenFile?: (filePath: string) => void; entryId?: string; onFork?: (entryId: string) => void; forking?: boolean; onNavigate?: (entryId: string) => void; prevAssistantEntryId?: string; onEditContent?: (message: UserMessage) => void; }) { const { t } = useI18n(); const [hovered, setHovered] = useState(false); const [copied, setCopied] = useState(false); const [expanded, setExpanded] = useState(false); const content = typeof message.content === "string" ? message.content : message.content .filter((b): b is TextContent => b.type === "text") .map((b) => b.text) .join("\n"); const imageBlocks: ImageContent[] = typeof message.content === "string" ? [] : message.content.filter((b): b is ImageContent => b.type === "image"); const commandText = skillExpansionToCommand(content); const commandSeparator = commandText?.search(/\s/) ?? -1; const commandName = commandText ? commandSeparator === -1 ? commandText : commandText.slice(0, commandSeparator) : ""; const commandArgs = commandText && commandSeparator !== -1 ? commandText.slice(commandSeparator + 1) : ""; const time = formatTime(message.timestamp); const canFork = !!entryId && !!onFork; const copyTarget = commandText ?? content; const editTarget = commandText ? replaceUserMessageText(message, commandText) : message; const imageBlocksNode = imageBlocks.length > 0 && (
{imageBlocks.map((img, i) => { // lib/types.ts ImageContent uses {source:{type,data,media_type,url}} // pi-ai on-disk format uses flat {data, mimeType} — handle both const flat = img as unknown as { data?: string; mimeType?: string }; const src = img.source ? img.source.type === "base64" ? `data:${img.source.media_type};base64,${img.source.data}` : img.source.url ?? "" : flat.data ? `data:${flat.mimeType};base64,${flat.data}` : ""; return ( {/* eslint-disable-next-line @next/next/no-img-element */} ); })}
); const canNavigate = !!prevAssistantEntryId && !!onNavigate; const copyContent = () => { copyText(copyTarget).then(() => { setCopied(true); setTimeout(() => setCopied(false), 1500); }); }; return (
setHovered(true)} onMouseLeave={() => setHovered(false)} >
{commandText ? (
{imageBlocksNode}
{commandArgs && ( {commandArgs} )}
{expanded && ( {content} )}
) : ( <> {imageBlocksNode} {content && {content}} )}
{/* Bottom row: action buttons + timestamp */} {(time || canFork || canNavigate || true) && (
{(canFork || canNavigate) && (
{canNavigate && ( )} {canFork && ( )}
)} {time && {time}}
)}
); } function AssistantMessageView({ message, isStreaming, toolResults, modelNames, cwd, onOpenFile, showTimestamp, prevTimestamp, sessionId, entryId, writtenFiles, }: { message: AssistantMessage; isStreaming?: boolean; toolResults?: Map; modelNames?: Record; cwd?: string; onOpenFile?: (filePath: string) => void; showTimestamp?: boolean; prevTimestamp?: number; sessionId?: string; entryId?: string; writtenFiles?: WrittenFile[]; }) { const { t } = useI18n(); const time = showTimestamp ? formatTime(message.timestamp) : null; const blockItems = useMemo(() => (message.content ?? []) .map((block, originalIndex) => ({ block, originalIndex })) .filter(({ block }) => !isEmptyThinkingBlock(block, { isStreaming })), [message.content, isStreaming]); const blocks = useMemo(() => blockItems.map(({ block }) => block), [blockItems]); const providerError = getAssistantErrorMessage(message, { isStreaming }); const [hovered, setHovered] = useState(false); const [copied, setCopied] = useState(false); const streamStartRef = useRef(null); const [tps, setTps] = useState(null); const blockItemsRef = useRef(blockItems); blockItemsRef.current = blockItems; const tokenEstimateCacheRef = useRef>(new Map()); const estimatedTokens = useMemo(() => { if (!isStreaming) { tokenEstimateCacheRef.current = new Map(); return 0; } const nextCache = new Map(); let total = 0; for (const { block, originalIndex } of blockItems) { const text = getTokenEstimateText(block); if (text === null) continue; const tokens = estimateUpdatedTokens(tokenEstimateCacheRef.current.get(originalIndex), text); nextCache.set(originalIndex, { text, tokens }); total += tokens; } tokenEstimateCacheRef.current = nextCache; return total; }, [blockItems, isStreaming]); const estimatedTokensRef = useRef(estimatedTokens); estimatedTokensRef.current = estimatedTokens; // Streaming-based timing for thinking blocks const blockStartTimesRef = useRef>(new Map()); const [streamingDurations, setStreamingDurations] = useState>(new Map()); // Thinking duration derived from file timestamps: time from prev message end to this message end // This is the total generation time (thinking + any text before first tool call) const thinkingDurationFromFile = useMemo(() => { if (!message.timestamp || !prevTimestamp) return undefined; const secs = Math.round((message.timestamp - prevTimestamp) / 1000); return secs > 0 ? secs : undefined; }, [message.timestamp, prevTimestamp]); // Tool call durations derived from session file timestamps (accurate for completed messages) // assistant message timestamp = when generation ended = when tools started running // toolResult timestamp = when tool execution finished const toolCallDurations = useMemo>(() => { const map = new Map(); if (!toolResults || !message.timestamp) return map; for (const [callId, result] of toolResults) { if (result.timestamp && message.timestamp) { const secs = Math.round((result.timestamp - message.timestamp) / 1000); if (secs > 0) map.set(callId, secs); } } return map; }, [toolResults, message.timestamp]); const textContent = blocks .filter((b): b is TextContent => b.type === "text") .map((b) => b.text) .join("\n"); const copyContent = () => { copyText(textContent).then(() => { setCopied(true); setTimeout(() => setCopied(false), 1500); }); }; useEffect(() => { if (!isStreaming) { // Finalise any un-finished thinking block durations on stream end const now = new Date().getTime(); setStreamingDurations((prev: Map) => { const next = new Map(prev); for (const [idx, start] of blockStartTimesRef.current) { if (!next.has(idx)) next.set(idx, Math.round((now - start) / 1000)); } return next; }); streamStartRef.current = null; setTps(null); return; } const tick = () => { const items = blockItemsRef.current; const now = Date.now(); // Record start time for each block the first time we see it items.forEach(({ originalIndex }) => { if (!blockStartTimesRef.current.has(originalIndex)) blockStartTimesRef.current.set(originalIndex, now); }); // When a non-last block has a successor already started, finalise its duration setStreamingDurations((prev: Map) => { let changed = false; const next = new Map(prev); for (let i = 0; i < items.length - 1; i++) { const originalIndex = items[i].originalIndex; const nextOriginalIndex = items[i + 1].originalIndex; if (!next.has(originalIndex) && blockStartTimesRef.current.has(originalIndex)) { const start = blockStartTimesRef.current.get(originalIndex)!; const nextStart = blockStartTimesRef.current.get(nextOriginalIndex) ?? now; next.set(originalIndex, Math.round((nextStart - start) / 1000)); changed = true; } } return changed ? next : prev; }); const tokens = estimatedTokensRef.current; if (tokens === 0) return; if (streamStartRef.current === null) streamStartRef.current = now; const elapsed = (now - streamStartRef.current) / 1000; if (elapsed > 0.5) setTps(tokens / elapsed); }; const id = setInterval(tick, 300); return () => clearInterval(id); }, [isStreaming]); if (blocks.length === 0 && !isStreaming && !providerError) return null; return (
setHovered(true)} onMouseLeave={() => setHovered(false)} > {/* Model label */}
{message.provider && ( {modelNames?.[`${message.provider}:${message.model}`] ?? modelNames?.[message.model] ?? message.model} )} {isStreaming && (() => { const est = Math.round(estimatedTokens); return ( <> {est > 0 && ( {est} {tps !== null && (() => { const bg = tps >= 50 ? "#53b3cb" : tps >= 30 ? "#9bc53d" : tps >= 15 ? "#f9c22e" : "#e01a4f"; return ( {tps.toFixed(1)} t/s ); })()} )} ); })()}
{blockItems.map(({ block, originalIndex }) => ( ))}
{providerError && (
0 ? 8 : 0, padding: "7px 10px", border: "1px solid rgba(239,68,68,0.3)", borderRadius: 6, background: "rgba(239,68,68,0.07)", color: "#ef4444", fontFamily: "var(--font-mono)", fontSize: 12, lineHeight: 1.5, whiteSpace: "pre-wrap", overflowWrap: "anywhere", }} > Error: {providerError}
)} {writtenFiles && writtenFiles.length > 0 && ( )}
{message.usage && !isStreaming && (
{formatUsage(message.usage)}
)} {textContent && !isStreaming && ( )} {time && !isStreaming && ( {time} )}
); } function BlockView({ block, toolResults, isStreaming, streamingDuration, toolCallDurations, cwd, onOpenFile, sessionId, entryId, blockIndex }: { block: AssistantContentBlock; toolResults?: Map; isStreaming?: boolean; streamingDuration?: number; toolCallDurations?: Map; cwd?: string; onOpenFile?: (filePath: string) => void; sessionId?: string; entryId?: string; blockIndex: number }) { if (block.type === "text") { return ; } if (block.type === "thinking") { return ; } if (block.type === "toolCall") { const tc = block as ToolCallContent; const result = toolResults?.get(tc.toolCallId); const duration = toolCallDurations?.get(tc.toolCallId); return ; } return null; } function TextBlock({ block, isStreaming, cwd, onOpenFile }: { block: TextContent; isStreaming?: boolean; cwd?: string; onOpenFile?: (filePath: string) => void }) { return {block.text}; } function ThinkingBlock({ block, duration, sessionId, entryId, blockIndex }: { block: ThinkingContent; duration?: number; sessionId?: string; entryId?: string; blockIndex: number; }) { const { t } = useI18n(); const [expanded, setExpanded] = useState(false); const [content, setContent] = useState(null); const [loading, setLoading] = useState(false); const [error, setError] = useState(null); const toggle = async () => { const nextExpanded = !expanded; setExpanded(nextExpanded); if (!nextExpanded || !block.deferred || content !== null) return; if (!sessionId || !entryId) { setError(t("i18n.thinkingUnavailable")); return; } setLoading(true); setError(null); try { setContent(await loadThinkingContent(sessionId, entryId, blockIndex)); } catch (err) { setError(err instanceof Error ? err.message : String(err)); } finally { setLoading(false); } }; return (
{expanded && (
{loading ? t("i18n.loadingThinking") : error ?? (block.deferred ? content : block.thinking)}
)}
); } function ToolCallBlock({ block, result, duration }: { block: ToolCallContent; result?: ToolResultMessage; duration?: number }) { const [expanded, setExpanded] = useState(false); const inputStr = JSON.stringify(block.input, null, 2); const isEditTool = isEditToolName(block.toolName); const resultDiff = result && !result.isError ? getResultDiff(result) : null; // Result display const resultText = result ? result.content.filter((b): b is { type: "text"; text: string } => b.type === "text").map((b) => b.text).join("\n") : null; const resultIsEmpty = resultText === null ? false : (resultText.trim() === "(no output)" || resultText.trim() === ""); const isError = result?.isError ?? false; return (
{/* ── Tool call header ── */} {/* ── Expanded: input args ── */} {expanded && !isEditTool && (
          {inputStr}
        
)} {/* ── Paired result — only shown when expanded ── */} {expanded && result && ( resultDiff ? ( ) : ( ) )}
); } interface ResultDiff { text: string; } function PairedDiffResult({ diff }: { diff: ResultDiff; }) { return (
); } function SplitPatchView({ text }: { text: string }) { const { t } = useI18n(); const files = useMemo(() => parseUnifiedPatch(text), [text]); if (!files) return ; const showFileHeaders = files.length > 1; return (
{files.map((file, fileIndex) => (
{showFileHeaders && (
)}
{file.rows.map((row, rowIndex) => { if (row.type === "hunk") { return null; } return (
); })}
))}
); } function SplitDiffHeader({ title, side }: { title: string; side: "left" | "right" }) { return (
{title}
); } function SplitDiffCellView({ cell, side }: { cell: SplitDiffCell; side: "left" | "right" }) { const bg = cell.type === "added" ? "rgba(34,197,94,0.12)" : cell.type === "removed" ? "rgba(248,113,113,0.13)" : cell.type === "empty" ? "var(--bg-subtle)" : "transparent"; const marker = cell.type === "added" ? "+" : cell.type === "removed" ? "-" : " "; const markerColor = cell.type === "added" ? "#22c55e" : cell.type === "removed" ? "#f87171" : "var(--text-dim)"; return (
{cell.lineNo ?? ""} {marker} {cell.text || "\u00a0"}
); } function PatchTextView({ text }: { text: string }) { const lines = text.split(/\r?\n/); return (
{lines.map((line, i) => { const kind = line.startsWith("@@") ? "hunk" : line.startsWith("+") && !line.startsWith("+++") ? "added" : line.startsWith("-") && !line.startsWith("---") ? "removed" : "context"; const bg = kind === "added" ? "rgba(34,197,94,0.12)" : kind === "removed" ? "rgba(248,113,113,0.13)" : kind === "hunk" ? "rgba(96,165,250,0.12)" : "transparent"; const color = kind === "added" ? "#22c55e" : kind === "removed" ? "#f87171" : kind === "hunk" ? "var(--accent)" : "var(--text)"; return (
{i + 1} {line || "\u00a0"}
); })}
); } function getResultDiff(result: ToolResultMessage): ResultDiff | null { const details = (result as ToolResultMessage & { details?: unknown }).details; if (!isRecord(details)) return null; const patch = typeof details.patch === "string" ? details.patch : null; if (patch) return { text: patch }; const diff = typeof details.diff === "string" ? details.diff : null; if (diff) return { text: diff }; return null; } function isRecord(value: unknown): value is Record { return typeof value === "object" && value !== null && !Array.isArray(value); } function PairedResult({ text, isEmpty, isError }: { text: string; isEmpty: boolean; isError: boolean; }) { const { t } = useI18n(); return (
         {isEmpty ? t("i18n.noOutput") : text}
      
); } function CompactionMessageView({ message }: { message: CustomMessage }) { const { t } = useI18n(); const summary = getMessageText(message.content); const parsedSummary = useMemo(() => parseCompactionSummary(summary), [summary]); const time = formatTime(message.timestamp); return (
compaction {time && {time}}
{t("i18n.conversationCompacted")}
{t("i18n.compactionDescription")}
{parsedSummary.body ? ( {parsedSummary.body} ) : ( {t("i18n.noSummary")} )}
); } function CompactionFileMetadata({ readFiles, modifiedFiles }: { readFiles: string[]; modifiedFiles: string[] }) { const { t } = useI18n(); const total = readFiles.length + modifiedFiles.length; if (total === 0) return null; const parts = []; if (readFiles.length > 0) parts.push(`${readFiles.length} read`); if (modifiedFiles.length > 0) parts.push(`${modifiedFiles.length} modified`); return (
{t("i18n.fileContext", { details: parts.join(", ") })} {modifiedFiles.length > 0 && } {readFiles.length > 0 && }
); } function CompactionFileList({ title, files }: { title: string; files: string[] }) { return (
{title}
    {files.map((file) => (
  • {file}
  • ))}
); } function CustomMessageView({ message, cwd, onOpenFile }: { message: CustomMessage; cwd?: string; onOpenFile?: (filePath: string) => void }) { const { t } = useI18n(); const isHiddenDisplay = message.display === false; const [contentExpanded, setContentExpanded] = useState(!isHiddenDisplay); const [detailsExpanded, setDetailsExpanded] = useState(false); const [copied, setCopied] = useState(false); const text = getMessageText(message.content); const images = getMessageImages(message.content); const hasDetails = message.details !== undefined; const detailsText = hasDetails ? safeJson(message.details) : ""; const title = formatCustomType(message.customType); const time = formatTime(message.timestamp); const copyContent = () => { copyText(text || detailsText).then(() => { setCopied(true); setTimeout(() => setCopied(false), 1500); }); }; return (
{title} {isHiddenDisplay && {t("i18n.hiddenExtensionMessage")}} {time && {time}}
{contentExpanded ? (
{images.length > 0 && (
{images.map((img, i) => { const src = imageSource(img); if (!src) return null; return ( {/* eslint-disable-next-line @next/next/no-img-element */} ); })}
)} {text ? {text} : {t("i18n.noMessage")}}
) : ( )}
{text || detailsText ? ( ) : null} {(hasDetails || isHiddenDisplay) && ( )}
{hasDetails && ((isHiddenDisplay && contentExpanded) || (!isHiddenDisplay && detailsExpanded)) && (
            {detailsText}
          
)}
); } function getMessageText(content: CustomMessage["content"] | UserMessage["content"]): string { if (typeof content === "string") return content; return content .filter((b): b is TextContent => b.type === "text") .map((b) => b.text) .join("\n"); } function getMessageImages(content: CustomMessage["content"] | UserMessage["content"]): ImageContent[] { if (typeof content === "string") return []; return content.filter((b): b is ImageContent => b.type === "image"); } function imageSource(img: ImageContent): string { const flat = img as unknown as { data?: string; mimeType?: string }; if (img.source) { return img.source.type === "base64" ? `data:${img.source.media_type};base64,${img.source.data}` : img.source.url ?? ""; } return flat.data ? `data:${flat.mimeType};base64,${flat.data}` : ""; } function safeJson(value: unknown): string { try { return JSON.stringify(value, null, 2); } catch { return String(value); } } function formatCustomType(type: string): string { return type || "extension"; } function previewText(text: string): string { const normalized = text.replace(/\s+/g, " ").trim(); if (!normalized) return "Show extension message"; return normalized.length > 140 ? `${normalized.slice(0, 140)}...` : normalized; } function getToolPreview(block: ToolCallContent): string { const input = block.input; if (!input || typeof input !== "object") return ""; const keys = Object.keys(input); if (keys.length === 0) return ""; // Common tool input patterns if ("command" in input) return String(input.command).slice(0, 120); if ("path" in input) return String(input.path).slice(0, 120); if ("file_path" in input) return String(input.file_path).slice(0, 120); if ("pattern" in input) return String(input.pattern).slice(0, 120); if ("query" in input) return String(input.query).slice(0, 120); const first = input[keys[0]]; return String(first).slice(0, 120); } function formatUsage(usage: { input: number; output: number; cacheRead: number; cacheWrite: number; cost: { total: number }; }): string { const parts = []; if (usage.input) parts.push(`${usage.input.toLocaleString()} in`); if (usage.output) parts.push(`${usage.output.toLocaleString()} out`); if (usage.cacheRead) parts.push(`${usage.cacheRead.toLocaleString()} cache R`); if (usage.cacheWrite) parts.push(`${usage.cacheWrite.toLocaleString()} cache W`); if (usage.cost?.total) parts.push(`$${usage.cost.total.toFixed(4)}`); return parts.join(" · "); } function BashExecutionView({ message, sessionId }: { message: BashExecutionMessage; sessionId?: string }) { const [fullOutput, setFullOutput] = useState(null); const [loadingFull, setLoadingFull] = useState(false); const [fullError, setFullError] = useState(null); const isPending = !message.output && message.exitCode === undefined && !message.cancelled; const isError = message.cancelled || (message.exitCode !== undefined && message.exitCode !== 0); const fullOutputUrl = sessionId && message.fullOutputPath ? `/api/agent/${encodeURIComponent(sessionId)}/bash-output?path=${encodeURIComponent(message.fullOutputPath)}` : null; const showFullButton = message.truncated && fullOutputUrl && fullOutput === null; const displayOutput = fullOutput ?? message.output; async function loadFullOutput() { if (!fullOutputUrl) return; setLoadingFull(true); setFullError(null); try { const res = await fetch(fullOutputUrl); const d = await res.json() as { success?: boolean; data?: { output?: string }; error?: string }; if (d.success) { setFullOutput(d.data?.output ?? ""); } else { setFullError(d.error ?? "failed"); } } catch (e) { setFullError(String(e)); } finally { setLoadingFull(false); } } // Reuse the existing ToolCallBlock so user-run bash looks identical to an // agent-run bash tool call: same header, collapse behavior, result pane. // Synthesize an equivalent ToolCallContent + ToolResultMessage pair. const toolName = message.excludeFromContext ? "bash (local)" : "bash"; const block: ToolCallContent = { type: "toolCall", toolCallId: `bash-${message.timestamp ?? ""}`, toolName, input: { command: message.command }, }; const result: ToolResultMessage | undefined = isPending ? undefined : { role: "toolResult", toolCallId: block.toolCallId, toolName, content: displayOutput ? [{ type: "text", text: displayOutput }] : [], isError, timestamp: message.timestamp, }; return (
{message.truncated && fullOutputUrl && (
{showFullButton && ( )} download full output {fullError && ({fullError})}
)}
); }