feat(tool-call-sheet): improve MCP tool call display
- Extract key-value pairs from tool arguments and results - Add Input/Output group headers with visual separation - Fix Playwright MCP tool calls not being clickable (pass data.input as args) - Increase default sheet height to 60% - Add extractKeyValuePairs utility function using Zod 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.5
parent
914cebc914
commit
8add1a6620
@@ -290,7 +290,7 @@ export function AgentStreamView({
|
|||||||
<ToolCall
|
<ToolCall
|
||||||
toolName={toolLabel}
|
toolName={toolLabel}
|
||||||
kind={data.kind}
|
kind={data.kind}
|
||||||
args={undefined}
|
args={data.input}
|
||||||
result={data.result}
|
result={data.result}
|
||||||
error={data.error}
|
error={data.error}
|
||||||
status={data.status as "executing" | "completed" | "failed"}
|
status={data.status as "executing" | "completed" | "failed"}
|
||||||
@@ -594,7 +594,7 @@ function WorkingIndicator() {
|
|||||||
};
|
};
|
||||||
}, [dotOne, dotTwo, dotThree]);
|
}, [dotOne, dotTwo, dotThree]);
|
||||||
|
|
||||||
const translateDistance = -4;
|
const translateDistance = -2;
|
||||||
const dotOneStyle = useAnimatedStyle(() => ({
|
const dotOneStyle = useAnimatedStyle(() => ({
|
||||||
opacity: 0.3 + dotOne.value * 0.7,
|
opacity: 0.3 + dotOne.value * 0.7,
|
||||||
transform: [{ translateY: dotOne.value * translateDistance }],
|
transform: [{ translateY: dotOne.value * translateDistance }],
|
||||||
@@ -612,7 +612,6 @@ function WorkingIndicator() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<View style={stylesheet.workingIndicatorBubble}>
|
<View style={stylesheet.workingIndicatorBubble}>
|
||||||
<Text style={stylesheet.workingIndicatorText}>Working</Text>
|
|
||||||
<View style={stylesheet.workingDotsRow}>
|
<View style={stylesheet.workingDotsRow}>
|
||||||
<Animated.View style={[stylesheet.workingDot, dotOneStyle]} />
|
<Animated.View style={[stylesheet.workingDot, dotOneStyle]} />
|
||||||
<Animated.View style={[stylesheet.workingDot, dotTwoStyle]} />
|
<Animated.View style={[stylesheet.workingDot, dotTwoStyle]} />
|
||||||
@@ -1066,6 +1065,9 @@ const stylesheet = StyleSheet.create((theme) => ({
|
|||||||
},
|
},
|
||||||
workingIndicatorWrapper: {
|
workingIndicatorWrapper: {
|
||||||
alignItems: "flex-start",
|
alignItems: "flex-start",
|
||||||
|
paddingLeft: 3,
|
||||||
|
paddingTop: theme.spacing[3],
|
||||||
|
paddingBottom: theme.spacing[2],
|
||||||
},
|
},
|
||||||
workingIndicatorBubble: {
|
workingIndicatorBubble: {
|
||||||
flexDirection: "row",
|
flexDirection: "row",
|
||||||
@@ -1079,11 +1081,6 @@ const stylesheet = StyleSheet.create((theme) => ({
|
|||||||
borderWidth: 0,
|
borderWidth: 0,
|
||||||
alignSelf: "flex-start",
|
alignSelf: "flex-start",
|
||||||
},
|
},
|
||||||
workingIndicatorText: {
|
|
||||||
color: theme.colors.mutedForeground,
|
|
||||||
fontSize: theme.fontSize.sm,
|
|
||||||
fontWeight: theme.fontWeight.semibold,
|
|
||||||
},
|
|
||||||
workingDotsRow: {
|
workingDotsRow: {
|
||||||
flexDirection: "row",
|
flexDirection: "row",
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
|
|||||||
@@ -15,7 +15,14 @@ import {
|
|||||||
} from "@gorhom/bottom-sheet";
|
} from "@gorhom/bottom-sheet";
|
||||||
import { Pencil, Eye, SquareTerminal, Search, Wrench, X } from "lucide-react-native";
|
import { Pencil, Eye, SquareTerminal, Search, Wrench, X } from "lucide-react-native";
|
||||||
import { DiffViewer } from "./diff-viewer";
|
import { DiffViewer } from "./diff-viewer";
|
||||||
import type { EditEntry, ReadEntry, CommandDetails, DiffLine } from "@/utils/tool-call-parsers";
|
import {
|
||||||
|
extractKeyValuePairs,
|
||||||
|
type EditEntry,
|
||||||
|
type ReadEntry,
|
||||||
|
type CommandDetails,
|
||||||
|
type DiffLine,
|
||||||
|
type KeyValuePair,
|
||||||
|
} from "@/utils/tool-call-parsers";
|
||||||
|
|
||||||
// ----- Types -----
|
// ----- Types -----
|
||||||
|
|
||||||
@@ -157,7 +164,7 @@ export function ToolCallSheetProvider({ children }: ToolCallSheetProviderProps)
|
|||||||
const bottomSheetRef = useRef<BottomSheetModal>(null);
|
const bottomSheetRef = useRef<BottomSheetModal>(null);
|
||||||
const [sheetData, setSheetData] = React.useState<ToolCallSheetData | null>(null);
|
const [sheetData, setSheetData] = React.useState<ToolCallSheetData | null>(null);
|
||||||
|
|
||||||
const snapPoints = useMemo(() => ["50%", "90%"], []);
|
const snapPoints = useMemo(() => ["60%", "90%"], []);
|
||||||
|
|
||||||
const openToolCall = useCallback((data: ToolCallSheetData) => {
|
const openToolCall = useCallback((data: ToolCallSheetData) => {
|
||||||
setSheetData(data);
|
setSheetData(data);
|
||||||
@@ -321,31 +328,73 @@ function ToolCallSheetContent({ data, onClose }: ToolCallSheetContentProps) {
|
|||||||
// Render content sections
|
// Render content sections
|
||||||
const renderSections = useCallback(() => {
|
const renderSections = useCallback(() => {
|
||||||
const sections: ReactNode[] = [];
|
const sections: ReactNode[] = [];
|
||||||
|
let hasOutput = false;
|
||||||
|
|
||||||
// Always show args first if available
|
// Always show args first if available
|
||||||
if (args !== undefined) {
|
if (args !== undefined) {
|
||||||
|
// Add Input group header
|
||||||
sections.push(
|
sections.push(
|
||||||
<View key="args" style={styles.section}>
|
<View key="input-header" style={styles.groupHeader}>
|
||||||
<Text style={styles.sectionTitle}>Arguments</Text>
|
<Text style={styles.groupHeaderText}>Input</Text>
|
||||||
<ScrollView
|
|
||||||
horizontal
|
|
||||||
nestedScrollEnabled
|
|
||||||
style={styles.jsonScroll}
|
|
||||||
contentContainerStyle={styles.jsonContent}
|
|
||||||
showsHorizontalScrollIndicator={true}
|
|
||||||
>
|
|
||||||
<Text style={styles.scrollText}>{serializedArgs}</Text>
|
|
||||||
</ScrollView>
|
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const argPairs = extractKeyValuePairs(args);
|
||||||
|
if (argPairs.length > 0) {
|
||||||
|
argPairs.forEach((pair, index) => {
|
||||||
|
sections.push(
|
||||||
|
<View key={`arg-${index}-${pair.key}`} style={styles.section}>
|
||||||
|
<Text style={styles.sectionTitle}>{pair.key}</Text>
|
||||||
|
<ScrollView
|
||||||
|
horizontal
|
||||||
|
nestedScrollEnabled
|
||||||
|
style={styles.jsonScroll}
|
||||||
|
contentContainerStyle={styles.jsonContent}
|
||||||
|
showsHorizontalScrollIndicator={true}
|
||||||
|
>
|
||||||
|
<Text style={styles.scrollText}>{pair.value}</Text>
|
||||||
|
</ScrollView>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
// Fallback to raw JSON display
|
||||||
|
sections.push(
|
||||||
|
<View key="args" style={styles.section}>
|
||||||
|
<Text style={styles.sectionTitle}>Arguments</Text>
|
||||||
|
<ScrollView
|
||||||
|
horizontal
|
||||||
|
nestedScrollEnabled
|
||||||
|
style={styles.jsonScroll}
|
||||||
|
contentContainerStyle={styles.jsonContent}
|
||||||
|
showsHorizontalScrollIndicator={true}
|
||||||
|
>
|
||||||
|
<Text style={styles.scrollText}>{serializedArgs}</Text>
|
||||||
|
</ScrollView>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Helper to add output header once
|
||||||
|
const addOutputHeader = () => {
|
||||||
|
if (!hasOutput) {
|
||||||
|
hasOutput = true;
|
||||||
|
sections.push(
|
||||||
|
<View key="output-header" style={styles.groupHeader}>
|
||||||
|
<Text style={styles.groupHeaderText}>Output</Text>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
// Render based on structured result type or raw data
|
// Render based on structured result type or raw data
|
||||||
if (structuredResult) {
|
if (structuredResult) {
|
||||||
switch (structuredResult.type) {
|
switch (structuredResult.type) {
|
||||||
case "command": {
|
case "command": {
|
||||||
const cmd = parsedCommandDetails ?? extractCommandFromStructured(structuredResult);
|
const cmd = parsedCommandDetails ?? extractCommandFromStructured(structuredResult);
|
||||||
if (cmd) {
|
if (cmd) {
|
||||||
|
addOutputHeader();
|
||||||
sections.push(
|
sections.push(
|
||||||
<View key="command" style={styles.section}>
|
<View key="command" style={styles.section}>
|
||||||
<Text style={styles.sectionTitle}>Command</Text>
|
<Text style={styles.sectionTitle}>Command</Text>
|
||||||
@@ -395,6 +444,9 @@ function ToolCallSheetContent({ data, onClose }: ToolCallSheetContentProps) {
|
|||||||
const diffs = parsedEditEntries?.length
|
const diffs = parsedEditEntries?.length
|
||||||
? parsedEditEntries
|
? parsedEditEntries
|
||||||
: extractDiffFromStructured(structuredResult);
|
: extractDiffFromStructured(structuredResult);
|
||||||
|
if (diffs.length > 0) {
|
||||||
|
addOutputHeader();
|
||||||
|
}
|
||||||
diffs.forEach((entry, index) => {
|
diffs.forEach((entry, index) => {
|
||||||
sections.push(
|
sections.push(
|
||||||
<View key={`diff-${index}`} style={styles.section}>
|
<View key={`diff-${index}`} style={styles.section}>
|
||||||
@@ -417,6 +469,9 @@ function ToolCallSheetContent({ data, onClose }: ToolCallSheetContentProps) {
|
|||||||
const reads = parsedReadEntries?.length
|
const reads = parsedReadEntries?.length
|
||||||
? parsedReadEntries
|
? parsedReadEntries
|
||||||
: extractReadFromStructured(structuredResult);
|
: extractReadFromStructured(structuredResult);
|
||||||
|
if (reads.length > 0) {
|
||||||
|
addOutputHeader();
|
||||||
|
}
|
||||||
reads.forEach((entry, index) => {
|
reads.forEach((entry, index) => {
|
||||||
sections.push(
|
sections.push(
|
||||||
<View key={`read-${index}`} style={styles.section}>
|
<View key={`read-${index}`} style={styles.section}>
|
||||||
@@ -444,6 +499,7 @@ function ToolCallSheetContent({ data, onClose }: ToolCallSheetContentProps) {
|
|||||||
default: {
|
default: {
|
||||||
if (result !== undefined && sections.length === 1) {
|
if (result !== undefined && sections.length === 1) {
|
||||||
// Only args shown, add result
|
// Only args shown, add result
|
||||||
|
addOutputHeader();
|
||||||
sections.push(
|
sections.push(
|
||||||
<View key="result" style={styles.section}>
|
<View key="result" style={styles.section}>
|
||||||
<Text style={styles.sectionTitle}>Result</Text>
|
<Text style={styles.sectionTitle}>Result</Text>
|
||||||
@@ -463,28 +519,50 @@ function ToolCallSheetContent({ data, onClose }: ToolCallSheetContentProps) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else if (result !== undefined) {
|
} else if (result !== undefined) {
|
||||||
// No structured result - show raw result
|
// No structured result - try to extract key-value pairs
|
||||||
sections.push(
|
addOutputHeader();
|
||||||
<View key="result" style={styles.section}>
|
const keyValuePairs = extractKeyValuePairs(result);
|
||||||
<Text style={styles.sectionTitle}>Result</Text>
|
if (keyValuePairs.length > 0) {
|
||||||
<ScrollView
|
keyValuePairs.forEach((pair, index) => {
|
||||||
horizontal
|
sections.push(
|
||||||
nestedScrollEnabled
|
<View key={`kv-${index}-${pair.key}`} style={styles.section}>
|
||||||
style={styles.jsonScroll}
|
<Text style={styles.sectionTitle}>{pair.key}</Text>
|
||||||
contentContainerStyle={styles.jsonContent}
|
<ScrollView
|
||||||
showsHorizontalScrollIndicator={true}
|
horizontal
|
||||||
>
|
nestedScrollEnabled
|
||||||
<Text style={styles.scrollText}>{serializedResult}</Text>
|
style={styles.jsonScroll}
|
||||||
</ScrollView>
|
contentContainerStyle={styles.jsonContent}
|
||||||
</View>
|
showsHorizontalScrollIndicator={true}
|
||||||
);
|
>
|
||||||
|
<Text style={styles.scrollText}>{pair.value}</Text>
|
||||||
|
</ScrollView>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
// Fallback to raw JSON display
|
||||||
|
sections.push(
|
||||||
|
<View key="result" style={styles.section}>
|
||||||
|
<Text style={styles.sectionTitle}>Result</Text>
|
||||||
|
<ScrollView
|
||||||
|
horizontal
|
||||||
|
nestedScrollEnabled
|
||||||
|
style={styles.jsonScroll}
|
||||||
|
contentContainerStyle={styles.jsonContent}
|
||||||
|
showsHorizontalScrollIndicator={true}
|
||||||
|
>
|
||||||
|
<Text style={styles.scrollText}>{serializedResult}</Text>
|
||||||
|
</ScrollView>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Always show errors if available
|
// Always show errors if available
|
||||||
if (error !== undefined) {
|
if (error !== undefined) {
|
||||||
sections.push(
|
sections.push(
|
||||||
<View key="error" style={styles.section}>
|
<View key="error" style={styles.section}>
|
||||||
<Text style={styles.sectionTitle}>Error</Text>
|
<Text style={[styles.sectionTitle, styles.errorText]}>Error</Text>
|
||||||
<ScrollView
|
<ScrollView
|
||||||
horizontal
|
horizontal
|
||||||
nestedScrollEnabled
|
nestedScrollEnabled
|
||||||
@@ -644,8 +722,24 @@ const styles = StyleSheet.create((theme) => ({
|
|||||||
},
|
},
|
||||||
contentContainer: {
|
contentContainer: {
|
||||||
paddingHorizontal: theme.spacing[4],
|
paddingHorizontal: theme.spacing[4],
|
||||||
paddingVertical: theme.spacing[4],
|
paddingTop: theme.spacing[4],
|
||||||
gap: 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: {
|
section: {
|
||||||
gap: theme.spacing[2],
|
gap: theme.spacing[2],
|
||||||
|
|||||||
@@ -704,3 +704,51 @@ export function extractCommandDetails(...sources: unknown[]): CommandDetails | n
|
|||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---- Key-Value Extraction for Generic Tool Results ----
|
||||||
|
|
||||||
|
export interface KeyValuePair {
|
||||||
|
key: string;
|
||||||
|
value: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const WrappedOutputSchema = z
|
||||||
|
.object({ output: z.record(z.unknown()) })
|
||||||
|
.transform((data) => data.output);
|
||||||
|
|
||||||
|
const DirectRecordSchema = z.record(z.unknown());
|
||||||
|
|
||||||
|
const ToolResultRecordSchema = z.union([WrappedOutputSchema, DirectRecordSchema]);
|
||||||
|
|
||||||
|
function stringifyValue(value: unknown): string {
|
||||||
|
if (value === null) {
|
||||||
|
return "null";
|
||||||
|
}
|
||||||
|
if (value === undefined) {
|
||||||
|
return "undefined";
|
||||||
|
}
|
||||||
|
if (typeof value === "string") {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
if (typeof value === "number" || typeof value === "boolean") {
|
||||||
|
return String(value);
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
return JSON.stringify(value, null, 2);
|
||||||
|
} catch {
|
||||||
|
return String(value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function extractKeyValuePairs(result: unknown): KeyValuePair[] {
|
||||||
|
const parsed = ToolResultRecordSchema.safeParse(result);
|
||||||
|
if (!parsed.success) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const record = parsed.data;
|
||||||
|
return Object.entries(record).map(([key, value]) => ({
|
||||||
|
key,
|
||||||
|
value: stringifyValue(value),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user