ToolResultRenderer.tsx (3431B)
1 import type { ReactNode } from "react"; 2 import { MarkdownRenderer } from "../Message/MarkdownRenderer"; 3 import { JsonResult } from "./JsonResult"; 4 import { DiffResult } from "./DiffResult"; 5 6 export type ContentType = "json" | "diff" | "markdown"; 7 8 export interface OperationCategory { 9 label: string; 10 color: string; 11 } 12 13 export interface PreambleResult { 14 preamble: string; 15 jsonContent: string | null; 16 } 17 18 const CATEGORY_MAP: Record<string, OperationCategory> = { 19 read_file: { label: "READ", color: "blue" }, 20 write_file: { label: "WRITE", color: "green" }, 21 patch: { label: "PATCH", color: "yellow" }, 22 search_files: { label: "SEARCH", color: "purple" }, 23 terminal: { label: "EXEC", color: "orange" }, 24 }; 25 26 const HUNK_HEADER_RE = /^@@\s+[-+]\d+/m; 27 28 function hasDiffMarkers(content: string): boolean { 29 return HUNK_HEADER_RE.test(content); 30 } 31 32 function tryParseJson(content: string): { parsed: unknown; jsonStart: number } | null { 33 const firstBrace = content.indexOf("{"); 34 const firstBracket = content.indexOf("["); 35 const jsonStart = 36 firstBrace === -1 ? firstBracket : 37 firstBracket === -1 ? firstBrace : 38 Math.min(firstBrace, firstBracket); 39 40 if (jsonStart === -1) return null; 41 42 const candidate = content.slice(jsonStart); 43 try { 44 const parsed = JSON.parse(candidate); 45 return { parsed, jsonStart }; 46 } catch { 47 return null; 48 } 49 } 50 51 /** Detect whether content is a unified diff, JSON, or plain markdown. */ 52 export function detectContentType(content: string): ContentType { 53 const trimmed = content.trim(); 54 if (!trimmed) return "markdown"; 55 56 // Diff first — diffs contain { / [ in code which would false-trigger JSON 57 if (hasDiffMarkers(trimmed)) return "diff"; 58 59 // JSON — find first { or [, parse from there 60 const result = tryParseJson(trimmed); 61 if (result && (typeof result.parsed === "object" && result.parsed !== null)) { 62 return "json"; 63 } 64 65 return "markdown"; 66 } 67 68 /** Split content into optional preamble text and JSON content. */ 69 export function extractPreamble(content: string): PreambleResult { 70 const trimmed = content.trim(); 71 const result = tryParseJson(trimmed); 72 73 if (!result || result.jsonStart === 0) { 74 return result 75 ? { preamble: "", jsonContent: trimmed } 76 : { preamble: trimmed, jsonContent: null }; 77 } 78 79 return { 80 preamble: trimmed.slice(0, result.jsonStart).trim(), 81 jsonContent: trimmed.slice(result.jsonStart), 82 }; 83 } 84 85 /** Map a tool name to an operation category with label and color. */ 86 export function getOperationCategory(toolName: string): OperationCategory { 87 return CATEGORY_MAP[toolName] ?? { label: toolName, color: "gray" }; 88 } 89 90 interface ToolResultRendererProps { 91 content: string; 92 toolName?: string; 93 } 94 95 /** Route tool result content to the appropriate renderer. */ 96 export function ToolResultRenderer({ content }: ToolResultRendererProps): ReactNode { 97 const contentType = detectContentType(content); 98 99 if (contentType === "diff") { 100 return <DiffResult content={content} />; 101 } 102 103 if (contentType === "json") { 104 const { preamble, jsonContent } = extractPreamble(content); 105 try { 106 const parsed = JSON.parse(jsonContent!); 107 return ( 108 <> 109 {preamble && <MarkdownRenderer content={preamble} />} 110 <JsonResult data={parsed} /> 111 </> 112 ); 113 } catch { 114 // Malformed — fall through to markdown 115 } 116 } 117 118 return <MarkdownRenderer content={content} />; 119 }