import React, { useMemo, ReactNode } from "react"; import { View, Text } from "react-native"; import { ScrollView } from "react-native-gesture-handler"; import { StyleSheet } from "react-native-unistyles"; import { Fonts } from "@/constants/theme"; import { getNowMs, isPerfLoggingEnabled, perfLog } from "@/utils/perf"; import { parseToolCallDisplay, buildLineDiff, parseUnifiedDiff, type ToolCallDisplay, } from "@/utils/tool-call-parsers"; import { DiffViewer } from "./diff-viewer"; import { getCodeInsets } from "./code-insets"; // ---- Types ---- export interface ToolCallDetailsData { toolName: string; args?: unknown; result?: unknown; error?: unknown; } const TOOL_CALL_DETAILS_LOG_TAG = "[ToolCallDetails]"; const TOOL_CALL_DETAILS_DURATION_THRESHOLD_MS = 8; const TOOL_CALL_DETAILS_SIZE_THRESHOLD = 20000; type ToolCallDisplaySummary = { displayType: ToolCallDisplay["type"]; totalChars: number; detail: Record; }; function summarizeToolCallDisplay(display: ToolCallDisplay): ToolCallDisplaySummary { switch (display.type) { case "shell": { const commandLength = display.command.length; const outputLength = display.output.length; return { displayType: display.type, totalChars: commandLength + outputLength, detail: { commandLength, outputLength, }, }; } case "edit": { const oldLength = display.oldString.length; const newLength = display.newString.length; return { displayType: display.type, totalChars: oldLength + newLength, detail: { filePath: display.filePath, oldLength, newLength, }, }; } case "read": { const contentLength = display.content.length; return { displayType: display.type, totalChars: contentLength, detail: { filePath: display.filePath, contentLength, offset: display.offset, limit: display.limit, }, }; } case "generic": { const inputPairs = display.input.length; const outputPairs = display.output.length; const inputChars = display.input.reduce((sum, pair) => sum + pair.value.length, 0); const outputChars = display.output.reduce((sum, pair) => sum + pair.value.length, 0); return { displayType: display.type, totalChars: inputChars + outputChars, detail: { inputPairs, outputPairs, inputChars, outputChars, }, }; } case "thinking": { const contentLength = display.content.length; return { displayType: display.type, totalChars: contentLength, detail: { contentLength }, }; } default: return assertNever(display); } } // ---- Helper ---- function assertNever(value: never): never { throw new Error(`Unhandled tool call display: ${JSON.stringify(value)}`); } function formatValue(value: unknown): string { if (value === undefined) { return ""; } if (typeof value === "string") { return value; } // Extract content from tool_result objects if ( typeof value === "object" && value !== null && "type" in value && (value as { type: string }).type === "tool_result" && "content" in value ) { const content = (value as { content: unknown }).content; if (typeof content === "string") { return content; } } try { return JSON.stringify(value, null, 2); } catch { return String(value); } } // ---- Content Component ---- interface ToolCallDetailsContentProps { display: ToolCallDisplay; errorText?: string; maxHeight?: number; } export function ToolCallDetailsContent({ display, errorText, maxHeight = 300, }: ToolCallDetailsContentProps) { // Compute diff lines for edit type const diffLines = useMemo(() => { if (display.type !== "edit") return undefined; // Use pre-computed unified diff if available (e.g., from apply_patch) if (display.unifiedDiff) { return parseUnifiedDiff(display.unifiedDiff); } return buildLineDiff(display.oldString, display.newString); }, [display]); const sections: ReactNode[] = []; const isFullBleed = display.type === "edit" || display.type === "shell"; const codeBlockStyle = isFullBleed ? styles.fullBleedBlock : styles.diffContainer; if (display.type === "shell") { const command = display.command.replace(/\n+$/, ""); const output = display.output.replace(/^\n+/, ""); const hasOutput = output.length > 0; sections.push( $ {command} {hasOutput ? `\n\n${output}` : ""} ); } else if (display.type === "edit") { sections.push( {diffLines ? ( ) : null} ); } else if (display.type === "read") { sections.push( {(display.offset !== undefined || display.limit !== undefined) ? ( {display.offset !== undefined ? `Offset: ${display.offset}` : ""} {display.offset !== undefined && display.limit !== undefined ? " • " : ""} {display.limit !== undefined ? `Limit: ${display.limit}` : ""} ) : null} {display.content ? ( {display.content} ) : null} ); } else if (display.type === "thinking") { // Thinking: display the content as plain text sections.push( {display.content} ); } else { // Generic tool: show input/output as key-value pairs if (display.input.length > 0) { sections.push( Input ); display.input.forEach((pair, index) => { sections.push( {pair.key} {pair.value} ); }); } if (display.output.length > 0) { sections.push( Output ); display.output.forEach((pair, index) => { sections.push( {pair.key} {pair.value} ); }); } } // Always show errors if available if (errorText) { sections.push( Error {errorText} ); } if (sections.length === 0) { return ( No additional details available ); } return ( {sections} ); } // ---- Hook for parsing tool call data ---- export function useToolCallDetails(data: ToolCallDetailsData) { const { toolName, args, result, error } = data; return useMemo(() => { const shouldLog = isPerfLoggingEnabled(); const startMs = shouldLog ? getNowMs() : 0; const display = parseToolCallDisplay(toolName, args, result); const errorText = error !== undefined ? formatValue(error) : undefined; if (shouldLog) { const durationMs = getNowMs() - startMs; const summary = summarizeToolCallDisplay(display); if ( durationMs >= TOOL_CALL_DETAILS_DURATION_THRESHOLD_MS || summary.totalChars >= TOOL_CALL_DETAILS_SIZE_THRESHOLD ) { perfLog(TOOL_CALL_DETAILS_LOG_TAG, { event: "parse", durationMs: Math.round(durationMs), displayType: summary.displayType, totalChars: summary.totalChars, errorLength: errorText ? errorText.length : 0, ...summary.detail, }); } } return { display, errorText }; }, [toolName, args, result, error]); } // ---- Styles ---- const styles = StyleSheet.create((theme) => { const insets = getCodeInsets(theme); return { paddedContainer: { gap: theme.spacing[4], padding: theme.spacing[2], }, fullBleedContainer: { gap: theme.spacing[2], padding: 0, }, groupHeader: { flexDirection: "row", alignItems: "center", gap: theme.spacing[2], paddingBottom: theme.spacing[1], borderBottomWidth: theme.borderWidth[1], borderBottomColor: theme.colors.border, }, groupHeaderText: { color: theme.colors.primary, fontSize: theme.fontSize.sm, fontWeight: theme.fontWeight.bold, textTransform: "uppercase", letterSpacing: 1, }, section: { gap: theme.spacing[2], }, sectionTitle: { color: theme.colors.foregroundMuted, fontSize: theme.fontSize.xs, fontWeight: theme.fontWeight.semibold, textTransform: "uppercase", letterSpacing: 0.5, }, rangeText: { color: theme.colors.foregroundMuted, fontSize: theme.fontSize.xs, }, diffContainer: { borderWidth: theme.borderWidth[1], borderColor: theme.colors.border, borderRadius: theme.borderRadius.base, overflow: "hidden", backgroundColor: theme.colors.surface2, }, fullBleedBlock: { borderWidth: 0, borderRadius: 0, overflow: "hidden", backgroundColor: theme.colors.surface1, }, codeVerticalScroll: {}, codeVerticalContent: { flexGrow: 1, paddingBottom: insets.extraBottom, }, codeHorizontalContent: { paddingRight: insets.extraRight, }, codeLine: { minWidth: "100%", paddingHorizontal: insets.padding, paddingVertical: insets.padding, }, scrollArea: { borderWidth: theme.borderWidth[1], borderColor: theme.colors.border, borderRadius: theme.borderRadius.base, backgroundColor: theme.colors.surface2, }, scrollContent: { padding: insets.padding, }, scrollText: { fontFamily: Fonts.mono, fontSize: theme.fontSize.xs, color: theme.colors.foreground, lineHeight: 18, }, shellPrompt: { color: theme.colors.foregroundMuted, }, jsonScroll: { borderWidth: theme.borderWidth[1], borderColor: theme.colors.border, borderRadius: theme.borderRadius.base, backgroundColor: theme.colors.surface2, }, jsonScrollError: { borderColor: theme.colors.destructive, }, jsonContent: { padding: insets.padding, }, errorText: { color: theme.colors.destructive, }, emptyStateText: { color: theme.colors.foregroundMuted, fontSize: theme.fontSize.sm, fontStyle: "italic", }, }; });