refactor(ui): extract diff viewer and tool call parsers into reusable components

This commit is contained in:
Mohamed Boudra
2025-11-15 16:28:57 +01:00
parent 1886898bf2
commit edcfdf970d
10 changed files with 1402 additions and 870 deletions
+34 -6
View File
@@ -4,6 +4,7 @@ import { StyleSheet, useUnistyles } from "react-native-unistyles";
import type { Agent } from "@/contexts/session-context";
import { formatTimeAgo } from "@/utils/time";
import { getAgentStatusColor, getAgentStatusLabel } from "@/utils/agent-status";
import { getAgentProviderDefinition } from "@server/server/agent/provider-manifest";
interface AgentListProps {
agents: Map<string, Agent>;
@@ -27,6 +28,7 @@ export function AgentList({ agents }: AgentListProps) {
const statusColor = getAgentStatusColor(agent.status);
const statusLabel = getAgentStatusLabel(agent.status);
const timeAgo = formatTimeAgo(agent.lastActivityAt);
const providerLabel = getAgentProviderDefinition(agent.provider).label;
return (
<Pressable
@@ -53,13 +55,26 @@ export function AgentList({ agents }: AgentListProps) {
</Text>
<View style={styles.statusRow}>
<View style={styles.statusBadge}>
<View style={styles.statusGroup}>
<View
style={[styles.statusDot, { backgroundColor: statusColor }]}
/>
<Text style={[styles.statusText, { color: statusColor }]}>
{statusLabel}
</Text>
style={[styles.providerBadge, { backgroundColor: theme.colors.muted }]}
>
<Text
style={[styles.providerText, { color: theme.colors.mutedForeground }]}
numberOfLines={1}
>
{providerLabel}
</Text>
</View>
<View style={styles.statusBadge}>
<View
style={[styles.statusDot, { backgroundColor: statusColor }]}
/>
<Text style={[styles.statusText, { color: statusColor }]}>
{statusLabel}
</Text>
</View>
</View>
<Text style={styles.timeAgo}>
@@ -111,6 +126,19 @@ const styles = StyleSheet.create((theme) => ({
justifyContent: "space-between",
gap: theme.spacing[2],
},
statusGroup: {
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[2],
},
providerBadge: {
borderRadius: theme.borderRadius.full,
},
providerText: {
fontSize: theme.fontSize.xs,
fontWeight: theme.fontWeight.semibold,
textTransform: "uppercase",
},
statusBadge: {
flexDirection: "row",
alignItems: "center",
+120
View File
@@ -0,0 +1,120 @@
import React from "react";
import { View, Text } from "react-native";
import { ScrollView } from "react-native-gesture-handler";
import { StyleSheet } from "react-native-unistyles";
import type { DiffLine } from "@/utils/tool-call-parsers";
interface DiffViewerProps {
diffLines: DiffLine[];
maxHeight?: number;
emptyLabel?: string;
}
export function DiffViewer({ diffLines, maxHeight = 280, emptyLabel = "No changes to display" }: DiffViewerProps) {
if (!diffLines.length) {
return (
<View style={styles.emptyState}>
<Text style={styles.emptyText}>{emptyLabel}</Text>
</View>
);
}
return (
<ScrollView
style={[styles.verticalScroll, { maxHeight }]}
contentContainerStyle={styles.verticalContent}
nestedScrollEnabled
showsVerticalScrollIndicator
>
<ScrollView
horizontal
nestedScrollEnabled
showsHorizontalScrollIndicator
contentContainerStyle={styles.horizontalContent}
>
<View style={styles.linesContainer}>
{diffLines.map((line, index) => (
<View
key={`${line.type}-${index}`}
style={[
styles.line,
line.type === "header" && styles.headerLine,
line.type === "add" && styles.addLine,
line.type === "remove" && styles.removeLine,
line.type === "context" && styles.contextLine,
]}
>
<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>
</ScrollView>
</ScrollView>
);
}
const styles = StyleSheet.create((theme) => ({
verticalScroll: {},
verticalContent: {
flexGrow: 1,
},
horizontalContent: {
flexDirection: "column" as const,
},
linesContainer: {
alignSelf: "flex-start",
},
line: {
minWidth: "100%",
paddingHorizontal: theme.spacing[3],
paddingVertical: theme.spacing[1],
},
lineText: {
fontFamily: "monospace",
fontSize: theme.fontSize.xs,
color: theme.colors.foreground,
},
headerLine: {
backgroundColor: theme.colors.muted,
},
headerText: {
color: theme.colors.mutedForeground,
},
addLine: {
backgroundColor: theme.colors.palette.green[900],
},
addText: {
color: theme.colors.palette.green[200],
},
removeLine: {
backgroundColor: theme.colors.palette.red[900],
},
removeText: {
color: theme.colors.palette.red[200],
},
contextLine: {
backgroundColor: theme.colors.card,
},
contextText: {
color: theme.colors.mutedForeground,
},
emptyState: {
padding: theme.spacing[4],
alignItems: "center" as const,
justifyContent: "center" as const,
},
emptyText: {
fontSize: theme.fontSize.sm,
color: theme.colors.mutedForeground,
},
}));
@@ -5,700 +5,20 @@ import { ScrollView } from "react-native-gesture-handler";
import { StyleSheet } from "react-native-unistyles";
import {
BottomSheetModal,
BottomSheetView,
BottomSheetScrollView,
BottomSheetBackdrop,
BottomSheetBackdropProps,
} from "@gorhom/bottom-sheet";
import type { SelectedToolCall } from "@/types/shared";
import { z } from "zod";
type DiffLine = {
type: "add" | "remove" | "context" | "header";
content: string;
};
type EditEntry = {
filePath?: string;
diffLines: DiffLine[];
};
type ReadEntry = {
filePath?: string;
content: string;
};
type CommandDetails = {
command?: string;
cwd?: string;
output?: string;
exitCode?: number | null;
};
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null;
}
function getString(value: unknown): string | undefined {
return typeof value === "string" ? value : undefined;
}
function splitIntoLines(text: string): string[] {
if (!text) {
return [];
}
return text.replace(/\r\n/g, "\n").split("\n");
}
function buildLineDiff(originalText: string, updatedText: string): DiffLine[] {
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;
}
function parseUnifiedDiff(diffText?: string): DiffLine[] {
if (!diffText) {
return [];
}
const lines = splitIntoLines(diffText);
const diff: DiffLine[] = [];
for (const line of lines) {
if (!line.length) {
diff.push({ type: "context", content: line });
continue;
}
if (line.startsWith("@@")) {
diff.push({ type: "header", content: line });
continue;
}
if (line.startsWith("+")) {
if (!line.startsWith("+++")) {
diff.push({ type: "add", content: line });
}
continue;
}
if (line.startsWith("-")) {
if (!line.startsWith("---")) {
diff.push({ type: "remove", content: line });
}
continue;
}
if (
line.startsWith("diff --git") ||
line.startsWith("index ") ||
line.startsWith("---") ||
line.startsWith("+++")
) {
continue;
}
if (line.startsWith("\\ No newline")) {
diff.push({ type: "header", content: line });
continue;
}
diff.push({ type: "context", content: line });
}
return diff;
}
function deriveDiffLines({
unifiedDiff,
original,
updated,
}: {
unifiedDiff?: string;
original?: string;
updated?: string;
}): DiffLine[] {
if (unifiedDiff) {
const parsed = parseUnifiedDiff(unifiedDiff);
if (parsed.length > 0) {
return parsed;
}
}
if (original !== undefined || updated !== undefined) {
return buildLineDiff(original ?? "", updated ?? "");
}
return [];
}
function looksLikePatch(text: string): boolean {
if (!text) {
return false;
}
return /(\*\*\* Begin Patch|@@|diff --git|\+\+\+|--- )/.test(text);
}
function parsePatchText(text: string): DiffLine[] {
if (!text) {
return [];
}
return parseUnifiedDiff(text);
}
function getFilePathFromRecord(record: Record<string, unknown>): string | undefined {
return (
getString(record["file_path"]) ??
getString(record["filePath"]) ??
getString(record["path"]) ??
getString(record["target_path"]) ??
getString(record["targetPath"]) ??
undefined
);
}
const ChangeBlockSchema = z
.object({
unified_diff: z.string().optional(),
unifiedDiff: z.string().optional(),
diff: z.string().optional(),
patch: z.string().optional(),
old_content: z.string().optional(),
oldContent: z.string().optional(),
previous_content: z.string().optional(),
previousContent: z.string().optional(),
base_content: z.string().optional(),
baseContent: z.string().optional(),
old_string: z.string().optional(),
new_string: z.string().optional(),
new_content: z.string().optional(),
newContent: z.string().optional(),
replace_with: z.string().optional(),
replaceWith: z.string().optional(),
content: z.string().optional(),
})
.passthrough();
function buildEditEntryFromBlock(
filePath: string | undefined,
blockValue: Record<string, unknown>
): EditEntry | null {
const parsed = ChangeBlockSchema.safeParse(blockValue);
if (!parsed.success) {
return null;
}
const data = parsed.data;
const diffLines = deriveDiffLines({
unifiedDiff:
getString(
data.unified_diff ??
data.unifiedDiff ??
data.patch ??
data.diff
) ?? undefined,
original:
getString(
data.old_string ??
data.old_content ??
data.oldContent ??
data.previous_content ??
data.previousContent ??
data.base_content ??
data.baseContent
) ?? undefined,
updated:
getString(
data.new_string ??
data.new_content ??
data.newContent ??
data.replace_with ??
data.replaceWith ??
data.content
) ?? undefined,
});
if (diffLines.length > 0) {
return {
filePath: filePath ?? getFilePathFromRecord(blockValue),
diffLines,
};
}
const patchCandidate =
getString(data.unified_diff ?? data.unifiedDiff ?? data.patch ?? data.diff) ??
undefined;
if (patchCandidate && looksLikePatch(patchCandidate)) {
const parsedLines = parsePatchText(patchCandidate);
if (parsedLines.length > 0) {
return {
filePath: filePath ?? getFilePathFromRecord(blockValue),
diffLines: parsedLines,
};
}
}
return null;
}
function mergeEditEntries(entries: EditEntry[]): EditEntry[] {
if (entries.length === 0) {
return [];
}
const seen = new Map<string, EditEntry>();
entries.forEach((entry) => {
if (!entry.diffLines.length) {
return;
}
const hash = `${entry.filePath ?? "unknown"}::${entry.diffLines
.map((line) => `${line.type}:${line.content}`)
.join("|")}`;
if (!seen.has(hash)) {
seen.set(hash, entry);
}
});
return Array.from(seen.values());
}
function parseEditArguments(value: unknown, depth = 0): EditEntry[] {
if (!value || depth > 5) {
return [];
}
if (typeof value === "string") {
if (looksLikePatch(value)) {
const diffLines = parsePatchText(value);
return diffLines.length ? [{ diffLines }] : [];
}
return [];
}
if (Array.isArray(value)) {
return mergeEditEntries(
value.flatMap((entry) => parseEditArguments(entry, depth + 1))
);
}
if (!isRecord(value)) {
return [];
}
const entries: EditEntry[] = [];
const filePathHint = getFilePathFromRecord(value);
const changesParse = z.record(z.unknown()).safeParse(value["changes"]);
if (changesParse.success) {
for (const [filePath, changeValue] of Object.entries(changesParse.data)) {
const nested = parseEditArguments(changeValue, depth + 1);
entries.push(
...nested.map((entry) => ({
...entry,
filePath: entry.filePath ?? filePath ?? filePathHint,
}))
);
}
}
if (Array.isArray(value["files"])) {
for (const fileEntry of value["files"] as unknown[]) {
const nested = parseEditArguments(fileEntry, depth + 1);
const derivedFilePath = isRecord(fileEntry)
? getFilePathFromRecord(fileEntry)
: undefined;
const resolvedFilePath = derivedFilePath ?? filePathHint;
entries.push(
...nested.map((entry) => ({
...entry,
filePath: entry.filePath ?? resolvedFilePath,
}))
);
}
}
const direct = buildEditEntryFromBlock(filePathHint, value);
if (direct) {
entries.push(direct);
} else {
const patchCandidates = [
getString(value["patch"]),
getString(value["diff"]),
getString(value["unified_diff"]),
getString(value["unifiedDiff"]),
].filter(Boolean) as string[];
for (const patch of patchCandidates) {
if (patch && looksLikePatch(patch)) {
const diffLines = parsePatchText(patch);
if (diffLines.length) {
entries.push({ filePath: filePathHint, diffLines });
break;
}
}
}
}
const nestedKeys = [
"input",
"update",
"create",
"delete",
"raw",
"data",
"payload",
"arguments",
"result",
] as const;
for (const key of nestedKeys) {
if (value[key] !== undefined) {
const nestedEntries = parseEditArguments(value[key], depth + 1);
entries.push(
...nestedEntries.map((entry) => ({
...entry,
filePath: entry.filePath ?? filePathHint,
}))
);
}
}
return mergeEditEntries(entries);
}
const ReadContainerSchema = z
.object({
filePath: z.string().optional(),
file_path: z.string().optional(),
path: z.string().optional(),
content: z.string().optional(),
text: z.string().optional(),
blob: z.string().optional(),
data: z
.object({
content: z.string().optional(),
text: z.string().optional(),
})
.optional(),
structuredContent: z
.object({
content: z.string().optional(),
text: z.string().optional(),
data: z
.object({
content: z.string().optional(),
text: z.string().optional(),
})
.optional(),
})
.optional(),
structured_content: z
.object({
content: z.string().optional(),
text: z.string().optional(),
})
.optional(),
output: z
.object({
content: z.string().optional(),
text: z.string().optional(),
})
.optional(),
})
.passthrough();
function parseReadEntries(value: unknown, depth = 0): ReadEntry[] {
if (!value || depth > 4) {
return [];
}
if (typeof value === "string") {
const trimmed = value.trim();
return trimmed.length ? [{ content: value }] : [];
}
if (Array.isArray(value)) {
return value.flatMap((entry) => parseReadEntries(entry, depth + 1));
}
if (!isRecord(value)) {
return [];
}
const parsed = ReadContainerSchema.safeParse(value);
if (parsed.success) {
const data = parsed.data;
const content =
getString(data.content) ??
getString(data.text) ??
getString(data.blob) ??
getString(data.data?.content) ??
getString(data.data?.text) ??
getString(data.structuredContent?.content) ??
getString(data.structuredContent?.text) ??
getString(data.structuredContent?.data?.content) ??
getString(data.structuredContent?.data?.text) ??
getString(data.structured_content?.content) ??
getString(data.structured_content?.text) ??
getString(data.output?.content) ??
getString(data.output?.text);
if (content) {
return [
{
filePath: data.filePath ?? data.file_path ?? data.path,
content,
},
];
}
}
const nestedKeys = [
"output",
"result",
"structuredContent",
"structured_content",
"data",
"raw",
"value",
"content",
] as const;
const entries: ReadEntry[] = [];
for (const key of nestedKeys) {
if (value[key] !== undefined) {
entries.push(...parseReadEntries(value[key], depth + 1));
}
}
return entries;
}
function mergeReadEntries(entries: ReadEntry[]): ReadEntry[] {
if (!entries.length) {
return [];
}
const seen = new Map<string, ReadEntry>();
entries.forEach((entry) => {
const hash = `${entry.filePath ?? "content"}::${entry.content}`;
if (!seen.has(hash)) {
seen.set(hash, entry);
}
});
return Array.from(seen.values());
}
const CommandRawSchema = z
.object({
type: z.string().optional(),
command: z.union([z.string(), z.array(z.string())]).optional(),
aggregated_output: z.string().optional(),
exit_code: z.number().optional(),
cwd: z.string().optional(),
directory: z.string().optional(),
metadata: z
.object({
exit_code: z.number().optional(),
})
.optional(),
input: z.unknown().optional(),
output: z.unknown().optional(),
})
.passthrough();
const CommandResultSchema = z
.object({
output: z.string().optional(),
exitCode: z.number().nullable().optional(),
structuredContent: z
.object({
output: z.string().optional(),
text: z.string().optional(),
content: z.string().optional(),
})
.optional(),
structured_content: z
.object({
output: z.string().optional(),
text: z.string().optional(),
content: z.string().optional(),
})
.optional(),
metadata: z
.object({
exit_code: z.number().optional(),
})
.optional(),
result: z.unknown().optional(),
})
.passthrough();
function coerceCommandValue(value: unknown): string | undefined {
if (typeof value === "string" && value.length > 0) {
return value;
}
if (Array.isArray(value)) {
const tokens = value.filter((entry): entry is string => typeof entry === "string");
if (tokens.length) {
return tokens.join(" ");
}
}
return undefined;
}
function collectCommandDetails(
target: CommandDetails,
value: unknown,
depth = 0
): void {
if (!value || depth > 4) {
return;
}
if (typeof value === "string") {
if (!target.output) {
target.output = value;
}
return;
}
if (!isRecord(value)) {
return;
}
const rawParsed = CommandRawSchema.safeParse(value);
if (rawParsed.success) {
const data = rawParsed.data;
const commandCandidate =
coerceCommandValue(data.command) ??
(isRecord(data.input) ? coerceCommandValue(data.input["command"]) : undefined);
if (!target.command && commandCandidate) {
target.command = commandCandidate;
}
const cwdCandidate =
getString(data.cwd ?? data.directory) ??
(isRecord(data.input)
? getString(data.input["cwd"] ?? data.input["directory"])
: undefined);
if (!target.cwd && cwdCandidate) {
target.cwd = cwdCandidate;
}
const aggregatedOutput =
getString(data.aggregated_output) ??
(isRecord(data.output)
? getString(
(data.output as Record<string, unknown>)["aggregated_output"] ??
(data.output as Record<string, unknown>)["output"] ??
(data.output as Record<string, unknown>)["text"]
)
: undefined);
if (!target.output && aggregatedOutput) {
target.output = aggregatedOutput;
}
const exitCandidate =
data.exit_code ??
(data.metadata ? data.metadata.exit_code : undefined) ??
(isRecord(data.output)
? ((data.output as Record<string, unknown>)["exit_code"] as number | undefined) ??
((data.output as Record<string, unknown>)["exitCode"] as number | undefined)
: undefined);
if ((typeof exitCandidate === "number" || exitCandidate === null) && target.exitCode === undefined) {
target.exitCode = exitCandidate ?? null;
}
}
const resultParsed = CommandResultSchema.safeParse(value);
if (resultParsed.success) {
const data = resultParsed.data;
const outputCandidate =
getString(data.output) ??
getString(data.structuredContent?.output) ??
getString(data.structuredContent?.text) ??
getString(data.structured_content?.output) ??
getString(data.structured_content?.text) ??
undefined;
if (!target.output && outputCandidate) {
target.output = outputCandidate;
}
const exitCandidate = data.exitCode ?? data.metadata?.exit_code;
if ((typeof exitCandidate === "number" || exitCandidate === null) && target.exitCode === undefined) {
target.exitCode = exitCandidate ?? null;
}
if (data.result !== undefined) {
collectCommandDetails(target, data.result, depth + 1);
}
}
const nestedKeys = [
"input",
"output",
"result",
"raw",
"data",
"payload",
"structuredContent",
"structured_content",
] as const;
for (const key of nestedKeys) {
if (value[key] !== undefined) {
collectCommandDetails(target, value[key], depth + 1);
}
}
}
function parseCommandDetails(args: unknown, result: unknown): CommandDetails | null {
const details: CommandDetails = {};
collectCommandDetails(details, args);
collectCommandDetails(details, result);
if (details.command || details.output) {
return details;
}
return null;
}
import {
extractCommandDetails,
extractEditEntries,
extractReadEntries,
type CommandDetails,
type EditEntry,
type ReadEntry,
} from "@/utils/tool-call-parsers";
import { DiffViewer } from "./diff-viewer";
interface ToolCallBottomSheetProps {
bottomSheetRef: React.RefObject<BottomSheetModal | null>;
@@ -727,13 +47,16 @@ export function ToolCallBottomSheet({
);
// Extract data based on source
const { toolName, args, result, error } = useMemo(() => {
const { toolName, args, result, error, parsedEdits, parsedReads, parsedCommand } = useMemo(() => {
if (!selectedToolCall) {
return {
toolName: "Tool Call",
args: undefined,
result: undefined,
error: undefined,
parsedEdits: undefined,
parsedReads: undefined,
parsedCommand: undefined,
};
}
@@ -746,6 +69,9 @@ export function ToolCallBottomSheet({
args: data.raw,
result: data.result,
error: data.error,
parsedEdits: data.parsedEdits,
parsedReads: data.parsedReads,
parsedCommand: data.parsedCommand,
};
}
@@ -755,29 +81,30 @@ export function ToolCallBottomSheet({
args: data.arguments,
result: data.result,
error: data.error,
parsedEdits: undefined,
parsedReads: undefined,
parsedCommand: undefined,
};
}, [selectedToolCall]);
const editEntries = useMemo(() => {
const entries = [
...parseEditArguments(args),
...parseEditArguments(result),
];
return mergeEditEntries(entries);
}, [args, result]);
const readEntries = useMemo(() => {
const entries = [
...parseReadEntries(result),
...parseReadEntries(args),
];
return mergeReadEntries(entries);
}, [args, result]);
const commandDetails = useMemo(() => parseCommandDetails(args, result), [
const fallbackEditEntries = useMemo(() => extractEditEntries(args, result), [
args,
result,
]);
const editEntries: EditEntry[] = parsedEdits ?? fallbackEditEntries;
const fallbackReadEntries = useMemo(() => extractReadEntries(result, args), [
args,
result,
]);
const readEntries: ReadEntry[] = parsedReads ?? fallbackReadEntries;
const fallbackCommandDetails = useMemo(
() => extractCommandDetails(args, result),
[args, result]
);
const commandDetails: CommandDetails | null =
parsedCommand ?? fallbackCommandDetails;
return (
<BottomSheetModal
@@ -812,52 +139,7 @@ export function ToolCallBottomSheet({
<Text style={styles.sectionTitle}>Diff</Text>
<View style={styles.diffContainer}>
{entry.diffLines.length === 0 ? (
<View style={styles.diffEmptyState}>
<Text style={styles.diffEmptyText}>No changes to display</Text>
</View>
) : (
<ScrollView
style={styles.diffScrollVertical}
contentContainerStyle={styles.diffVerticalContent}
nestedScrollEnabled
showsVerticalScrollIndicator
>
<ScrollView
horizontal
nestedScrollEnabled
showsHorizontalScrollIndicator
contentContainerStyle={styles.diffScrollContent}
>
<View style={styles.diffLinesContainer}>
{entry.diffLines.map((line, lineIndex) => (
<View
key={`${line.type}-${lineIndex}`}
style={[
styles.diffLine,
line.type === "header" && styles.diffHeaderLine,
line.type === "add" && styles.diffAddLine,
line.type === "remove" && styles.diffRemoveLine,
line.type === "context" && styles.diffContextLine,
]}
>
<Text
style={[
styles.diffLineText,
line.type === "header" && styles.diffHeaderText,
line.type === "add" && styles.diffAddText,
line.type === "remove" && styles.diffRemoveText,
line.type === "context" && styles.diffContextText,
]}
>
{line.content}
</Text>
</View>
))}
</View>
</ScrollView>
</ScrollView>
)}
<DiffViewer diffLines={entry.diffLines} />
</View>
</View>
))}
@@ -1039,61 +321,6 @@ const styles = StyleSheet.create((theme) => ({
backgroundColor: theme.colors.card,
overflow: "hidden",
},
diffScrollVertical: {
maxHeight: 280,
},
diffVerticalContent: {
flexGrow: 1,
},
diffScrollContent: {
flexDirection: "column" as const,
},
diffLinesContainer: {
alignSelf: "flex-start",
},
diffLine: {
minWidth: "100%",
paddingHorizontal: theme.spacing[3],
paddingVertical: theme.spacing[1],
},
diffLineText: {
fontFamily: "monospace",
fontSize: theme.fontSize.xs,
color: theme.colors.foreground,
},
diffHeaderLine: {
backgroundColor: theme.colors.muted,
},
diffHeaderText: {
color: theme.colors.mutedForeground,
},
diffAddLine: {
backgroundColor: theme.colors.palette.green[900],
},
diffAddText: {
color: theme.colors.palette.green[200],
},
diffRemoveLine: {
backgroundColor: theme.colors.palette.red[900],
},
diffRemoveText: {
color: theme.colors.palette.red[200],
},
diffContextLine: {
backgroundColor: theme.colors.card,
},
diffContextText: {
color: theme.colors.mutedForeground,
},
diffEmptyState: {
padding: theme.spacing[4],
alignItems: "center" as const,
justifyContent: "center" as const,
},
diffEmptyText: {
fontSize: theme.fontSize.sm,
color: theme.colors.mutedForeground,
},
jsonContainer: {
backgroundColor: theme.colors.background,
borderRadius: theme.borderRadius.lg,
+56
View File
@@ -1064,6 +1064,60 @@ function testToolCallDeduplicationHydrated() {
});
}
function buildOutOfOrderToolCallSequence(provider: ToolCallProvider) {
const timestamps = [
new Date('2025-01-01T13:00:00Z'),
new Date('2025-01-01T13:00:05Z'),
];
const callId = `${provider}-out-of-order`;
return [
{
event: toolTimeline(
'shell',
'completed',
{ type: 'tool_result', provider, tool_call_id: callId },
{ provider, server: 'command', tool: 'shell', callId }
),
timestamp: timestamps[0],
},
{
event: toolTimeline(
'shell',
'executing',
{ type: 'tool_use', provider },
{ provider, server: 'command', tool: 'shell', callId: null }
),
timestamp: timestamps[1],
},
];
}
function testOutOfOrderToolCallMergingLive() {
(['claude', 'codex'] as const).forEach((provider) => {
const updates = buildOutOfOrderToolCallSequence(provider);
const finalState = updates.reduce<StreamItem[]>((state, { event, timestamp }) => {
return reduceStreamUpdate(state, event, timestamp);
}, []);
const toolCalls = finalState.filter(
(item): item is ToolCallItem => item.kind === 'tool_call' && item.payload.source === 'agent'
);
assert.strictEqual(toolCalls.length, 1, `${provider} live stream should not duplicate out-of-order calls`);
assert.strictEqual(toolCalls[0]?.payload.data.status, 'completed', `${provider} live stream should keep completed status`);
});
}
function testOutOfOrderToolCallMergingHydrated() {
(['claude', 'codex'] as const).forEach((provider) => {
const updates = buildOutOfOrderToolCallSequence(provider);
const hydrated = hydrateStreamState(updates);
const toolCalls = hydrated.filter(
(item): item is ToolCallItem => item.kind === 'tool_call' && item.payload.source === 'agent'
);
assert.strictEqual(toolCalls.length, 1, `${provider} hydration should not duplicate out-of-order calls`);
assert.strictEqual(toolCalls[0]?.payload.data.status, 'completed', `${provider} hydration should keep completed status`);
});
}
describe('stream timeline reducers', () => {
it('produces deterministic hydration results', testIdempotentReduction);
it('deduplicates pending/completed tool entries in place', testUserMessageDeduplication);
@@ -1083,4 +1137,6 @@ describe('stream timeline reducers', () => {
it('keeps timeline ids stable after list shrinkage', testTimelineIdStabilityAfterRemovals);
it('deduplicates live tool call entries', testToolCallDeduplicationLive);
it('deduplicates hydrated tool call entries', testToolCallDeduplicationHydrated);
it('merges out-of-order tool call updates without duplicating entries (live)', testOutOfOrderToolCallMergingLive);
it('merges out-of-order tool call updates without duplicating entries (hydrated)', testOutOfOrderToolCallMergingHydrated);
});
+77 -16
View File
@@ -35,13 +35,30 @@ function createTimelineId(prefix: string, text: string, timestamp: Date): string
function createUniqueTimelineId(
state: StreamItem[],
prefix: "assistant" | "thought",
prefix: "assistant" | "thought" | "user",
text: string,
timestamp: Date
): string {
const base = createTimelineId(prefix, text, timestamp);
const suffixSeed = state.length.toString(36);
return `${base}_${suffixSeed}`;
let suffixSeed = state.length;
let candidate = `${base}_${suffixSeed.toString(36)}`;
// Fast path when the generated id hasn't been used yet
const hasCollision = state.some((entry) => entry.id === candidate);
if (!hasCollision) {
return candidate;
}
// If the id already exists (e.g. prior items were pruned/replaced),
// spin until we find an unused suffix. Building the lookup Set lazily keeps
// the common case cheap while still guaranteeing uniqueness.
const usedIds = new Set(state.map((entry) => entry.id));
while (usedIds.has(candidate)) {
suffixSeed += 1;
candidate = `${base}_${suffixSeed.toString(36)}`;
}
return candidate;
}
export type StreamItem =
@@ -157,7 +174,9 @@ function appendUserMessage(
return state;
}
const entryId = messageId ?? createTimelineId("user", chunk.trim() || chunk, timestamp);
const chunkSeed = chunk.trim() || chunk;
const entryId =
messageId ?? createUniqueTimelineId(state, "user", chunkSeed, timestamp);
const existingIndex = state.findIndex(
(entry) => entry.kind === "user_message" && entry.id === entryId
);
@@ -297,23 +316,27 @@ function findExistingAgentToolCallIndex(
}
const fallbackCandidates: Array<{ index: number; item: AgentToolCallItem }> = [];
const metadataMatches: Array<{ index: number; item: AgentToolCallItem }> = [];
for (let i = 0; i < state.length; i += 1) {
const entry = state[i];
if (entry.kind !== "tool_call" || entry.payload.source !== "agent") {
continue;
}
const payload = entry.payload.data;
const providerMatches =
payload.provider === data.provider &&
payload.server === data.server &&
payload.tool === data.tool;
if (providerMatches) {
metadataMatches.push({ index: i, item: entry as AgentToolCallItem });
}
if (payload.callId) {
continue;
}
if (payload.status !== "executing") {
continue;
}
if (
payload.provider === data.provider &&
payload.server === data.server &&
payload.tool === data.tool
) {
if (providerMatches) {
fallbackCandidates.push({ index: i, item: entry as AgentToolCallItem });
}
}
@@ -346,14 +369,34 @@ function findExistingAgentToolCallIndex(
normalizedDisplayName
);
const byKind = filterByComparableField(
byDisplayName,
(entry) => normalizeComparableString(entry.payload.data.kind),
normalizedKind
);
const selectCandidate = (
candidates: Array<{ index: number; item: AgentToolCallItem }>
): number => {
const byDisplayName = filterByComparableField(
candidates,
(entry) => normalizeComparableString(entry.payload.data.displayName),
normalizedDisplayName
);
// Fall back to the oldest candidate (FIFO) to avoid duplicating the initial "executing" pill.
return byKind[0]?.index ?? -1;
const byKind = filterByComparableField(
byDisplayName,
(entry) => normalizeComparableString(entry.payload.data.kind),
normalizedKind
);
return byKind[0]?.index ?? -1;
};
if (fallbackCandidates.length) {
return selectCandidate(fallbackCandidates);
}
// If this update still lacks a call id, fall back to metadata matches (e.g. replayed hydration events)
if (!normalizedCallId && metadataMatches.length) {
return selectCandidate(metadataMatches);
}
return -1;
}
function appendAgentToolCall(
@@ -389,6 +432,10 @@ function appendAgentToolCall(
payloadData.error !== undefined
? payloadData.error
: existing.payload.data.error;
const mergedStatus = mergeToolCallStatus(
existing.payload.data.status,
payloadData.status ?? existing.payload.data.status ?? "executing"
);
const parsed = computeParsedToolPayload(mergedRaw, mergedResult);
next[existingIndex] = {
...existing,
@@ -398,6 +445,7 @@ function appendAgentToolCall(
data: {
...existing.payload.data,
...payloadData,
status: mergedStatus,
raw: mergedRaw,
result: mergedResult,
error: mergedError,
@@ -579,6 +627,19 @@ function normalizeToolCallStatus(
return normalizedFromStatus ?? "executing";
}
function mergeToolCallStatus(
existing: "executing" | "completed" | "failed" | undefined,
incoming: "executing" | "completed" | "failed"
): "executing" | "completed" | "failed" {
if (existing === "failed" || incoming === "failed") {
return "failed";
}
if (existing === "completed" || incoming === "completed") {
return "completed";
}
return incoming ?? existing ?? "executing";
}
const TOOL_CALL_ID_KEYS = [
"toolCallId",
"tool_call_id",
+706
View File
@@ -0,0 +1,706 @@
import { z } from "zod";
export type DiffLine = {
type: "add" | "remove" | "context" | "header";
content: string;
};
export type EditEntry = {
filePath?: string;
diffLines: DiffLine[];
};
export type ReadEntry = {
filePath?: string;
content: string;
};
export type CommandDetails = {
command?: string;
cwd?: string;
output?: string;
exitCode?: number | null;
};
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null;
}
function getString(value: unknown): string | undefined {
return typeof value === "string" ? value : undefined;
}
function splitIntoLines(text: string): string[] {
if (!text) {
return [];
}
return text.replace(/\r\n/g, "\n").split("\n");
}
function buildLineDiff(originalText: string, updatedText: string): DiffLine[] {
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;
}
function parseUnifiedDiff(diffText?: string): DiffLine[] {
if (!diffText) {
return [];
}
const lines = splitIntoLines(diffText);
const diff: DiffLine[] = [];
for (const line of lines) {
if (!line.length) {
diff.push({ type: "context", content: line });
continue;
}
if (line.startsWith("@@")) {
diff.push({ type: "header", content: line });
continue;
}
if (line.startsWith("+")) {
if (!line.startsWith("+++")) {
diff.push({ type: "add", content: line });
}
continue;
}
if (line.startsWith("-")) {
if (!line.startsWith("---")) {
diff.push({ type: "remove", content: line });
}
continue;
}
if (
line.startsWith("diff --git") ||
line.startsWith("index ") ||
line.startsWith("---") ||
line.startsWith("+++")
) {
continue;
}
if (line.startsWith("\\ No newline")) {
diff.push({ type: "header", content: line });
continue;
}
diff.push({ type: "context", content: line });
}
return diff;
}
function deriveDiffLines({
unifiedDiff,
original,
updated,
}: {
unifiedDiff?: string;
original?: string;
updated?: string;
}): DiffLine[] {
if (unifiedDiff) {
const parsed = parseUnifiedDiff(unifiedDiff);
if (parsed.length > 0) {
return parsed;
}
}
if (original !== undefined || updated !== undefined) {
return buildLineDiff(original ?? "", updated ?? "");
}
return [];
}
function looksLikePatch(text: string): boolean {
if (!text) {
return false;
}
return /(\*\*\* Begin Patch|@@|diff --git|\+\+\+|--- )/.test(text);
}
function parsePatchText(text: string): DiffLine[] {
if (!text) {
return [];
}
return parseUnifiedDiff(text);
}
function getFilePathFromRecord(record: Record<string, unknown>): string | undefined {
return (
getString(record["file_path"]) ??
getString(record["filePath"]) ??
getString(record["path"]) ??
getString(record["target_path"]) ??
getString(record["targetPath"]) ??
undefined
);
}
const ChangeBlockSchema = z
.object({
unified_diff: z.string().optional(),
unifiedDiff: z.string().optional(),
diff: z.string().optional(),
patch: z.string().optional(),
old_content: z.string().optional(),
oldContent: z.string().optional(),
previous_content: z.string().optional(),
previousContent: z.string().optional(),
base_content: z.string().optional(),
baseContent: z.string().optional(),
old_string: z.string().optional(),
new_string: z.string().optional(),
new_content: z.string().optional(),
newContent: z.string().optional(),
replace_with: z.string().optional(),
replaceWith: z.string().optional(),
content: z.string().optional(),
})
.passthrough();
function buildEditEntryFromBlock(
filePath: string | undefined,
blockValue: Record<string, unknown>
): EditEntry | null {
const parsed = ChangeBlockSchema.safeParse(blockValue);
if (!parsed.success) {
return null;
}
const data = parsed.data;
const diffLines = deriveDiffLines({
unifiedDiff:
getString(
data.unified_diff ??
data.unifiedDiff ??
data.patch ??
data.diff
) ?? undefined,
original:
getString(
data.old_string ??
data.old_content ??
data.oldContent ??
data.previous_content ??
data.previousContent ??
data.base_content ??
data.baseContent
) ?? undefined,
updated:
getString(
data.new_string ??
data.new_content ??
data.newContent ??
data.replace_with ??
data.replaceWith ??
data.content
) ?? undefined,
});
if (diffLines.length > 0) {
return {
filePath: filePath ?? getFilePathFromRecord(blockValue),
diffLines,
};
}
const patchCandidate =
getString(data.unified_diff ?? data.unifiedDiff ?? data.patch ?? data.diff) ??
undefined;
if (patchCandidate && looksLikePatch(patchCandidate)) {
const parsedLines = parsePatchText(patchCandidate);
if (parsedLines.length > 0) {
return {
filePath: filePath ?? getFilePathFromRecord(blockValue),
diffLines: parsedLines,
};
}
}
return null;
}
function mergeEditEntries(entries: EditEntry[]): EditEntry[] {
if (entries.length === 0) {
return [];
}
const seen = new Map<string, EditEntry>();
entries.forEach((entry) => {
if (!entry.diffLines.length) {
return;
}
const hash = `${entry.filePath ?? "unknown"}::${entry.diffLines
.map((line) => `${line.type}:${line.content}`)
.join("|")}`;
if (!seen.has(hash)) {
seen.set(hash, entry);
}
});
return Array.from(seen.values());
}
function parseEditArguments(value: unknown, depth = 0): EditEntry[] {
if (!value || depth > 5) {
return [];
}
if (typeof value === "string") {
if (looksLikePatch(value)) {
const diffLines = parsePatchText(value);
return diffLines.length ? [{ diffLines }] : [];
}
return [];
}
if (Array.isArray(value)) {
return value.flatMap((entry) => parseEditArguments(entry, depth + 1));
}
if (!isRecord(value)) {
return [];
}
const filePathHint = getFilePathFromRecord(value) ?? getString(value["name"]);
if (value["patch"] || value["diff"] || value["unified_diff"] || value["unifiedDiff"]) {
const entry = buildEditEntryFromBlock(filePathHint, value);
return entry ? [entry] : [];
}
const entries: EditEntry[] = [];
const changeKeys = [
"changes",
"files",
"fileChanges",
"file_changes",
"edits",
"diffs",
"patches",
"fileDiffs",
"file_diffs",
] as const;
for (const key of changeKeys) {
const block = value[key];
if (!block) {
continue;
}
if (Array.isArray(block)) {
for (const item of block) {
if (isRecord(item)) {
const entry = buildEditEntryFromBlock(filePathHint, item);
if (entry) {
entries.push(entry);
}
}
}
continue;
}
if (isRecord(block)) {
if (block["patch"] || block["diff"]) {
const entry = buildEditEntryFromBlock(filePathHint, block);
if (entry) {
entries.push(entry);
}
continue;
}
for (const [path, nested] of Object.entries(block)) {
if (isRecord(nested)) {
const entry = buildEditEntryFromBlock(path, nested);
if (entry) {
entries.push(entry);
}
} else if (typeof nested === "string" && looksLikePatch(nested)) {
const diffLines = parsePatchText(nested);
if (diffLines.length) {
entries.push({ filePath: path, diffLines });
}
}
}
}
}
const changeEntry = buildEditEntryFromBlock(filePathHint, value);
if (changeEntry) {
entries.push(changeEntry);
}
const nestedKeys = [
"create",
"delete",
"raw",
"data",
"payload",
"arguments",
"result",
] as const;
for (const key of nestedKeys) {
if (value[key] !== undefined) {
const nestedEntries = parseEditArguments(value[key], depth + 1);
entries.push(
...nestedEntries.map((entry) => ({
...entry,
filePath: entry.filePath ?? filePathHint,
}))
);
}
}
return entries;
}
const ReadContainerSchema = z
.object({
filePath: z.string().optional(),
file_path: z.string().optional(),
path: z.string().optional(),
content: z.string().optional(),
text: z.string().optional(),
blob: z.string().optional(),
data: z
.object({
content: z.string().optional(),
text: z.string().optional(),
})
.optional(),
structuredContent: z
.object({
content: z.string().optional(),
text: z.string().optional(),
data: z
.object({
content: z.string().optional(),
text: z.string().optional(),
})
.optional(),
})
.optional(),
structured_content: z
.object({
content: z.string().optional(),
text: z.string().optional(),
})
.optional(),
output: z
.object({
content: z.string().optional(),
text: z.string().optional(),
})
.optional(),
})
.passthrough();
function parseReadEntriesInternal(value: unknown, depth = 0): ReadEntry[] {
if (!value || depth > 4) {
return [];
}
if (typeof value === "string") {
const trimmed = value.trim();
return trimmed.length ? [{ content: value }] : [];
}
if (Array.isArray(value)) {
return value.flatMap((entry) => parseReadEntriesInternal(entry, depth + 1));
}
if (!isRecord(value)) {
return [];
}
const parsed = ReadContainerSchema.safeParse(value);
if (parsed.success) {
const data = parsed.data;
const content =
getString(data.content) ??
getString(data.text) ??
getString(data.blob) ??
getString(data.data?.content) ??
getString(data.data?.text) ??
getString(data.structuredContent?.content) ??
getString(data.structuredContent?.text) ??
getString(data.structuredContent?.data?.content) ??
getString(data.structuredContent?.data?.text) ??
getString(data.structured_content?.content) ??
getString(data.structured_content?.text) ??
getString(data.output?.content) ??
getString(data.output?.text);
if (content) {
return [
{
filePath: data.filePath ?? data.file_path ?? data.path,
content,
},
];
}
}
const nestedKeys = [
"output",
"result",
"structuredContent",
"structured_content",
"data",
"raw",
"value",
"content",
] as const;
const entries: ReadEntry[] = [];
for (const key of nestedKeys) {
if (value[key] !== undefined) {
entries.push(...parseReadEntriesInternal(value[key], depth + 1));
}
}
return entries;
}
function mergeReadEntries(entries: ReadEntry[]): ReadEntry[] {
if (!entries.length) {
return [];
}
const seen = new Map<string, ReadEntry>();
entries.forEach((entry) => {
const hash = `${entry.filePath ?? "content"}::${entry.content}`;
if (!seen.has(hash)) {
seen.set(hash, entry);
}
});
return Array.from(seen.values());
}
const CommandRawSchema = z
.object({
type: z.string().optional(),
command: z.union([z.string(), z.array(z.string())]).optional(),
aggregated_output: z.string().optional(),
exit_code: z.number().optional(),
cwd: z.string().optional(),
directory: z.string().optional(),
metadata: z
.object({
exit_code: z.number().optional(),
})
.optional(),
input: z.unknown().optional(),
output: z.unknown().optional(),
})
.passthrough();
const CommandResultSchema = z
.object({
output: z.string().optional(),
exitCode: z.number().nullable().optional(),
structuredContent: z
.object({
output: z.string().optional(),
text: z.string().optional(),
content: z.string().optional(),
})
.optional(),
structured_content: z
.object({
output: z.string().optional(),
text: z.string().optional(),
content: z.string().optional(),
})
.optional(),
metadata: z
.object({
exit_code: z.number().optional(),
})
.optional(),
result: z.unknown().optional(),
})
.passthrough();
function coerceCommandValue(value: unknown): string | undefined {
if (typeof value === "string" && value.length > 0) {
return value;
}
if (Array.isArray(value)) {
const tokens = value.filter((entry): entry is string => typeof entry === "string");
if (tokens.length) {
return tokens.join(" ");
}
}
return undefined;
}
function collectCommandDetails(
target: CommandDetails,
value: unknown,
depth = 0
): void {
if (!value || depth > 4) {
return;
}
if (typeof value === "string") {
if (!target.output) {
target.output = value;
}
return;
}
if (!isRecord(value)) {
return;
}
const rawParsed = CommandRawSchema.safeParse(value);
if (rawParsed.success) {
const data = rawParsed.data;
const commandCandidate =
coerceCommandValue(data.command) ??
(isRecord(data.input) ? coerceCommandValue(data.input["command"]) : undefined);
if (!target.command && commandCandidate) {
target.command = commandCandidate;
}
const cwdCandidate =
getString(data.cwd ?? data.directory) ??
(isRecord(data.input)
? getString(data.input["cwd"] ?? data.input["directory"])
: undefined);
if (!target.cwd && cwdCandidate) {
target.cwd = cwdCandidate;
}
const aggregatedOutput =
getString(data.aggregated_output) ??
(isRecord(data.output)
? getString(
(data.output as Record<string, unknown>)["aggregated_output"] ??
(data.output as Record<string, unknown>)["output"] ??
(data.output as Record<string, unknown>)["text"]
)
: undefined);
if (!target.output && aggregatedOutput) {
target.output = aggregatedOutput;
}
const exitCandidate =
data.exit_code ??
(data.metadata ? data.metadata.exit_code : undefined) ??
(isRecord(data.output)
? ((data.output as Record<string, unknown>)["exit_code"] as number | undefined) ??
((data.output as Record<string, unknown>)["exitCode"] as number | undefined)
: undefined);
if (target.exitCode === undefined && exitCandidate !== undefined) {
target.exitCode = exitCandidate;
}
}
const resultParsed = CommandResultSchema.safeParse(value);
if (resultParsed.success) {
const data = resultParsed.data;
if (!target.output) {
target.output =
getString(data.output) ??
getString(data.structuredContent?.output) ??
getString(data.structuredContent?.text) ??
getString(data.structured_content?.output) ??
getString(data.structured_content?.text) ??
(typeof data.result === "string" ? data.result : undefined);
}
if (target.exitCode === undefined) {
target.exitCode = data.exitCode ?? data.metadata?.exit_code;
}
if (!target.command && isRecord(data.result)) {
const nestedCommand =
coerceCommandValue(data.result["command"]) ??
coerceCommandValue((data.result as Record<string, unknown>)["args"]);
if (nestedCommand) {
target.command = nestedCommand;
}
}
}
const nestedKeys = [
"input",
"output",
"result",
"response",
"data",
"raw",
"payload",
] as const;
for (const key of nestedKeys) {
if (value[key] !== undefined) {
collectCommandDetails(target, value[key], depth + 1);
}
}
}
export function extractEditEntries(...sources: unknown[]): EditEntry[] {
const entries = sources.flatMap((value) => parseEditArguments(value));
return mergeEditEntries(entries);
}
export function extractReadEntries(...sources: unknown[]): ReadEntry[] {
return mergeReadEntries(sources.flatMap((value) => parseReadEntriesInternal(value)));
}
export function extractCommandDetails(...sources: unknown[]): CommandDetails | null {
const details: CommandDetails = {};
sources.forEach((value) => collectCommandDetails(details, value));
if (details.command || details.output || details.cwd) {
return details;
}
return null;
}