feat: show visual diffs for Edit/Write tool returns (#392)
Co-authored-by: Letta <noreply@letta.com>
This commit is contained in:
@@ -1,16 +1,195 @@
|
||||
// Utility to format tool argument JSON strings into a concise display label
|
||||
// Copied from old letta-code repo to preserve exact formatting behavior
|
||||
|
||||
import { relative } from "node:path";
|
||||
import {
|
||||
isFileEditTool,
|
||||
isFileReadTool,
|
||||
isFileWriteTool,
|
||||
isPatchTool,
|
||||
isShellTool,
|
||||
} from "./toolNameMapping.js";
|
||||
|
||||
// Small helpers
|
||||
const isRecord = (v: unknown): v is Record<string, unknown> =>
|
||||
typeof v === "object" && v !== null;
|
||||
|
||||
export function formatArgsDisplay(argsJson: string): {
|
||||
/**
|
||||
* Formats a file path for display (matches Claude Code style):
|
||||
* - Files within cwd: relative path without ./ prefix
|
||||
* - Files outside cwd: full absolute path
|
||||
*/
|
||||
function formatDisplayPath(filePath: string): string {
|
||||
const cwd = process.cwd();
|
||||
const relativePath = relative(cwd, filePath);
|
||||
// If path goes outside cwd (starts with ..), show full absolute path
|
||||
if (relativePath.startsWith("..")) {
|
||||
return filePath;
|
||||
}
|
||||
return relativePath;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses a patch input to extract operation type and file path.
|
||||
* Returns null if parsing fails. Used for tool call display.
|
||||
*/
|
||||
export function parsePatchInput(
|
||||
input: string,
|
||||
): { kind: "add" | "update" | "delete"; path: string } | null {
|
||||
if (!input) return null;
|
||||
|
||||
// Look for the first operation marker
|
||||
const addMatch = /\*\*\* Add File:\s*(.+)/.exec(input);
|
||||
if (addMatch?.[1]) {
|
||||
return { kind: "add", path: addMatch[1].trim() };
|
||||
}
|
||||
|
||||
const updateMatch = /\*\*\* Update File:\s*(.+)/.exec(input);
|
||||
if (updateMatch?.[1]) {
|
||||
return { kind: "update", path: updateMatch[1].trim() };
|
||||
}
|
||||
|
||||
const deleteMatch = /\*\*\* Delete File:\s*(.+)/.exec(input);
|
||||
if (deleteMatch?.[1]) {
|
||||
return { kind: "delete", path: deleteMatch[1].trim() };
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Patch operation types for result rendering
|
||||
*/
|
||||
export type PatchOperation =
|
||||
| { kind: "add"; path: string; content: string }
|
||||
| { kind: "update"; path: string; oldString: string; newString: string }
|
||||
| { kind: "delete"; path: string };
|
||||
|
||||
/**
|
||||
* Parses a patch input to extract all operations with full content.
|
||||
* Used for rendering patch results (shows diffs/content).
|
||||
* Based on ApplyPatch.ts parsing logic.
|
||||
*/
|
||||
export function parsePatchOperations(input: string): PatchOperation[] {
|
||||
if (!input) return [];
|
||||
|
||||
const lines = input.split(/\r?\n/);
|
||||
const beginIdx = lines.findIndex((l) => l.trim() === "*** Begin Patch");
|
||||
const endIdx = lines.findIndex((l) => l.trim() === "*** End Patch");
|
||||
|
||||
// If no markers, try to parse anyway (some patches might not have them)
|
||||
const startIdx = beginIdx === -1 ? 0 : beginIdx + 1;
|
||||
const stopIdx = endIdx === -1 ? lines.length : endIdx;
|
||||
|
||||
const operations: PatchOperation[] = [];
|
||||
let i = startIdx;
|
||||
|
||||
while (i < stopIdx) {
|
||||
const line = lines[i]?.trim();
|
||||
if (!line) {
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Add File operation
|
||||
if (line.startsWith("*** Add File:")) {
|
||||
const path = line.replace("*** Add File:", "").trim();
|
||||
i++;
|
||||
const contentLines: string[] = [];
|
||||
while (i < stopIdx) {
|
||||
const raw = lines[i];
|
||||
if (raw === undefined || raw.startsWith("*** ")) break;
|
||||
if (raw.startsWith("+")) {
|
||||
contentLines.push(raw.slice(1));
|
||||
}
|
||||
i++;
|
||||
}
|
||||
operations.push({ kind: "add", path, content: contentLines.join("\n") });
|
||||
continue;
|
||||
}
|
||||
|
||||
// Update File operation
|
||||
if (line.startsWith("*** Update File:")) {
|
||||
const path = line.replace("*** Update File:", "").trim();
|
||||
i++;
|
||||
|
||||
// Skip optional "*** Move to:" line
|
||||
if (i < stopIdx && lines[i]?.startsWith("*** Move to:")) {
|
||||
i++;
|
||||
}
|
||||
|
||||
// Collect all hunk lines
|
||||
const oldParts: string[] = [];
|
||||
const newParts: string[] = [];
|
||||
|
||||
while (i < stopIdx) {
|
||||
const hLine = lines[i];
|
||||
if (hLine === undefined || hLine.startsWith("*** ")) break;
|
||||
|
||||
if (hLine.startsWith("@@")) {
|
||||
// Skip hunk header
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Parse diff lines
|
||||
if (hLine === "") {
|
||||
// Empty line counts as context
|
||||
oldParts.push("");
|
||||
newParts.push("");
|
||||
} else {
|
||||
const prefix = hLine[0];
|
||||
const text = hLine.slice(1);
|
||||
|
||||
if (prefix === " ") {
|
||||
// Context line - appears in both
|
||||
oldParts.push(text);
|
||||
newParts.push(text);
|
||||
} else if (prefix === "-") {
|
||||
// Removed line
|
||||
oldParts.push(text);
|
||||
} else if (prefix === "+") {
|
||||
// Added line
|
||||
newParts.push(text);
|
||||
}
|
||||
}
|
||||
i++;
|
||||
}
|
||||
|
||||
operations.push({
|
||||
kind: "update",
|
||||
path,
|
||||
oldString: oldParts.join("\n"),
|
||||
newString: newParts.join("\n"),
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
// Delete File operation
|
||||
if (line.startsWith("*** Delete File:")) {
|
||||
const path = line.replace("*** Delete File:", "").trim();
|
||||
operations.push({ kind: "delete", path });
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Unknown line, skip
|
||||
i++;
|
||||
}
|
||||
|
||||
return operations;
|
||||
}
|
||||
|
||||
export function formatArgsDisplay(
|
||||
argsJson: string,
|
||||
toolName?: string,
|
||||
): {
|
||||
display: string;
|
||||
parsed: Record<string, unknown>;
|
||||
} {
|
||||
let parsed: Record<string, unknown> = {};
|
||||
let display = "…";
|
||||
|
||||
try {
|
||||
if (argsJson?.trim()) {
|
||||
const p = JSON.parse(argsJson);
|
||||
@@ -22,6 +201,83 @@ export function formatArgsDisplay(argsJson: string): {
|
||||
>;
|
||||
if ("request_heartbeat" in clone) delete clone.request_heartbeat;
|
||||
parsed = clone;
|
||||
|
||||
// Special handling for file tools - show clean relative path
|
||||
if (toolName) {
|
||||
// Patch tools: parse input and show operation + path
|
||||
if (isPatchTool(toolName) && typeof parsed.input === "string") {
|
||||
const patchInfo = parsePatchInput(parsed.input);
|
||||
if (patchInfo) {
|
||||
display = formatDisplayPath(patchInfo.path);
|
||||
return { display, parsed };
|
||||
}
|
||||
// Fallback if parsing fails
|
||||
display = "…";
|
||||
return { display, parsed };
|
||||
}
|
||||
|
||||
// Edit tools: show just the file path
|
||||
if (isFileEditTool(toolName) && parsed.file_path) {
|
||||
const filePath = String(parsed.file_path);
|
||||
display = formatDisplayPath(filePath);
|
||||
return { display, parsed };
|
||||
}
|
||||
|
||||
// Write tools: show just the file path
|
||||
if (isFileWriteTool(toolName) && parsed.file_path) {
|
||||
const filePath = String(parsed.file_path);
|
||||
display = formatDisplayPath(filePath);
|
||||
return { display, parsed };
|
||||
}
|
||||
|
||||
// Read tools: show file path + any other useful args (limit, offset)
|
||||
if (isFileReadTool(toolName) && parsed.file_path) {
|
||||
const filePath = String(parsed.file_path);
|
||||
const relativePath = formatDisplayPath(filePath);
|
||||
|
||||
// Collect other non-hidden args
|
||||
const otherArgs: string[] = [];
|
||||
for (const [k, v] of Object.entries(parsed)) {
|
||||
if (k === "file_path") continue;
|
||||
if (v === undefined || v === null) continue;
|
||||
if (typeof v === "boolean" || typeof v === "number") {
|
||||
otherArgs.push(`${k}=${v}`);
|
||||
} else if (typeof v === "string" && v.length <= 30) {
|
||||
otherArgs.push(`${k}="${v}"`);
|
||||
}
|
||||
}
|
||||
|
||||
if (otherArgs.length > 0) {
|
||||
display = `${relativePath}, ${otherArgs.join(", ")}`;
|
||||
} else {
|
||||
display = relativePath;
|
||||
}
|
||||
return { display, parsed };
|
||||
}
|
||||
|
||||
// Shell/Bash tools: show just the command
|
||||
if (isShellTool(toolName) && parsed.command) {
|
||||
// Handle both string and array command formats
|
||||
if (Array.isArray(parsed.command)) {
|
||||
// For ["bash", "-c", "actual command"], show just the actual command
|
||||
const cmd = parsed.command;
|
||||
if (
|
||||
cmd.length >= 3 &&
|
||||
(cmd[0] === "bash" || cmd[0] === "sh") &&
|
||||
(cmd[1] === "-c" || cmd[1] === "-lc")
|
||||
) {
|
||||
display = cmd.slice(2).join(" ");
|
||||
} else {
|
||||
display = cmd.join(" ");
|
||||
}
|
||||
} else {
|
||||
display = String(parsed.command);
|
||||
}
|
||||
return { display, parsed };
|
||||
}
|
||||
}
|
||||
|
||||
// Default handling for other tools
|
||||
const keys = Object.keys(parsed);
|
||||
const firstKey = keys[0];
|
||||
if (
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
export function getDisplayToolName(rawName: string): string {
|
||||
// Anthropic toolset
|
||||
if (rawName === "write") return "Write";
|
||||
if (rawName === "edit" || rawName === "multi_edit") return "Edit";
|
||||
if (rawName === "edit" || rawName === "multi_edit") return "Update";
|
||||
if (rawName === "read") return "Read";
|
||||
if (rawName === "bash") return "Bash";
|
||||
if (rawName === "grep") return "Grep";
|
||||
@@ -26,7 +26,7 @@ export function getDisplayToolName(rawName: string): string {
|
||||
|
||||
// Codex toolset (snake_case)
|
||||
if (rawName === "update_plan") return "Planning";
|
||||
if (rawName === "shell_command" || rawName === "shell") return "Shell";
|
||||
if (rawName === "shell_command" || rawName === "shell") return "Bash";
|
||||
if (rawName === "read_file") return "Read";
|
||||
if (rawName === "list_dir") return "LS";
|
||||
if (rawName === "grep_files") return "Grep";
|
||||
@@ -34,14 +34,14 @@ export function getDisplayToolName(rawName: string): string {
|
||||
|
||||
// Codex toolset (PascalCase)
|
||||
if (rawName === "UpdatePlan") return "Planning";
|
||||
if (rawName === "ShellCommand" || rawName === "Shell") return "Shell";
|
||||
if (rawName === "ShellCommand" || rawName === "Shell") return "Bash";
|
||||
if (rawName === "ReadFile") return "Read";
|
||||
if (rawName === "ListDir") return "LS";
|
||||
if (rawName === "GrepFiles") return "Grep";
|
||||
if (rawName === "ApplyPatch") return "Patch";
|
||||
|
||||
// Gemini toolset (snake_case)
|
||||
if (rawName === "run_shell_command") return "Shell";
|
||||
if (rawName === "run_shell_command") return "Bash";
|
||||
if (rawName === "read_file_gemini") return "Read";
|
||||
if (rawName === "list_directory") return "LS";
|
||||
if (rawName === "glob_gemini") return "Glob";
|
||||
@@ -51,7 +51,7 @@ export function getDisplayToolName(rawName: string): string {
|
||||
if (rawName === "read_many_files") return "Read Multiple";
|
||||
|
||||
// Gemini toolset (PascalCase)
|
||||
if (rawName === "RunShellCommand") return "Shell";
|
||||
if (rawName === "RunShellCommand") return "Bash";
|
||||
if (rawName === "ReadFileGemini") return "Read";
|
||||
if (rawName === "ListDirectory") return "LS";
|
||||
if (rawName === "GlobGemini") return "Glob";
|
||||
@@ -61,11 +61,11 @@ export function getDisplayToolName(rawName: string): string {
|
||||
if (rawName === "ReadManyFiles") return "Read Multiple";
|
||||
|
||||
// Additional tools
|
||||
if (rawName === "Replace" || rawName === "replace") return "Edit";
|
||||
if (rawName === "Replace" || rawName === "replace") return "Update";
|
||||
if (rawName === "WriteFile" || rawName === "write_file") return "Write";
|
||||
if (rawName === "KillBash") return "Kill Shell";
|
||||
if (rawName === "KillBash") return "Kill Bash";
|
||||
if (rawName === "BashOutput") return "Shell Output";
|
||||
if (rawName === "MultiEdit") return "Edit";
|
||||
if (rawName === "MultiEdit") return "Update";
|
||||
|
||||
// No mapping found, return as-is
|
||||
return rawName;
|
||||
@@ -119,3 +119,70 @@ export function isFancyUITool(name: string): boolean {
|
||||
export function isMemoryTool(name: string): boolean {
|
||||
return name === "memory" || name === "memory_apply_patch";
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if a tool is a file edit tool (has old_string/new_string args)
|
||||
*/
|
||||
export function isFileEditTool(name: string): boolean {
|
||||
return (
|
||||
name === "edit" ||
|
||||
name === "Edit" ||
|
||||
name === "multi_edit" ||
|
||||
name === "MultiEdit" ||
|
||||
name === "Replace" ||
|
||||
name === "replace"
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if a tool is a file write tool (has file_path/content args)
|
||||
*/
|
||||
export function isFileWriteTool(name: string): boolean {
|
||||
return (
|
||||
name === "write" ||
|
||||
name === "Write" ||
|
||||
name === "WriteFile" ||
|
||||
name === "write_file" ||
|
||||
name === "write_file_gemini" ||
|
||||
name === "WriteFileGemini"
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if a tool is a file read tool (has file_path arg)
|
||||
*/
|
||||
export function isFileReadTool(name: string): boolean {
|
||||
return (
|
||||
name === "read" ||
|
||||
name === "Read" ||
|
||||
name === "ReadFile" ||
|
||||
name === "read_file" ||
|
||||
name === "read_file_gemini" ||
|
||||
name === "ReadFileGemini" ||
|
||||
name === "read_many_files" ||
|
||||
name === "ReadManyFiles"
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if a tool is a patch tool (applies unified diffs)
|
||||
*/
|
||||
export function isPatchTool(name: string): boolean {
|
||||
return name === "apply_patch" || name === "ApplyPatch";
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if a tool is a shell/bash tool
|
||||
*/
|
||||
export function isShellTool(name: string): boolean {
|
||||
return (
|
||||
name === "bash" ||
|
||||
name === "Bash" ||
|
||||
name === "shell" ||
|
||||
name === "Shell" ||
|
||||
name === "shell_command" ||
|
||||
name === "ShellCommand" ||
|
||||
name === "run_shell_command" ||
|
||||
name === "RunShellCommand"
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user