ToolCall component had parsedEditEntries, parsedReadEntries, and parsedCommandDetails props defined but never used them. Now prefers these pre-parsed props when available, enabling Codex apply_patch diffs to render correctly in the expanded view. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
1671 lines
46 KiB
TypeScript
1671 lines
46 KiB
TypeScript
import { View, Text, Pressable, Animated, ScrollView } from "react-native";
|
|
import { useState, useEffect, useRef, memo, useMemo, useCallback } from "react";
|
|
import type { ReactNode, ComponentType } from "react";
|
|
import type { AgentProvider } from "@server/server/agent/agent-sdk-types";
|
|
import { getAgentProviderDefinition } from "@server/server/agent/provider-manifest";
|
|
import Markdown from "react-native-markdown-display";
|
|
import {
|
|
Circle,
|
|
Info,
|
|
CheckCircle,
|
|
XCircle,
|
|
FileText,
|
|
ChevronRight,
|
|
ChevronDown,
|
|
Loader2,
|
|
Check,
|
|
X,
|
|
Wrench,
|
|
Pencil,
|
|
Eye,
|
|
SquareTerminal,
|
|
Search,
|
|
Brain,
|
|
} from "lucide-react-native";
|
|
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
|
import { baseColors, theme } from "@/styles/theme";
|
|
import { Colors } from "@/constants/theme";
|
|
import * as Clipboard from "expo-clipboard";
|
|
import type { TodoEntry, ThoughtStatus } from "@/types/stream";
|
|
import type { CommandDetails, EditEntry, ReadEntry, DiffLine } from "@/utils/tool-call-parsers";
|
|
import { DiffViewer } from "./diff-viewer";
|
|
import { resolveToolCallPreview } from "./tool-call-preview";
|
|
|
|
interface UserMessageProps {
|
|
message: string;
|
|
timestamp: number;
|
|
}
|
|
|
|
const userMessageStylesheet = StyleSheet.create((theme) => ({
|
|
container: {
|
|
flexDirection: "row",
|
|
justifyContent: "flex-end",
|
|
marginBottom: theme.spacing[3],
|
|
paddingHorizontal: theme.spacing[4],
|
|
},
|
|
bubble: {
|
|
backgroundColor: theme.colors.primary,
|
|
borderRadius: theme.borderRadius["2xl"],
|
|
borderTopRightRadius: theme.borderRadius.sm,
|
|
paddingHorizontal: theme.spacing[4],
|
|
paddingVertical: theme.spacing[3],
|
|
maxWidth: "80%",
|
|
},
|
|
text: {
|
|
color: theme.colors.primaryForeground,
|
|
fontSize: theme.fontSize.lg,
|
|
lineHeight: 24,
|
|
},
|
|
bubblePressed: {
|
|
opacity: 0.85,
|
|
},
|
|
copiedTagContainer: {
|
|
marginTop: theme.spacing[1],
|
|
marginRight: theme.spacing[4],
|
|
alignSelf: "flex-end",
|
|
backgroundColor: theme.colors.secondary,
|
|
borderRadius: theme.borderRadius.base,
|
|
paddingHorizontal: theme.spacing[2],
|
|
paddingVertical: 4,
|
|
},
|
|
copiedTagText: {
|
|
color: theme.colors.secondaryForeground,
|
|
fontSize: theme.fontSize.xs,
|
|
},
|
|
}));
|
|
|
|
export const UserMessage = memo(function UserMessage({
|
|
message,
|
|
timestamp,
|
|
}: UserMessageProps) {
|
|
const [copied, setCopied] = useState(false);
|
|
const copyTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
|
|
const handleLongPress = useCallback(async () => {
|
|
if (!message) {
|
|
return;
|
|
}
|
|
|
|
await Clipboard.setStringAsync(message);
|
|
setCopied(true);
|
|
|
|
if (copyTimeoutRef.current) {
|
|
clearTimeout(copyTimeoutRef.current);
|
|
}
|
|
|
|
copyTimeoutRef.current = setTimeout(() => {
|
|
setCopied(false);
|
|
copyTimeoutRef.current = null;
|
|
}, 1500);
|
|
}, [message]);
|
|
|
|
useEffect(() => {
|
|
return () => {
|
|
if (copyTimeoutRef.current) {
|
|
clearTimeout(copyTimeoutRef.current);
|
|
}
|
|
};
|
|
}, []);
|
|
|
|
return (
|
|
<View style={userMessageStylesheet.container}>
|
|
<Pressable
|
|
accessibilityRole="button"
|
|
accessibilityLabel="Copy message"
|
|
accessibilityHint="Long press to copy this message"
|
|
delayLongPress={250}
|
|
onLongPress={handleLongPress}
|
|
style={({ pressed }) => [
|
|
userMessageStylesheet.bubble,
|
|
pressed ? userMessageStylesheet.bubblePressed : null,
|
|
]}
|
|
>
|
|
<Text style={userMessageStylesheet.text}>{message}</Text>
|
|
</Pressable>
|
|
{copied && (
|
|
<View style={userMessageStylesheet.copiedTagContainer}>
|
|
<Text style={userMessageStylesheet.copiedTagText}>
|
|
Copied to clipboard
|
|
</Text>
|
|
</View>
|
|
)}
|
|
</View>
|
|
);
|
|
});
|
|
|
|
export interface InlinePathTarget {
|
|
raw: string;
|
|
path: string;
|
|
lineStart?: number;
|
|
lineEnd?: number;
|
|
}
|
|
|
|
interface AssistantMessageProps {
|
|
message: string;
|
|
timestamp: number;
|
|
isStreaming?: boolean;
|
|
onInlinePathPress?: (target: InlinePathTarget) => void;
|
|
}
|
|
|
|
export const assistantMessageStylesheet = StyleSheet.create((theme) => ({
|
|
container: {
|
|
marginBottom: theme.spacing[3],
|
|
paddingHorizontal: theme.spacing[4],
|
|
paddingVertical: theme.spacing[3],
|
|
},
|
|
streamingIndicator: {
|
|
marginTop: theme.spacing[1],
|
|
},
|
|
streamingText: {
|
|
color: theme.colors.palette.teal[200],
|
|
fontSize: theme.fontSize.xs,
|
|
fontWeight: theme.fontWeight.bold,
|
|
},
|
|
// Markdown styles
|
|
markdownBody: {
|
|
color: theme.colors.foreground,
|
|
fontSize: theme.fontSize.base,
|
|
lineHeight: 24,
|
|
},
|
|
markdownText: {
|
|
color: theme.colors.foreground,
|
|
fontSize: theme.fontSize.base,
|
|
lineHeight: 22,
|
|
},
|
|
markdownParagraph: {
|
|
marginTop: 0,
|
|
marginBottom: theme.spacing[2],
|
|
},
|
|
markdownStrong: {
|
|
fontWeight: theme.fontWeight.bold,
|
|
},
|
|
markdownEm: {
|
|
fontStyle: "italic" as const,
|
|
},
|
|
markdownCodeInline: {
|
|
backgroundColor: theme.colors.secondary,
|
|
color: theme.colors.secondaryForeground,
|
|
paddingHorizontal: theme.spacing[2],
|
|
paddingVertical: 2,
|
|
borderRadius: theme.borderRadius.sm,
|
|
fontFamily: "monospace",
|
|
fontSize: 13,
|
|
},
|
|
markdownCodeBlock: {
|
|
backgroundColor: theme.colors.secondary,
|
|
color: theme.colors.secondaryForeground,
|
|
padding: theme.spacing[3],
|
|
borderRadius: theme.borderRadius.md,
|
|
fontFamily: "monospace",
|
|
fontSize: 13,
|
|
},
|
|
markdownFence: {
|
|
backgroundColor: theme.colors.secondary,
|
|
borderColor: theme.colors.border,
|
|
color: theme.colors.secondaryForeground,
|
|
padding: theme.spacing[3],
|
|
borderRadius: theme.borderRadius.md,
|
|
marginVertical: theme.spacing[2],
|
|
fontFamily: "monospace",
|
|
fontSize: 13,
|
|
},
|
|
markdownLink: {
|
|
color: theme.colors.primary,
|
|
textDecorationLine: "underline" as const,
|
|
},
|
|
markdownList: {
|
|
marginBottom: theme.spacing[2],
|
|
},
|
|
markdownListItem: {
|
|
marginBottom: theme.spacing[1],
|
|
},
|
|
markdownBlockquote: {
|
|
backgroundColor: theme.colors.secondary,
|
|
borderLeftWidth: 4,
|
|
borderLeftColor: theme.colors.primary,
|
|
paddingHorizontal: theme.spacing[3],
|
|
paddingVertical: theme.spacing[2],
|
|
marginVertical: theme.spacing[2],
|
|
borderRadius: theme.borderRadius.sm,
|
|
},
|
|
markdownBlockquoteText: {
|
|
color: theme.colors.foreground,
|
|
fontStyle: "italic" as const,
|
|
},
|
|
pathChip: {
|
|
backgroundColor: theme.colors.secondary,
|
|
borderRadius: theme.borderRadius.full,
|
|
paddingHorizontal: theme.spacing[2],
|
|
paddingVertical: 2,
|
|
marginRight: theme.spacing[1],
|
|
marginVertical: 2,
|
|
},
|
|
pathChipText: {
|
|
color: theme.colors.secondaryForeground,
|
|
fontFamily: "monospace",
|
|
fontSize: 13,
|
|
},
|
|
}));
|
|
|
|
const expandableBadgeStylesheet = StyleSheet.create((theme) => ({
|
|
container: {
|
|
marginHorizontal: theme.spacing[2],
|
|
marginBottom: theme.spacing[2],
|
|
},
|
|
pressable: {
|
|
borderRadius: theme.borderRadius.lg,
|
|
borderWidth: theme.borderWidth[1],
|
|
borderColor: theme.colors.border,
|
|
backgroundColor: theme.colors.secondary,
|
|
paddingHorizontal: theme.spacing[2],
|
|
paddingVertical: theme.spacing[1],
|
|
},
|
|
pressablePressed: {
|
|
opacity: 0.9,
|
|
},
|
|
headerRow: {
|
|
flexDirection: "row",
|
|
alignItems: "center",
|
|
},
|
|
iconBadge: {
|
|
width: 22,
|
|
height: 22,
|
|
borderRadius: 11,
|
|
alignItems: "center",
|
|
justifyContent: "center",
|
|
marginRight: theme.spacing[2],
|
|
backgroundColor: "transparent",
|
|
},
|
|
label: {
|
|
flex: 1,
|
|
color: theme.colors.mutedForeground,
|
|
fontSize: theme.fontSize.base,
|
|
fontWeight: theme.fontWeight.normal,
|
|
},
|
|
chevron: {
|
|
marginLeft: theme.spacing[1],
|
|
},
|
|
detailWrapper: {
|
|
marginTop: theme.spacing[2],
|
|
borderRadius: theme.borderRadius.md,
|
|
borderWidth: theme.borderWidth[1],
|
|
borderColor: theme.colors.border,
|
|
backgroundColor: theme.colors.background,
|
|
padding: theme.spacing[2],
|
|
gap: theme.spacing[2],
|
|
},
|
|
}));
|
|
|
|
function isLikelyPathToken(value: string): boolean {
|
|
if (!value || value.length > 300) {
|
|
return false;
|
|
}
|
|
|
|
if (/\s/.test(value)) {
|
|
return false;
|
|
}
|
|
|
|
const hasSlash = value.includes("/") || value.includes("\\");
|
|
const hasExtension = /\.[a-zA-Z0-9]{1,8}$/.test(value);
|
|
|
|
if (!hasSlash && !hasExtension) {
|
|
return false;
|
|
}
|
|
|
|
const looksLikeDir = value.endsWith("/") || value.startsWith("./") || value.startsWith("../");
|
|
|
|
return hasExtension || looksLikeDir || value.includes("/");
|
|
}
|
|
|
|
function normalizeInlinePathValue(value: string): string | null {
|
|
const trimmed = value.trim().replace(/^['"`]/, "").replace(/['"`]$/, "");
|
|
if (!trimmed) {
|
|
return null;
|
|
}
|
|
|
|
return trimmed.replace(/\\/g, "/");
|
|
}
|
|
|
|
function parseInlinePathToken(
|
|
value: string,
|
|
lastPathRef: React.MutableRefObject<string | null>
|
|
): InlinePathTarget | null {
|
|
const rawValue = value ?? "";
|
|
const trimmed = rawValue.trim();
|
|
if (!trimmed) {
|
|
return null;
|
|
}
|
|
|
|
const rangeOnlyMatch = trimmed.match(/^:([0-9]+)(?:-([0-9]+))?$/);
|
|
if (rangeOnlyMatch) {
|
|
const basePath = lastPathRef.current;
|
|
if (!basePath) {
|
|
return null;
|
|
}
|
|
const lineStart = parseInt(rangeOnlyMatch[1], 10);
|
|
const lineEnd = rangeOnlyMatch[2] ? parseInt(rangeOnlyMatch[2], 10) : undefined;
|
|
return {
|
|
raw: rawValue,
|
|
path: basePath,
|
|
lineStart,
|
|
lineEnd,
|
|
};
|
|
}
|
|
|
|
const pathMatch = trimmed.match(/^(.*?)(?::([0-9]+)(?:-([0-9]+))?)?$/);
|
|
if (!pathMatch) {
|
|
return null;
|
|
}
|
|
|
|
const basePath = pathMatch[1]?.trim();
|
|
if (!basePath || !isLikelyPathToken(basePath)) {
|
|
return null;
|
|
}
|
|
|
|
const normalizedPath = normalizeInlinePathValue(basePath);
|
|
if (!normalizedPath) {
|
|
return null;
|
|
}
|
|
|
|
lastPathRef.current = normalizedPath;
|
|
|
|
const lineStart = pathMatch[2] ? parseInt(pathMatch[2], 10) : undefined;
|
|
const lineEnd = pathMatch[3] ? parseInt(pathMatch[3], 10) : undefined;
|
|
|
|
return {
|
|
raw: rawValue,
|
|
path: normalizedPath,
|
|
lineStart,
|
|
lineEnd,
|
|
};
|
|
}
|
|
|
|
export const AssistantMessage = memo(function AssistantMessage({
|
|
message,
|
|
timestamp,
|
|
isStreaming = false,
|
|
onInlinePathPress,
|
|
}: AssistantMessageProps) {
|
|
const { theme } = useUnistyles();
|
|
const fadeAnim = useRef(new Animated.Value(0.3)).current;
|
|
const lastPathRef = useRef<string | null>(null);
|
|
|
|
const markdownStyles = useMemo(
|
|
() => ({
|
|
body: {
|
|
color: theme.colors.foreground,
|
|
fontSize: theme.fontSize.base,
|
|
lineHeight: 24,
|
|
},
|
|
text: {
|
|
color: theme.colors.foreground,
|
|
},
|
|
paragraph: {
|
|
marginTop: 0,
|
|
marginBottom: theme.spacing[2],
|
|
},
|
|
strong: {
|
|
fontWeight: theme.fontWeight.bold,
|
|
},
|
|
em: {
|
|
fontStyle: "italic" as const,
|
|
},
|
|
code_inline: {
|
|
backgroundColor: theme.colors.secondary,
|
|
color: theme.colors.secondaryForeground,
|
|
paddingHorizontal: theme.spacing[2],
|
|
paddingVertical: 2,
|
|
borderRadius: theme.borderRadius.sm,
|
|
fontFamily: "monospace",
|
|
fontSize: 13,
|
|
},
|
|
code_block: {
|
|
backgroundColor: theme.colors.secondary,
|
|
color: theme.colors.secondaryForeground,
|
|
padding: theme.spacing[3],
|
|
borderRadius: theme.borderRadius.md,
|
|
fontFamily: "monospace",
|
|
fontSize: 13,
|
|
},
|
|
fence: {
|
|
backgroundColor: theme.colors.secondary,
|
|
borderColor: theme.colors.border,
|
|
color: theme.colors.secondaryForeground,
|
|
padding: theme.spacing[3],
|
|
borderRadius: theme.borderRadius.md,
|
|
marginVertical: theme.spacing[2],
|
|
fontFamily: "monospace",
|
|
fontSize: 13,
|
|
},
|
|
link: {
|
|
color: theme.colors.primary,
|
|
textDecorationLine: "underline" as const,
|
|
},
|
|
bullet_list: {
|
|
marginBottom: theme.spacing[2],
|
|
},
|
|
ordered_list: {
|
|
marginBottom: theme.spacing[2],
|
|
},
|
|
list_item: {
|
|
marginBottom: theme.spacing[1],
|
|
},
|
|
blockquote: {
|
|
backgroundColor: theme.colors.secondary,
|
|
borderLeftWidth: 4,
|
|
borderLeftColor: theme.colors.primary,
|
|
paddingHorizontal: theme.spacing[3],
|
|
paddingVertical: theme.spacing[2],
|
|
marginVertical: theme.spacing[2],
|
|
borderRadius: theme.borderRadius.sm,
|
|
},
|
|
blockquote_text: {
|
|
color: theme.colors.foreground,
|
|
fontStyle: "italic" as const,
|
|
},
|
|
}),
|
|
[theme]
|
|
);
|
|
|
|
const markdownRules = useMemo(() => {
|
|
if (!onInlinePathPress) {
|
|
return undefined;
|
|
}
|
|
|
|
return {
|
|
code_inline: (node: any) => {
|
|
const content = node.content ?? "";
|
|
const parsed = parseInlinePathToken(content, lastPathRef);
|
|
|
|
if (!parsed) {
|
|
return (
|
|
<Text key={node.key} style={assistantMessageStylesheet.markdownCodeInline}>
|
|
{content}
|
|
</Text>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<Text
|
|
key={node.key}
|
|
onPress={() => parsed && onInlinePathPress?.(parsed)}
|
|
style={[assistantMessageStylesheet.pathChip, assistantMessageStylesheet.pathChipText]}
|
|
>
|
|
{content}
|
|
</Text>
|
|
);
|
|
},
|
|
};
|
|
}, [onInlinePathPress]);
|
|
|
|
useEffect(() => {
|
|
if (isStreaming) {
|
|
Animated.loop(
|
|
Animated.sequence([
|
|
Animated.timing(fadeAnim, {
|
|
toValue: 1,
|
|
duration: 600,
|
|
useNativeDriver: true,
|
|
}),
|
|
Animated.timing(fadeAnim, {
|
|
toValue: 0.3,
|
|
duration: 600,
|
|
useNativeDriver: true,
|
|
}),
|
|
])
|
|
).start();
|
|
} else {
|
|
fadeAnim.stopAnimation();
|
|
fadeAnim.setValue(1);
|
|
}
|
|
}, [isStreaming, fadeAnim]);
|
|
|
|
return (
|
|
<View style={assistantMessageStylesheet.container}>
|
|
<Markdown style={markdownStyles} rules={markdownRules}>
|
|
{message}
|
|
</Markdown>
|
|
{isStreaming && (
|
|
<Animated.View
|
|
style={[
|
|
assistantMessageStylesheet.streamingIndicator,
|
|
{ opacity: fadeAnim },
|
|
]}
|
|
>
|
|
<Text style={assistantMessageStylesheet.streamingText}>...</Text>
|
|
</Animated.View>
|
|
)}
|
|
</View>
|
|
);
|
|
});
|
|
|
|
interface ActivityLogProps {
|
|
type: "system" | "info" | "success" | "error" | "artifact";
|
|
message: string;
|
|
timestamp: number;
|
|
metadata?: Record<string, unknown>;
|
|
artifactId?: string;
|
|
artifactType?: string;
|
|
title?: string;
|
|
onArtifactClick?: (artifactId: string) => void;
|
|
}
|
|
|
|
const activityLogStylesheet = StyleSheet.create((theme) => ({
|
|
pressable: {
|
|
marginHorizontal: theme.spacing[2],
|
|
marginBottom: theme.spacing[1],
|
|
borderRadius: theme.borderRadius.md,
|
|
overflow: "hidden",
|
|
},
|
|
pressableActive: {
|
|
opacity: 0.7,
|
|
},
|
|
systemBg: {
|
|
backgroundColor: "rgba(39, 39, 42, 0.5)",
|
|
},
|
|
infoBg: {
|
|
backgroundColor: "rgba(30, 58, 138, 0.3)",
|
|
},
|
|
successBg: {
|
|
backgroundColor: "rgba(20, 83, 45, 0.3)",
|
|
},
|
|
errorBg: {
|
|
backgroundColor: "rgba(127, 29, 29, 0.3)",
|
|
},
|
|
artifactBg: {
|
|
backgroundColor: "rgba(30, 58, 138, 0.4)",
|
|
},
|
|
content: {
|
|
paddingHorizontal: theme.spacing[3],
|
|
paddingVertical: 10,
|
|
},
|
|
row: {
|
|
flexDirection: "row",
|
|
alignItems: "flex-start",
|
|
gap: theme.spacing[2],
|
|
},
|
|
iconContainer: {
|
|
flexShrink: 0,
|
|
},
|
|
textContainer: {
|
|
flex: 1,
|
|
},
|
|
messageText: {
|
|
fontSize: theme.fontSize.sm,
|
|
lineHeight: 20,
|
|
},
|
|
detailsRow: {
|
|
flexDirection: "row",
|
|
alignItems: "center",
|
|
marginTop: theme.spacing[1],
|
|
},
|
|
detailsText: {
|
|
color: theme.colors.mutedForeground,
|
|
fontSize: theme.fontSize.xs,
|
|
marginRight: theme.spacing[1],
|
|
},
|
|
metadataContainer: {
|
|
marginTop: theme.spacing[2],
|
|
backgroundColor: "rgba(0, 0, 0, 0.5)",
|
|
borderRadius: theme.borderRadius.base,
|
|
padding: theme.spacing[2],
|
|
borderWidth: theme.borderWidth[1],
|
|
borderColor: theme.colors.border,
|
|
},
|
|
metadataText: {
|
|
color: theme.colors.foreground,
|
|
fontSize: theme.fontSize.xs,
|
|
fontFamily: "monospace",
|
|
lineHeight: 16,
|
|
},
|
|
}));
|
|
|
|
export const ActivityLog = memo(function ActivityLog({
|
|
type,
|
|
message,
|
|
timestamp,
|
|
metadata,
|
|
artifactId,
|
|
artifactType,
|
|
title,
|
|
onArtifactClick,
|
|
}: ActivityLogProps) {
|
|
const [isExpanded, setIsExpanded] = useState(false);
|
|
|
|
const typeConfig = {
|
|
system: {
|
|
bg: activityLogStylesheet.systemBg,
|
|
color: "#a1a1aa",
|
|
Icon: Circle,
|
|
},
|
|
info: { bg: activityLogStylesheet.infoBg, color: "#60a5fa", Icon: Info },
|
|
success: {
|
|
bg: activityLogStylesheet.successBg,
|
|
color: "#4ade80",
|
|
Icon: CheckCircle,
|
|
},
|
|
error: {
|
|
bg: activityLogStylesheet.errorBg,
|
|
color: "#f87171",
|
|
Icon: XCircle,
|
|
},
|
|
artifact: {
|
|
bg: activityLogStylesheet.artifactBg,
|
|
color: "#93c5fd",
|
|
Icon: FileText,
|
|
},
|
|
};
|
|
|
|
const config = typeConfig[type];
|
|
const IconComponent = config.Icon;
|
|
|
|
const handlePress = () => {
|
|
if (type === "artifact" && artifactId && onArtifactClick) {
|
|
onArtifactClick(artifactId);
|
|
} else if (metadata) {
|
|
setIsExpanded(!isExpanded);
|
|
}
|
|
};
|
|
|
|
const displayMessage =
|
|
type === "artifact" && artifactType && title
|
|
? `${artifactType}: ${title}`
|
|
: message;
|
|
|
|
const isInteractive = type === "artifact" || metadata;
|
|
|
|
return (
|
|
<Pressable
|
|
onPress={handlePress}
|
|
disabled={!isInteractive}
|
|
style={[
|
|
activityLogStylesheet.pressable,
|
|
config.bg,
|
|
isInteractive && activityLogStylesheet.pressableActive,
|
|
]}
|
|
>
|
|
<View style={activityLogStylesheet.content}>
|
|
<View style={activityLogStylesheet.row}>
|
|
<View style={activityLogStylesheet.iconContainer}>
|
|
<IconComponent size={16} color={config.color} />
|
|
</View>
|
|
<View style={activityLogStylesheet.textContainer}>
|
|
<Text
|
|
style={[
|
|
activityLogStylesheet.messageText,
|
|
{ color: config.color },
|
|
]}
|
|
>
|
|
{displayMessage}
|
|
</Text>
|
|
{metadata && (
|
|
<View style={activityLogStylesheet.detailsRow}>
|
|
<Text style={activityLogStylesheet.detailsText}>Details</Text>
|
|
{isExpanded ? (
|
|
<ChevronDown size={12} color="#71717a" />
|
|
) : (
|
|
<ChevronRight size={12} color="#71717a" />
|
|
)}
|
|
</View>
|
|
)}
|
|
</View>
|
|
</View>
|
|
{isExpanded && metadata && (
|
|
<View style={activityLogStylesheet.metadataContainer}>
|
|
<Text style={activityLogStylesheet.metadataText}>
|
|
{JSON.stringify(metadata, null, 2)}
|
|
</Text>
|
|
</View>
|
|
)}
|
|
</View>
|
|
</Pressable>
|
|
);
|
|
});
|
|
|
|
interface TodoListCardProps {
|
|
provider: AgentProvider;
|
|
timestamp: number;
|
|
items: TodoEntry[];
|
|
}
|
|
|
|
function formatPlanTimestamp(timestamp: number): string {
|
|
try {
|
|
return new Intl.DateTimeFormat("en-US", {
|
|
hour: "numeric",
|
|
minute: "2-digit",
|
|
second: "2-digit",
|
|
}).format(new Date(timestamp));
|
|
} catch {
|
|
return new Date(timestamp).toLocaleTimeString();
|
|
}
|
|
}
|
|
|
|
const todoListCardStylesheet = StyleSheet.create((theme) => ({
|
|
container: {
|
|
marginHorizontal: theme.spacing[2],
|
|
marginBottom: theme.spacing[2],
|
|
},
|
|
card: {
|
|
backgroundColor: theme.colors.card,
|
|
borderRadius: theme.borderRadius.lg,
|
|
borderWidth: theme.borderWidth[1],
|
|
borderColor: theme.colors.border,
|
|
padding: theme.spacing[3],
|
|
},
|
|
header: {
|
|
flexDirection: "row",
|
|
alignItems: "center",
|
|
justifyContent: "space-between",
|
|
marginBottom: theme.spacing[2],
|
|
},
|
|
headerMeta: {
|
|
flexDirection: "column",
|
|
gap: theme.spacing[0],
|
|
},
|
|
title: {
|
|
color: theme.colors.foreground,
|
|
fontSize: theme.fontSize.sm,
|
|
fontWeight: theme.fontWeight.semibold,
|
|
textTransform: "uppercase",
|
|
letterSpacing: 0.6,
|
|
},
|
|
timestamp: {
|
|
color: theme.colors.mutedForeground,
|
|
fontSize: theme.fontSize.xs,
|
|
},
|
|
providerBadge: {
|
|
backgroundColor: "rgba(59, 130, 246, 0.15)",
|
|
borderRadius: theme.borderRadius.full,
|
|
paddingHorizontal: theme.spacing[2],
|
|
paddingVertical: theme.spacing[1],
|
|
},
|
|
providerText: {
|
|
color: "#93c5fd",
|
|
fontSize: theme.fontSize.xs,
|
|
fontWeight: theme.fontWeight.semibold,
|
|
},
|
|
progressText: {
|
|
color: theme.colors.mutedForeground,
|
|
fontSize: theme.fontSize.xs,
|
|
marginBottom: theme.spacing[2],
|
|
},
|
|
list: {
|
|
gap: theme.spacing[2],
|
|
},
|
|
itemRow: {
|
|
flexDirection: "row",
|
|
alignItems: "center",
|
|
gap: theme.spacing[2],
|
|
},
|
|
checkbox: {
|
|
width: 24,
|
|
height: 24,
|
|
borderRadius: theme.borderRadius.base,
|
|
borderWidth: theme.borderWidth[1],
|
|
borderColor: theme.colors.border,
|
|
alignItems: "center",
|
|
justifyContent: "center",
|
|
backgroundColor: "transparent",
|
|
},
|
|
checkboxCompleted: {
|
|
backgroundColor: theme.colors.primary,
|
|
borderColor: theme.colors.primary,
|
|
},
|
|
itemText: {
|
|
flex: 1,
|
|
color: theme.colors.foreground,
|
|
fontSize: theme.fontSize.sm,
|
|
},
|
|
itemTextCompleted: {
|
|
color: theme.colors.mutedForeground,
|
|
textDecorationLine: "line-through",
|
|
},
|
|
emptyText: {
|
|
color: theme.colors.mutedForeground,
|
|
fontSize: theme.fontSize.sm,
|
|
fontStyle: "italic",
|
|
},
|
|
}));
|
|
|
|
export const TodoListCard = memo(function TodoListCard({
|
|
provider,
|
|
timestamp,
|
|
items,
|
|
}: TodoListCardProps) {
|
|
const providerLabel = useMemo(() => {
|
|
const definition = getAgentProviderDefinition(provider);
|
|
return definition?.label ?? provider;
|
|
}, [provider]);
|
|
|
|
const completedCount = useMemo(
|
|
() => items.filter((item) => item.completed).length,
|
|
[items]
|
|
);
|
|
|
|
const timestampLabel = useMemo(() => formatPlanTimestamp(timestamp), [timestamp]);
|
|
|
|
const iconColor = theme.colors.background;
|
|
|
|
return (
|
|
<View style={todoListCardStylesheet.container}>
|
|
<View style={todoListCardStylesheet.card}>
|
|
<View style={todoListCardStylesheet.header}>
|
|
<View style={todoListCardStylesheet.headerMeta}>
|
|
<Text style={todoListCardStylesheet.title}>Plan</Text>
|
|
<Text style={todoListCardStylesheet.timestamp}>{timestampLabel}</Text>
|
|
</View>
|
|
<View style={todoListCardStylesheet.providerBadge}>
|
|
<Text style={todoListCardStylesheet.providerText}>{providerLabel}</Text>
|
|
</View>
|
|
</View>
|
|
<Text style={todoListCardStylesheet.progressText}>
|
|
{items.length > 0
|
|
? `${completedCount}/${items.length} completed`
|
|
: "Waiting for tasks..."}
|
|
</Text>
|
|
<View style={todoListCardStylesheet.list}>
|
|
{items.length === 0 ? (
|
|
<Text style={todoListCardStylesheet.emptyText}>
|
|
No todo items shared yet.
|
|
</Text>
|
|
) : (
|
|
items.map((item, idx) => (
|
|
<View key={`${item.text}-${idx}`} style={todoListCardStylesheet.itemRow}>
|
|
<View
|
|
style={[
|
|
todoListCardStylesheet.checkbox,
|
|
item.completed && todoListCardStylesheet.checkboxCompleted,
|
|
]}
|
|
>
|
|
{item.completed && <Check size={14} color={iconColor} />}
|
|
</View>
|
|
<Text
|
|
style={[
|
|
todoListCardStylesheet.itemText,
|
|
item.completed && todoListCardStylesheet.itemTextCompleted,
|
|
]}
|
|
>
|
|
{item.text}
|
|
</Text>
|
|
</View>
|
|
))
|
|
)}
|
|
</View>
|
|
</View>
|
|
</View>
|
|
);
|
|
});
|
|
|
|
interface AgentThoughtMessageProps {
|
|
message: string;
|
|
status?: ThoughtStatus;
|
|
}
|
|
|
|
interface ExpandableBadgeProps {
|
|
label: string;
|
|
icon?: ComponentType<{ size?: number; color?: string }>;
|
|
isExpanded: boolean;
|
|
onToggle: () => void;
|
|
renderDetails?: () => ReactNode;
|
|
isLoading?: boolean;
|
|
isError?: boolean;
|
|
}
|
|
|
|
const ExpandableBadge = memo(function ExpandableBadge({
|
|
label,
|
|
icon,
|
|
isExpanded,
|
|
onToggle,
|
|
renderDetails,
|
|
isLoading = false,
|
|
isError = false,
|
|
}: ExpandableBadgeProps) {
|
|
const { theme } = useUnistyles();
|
|
const hasDetails = Boolean(renderDetails);
|
|
const detailContent = hasDetails && isExpanded ? renderDetails?.() : null;
|
|
|
|
const spinAnim = useRef(new Animated.Value(0)).current;
|
|
|
|
useEffect(() => {
|
|
let loop: Animated.CompositeAnimation | null = null;
|
|
if (isLoading) {
|
|
spinAnim.setValue(0);
|
|
loop = Animated.loop(
|
|
Animated.timing(spinAnim, {
|
|
toValue: 1,
|
|
duration: 1400,
|
|
useNativeDriver: true,
|
|
easing: (t) => t,
|
|
})
|
|
);
|
|
loop.start();
|
|
} else {
|
|
spinAnim.stopAnimation();
|
|
}
|
|
|
|
return () => {
|
|
loop?.stop();
|
|
};
|
|
}, [isLoading, spinAnim]);
|
|
|
|
const spin = spinAnim.interpolate({
|
|
inputRange: [0, 1],
|
|
outputRange: ["0deg", "360deg"],
|
|
});
|
|
|
|
const IconComponent = icon;
|
|
const iconColor = isError
|
|
? theme.colors.destructive
|
|
: theme.colors.foreground;
|
|
|
|
let iconNode: ReactNode = null;
|
|
if (isLoading) {
|
|
iconNode = (
|
|
<Animated.View style={{ transform: [{ rotate: spin }] }}>
|
|
<Loader2 size={12} color={iconColor} />
|
|
</Animated.View>
|
|
);
|
|
} else if (isError) {
|
|
iconNode = <X size={12} color={iconColor} />;
|
|
} else if (IconComponent) {
|
|
iconNode = <IconComponent size={12} color={iconColor} />;
|
|
}
|
|
|
|
return (
|
|
<View style={expandableBadgeStylesheet.container}>
|
|
<Pressable
|
|
onPress={hasDetails ? onToggle : undefined}
|
|
disabled={!hasDetails}
|
|
accessibilityRole={hasDetails ? "button" : undefined}
|
|
accessibilityState={hasDetails ? { expanded: isExpanded } : undefined}
|
|
style={({ pressed }) => [
|
|
expandableBadgeStylesheet.pressable,
|
|
pressed && hasDetails ? expandableBadgeStylesheet.pressablePressed : null,
|
|
]}
|
|
>
|
|
<View style={expandableBadgeStylesheet.headerRow}>
|
|
<View style={expandableBadgeStylesheet.iconBadge}>{iconNode}</View>
|
|
<Text style={expandableBadgeStylesheet.label} numberOfLines={1}>
|
|
{label}
|
|
</Text>
|
|
{hasDetails ? (
|
|
<ChevronRight
|
|
size={14}
|
|
color={theme.colors.mutedForeground}
|
|
style={[
|
|
expandableBadgeStylesheet.chevron,
|
|
{ transform: [{ rotate: isExpanded ? "90deg" : "0deg" }] },
|
|
]}
|
|
/>
|
|
) : null}
|
|
</View>
|
|
{detailContent ? (
|
|
<View style={expandableBadgeStylesheet.detailWrapper}>{detailContent}</View>
|
|
) : null}
|
|
</Pressable>
|
|
</View>
|
|
);
|
|
});
|
|
|
|
const agentThoughtStylesheet = StyleSheet.create((theme) => ({
|
|
emptyText: {
|
|
color: theme.colors.mutedForeground,
|
|
fontSize: theme.fontSize.xs,
|
|
fontStyle: "italic" as const,
|
|
},
|
|
}));
|
|
|
|
export function AgentThoughtMessage({ message, status = "ready" }: AgentThoughtMessageProps) {
|
|
const { theme } = useUnistyles();
|
|
const [isExpanded, setIsExpanded] = useState(false);
|
|
const markdownContent = useMemo(() => message?.trim() ?? "", [message]);
|
|
const markdownStyles = useMemo(
|
|
() => ({
|
|
body: {
|
|
color: theme.colors.foreground,
|
|
fontSize: theme.fontSize.sm,
|
|
lineHeight: 20,
|
|
},
|
|
text: {
|
|
color: theme.colors.foreground,
|
|
},
|
|
paragraph: {
|
|
marginBottom: theme.spacing[2],
|
|
},
|
|
strong: {
|
|
fontWeight: theme.fontWeight.semibold,
|
|
},
|
|
em: {
|
|
fontStyle: "italic" as const,
|
|
},
|
|
code_inline: {
|
|
backgroundColor: theme.colors.secondary,
|
|
color: theme.colors.secondaryForeground,
|
|
paddingHorizontal: theme.spacing[1],
|
|
paddingVertical: 2,
|
|
borderRadius: theme.borderRadius.sm,
|
|
fontFamily: "monospace",
|
|
fontSize: theme.fontSize.xs,
|
|
},
|
|
code_block: {
|
|
backgroundColor: theme.colors.secondary,
|
|
color: theme.colors.secondaryForeground,
|
|
padding: theme.spacing[3],
|
|
borderRadius: theme.borderRadius.md,
|
|
fontFamily: "monospace",
|
|
fontSize: theme.fontSize.sm,
|
|
},
|
|
}),
|
|
[theme]
|
|
);
|
|
|
|
const toggle = useCallback(() => {
|
|
setIsExpanded((prev) => !prev);
|
|
}, []);
|
|
|
|
const renderDetails = useCallback(() => {
|
|
if (!markdownContent) {
|
|
return <Text style={agentThoughtStylesheet.emptyText}>No captured thinking</Text>;
|
|
}
|
|
return <Markdown style={markdownStyles}>{markdownContent}</Markdown>;
|
|
}, [markdownContent, markdownStyles]);
|
|
|
|
return (
|
|
<ExpandableBadge
|
|
label="Thinking"
|
|
icon={status === "ready" ? Brain : undefined}
|
|
isExpanded={isExpanded}
|
|
onToggle={toggle}
|
|
renderDetails={renderDetails}
|
|
isLoading={status !== "ready"}
|
|
/>
|
|
);
|
|
}
|
|
|
|
interface ToolCallProps {
|
|
toolName: string;
|
|
kind?: string; // Optional kind for ACP tool calls
|
|
args: any;
|
|
result?: any;
|
|
error?: any;
|
|
status: "executing" | "completed" | "failed";
|
|
parsedEditEntries?: EditEntry[];
|
|
parsedReadEntries?: ReadEntry[];
|
|
parsedCommandDetails?: CommandDetails | null;
|
|
}
|
|
|
|
const toolCallStylesheet = StyleSheet.create((theme) => ({
|
|
detailContent: {
|
|
gap: theme.spacing[3],
|
|
},
|
|
section: {
|
|
gap: theme.spacing[1],
|
|
},
|
|
sectionTitle: {
|
|
color: theme.colors.mutedForeground,
|
|
fontSize: theme.fontSize.xs,
|
|
fontWeight: theme.fontWeight.semibold,
|
|
textTransform: "uppercase",
|
|
letterSpacing: 0.5,
|
|
},
|
|
fileBadge: {
|
|
alignSelf: "flex-start",
|
|
paddingHorizontal: theme.spacing[2],
|
|
paddingVertical: theme.spacing[1],
|
|
borderRadius: theme.borderRadius.base,
|
|
borderWidth: theme.borderWidth[1],
|
|
borderColor: theme.colors.border,
|
|
backgroundColor: theme.colors.card,
|
|
},
|
|
fileBadgeText: {
|
|
color: theme.colors.foreground,
|
|
fontFamily: "monospace",
|
|
fontSize: theme.fontSize.xs,
|
|
},
|
|
diffContainer: {
|
|
borderWidth: theme.borderWidth[1],
|
|
borderColor: theme.colors.border,
|
|
borderRadius: theme.borderRadius.base,
|
|
overflow: "hidden",
|
|
backgroundColor: theme.colors.card,
|
|
},
|
|
scrollArea: {
|
|
borderWidth: theme.borderWidth[1],
|
|
borderColor: theme.colors.border,
|
|
borderRadius: theme.borderRadius.base,
|
|
maxHeight: 260,
|
|
},
|
|
scrollContent: {
|
|
padding: theme.spacing[2],
|
|
},
|
|
scrollText: {
|
|
fontFamily: "monospace",
|
|
fontSize: theme.fontSize.xs,
|
|
color: theme.colors.foreground,
|
|
lineHeight: 18,
|
|
},
|
|
jsonScroll: {
|
|
borderWidth: theme.borderWidth[1],
|
|
borderColor: theme.colors.border,
|
|
borderRadius: theme.borderRadius.base,
|
|
},
|
|
jsonScrollError: {
|
|
borderColor: theme.colors.destructive,
|
|
},
|
|
jsonContent: {
|
|
padding: theme.spacing[2],
|
|
},
|
|
errorText: {
|
|
color: theme.colors.destructive,
|
|
},
|
|
emptyStateText: {
|
|
color: theme.colors.mutedForeground,
|
|
fontSize: theme.fontSize.sm,
|
|
fontStyle: "italic",
|
|
},
|
|
metaRow: {
|
|
flexDirection: "row",
|
|
alignItems: "center",
|
|
gap: theme.spacing[2],
|
|
},
|
|
metaLabel: {
|
|
color: theme.colors.mutedForeground,
|
|
fontSize: theme.fontSize.xs,
|
|
textTransform: "uppercase",
|
|
letterSpacing: 0.5,
|
|
},
|
|
metaValue: {
|
|
color: theme.colors.foreground,
|
|
fontFamily: "monospace",
|
|
fontSize: theme.fontSize.xs,
|
|
flex: 1,
|
|
},
|
|
}));
|
|
|
|
// Icon mapping for tool kinds
|
|
const toolKindIcons: Record<string, any> = {
|
|
edit: Pencil,
|
|
read: Eye,
|
|
execute: SquareTerminal,
|
|
search: Search,
|
|
// Add more mappings as needed
|
|
};
|
|
|
|
function formatFullValue(value: unknown): string {
|
|
if (value === undefined) {
|
|
return "";
|
|
}
|
|
if (typeof value === "string") {
|
|
return value;
|
|
}
|
|
try {
|
|
return JSON.stringify(value, null, 2);
|
|
} catch {
|
|
return String(value);
|
|
}
|
|
}
|
|
|
|
// Helper function to build diff lines (mirrors logic from tool-call-parsers)
|
|
function buildLineDiffFromStrings(originalText: string, updatedText: string): DiffLine[] {
|
|
const splitIntoLines = (text: string): string[] => {
|
|
if (!text) return [];
|
|
return text.replace(/\r\n/g, "\n").split("\n");
|
|
};
|
|
|
|
const originalLines = splitIntoLines(originalText);
|
|
const updatedLines = splitIntoLines(updatedText);
|
|
|
|
const hasAnyContent = originalLines.length > 0 || updatedLines.length > 0;
|
|
if (!hasAnyContent) return [];
|
|
|
|
const m = originalLines.length;
|
|
const n = updatedLines.length;
|
|
const dp: number[][] = Array.from({ length: m + 1 }, () => Array(n + 1).fill(0));
|
|
|
|
for (let i = m - 1; i >= 0; i -= 1) {
|
|
for (let j = n - 1; j >= 0; j -= 1) {
|
|
if (originalLines[i] === updatedLines[j]) {
|
|
dp[i][j] = dp[i + 1][j + 1] + 1;
|
|
} else {
|
|
dp[i][j] = Math.max(dp[i + 1][j], dp[i][j + 1]);
|
|
}
|
|
}
|
|
}
|
|
|
|
const diff: DiffLine[] = [];
|
|
let i = 0;
|
|
let j = 0;
|
|
|
|
while (i < m && j < n) {
|
|
if (originalLines[i] === updatedLines[j]) {
|
|
diff.push({ type: "context", content: ` ${originalLines[i]}` });
|
|
i += 1;
|
|
j += 1;
|
|
} else if (dp[i + 1][j] >= dp[i][j + 1]) {
|
|
diff.push({ type: "remove", content: `-${originalLines[i]}` });
|
|
i += 1;
|
|
} else {
|
|
diff.push({ type: "add", content: `+${updatedLines[j]}` });
|
|
j += 1;
|
|
}
|
|
}
|
|
|
|
while (i < m) {
|
|
diff.push({ type: "remove", content: `-${originalLines[i]}` });
|
|
i += 1;
|
|
}
|
|
|
|
while (j < n) {
|
|
diff.push({ type: "add", content: `+${updatedLines[j]}` });
|
|
j += 1;
|
|
}
|
|
|
|
return diff;
|
|
}
|
|
|
|
// Type guard for structured tool results
|
|
type StructuredToolResult = {
|
|
type: "command" | "file_write" | "file_edit" | "file_read" | "generic";
|
|
[key: string]: unknown;
|
|
};
|
|
|
|
function isStructuredToolResult(result: unknown): result is StructuredToolResult {
|
|
return (
|
|
typeof result === "object" &&
|
|
result !== null &&
|
|
"type" in result &&
|
|
typeof result.type === "string" &&
|
|
["command", "file_write", "file_edit", "file_read", "generic"].includes(result.type)
|
|
);
|
|
}
|
|
|
|
export const ToolCall = memo(function ToolCall({
|
|
toolName,
|
|
kind,
|
|
args,
|
|
result,
|
|
error,
|
|
status,
|
|
parsedEditEntries,
|
|
parsedReadEntries,
|
|
parsedCommandDetails,
|
|
}: ToolCallProps) {
|
|
const [isExpanded, setIsExpanded] = useState(false);
|
|
|
|
// Check if result has a type field for structured rendering
|
|
const structuredResult = useMemo(
|
|
() => (isStructuredToolResult(result) ? result : null),
|
|
[result]
|
|
);
|
|
|
|
const IconComponent = kind
|
|
? toolKindIcons[kind.toLowerCase()] || Wrench
|
|
: Wrench;
|
|
|
|
const serializedArgs = useMemo(
|
|
() => (args !== undefined ? formatFullValue(args) : ""),
|
|
[args]
|
|
);
|
|
const serializedResult = useMemo(
|
|
() => (result !== undefined ? formatFullValue(result) : ""),
|
|
[result]
|
|
);
|
|
const serializedError = useMemo(
|
|
() => (error !== undefined ? formatFullValue(error) : ""),
|
|
[error]
|
|
);
|
|
|
|
const handleToggle = useCallback(() => {
|
|
setIsExpanded((prev) => !prev);
|
|
}, []);
|
|
|
|
// Helper functions to extract data from structured results
|
|
const extractCommandFromStructured = useCallback(
|
|
(structured: StructuredToolResult): CommandDetails | null => {
|
|
if (structured.type !== "command") return null;
|
|
|
|
const cmd: CommandDetails = {};
|
|
if (typeof structured.command === "string") cmd.command = structured.command;
|
|
if (typeof structured.cwd === "string") cmd.cwd = structured.cwd;
|
|
if (typeof structured.output === "string") cmd.output = structured.output;
|
|
if (typeof structured.exitCode === "number") cmd.exitCode = structured.exitCode;
|
|
|
|
return cmd.command || cmd.output ? cmd : null;
|
|
},
|
|
[]
|
|
);
|
|
|
|
const extractDiffFromStructured = useCallback(
|
|
(structured: StructuredToolResult): EditEntry[] => {
|
|
if (structured.type !== "file_write" && structured.type !== "file_edit") {
|
|
return [];
|
|
}
|
|
|
|
const filePath = typeof structured.filePath === "string" ? structured.filePath : undefined;
|
|
|
|
// For file_write, create a diff from oldContent -> newContent
|
|
if (structured.type === "file_write") {
|
|
const oldContent = typeof structured.oldContent === "string" ? structured.oldContent : "";
|
|
const newContent = typeof structured.newContent === "string" ? structured.newContent : "";
|
|
|
|
// Use the same diff building logic from tool-call-parsers
|
|
const diffLines = buildLineDiffFromStrings(oldContent, newContent);
|
|
if (diffLines.length > 0) {
|
|
return [{ filePath, diffLines }];
|
|
}
|
|
}
|
|
|
|
// For file_edit, it might already have diffLines or we need to construct them
|
|
if (structured.type === "file_edit") {
|
|
// Check if diffLines are provided directly
|
|
if (Array.isArray(structured.diffLines)) {
|
|
return [{ filePath, diffLines: structured.diffLines as DiffLine[] }];
|
|
}
|
|
|
|
// Otherwise try to build from old/new content
|
|
const oldContent = typeof structured.oldContent === "string" ? structured.oldContent : "";
|
|
const newContent = typeof structured.newContent === "string" ? structured.newContent : "";
|
|
const diffLines = buildLineDiffFromStrings(oldContent, newContent);
|
|
if (diffLines.length > 0) {
|
|
return [{ filePath, diffLines }];
|
|
}
|
|
}
|
|
|
|
return [];
|
|
},
|
|
[]
|
|
);
|
|
|
|
const extractReadFromStructured = useCallback(
|
|
(structured: StructuredToolResult): ReadEntry[] => {
|
|
if (structured.type !== "file_read") return [];
|
|
|
|
const filePath = typeof structured.filePath === "string" ? structured.filePath : undefined;
|
|
const content = typeof structured.content === "string" ? structured.content : "";
|
|
|
|
if (content) {
|
|
return [{ filePath, content }];
|
|
}
|
|
|
|
return [];
|
|
},
|
|
[]
|
|
);
|
|
|
|
const renderDetails = useCallback(() => {
|
|
// If we have a structured result, use type-based rendering
|
|
if (structuredResult) {
|
|
const sections: ReactNode[] = [];
|
|
|
|
// Render based on type
|
|
switch (structuredResult.type) {
|
|
case "command": {
|
|
// Use pre-parsed command details if available, otherwise extract from structured result
|
|
const cmd = parsedCommandDetails ?? extractCommandFromStructured(structuredResult);
|
|
if (cmd) {
|
|
// Reuse the command section rendering logic
|
|
sections.push(
|
|
<View key="command" style={toolCallStylesheet.section}>
|
|
<Text style={toolCallStylesheet.sectionTitle}>Command</Text>
|
|
{cmd.command ? (
|
|
<ScrollView
|
|
horizontal
|
|
nestedScrollEnabled
|
|
style={toolCallStylesheet.jsonScroll}
|
|
contentContainerStyle={toolCallStylesheet.jsonContent}
|
|
showsHorizontalScrollIndicator={true}
|
|
>
|
|
<Text style={toolCallStylesheet.scrollText}>{cmd.command}</Text>
|
|
</ScrollView>
|
|
) : null}
|
|
{cmd.cwd ? (
|
|
<View style={toolCallStylesheet.metaRow}>
|
|
<Text style={toolCallStylesheet.metaLabel}>Directory</Text>
|
|
<Text style={toolCallStylesheet.metaValue}>{cmd.cwd}</Text>
|
|
</View>
|
|
) : null}
|
|
{cmd.exitCode !== undefined ? (
|
|
<View style={toolCallStylesheet.metaRow}>
|
|
<Text style={toolCallStylesheet.metaLabel}>Exit Code</Text>
|
|
<Text style={toolCallStylesheet.metaValue}>
|
|
{cmd.exitCode === null ? "Unknown" : cmd.exitCode}
|
|
</Text>
|
|
</View>
|
|
) : null}
|
|
{cmd.output ? (
|
|
<ScrollView
|
|
style={toolCallStylesheet.scrollArea}
|
|
contentContainerStyle={toolCallStylesheet.scrollContent}
|
|
nestedScrollEnabled
|
|
showsVerticalScrollIndicator={true}
|
|
>
|
|
<Text style={toolCallStylesheet.scrollText}>{cmd.output}</Text>
|
|
</ScrollView>
|
|
) : null}
|
|
</View>
|
|
);
|
|
}
|
|
break;
|
|
}
|
|
|
|
case "file_write":
|
|
case "file_edit": {
|
|
// Use pre-parsed entries if available, otherwise extract from structured result
|
|
const diffs = parsedEditEntries?.length
|
|
? parsedEditEntries
|
|
: extractDiffFromStructured(structuredResult);
|
|
diffs.forEach((entry, index) => {
|
|
sections.push(
|
|
<View
|
|
key={`diff-${index}`}
|
|
style={toolCallStylesheet.section}
|
|
>
|
|
<Text style={toolCallStylesheet.sectionTitle}>Diff</Text>
|
|
{entry.filePath ? (
|
|
<View style={toolCallStylesheet.fileBadge}>
|
|
<Text style={toolCallStylesheet.fileBadgeText}>{entry.filePath}</Text>
|
|
</View>
|
|
) : null}
|
|
<View style={toolCallStylesheet.diffContainer}>
|
|
<DiffViewer diffLines={entry.diffLines} maxHeight={240} />
|
|
</View>
|
|
</View>
|
|
);
|
|
});
|
|
break;
|
|
}
|
|
|
|
case "file_read": {
|
|
// Use pre-parsed entries if available, otherwise extract from structured result
|
|
const reads = parsedReadEntries?.length
|
|
? parsedReadEntries
|
|
: extractReadFromStructured(structuredResult);
|
|
reads.forEach((entry, index) => {
|
|
sections.push(
|
|
<View
|
|
key={`read-${index}`}
|
|
style={toolCallStylesheet.section}
|
|
>
|
|
<Text style={toolCallStylesheet.sectionTitle}>Read Result</Text>
|
|
{entry.filePath ? (
|
|
<View style={toolCallStylesheet.fileBadge}>
|
|
<Text style={toolCallStylesheet.fileBadgeText}>{entry.filePath}</Text>
|
|
</View>
|
|
) : null}
|
|
<ScrollView
|
|
style={toolCallStylesheet.scrollArea}
|
|
contentContainerStyle={toolCallStylesheet.scrollContent}
|
|
nestedScrollEnabled
|
|
showsVerticalScrollIndicator={true}
|
|
>
|
|
<Text style={toolCallStylesheet.scrollText}>{entry.content}</Text>
|
|
</ScrollView>
|
|
</View>
|
|
);
|
|
});
|
|
break;
|
|
}
|
|
|
|
case "generic":
|
|
default: {
|
|
// Show raw JSON for generic type
|
|
if (result !== undefined) {
|
|
sections.push(
|
|
<View key="result" style={toolCallStylesheet.section}>
|
|
<Text style={toolCallStylesheet.sectionTitle}>Result</Text>
|
|
<ScrollView
|
|
horizontal
|
|
nestedScrollEnabled
|
|
style={toolCallStylesheet.jsonScroll}
|
|
contentContainerStyle={toolCallStylesheet.jsonContent}
|
|
showsHorizontalScrollIndicator={true}
|
|
>
|
|
<Text style={toolCallStylesheet.scrollText}>{serializedResult}</Text>
|
|
</ScrollView>
|
|
</View>
|
|
);
|
|
}
|
|
break;
|
|
}
|
|
}
|
|
|
|
// Always show args if available
|
|
if (args !== undefined) {
|
|
sections.unshift(
|
|
<View key="args" style={toolCallStylesheet.section}>
|
|
<Text style={toolCallStylesheet.sectionTitle}>Arguments</Text>
|
|
<ScrollView
|
|
horizontal
|
|
nestedScrollEnabled
|
|
style={toolCallStylesheet.jsonScroll}
|
|
contentContainerStyle={toolCallStylesheet.jsonContent}
|
|
showsHorizontalScrollIndicator={true}
|
|
>
|
|
<Text style={toolCallStylesheet.scrollText}>{serializedArgs}</Text>
|
|
</ScrollView>
|
|
</View>
|
|
);
|
|
}
|
|
|
|
// Always show errors if available
|
|
if (error !== undefined) {
|
|
sections.push(
|
|
<View key="error" style={toolCallStylesheet.section}>
|
|
<Text style={toolCallStylesheet.sectionTitle}>Error</Text>
|
|
<ScrollView
|
|
horizontal
|
|
nestedScrollEnabled
|
|
style={[toolCallStylesheet.jsonScroll, toolCallStylesheet.jsonScrollError]}
|
|
contentContainerStyle={toolCallStylesheet.jsonContent}
|
|
showsHorizontalScrollIndicator={true}
|
|
>
|
|
<Text style={[toolCallStylesheet.scrollText, toolCallStylesheet.errorText]}>
|
|
{serializedError}
|
|
</Text>
|
|
</ScrollView>
|
|
</View>
|
|
);
|
|
}
|
|
|
|
if (sections.length === 0) {
|
|
return (
|
|
<Text style={toolCallStylesheet.emptyStateText}>
|
|
No additional details available
|
|
</Text>
|
|
);
|
|
}
|
|
|
|
return <View style={toolCallStylesheet.detailContent}>{sections}</View>;
|
|
}
|
|
|
|
// No structured result - show raw JSON
|
|
const sections: ReactNode[] = [];
|
|
|
|
if (args !== undefined) {
|
|
sections.push(
|
|
<View key="args" style={toolCallStylesheet.section}>
|
|
<Text style={toolCallStylesheet.sectionTitle}>Arguments</Text>
|
|
<ScrollView
|
|
horizontal
|
|
nestedScrollEnabled
|
|
style={toolCallStylesheet.jsonScroll}
|
|
contentContainerStyle={toolCallStylesheet.jsonContent}
|
|
showsHorizontalScrollIndicator={true}
|
|
>
|
|
<Text style={toolCallStylesheet.scrollText}>{serializedArgs}</Text>
|
|
</ScrollView>
|
|
</View>
|
|
);
|
|
}
|
|
|
|
if (result !== undefined) {
|
|
sections.push(
|
|
<View key="result" style={toolCallStylesheet.section}>
|
|
<Text style={toolCallStylesheet.sectionTitle}>Result</Text>
|
|
<ScrollView
|
|
horizontal
|
|
nestedScrollEnabled
|
|
style={toolCallStylesheet.jsonScroll}
|
|
contentContainerStyle={toolCallStylesheet.jsonContent}
|
|
showsHorizontalScrollIndicator={true}
|
|
>
|
|
<Text style={toolCallStylesheet.scrollText}>{serializedResult}</Text>
|
|
</ScrollView>
|
|
</View>
|
|
);
|
|
}
|
|
|
|
if (error !== undefined) {
|
|
sections.push(
|
|
<View key="error" style={toolCallStylesheet.section}>
|
|
<Text style={toolCallStylesheet.sectionTitle}>Error</Text>
|
|
<ScrollView
|
|
horizontal
|
|
nestedScrollEnabled
|
|
style={[toolCallStylesheet.jsonScroll, toolCallStylesheet.jsonScrollError]}
|
|
contentContainerStyle={toolCallStylesheet.jsonContent}
|
|
showsHorizontalScrollIndicator={true}
|
|
>
|
|
<Text style={[toolCallStylesheet.scrollText, toolCallStylesheet.errorText]}>
|
|
{serializedError}
|
|
</Text>
|
|
</ScrollView>
|
|
</View>
|
|
);
|
|
}
|
|
|
|
if (sections.length === 0) {
|
|
return (
|
|
<Text style={toolCallStylesheet.emptyStateText}>
|
|
No additional details available
|
|
</Text>
|
|
);
|
|
}
|
|
|
|
return <View style={toolCallStylesheet.detailContent}>{sections}</View>;
|
|
}, [
|
|
structuredResult,
|
|
extractCommandFromStructured,
|
|
extractDiffFromStructured,
|
|
extractReadFromStructured,
|
|
serializedArgs,
|
|
serializedResult,
|
|
serializedError,
|
|
args,
|
|
result,
|
|
error,
|
|
]);
|
|
|
|
return (
|
|
<ExpandableBadge
|
|
label={toolName}
|
|
icon={IconComponent}
|
|
isExpanded={isExpanded}
|
|
onToggle={handleToggle}
|
|
renderDetails={renderDetails}
|
|
isLoading={status === "executing"}
|
|
isError={status === "failed"}
|
|
/>
|
|
);
|
|
});
|