import React, { useMemo, type ReactNode } from "react"; import { View, Text, ScrollView as RNScrollView, type StyleProp, type ViewStyle, } from "react-native"; import { ScrollView as GHScrollView } from "react-native-gesture-handler"; import { StyleSheet } from "react-native-unistyles"; import { Fonts } from "@/constants/theme"; import type { ToolCallDetail } from "@getpaseo/protocol/agent-types"; import { buildLineDiff, parseUnifiedDiff, type DiffLine } from "@/utils/tool-call-parsers"; import { hasMeaningfulToolCallDetail } from "@/utils/tool-call-detail-state"; import { useWebScrollbarStyle } from "@/hooks/use-web-scrollbar-style"; import { inlineUnistylesStyle } from "@/styles/unistyles-inline-style"; import { DiffViewer } from "./diff-viewer"; import { getCodeInsets } from "./code-insets"; import { isWeb } from "@/constants/platform"; const ScrollView = isWeb ? RNScrollView : GHScrollView; // ---- Content Component ---- interface ToolCallDetailsContentProps { detail?: ToolCallDetail; errorText?: string; maxHeight?: number; fillAvailableHeight?: boolean; showLoadingSkeleton?: boolean; } interface DetailStyles { sectionFillStyle: StyleProp; codeBlockFillStyle: StyleProp; codeVerticalScrollStyle: StyleProp; scrollAreaFillStyle: StyleProp; scrollAreaStyle: StyleProp; jsonScrollCombined: StyleProp; jsonScrollErrorCombined: StyleProp; fullBleedContainerStyle: StyleProp; loadingContainerStyle: StyleProp; webScrollbarStyle: StyleProp; resolvedMaxHeight: number | undefined; shouldFill: boolean; isFullBleed: boolean; } function resolveIsFullBleed(detail: ToolCallDetail | undefined): boolean { return detail?.type === "edit" || detail?.type === "shell" || detail?.type === "write"; } function resolveShouldFill( detail: ToolCallDetail | undefined, fillAvailableHeight: boolean, ): boolean { if (!fillAvailableHeight) return false; const t = detail?.type; return t === "shell" || t === "edit" || t === "write" || t === "read" || t === "sub_agent"; } function useDetailStyles( detail: ToolCallDetail | undefined, resolvedMaxHeight: number | undefined, fillAvailableHeight: boolean, ): DetailStyles { const webScrollbarStyle = useWebScrollbarStyle(); const isFullBleed = resolveIsFullBleed(detail); const shouldFill = resolveShouldFill(detail, fillAvailableHeight); const codeBlockStyle = isFullBleed ? styles.fullBleedBlock : styles.diffContainer; const sectionFillStyle = useMemo( () => [styles.section, shouldFill && styles.fillHeight], [shouldFill], ); const codeBlockFillStyle = useMemo( () => [codeBlockStyle, shouldFill && styles.fillHeight], [codeBlockStyle, shouldFill], ); const codeVerticalScrollStyle = useMemo( () => [ styles.codeVerticalScroll, resolvedMaxHeight !== undefined && inlineUnistylesStyle({ maxHeight: resolvedMaxHeight }), shouldFill && styles.fillHeight, webScrollbarStyle, ], [resolvedMaxHeight, shouldFill, webScrollbarStyle], ); const scrollAreaFillStyle = useMemo( () => [ styles.scrollArea, resolvedMaxHeight !== undefined && inlineUnistylesStyle({ maxHeight: resolvedMaxHeight }), shouldFill && styles.fillHeight, webScrollbarStyle, ], [resolvedMaxHeight, shouldFill, webScrollbarStyle], ); const scrollAreaStyle = useMemo( () => [ styles.scrollArea, resolvedMaxHeight !== undefined && inlineUnistylesStyle({ maxHeight: resolvedMaxHeight }), webScrollbarStyle, ], [resolvedMaxHeight, webScrollbarStyle], ); const jsonScrollCombined = useMemo( () => [styles.jsonScroll, webScrollbarStyle], [webScrollbarStyle], ); const jsonScrollErrorCombined = useMemo( () => [styles.jsonScroll, styles.jsonScrollError, webScrollbarStyle], [webScrollbarStyle], ); const fullBleedContainerStyle = useMemo( () => [ isFullBleed ? styles.fullBleedContainer : styles.paddedContainer, shouldFill && styles.fillHeight, ], [isFullBleed, shouldFill], ); const loadingContainerStyle = useMemo( () => [styles.loadingContainer, fillAvailableHeight && styles.fillHeight], [fillAvailableHeight], ); return { sectionFillStyle, codeBlockFillStyle, codeVerticalScrollStyle, scrollAreaFillStyle, scrollAreaStyle, jsonScrollCombined, jsonScrollErrorCombined, fullBleedContainerStyle, loadingContainerStyle, webScrollbarStyle, resolvedMaxHeight, shouldFill, isFullBleed, }; } function useDiffLines(detail: ToolCallDetail | undefined): DiffLine[] | undefined { return useMemo(() => { if (!detail || detail.type !== "edit") return undefined; if (detail.unifiedDiff) { return parseUnifiedDiff(detail.unifiedDiff); } return buildLineDiff(detail.oldString ?? "", detail.newString ?? ""); }, [detail]); } interface ShellDetailProps { command: string; output: string | null | undefined; ds: DetailStyles; } function ShellDetailSection({ command, output, ds }: ShellDetailProps) { const normalizedCommand = command.replace(/\n+$/, ""); const commandOutput = (output ?? "").replace(/^\n+/, ""); const hasOutput = commandOutput.length > 0; return ( $ {normalizedCommand} {hasOutput ? `\n\n${commandOutput}` : ""} ); } interface WorktreeSetupDetailProps { log: string; branchName: string; worktreePath: string; ds: DetailStyles; } function WorktreeSetupDetailSection({ log, branchName, worktreePath, ds, }: WorktreeSetupDetailProps) { const setupLog = log.replace(/^\n+/, ""); const hasLog = setupLog.length > 0; return ( {hasLog ? setupLog : `Preparing worktree ${branchName} at ${worktreePath}`} ); } function resolveSubAgentFallbackHeader( subAgentType: string | null | undefined, description: string | null | undefined, ): string { if (subAgentType && description) { return `${subAgentType}: ${description}`; } return subAgentType ?? description ?? "Sub-agent activity"; } interface SubAgentDetailProps { log: string; childSessionId: string | null | undefined; subAgentType: string | null | undefined; description: string | null | undefined; ds: DetailStyles; } interface SubAgentActivityRow { index: number; toolName: string; summary?: string; } interface ParsedSubAgentLog { actions: SubAgentActivityRow[]; remainingLog: string; } function parseBracketedSubAgentLine(line: string, index: number): SubAgentActivityRow | null { const match = line.match(/^\[([^\]]+)\](?:\s+(.*))?$/); if (!match) { return null; } const toolName = match[1]?.trim(); if (!toolName) { return null; } const summary = match[2]?.trim(); return { index, toolName, ...(summary ? { summary } : {}), }; } function parseSubAgentLog(log: string): ParsedSubAgentLog { const actions: SubAgentActivityRow[] = []; const remainingLines: string[] = []; for (const line of log.replace(/^\n+/, "").split("\n")) { const normalizedLine = line.trim(); if (!normalizedLine) { continue; } const parsedAction = parseBracketedSubAgentLine(normalizedLine, actions.length + 1); if (parsedAction) { actions.push(parsedAction); } else { remainingLines.push(line); } } return { actions, remainingLog: remainingLines.join("\n").replace(/^\n+/, ""), }; } function SubAgentActionRow({ action }: { action: SubAgentActivityRow }) { return ( {formatSubAgentToolName(action.toolName)} {action.summary ? ( {action.summary} ) : null} ); } function formatSubAgentToolName(toolName: string): string { const trimmed = toolName.trim(); if (!trimmed) { return toolName; } return trimmed .replace(/[._-]+/g, " ") .split(" ") .filter((segment) => segment.length > 0) .map((segment) => `${segment[0]?.toUpperCase() ?? ""}${segment.slice(1)}`) .join(" "); } function SubAgentLogText({ activityLog, fallbackHeader, hasActions, }: { activityLog: string; fallbackHeader: string; hasActions: boolean; }) { if (activityLog.length > 0) { return ( {activityLog} ); } if (!hasActions) { return ( {fallbackHeader} ); } return null; } function SubAgentDetailSection({ log, childSessionId, subAgentType, description, ds, }: SubAgentDetailProps) { const { actions, remainingLog } = useMemo(() => parseSubAgentLog(log), [log]); const fallbackHeader = resolveSubAgentFallbackHeader(subAgentType, description); const hasActions = actions.length > 0; return ( {childSessionId ? ( session {childSessionId} ) : null} {hasActions ? ( {actions.map((action) => ( ))} ) : null} ); } interface EditDetailProps { diffLines: DiffLine[] | undefined; ds: DetailStyles; } function EditDetailSection({ diffLines, ds }: EditDetailProps) { return ( {diffLines ? ( ) : null} ); } interface ScrollableContentProps { content: string; ds: DetailStyles; wrapInSectionFill?: boolean; } function ScrollableTextSection({ content, ds, wrapInSectionFill = true }: ScrollableContentProps) { const body = ( {content} ); if (!wrapInSectionFill) return body; return {body}; } interface FetchDetailProps { url: string; result: string | null | undefined; ds: DetailStyles; } function FetchDetailSection({ url, result, ds }: FetchDetailProps) { return ( {result ? `${url}\n\n${result}` : url} ); } function PlainTextSection({ text }: { text: string }) { return ( {text} ); } interface SearchDetail { query?: string; content?: string; filePaths?: string[]; webResults?: { title: string; url: string }[]; annotations?: string[]; } function buildSearchSections(detail: SearchDetail, ds: DetailStyles): ReactNode[] { const out: ReactNode[] = []; if (detail.content) { out.push( {detail.content} , ); } if (detail.filePaths && detail.filePaths.length > 0) { out.push( {detail.filePaths.join("\n")} , ); } if (detail.webResults && detail.webResults.length > 0) { out.push( {detail.webResults.map((entry) => `${entry.title}\n${entry.url}`).join("\n\n")} , ); } if (detail.annotations && detail.annotations.length > 0) { out.push( {detail.annotations.join("\n\n")} , ); } return out; } function serializeUnknownValue(value: unknown): string { try { return typeof value === "string" ? value : JSON.stringify(value, null, 2); } catch { return String(value); } } interface UnknownDetail { input: unknown; output: unknown; } function buildUnknownSections(detail: UnknownDetail, ds: DetailStyles): ReactNode[] { const plainInputText = typeof detail.input === "string" && detail.output === null ? detail.input : null; if (plainInputText !== null) { return [ {plainInputText} , ]; } const sectionsFromTopLevel = [ { title: "Input", value: detail.input }, { title: "Output", value: detail.output }, ].filter((entry) => hasMeaningfulToolCallDetail({ type: "unknown", input: entry.value ?? null, output: null, }), ); const out: ReactNode[] = []; for (const section of sectionsFromTopLevel) { const value = serializeUnknownValue(section.value); if (!value.length) { continue; } out.push( {section.title} , ); out.push( {value} , ); } return out; } function buildDetailSections( detail: ToolCallDetail | undefined, diffLines: DiffLine[] | undefined, ds: DetailStyles, ): ReactNode[] { if (!detail) return []; if (detail.type === "shell") { return [ , ]; } if (detail.type === "worktree_setup") { return [ , ]; } if (detail.type === "sub_agent") { return [ , ]; } if (detail.type === "edit") { return []; } if (detail.type === "write") { return [ {detail.content ? ( ) : null} , ]; } if (detail.type === "read") { if (!detail.content) return []; return []; } if (detail.type === "search") { return buildSearchSections(detail, ds); } if (detail.type === "fetch") { return []; } if (detail.type === "plain_text") { if (!detail.text) return []; return []; } if (detail.type === "unknown") { return buildUnknownSections(detail, ds); } return []; } function ErrorSection({ errorText, ds }: { errorText: string; ds: DetailStyles }) { return ( Error {errorText} ); } function LoadingSkeleton({ containerStyle }: { containerStyle: StyleProp }) { return ( ); } export function ToolCallDetailsContent({ detail, errorText, maxHeight, fillAvailableHeight = false, showLoadingSkeleton = false, }: ToolCallDetailsContentProps) { const resolvedMaxHeight = fillAvailableHeight ? undefined : (maxHeight ?? 300); const ds = useDetailStyles(detail, resolvedMaxHeight, fillAvailableHeight); const diffLines = useDiffLines(detail); const sections: ReactNode[] = buildDetailSections(detail, diffLines, ds); if (errorText) { sections.push(); } if (sections.length === 0) { if (showLoadingSkeleton) { return ; } return No additional details available; } return {sections}; } // ---- Styles ---- const styles = StyleSheet.create((theme) => { const insets = getCodeInsets(theme); return { paddedContainer: { gap: theme.spacing[4], padding: 0, }, fullBleedContainer: { gap: theme.spacing[2], padding: 0, }, groupHeader: { flexDirection: "row", alignItems: "center", gap: theme.spacing[2], paddingHorizontal: theme.spacing[3], paddingVertical: theme.spacing[2], borderBottomWidth: theme.borderWidth[1], borderBottomColor: theme.colors.border, }, groupHeaderText: { color: theme.colors.foregroundMuted, fontSize: theme.fontSize.sm, fontWeight: theme.fontWeight.normal, }, section: { gap: theme.spacing[2], }, fillHeight: { flex: 1, minHeight: 0, }, plainTextSection: { gap: theme.spacing[2], padding: theme.spacing[3], }, plainText: { fontFamily: Fonts.sans, fontSize: theme.fontSize.base, color: theme.colors.foreground, lineHeight: 22, overflowWrap: "anywhere", }, 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, ...(isWeb ? { whiteSpace: "pre", overflowWrap: "normal", } : null), }, shellPrompt: { color: theme.colors.foregroundMuted, }, subAgentSessionText: { fontFamily: Fonts.mono, fontSize: theme.fontSize.xs, color: theme.colors.foregroundMuted, lineHeight: 18, marginBottom: theme.spacing[2], }, subAgentActions: { gap: theme.spacing[1], marginBottom: theme.spacing[2], }, subAgentActionRow: { flexDirection: "row", alignItems: "center", gap: theme.spacing[2], }, subAgentActionTool: { fontFamily: Fonts.mono, fontSize: theme.fontSize.xs, color: theme.colors.foregroundMuted, lineHeight: 18, }, subAgentActionSummary: { fontFamily: Fonts.mono, fontSize: theme.fontSize.xs, color: theme.colors.foreground, lineHeight: 18, }, 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", }, loadingContainer: { gap: theme.spacing[2], padding: theme.spacing[3], }, loadingLineWide: { height: 12, width: "100%", borderRadius: theme.borderRadius.full, backgroundColor: theme.colors.surface3, }, loadingLineMedium: { height: 12, width: "72%", borderRadius: theme.borderRadius.full, backgroundColor: theme.colors.surface3, }, loadingLineShort: { height: 12, width: "48%", borderRadius: theme.borderRadius.full, backgroundColor: theme.colors.surface3, }, }; }); const SECTION_TITLE_ERROR_STYLE = [styles.sectionTitle, styles.errorText]; const SCROLL_TEXT_ERROR_STYLE = [styles.scrollText, styles.errorText];