diff --git a/packages/app/src/components/agent-stream-view.tsx b/packages/app/src/components/agent-stream-view.tsx
index 570f31f23..a5cfc0d4c 100644
--- a/packages/app/src/components/agent-stream-view.tsx
+++ b/packages/app/src/components/agent-stream-view.tsx
@@ -280,9 +280,6 @@ export function AgentStreamView({
result={data.result}
error={data.error}
status={data.status as "executing" | "completed" | "failed"}
- parsedEditEntries={data.parsedEdits}
- parsedReadEntries={data.parsedReads}
- parsedCommandDetails={data.parsedCommand ?? null}
cwd={agent.cwd}
/>
);
diff --git a/packages/app/src/components/message.tsx b/packages/app/src/components/message.tsx
index 6e12a0faa..d58deda56 100644
--- a/packages/app/src/components/message.tsx
+++ b/packages/app/src/components/message.tsx
@@ -22,16 +22,16 @@ import {
Search,
Brain,
} from "lucide-react-native";
-import { StyleSheet, useUnistyles } from "react-native-unistyles";
+import { StyleSheet, useUnistyles, UnistylesRuntime } from "react-native-unistyles";
import { baseColors, theme } from "@/styles/theme";
import { createMarkdownStyles, createCompactMarkdownStyles } from "@/styles/markdown-styles";
import { Colors, Fonts } from "@/constants/theme";
import * as Clipboard from "expo-clipboard";
import type { TodoEntry, ThoughtStatus } from "@/types/stream";
-import type { CommandDetails, EditEntry, ReadEntry } from "@/utils/tool-call-parsers";
import { extractPrincipalParam } from "@/utils/tool-call-parsers";
import { resolveToolCallPreview } from "./tool-call-preview";
import { useToolCallSheet } from "./tool-call-sheet";
+import { ToolCallDetailsContent, useToolCallDetails } from "./tool-call-details";
interface UserMessageProps {
message: string;
@@ -845,7 +845,7 @@ interface ExpandableBadgeProps {
secondaryLabel?: string;
icon?: ComponentType<{ size?: number; color?: string }>;
isExpanded: boolean;
- onToggle: () => void;
+ onToggle?: () => void;
renderDetails?: () => ReactNode;
isLoading?: boolean;
isError?: boolean;
@@ -1110,9 +1110,6 @@ interface ToolCallProps {
result?: any;
error?: any;
status: "executing" | "completed" | "failed";
- parsedEditEntries?: EditEntry[];
- parsedReadEntries?: ReadEntry[];
- parsedCommandDetails?: CommandDetails | null;
cwd?: string;
}
@@ -1141,12 +1138,14 @@ export const ToolCall = memo(function ToolCall({
result,
error,
status,
- parsedEditEntries,
- parsedReadEntries,
- parsedCommandDetails,
cwd,
}: ToolCallProps) {
const { openToolCall } = useToolCallSheet();
+ const [isExpanded, setIsExpanded] = useState(false);
+
+ // Check if we're on mobile (use bottom sheet) or desktop (inline expand)
+ const isMobile =
+ UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm";
const kind = getToolKindFromName(toolName);
const IconComponent = toolKindIcons[kind] || Wrench;
@@ -1157,33 +1156,56 @@ export const ToolCall = memo(function ToolCall({
[args, cwd]
);
- // Check if there's any content to display in the sheet
+ // Check if there's any content to display
const hasDetails = args !== undefined || result !== undefined || error !== undefined;
- const handleOpenSheet = useCallback(() => {
- openToolCall({
- toolName,
- kind,
- status,
- args,
- result,
- error,
- });
- }, [openToolCall, toolName, kind, status, args, result, error]);
+ // Parse tool call details for inline rendering
+ const { display, errorText } = useToolCallDetails({ args, result, error });
- // Dummy renderDetails to make badge tappable - actual content is rendered in the sheet
- const dummyRenderDetails = useCallback(() => null, []);
+ const handleToggle = useCallback(() => {
+ if (isMobile) {
+ // Mobile: open bottom sheet
+ openToolCall({
+ toolName,
+ kind,
+ status,
+ args,
+ result,
+ error,
+ });
+ } else {
+ // Desktop: toggle inline expansion
+ setIsExpanded((prev) => !prev);
+ }
+ }, [isMobile, openToolCall, toolName, kind, status, args, result, error]);
+
+ // Render inline details for desktop
+ const renderDetails = useCallback(() => {
+ if (isMobile) return null;
+ return (
+
+
+
+ );
+ }, [isMobile, display, errorText]);
return (
null : undefined)}
isLoading={status === "executing"}
isError={status === "failed"}
/>
);
});
+
+const toolCallInlineStyles = StyleSheet.create((theme) => ({
+ detailsContainer: {
+ paddingTop: theme.spacing[3],
+ paddingBottom: theme.spacing[2],
+ },
+}));
diff --git a/packages/app/src/components/tool-call-details.tsx b/packages/app/src/components/tool-call-details.tsx
new file mode 100644
index 000000000..c8b294fb2
--- /dev/null
+++ b/packages/app/src/components/tool-call-details.tsx
@@ -0,0 +1,318 @@
+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 {
+ parseToolCallDisplay,
+ buildLineDiff,
+ type ToolCallDisplay,
+} from "@/utils/tool-call-parsers";
+import { DiffViewer } from "./diff-viewer";
+
+// ---- Types ----
+
+export interface ToolCallDetailsData {
+ args?: unknown;
+ result?: unknown;
+ error?: unknown;
+}
+
+// ---- Helper ----
+
+function formatValue(value: unknown): string {
+ if (value === undefined) {
+ return "";
+ }
+ if (typeof value === "string") {
+ return value;
+ }
+ 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;
+ return buildLineDiff(display.oldString, display.newString);
+ }, [display]);
+
+ const sections: ReactNode[] = [];
+
+ if (display.type === "shell") {
+ sections.push(
+
+ Command
+
+ {display.command}
+
+ {display.output ? (
+
+
+ {display.output}
+
+
+ ) : null}
+
+ );
+ } else if (display.type === "edit") {
+ sections.push(
+
+ File
+
+ {display.filePath}
+
+ {diffLines && diffLines.length > 0 ? (
+
+
+
+ ) : null}
+
+ );
+ } else if (display.type === "read") {
+ sections.push(
+
+ File
+
+ {display.filePath}
+
+ {(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 {
+ // 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 { args, result, error } = data;
+
+ return useMemo(() => {
+ const display = parseToolCallDisplay(args, result);
+ const errorText = error !== undefined ? formatValue(error) : undefined;
+ return { display, errorText };
+ }, [args, result, error]);
+}
+
+// ---- Styles ----
+
+const styles = StyleSheet.create((theme) => ({
+ container: {
+ gap: theme.spacing[4],
+ },
+ 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.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: Fonts.mono,
+ fontSize: theme.fontSize.xs,
+ },
+ rangeText: {
+ color: theme.colors.mutedForeground,
+ 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,
+ backgroundColor: theme.colors.card,
+ },
+ scrollContent: {
+ padding: theme.spacing[2],
+ },
+ scrollText: {
+ 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.card,
+ },
+ 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",
+ },
+}));
diff --git a/packages/app/src/components/tool-call-sheet.tsx b/packages/app/src/components/tool-call-sheet.tsx
index 2a3de9613..e67996612 100644
--- a/packages/app/src/components/tool-call-sheet.tsx
+++ b/packages/app/src/components/tool-call-sheet.tsx
@@ -7,17 +7,14 @@ import React, {
ReactNode,
} from "react";
import { View, Text, Pressable } from "react-native";
-import { ScrollView } from "react-native-gesture-handler";
import { StyleSheet } from "react-native-unistyles";
-import { Fonts } from "@/constants/theme";
import {
BottomSheetModal,
BottomSheetScrollView,
BottomSheetBackdrop,
} from "@gorhom/bottom-sheet";
import { Pencil, Eye, SquareTerminal, Search, Wrench, X } from "lucide-react-native";
-import { parseToolCallDisplay, buildLineDiff, type DiffLine } from "@/utils/tool-call-parsers";
-import { DiffViewer } from "./diff-viewer";
+import { ToolCallDetailsContent, useToolCallDetails } from "./tool-call-details";
// ----- Types -----
@@ -56,22 +53,6 @@ const toolKindIcons: Record (error !== undefined ? formatValue(error) : ""),
- [error]
- );
-
- // Parse tool call display using discriminated union
- const toolCallDisplay = useMemo(
- () => parseToolCallDisplay(args, result),
- [args, result]
- );
-
- // Compute diff lines for edit type
- const editDiffLines = useMemo((): DiffLine[] => {
- if (toolCallDisplay.type !== "edit") return [];
- return buildLineDiff(toolCallDisplay.oldString, toolCallDisplay.newString);
- }, [toolCallDisplay]);
-
- // Render content sections
- const renderSections = useCallback(() => {
- const sections: ReactNode[] = [];
-
- if (toolCallDisplay.type === "shell") {
- // Shell tool: show command and output as single block
- sections.push(
-
- Command
-
- {toolCallDisplay.command}
-
- {toolCallDisplay.output ? (
-
-
- {toolCallDisplay.output}
-
-
- ) : null}
-
- );
- } else if (toolCallDisplay.type === "edit") {
- // Edit tool: show file path and diff
- sections.push(
-
- File
-
- {toolCallDisplay.filePath}
-
- {editDiffLines.length > 0 ? (
-
-
-
- ) : null}
-
- );
- } else if (toolCallDisplay.type === "read") {
- // Read tool: show file path and content
- sections.push(
-
- File
-
- {toolCallDisplay.filePath}
-
- {(toolCallDisplay.offset !== undefined || toolCallDisplay.limit !== undefined) ? (
-
- {toolCallDisplay.offset !== undefined ? `Offset: ${toolCallDisplay.offset}` : ""}
- {toolCallDisplay.offset !== undefined && toolCallDisplay.limit !== undefined ? " • " : ""}
- {toolCallDisplay.limit !== undefined ? `Limit: ${toolCallDisplay.limit}` : ""}
-
- ) : null}
- {toolCallDisplay.content ? (
-
-
- {toolCallDisplay.content}
-
-
- ) : null}
-
- );
- } else {
- // Generic tool: show input/output as key-value pairs
- if (toolCallDisplay.input.length > 0) {
- sections.push(
-
- Input
-
- );
- toolCallDisplay.input.forEach((pair, index) => {
- sections.push(
-
- {pair.key}
-
- {pair.value}
-
-
- );
- });
- }
-
- if (toolCallDisplay.output.length > 0) {
- sections.push(
-
- Output
-
- );
- toolCallDisplay.output.forEach((pair, index) => {
- sections.push(
-
- {pair.key}
-
- {pair.value}
-
-
- );
- });
- }
- }
-
- // Always show errors if available
- if (error !== undefined) {
- sections.push(
-
- Error
-
- {serializedError}
-
-
- );
- }
-
- if (sections.length === 0) {
- return (
- No additional details available
- );
- }
-
- return sections;
- }, [toolCallDisplay, editDiffLines, error, serializedError]);
+ const { display, errorText } = useToolCallDetails({ args, result, error });
return (
@@ -349,7 +153,7 @@ function ToolCallSheetContent({ data, onClose }: ToolCallSheetContentProps) {
style={styles.content}
contentContainerStyle={styles.contentContainer}
>
- {renderSections()}
+
);
@@ -406,92 +210,5 @@ const styles = StyleSheet.create((theme) => ({
paddingHorizontal: theme.spacing[4],
paddingTop: theme.spacing[4],
paddingBottom: theme.spacing[8],
- gap: theme.spacing[6],
- },
- 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.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: Fonts.mono,
- fontSize: theme.fontSize.xs,
- },
- rangeText: {
- color: theme.colors.mutedForeground,
- 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,
- backgroundColor: theme.colors.card,
- },
- scrollContent: {
- padding: theme.spacing[2],
- },
- scrollText: {
- 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.card,
- },
- 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",
},
}));