import { memo, useMemo, useState, type ReactNode } from "react" import type { AdaptedContentPart } from "@/lib/adapters/ai-elements-adapter" import type { MessageRole } from "@/lib/types" import { normalizeToolName } from "@/lib/tool-call-normalization" import { useTranslations } from "next-intl" import { countUnifiedDiffLineChanges, estimateChangedLineStats, } from "@/lib/line-change-stats" import { MessageResponse } from "@/components/ai-elements/message" import { Tool, ToolHeader, ToolContent, ToolOutput, } from "@/components/ai-elements/tool" import { Terminal } from "@/components/ai-elements/terminal" import { CodeBlock } from "@/components/ai-elements/code-block" import { UnifiedDiffPreview } from "@/components/diff/unified-diff-preview" import { generateUnifiedDiff } from "@/lib/unified-diff-generator" import { Reasoning, ReasoningTrigger, ReasoningContent, } from "@/components/ai-elements/reasoning" import { FileTextIcon, FilePenLineIcon, FilePlusIcon, TerminalIcon, SearchIcon, GlobeIcon, ListTodoIcon, SparklesIcon, CircleIcon, CircleDotIcon, CircleCheckIcon, CompassIcon, MapIcon, MinusIcon, PlusIcon, WrenchIcon, } from "lucide-react" // ── helpers ──────────────────────────────────────────────────────────── /** Try JSON.parse; return null on failure. */ function tryParseJson(s: string): Record | null { try { const v = JSON.parse(s) return typeof v === "object" && v !== null && !Array.isArray(v) ? v : null } catch { return null } } /** Regex-extract a JSON string value for a given key (works on truncated JSON). */ function extractJsonField(input: string, key: string): string | null { const re = new RegExp(`"${key}"\\s*:\\s*"((?:[^"\\\\]|\\\\.)*)"`) const m = input.match(re) return m?.[1]?.replace(/\\"/g, '"').replace(/\\\\/g, "\\") ?? null } function asObjectLike(value: unknown): Record | null { if (value && typeof value === "object" && !Array.isArray(value)) { return value as Record } if (typeof value !== "string") return null const trimmed = value.trim() if (!trimmed.startsWith("{")) return null return tryParseJson(trimmed) } const NESTED_PAYLOAD_KEYS = ["input", "arguments", "params", "payload"] function findStringFieldDeep( value: unknown, key: string, depth: number = 0 ): string | null { if (depth > 4) return null if (Array.isArray(value)) { for (const item of value) { const found = findStringFieldDeep(item, key, depth + 1) if (found) return found } return null } const obj = asObjectLike(value) if (!obj) return null const direct = obj[key] if (typeof direct === "string" && direct.trim().length > 0) { return direct } for (const nestedKey of NESTED_PAYLOAD_KEYS) { const found = findStringFieldDeep(obj[nestedKey], key, depth + 1) if (found) return found } for (const nestedValue of Object.values(obj)) { const found = findStringFieldDeep(nestedValue, key, depth + 1) if (found) return found } return null } function findObjectFieldDeep( value: unknown, key: string, depth: number = 0 ): Record | null { if (depth > 4) return null if (Array.isArray(value)) { for (const item of value) { const found = findObjectFieldDeep(item, key, depth + 1) if (found) return found } return null } const obj = asObjectLike(value) if (!obj) return null const direct = asObjectLike(obj[key]) if (direct) return direct for (const nestedKey of NESTED_PAYLOAD_KEYS) { const found = findObjectFieldDeep(obj[nestedKey], key, depth + 1) if (found) return found } for (const nestedValue of Object.values(obj)) { const found = findObjectFieldDeep(nestedValue, key, depth + 1) if (found) return found } return null } function decodeJsonEscapedString(value: string): string { return value.replace(/\\"/g, '"').replace(/\\\//g, "/").replace(/\\\\/g, "\\") } function extractEditPathsFromChangesPayload( input: string, parsed: Record | null ): string[] { const changes = findObjectFieldDeep(parsed, "changes") if (changes) { const paths = Object.keys(changes) .map((path) => path.trim()) .filter((path) => path.length > 0) if (paths.length > 0) return paths } const firstPathMatch = input.match(/"changes"\s*:\s*\{\s*"((?:[^"\\]|\\.)+)"/) if (!firstPathMatch?.[1]) return [] return [decodeJsonEscapedString(firstPathMatch[1])] } function extractPathFromDiffText( text: string | null | undefined ): string | null { if (!text) return null const match = text.match(/^(?:---|\+\+\+)\s+([^\n]+)$/m) if (!match?.[1]) return null const raw = match[1].trim() if (!raw || raw === "/dev/null") return null return raw.replace(/^[ab]\//, "") } function isLikelyIdField(key: string): boolean { const lower = key.toLowerCase() return ( lower === "id" || lower === "uuid" || lower === "callid" || lower === "call_id" || lower === "tool_call_id" || lower.endsWith("_id") || lower.endsWith("id") ) } /** Shorten an absolute path to its last 2 segments. */ function shortPath(p: string): string { return p.split("/").slice(-2).join("/") } /** Truncate text to maxLen, appending "…" if truncated. */ function ellipsis(s: string, maxLen: number): string { return s.length > maxLen ? s.slice(0, maxLen - 1) + "…" : s } function unwrapQuotedCommand(command: string): string { const trimmed = command.trim() if (trimmed.length < 2) return trimmed if (trimmed.startsWith("'") && trimmed.endsWith("'")) { return trimmed.slice(1, -1) } if (trimmed.startsWith('"') && trimmed.endsWith('"')) { return trimmed .slice(1, -1) .replace(/\\"/g, '"') .replace(/\\n/g, "\n") .replace(/\\\\/g, "\\") } return trimmed } function simplifyShellCommand(command: string): string { let current = command.trim() const wrapperRe = /^(?:\/usr\/bin\/env\s+)?(?:(?:\/[^\s]+\/)?(?:bash|zsh|sh))\s+-(?:l?c)\s+(.+)$/i // Strip nested shell wrappers like "/bin/zsh -lc bash -lc ''". for (let i = 0; i < 6; i += 1) { const wrapped = current.match(wrapperRe) if (!wrapped) break const next = unwrapQuotedCommand(wrapped[1] ?? "").trim() if (!next || next === current) break current = next } return current } function extractDisplayCommandFromToolInput( input: string | null | undefined ): string | null { if (!input) return null const parsed = tryParseJson(input) const command = (parsed ? commandFromUnknownValue(parsed) : null) ?? extractCommandFromUnknownInput(input) if (!command) return null const simplified = simplifyShellCommand(command).trim() return simplified.length > 0 ? simplified : null } function formatCommandPrompt(command: string): string { return command .split("\n") .map((line, index) => `${index === 0 ? "$" : ">"} ${line}`) .join("\n") } function buildCommandTerminalOutput( command: string | null, output: string | null, isStreaming: boolean = false ): string { if (!command) return output ?? "" const prompt = formatCommandPrompt(command) const terminalOutput = output ?? "" const withTrailingNewline = (text: string): string => text.endsWith("\n") ? text : `${text}\n` if (!terminalOutput) { return isStreaming ? withTrailingNewline(prompt) : prompt } const lines = terminalOutput.split("\n") const firstNonEmptyLine = lines.find((line) => line.trim().length > 0) const commandFirstLine = command.split("\n")[0]?.trim() ?? "" if (firstNonEmptyLine) { const trimmedLine = firstNonEmptyLine.trim() const lineWithoutPrompt = trimmedLine.replace(/^\$\s*/, "") if ( trimmedLine === commandFirstLine || lineWithoutPrompt === commandFirstLine ) { if (isStreaming && !terminalOutput.includes("\n")) { return withTrailingNewline(terminalOutput) } return terminalOutput } } return `${prompt}\n${terminalOutput}` } function extractCommandFromUnknownInput(input: string): string | null { const trimmed = input.trim() if (!trimmed) return null try { const parsed: unknown = JSON.parse(trimmed) if (typeof parsed === "string") { return parsed } if (Array.isArray(parsed)) { const parts = parsed.filter((p): p is string => typeof p === "string") if (parts.length > 0) return parts.join(" ") } if (parsed && typeof parsed === "object") { const obj = parsed as Record const direct = obj.command ?? obj.cmd ?? obj.script if (typeof direct === "string") { return direct } if (Array.isArray(direct)) { const parts = direct.filter((p): p is string => typeof p === "string") if (parts.length > 0) return parts.join(" ") } } } catch { // Non-JSON command text is handled below. } if (trimmed.startsWith("{") || trimmed.startsWith("[")) { return null } return trimmed } function commandFromUnknownValue(value: unknown): string | null { if (typeof value === "string") { const trimmed = value.trim() return trimmed.length > 0 ? trimmed : null } if (Array.isArray(value)) { const parts = value .map((item) => (typeof item === "string" ? item : null)) .filter((item): item is string => item !== null && item.length > 0) if (parts.length > 0) { return parts.join(" ") } return null } if (!value || typeof value !== "object") { return null } const obj = value as Record const directKeys = [ "command", "cmd", "script", "args", "argv", "command_args", ] for (const key of directKeys) { const found = commandFromUnknownValue(obj[key]) if (found) return found } const nestedKeys = ["input", "arguments", "params", "payload"] for (const key of nestedKeys) { const found = commandFromUnknownValue(obj[key]) if (found) return found } return null } /** Get string field from parsed object */ function str(obj: Record, key: string): string | undefined { const v = obj[key] return typeof v === "string" ? v : undefined } /** Get number field from parsed object */ function num(obj: Record, key: string): number | undefined { const v = obj[key] return typeof v === "number" ? v : undefined } type ApplyPatchOp = "add" | "update" | "delete" | "move" type ApplyPatchFile = { op: ApplyPatchOp path: string from?: string to?: string } type LineChangeStats = { additions: number deletions: number } function parseApplyPatchInput(input: string): { files: ApplyPatchFile[] additions: number deletions: number } { const files: ApplyPatchFile[] = [] let currentFileIndex = -1 let additions = 0 let deletions = 0 for (const line of input.split("\n")) { if (line.startsWith("*** Add File: ")) { files.push({ op: "add", path: line.slice(14).trim() }) currentFileIndex = files.length - 1 continue } if (line.startsWith("*** Update File: ")) { files.push({ op: "update", path: line.slice(17).trim() }) currentFileIndex = files.length - 1 continue } if (line.startsWith("*** Delete File: ")) { files.push({ op: "delete", path: line.slice(17).trim() }) currentFileIndex = files.length - 1 continue } if (line.startsWith("*** Move to: ")) { const to = line.slice(13).trim() if (currentFileIndex >= 0) { const current = files[currentFileIndex] files[currentFileIndex] = { op: "move", path: `${current.path} -> ${to}`, from: current.path, to, } } continue } if (line.startsWith("+") && !line.startsWith("+++")) { additions += 1 continue } if (line.startsWith("-") && !line.startsWith("---")) { deletions += 1 } } return { files, additions, deletions } } function hasLineChanges( stats: LineChangeStats | null | undefined ): stats is LineChangeStats { return !!stats && (stats.additions > 0 || stats.deletions > 0) } function looksLikeDiffPayload(input: string): boolean { if (!input.trim()) return false const normalized = unescapeInlineEscapes(input) return ( normalized.includes("*** Begin Patch") || normalized.includes("*** Update File:") || /^diff --git /m.test(normalized) || (/^--- .+/m.test(normalized) && /^\+\+\+ .+/m.test(normalized)) || /^@@ /m.test(normalized) ) } function extractEditLineChangeStats( input: string | null | undefined ): LineChangeStats | null { if (!input || input.trim().length === 0) return null const parsed = tryParseJson(input) const patchInput = extractApplyPatchTextFromUnknownInput(input, parsed) if (patchInput) { const patchStats = parseApplyPatchInput(patchInput) const stats = { additions: patchStats.additions, deletions: patchStats.deletions, } if (hasLineChanges(stats)) return stats } if (parsed) { const changesPayload = extractEditChangesPayload(parsed) if (changesPayload.length > 0) { let additions = 0 let deletions = 0 for (const change of changesPayload) { if (change.unifiedDiff && change.unifiedDiff.trim().length > 0) { const diffStats = countUnifiedDiffLineChanges(change.unifiedDiff) additions += diffStats.additions deletions += diffStats.deletions continue } const estimated = estimateChangedLineStats( change.oldText, change.newText ) additions += estimated.additions deletions += estimated.deletions } const stats = { additions, deletions } if (hasLineChanges(stats)) return stats } if (isCanonicalEditPayload(parsed)) { const oldString = str(parsed, "old_string") ?? str(parsed, "old_text") ?? "" const newString = str(parsed, "new_string") ?? str(parsed, "new_text") ?? "" const stats = estimateChangedLineStats(oldString, newString) if (hasLineChanges(stats)) return stats } const parsedDiff = findStringFieldDeep(parsed, "unified_diff") ?? findStringFieldDeep(parsed, "unifiedDiff") ?? findStringFieldDeep(parsed, "patch") ?? findStringFieldDeep(parsed, "diff") if (parsedDiff && looksLikeDiffPayload(parsedDiff)) { const stats = countUnifiedDiffLineChanges( unescapeInlineEscapes(parsedDiff) ) if (hasLineChanges(stats)) return stats } } if (looksLikeDiffPayload(input)) { const stats = countUnifiedDiffLineChanges(unescapeInlineEscapes(input)) if (hasLineChanges(stats)) return stats } return null } function unescapeInlineEscapes(text: string): string { return text .replace(/\\r\\n/g, "\n") .replace(/\\n/g, "\n") .replace(/\\t/g, "\t") } function extractApplyPatchTextFromUnknownInput( input: string, parsed: Record | null ): string | null { const candidates: string[] = [input] const parsedCommand = parsed ? commandFromUnknownValue(parsed) : null if (parsedCommand) candidates.push(parsedCommand) const fallbackCommand = extractCommandFromUnknownInput(input) if (fallbackCommand) candidates.push(fallbackCommand) const seen = new Set() for (const rawCandidate of candidates) { const candidate = rawCandidate.trim() if (!candidate || seen.has(candidate)) continue seen.add(candidate) const variants = [candidate] const unescaped = unescapeInlineEscapes(candidate) if (unescaped !== candidate) variants.push(unescaped) for (const variant of variants) { if (!variant.includes("*** Begin Patch")) continue const block = variant.match( /(\*\*\* Begin Patch[\s\S]*?\*\*\* End Patch(?:\n|$))/m )?.[1] if (block) return block.trim() return variant.trim() } } return null } function parseApplyPatchFilesFromUnknownInput( input: string, parsed: Record | null ): ApplyPatchFile[] { const patchText = extractApplyPatchTextFromUnknownInput(input, parsed) if (patchText) { const fromPatchText = parseApplyPatchInput(patchText) if (fromPatchText.files.length > 0) return fromPatchText.files } const direct = parseApplyPatchInput(input) if (direct.files.length > 0) return direct.files const unescaped = unescapeInlineEscapes(input) if (unescaped !== input) { const normalized = parseApplyPatchInput(unescaped) if (normalized.files.length > 0) return normalized.files } return [] } function isCanonicalEditPayload(parsed: Record): boolean { return ( typeof parsed.file_path === "string" || typeof parsed.path === "string" || typeof parsed.old_string === "string" || typeof parsed.new_string === "string" || parsed.replace_all === true ) } type EditChangePreview = { path: string oldText: string newText: string unifiedDiff?: string } const EDIT_CHANGE_OLD_KEYS = [ "old_string", "oldString", "old_text", "oldText", "old", "previous", "before", "source", "original", ] const EDIT_CHANGE_NEW_KEYS = [ "new_string", "newString", "new_text", "newText", "new_content", "newContent", "new", "new_value", "newValue", "replacement", "after", "after_text", "afterText", "updated", "updated_text", "updatedText", "content", "new_source", "newSource", "text", ] const EDIT_CHANGE_DIFF_KEYS = ["diff", "patch", "unified_diff", "unifiedDiff"] function collectLikelyChangeStrings(value: Record): string[] { const entries = Object.entries(value).filter( ([, v]) => typeof v === "string" && v.length > 0 ) as Array<[string, string]> if (entries.length === 0) return [] const preferred = entries .filter(([key]) => /(old|new|before|after|content|text|source|replace|value)/i.test(key) ) .map(([, v]) => v) if (preferred.length > 0) return preferred return entries .filter( ([key]) => !/^(id|status|type|call_id|callId|source|auto_approved)$/i.test(key) ) .map(([, v]) => v) } function firstStringField( value: Record, keys: string[] ): string | null { for (const key of keys) { const field = value[key] if (typeof field === "string") { return field } } return null } function parseEditChangeValue( path: string, value: unknown ): EditChangePreview | null { if (typeof value === "string") { return { path, oldText: "", newText: value, } } const record = asObjectLike(value) if (!record) return null const oldText = firstStringField(record, EDIT_CHANGE_OLD_KEYS) ?? findStringFieldDeep(record, "old_string") ?? findStringFieldDeep(record, "old_text") ?? findStringFieldDeep(record, "before_text") ?? findStringFieldDeep(record, "old") ?? "" const newText = firstStringField(record, EDIT_CHANGE_NEW_KEYS) ?? findStringFieldDeep(record, "new_string") ?? findStringFieldDeep(record, "new_text") ?? findStringFieldDeep(record, "after_text") ?? findStringFieldDeep(record, "new") ?? "" const unifiedDiff = firstStringField(record, EDIT_CHANGE_DIFF_KEYS) ?? findStringFieldDeep(record, "diff") ?? "" if (unifiedDiff) { return { path, oldText, newText, unifiedDiff, } } if (oldText || newText) { return { path, oldText, newText, } } const fallbackStrings = collectLikelyChangeStrings(record) if (fallbackStrings.length >= 2) { return { path, oldText: fallbackStrings[0], newText: fallbackStrings[1], } } if (fallbackStrings.length === 1) { return { path, oldText: "", newText: fallbackStrings[0], } } return { path, oldText: "", newText: "", } } function extractEditChangesPayload( parsed: Record ): EditChangePreview[] { const changes = findObjectFieldDeep(parsed, "changes") if (!changes) return [] const items: EditChangePreview[] = [] for (const [path, value] of Object.entries(changes)) { const normalizedPath = path.trim() if (!normalizedPath) continue const parsedItem = parseEditChangeValue(normalizedPath, value) if (parsedItem) { items.push(parsedItem) } } return items } // ── tool icon mapping ──────────────────────────────────────────────── const ICON_CLASS = "size-4 text-muted-foreground" function getTaskToolIcon(input: string | null): ReactNode { if (!input) return const t = extractJsonField(input, "subagent_type")?.toLowerCase() if (!t) return if (t.includes("explore")) return if (t.includes("plan")) return if (t.includes("bash")) return return } function getToolIcon( toolName: string, input?: string | null ): ReactNode | undefined { const name = toolName.toLowerCase() if (name === "read" || name === "read file") return if (name === "edit") return if (name === "write" || name === "notebookedit") return if (name === "bash" || name === "exec_command") return if (name === "apply_patch") return if (name === "glob" || name === "grep") return if (name === "webfetch" || name === "websearch") return if (name === "todowrite") return if (name === "task") return getTaskToolIcon(input ?? null) if (name === "taskcreate" || name === "taskupdate" || name === "tasklist") return if (name === "agent") return getTaskToolIcon(input ?? null) if (name === "skill") return if ( name === "enterplanmode" || name === "exitplanmode" || name === "switch_mode" ) return if (name === "attempt_completion") return return undefined } // ── title derivation ────────────────────────────────────────────────── function deriveToolTitle( toolName: string, input: string | null, output?: string | null ): string | null { const name = toolName.toLowerCase() const titleSource = input ?? output ?? null if (!titleSource) return null const parsedInput = input ? tryParseJson(input) : null const parsedOutput = output ? tryParseJson(output) : null const parsed = parsedInput ?? parsedOutput const getField = (key: string): string | null => { const nested = findStringFieldDeep(parsed, key) if (nested) return nested if (input) { const fromInput = extractJsonField(input, key) if (fromInput) return fromInput } if (output) { const fromOutput = extractJsonField(output, key) if (fromOutput) return fromOutput } return null } // Cline: attempt_completion — show result summary as title if (name === "attempt_completion") { const result = getField("result") if (result) { const firstLine = result.split("\n")[0].trim() return `${ellipsis(firstLine, 80)}` } return "Completion" } // File-based tools const filePath = getField("file_path") ?? getField("filePath") ?? getField("target_file") ?? getField("targetFile") ?? getField("filename") ?? getField("path") ?? getField("notebook_path") if (filePath) { const sp = shortPath(filePath) if (name === "read" || name === "read file") return `Read ${sp}` if (name === "edit") return `Edit ${sp}` if (name === "write") return `Write ${sp}` if (name === "notebookedit") return `NotebookEdit ${sp}` } // Command tools if (name === "bash" || name === "exec_command") { const description = getField("description") if (description) { return ellipsis(description, 80) } const direct = getField("command") ?? getField("cmd") ?? getField("script") const parsedCommand = commandFromUnknownValue(parsed) const fallback = extractCommandFromUnknownInput(titleSource) const command = direct ?? parsedCommand ?? fallback if (command) { return ellipsis(simplifyShellCommand(command).split("\n")[0], 80) } return null } if (name === "apply_patch") { const files = parseApplyPatchFilesFromUnknownInput(titleSource, parsed) if (files.length === 0) return "Edit" if (files.length === 1) { const file = files[0] const targetPath = file.op === "move" && file.to ? file.to : (file.from ?? file.to ?? file.path) return `Edit ${shortPath(targetPath)}` } return `Edit (${files.length} files)` } if (name === "edit") { const patchFiles = parseApplyPatchFilesFromUnknownInput(titleSource, parsed) if (patchFiles.length === 1) { const file = patchFiles[0] const targetPath = file.op === "move" && file.to ? file.to : (file.from ?? file.to ?? file.path) return `Edit ${shortPath(targetPath)}` } if (patchFiles.length > 1) return `Edit (${patchFiles.length} files)` const changedPaths = extractEditPathsFromChangesPayload(titleSource, parsed) if (changedPaths.length === 1) return `Edit ${shortPath(changedPaths[0])}` if (changedPaths.length > 1) return `Edit (${changedPaths.length} files)` const diffPath = extractPathFromDiffText(output) if (diffPath) return `Edit ${shortPath(diffPath)}` return "Edit" } // Command-like fallback: if input looks like a shell command payload, // keep title behavior consistent with historical command tool rendering. const commandLike = (parsed ? commandFromUnknownValue(parsed) : null) ?? extractCommandFromUnknownInput(titleSource) if (commandLike && commandLike.trim().length > 0) { return ellipsis(simplifyShellCommand(commandLike).split("\n")[0], 80) } // Search tools if (name === "glob") { const p = getField("pattern") if (p) return `Glob ${p}` } if (name === "grep") { const p = getField("pattern") if (p) return `Grep ${ellipsis(p, 50)}` } // Task / agent tools if (name === "task") { const subagent = getField("subagent_type") const desc = getField("description") const prefix = subagent ? `${subagent}: ` : "" if (desc) return `${prefix}${ellipsis(desc, 60 - prefix.length)}` if (subagent) return subagent } if (name === "agent") { const subagent = getField("subagent_type") const desc = getField("description") const prefix = subagent ? `${subagent}: ` : "" if (desc) return `${prefix}${ellipsis(desc, 60 - prefix.length)}` if (subagent) return subagent } if (name === "taskcreate") { const subj = getField("subject") if (subj) return `TaskCreate: ${ellipsis(subj, 50)}` } if (name === "taskupdate") { const id = getField("taskId") const status = getField("status") if (id) return `TaskUpdate #${id}${status ? ` → ${status}` : ""}` } // Web tools if (name === "webfetch") { const url = getField("url") if (url) return `WebFetch ${ellipsis(url, 60)}` } if (name === "websearch") { const q = getField("query") if (q) return `WebSearch: ${ellipsis(q, 50)}` } // TodoWrite if (name === "todowrite") { if (parsed) { const todos = parsed.todos if (Array.isArray(todos)) { const count = todos.length const done = todos.filter( (t: Record) => t.status === "completed" ).length return `Todos (${done}/${count})` } } return "TodoWrite" } // Skill if (name === "skill") { const sk = getField("skill") if (sk) return `Skill: ${sk}` } // EnterPlanMode / ExitPlanMode / SwitchMode if ( name === "enterplanmode" || name === "exitplanmode" || name === "switch_mode" ) { const plan = getField("plan") if (plan) { const firstLine = plan .split("\n") .map((l) => l.replace(/^#+\s*/, "").trim()) .find((l) => l.length > 0) if (firstLine) return `Plan · ${ellipsis(firstLine, 60)}` } const title = getField("title") if (title) return `Plan · ${title}` return "Plan" } // Generic: try to show the first string field as context if (parsed) { for (const [k, v] of Object.entries(parsed)) { if (isLikelyIdField(k)) { continue } if (typeof v === "string" && v.length > 0) { return `${toolName}: ${ellipsis(v, 50)}` } } } return null } function sanitizeLiveTitle(title: string | null | undefined): string | null { const trimmed = title?.trim() if (!trimmed) return null const callIdTitle = trimmed.match( /^[::'"`“”‘’\s]*([a-z0-9_.-]+)(?:\s*[::])?\s*call[\w-]*['"`“”‘’\s]*$/i ) const source = callIdTitle?.[1] ?? trimmed const normalized = normalizeToolName(source) if (normalized === "apply_patch" || normalized === "edit") { return "Edit" } if ( /\b(?:functions\.)?(?:edit|apply[_\s-]?patch)\b/i.test(trimmed) && /\bcall[\w-]*\b/i.test(trimmed) ) { return "Edit" } if (normalized === "bash" || normalized === "exec_command") { return "Command" } return trimmed } function localizeDerivedToolTitle( title: string | null, t: (key: string, values?: Record) => string ): string | null { if (!title) return null if (title === "Edit") return t("title.edit") if (title === "Command") return t("title.command") if (title === "TodoWrite") return t("title.todoWrite") if (title === "Read") return t("title.read") if (title === "Write") return t("title.write") if (title === "NotebookEdit") return t("title.notebookEdit") const editFilesMatch = title.match(/^Edit \((\d+) files\)$/) if (editFilesMatch) { return t("title.editFiles", { count: Number(editFilesMatch[1]) }) } const editWithTarget = title.match(/^Edit (.+)$/) if (editWithTarget) { return t("title.editWithTarget", { target: editWithTarget[1] }) } const readWithTarget = title.match(/^Read (.+)$/) if (readWithTarget) { return t("title.readWithTarget", { target: readWithTarget[1] }) } const writeWithTarget = title.match(/^Write (.+)$/) if (writeWithTarget) { return t("title.writeWithTarget", { target: writeWithTarget[1] }) } const notebookEditWithTarget = title.match(/^NotebookEdit (.+)$/) if (notebookEditWithTarget) { return t("title.notebookEditWithTarget", { target: notebookEditWithTarget[1], }) } const globWithPattern = title.match(/^Glob (.+)$/) if (globWithPattern) { return t("title.globWithPattern", { pattern: globWithPattern[1] }) } const grepWithPattern = title.match(/^Grep (.+)$/) if (grepWithPattern) { return t("title.grepWithPattern", { pattern: grepWithPattern[1] }) } const taskCreateWithSubject = title.match(/^TaskCreate: (.+)$/) if (taskCreateWithSubject) { return t("title.taskCreateWithSubject", { subject: taskCreateWithSubject[1], }) } const taskUpdateWithStatus = title.match(/^TaskUpdate #([^ ]+)(?: → (.+))?$/) if (taskUpdateWithStatus) { const id = taskUpdateWithStatus[1] const status = taskUpdateWithStatus[2] if (status) { return t("title.taskUpdateWithStatus", { id, status }) } return t("title.taskUpdate", { id }) } const webFetchWithUrl = title.match(/^WebFetch (.+)$/) if (webFetchWithUrl) { return t("title.webFetchWithUrl", { url: webFetchWithUrl[1] }) } const webSearchWithQuery = title.match(/^WebSearch: (.+)$/) if (webSearchWithQuery) { return t("title.webSearchWithQuery", { query: webSearchWithQuery[1] }) } const todosProgress = title.match(/^Todos \((\d+)\/(\d+)\)$/) if (todosProgress) { return t("title.todosProgress", { done: Number(todosProgress[1]), total: Number(todosProgress[2]), }) } const skillWithName = title.match(/^Skill: (.+)$/) if (skillWithName) { return t("title.skillWithName", { name: skillWithName[1] }) } const genericWithContext = title.match(/^([^:]+): (.+)$/) if (genericWithContext) { return t("title.genericWithContext", { tool: genericWithContext[1], context: genericWithContext[2], }) } return title } // ── Specialized tool input renderers ───────────────────────────────── /** Edit tool: file path + unified diff view */ function EditToolInput({ input }: { input: Record }) { const filePath = str(input, "file_path") const oldString = str(input, "old_string") ?? "" const newString = str(input, "new_string") ?? "" const startLine = num(input, "_start_line") const diffCode = useMemo(() => { const diff = generateUnifiedDiff( oldString, newString, filePath ?? undefined ) if (!diff || !startLine || startLine <= 1) return diff ?? "" // Replace line numbers in hunk headers with real start line return diff.replace( /^@@ -(\d+),(\d+) \+(\d+),(\d+) @@/gm, (_, _o, oc, _n, nc) => `@@ -${startLine},${oc} +${startLine},${nc} @@` ) }, [oldString, newString, filePath, startLine]) return diffCode ? : null } /** Edit tool (changes payload): combined diff view */ function EditChangesToolInput({ changes }: { changes: EditChangePreview[] }) { const diffCode = useMemo(() => { const diffParts: string[] = [] for (const change of changes) { if (change.unifiedDiff && change.unifiedDiff.trim().length > 0) { diffParts.push(change.unifiedDiff.trim()) diffParts.push("") continue } const generated = generateUnifiedDiff( change.oldText, change.newText, change.path ) if (generated) { diffParts.push(generated) diffParts.push("") } } return diffParts.join("\n").trim() }, [changes]) return diffCode ? : null } /** Bash / exec_command: terminal-style command display */ function BashToolInput({ input }: { input: Record }) { const t = useTranslations("Folder.chat.contentParts") const command = commandFromUnknownValue(input) ?? str(input, "command") ?? str(input, "cmd") ?? str(input, "script") const description = str(input, "description") const timeout = num(input, "timeout") const background = input.run_in_background === true const displayCommand = command ? simplifyShellCommand(command) : null return (
{description && (
{description}
)} {displayCommand && } {(timeout || background) && (
{timeout && {t("timeoutMs", { timeout })}} {background && {t("backgroundTrue")}}
)}
) } /** * Parse structured read output from backend: `{"start_line":N,"content":"..."}`. * Falls back to raw text with startLine=1 if not structured. */ function parseReadOutput(raw: string): { startLine: number; content: string } { try { const parsed = JSON.parse(raw) if ( typeof parsed === "object" && parsed !== null && typeof parsed.start_line === "number" && typeof parsed.content === "string" ) { return { startLine: parsed.start_line, content: parsed.content } } } catch { // not JSON } return { startLine: 1, content: raw } } /** Lightweight file content viewer with line numbers */ function FileContentLines({ content, startLine = 1, }: { content: string startLine?: number }) { const lines = useMemo(() => content.split("\n"), [content]) return (
{lines.map((line, i) => (
{startLine + i} {line}
))}
) } /** Read / Write / NotebookEdit: file-focused display */ function FileToolInput({ toolName, input, output, }: { toolName: string input: Record output?: string | null }) { const t = useTranslations("Folder.chat.contentParts") const name = toolName.toLowerCase() const filePath = str(input, "file_path") ?? str(input, "path") ?? str(input, "notebook_path") const content = str(input, "content") const newSource = str(input, "new_source") const offset = num(input, "offset") const limit = num(input, "limit") const pages = str(input, "pages") const cellType = str(input, "cell_type") const editMode = str(input, "edit_mode") const isRead = name === "read" || name === "read file" const badges: string[] = [] if (offset != null) badges.push(t("offset", { offset })) if (limit != null) badges.push(t("limit", { limit })) if (pages) badges.push(t("pages", { pages })) if (editMode) badges.push(t("mode", { mode: editMode })) if (cellType) badges.push(t("cell", { cell: cellType })) const { displayContent, startLine } = useMemo(() => { if (isRead && output) { const parsed = parseReadOutput(output) return { displayContent: parsed.content, startLine: parsed.startLine } } return { displayContent: content ?? newSource ?? null, startLine: 1, } }, [isRead, output, content, newSource]) return (
{isRead ? "READ" : "WRITE"} {filePath ?? t("unknown")} {badges.length > 0 && ( {badges.map((b) => ( {b} ))} )}
{displayContent && (
)}
) } /** Glob / Grep: search-focused display */ function SearchToolInput({ toolName, input, }: { toolName: string input: Record }) { const t = useTranslations("Folder.chat.contentParts") const name = toolName.toLowerCase() const pattern = str(input, "pattern") const path = str(input, "path") const glob = str(input, "glob") const outputMode = str(input, "output_mode") const fileType = str(input, "type") const caseInsensitive = input["-i"] === true const multiline = input.multiline === true return (
{pattern && (
{pattern}
)}
{path && ( {t("pathLabel")}{" "} {path} )} {glob && ( {t("globLabel")}{" "} {glob} )} {fileType && ( {t("typeLabel")}{" "} {fileType} )} {name === "grep" && outputMode && ( {t("outputLabel")}{" "} {outputMode} )} {caseInsensitive && {t("caseInsensitive")}} {multiline && {t("multiline")}}
) } /** Web tools: URL / query focused */ function WebToolInput({ toolName, input, }: { toolName: string input: Record }) { const t = useTranslations("Folder.chat.contentParts") const name = toolName.toLowerCase() const url = str(input, "url") const query = str(input, "query") const prompt = str(input, "prompt") return (
{name === "websearch" && query && (
{query}
)} {name === "webfetch" && url && (
{url}
)} {prompt && (
{t("promptLabel")}
{prompt}
)}
) } /** Task tools: description / subject focused */ function TaskToolInput({ input }: { input: Record }) { const t = useTranslations("Folder.chat.contentParts") const subject = str(input, "subject") const taskId = str(input, "taskId") const status = str(input, "status") const agentName = str(input, "name") const hasFields = subject || taskId || agentName if (!hasFields) return null return (
{subject && (
{t("subjectLabel")} {subject}
)} {taskId && (
{t("taskLabel")} #{taskId} {status ? ` → ${status}` : ""}
)} {agentName && (
{t("nameLabel")}{" "} {agentName}
)}
) } /** TodoWrite: checklist-style display */ function TodoWriteToolInput({ input }: { input: Record }) { const todos = Array.isArray(input.todos) ? input.todos : [] if (todos.length === 0) return null const statusIcon = (status: string) => { if (status === "completed") return if (status === "in_progress") return return } const priorityBadge = (priority: string) => { const colors: Record = { high: "bg-red-500/15 text-red-500", medium: "bg-yellow-500/15 text-yellow-600", low: "bg-muted text-muted-foreground", } return ( {priority} ) } return (
{todos.map((todo: Record, i: number) => { const id = str(todo, "id") ?? String(i + 1) const content = str(todo, "content") ?? "" const status = str(todo, "status") ?? "pending" const priority = str(todo, "priority") return (
{statusIcon(status)} {content} {priority && priorityBadge(priority)}
) })}
) } function ApplyPatchToolInput({ input }: { input: string }) { return } // ── Switch mode (plan) input ────────────────────────────────────────── function extractPlanMarkdown(input: Record): string | null { const direct = input.plan ?? input.Plan if (typeof direct === "string" && direct.trim().length > 0) return direct const nested = typeof input.rawInput === "object" && input.rawInput !== null ? (input.rawInput as Record) : typeof input.raw_input === "object" && input.raw_input !== null ? (input.raw_input as Record) : null if (nested) { const nestedPlan = nested.plan ?? nested.Plan if (typeof nestedPlan === "string" && nestedPlan.trim().length > 0) { return nestedPlan } } return null } function SwitchModeToolInput({ input }: { input: Record }) { const planMarkdown = extractPlanMarkdown(input) if (!planMarkdown) return null return (
{planMarkdown}
) } // ── Generic structured input (fallback) ────────────────────────────── /** Fields that typically contain code / long text → render in code blocks */ const CODE_FIELDS = new Set([ "command", "old_string", "new_string", "content", "new_source", "prompt", ]) /** Fields to hide */ const HIDDEN_FIELDS = new Set(["dangerouslyDisableSandbox"]) function GenericToolInput({ input }: { input: string }) { const t = useTranslations("Folder.chat.contentParts") const parsed = tryParseJson(input) if (!parsed) { return (
        {input}
      
) } const entries = Object.entries(parsed).filter(([k]) => !HIDDEN_FIELDS.has(k)) if (entries.length === 0) return null return (
{entries.map(([key, value]) => { const labelKey = fieldLabelKey(key) const label = labelKey ? t(labelKey) : key if (CODE_FIELDS.has(key) && typeof value === "string") { const lang = key === "command" ? ("bash" as const) : key === "prompt" ? ("log" as const) : ("log" as const) return ( ) } if (typeof value === "string") { if (value.length > 200) { return (
                  {value}
                
) } return } if (typeof value === "number" || typeof value === "boolean") { return } if (value !== null && value !== undefined) { return ( ) } return null })}
) } // ── Dispatcher ─────────────────────────────────────────────────────── function isTruncatedInput(input: string): boolean { return input.endsWith('..."') || input.endsWith("...") } function StructuredToolInput({ toolName, input, output, }: { toolName: string input: string output?: string | null }) { const t = useTranslations("Folder.chat.contentParts") const name = toolName.toLowerCase() const parsed = tryParseJson(input) const truncated = (name === "edit" || name === "write" || name === "apply_patch") && isTruncatedInput(input) const truncationBanner = truncated ? (
{t("inputTruncated")}
) : null if (name === "apply_patch") { const patchInput = extractApplyPatchTextFromUnknownInput(input, parsed) ?? input return ( <> {truncationBanner} ) } if (name === "bash" || name === "exec_command") { if (parsed) { return } const plainCommand = extractCommandFromUnknownInput(input) if (plainCommand) { return } } if (!parsed) { return (
        {input}
      
) } if (name === "edit") { const patchInput = extractApplyPatchTextFromUnknownInput(input, parsed) if (patchInput) { return ( <> {truncationBanner} ) } if (parsed) { const changesPayload = extractEditChangesPayload(parsed) if (changesPayload.length > 0) { return ( <> {truncationBanner} ) } } // Prefer tool output if it contains a structured diff with real line numbers // (injected by backend from toolUseResult.structuredPatch) if (output && typeof output === "string" && /^@@ /m.test(output)) { return ( <> {truncationBanner} ) } if (isCanonicalEditPayload(parsed)) { return ( <> {truncationBanner} ) } return } if (name === "bash" || name === "exec_command") return if ( name === "read" || name === "read file" || name === "write" || name === "notebookedit" ) return if (name === "glob" || name === "grep") return if (name === "webfetch" || name === "websearch") return if (name === "todowrite") return if ( name === "task" || name === "taskcreate" || name === "taskupdate" || name === "tasklist" ) return if ( name === "switch_mode" || name === "enterplanmode" || name === "exitplanmode" ) { if (extractPlanMarkdown(parsed)) { return } } return } // ── Shared field components ────────────────────────────────────────── function FieldInline({ label, value }: { label: string; value: string }) { return (
{label} {value}
) } function FieldBlock({ label, children, }: { label: string children: React.ReactNode }) { return (
{label}
{children}
) } const FIELD_LABEL_KEYS = { file_path: "field.file", notebook_path: "field.notebook", command: "field.command", cmd: "field.command", old_string: "field.old", new_string: "field.new", pattern: "field.pattern", path: "field.path", query: "field.query", url: "field.url", description: "field.description", content: "field.content", new_source: "field.source", prompt: "field.prompt", subject: "field.subject", taskId: "field.taskId", status: "field.status", skill: "field.skill", args: "field.args", offset: "field.offset", limit: "field.limit", glob: "field.glob", type: "field.type", output_mode: "field.output", replace_all: "field.replaceAll", language: "field.language", timeout: "field.timeout", run_in_background: "field.background", subagent_type: "field.agentType", libraryName: "field.library", libraryId: "field.libraryId", } as const function fieldLabelKey( key: string ): (typeof FIELD_LABEL_KEYS)[keyof typeof FIELD_LABEL_KEYS] | null { const translationKey = FIELD_LABEL_KEYS[key as keyof typeof FIELD_LABEL_KEYS] return translationKey ?? null } function commandOutputFromJsonString(output: string): string | null { try { const parsed: unknown = JSON.parse(output) if (typeof parsed === "string") { return parsed } if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) { return null } const obj = parsed as Record const isCommandEnvelope = "command" in obj || "parsed_cmd" in obj || "cwd" in obj || "exit_code" in obj || "stdout" in obj || "stderr" in obj || "formatted_output" in obj || "aggregated_output" in obj // Prefer raw stdout/stderr when present (more likely to preserve ANSI colors). const stdout = typeof obj.stdout === "string" ? obj.stdout : "" const stderr = typeof obj.stderr === "string" ? obj.stderr : "" if (stdout.length > 0 || stderr.length > 0) { if (stdout.length > 0 && stderr.length > 0) { return `${stdout}\n[stderr]\n${stderr}` } return stdout || stderr } const preferredKeys = [ "formatted_output", "aggregated_output", "output", "text", "result", ] for (const key of preferredKeys) { const value = obj[key] if (typeof value === "string" && value.length > 0) { return value } } // Some command results are metadata-only envelopes (command/cwd/exit_code). // Returning empty string avoids rendering raw JSON as terminal output. if (isCommandEnvelope) { return "" } return null } catch { return null } } function stripMarkdownCodeFence(text: string): string { let result = text // Remove leading fenced-code line like ```sh / ```bash / ``` result = result.replace(/^\s*```[\w-]*\s*\n?/, "") // Remove trailing closing fence if present result = result.replace(/\n?\s*```\s*$/, "") return result } /** Regex matching metadata lines in CLI execution output envelopes. */ const CLI_META_LINE_RE = /^(exit code\s*[:=]|wall time\s*[:=]|chunk id\s*[:=]|original token count\s*[:=]|total output lines\s*[:=]|process exited with code\s)/i /** * Parse a CLI execution envelope, stripping all metadata and the "Output:" * separator, returning only the actual command output and the wall time. * * Handles formats like: * Chunk ID: 065b2b * Wall time: 0.05s * Process exited with code 0 * Original token count: 27006 * Output: * Total output lines: 1134 * */ function parseCliExecutionEnvelope(text: string): { output: string wallTime: string | null } { const lines = text.split("\n") let wallTime: string | null = null // Look for "Output:" separator and extract wall time from header let outputSepIndex = -1 for (let i = 0; i < lines.length; i++) { const trimmed = lines[i].trim() const wtMatch = trimmed.match(/^wall time\s*:\s*(.+)/i) if (wtMatch) wallTime = wtMatch[1].trim() if (/^output:\s*$/i.test(trimmed)) { outputSepIndex = i break } // Stop scanning if we hit a non-metadata, non-blank line (actual content) if (!CLI_META_LINE_RE.test(trimmed) && trimmed.length > 0) break } // If "Output:" separator found, skip everything before it plus any // remaining metadata/blank lines after it if (outputSepIndex >= 0) { let start = outputSepIndex + 1 while (start < lines.length) { const trimmed = lines[start].trim() if (CLI_META_LINE_RE.test(trimmed) || trimmed.length === 0) { start++ continue } break } return { output: lines.slice(start).join("\n"), wallTime } } // No "Output:" separator — strip leading metadata lines let index = 0 let sawMeta = false while (index < lines.length) { const trimmed = lines[index].trim() if (CLI_META_LINE_RE.test(trimmed)) { sawMeta = true if (!wallTime) { const wtMatch = trimmed.match(/^wall time\s*:\s*(.+)/i) if (wtMatch) wallTime = wtMatch[1].trim() } index++ continue } if (sawMeta && trimmed.length === 0) { index++ continue } break } if (!sawMeta) return { output: text, wallTime: null } while (index < lines.length && lines[index].trim().length === 0) index++ return { output: lines.slice(index).join("\n"), wallTime } } // ── Part components ─────────────────────────────────────────────────── const TextPart = memo(function TextPart({ text, preserveNewlines = false, }: { text: string preserveNewlines?: boolean }) { if (preserveNewlines) { return
{text}
} return (
{text}
) }) const ToolCallPart = memo(function ToolCallPart({ part, }: { part: Extract }) { const t = useTranslations("Folder.chat.contentParts") const [manualOpen, setManualOpen] = useState(false) const normalizedToolName = useMemo( () => normalizeToolName(part.toolName), [part.toolName] ) const toolNameLower = normalizedToolName.toLowerCase() const isCommandTool = toolNameLower === "bash" || toolNameLower === "exec_command" const isCommandLikeTool = isCommandTool || toolNameLower === "apply_patch" const isRunning = part.state === "input-available" || part.state === "input-streaming" const title = useMemo(() => { const rawTitle = deriveToolTitle( normalizedToolName, part.input, part.output ?? part.errorText ?? null ) ?? sanitizeLiveTitle(part.displayTitle) ?? null return localizeDerivedToolTitle(rawTitle, ((key, values) => t(key as never, values as never)) as ( key: string, values?: Record ) => string) }, [ normalizedToolName, part.input, part.output, part.errorText, part.displayTitle, t, ]) const lineChangeStats = useMemo(() => { if (toolNameLower !== "edit" && toolNameLower !== "apply_patch") { return null } // Prefer finalized tool output, then the declared input. // Keep error text as last fallback because permission wrappers can include // verbose envelopes that inflate +/- counts before approval. const prioritizedCandidates = [ part.output ?? null, part.input, part.errorText ?? null, ] for (const candidate of prioritizedCandidates) { const stats = extractEditLineChangeStats(candidate) if (!stats) continue return stats } return null }, [toolNameLower, part.input, part.output, part.errorText]) const wallTime = useMemo(() => { const source = part.output ?? part.errorText if (!source) return null const normalized = commandOutputFromJsonString(source) ?? source const match = normalized.match(/^wall time\s*:\s*(.+)/im) if (!match) return null const raw = match[1].trim() // Parse "0.0519 seconds" → "52ms", "1.234 seconds" → "1.2s" const numMatch = raw.match(/^([\d.]+)\s*s/) if (!numMatch) return raw const sec = parseFloat(numMatch[1]) if (Number.isNaN(sec)) return raw if (sec < 0.001) return "<1ms" if (sec < 1) return `${Math.round(sec * 1000)}ms` if (sec < 60) return `${sec.toFixed(1)}s` return `${(sec / 60).toFixed(1)}m` }, [part.output, part.errorText]) const titleSuffix = useMemo(() => { const hasStats = lineChangeStats && (lineChangeStats.additions > 0 || lineChangeStats.deletions > 0) if (!hasStats && !wallTime) return null return ( {hasStats && lineChangeStats.additions > 0 && ( {lineChangeStats.additions} )} {hasStats && lineChangeStats.deletions > 0 && ( {lineChangeStats.deletions} )} {wallTime && ( {wallTime} )} ) }, [lineChangeStats, wallTime]) const icon = useMemo( () => getToolIcon(normalizedToolName, part.input), [normalizedToolName, part.input] ) const displayCommand = useMemo(() => { if (!isCommandTool) return null return ( extractDisplayCommandFromToolInput(part.input) ?? extractDisplayCommandFromToolInput(part.output) ?? extractDisplayCommandFromToolInput(part.errorText) ) }, [isCommandTool, part.input, part.output, part.errorText]) const commandOutput = useMemo(() => { if (!isCommandLikeTool) return null const source = typeof part.output === "string" ? part.output : typeof part.errorText === "string" ? part.errorText : null if (!source) return null const normalized = commandOutputFromJsonString(source) ?? source const envelope = parseCliExecutionEnvelope(normalized) return stripMarkdownCodeFence(envelope.output) }, [isCommandLikeTool, part.output, part.errorText]) const hasLiveOutput = isRunning && isCommandTool && typeof commandOutput === "string" const liveOutput = useMemo(() => { if (!hasLiveOutput || typeof commandOutput !== "string") { return null } const maxChars = 24000 return commandOutput.length > maxChars ? commandOutput.slice(-maxChars) : commandOutput }, [hasLiveOutput, commandOutput]) const liveOutputTruncated = hasLiveOutput && typeof commandOutput === "string" && typeof liveOutput === "string" && liveOutput.length < commandOutput.length const shouldRenderCommandTerminal = isCommandTool && (isRunning || (typeof commandOutput === "string" && commandOutput.length > 0) || (typeof displayCommand === "string" && displayCommand.length > 0)) const terminalOutput = useMemo(() => { if (!shouldRenderCommandTerminal) return "" const output = hasLiveOutput ? (liveOutput ?? "") : (commandOutput ?? "") return buildCommandTerminalOutput(displayCommand, output, isRunning) }, [ shouldRenderCommandTerminal, hasLiveOutput, liveOutput, commandOutput, displayCommand, isRunning, ]) const isFileTool = toolNameLower === "read" || toolNameLower === "read file" || toolNameLower === "write" || toolNameLower === "notebookedit" const shouldHideDuplicateResult = (toolNameLower === "edit" || toolNameLower === "apply_patch" || toolNameLower === "switch_mode" || toolNameLower === "enterplanmode" || toolNameLower === "exitplanmode" || isFileTool) && !part.errorText // Cline: attempt_completion — render as an expanded card with result + progress if (toolNameLower === "attempt_completion") { const parsedCompletion = tryParseJson(part.input ?? "") const completionResult = (parsedCompletion?.result as string | undefined)?.trim() ?? null const taskProgress = (parsedCompletion?.task_progress as string | undefined)?.trim() ?? null return ( {completionResult && (
{completionResult}
)} {taskProgress && (
Progress
{taskProgress}
)}
) } const open = (isRunning && (isCommandTool || hasLiveOutput)) || manualOpen return ( {part.input && (!isCommandTool || !shouldRenderCommandTerminal) && ( )} {(toolNameLower === "task" || toolNameLower === "agent") && part.output ? (
{part.output}
) : ( <> {shouldRenderCommandTerminal ? (
{liveOutputTruncated && (
{t("showingTailOutput")}
)}
) : ( !shouldHideDuplicateResult && (part.output || part.errorText) && ( ) )} )}
) }) const ToolResultPart = memo(function ToolResultPart({ part, }: { part: Extract }) { const t = useTranslations("Folder.chat.contentParts") return ( ) }) const ReasoningPart = memo(function ReasoningPart({ part, }: { part: Extract }) { return ( {part.content} ) }) // ── Main renderer ───────────────────────────────────────────────────── interface ContentPartsRendererProps { parts: AdaptedContentPart[] role?: MessageRole } export const ContentPartsRenderer = memo(function ContentPartsRenderer({ parts, role, }: ContentPartsRendererProps) { return (
{parts.map((part, i) => { if (part.type === "text") { return ( ) } if (part.type === "tool-call") { return } if (part.type === "tool-result") { return ( ) } if (part.type === "reasoning") { return } return null })}
) })