feat(app): improve tool call sheet with word-level diff and better UX
- Add word-level highlighting using LCS algorithm for edit diffs - Match diff colors to explorer sidebar (GitHub-style rgba backgrounds) - Use discriminated union for tool call parsing (shell, edit, generic) - Add horizontal scroll for bash/command output instead of wrapping - Remove status badge from bottom sheet header - Fix sheet snap points (60%/95%) and disable dynamic sizing - Add min-width to diff viewer for full-width backgrounds 🤖 Generated with [Claude Code](https://claude.com/claude-code)
This commit is contained in:
@@ -3,7 +3,7 @@ import { View, Text } from "react-native";
|
|||||||
import { ScrollView } from "react-native-gesture-handler";
|
import { ScrollView } from "react-native-gesture-handler";
|
||||||
import { StyleSheet } from "react-native-unistyles";
|
import { StyleSheet } from "react-native-unistyles";
|
||||||
import { Fonts } from "@/constants/theme";
|
import { Fonts } from "@/constants/theme";
|
||||||
import type { DiffLine } from "@/utils/tool-call-parsers";
|
import type { DiffLine, DiffSegment } from "@/utils/tool-call-parsers";
|
||||||
|
|
||||||
interface DiffViewerProps {
|
interface DiffViewerProps {
|
||||||
diffLines: DiffLine[];
|
diffLines: DiffLine[];
|
||||||
@@ -12,6 +12,8 @@ interface DiffViewerProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function DiffViewer({ diffLines, maxHeight = 280, emptyLabel = "No changes to display" }: DiffViewerProps) {
|
export function DiffViewer({ diffLines, maxHeight = 280, emptyLabel = "No changes to display" }: DiffViewerProps) {
|
||||||
|
const [scrollViewWidth, setScrollViewWidth] = React.useState(0);
|
||||||
|
|
||||||
if (!diffLines.length) {
|
if (!diffLines.length) {
|
||||||
return (
|
return (
|
||||||
<View style={styles.emptyState}>
|
<View style={styles.emptyState}>
|
||||||
@@ -32,8 +34,9 @@ export function DiffViewer({ diffLines, maxHeight = 280, emptyLabel = "No change
|
|||||||
nestedScrollEnabled
|
nestedScrollEnabled
|
||||||
showsHorizontalScrollIndicator
|
showsHorizontalScrollIndicator
|
||||||
contentContainerStyle={styles.horizontalContent}
|
contentContainerStyle={styles.horizontalContent}
|
||||||
|
onLayout={(e) => setScrollViewWidth(e.nativeEvent.layout.width)}
|
||||||
>
|
>
|
||||||
<View style={styles.linesContainer}>
|
<View style={[styles.linesContainer, scrollViewWidth > 0 && { minWidth: scrollViewWidth }]}>
|
||||||
{diffLines.map((line, index) => (
|
{diffLines.map((line, index) => (
|
||||||
<View
|
<View
|
||||||
key={`${line.type}-${index}`}
|
key={`${line.type}-${index}`}
|
||||||
@@ -45,17 +48,36 @@ export function DiffViewer({ diffLines, maxHeight = 280, emptyLabel = "No change
|
|||||||
line.type === "context" && styles.contextLine,
|
line.type === "context" && styles.contextLine,
|
||||||
]}
|
]}
|
||||||
>
|
>
|
||||||
<Text
|
{line.segments ? (
|
||||||
style={[
|
<Text style={styles.lineText}>
|
||||||
styles.lineText,
|
<Text style={line.type === "add" ? styles.addText : styles.removeText}>
|
||||||
line.type === "header" && styles.headerText,
|
{line.content[0]}
|
||||||
line.type === "add" && styles.addText,
|
</Text>
|
||||||
line.type === "remove" && styles.removeText,
|
{line.segments.map((segment, segIdx) => (
|
||||||
line.type === "context" && styles.contextText,
|
<Text
|
||||||
]}
|
key={segIdx}
|
||||||
>
|
style={[
|
||||||
{line.content}
|
line.type === "add" ? styles.addText : styles.removeText,
|
||||||
</Text>
|
segment.changed && (line.type === "add" ? styles.addHighlight : styles.removeHighlight),
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
{segment.text}
|
||||||
|
</Text>
|
||||||
|
))}
|
||||||
|
</Text>
|
||||||
|
) : (
|
||||||
|
<Text
|
||||||
|
style={[
|
||||||
|
styles.lineText,
|
||||||
|
line.type === "header" && styles.headerText,
|
||||||
|
line.type === "add" && styles.addText,
|
||||||
|
line.type === "remove" && styles.removeText,
|
||||||
|
line.type === "context" && styles.contextText,
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
{line.content}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
</View>
|
</View>
|
||||||
))}
|
))}
|
||||||
</View>
|
</View>
|
||||||
@@ -92,16 +114,22 @@ const styles = StyleSheet.create((theme) => ({
|
|||||||
color: theme.colors.mutedForeground,
|
color: theme.colors.mutedForeground,
|
||||||
},
|
},
|
||||||
addLine: {
|
addLine: {
|
||||||
backgroundColor: theme.colors.palette.green[900],
|
backgroundColor: "rgba(46, 160, 67, 0.15)",
|
||||||
},
|
},
|
||||||
addText: {
|
addText: {
|
||||||
color: theme.colors.palette.green[200],
|
color: theme.colors.foreground,
|
||||||
},
|
},
|
||||||
removeLine: {
|
removeLine: {
|
||||||
backgroundColor: theme.colors.palette.red[900],
|
backgroundColor: "rgba(248, 81, 73, 0.1)",
|
||||||
},
|
},
|
||||||
removeText: {
|
removeText: {
|
||||||
color: theme.colors.palette.red[200],
|
color: theme.colors.foreground,
|
||||||
|
},
|
||||||
|
addHighlight: {
|
||||||
|
backgroundColor: "rgba(46, 160, 67, 0.4)",
|
||||||
|
},
|
||||||
|
removeHighlight: {
|
||||||
|
backgroundColor: "rgba(248, 81, 73, 0.35)",
|
||||||
},
|
},
|
||||||
contextLine: {
|
contextLine: {
|
||||||
backgroundColor: theme.colors.card,
|
backgroundColor: theme.colors.card,
|
||||||
|
|||||||
@@ -1168,12 +1168,8 @@ export const ToolCall = memo(function ToolCall({
|
|||||||
args,
|
args,
|
||||||
result,
|
result,
|
||||||
error,
|
error,
|
||||||
parsedEditEntries,
|
|
||||||
parsedReadEntries,
|
|
||||||
parsedCommandDetails,
|
|
||||||
cwd,
|
|
||||||
});
|
});
|
||||||
}, [openToolCall, toolName, kind, status, args, result, error, parsedEditEntries, parsedReadEntries, parsedCommandDetails, cwd]);
|
}, [openToolCall, toolName, kind, status, args, result, error]);
|
||||||
|
|
||||||
// Dummy renderDetails to make badge tappable - actual content is rendered in the sheet
|
// Dummy renderDetails to make badge tappable - actual content is rendered in the sheet
|
||||||
const dummyRenderDetails = useCallback(() => null, []);
|
const dummyRenderDetails = useCallback(() => null, []);
|
||||||
|
|||||||
@@ -15,16 +15,8 @@ import {
|
|||||||
BottomSheetBackdrop,
|
BottomSheetBackdrop,
|
||||||
} 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 { parseToolCallDisplay, buildLineDiff, type DiffLine } from "@/utils/tool-call-parsers";
|
||||||
import { DiffViewer } from "./diff-viewer";
|
import { DiffViewer } from "./diff-viewer";
|
||||||
import {
|
|
||||||
extractKeyValuePairs,
|
|
||||||
stripCwdPrefix,
|
|
||||||
type EditEntry,
|
|
||||||
type ReadEntry,
|
|
||||||
type CommandDetails,
|
|
||||||
type DiffLine,
|
|
||||||
type KeyValuePair,
|
|
||||||
} from "@/utils/tool-call-parsers";
|
|
||||||
|
|
||||||
// ----- Types -----
|
// ----- Types -----
|
||||||
|
|
||||||
@@ -35,10 +27,6 @@ export interface ToolCallSheetData {
|
|||||||
args?: unknown;
|
args?: unknown;
|
||||||
result?: unknown;
|
result?: unknown;
|
||||||
error?: unknown;
|
error?: unknown;
|
||||||
parsedEditEntries?: EditEntry[];
|
|
||||||
parsedReadEntries?: ReadEntry[];
|
|
||||||
parsedCommandDetails?: CommandDetails | null;
|
|
||||||
cwd?: string;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ToolCallSheetContextValue {
|
interface ToolCallSheetContextValue {
|
||||||
@@ -83,80 +71,6 @@ function formatValue(value: unknown): string {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 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)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Build diff lines from before/after strings
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ----- Provider Component -----
|
// ----- Provider Component -----
|
||||||
|
|
||||||
interface ToolCallSheetProviderProps {
|
interface ToolCallSheetProviderProps {
|
||||||
@@ -167,7 +81,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(() => ["60%", "90%"], []);
|
const snapPoints = useMemo(() => ["60%", "95%"], []);
|
||||||
|
|
||||||
const openToolCall = useCallback((data: ToolCallSheetData) => {
|
const openToolCall = useCallback((data: ToolCallSheetData) => {
|
||||||
setSheetData(data);
|
setSheetData(data);
|
||||||
@@ -207,6 +121,8 @@ export function ToolCallSheetProvider({ children }: ToolCallSheetProviderProps)
|
|||||||
<BottomSheetModal
|
<BottomSheetModal
|
||||||
ref={bottomSheetRef}
|
ref={bottomSheetRef}
|
||||||
snapPoints={snapPoints}
|
snapPoints={snapPoints}
|
||||||
|
index={0}
|
||||||
|
enableDynamicSizing={false}
|
||||||
onChange={handleSheetChange}
|
onChange={handleSheetChange}
|
||||||
backdropComponent={renderBackdrop}
|
backdropComponent={renderBackdrop}
|
||||||
enablePanDownToClose
|
enablePanDownToClose
|
||||||
@@ -227,127 +143,91 @@ interface ToolCallSheetContentProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function ToolCallSheetContent({ data, onClose }: ToolCallSheetContentProps) {
|
function ToolCallSheetContent({ data, onClose }: ToolCallSheetContentProps) {
|
||||||
const {
|
const { toolName, kind, args, result, error } = data;
|
||||||
toolName,
|
|
||||||
kind,
|
|
||||||
status,
|
|
||||||
args,
|
|
||||||
result,
|
|
||||||
error,
|
|
||||||
parsedEditEntries,
|
|
||||||
parsedReadEntries,
|
|
||||||
parsedCommandDetails,
|
|
||||||
cwd,
|
|
||||||
} = data;
|
|
||||||
|
|
||||||
const IconComponent = kind
|
const IconComponent = kind
|
||||||
? toolKindIcons[kind.toLowerCase()] || Wrench
|
? toolKindIcons[kind.toLowerCase()] || Wrench
|
||||||
: Wrench;
|
: Wrench;
|
||||||
|
|
||||||
const serializedArgs = useMemo(
|
|
||||||
() => (args !== undefined ? formatValue(args) : ""),
|
|
||||||
[args]
|
|
||||||
);
|
|
||||||
const serializedResult = useMemo(
|
|
||||||
() => (result !== undefined ? formatValue(result) : ""),
|
|
||||||
[result]
|
|
||||||
);
|
|
||||||
const serializedError = useMemo(
|
const serializedError = useMemo(
|
||||||
() => (error !== undefined ? formatValue(error) : ""),
|
() => (error !== undefined ? formatValue(error) : ""),
|
||||||
[error]
|
[error]
|
||||||
);
|
);
|
||||||
|
|
||||||
// Check if result has a type field for structured rendering
|
// Parse tool call display using discriminated union
|
||||||
const structuredResult = useMemo(
|
const toolCallDisplay = useMemo(
|
||||||
() => (isStructuredToolResult(result) ? result : null),
|
() => parseToolCallDisplay(args, result),
|
||||||
[result]
|
[args, result]
|
||||||
);
|
);
|
||||||
|
|
||||||
// Extract functions for structured results
|
// Compute diff lines for edit type
|
||||||
const extractCommandFromStructured = useCallback(
|
const editDiffLines = useMemo((): DiffLine[] => {
|
||||||
(structured: StructuredToolResult): CommandDetails | null => {
|
if (toolCallDisplay.type !== "edit") return [];
|
||||||
if (structured.type !== "command") return null;
|
return buildLineDiff(toolCallDisplay.oldString, toolCallDisplay.newString);
|
||||||
|
}, [toolCallDisplay]);
|
||||||
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;
|
|
||||||
|
|
||||||
if (structured.type === "file_write") {
|
|
||||||
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 }];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (structured.type === "file_edit") {
|
|
||||||
if (Array.isArray(structured.diffLines)) {
|
|
||||||
return [{ filePath, diffLines: structured.diffLines as DiffLine[] }];
|
|
||||||
}
|
|
||||||
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 [];
|
|
||||||
},
|
|
||||||
[]
|
|
||||||
);
|
|
||||||
|
|
||||||
// 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
|
if (toolCallDisplay.type === "shell") {
|
||||||
if (args !== undefined) {
|
// Shell tool: show command and output as single block
|
||||||
// Add Input group header
|
|
||||||
sections.push(
|
sections.push(
|
||||||
<View key="input-header" style={styles.groupHeader}>
|
<View key="shell" style={styles.section}>
|
||||||
<Text style={styles.groupHeaderText}>Input</Text>
|
<Text style={styles.sectionTitle}>Command</Text>
|
||||||
|
<ScrollView
|
||||||
|
horizontal
|
||||||
|
nestedScrollEnabled
|
||||||
|
style={styles.jsonScroll}
|
||||||
|
contentContainerStyle={styles.jsonContent}
|
||||||
|
showsHorizontalScrollIndicator={true}
|
||||||
|
>
|
||||||
|
<Text style={styles.scrollText}>{toolCallDisplay.command}</Text>
|
||||||
|
</ScrollView>
|
||||||
|
{toolCallDisplay.output ? (
|
||||||
|
<ScrollView
|
||||||
|
style={styles.scrollArea}
|
||||||
|
contentContainerStyle={styles.scrollContent}
|
||||||
|
nestedScrollEnabled
|
||||||
|
showsVerticalScrollIndicator={true}
|
||||||
|
>
|
||||||
|
<ScrollView
|
||||||
|
horizontal
|
||||||
|
nestedScrollEnabled
|
||||||
|
showsHorizontalScrollIndicator={true}
|
||||||
|
>
|
||||||
|
<Text style={styles.scrollText}>{toolCallDisplay.output}</Text>
|
||||||
|
</ScrollView>
|
||||||
|
</ScrollView>
|
||||||
|
) : null}
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
|
} else if (toolCallDisplay.type === "edit") {
|
||||||
const argPairs = extractKeyValuePairs(args);
|
// Edit tool: show file path and diff
|
||||||
if (argPairs.length > 0) {
|
sections.push(
|
||||||
argPairs.forEach((pair, index) => {
|
<View key="edit" style={styles.section}>
|
||||||
|
<Text style={styles.sectionTitle}>File</Text>
|
||||||
|
<View style={styles.fileBadge}>
|
||||||
|
<Text style={styles.fileBadgeText}>{toolCallDisplay.filePath}</Text>
|
||||||
|
</View>
|
||||||
|
{editDiffLines.length > 0 ? (
|
||||||
|
<View style={styles.diffContainer}>
|
||||||
|
<DiffViewer diffLines={editDiffLines} maxHeight={300} />
|
||||||
|
</View>
|
||||||
|
) : null}
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
// Generic tool: show input/output as key-value pairs
|
||||||
|
if (toolCallDisplay.input.length > 0) {
|
||||||
|
sections.push(
|
||||||
|
<View key="input-header" style={styles.groupHeader}>
|
||||||
|
<Text style={styles.groupHeaderText}>Input</Text>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
toolCallDisplay.input.forEach((pair, index) => {
|
||||||
sections.push(
|
sections.push(
|
||||||
<View key={`arg-${index}-${pair.key}`} style={styles.section}>
|
<View key={`input-${index}-${pair.key}`} style={styles.section}>
|
||||||
<Text style={styles.sectionTitle}>{pair.key}</Text>
|
<Text style={styles.sectionTitle}>{pair.key}</Text>
|
||||||
<ScrollView
|
<ScrollView
|
||||||
horizontal
|
horizontal
|
||||||
@@ -361,175 +241,17 @@ function ToolCallSheetContent({ data, onClose }: ToolCallSheetContentProps) {
|
|||||||
</View>
|
</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
|
if (toolCallDisplay.output.length > 0) {
|
||||||
const addOutputHeader = () => {
|
|
||||||
if (!hasOutput) {
|
|
||||||
hasOutput = true;
|
|
||||||
sections.push(
|
sections.push(
|
||||||
<View key="output-header" style={styles.groupHeader}>
|
<View key="output-header" style={styles.groupHeader}>
|
||||||
<Text style={styles.groupHeaderText}>Output</Text>
|
<Text style={styles.groupHeaderText}>Output</Text>
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
}
|
toolCallDisplay.output.forEach((pair, index) => {
|
||||||
};
|
|
||||||
|
|
||||||
// Render based on structured result type or raw data
|
|
||||||
if (structuredResult) {
|
|
||||||
switch (structuredResult.type) {
|
|
||||||
case "command": {
|
|
||||||
const cmd = parsedCommandDetails ?? extractCommandFromStructured(structuredResult);
|
|
||||||
if (cmd) {
|
|
||||||
addOutputHeader();
|
|
||||||
sections.push(
|
|
||||||
<View key="command" style={styles.section}>
|
|
||||||
<Text style={styles.sectionTitle}>Command</Text>
|
|
||||||
{cmd.command ? (
|
|
||||||
<ScrollView
|
|
||||||
horizontal
|
|
||||||
nestedScrollEnabled
|
|
||||||
style={styles.jsonScroll}
|
|
||||||
contentContainerStyle={styles.jsonContent}
|
|
||||||
showsHorizontalScrollIndicator={true}
|
|
||||||
>
|
|
||||||
<Text style={styles.scrollText}>{cmd.command}</Text>
|
|
||||||
</ScrollView>
|
|
||||||
) : null}
|
|
||||||
{cmd.cwd ? (
|
|
||||||
<View style={styles.metaRow}>
|
|
||||||
<Text style={styles.metaLabel}>Directory</Text>
|
|
||||||
<Text style={styles.metaValue}>{cmd.cwd}</Text>
|
|
||||||
</View>
|
|
||||||
) : null}
|
|
||||||
{cmd.exitCode !== undefined ? (
|
|
||||||
<View style={styles.metaRow}>
|
|
||||||
<Text style={styles.metaLabel}>Exit Code</Text>
|
|
||||||
<Text style={styles.metaValue}>
|
|
||||||
{cmd.exitCode === null ? "Unknown" : cmd.exitCode}
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
) : null}
|
|
||||||
{cmd.output ? (
|
|
||||||
<ScrollView
|
|
||||||
style={styles.scrollArea}
|
|
||||||
contentContainerStyle={styles.scrollContent}
|
|
||||||
nestedScrollEnabled
|
|
||||||
showsVerticalScrollIndicator={true}
|
|
||||||
>
|
|
||||||
<Text style={styles.scrollText}>{cmd.output}</Text>
|
|
||||||
</ScrollView>
|
|
||||||
) : null}
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
case "file_write":
|
|
||||||
case "file_edit": {
|
|
||||||
const diffs = parsedEditEntries?.length
|
|
||||||
? parsedEditEntries
|
|
||||||
: extractDiffFromStructured(structuredResult);
|
|
||||||
if (diffs.length > 0) {
|
|
||||||
addOutputHeader();
|
|
||||||
}
|
|
||||||
diffs.forEach((entry, index) => {
|
|
||||||
sections.push(
|
|
||||||
<View key={`diff-${index}`} style={styles.section}>
|
|
||||||
<Text style={styles.sectionTitle}>Diff</Text>
|
|
||||||
{entry.filePath ? (
|
|
||||||
<View style={styles.fileBadge}>
|
|
||||||
<Text style={styles.fileBadgeText}>{stripCwdPrefix(entry.filePath, cwd)}</Text>
|
|
||||||
</View>
|
|
||||||
) : null}
|
|
||||||
<View style={styles.diffContainer}>
|
|
||||||
<DiffViewer diffLines={entry.diffLines} maxHeight={300} />
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
});
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
case "file_read": {
|
|
||||||
const reads = parsedReadEntries?.length
|
|
||||||
? parsedReadEntries
|
|
||||||
: extractReadFromStructured(structuredResult);
|
|
||||||
if (reads.length > 0) {
|
|
||||||
addOutputHeader();
|
|
||||||
}
|
|
||||||
reads.forEach((entry, index) => {
|
|
||||||
sections.push(
|
|
||||||
<View key={`read-${index}`} style={styles.section}>
|
|
||||||
<Text style={styles.sectionTitle}>Read Result</Text>
|
|
||||||
{entry.filePath ? (
|
|
||||||
<View style={styles.fileBadge}>
|
|
||||||
<Text style={styles.fileBadgeText}>{stripCwdPrefix(entry.filePath, cwd)}</Text>
|
|
||||||
</View>
|
|
||||||
) : null}
|
|
||||||
<ScrollView
|
|
||||||
style={styles.scrollArea}
|
|
||||||
contentContainerStyle={styles.scrollContent}
|
|
||||||
nestedScrollEnabled
|
|
||||||
showsVerticalScrollIndicator={true}
|
|
||||||
>
|
|
||||||
<Text style={styles.scrollText}>{entry.content}</Text>
|
|
||||||
</ScrollView>
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
});
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
case "generic":
|
|
||||||
default: {
|
|
||||||
if (result !== undefined && sections.length === 1) {
|
|
||||||
// Only args shown, add result
|
|
||||||
addOutputHeader();
|
|
||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else if (result !== undefined) {
|
|
||||||
// No structured result - try to extract key-value pairs
|
|
||||||
addOutputHeader();
|
|
||||||
const keyValuePairs = extractKeyValuePairs(result);
|
|
||||||
if (keyValuePairs.length > 0) {
|
|
||||||
keyValuePairs.forEach((pair, index) => {
|
|
||||||
sections.push(
|
sections.push(
|
||||||
<View key={`kv-${index}-${pair.key}`} style={styles.section}>
|
<View key={`output-${index}-${pair.key}`} style={styles.section}>
|
||||||
<Text style={styles.sectionTitle}>{pair.key}</Text>
|
<Text style={styles.sectionTitle}>{pair.key}</Text>
|
||||||
<ScrollView
|
<ScrollView
|
||||||
horizontal
|
horizontal
|
||||||
@@ -543,22 +265,6 @@ function ToolCallSheetContent({ data, onClose }: ToolCallSheetContentProps) {
|
|||||||
</View>
|
</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>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -587,21 +293,7 @@ function ToolCallSheetContent({ data, onClose }: ToolCallSheetContentProps) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return sections;
|
return sections;
|
||||||
}, [
|
}, [toolCallDisplay, editDiffLines, error, serializedError]);
|
||||||
args,
|
|
||||||
result,
|
|
||||||
error,
|
|
||||||
serializedArgs,
|
|
||||||
serializedResult,
|
|
||||||
serializedError,
|
|
||||||
structuredResult,
|
|
||||||
parsedEditEntries,
|
|
||||||
parsedReadEntries,
|
|
||||||
parsedCommandDetails,
|
|
||||||
extractCommandFromStructured,
|
|
||||||
extractDiffFromStructured,
|
|
||||||
extractReadFromStructured,
|
|
||||||
]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View style={styles.container}>
|
<View style={styles.container}>
|
||||||
@@ -612,27 +304,6 @@ function ToolCallSheetContent({ data, onClose }: ToolCallSheetContentProps) {
|
|||||||
<Text style={styles.headerTitle} numberOfLines={1}>
|
<Text style={styles.headerTitle} numberOfLines={1}>
|
||||||
{toolName}
|
{toolName}
|
||||||
</Text>
|
</Text>
|
||||||
{status && (
|
|
||||||
<View
|
|
||||||
style={[
|
|
||||||
styles.statusBadge,
|
|
||||||
status === "executing" && styles.statusExecuting,
|
|
||||||
status === "completed" && styles.statusCompleted,
|
|
||||||
status === "failed" && styles.statusFailed,
|
|
||||||
]}
|
|
||||||
>
|
|
||||||
<Text
|
|
||||||
style={[
|
|
||||||
styles.statusText,
|
|
||||||
status === "executing" && styles.statusTextExecuting,
|
|
||||||
status === "completed" && styles.statusTextCompleted,
|
|
||||||
status === "failed" && styles.statusTextFailed,
|
|
||||||
]}
|
|
||||||
>
|
|
||||||
{status === "executing" ? "Running" : status === "completed" ? "Done" : "Failed"}
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
)}
|
|
||||||
</View>
|
</View>
|
||||||
<Pressable onPress={onClose} style={styles.closeButton}>
|
<Pressable onPress={onClose} style={styles.closeButton}>
|
||||||
<X size={20} color={styles.closeIcon.color} />
|
<X size={20} color={styles.closeIcon.color} />
|
||||||
@@ -687,33 +358,6 @@ const styles = StyleSheet.create((theme) => ({
|
|||||||
color: theme.colors.foreground,
|
color: theme.colors.foreground,
|
||||||
flex: 1,
|
flex: 1,
|
||||||
},
|
},
|
||||||
statusBadge: {
|
|
||||||
paddingHorizontal: theme.spacing[2],
|
|
||||||
paddingVertical: theme.spacing[1],
|
|
||||||
borderRadius: theme.borderRadius.full,
|
|
||||||
},
|
|
||||||
statusExecuting: {
|
|
||||||
backgroundColor: theme.colors.palette.blue[900],
|
|
||||||
},
|
|
||||||
statusCompleted: {
|
|
||||||
backgroundColor: theme.colors.palette.green[900],
|
|
||||||
},
|
|
||||||
statusFailed: {
|
|
||||||
backgroundColor: theme.colors.palette.red[900],
|
|
||||||
},
|
|
||||||
statusText: {
|
|
||||||
fontSize: theme.fontSize.xs,
|
|
||||||
fontWeight: theme.fontWeight.semibold,
|
|
||||||
},
|
|
||||||
statusTextExecuting: {
|
|
||||||
color: theme.colors.palette.blue[200],
|
|
||||||
},
|
|
||||||
statusTextCompleted: {
|
|
||||||
color: theme.colors.palette.green[200],
|
|
||||||
},
|
|
||||||
statusTextFailed: {
|
|
||||||
color: theme.colors.palette.red[200],
|
|
||||||
},
|
|
||||||
closeButton: {
|
closeButton: {
|
||||||
padding: theme.spacing[2],
|
padding: theme.spacing[2],
|
||||||
},
|
},
|
||||||
@@ -812,21 +456,4 @@ const styles = StyleSheet.create((theme) => ({
|
|||||||
fontSize: theme.fontSize.sm,
|
fontSize: theme.fontSize.sm,
|
||||||
fontStyle: "italic",
|
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: Fonts.mono,
|
|
||||||
fontSize: theme.fontSize.xs,
|
|
||||||
flex: 1,
|
|
||||||
},
|
|
||||||
}));
|
}));
|
||||||
|
|||||||
@@ -0,0 +1,160 @@
|
|||||||
|
import { describe, test, expect } from "vitest";
|
||||||
|
import {
|
||||||
|
extractKeyValuePairs,
|
||||||
|
parseToolCallDisplay,
|
||||||
|
type ToolCallDisplay,
|
||||||
|
} from "./tool-call-parsers";
|
||||||
|
|
||||||
|
describe("tool-call-parsers - real runtime shapes", () => {
|
||||||
|
// Real data captured from Claude agent test: "shows the command inside pending tool calls"
|
||||||
|
// Run: npx vitest run claude-agent.test.ts -t "shows the command"
|
||||||
|
|
||||||
|
test("bash tool call - input shape", () => {
|
||||||
|
// REAL shape from Claude SDK timeline event (status: pending/completed)
|
||||||
|
const bashInput = {
|
||||||
|
command: "pwd",
|
||||||
|
description: "Print working directory",
|
||||||
|
};
|
||||||
|
|
||||||
|
const pairs = extractKeyValuePairs(bashInput);
|
||||||
|
expect(pairs).toContainEqual({ key: "command", value: "pwd" });
|
||||||
|
expect(pairs).toContainEqual({ key: "description", value: "Print working directory" });
|
||||||
|
});
|
||||||
|
|
||||||
|
test("bash tool call - output shape (completed)", () => {
|
||||||
|
// REAL shape from Claude SDK timeline event (status: completed)
|
||||||
|
// NOTE: output already has type: "command" discriminator!
|
||||||
|
const bashOutput = {
|
||||||
|
type: "command",
|
||||||
|
command: "pwd",
|
||||||
|
output: "/private/var/folders/xl/kkk9drfd3ms_t8x7rmy4z6900000gn/T/claude-agent-e2e-9tnmUm",
|
||||||
|
};
|
||||||
|
|
||||||
|
const pairs = extractKeyValuePairs(bashOutput);
|
||||||
|
expect(pairs).toContainEqual({ key: "type", value: "command" });
|
||||||
|
expect(pairs).toContainEqual({ key: "command", value: "pwd" });
|
||||||
|
expect(pairs).toContainEqual({ key: "output", value: expect.stringContaining("claude-agent") });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("parseToolCallDisplay", () => {
|
||||||
|
test("parses completed bash tool call into shell type", () => {
|
||||||
|
const input = { command: "pwd", description: "Print working directory" };
|
||||||
|
const result = { type: "command", command: "pwd", output: "/some/path" };
|
||||||
|
|
||||||
|
const display: ToolCallDisplay = parseToolCallDisplay(input, result);
|
||||||
|
expect(display.type).toBe("shell");
|
||||||
|
if (display.type === "shell") {
|
||||||
|
expect(display.command).toBe("pwd");
|
||||||
|
expect(display.output).toBe("/some/path");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("parses pending bash tool call into shell type with empty output", () => {
|
||||||
|
// When tool is pending, we have input but no result yet
|
||||||
|
const input = { command: "pwd", description: "Print working directory" };
|
||||||
|
const result = undefined;
|
||||||
|
|
||||||
|
const display: ToolCallDisplay = parseToolCallDisplay(input, result);
|
||||||
|
expect(display.type).toBe("shell");
|
||||||
|
if (display.type === "shell") {
|
||||||
|
expect(display.command).toBe("pwd");
|
||||||
|
expect(display.output).toBe("");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("handles command as array", () => {
|
||||||
|
const input = { command: ["git", "status"] };
|
||||||
|
const result = { type: "command", output: "On branch main" };
|
||||||
|
|
||||||
|
const display: ToolCallDisplay = parseToolCallDisplay(input, result);
|
||||||
|
expect(display.type).toBe("shell");
|
||||||
|
if (display.type === "shell") {
|
||||||
|
expect(display.command).toBe("git status");
|
||||||
|
expect(display.output).toBe("On branch main");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("parses non-command tool call into generic type", () => {
|
||||||
|
const input = { file_path: "/some/file.txt" };
|
||||||
|
const result = { content: "file contents here", lineCount: 42 };
|
||||||
|
|
||||||
|
const display: ToolCallDisplay = parseToolCallDisplay(input, result);
|
||||||
|
expect(display.type).toBe("generic");
|
||||||
|
if (display.type === "generic") {
|
||||||
|
expect(display.input).toContainEqual({ key: "file_path", value: "/some/file.txt" });
|
||||||
|
expect(display.output).toContainEqual({ key: "content", value: "file contents here" });
|
||||||
|
expect(display.output).toContainEqual({ key: "lineCount", value: "42" });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("handles file_write output as generic", () => {
|
||||||
|
const input = { file_path: "/some/file.txt", content: "new content" };
|
||||||
|
const result = { type: "file_write", filePath: "/some/file.txt" };
|
||||||
|
|
||||||
|
const display: ToolCallDisplay = parseToolCallDisplay(input, result);
|
||||||
|
expect(display.type).toBe("generic");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("handles undefined input and result gracefully", () => {
|
||||||
|
const display: ToolCallDisplay = parseToolCallDisplay(undefined, undefined);
|
||||||
|
expect(display.type).toBe("generic");
|
||||||
|
if (display.type === "generic") {
|
||||||
|
expect(display.input).toEqual([]);
|
||||||
|
expect(display.output).toEqual([]);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("parses edit tool call into edit type with old_string/new_string", () => {
|
||||||
|
const input = {
|
||||||
|
file_path: "/some/file.txt",
|
||||||
|
old_string: "const foo = 1;",
|
||||||
|
new_string: "const foo = 2;",
|
||||||
|
};
|
||||||
|
const result = {
|
||||||
|
type: "file_edit",
|
||||||
|
filePath: "/some/file.txt",
|
||||||
|
};
|
||||||
|
|
||||||
|
const display: ToolCallDisplay = parseToolCallDisplay(input, result);
|
||||||
|
expect(display.type).toBe("edit");
|
||||||
|
if (display.type === "edit") {
|
||||||
|
expect(display.filePath).toBe("/some/file.txt");
|
||||||
|
expect(display.oldString).toBe("const foo = 1;");
|
||||||
|
expect(display.newString).toBe("const foo = 2;");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("parses edit tool call with old_str/new_str variants", () => {
|
||||||
|
const input = {
|
||||||
|
file_path: "/some/file.txt",
|
||||||
|
old_str: "line 1",
|
||||||
|
new_str: "line 2",
|
||||||
|
};
|
||||||
|
const result = undefined;
|
||||||
|
|
||||||
|
const display: ToolCallDisplay = parseToolCallDisplay(input, result);
|
||||||
|
expect(display.type).toBe("edit");
|
||||||
|
if (display.type === "edit") {
|
||||||
|
expect(display.filePath).toBe("/some/file.txt");
|
||||||
|
expect(display.oldString).toBe("line 1");
|
||||||
|
expect(display.newString).toBe("line 2");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("parses pending edit tool call (no result yet)", () => {
|
||||||
|
const input = {
|
||||||
|
file_path: "/some/file.txt",
|
||||||
|
old_string: "old content",
|
||||||
|
new_string: "new content",
|
||||||
|
};
|
||||||
|
|
||||||
|
const display: ToolCallDisplay = parseToolCallDisplay(input, undefined);
|
||||||
|
expect(display.type).toBe("edit");
|
||||||
|
if (display.type === "edit") {
|
||||||
|
expect(display.filePath).toBe("/some/file.txt");
|
||||||
|
expect(display.oldString).toBe("old content");
|
||||||
|
expect(display.newString).toBe("new content");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,8 +1,14 @@
|
|||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
|
||||||
|
export type DiffSegment = {
|
||||||
|
text: string;
|
||||||
|
changed: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
export type DiffLine = {
|
export type DiffLine = {
|
||||||
type: "add" | "remove" | "context" | "header";
|
type: "add" | "remove" | "context" | "header";
|
||||||
content: string;
|
content: string;
|
||||||
|
segments?: DiffSegment[];
|
||||||
};
|
};
|
||||||
|
|
||||||
export type EditEntry = {
|
export type EditEntry = {
|
||||||
@@ -38,7 +44,112 @@ function splitIntoLines(text: string): string[] {
|
|||||||
return text.replace(/\r\n/g, "\n").split("\n");
|
return text.replace(/\r\n/g, "\n").split("\n");
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildLineDiff(originalText: string, updatedText: string): DiffLine[] {
|
function splitIntoWords(text: string): string[] {
|
||||||
|
const result: string[] = [];
|
||||||
|
let current = "";
|
||||||
|
let inWord = false;
|
||||||
|
|
||||||
|
for (const char of text) {
|
||||||
|
const isWordChar = /\w/.test(char);
|
||||||
|
if (isWordChar) {
|
||||||
|
if (!inWord && current) {
|
||||||
|
result.push(current);
|
||||||
|
current = "";
|
||||||
|
}
|
||||||
|
inWord = true;
|
||||||
|
current += char;
|
||||||
|
} else {
|
||||||
|
if (inWord && current) {
|
||||||
|
result.push(current);
|
||||||
|
current = "";
|
||||||
|
}
|
||||||
|
inWord = false;
|
||||||
|
current += char;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (current) {
|
||||||
|
result.push(current);
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
function computeWordLevelDiff(oldLine: string, newLine: string): { oldSegments: DiffSegment[]; newSegments: DiffSegment[] } {
|
||||||
|
const oldWords = splitIntoWords(oldLine);
|
||||||
|
const newWords = splitIntoWords(newLine);
|
||||||
|
|
||||||
|
const m = oldWords.length;
|
||||||
|
const n = newWords.length;
|
||||||
|
|
||||||
|
// LCS to find common words
|
||||||
|
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 (oldWords[i] === newWords[j]) {
|
||||||
|
dp[i][j] = dp[i + 1][j + 1] + 1;
|
||||||
|
} else {
|
||||||
|
dp[i][j] = Math.max(dp[i + 1][j], dp[i][j + 1]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mark which words are in LCS (unchanged)
|
||||||
|
const oldInLCS = new Set<number>();
|
||||||
|
const newInLCS = new Set<number>();
|
||||||
|
|
||||||
|
let i = 0;
|
||||||
|
let j = 0;
|
||||||
|
while (i < m && j < n) {
|
||||||
|
if (oldWords[i] === newWords[j]) {
|
||||||
|
oldInLCS.add(i);
|
||||||
|
newInLCS.add(j);
|
||||||
|
i += 1;
|
||||||
|
j += 1;
|
||||||
|
} else if (dp[i + 1][j] >= dp[i][j + 1]) {
|
||||||
|
i += 1;
|
||||||
|
} else {
|
||||||
|
j += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build segments: consecutive unchanged or changed words merged
|
||||||
|
const buildSegments = (words: string[], inLCS: Set<number>): DiffSegment[] => {
|
||||||
|
if (words.length === 0) return [];
|
||||||
|
|
||||||
|
const segments: DiffSegment[] = [];
|
||||||
|
let currentText = "";
|
||||||
|
let currentChanged: boolean | null = null;
|
||||||
|
|
||||||
|
for (let idx = 0; idx < words.length; idx++) {
|
||||||
|
const word = words[idx];
|
||||||
|
const changed = !inLCS.has(idx);
|
||||||
|
|
||||||
|
if (currentChanged === null) {
|
||||||
|
currentText = word;
|
||||||
|
currentChanged = changed;
|
||||||
|
} else if (changed === currentChanged) {
|
||||||
|
currentText += word;
|
||||||
|
} else {
|
||||||
|
segments.push({ text: currentText, changed: currentChanged });
|
||||||
|
currentText = word;
|
||||||
|
currentChanged = changed;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (currentText) {
|
||||||
|
segments.push({ text: currentText, changed: currentChanged ?? false });
|
||||||
|
}
|
||||||
|
|
||||||
|
return segments;
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
oldSegments: buildSegments(oldWords, oldInLCS),
|
||||||
|
newSegments: buildSegments(newWords, newInLCS),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildLineDiff(originalText: string, updatedText: string): DiffLine[] {
|
||||||
const originalLines = splitIntoLines(originalText);
|
const originalLines = splitIntoLines(originalText);
|
||||||
const updatedLines = splitIntoLines(updatedText);
|
const updatedLines = splitIntoLines(updatedText);
|
||||||
|
|
||||||
@@ -91,6 +202,22 @@ function buildLineDiff(originalText: string, updatedText: string): DiffLine[] {
|
|||||||
j += 1;
|
j += 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Post-process to add word-level segments for adjacent remove/add pairs
|
||||||
|
for (let idx = 0; idx < diff.length - 1; idx++) {
|
||||||
|
const curr = diff[idx];
|
||||||
|
const next = diff[idx + 1];
|
||||||
|
|
||||||
|
if (curr.type === "remove" && next.type === "add") {
|
||||||
|
// Strip the leading -/+ from content for comparison
|
||||||
|
const oldLineText = curr.content.slice(1);
|
||||||
|
const newLineText = next.content.slice(1);
|
||||||
|
|
||||||
|
const { oldSegments, newSegments } = computeWordLevelDiff(oldLineText, newLineText);
|
||||||
|
curr.segments = oldSegments;
|
||||||
|
next.segments = newSegments;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return diff;
|
return diff;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -753,6 +880,118 @@ export function extractKeyValuePairs(result: unknown): KeyValuePair[] {
|
|||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---- Tool Call Display Discriminated Union ----
|
||||||
|
|
||||||
|
const KeyValuePairsSchema = z.record(z.unknown()).transform((data) =>
|
||||||
|
Object.entries(data).map(([key, value]) => ({
|
||||||
|
key,
|
||||||
|
value: stringifyValue(value),
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
|
||||||
|
// Shell input: { command: "pwd", description?: "..." }
|
||||||
|
const ShellInputSchema = z.object({
|
||||||
|
command: z.union([z.string(), z.array(z.string())]),
|
||||||
|
}).passthrough();
|
||||||
|
|
||||||
|
// Shell result (when completed): { type: "command", command: "pwd", output: "..." }
|
||||||
|
const ShellResultSchema = z.object({
|
||||||
|
type: z.literal("command"),
|
||||||
|
output: z.string(),
|
||||||
|
}).passthrough();
|
||||||
|
|
||||||
|
// Shell tool call display schema
|
||||||
|
const ShellToolCallSchema = z
|
||||||
|
.object({
|
||||||
|
input: ShellInputSchema,
|
||||||
|
result: z.unknown(),
|
||||||
|
})
|
||||||
|
.transform((data) => {
|
||||||
|
const command = Array.isArray(data.input.command)
|
||||||
|
? data.input.command.join(" ")
|
||||||
|
: data.input.command;
|
||||||
|
|
||||||
|
const resultParsed = ShellResultSchema.safeParse(data.result);
|
||||||
|
const output = resultParsed.success ? resultParsed.data.output : "";
|
||||||
|
|
||||||
|
return {
|
||||||
|
type: "shell" as const,
|
||||||
|
command,
|
||||||
|
output,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
// Edit input: { file_path: string, old_string: string, new_string: string }
|
||||||
|
// Also supports old_str/new_str variants
|
||||||
|
const EditInputSchema = z.union([
|
||||||
|
z.object({
|
||||||
|
file_path: z.string(),
|
||||||
|
old_string: z.string(),
|
||||||
|
new_string: z.string(),
|
||||||
|
}).passthrough(),
|
||||||
|
z.object({
|
||||||
|
file_path: z.string(),
|
||||||
|
old_str: z.string(),
|
||||||
|
new_str: z.string(),
|
||||||
|
}).passthrough(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Edit result: { type: "file_edit", filePath: string, oldContent?: string, newContent?: string }
|
||||||
|
const EditResultSchema = z.object({
|
||||||
|
type: z.literal("file_edit"),
|
||||||
|
filePath: z.string(),
|
||||||
|
oldContent: z.string().optional(),
|
||||||
|
newContent: z.string().optional(),
|
||||||
|
}).passthrough();
|
||||||
|
|
||||||
|
// Edit tool call display schema
|
||||||
|
const EditToolCallSchema = z
|
||||||
|
.object({
|
||||||
|
input: EditInputSchema,
|
||||||
|
result: z.unknown(),
|
||||||
|
})
|
||||||
|
.transform((data): { type: "edit"; filePath: string; oldString: string; newString: string } => {
|
||||||
|
const filePath = data.input.file_path;
|
||||||
|
const oldString = "old_string" in data.input
|
||||||
|
? (data.input as { old_string: string }).old_string
|
||||||
|
: (data.input as { old_str: string }).old_str;
|
||||||
|
const newString = "new_string" in data.input
|
||||||
|
? (data.input as { new_string: string }).new_string
|
||||||
|
: (data.input as { new_str: string }).new_str;
|
||||||
|
|
||||||
|
return {
|
||||||
|
type: "edit",
|
||||||
|
filePath,
|
||||||
|
oldString,
|
||||||
|
newString,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
// Generic tool call display schema (fallback)
|
||||||
|
const GenericToolCallSchema = z
|
||||||
|
.object({
|
||||||
|
input: z.unknown(),
|
||||||
|
result: z.unknown(),
|
||||||
|
})
|
||||||
|
.transform((data) => {
|
||||||
|
const inputPairs = KeyValuePairsSchema.safeParse(data.input);
|
||||||
|
const resultPairs = KeyValuePairsSchema.safeParse(data.result);
|
||||||
|
|
||||||
|
return {
|
||||||
|
type: "generic" as const,
|
||||||
|
input: inputPairs.success ? inputPairs.data : [],
|
||||||
|
output: resultPairs.success ? resultPairs.data : [],
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const ToolCallDisplaySchema = z.union([ShellToolCallSchema, EditToolCallSchema, GenericToolCallSchema]);
|
||||||
|
|
||||||
|
export type ToolCallDisplay = z.infer<typeof ToolCallDisplaySchema>;
|
||||||
|
|
||||||
|
export function parseToolCallDisplay(input: unknown, result: unknown): ToolCallDisplay {
|
||||||
|
return ToolCallDisplaySchema.parse({ input, result });
|
||||||
|
}
|
||||||
|
|
||||||
// ---- Principal Parameter Extraction ----
|
// ---- Principal Parameter Extraction ----
|
||||||
// Re-export from server to avoid drift
|
// Re-export from server to avoid drift
|
||||||
export {
|
export {
|
||||||
|
|||||||
Reference in New Issue
Block a user