diff --git a/packages/app/src/components/message.tsx b/packages/app/src/components/message.tsx
index c25dba5b7..35df2274e 100644
--- a/packages/app/src/components/message.tsx
+++ b/packages/app/src/components/message.tsx
@@ -27,7 +27,7 @@ import { baseColors, theme } from "@/styles/theme";
import { Colors } from "@/constants/theme";
import * as Clipboard from "expo-clipboard";
import type { TodoEntry, ThoughtStatus } from "@/types/stream";
-import type { CommandDetails, EditEntry, ReadEntry } from "@/utils/tool-call-parsers";
+import type { CommandDetails, EditEntry, ReadEntry, DiffLine } from "@/utils/tool-call-parsers";
import { DiffViewer } from "./diff-viewer";
import { resolveToolCallPreview } from "./tool-call-preview";
@@ -1205,6 +1205,80 @@ function formatFullValue(value: unknown): string {
}
}
+// Helper function to build diff lines (mirrors logic from tool-call-parsers)
+function buildLineDiffFromStrings(originalText: string, updatedText: string): DiffLine[] {
+ const splitIntoLines = (text: string): string[] => {
+ if (!text) return [];
+ return text.replace(/\r\n/g, "\n").split("\n");
+ };
+
+ const originalLines = splitIntoLines(originalText);
+ const updatedLines = splitIntoLines(updatedText);
+
+ const hasAnyContent = originalLines.length > 0 || updatedLines.length > 0;
+ if (!hasAnyContent) return [];
+
+ const m = originalLines.length;
+ const n = updatedLines.length;
+ const dp: number[][] = Array.from({ length: m + 1 }, () => Array(n + 1).fill(0));
+
+ for (let i = m - 1; i >= 0; i -= 1) {
+ for (let j = n - 1; j >= 0; j -= 1) {
+ if (originalLines[i] === updatedLines[j]) {
+ dp[i][j] = dp[i + 1][j + 1] + 1;
+ } else {
+ dp[i][j] = Math.max(dp[i + 1][j], dp[i][j + 1]);
+ }
+ }
+ }
+
+ const diff: DiffLine[] = [];
+ let i = 0;
+ let j = 0;
+
+ while (i < m && j < n) {
+ if (originalLines[i] === updatedLines[j]) {
+ diff.push({ type: "context", content: ` ${originalLines[i]}` });
+ i += 1;
+ j += 1;
+ } else if (dp[i + 1][j] >= dp[i][j + 1]) {
+ diff.push({ type: "remove", content: `-${originalLines[i]}` });
+ i += 1;
+ } else {
+ diff.push({ type: "add", content: `+${updatedLines[j]}` });
+ j += 1;
+ }
+ }
+
+ while (i < m) {
+ diff.push({ type: "remove", content: `-${originalLines[i]}` });
+ i += 1;
+ }
+
+ while (j < n) {
+ diff.push({ type: "add", content: `+${updatedLines[j]}` });
+ j += 1;
+ }
+
+ return diff;
+}
+
+// Type guard for structured tool results
+type StructuredToolResult = {
+ type: "command" | "file_write" | "file_edit" | "file_read" | "generic";
+ [key: string]: unknown;
+};
+
+function isStructuredToolResult(result: unknown): result is StructuredToolResult {
+ return (
+ typeof result === "object" &&
+ result !== null &&
+ "type" in result &&
+ typeof result.type === "string" &&
+ ["command", "file_write", "file_edit", "file_read", "generic"].includes(result.type)
+ );
+}
+
export const ToolCall = memo(function ToolCall({
toolName,
kind,
@@ -1230,6 +1304,12 @@ export const ToolCall = memo(function ToolCall({
const [isExpanded, setIsExpanded] = useState(false);
+ // Check if result has a type field for structured rendering
+ const structuredResult = useMemo(
+ () => (isStructuredToolResult(result) ? result : null),
+ [result]
+ );
+
const IconComponent = kind
? toolKindIcons[kind.toLowerCase()] || Wrench
: Wrench;
@@ -1251,6 +1331,79 @@ export const ToolCall = memo(function ToolCall({
setIsExpanded((prev) => !prev);
}, []);
+ // Helper functions to extract data from structured results
+ const extractCommandFromStructured = useCallback(
+ (structured: StructuredToolResult): CommandDetails | null => {
+ if (structured.type !== "command") return null;
+
+ const cmd: CommandDetails = {};
+ if (typeof structured.command === "string") cmd.command = structured.command;
+ if (typeof structured.cwd === "string") cmd.cwd = structured.cwd;
+ if (typeof structured.output === "string") cmd.output = structured.output;
+ if (typeof structured.exitCode === "number") cmd.exitCode = structured.exitCode;
+
+ return cmd.command || cmd.output ? cmd : null;
+ },
+ []
+ );
+
+ const extractDiffFromStructured = useCallback(
+ (structured: StructuredToolResult): EditEntry[] => {
+ if (structured.type !== "file_write" && structured.type !== "file_edit") {
+ return [];
+ }
+
+ const filePath = typeof structured.filePath === "string" ? structured.filePath : undefined;
+
+ // For file_write, create a diff from oldContent -> newContent
+ if (structured.type === "file_write") {
+ const oldContent = typeof structured.oldContent === "string" ? structured.oldContent : "";
+ const newContent = typeof structured.newContent === "string" ? structured.newContent : "";
+
+ // Use the same diff building logic from tool-call-parsers
+ const diffLines = buildLineDiffFromStrings(oldContent, newContent);
+ if (diffLines.length > 0) {
+ return [{ filePath, diffLines }];
+ }
+ }
+
+ // For file_edit, it might already have diffLines or we need to construct them
+ if (structured.type === "file_edit") {
+ // Check if diffLines are provided directly
+ if (Array.isArray(structured.diffLines)) {
+ return [{ filePath, diffLines: structured.diffLines as DiffLine[] }];
+ }
+
+ // Otherwise try to build from old/new content
+ const oldContent = typeof structured.oldContent === "string" ? structured.oldContent : "";
+ const newContent = typeof structured.newContent === "string" ? structured.newContent : "";
+ const diffLines = buildLineDiffFromStrings(oldContent, newContent);
+ if (diffLines.length > 0) {
+ return [{ filePath, diffLines }];
+ }
+ }
+
+ return [];
+ },
+ []
+ );
+
+ const extractReadFromStructured = useCallback(
+ (structured: StructuredToolResult): ReadEntry[] => {
+ if (structured.type !== "file_read") return [];
+
+ const filePath = typeof structured.filePath === "string" ? structured.filePath : undefined;
+ const content = typeof structured.content === "string" ? structured.content : "";
+
+ if (content) {
+ return [{ filePath, content }];
+ }
+
+ return [];
+ },
+ []
+ );
+
const hasCommandDetails = Boolean(
commandDetails &&
(commandDetails.command ||
@@ -1412,7 +1565,185 @@ export const ToolCall = memo(function ToolCall({
}, [args, serializedArgs, result, serializedResult, error, serializedError, hasStructuredContent]);
const renderDetails = useCallback(() => {
- // Don't show readSections if commandDetails has output (they show the same data)
+ // If we have a structured result, use type-based rendering
+ if (structuredResult) {
+ const sections: ReactNode[] = [];
+
+ // Render based on type
+ switch (structuredResult.type) {
+ case "command": {
+ const cmd = extractCommandFromStructured(structuredResult);
+ if (cmd) {
+ // Reuse the command section rendering logic
+ sections.push(
+
+ Command
+ {cmd.command ? (
+
+ {cmd.command}
+
+ ) : null}
+ {cmd.cwd ? (
+
+ Directory
+ {cmd.cwd}
+
+ ) : null}
+ {cmd.exitCode !== undefined ? (
+
+ Exit Code
+
+ {cmd.exitCode === null ? "Unknown" : cmd.exitCode}
+
+
+ ) : null}
+ {cmd.output ? (
+
+ {cmd.output}
+
+ ) : null}
+
+ );
+ }
+ break;
+ }
+
+ case "file_write":
+ case "file_edit": {
+ const diffs = extractDiffFromStructured(structuredResult);
+ diffs.forEach((entry, index) => {
+ sections.push(
+
+ Diff
+ {entry.filePath ? (
+
+ {entry.filePath}
+
+ ) : null}
+
+
+
+
+ );
+ });
+ break;
+ }
+
+ case "file_read": {
+ const reads = extractReadFromStructured(structuredResult);
+ reads.forEach((entry, index) => {
+ sections.push(
+
+ Read Result
+ {entry.filePath ? (
+
+ {entry.filePath}
+
+ ) : null}
+
+ {entry.content}
+
+
+ );
+ });
+ break;
+ }
+
+ case "generic":
+ default: {
+ // Show raw JSON for generic type
+ if (result !== undefined) {
+ sections.push(
+
+ Result
+
+ {serializedResult}
+
+
+ );
+ }
+ break;
+ }
+ }
+
+ // Always show args if available
+ if (args !== undefined) {
+ sections.unshift(
+
+ Arguments
+
+ {serializedArgs}
+
+
+ );
+ }
+
+ // Always show errors if available
+ if (error !== undefined) {
+ sections.push(
+
+ Error
+
+
+ {serializedError}
+
+
+
+ );
+ }
+
+ if (sections.length === 0) {
+ return (
+
+ No additional details available
+
+ );
+ }
+
+ return {sections};
+ }
+
+ // Fall back to heuristic parsing for backwards compatibility
const showReadSections = !commandDetails?.output && readSections.length > 0;
if (
@@ -1436,7 +1767,23 @@ export const ToolCall = memo(function ToolCall({
{jsonSections}
);
- }, [commandSection, commandDetails?.output, editSections, readSections, jsonSections]);
+ }, [
+ structuredResult,
+ extractCommandFromStructured,
+ extractDiffFromStructured,
+ extractReadFromStructured,
+ serializedArgs,
+ serializedResult,
+ serializedError,
+ args,
+ result,
+ error,
+ commandSection,
+ commandDetails?.output,
+ editSections,
+ readSections,
+ jsonSections,
+ ]);
return (
= {};
- // SDK tool_result blocks have content as a string
- if (typeof block.content === "string" && block.content.length > 0) {
- result.result = { output: block.content };
+ if (content.length > 0) {
+ result.result = { output: content };
}
// Preserve file changes tracked during tool execution
@@ -1058,6 +1070,86 @@ class ClaudeAgentSession implements AgentSession {
return Object.keys(result).length > 0 ? result : undefined;
}
+ private buildStructuredToolResult(
+ server: string,
+ tool: string,
+ output: string,
+ input?: Record | null
+ ): Record | undefined {
+ const normalizedServer = server.toLowerCase();
+ const normalizedTool = tool.toLowerCase();
+
+ // Command execution tools
+ if (
+ normalizedServer.includes("bash") ||
+ normalizedServer.includes("shell") ||
+ normalizedServer.includes("command") ||
+ normalizedTool.includes("bash") ||
+ normalizedTool.includes("shell") ||
+ normalizedTool.includes("command") ||
+ (input && (typeof input.command === "string" || Array.isArray(input.command)))
+ ) {
+ const command = this.extractCommandText(input ?? {}) ?? "command";
+ return {
+ type: "command",
+ command,
+ output,
+ cwd: typeof input?.cwd === "string" ? input.cwd : undefined,
+ };
+ }
+
+ // File write tools (new files or complete replacements)
+ if (
+ normalizedTool.includes("write") ||
+ normalizedTool === "write_file" ||
+ normalizedTool === "create_file"
+ ) {
+ if (input && typeof input.file_path === "string") {
+ return {
+ type: "file_write",
+ filePath: input.file_path,
+ oldContent: "",
+ newContent: typeof input.content === "string" ? input.content : output,
+ };
+ }
+ }
+
+ // File edit/patch tools
+ if (
+ normalizedTool.includes("edit") ||
+ normalizedTool.includes("patch") ||
+ normalizedTool === "apply_patch" ||
+ normalizedTool === "apply_diff"
+ ) {
+ if (input && typeof input.file_path === "string") {
+ return {
+ type: "file_edit",
+ filePath: input.file_path,
+ diff: typeof input.patch === "string" ? input.patch : typeof input.diff === "string" ? input.diff : undefined,
+ oldContent: typeof input.old_str === "string" ? input.old_str : undefined,
+ newContent: typeof input.new_str === "string" ? input.new_str : undefined,
+ };
+ }
+ }
+
+ // File read tools
+ if (
+ normalizedTool.includes("read") ||
+ normalizedTool === "read_file" ||
+ normalizedTool === "view_file"
+ ) {
+ if (input && typeof input.file_path === "string") {
+ return {
+ type: "file_read",
+ filePath: input.file_path,
+ content: output,
+ };
+ }
+ }
+
+ return undefined;
+ }
+
private mapPartialEvent(event: SDKPartialAssistantMessage["event"]): AgentTimelineItem[] {
if (event.type === "content_block_start" && (event.content_block as ClaudeContentChunk | undefined)?.type === "tool_use") {
const block = event.content_block as ClaudeContentChunk;
diff --git a/packages/server/src/server/agent/providers/codex-agent.ts b/packages/server/src/server/agent/providers/codex-agent.ts
index a5fd855ef..5f2a1392c 100644
--- a/packages/server/src/server/agent/providers/codex-agent.ts
+++ b/packages/server/src/server/agent/providers/codex-agent.ts
@@ -817,7 +817,12 @@ class CodexAgentSession implements AgentSession {
return { type: "assistant_message", text: item.text };
case "reasoning":
return { type: "reasoning", text: item.text };
- case "command_execution":
+ case "command_execution": {
+ const output = (item as any)?.output;
+ const cwd = (item as any)?.cwd;
+ const commandValue = item.command;
+ const command = typeof commandValue === "string" ? commandValue :
+ Array.isArray(commandValue) ? (commandValue as string[]).join(" ") : "command";
return createToolCallTimelineItem({
server: "command",
tool: "shell",
@@ -825,10 +830,16 @@ class CodexAgentSession implements AgentSession {
callId: (item as any)?.call_id,
displayName: buildCommandDisplayName(item.command),
kind: "execute",
- input: { command: item.command, cwd: (item as any)?.cwd },
- output: (item as any)?.output,
+ input: { command: item.command, cwd },
+ output: typeof output === "string" ? {
+ type: "command",
+ command,
+ output,
+ cwd,
+ } : undefined,
error: (item as any)?.error,
});
+ }
case "file_change": {
const files = item.changes.map((change) => ({ path: change.path, kind: change.kind }));
return createToolCallTimelineItem({
@@ -1100,6 +1111,16 @@ function finalizeRolloutFunctionCall(
const result = safeJsonParse(payload.output);
const exitCode = result?.metadata?.exit_code;
const status = exitCode === undefined || exitCode === 0 ? "completed" : "failed";
+
+ // Build structured command output
+ const output = result?.stdout ? {
+ type: "command" as const,
+ command: command.command,
+ output: result.stdout,
+ exitCode,
+ cwd: command.cwd,
+ } : result;
+
events.push({
type: "timeline",
provider: "codex",
@@ -1111,7 +1132,7 @@ function finalizeRolloutFunctionCall(
displayName: buildCommandDisplayName(command.command),
kind: "execute",
input: { command: command.command, cwd: command.cwd },
- output: result,
+ output,
}),
});
commandCalls.delete(payload.call_id);
diff --git a/packages/server/src/server/messages.ts b/packages/server/src/server/messages.ts
index b788b3a24..c829f88e6 100644
--- a/packages/server/src/server/messages.ts
+++ b/packages/server/src/server/messages.ts
@@ -115,6 +115,15 @@ export const AgentPermissionRequestPayloadSchema: z.ZodType =
z.discriminatedUnion("type", [
z.object({