Files
codeg/src/components/message/content-parts-renderer.tsx
2026-03-28 20:15:21 +08:00

2409 lines
70 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<string, unknown> | 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<string, unknown> | null {
if (value && typeof value === "object" && !Array.isArray(value)) {
return value as Record<string, unknown>
}
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<string, unknown> | 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<string, unknown> | 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 '<cmd>'".
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<string, unknown>
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<string, unknown>
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<string, unknown>, key: string): string | undefined {
const v = obj[key]
return typeof v === "string" ? v : undefined
}
/** Get number field from parsed object */
function num(obj: Record<string, unknown>, 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<string, unknown> | 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<string>()
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<string, unknown> | 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<string, unknown>): 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, unknown>): 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<string, unknown>,
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<string, unknown>
): 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 <ListTodoIcon className={ICON_CLASS} />
const t = extractJsonField(input, "subagent_type")?.toLowerCase()
if (!t) return <ListTodoIcon className={ICON_CLASS} />
if (t.includes("explore")) return <CompassIcon className={ICON_CLASS} />
if (t.includes("plan")) return <MapIcon className={ICON_CLASS} />
if (t.includes("bash")) return <TerminalIcon className={ICON_CLASS} />
return <WrenchIcon className={ICON_CLASS} />
}
function getToolIcon(
toolName: string,
input?: string | null
): ReactNode | undefined {
const name = toolName.toLowerCase()
if (name === "read" || name === "read file")
return <FileTextIcon className={ICON_CLASS} />
if (name === "edit") return <FilePenLineIcon className={ICON_CLASS} />
if (name === "write" || name === "notebookedit")
return <FilePlusIcon className={ICON_CLASS} />
if (name === "bash" || name === "exec_command")
return <TerminalIcon className={ICON_CLASS} />
if (name === "apply_patch") return <FilePenLineIcon className={ICON_CLASS} />
if (name === "glob" || name === "grep")
return <SearchIcon className={ICON_CLASS} />
if (name === "webfetch" || name === "websearch")
return <GlobeIcon className={ICON_CLASS} />
if (name === "todowrite") return <ListTodoIcon className={ICON_CLASS} />
if (name === "task") return getTaskToolIcon(input ?? null)
if (name === "taskcreate" || name === "taskupdate" || name === "tasklist")
return <ListTodoIcon className={ICON_CLASS} />
if (name === "agent") return getTaskToolIcon(input ?? null)
if (name === "skill") return <SparklesIcon className={ICON_CLASS} />
if (
name === "enterplanmode" ||
name === "exitplanmode" ||
name === "switch_mode"
)
return <ListTodoIcon className={ICON_CLASS} />
if (name === "attempt_completion")
return <CircleCheckIcon className={ICON_CLASS} />
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<string, unknown>) => 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, unknown>) => 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<string, unknown> }) {
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 ? <UnifiedDiffPreview diffText={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 ? <UnifiedDiffPreview diffText={diffCode} /> : null
}
/** Bash / exec_command: terminal-style command display */
function BashToolInput({ input }: { input: Record<string, unknown> }) {
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 (
<div className="space-y-2">
{description && (
<div className="flex items-center gap-2 text-xs">
<TerminalIcon className="size-3.5 shrink-0 text-muted-foreground" />
<span className="text-muted-foreground">{description}</span>
</div>
)}
{displayCommand && <CodeBlock code={displayCommand} language="bash" />}
{(timeout || background) && (
<div className="flex flex-wrap gap-3 text-xs text-muted-foreground">
{timeout && <span>{t("timeoutMs", { timeout })}</span>}
{background && <span>{t("backgroundTrue")}</span>}
</div>
)}
</div>
)
}
/**
* 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 (
<div className="inline-block min-w-full font-mono text-[12px] leading-[20px]">
{lines.map((line, i) => (
<div key={i} className="flex">
<span className="w-[3.5rem] shrink-0 select-none pr-1 text-right text-muted-foreground/40">
{startLine + i}
</span>
<span className="flex-1 whitespace-pre pr-3">{line}</span>
</div>
))}
</div>
)
}
/** Read / Write / NotebookEdit: file-focused display */
function FileToolInput({
toolName,
input,
output,
}: {
toolName: string
input: Record<string, unknown>
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 (
<section className="flex max-h-[420px] flex-col rounded-lg border border-border bg-background">
<header className="flex shrink-0 items-center gap-2 border-b border-border bg-muted/40 px-3 py-2 text-[11px]">
<span className="shrink-0 rounded border border-border bg-background px-1.5 py-0.5 text-[10px] text-muted-foreground">
{isRead ? "READ" : "WRITE"}
</span>
<span
className="min-w-0 flex-1 truncate font-mono text-foreground"
title={filePath ?? undefined}
>
{filePath ?? t("unknown")}
</span>
{badges.length > 0 && (
<span className="ml-auto inline-flex shrink-0 items-center gap-2 text-[10px] text-muted-foreground">
{badges.map((b) => (
<span key={b}>{b}</span>
))}
</span>
)}
</header>
{displayContent && (
<div className="overflow-auto">
<FileContentLines content={displayContent} startLine={startLine} />
</div>
)}
</section>
)
}
/** Glob / Grep: search-focused display */
function SearchToolInput({
toolName,
input,
}: {
toolName: string
input: Record<string, unknown>
}) {
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 (
<div className="space-y-2">
{pattern && (
<div className="flex items-center gap-2 rounded-md bg-muted/50 px-3 py-2">
<SearchIcon className="size-3.5 shrink-0 text-muted-foreground" />
<code className="break-all text-xs text-foreground">{pattern}</code>
</div>
)}
<div className="flex flex-wrap gap-x-4 gap-y-1 text-xs text-muted-foreground">
{path && (
<span>
{t("pathLabel")}{" "}
<span className="font-mono text-foreground">{path}</span>
</span>
)}
{glob && (
<span>
{t("globLabel")}{" "}
<span className="font-mono text-foreground">{glob}</span>
</span>
)}
{fileType && (
<span>
{t("typeLabel")}{" "}
<span className="font-mono text-foreground">{fileType}</span>
</span>
)}
{name === "grep" && outputMode && (
<span>
{t("outputLabel")}{" "}
<span className="font-mono text-foreground">{outputMode}</span>
</span>
)}
{caseInsensitive && <span>{t("caseInsensitive")}</span>}
{multiline && <span>{t("multiline")}</span>}
</div>
</div>
)
}
/** Web tools: URL / query focused */
function WebToolInput({
toolName,
input,
}: {
toolName: string
input: Record<string, unknown>
}) {
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 (
<div className="space-y-2">
{name === "websearch" && query && (
<div className="flex items-center gap-2 rounded-md bg-muted/50 px-3 py-2">
<SearchIcon className="size-3.5 shrink-0 text-muted-foreground" />
<span className="break-all text-xs font-medium text-foreground">
{query}
</span>
</div>
)}
{name === "webfetch" && url && (
<div className="flex items-center gap-2 rounded-md bg-muted/50 px-3 py-2">
<GlobeIcon className="size-3.5 shrink-0 text-muted-foreground" />
<span className="break-all font-mono text-xs text-foreground">
{url}
</span>
</div>
)}
{prompt && (
<div className="space-y-1">
<span className="text-xs font-medium text-muted-foreground">
{t("promptLabel")}
</span>
<div className="rounded-md bg-muted/50 p-3 text-xs prose prose-sm dark:prose-invert max-w-none [&_ul]:list-inside [&_ol]:list-inside">
<MessageResponse>{prompt}</MessageResponse>
</div>
</div>
)}
</div>
)
}
/** Task tools: description / subject focused */
function TaskToolInput({ input }: { input: Record<string, unknown> }) {
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 (
<div className="space-y-2">
{subject && (
<div className="flex items-baseline gap-2 text-xs">
<span className="shrink-0 font-medium text-muted-foreground">
{t("subjectLabel")}
</span>
<span className="text-foreground">{subject}</span>
</div>
)}
{taskId && (
<div className="flex items-baseline gap-2 text-xs">
<span className="shrink-0 font-medium text-muted-foreground">
{t("taskLabel")}
</span>
<span className="font-mono text-foreground">
#{taskId}
{status ? `${status}` : ""}
</span>
</div>
)}
{agentName && (
<div className="text-xs text-muted-foreground">
{t("nameLabel")}{" "}
<span className="font-mono text-foreground">{agentName}</span>
</div>
)}
</div>
)
}
/** TodoWrite: checklist-style display */
function TodoWriteToolInput({ input }: { input: Record<string, unknown> }) {
const todos = Array.isArray(input.todos) ? input.todos : []
if (todos.length === 0) return null
const statusIcon = (status: string) => {
if (status === "completed")
return <CircleCheckIcon className="size-3.5 shrink-0 text-green-500" />
if (status === "in_progress")
return <CircleDotIcon className="size-3.5 shrink-0 text-blue-500" />
return <CircleIcon className="size-3.5 shrink-0 text-muted-foreground" />
}
const priorityBadge = (priority: string) => {
const colors: Record<string, string> = {
high: "bg-red-500/15 text-red-500",
medium: "bg-yellow-500/15 text-yellow-600",
low: "bg-muted text-muted-foreground",
}
return (
<span
className={`rounded px-1.5 py-0.5 text-[10px] font-medium ${colors[priority] ?? colors.low}`}
>
{priority}
</span>
)
}
return (
<div className="space-y-1">
{todos.map((todo: Record<string, unknown>, 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 (
<div
key={id}
className="flex items-start gap-2 rounded-md px-2 py-1.5 text-xs"
>
{statusIcon(status)}
<span
className={
status === "completed"
? "text-muted-foreground line-through"
: "text-foreground"
}
>
{content}
</span>
{priority && priorityBadge(priority)}
</div>
)
})}
</div>
)
}
function ApplyPatchToolInput({ input }: { input: string }) {
return <UnifiedDiffPreview diffText={input} />
}
// ── Switch mode (plan) input ──────────────────────────────────────────
function extractPlanMarkdown(input: Record<string, unknown>): 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<string, unknown>)
: typeof input.raw_input === "object" && input.raw_input !== null
? (input.raw_input as Record<string, unknown>)
: 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<string, unknown> }) {
const planMarkdown = extractPlanMarkdown(input)
if (!planMarkdown) return null
return (
<div className="text-sm prose prose-sm dark:prose-invert max-w-none [&_ul]:list-inside [&_ol]:list-inside">
<MessageResponse>{planMarkdown}</MessageResponse>
</div>
)
}
// ── 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 (
<pre className="whitespace-pre-wrap break-all rounded-md bg-muted/50 p-3 text-xs text-muted-foreground">
{input}
</pre>
)
}
const entries = Object.entries(parsed).filter(([k]) => !HIDDEN_FIELDS.has(k))
if (entries.length === 0) return null
return (
<div className="space-y-3">
{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 (
<FieldBlock key={key} label={label}>
<CodeBlock code={value} language={lang} />
</FieldBlock>
)
}
if (typeof value === "string") {
if (value.length > 200) {
return (
<FieldBlock key={key} label={label}>
<pre className="whitespace-pre-wrap break-all rounded-md bg-muted/50 p-3 text-xs">
{value}
</pre>
</FieldBlock>
)
}
return <FieldInline key={key} label={label} value={value} />
}
if (typeof value === "number" || typeof value === "boolean") {
return <FieldInline key={key} label={label} value={String(value)} />
}
if (value !== null && value !== undefined) {
return (
<FieldBlock key={key} label={label}>
<CodeBlock
code={JSON.stringify(value, null, 2)}
language="json"
/>
</FieldBlock>
)
}
return null
})}
</div>
)
}
// ── 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 ? (
<div className="rounded-md bg-yellow-500/10 px-2.5 py-1.5 text-[11px] text-yellow-700 dark:text-yellow-400">
{t("inputTruncated")}
</div>
) : null
if (name === "apply_patch") {
const patchInput =
extractApplyPatchTextFromUnknownInput(input, parsed) ?? input
return (
<>
{truncationBanner}
<ApplyPatchToolInput input={patchInput} />
</>
)
}
if (name === "bash" || name === "exec_command") {
if (parsed) {
return <BashToolInput input={parsed} />
}
const plainCommand = extractCommandFromUnknownInput(input)
if (plainCommand) {
return <BashToolInput input={{ command: plainCommand }} />
}
}
if (!parsed) {
return (
<pre className="whitespace-pre-wrap break-all rounded-md bg-muted/50 p-3 text-xs text-muted-foreground">
{input}
</pre>
)
}
if (name === "edit") {
const patchInput = extractApplyPatchTextFromUnknownInput(input, parsed)
if (patchInput) {
return (
<>
{truncationBanner}
<ApplyPatchToolInput input={patchInput} />
</>
)
}
if (parsed) {
const changesPayload = extractEditChangesPayload(parsed)
if (changesPayload.length > 0) {
return (
<>
{truncationBanner}
<EditChangesToolInput changes={changesPayload} />
</>
)
}
}
// 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}
<UnifiedDiffPreview diffText={output} />
</>
)
}
if (isCanonicalEditPayload(parsed)) {
return (
<>
{truncationBanner}
<EditToolInput input={parsed} />
</>
)
}
return <GenericToolInput input={input} />
}
if (name === "bash" || name === "exec_command")
return <BashToolInput input={parsed} />
if (
name === "read" ||
name === "read file" ||
name === "write" ||
name === "notebookedit"
)
return <FileToolInput toolName={toolName} input={parsed} output={output} />
if (name === "glob" || name === "grep")
return <SearchToolInput toolName={toolName} input={parsed} />
if (name === "webfetch" || name === "websearch")
return <WebToolInput toolName={toolName} input={parsed} />
if (name === "todowrite") return <TodoWriteToolInput input={parsed} />
if (
name === "task" ||
name === "taskcreate" ||
name === "taskupdate" ||
name === "tasklist"
)
return <TaskToolInput input={parsed} />
if (
name === "switch_mode" ||
name === "enterplanmode" ||
name === "exitplanmode"
) {
if (extractPlanMarkdown(parsed)) {
return <SwitchModeToolInput input={parsed} />
}
}
return <GenericToolInput input={input} />
}
// ── Shared field components ──────────────────────────────────────────
function FieldInline({ label, value }: { label: string; value: string }) {
return (
<div className="flex items-baseline gap-2 text-xs">
<span className="shrink-0 font-medium text-muted-foreground">
{label}
</span>
<span className="break-all font-mono text-foreground">{value}</span>
</div>
)
}
function FieldBlock({
label,
children,
}: {
label: string
children: React.ReactNode
}) {
return (
<div className="space-y-1">
<span className="text-xs font-medium text-muted-foreground">{label}</span>
<div className="rounded-md bg-muted/50">{children}</div>
</div>
)
}
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<string, unknown>
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
* <actual output here>
*/
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 <div className="whitespace-pre-wrap break-words text-sm">{text}</div>
}
return (
<div className="break-words text-sm prose prose-sm dark:prose-invert max-w-none [&_ul]:list-inside [&_ol]:list-inside">
<MessageResponse>{text}</MessageResponse>
</div>
)
})
const ToolCallPart = memo(function ToolCallPart({
part,
}: {
part: Extract<AdaptedContentPart, { type: "tool-call" }>
}) {
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, unknown>
) => 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 (
<span className="flex items-center gap-1.5 text-xs font-medium">
{hasStats && lineChangeStats.additions > 0 && (
<span className="inline-flex items-center gap-0.5 text-green-600 dark:text-green-400">
<PlusIcon className="size-3" />
{lineChangeStats.additions}
</span>
)}
{hasStats && lineChangeStats.deletions > 0 && (
<span className="inline-flex items-center gap-0.5 text-red-600 dark:text-red-400">
<MinusIcon className="size-3" />
{lineChangeStats.deletions}
</span>
)}
{wallTime && (
<span className="text-muted-foreground/60 font-normal">
{wallTime}
</span>
)}
</span>
)
}, [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 (
<Tool open onOpenChange={setManualOpen}>
<ToolHeader
type="dynamic-tool"
state={part.state}
toolName={normalizedToolName}
title={title ?? "Completion"}
icon={icon}
/>
<ToolContent>
{completionResult && (
<div className="text-sm prose prose-sm dark:prose-invert max-w-none [&_ul]:list-inside [&_ol]:list-inside">
<MessageResponse>{completionResult}</MessageResponse>
</div>
)}
{taskProgress && (
<div className="mt-2 rounded-md border bg-muted/30 px-3 py-2">
<div className="text-[11px] font-medium text-muted-foreground mb-1">
Progress
</div>
<div className="text-xs prose prose-sm dark:prose-invert max-w-none [&_ul]:list-inside [&_ol]:list-inside">
<MessageResponse>{taskProgress}</MessageResponse>
</div>
</div>
)}
</ToolContent>
</Tool>
)
}
const open = (isRunning && (isCommandTool || hasLiveOutput)) || manualOpen
return (
<Tool open={open} onOpenChange={setManualOpen}>
<ToolHeader
type="dynamic-tool"
state={part.state}
toolName={normalizedToolName}
title={title ?? undefined}
titleSuffix={titleSuffix ?? undefined}
icon={icon}
/>
<ToolContent>
{part.input && (!isCommandTool || !shouldRenderCommandTerminal) && (
<StructuredToolInput
toolName={normalizedToolName}
input={part.input}
output={part.output}
/>
)}
{(toolNameLower === "task" || toolNameLower === "agent") &&
part.output ? (
<div className="text-sm prose prose-sm dark:prose-invert max-w-none [&_ul]:list-inside [&_ol]:list-inside">
<MessageResponse>{part.output}</MessageResponse>
</div>
) : (
<>
{shouldRenderCommandTerminal ? (
<div>
<Terminal
output={terminalOutput}
isStreaming={isRunning}
className="max-h-80"
/>
{liveOutputTruncated && (
<div className="text-[11px] text-muted-foreground">
{t("showingTailOutput")}
</div>
)}
</div>
) : (
!shouldHideDuplicateResult &&
(part.output || part.errorText) && (
<ToolOutput output={part.output} errorText={part.errorText} />
)
)}
</>
)}
</ToolContent>
</Tool>
)
})
const ToolResultPart = memo(function ToolResultPart({
part,
}: {
part: Extract<AdaptedContentPart, { type: "tool-result" }>
}) {
const t = useTranslations("Folder.chat.contentParts")
return (
<Tool>
<ToolHeader
type="dynamic-tool"
state={part.state}
toolName={t("result")}
/>
<ToolContent>
<ToolOutput output={part.output} errorText={part.errorText} />
</ToolContent>
</Tool>
)
})
const ReasoningPart = memo(function ReasoningPart({
part,
}: {
part: Extract<AdaptedContentPart, { type: "reasoning" }>
}) {
return (
<Reasoning isStreaming={part.isStreaming}>
<ReasoningTrigger />
<ReasoningContent>{part.content}</ReasoningContent>
</Reasoning>
)
})
// ── Main renderer ─────────────────────────────────────────────────────
interface ContentPartsRendererProps {
parts: AdaptedContentPart[]
role?: MessageRole
}
export const ContentPartsRenderer = memo(function ContentPartsRenderer({
parts,
role,
}: ContentPartsRendererProps) {
return (
<div className="space-y-4">
{parts.map((part, i) => {
if (part.type === "text") {
return (
<TextPart
key={`text-${i}`}
text={part.text}
preserveNewlines={role === "user"}
/>
)
}
if (part.type === "tool-call") {
return <ToolCallPart key={`tc-${part.toolCallId ?? i}`} part={part} />
}
if (part.type === "tool-result") {
return (
<ToolResultPart key={`tr-${part.toolCallId ?? i}`} part={part} />
)
}
if (part.type === "reasoning") {
return <ReasoningPart key={`reasoning-${i}`} part={part} />
}
return null
})}
</div>
)
})