feat: show visual diffs for Edit/Write tool returns (#392)

Co-authored-by: Letta <noreply@letta.com>
This commit is contained in:
Charles Packer
2025-12-25 18:52:51 -08:00
committed by GitHub
parent 0fe7872aa0
commit 4db6c6f93c
9 changed files with 973 additions and 165 deletions

View File

@@ -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 (

View File

@@ -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"
);
}