feat: show visual diffs for Edit/Write tool returns (#392)
Co-authored-by: Letta <noreply@letta.com>
This commit is contained in:
@@ -257,9 +257,20 @@ export function AdvancedDiffRenderer(
|
||||
}
|
||||
|
||||
if (result.mode === "unpreviewable") {
|
||||
const gutterWidth = 4;
|
||||
return (
|
||||
<Box flexDirection="column">
|
||||
<Text dimColor> ⎿ Cannot preview changes: {result.reason}</Text>
|
||||
<Box flexDirection="row">
|
||||
<Box width={gutterWidth} flexShrink={0}>
|
||||
<Text>
|
||||
{" "}
|
||||
<Text dimColor>⎿</Text>
|
||||
</Text>
|
||||
</Box>
|
||||
<Box flexGrow={1}>
|
||||
<Text wrap="wrap" dimColor>
|
||||
Cannot preview changes: {result.reason}
|
||||
</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import type React from "react";
|
||||
import { memo, useEffect, useMemo, useState } from "react";
|
||||
import type { ApprovalContext } from "../../permissions/analyzer";
|
||||
import { type AdvancedDiffSuccess, computeAdvancedDiff } from "../helpers/diff";
|
||||
import { parsePatchOperations } from "../helpers/formatArgsDisplay";
|
||||
import { resolvePlaceholders } from "../helpers/pasteRegistry";
|
||||
import type { ApprovalRequest } from "../helpers/stream";
|
||||
import { AdvancedDiffRenderer } from "./AdvancedDiffRenderer";
|
||||
@@ -185,16 +186,61 @@ const DynamicPreview: React.FC<DynamicPreviewProps> = ({
|
||||
|
||||
if (t === "apply_patch" || t === "applypatch") {
|
||||
const inputVal = parsedArgs?.input;
|
||||
const patchPreview =
|
||||
typeof inputVal === "string" && inputVal.length > 100
|
||||
? `${inputVal.slice(0, 100)}...`
|
||||
: typeof inputVal === "string"
|
||||
? inputVal
|
||||
: "(no patch content)";
|
||||
if (typeof inputVal === "string") {
|
||||
const operations = parsePatchOperations(inputVal);
|
||||
if (operations.length > 0) {
|
||||
return (
|
||||
<Box flexDirection="column" paddingLeft={2}>
|
||||
{operations.map((op) => {
|
||||
if (op.kind === "add") {
|
||||
return (
|
||||
<AdvancedDiffRenderer
|
||||
key={`patch-add-${op.path}`}
|
||||
precomputed={precomputedDiff ?? undefined}
|
||||
kind="write"
|
||||
filePath={op.path}
|
||||
content={op.content}
|
||||
showHeader={false}
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (op.kind === "update") {
|
||||
return (
|
||||
<AdvancedDiffRenderer
|
||||
key={`patch-update-${op.path}`}
|
||||
precomputed={precomputedDiff ?? undefined}
|
||||
kind="edit"
|
||||
filePath={op.path}
|
||||
oldString={op.oldString}
|
||||
newString={op.newString}
|
||||
showHeader={false}
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (op.kind === "delete") {
|
||||
return (
|
||||
<Text key={`patch-delete-${op.path}`}>
|
||||
Delete file: {op.path}
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
})}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback for unparseable patches
|
||||
return (
|
||||
<Box flexDirection="column" paddingLeft={2}>
|
||||
<Text dimColor>{patchPreview}</Text>
|
||||
<Text dimColor>
|
||||
{typeof inputVal === "string" && inputVal.length > 100
|
||||
? `${inputVal.slice(0, 100)}...`
|
||||
: typeof inputVal === "string"
|
||||
? inputVal
|
||||
: "(no patch content)"}
|
||||
</Text>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -623,14 +669,31 @@ export const ApprovalDialog = memo(function ApprovalDialog({
|
||||
return null;
|
||||
}, [approvalRequest, parsedArgs]);
|
||||
|
||||
// Get the human-readable header label
|
||||
const headerLabel = useMemo(() => {
|
||||
if (!approvalRequest) return "";
|
||||
const t = approvalRequest.toolName.toLowerCase();
|
||||
// For patch tools, determine header from operation type
|
||||
if (t === "apply_patch" || t === "applypatch") {
|
||||
if (parsedArgs?.input && typeof parsedArgs.input === "string") {
|
||||
const operations = parsePatchOperations(parsedArgs.input);
|
||||
const firstOp = operations[0];
|
||||
if (firstOp) {
|
||||
if (firstOp.kind === "add") return "Write File";
|
||||
if (firstOp.kind === "update") return "Edit File";
|
||||
if (firstOp.kind === "delete") return "Delete File";
|
||||
}
|
||||
}
|
||||
return "Apply Patch"; // Fallback
|
||||
}
|
||||
return getHeaderLabel(approvalRequest.toolName);
|
||||
}, [approvalRequest, parsedArgs]);
|
||||
|
||||
// Guard: should never happen as parent checks length, but satisfies TypeScript
|
||||
if (!approvalRequest) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Get the human-readable header label
|
||||
const headerLabel = getHeaderLabel(approvalRequest.toolName);
|
||||
|
||||
if (isEnteringReason) {
|
||||
return (
|
||||
<Box flexDirection="column">
|
||||
|
||||
@@ -5,14 +5,17 @@ import { useTerminalWidth } from "../hooks/useTerminalWidth";
|
||||
import { colors } from "./colors";
|
||||
|
||||
// Helper to format path as relative with ../
|
||||
function formatRelativePath(filePath: string): 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 file is outside cwd, it will start with ..
|
||||
// If file is in cwd, add ./ prefix
|
||||
if (!relativePath.startsWith("..")) {
|
||||
return `./${relativePath}`;
|
||||
// If path goes outside cwd (starts with ..), show full absolute path
|
||||
if (relativePath.startsWith("..")) {
|
||||
return filePath;
|
||||
}
|
||||
return relativePath;
|
||||
}
|
||||
@@ -30,6 +33,7 @@ interface DiffLineProps {
|
||||
content: string;
|
||||
compareContent?: string; // The other version to compare against for word diff
|
||||
columns: number;
|
||||
showLineNumbers?: boolean; // Whether to show line numbers (default true)
|
||||
}
|
||||
|
||||
function DiffLine({
|
||||
@@ -38,6 +42,7 @@ function DiffLine({
|
||||
content,
|
||||
compareContent,
|
||||
columns,
|
||||
showLineNumbers = true,
|
||||
}: DiffLineProps) {
|
||||
const prefix = type === "add" ? "+" : "-";
|
||||
const lineBg =
|
||||
@@ -45,8 +50,13 @@ function DiffLine({
|
||||
const wordBg =
|
||||
type === "add" ? colors.diff.addedWordBg : colors.diff.removedWordBg;
|
||||
|
||||
const prefixWidth = 1; // Single space prefix
|
||||
const contentWidth = Math.max(0, columns - prefixWidth);
|
||||
const gutterWidth = 4; // " " indent to align with tool return prefix
|
||||
const contentWidth = Math.max(0, columns - gutterWidth);
|
||||
|
||||
// Build the line prefix (with or without line number)
|
||||
const linePrefix = showLineNumbers
|
||||
? `${lineNumber} ${prefix} `
|
||||
: `${prefix} `;
|
||||
|
||||
// If we have something to compare against, do word-level diff
|
||||
if (compareContent !== undefined && content.trim() && compareContent.trim()) {
|
||||
@@ -57,13 +67,13 @@ function DiffLine({
|
||||
|
||||
return (
|
||||
<Box flexDirection="row">
|
||||
<Box width={prefixWidth} flexShrink={0}>
|
||||
<Text> </Text>
|
||||
<Box width={gutterWidth} flexShrink={0}>
|
||||
<Text>{" "}</Text>
|
||||
</Box>
|
||||
<Box flexGrow={1} width={contentWidth}>
|
||||
<Text wrap="wrap">
|
||||
<Text backgroundColor={lineBg} color={colors.diff.textOnDark}>
|
||||
{`${lineNumber} ${prefix} `}
|
||||
{linePrefix}
|
||||
</Text>
|
||||
{wordDiffs.map((part, i) => {
|
||||
if (part.added && type === "add") {
|
||||
@@ -112,8 +122,8 @@ function DiffLine({
|
||||
// No comparison, just show the whole line with one background
|
||||
return (
|
||||
<Box flexDirection="row">
|
||||
<Box width={prefixWidth} flexShrink={0}>
|
||||
<Text> </Text>
|
||||
<Box width={gutterWidth} flexShrink={0}>
|
||||
<Text>{" "}</Text>
|
||||
</Box>
|
||||
<Box flexGrow={1} width={contentWidth}>
|
||||
<Text
|
||||
@@ -121,7 +131,7 @@ function DiffLine({
|
||||
color={colors.diff.textOnDark}
|
||||
wrap="wrap"
|
||||
>
|
||||
{`${lineNumber} ${prefix} ${content}`}
|
||||
{`${linePrefix}${content}`}
|
||||
</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
@@ -135,23 +145,33 @@ interface WriteRendererProps {
|
||||
|
||||
export function WriteRenderer({ filePath, content }: WriteRendererProps) {
|
||||
const columns = useTerminalWidth();
|
||||
const relativePath = formatRelativePath(filePath);
|
||||
const relativePath = formatDisplayPath(filePath);
|
||||
const lines = content.split("\n");
|
||||
const lineCount = lines.length;
|
||||
|
||||
const prefixWidth = 1; // Single space prefix
|
||||
const contentWidth = Math.max(0, columns - prefixWidth);
|
||||
const gutterWidth = 4; // " " indent to align with tool return prefix
|
||||
const contentWidth = Math.max(0, columns - gutterWidth);
|
||||
|
||||
return (
|
||||
<Box flexDirection="column">
|
||||
<Text>
|
||||
{" "}
|
||||
⎿ Wrote {lineCount} line{lineCount !== 1 ? "s" : ""} to {relativePath}
|
||||
</Text>
|
||||
<Box flexDirection="row">
|
||||
<Box width={gutterWidth} flexShrink={0}>
|
||||
<Text>
|
||||
{" "}
|
||||
<Text dimColor>⎿</Text>
|
||||
</Text>
|
||||
</Box>
|
||||
<Box flexGrow={1} width={contentWidth}>
|
||||
<Text wrap="wrap">
|
||||
Wrote <Text bold>{lineCount}</Text> line
|
||||
{lineCount !== 1 ? "s" : ""} to <Text bold>{relativePath}</Text>
|
||||
</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
{lines.map((line, i) => (
|
||||
<Box key={`line-${i}-${line.substring(0, 20)}`} flexDirection="row">
|
||||
<Box width={prefixWidth} flexShrink={0}>
|
||||
<Text> </Text>
|
||||
<Box width={gutterWidth} flexShrink={0}>
|
||||
<Text>{" "}</Text>
|
||||
</Box>
|
||||
<Box flexGrow={1} width={contentWidth}>
|
||||
<Text wrap="wrap">{line}</Text>
|
||||
@@ -166,15 +186,17 @@ interface EditRendererProps {
|
||||
filePath: string;
|
||||
oldString: string;
|
||||
newString: string;
|
||||
showLineNumbers?: boolean; // Whether to show line numbers (default true)
|
||||
}
|
||||
|
||||
export function EditRenderer({
|
||||
filePath,
|
||||
oldString,
|
||||
newString,
|
||||
showLineNumbers = true,
|
||||
}: EditRendererProps) {
|
||||
const columns = useTerminalWidth();
|
||||
const relativePath = formatRelativePath(filePath);
|
||||
const relativePath = formatDisplayPath(filePath);
|
||||
const oldLines = oldString.split("\n");
|
||||
const newLines = newString.split("\n");
|
||||
|
||||
@@ -187,14 +209,28 @@ export function EditRenderer({
|
||||
// For multi-line, we could do more sophisticated matching
|
||||
const singleLineEdit = oldLines.length === 1 && newLines.length === 1;
|
||||
|
||||
const gutterWidth = 4; // " " indent to align with tool return prefix
|
||||
const contentWidth = Math.max(0, columns - gutterWidth);
|
||||
|
||||
return (
|
||||
<Box flexDirection="column">
|
||||
<Text>
|
||||
{" "}
|
||||
⎿ Updated {relativePath} with {additions} addition
|
||||
{additions !== 1 ? "s" : ""} and {removals} removal
|
||||
{removals !== 1 ? "s" : ""}
|
||||
</Text>
|
||||
<Box flexDirection="row">
|
||||
<Box width={gutterWidth} flexShrink={0}>
|
||||
<Text>
|
||||
{" "}
|
||||
<Text dimColor>⎿</Text>
|
||||
</Text>
|
||||
</Box>
|
||||
<Box flexGrow={1} width={contentWidth}>
|
||||
<Text wrap="wrap">
|
||||
Updated <Text bold>{relativePath}</Text> with{" "}
|
||||
<Text bold>{additions}</Text> addition
|
||||
{additions !== 1 ? "s" : ""} and <Text bold>{removals}</Text>{" "}
|
||||
removal
|
||||
{removals !== 1 ? "s" : ""}
|
||||
</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{/* Show removals */}
|
||||
{oldLines.map((line, i) => (
|
||||
@@ -205,6 +241,7 @@ export function EditRenderer({
|
||||
content={line}
|
||||
compareContent={singleLineEdit ? newLines[0] : undefined}
|
||||
columns={columns}
|
||||
showLineNumbers={showLineNumbers}
|
||||
/>
|
||||
))}
|
||||
|
||||
@@ -217,6 +254,7 @@ export function EditRenderer({
|
||||
content={line}
|
||||
compareContent={singleLineEdit ? oldLines[0] : undefined}
|
||||
columns={columns}
|
||||
showLineNumbers={showLineNumbers}
|
||||
/>
|
||||
))}
|
||||
</Box>
|
||||
@@ -229,11 +267,16 @@ interface MultiEditRendererProps {
|
||||
old_string: string;
|
||||
new_string: string;
|
||||
}>;
|
||||
showLineNumbers?: boolean; // Whether to show line numbers (default true)
|
||||
}
|
||||
|
||||
export function MultiEditRenderer({ filePath, edits }: MultiEditRendererProps) {
|
||||
export function MultiEditRenderer({
|
||||
filePath,
|
||||
edits,
|
||||
showLineNumbers = true,
|
||||
}: MultiEditRendererProps) {
|
||||
const columns = useTerminalWidth();
|
||||
const relativePath = formatRelativePath(filePath);
|
||||
const relativePath = formatDisplayPath(filePath);
|
||||
|
||||
// Count total additions and removals
|
||||
let totalAdditions = 0;
|
||||
@@ -244,14 +287,28 @@ export function MultiEditRenderer({ filePath, edits }: MultiEditRendererProps) {
|
||||
totalRemovals += countLines(edit.old_string);
|
||||
});
|
||||
|
||||
const gutterWidth = 4; // " " indent to align with tool return prefix
|
||||
const contentWidth = Math.max(0, columns - gutterWidth);
|
||||
|
||||
return (
|
||||
<Box flexDirection="column">
|
||||
<Text>
|
||||
{" "}
|
||||
⎿ Updated {relativePath} with {totalAdditions} addition
|
||||
{totalAdditions !== 1 ? "s" : ""} and {totalRemovals} removal
|
||||
{totalRemovals !== 1 ? "s" : ""}
|
||||
</Text>
|
||||
<Box flexDirection="row">
|
||||
<Box width={gutterWidth} flexShrink={0}>
|
||||
<Text>
|
||||
{" "}
|
||||
<Text dimColor>⎿</Text>
|
||||
</Text>
|
||||
</Box>
|
||||
<Box flexGrow={1} width={contentWidth}>
|
||||
<Text wrap="wrap">
|
||||
Updated <Text bold>{relativePath}</Text> with{" "}
|
||||
<Text bold>{totalAdditions}</Text> addition
|
||||
{totalAdditions !== 1 ? "s" : ""} and{" "}
|
||||
<Text bold>{totalRemovals}</Text> removal
|
||||
{totalRemovals !== 1 ? "s" : ""}
|
||||
</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{/* For multi-edit, show each edit sequentially */}
|
||||
{edits.map((edit, index) => {
|
||||
@@ -267,25 +324,27 @@ export function MultiEditRenderer({ filePath, edits }: MultiEditRendererProps) {
|
||||
{oldLines.map((line, i) => (
|
||||
<DiffLine
|
||||
key={`old-${index}-${i}-${line.substring(0, 20)}`}
|
||||
lineNumber={i + 1} // TODO: This should be actual file line numbers
|
||||
lineNumber={i + 1}
|
||||
type="remove"
|
||||
content={line}
|
||||
compareContent={
|
||||
singleLineEdit && i === 0 ? newLines[0] : undefined
|
||||
}
|
||||
columns={columns}
|
||||
showLineNumbers={showLineNumbers}
|
||||
/>
|
||||
))}
|
||||
{newLines.map((line, i) => (
|
||||
<DiffLine
|
||||
key={`new-${index}-${i}-${line.substring(0, 20)}`}
|
||||
lineNumber={i + 1} // TODO: This should be actual file line numbers
|
||||
lineNumber={i + 1}
|
||||
type="add"
|
||||
content={line}
|
||||
compareContent={
|
||||
singleLineEdit && i === 0 ? oldLines[0] : undefined
|
||||
}
|
||||
columns={columns}
|
||||
showLineNumbers={showLineNumbers}
|
||||
/>
|
||||
))}
|
||||
</Box>
|
||||
|
||||
@@ -58,12 +58,23 @@ export function MemoryDiffRenderer({
|
||||
const contentWidth = Math.max(0, columns - prefixWidth);
|
||||
return (
|
||||
<Box flexDirection="column">
|
||||
<Text>
|
||||
{" "}
|
||||
<Text dimColor>⎿</Text> Inserted into memory block{" "}
|
||||
<Text color={colors.tool.memoryName}>{blockName}</Text>
|
||||
{insertLine !== undefined && ` at line ${insertLine}`}
|
||||
</Text>
|
||||
<Box flexDirection="row">
|
||||
<Box width={prefixWidth} flexShrink={0}>
|
||||
<Text>
|
||||
{" "}
|
||||
<Text dimColor>⎿</Text>
|
||||
</Text>
|
||||
</Box>
|
||||
<Box flexGrow={1} width={contentWidth}>
|
||||
<Text wrap="wrap">
|
||||
Inserted into memory block{" "}
|
||||
<Text bold color={colors.tool.memoryName}>
|
||||
{blockName}
|
||||
</Text>
|
||||
{insertLine !== undefined && ` at line ${insertLine}`}
|
||||
</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
{insertText.split("\n").map((line: string, i: number) => (
|
||||
<Box
|
||||
key={`insert-${i}-${line.substring(0, 20)}`}
|
||||
@@ -94,14 +105,25 @@ export function MemoryDiffRenderer({
|
||||
const contentWidth = Math.max(0, columns - prefixWidth);
|
||||
return (
|
||||
<Box flexDirection="column">
|
||||
<Text>
|
||||
{" "}
|
||||
<Text dimColor>⎿</Text> Created memory block{" "}
|
||||
<Text color={colors.tool.memoryName}>{blockName}</Text>
|
||||
{description && (
|
||||
<Text dimColor> - {truncate(description, 40)}</Text>
|
||||
)}
|
||||
</Text>
|
||||
<Box flexDirection="row">
|
||||
<Box width={prefixWidth} flexShrink={0}>
|
||||
<Text>
|
||||
{" "}
|
||||
<Text dimColor>⎿</Text>
|
||||
</Text>
|
||||
</Box>
|
||||
<Box flexGrow={1} width={contentWidth}>
|
||||
<Text wrap="wrap">
|
||||
Created memory block{" "}
|
||||
<Text bold color={colors.tool.memoryName}>
|
||||
{blockName}
|
||||
</Text>
|
||||
{description && (
|
||||
<Text dimColor> - {truncate(description, 40)}</Text>
|
||||
)}
|
||||
</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
{fileText
|
||||
?.split("\n")
|
||||
.slice(0, 3)
|
||||
@@ -134,12 +156,25 @@ export function MemoryDiffRenderer({
|
||||
}
|
||||
|
||||
case "delete": {
|
||||
const prefixWidth = 4;
|
||||
const contentWidth = Math.max(0, columns - prefixWidth);
|
||||
return (
|
||||
<Text>
|
||||
{" "}
|
||||
<Text dimColor>⎿</Text> Deleted memory block{" "}
|
||||
<Text color={colors.tool.memoryName}>{blockName}</Text>
|
||||
</Text>
|
||||
<Box flexDirection="row">
|
||||
<Box width={prefixWidth} flexShrink={0}>
|
||||
<Text>
|
||||
{" "}
|
||||
<Text dimColor>⎿</Text>
|
||||
</Text>
|
||||
</Box>
|
||||
<Box flexGrow={1} width={contentWidth}>
|
||||
<Text wrap="wrap">
|
||||
Deleted memory block{" "}
|
||||
<Text bold color={colors.tool.memoryName}>
|
||||
{blockName}
|
||||
</Text>
|
||||
</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -147,33 +182,74 @@ export function MemoryDiffRenderer({
|
||||
const newPath = args.new_path || "";
|
||||
const newBlockName = newPath.split("/").pop() || newPath;
|
||||
const description = args.description;
|
||||
const prefixWidth = 4;
|
||||
const contentWidth = Math.max(0, columns - prefixWidth);
|
||||
if (description) {
|
||||
return (
|
||||
<Text>
|
||||
{" "}
|
||||
<Text dimColor>⎿</Text> Updated description of{" "}
|
||||
<Text color={colors.tool.memoryName}>{blockName}</Text>
|
||||
</Text>
|
||||
<Box flexDirection="row">
|
||||
<Box width={prefixWidth} flexShrink={0}>
|
||||
<Text>
|
||||
{" "}
|
||||
<Text dimColor>⎿</Text>
|
||||
</Text>
|
||||
</Box>
|
||||
<Box flexGrow={1} width={contentWidth}>
|
||||
<Text wrap="wrap">
|
||||
Updated description of{" "}
|
||||
<Text bold color={colors.tool.memoryName}>
|
||||
{blockName}
|
||||
</Text>
|
||||
</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<Text>
|
||||
{" "}
|
||||
<Text dimColor>⎿</Text> Renamed{" "}
|
||||
<Text color={colors.tool.memoryName}>{blockName}</Text> to{" "}
|
||||
<Text color={colors.tool.memoryName}>{newBlockName}</Text>
|
||||
</Text>
|
||||
<Box flexDirection="row">
|
||||
<Box width={prefixWidth} flexShrink={0}>
|
||||
<Text>
|
||||
{" "}
|
||||
<Text dimColor>⎿</Text>
|
||||
</Text>
|
||||
</Box>
|
||||
<Box flexGrow={1} width={contentWidth}>
|
||||
<Text wrap="wrap">
|
||||
Renamed{" "}
|
||||
<Text bold color={colors.tool.memoryName}>
|
||||
{blockName}
|
||||
</Text>{" "}
|
||||
to{" "}
|
||||
<Text bold color={colors.tool.memoryName}>
|
||||
{newBlockName}
|
||||
</Text>
|
||||
</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
default:
|
||||
default: {
|
||||
const defaultPrefixWidth = 4;
|
||||
const defaultContentWidth = Math.max(0, columns - defaultPrefixWidth);
|
||||
return (
|
||||
<Text>
|
||||
{" "}
|
||||
<Text dimColor>⎿</Text> Memory operation: {command} on{" "}
|
||||
<Text color={colors.tool.memoryName}>{blockName}</Text>
|
||||
</Text>
|
||||
<Box flexDirection="row">
|
||||
<Box width={defaultPrefixWidth} flexShrink={0}>
|
||||
<Text>
|
||||
{" "}
|
||||
<Text dimColor>⎿</Text>
|
||||
</Text>
|
||||
</Box>
|
||||
<Box flexGrow={1} width={defaultContentWidth}>
|
||||
<Text wrap="wrap">
|
||||
Memory operation: {command} on{" "}
|
||||
<Text bold color={colors.tool.memoryName}>
|
||||
{blockName}
|
||||
</Text>
|
||||
</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// If parsing fails, return null to fall through to regular handling
|
||||
@@ -182,7 +258,9 @@ export function MemoryDiffRenderer({
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders a str_replace diff with word-level highlighting
|
||||
* Renders a str_replace diff with line-level diffing and word-level highlighting.
|
||||
* Uses Diff.diffLines() to only show lines that actually changed,
|
||||
* instead of showing all old lines then all new lines.
|
||||
*/
|
||||
function MemoryStrReplaceDiff({
|
||||
blockName,
|
||||
@@ -195,47 +273,111 @@ function MemoryStrReplaceDiff({
|
||||
newStr: string;
|
||||
columns: number;
|
||||
}) {
|
||||
const oldLines = oldStr.split("\n");
|
||||
const newLines = newStr.split("\n");
|
||||
const singleLine = oldLines.length === 1 && newLines.length === 1;
|
||||
// Use line-level diff to find what actually changed
|
||||
const lineDiffs = Diff.diffLines(oldStr, newStr);
|
||||
|
||||
// Build display rows: only show added/removed lines, not unchanged
|
||||
// For adjacent remove/add pairs, enable word-level highlighting
|
||||
type DiffRow = {
|
||||
type: "add" | "remove";
|
||||
content: string;
|
||||
pairContent?: string; // For word-level diff when remove is followed by add
|
||||
};
|
||||
const rows: DiffRow[] = [];
|
||||
|
||||
for (let i = 0; i < lineDiffs.length; i++) {
|
||||
const part = lineDiffs[i];
|
||||
if (!part) continue;
|
||||
|
||||
// Skip unchanged lines (context)
|
||||
if (!part.added && !part.removed) continue;
|
||||
|
||||
// Split the value into individual lines (remove trailing newline)
|
||||
const lines = part.value.replace(/\n$/, "").split("\n");
|
||||
|
||||
if (part.removed) {
|
||||
// Check if next part is an addition (for word-level diff pairing)
|
||||
const nextPart = lineDiffs[i + 1];
|
||||
const nextIsAdd = nextPart?.added;
|
||||
const nextLines = nextIsAdd
|
||||
? nextPart.value.replace(/\n$/, "").split("\n")
|
||||
: [];
|
||||
|
||||
lines.forEach((line, lineIdx) => {
|
||||
rows.push({
|
||||
type: "remove",
|
||||
content: line,
|
||||
// Pair with corresponding add line for word-level diff (if same count)
|
||||
pairContent:
|
||||
nextIsAdd && lines.length === nextLines.length
|
||||
? nextLines[lineIdx]
|
||||
: undefined,
|
||||
});
|
||||
});
|
||||
} else if (part.added) {
|
||||
// Check if previous part was a removal (already handled pairing above)
|
||||
const prevPart = lineDiffs[i - 1];
|
||||
const prevIsRemove = prevPart?.removed;
|
||||
const prevLines = prevIsRemove
|
||||
? prevPart.value.replace(/\n$/, "").split("\n")
|
||||
: [];
|
||||
|
||||
lines.forEach((line, lineIdx) => {
|
||||
rows.push({
|
||||
type: "add",
|
||||
content: line,
|
||||
// Pair with corresponding remove line for word-level diff (if same count)
|
||||
pairContent:
|
||||
prevIsRemove && lines.length === prevLines.length
|
||||
? prevLines[lineIdx]
|
||||
: undefined,
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Limit display to avoid huge diffs
|
||||
const maxLines = 5;
|
||||
const oldTruncated = oldLines.slice(0, maxLines);
|
||||
const newTruncated = newLines.slice(0, maxLines);
|
||||
const hasMore = oldLines.length > maxLines || newLines.length > maxLines;
|
||||
const maxRows = 10;
|
||||
const displayRows = rows.slice(0, maxRows);
|
||||
const hasMore = rows.length > maxRows;
|
||||
|
||||
const prefixWidth = 4;
|
||||
const contentWidth = Math.max(0, columns - prefixWidth);
|
||||
|
||||
return (
|
||||
<Box flexDirection="column">
|
||||
<Text>
|
||||
{" "}
|
||||
<Text dimColor>⎿</Text> Updated memory block{" "}
|
||||
<Text color={colors.tool.memoryName}>{blockName}</Text>
|
||||
</Text>
|
||||
<Box flexDirection="row">
|
||||
<Box width={prefixWidth} flexShrink={0}>
|
||||
<Text>
|
||||
{" "}
|
||||
<Text dimColor>⎿</Text>
|
||||
</Text>
|
||||
</Box>
|
||||
<Box flexGrow={1} width={contentWidth}>
|
||||
<Text wrap="wrap">
|
||||
Updated memory block{" "}
|
||||
<Text bold color={colors.tool.memoryName}>
|
||||
{blockName}
|
||||
</Text>
|
||||
</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{/* Removals */}
|
||||
{oldTruncated.map((line, i) => (
|
||||
{displayRows.map((row, i) => (
|
||||
<DiffLine
|
||||
key={`old-${i}-${line.substring(0, 20)}`}
|
||||
type="remove"
|
||||
content={line}
|
||||
compareContent={singleLine ? newLines[0] : undefined}
|
||||
key={`diff-${i}-${row.type}-${row.content.substring(0, 20)}`}
|
||||
type={row.type}
|
||||
content={row.content}
|
||||
compareContent={row.pairContent}
|
||||
columns={columns}
|
||||
/>
|
||||
))}
|
||||
|
||||
{/* Additions */}
|
||||
{newTruncated.map((line, i) => (
|
||||
<DiffLine
|
||||
key={`new-${i}-${line.substring(0, 20)}`}
|
||||
type="add"
|
||||
content={line}
|
||||
compareContent={singleLine ? oldLines[0] : undefined}
|
||||
columns={columns}
|
||||
/>
|
||||
))}
|
||||
|
||||
{hasMore && <Text dimColor>{" "}... diff truncated</Text>}
|
||||
{hasMore && (
|
||||
<Text dimColor>
|
||||
{" "}... {rows.length - maxRows} more lines
|
||||
</Text>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -366,11 +508,22 @@ function PatchDiffRenderer({
|
||||
|
||||
return (
|
||||
<Box flexDirection="column">
|
||||
<Text>
|
||||
{" "}
|
||||
<Text dimColor>⎿</Text> Patched memory block{" "}
|
||||
<Text color={colors.tool.memoryName}>{label}</Text>
|
||||
</Text>
|
||||
<Box flexDirection="row">
|
||||
<Box width={prefixWidth} flexShrink={0}>
|
||||
<Text>
|
||||
{" "}
|
||||
<Text dimColor>⎿</Text>
|
||||
</Text>
|
||||
</Box>
|
||||
<Box flexGrow={1} width={contentWidth}>
|
||||
<Text wrap="wrap">
|
||||
Patched memory block{" "}
|
||||
<Text bold color={colors.tool.memoryName}>
|
||||
{label}
|
||||
</Text>
|
||||
</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
{displayLines.map((line, i) => {
|
||||
// Skip @@ hunk headers
|
||||
if (line.startsWith("@@")) {
|
||||
|
||||
@@ -24,6 +24,7 @@ import {
|
||||
subscribe,
|
||||
toggleExpanded,
|
||||
} from "../helpers/subagentState.js";
|
||||
import { useTerminalWidth } from "../hooks/useTerminalWidth.js";
|
||||
import { BlinkDot } from "./BlinkDot.js";
|
||||
import { colors } from "./colors.js";
|
||||
|
||||
@@ -62,6 +63,9 @@ interface AgentRowProps {
|
||||
|
||||
const AgentRow = memo(({ agent, isLast, expanded }: AgentRowProps) => {
|
||||
const { treeChar, continueChar } = getTreeChars(isLast);
|
||||
const columns = useTerminalWidth();
|
||||
const gutterWidth = 6; // tree char (1) + " ⎿ " (5)
|
||||
const contentWidth = Math.max(0, columns - gutterWidth);
|
||||
|
||||
const getDotElement = () => {
|
||||
switch (agent.status) {
|
||||
@@ -101,11 +105,17 @@ const AgentRow = memo(({ agent, isLast, expanded }: AgentRowProps) => {
|
||||
{/* Subagent URL */}
|
||||
{agent.agentURL && (
|
||||
<Box flexDirection="row">
|
||||
<Text color={colors.subagent.treeChar}>{continueChar}</Text>
|
||||
<Text dimColor>
|
||||
{" ⎿ Subagent: "}
|
||||
{agent.agentURL}
|
||||
</Text>
|
||||
<Box width={gutterWidth} flexShrink={0}>
|
||||
<Text>
|
||||
<Text color={colors.subagent.treeChar}>{continueChar}</Text>
|
||||
<Text dimColor>{" ⎿ "}</Text>
|
||||
</Text>
|
||||
</Box>
|
||||
<Box flexGrow={1} width={contentWidth}>
|
||||
<Text wrap="wrap" dimColor>
|
||||
Subagent: {agent.agentURL}
|
||||
</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
@@ -126,21 +136,38 @@ const AgentRow = memo(({ agent, isLast, expanded }: AgentRowProps) => {
|
||||
|
||||
{/* Status line */}
|
||||
<Box flexDirection="row">
|
||||
<Text color={colors.subagent.treeChar}>{continueChar}</Text>
|
||||
{agent.status === "completed" ? (
|
||||
<Text dimColor>{" ⎿ Done"}</Text>
|
||||
<>
|
||||
<Text color={colors.subagent.treeChar}>{continueChar}</Text>
|
||||
<Text dimColor>{" ⎿ Done"}</Text>
|
||||
</>
|
||||
) : agent.status === "error" ? (
|
||||
<Text color={colors.subagent.error}>
|
||||
{" ⎿ "}
|
||||
{agent.error}
|
||||
</Text>
|
||||
<>
|
||||
<Box width={gutterWidth} flexShrink={0}>
|
||||
<Text>
|
||||
<Text color={colors.subagent.treeChar}>{continueChar}</Text>
|
||||
<Text dimColor>{" ⎿ "}</Text>
|
||||
</Text>
|
||||
</Box>
|
||||
<Box flexGrow={1} width={contentWidth}>
|
||||
<Text wrap="wrap" color={colors.subagent.error}>
|
||||
{agent.error}
|
||||
</Text>
|
||||
</Box>
|
||||
</>
|
||||
) : lastTool ? (
|
||||
<Text dimColor>
|
||||
{" ⎿ "}
|
||||
{lastTool.name}
|
||||
</Text>
|
||||
<>
|
||||
<Text color={colors.subagent.treeChar}>{continueChar}</Text>
|
||||
<Text dimColor>
|
||||
{" ⎿ "}
|
||||
{lastTool.name}
|
||||
</Text>
|
||||
</>
|
||||
) : (
|
||||
<Text dimColor>{" ⎿ Starting..."}</Text>
|
||||
<>
|
||||
<Text color={colors.subagent.treeChar}>{continueChar}</Text>
|
||||
<Text dimColor>{" ⎿ Starting..."}</Text>
|
||||
</>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
@@ -16,6 +16,7 @@
|
||||
import { Box, Text } from "ink";
|
||||
import { memo } from "react";
|
||||
import { formatStats, getTreeChars } from "../helpers/subagentDisplay.js";
|
||||
import { useTerminalWidth } from "../hooks/useTerminalWidth.js";
|
||||
import { colors } from "./colors.js";
|
||||
|
||||
// ============================================================================
|
||||
@@ -49,6 +50,9 @@ interface AgentRowProps {
|
||||
|
||||
const AgentRow = memo(({ agent, isLast }: AgentRowProps) => {
|
||||
const { treeChar, continueChar } = getTreeChars(isLast);
|
||||
const columns = useTerminalWidth();
|
||||
const gutterWidth = 6; // tree char (1) + " ⎿ " (5)
|
||||
const contentWidth = Math.max(0, columns - gutterWidth);
|
||||
|
||||
const dotColor =
|
||||
agent.status === "completed"
|
||||
@@ -72,24 +76,41 @@ const AgentRow = memo(({ agent, isLast }: AgentRowProps) => {
|
||||
{/* Subagent URL */}
|
||||
{agent.agentURL && (
|
||||
<Box flexDirection="row">
|
||||
<Text color={colors.subagent.treeChar}>{continueChar}</Text>
|
||||
<Text dimColor>
|
||||
{" ⎿ Subagent: "}
|
||||
{agent.agentURL}
|
||||
</Text>
|
||||
<Box width={gutterWidth} flexShrink={0}>
|
||||
<Text>
|
||||
<Text color={colors.subagent.treeChar}>{continueChar}</Text>
|
||||
<Text dimColor>{" ⎿ "}</Text>
|
||||
</Text>
|
||||
</Box>
|
||||
<Box flexGrow={1} width={contentWidth}>
|
||||
<Text wrap="wrap" dimColor>
|
||||
Subagent: {agent.agentURL}
|
||||
</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* Status line */}
|
||||
<Box flexDirection="row">
|
||||
<Text color={colors.subagent.treeChar}>{continueChar}</Text>
|
||||
{agent.status === "completed" ? (
|
||||
<Text dimColor>{" ⎿ Done"}</Text>
|
||||
<>
|
||||
<Text color={colors.subagent.treeChar}>{continueChar}</Text>
|
||||
<Text dimColor>{" ⎿ Done"}</Text>
|
||||
</>
|
||||
) : (
|
||||
<Text color={colors.subagent.error}>
|
||||
{" ⎿ "}
|
||||
{agent.error}
|
||||
</Text>
|
||||
<>
|
||||
<Box width={gutterWidth} flexShrink={0}>
|
||||
<Text>
|
||||
<Text color={colors.subagent.treeChar}>{continueChar}</Text>
|
||||
<Text dimColor>{" ⎿ "}</Text>
|
||||
</Text>
|
||||
</Box>
|
||||
<Box flexGrow={1} width={contentWidth}>
|
||||
<Text wrap="wrap" color={colors.subagent.error}>
|
||||
{agent.error}
|
||||
</Text>
|
||||
</Box>
|
||||
</>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
@@ -2,10 +2,17 @@ import { Box, Text } from "ink";
|
||||
import { memo } from "react";
|
||||
import { INTERRUPTED_BY_USER } from "../../constants";
|
||||
import { clipToolReturn } from "../../tools/manager.js";
|
||||
import { formatArgsDisplay } from "../helpers/formatArgsDisplay.js";
|
||||
import {
|
||||
formatArgsDisplay,
|
||||
parsePatchInput,
|
||||
parsePatchOperations,
|
||||
} from "../helpers/formatArgsDisplay.js";
|
||||
import {
|
||||
getDisplayToolName,
|
||||
isFileEditTool,
|
||||
isFileWriteTool,
|
||||
isMemoryTool,
|
||||
isPatchTool,
|
||||
isPlanTool,
|
||||
isTaskTool,
|
||||
isTodoTool,
|
||||
@@ -13,6 +20,11 @@ import {
|
||||
import { useTerminalWidth } from "../hooks/useTerminalWidth";
|
||||
import { BlinkDot } from "./BlinkDot.js";
|
||||
import { colors } from "./colors.js";
|
||||
import {
|
||||
EditRenderer,
|
||||
MultiEditRenderer,
|
||||
WriteRenderer,
|
||||
} from "./DiffRenderer.js";
|
||||
import { MarkdownDisplay } from "./MarkdownDisplay.js";
|
||||
import { MemoryDiffRenderer } from "./MemoryDiffRenderer.js";
|
||||
import { PlanRenderer } from "./PlanRenderer.js";
|
||||
@@ -58,10 +70,29 @@ export const ToolCallMessage = memo(({ line }: { line: ToolCallLine }) => {
|
||||
}
|
||||
|
||||
// Apply tool name remapping
|
||||
const displayName = getDisplayToolName(rawName);
|
||||
let displayName = getDisplayToolName(rawName);
|
||||
|
||||
// For Patch tools, override display name based on patch content
|
||||
// (Add → Write, Update → Update, Delete → Delete)
|
||||
if (isPatchTool(rawName)) {
|
||||
try {
|
||||
const parsedArgs = JSON.parse(argsText);
|
||||
if (parsedArgs.input) {
|
||||
const patchInfo = parsePatchInput(parsedArgs.input);
|
||||
if (patchInfo) {
|
||||
if (patchInfo.kind === "add") displayName = "Write";
|
||||
else if (patchInfo.kind === "update") displayName = "Update";
|
||||
else if (patchInfo.kind === "delete") displayName = "Delete";
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Keep default "Patch" name if parsing fails
|
||||
}
|
||||
}
|
||||
|
||||
// Format arguments for display using the old formatting logic
|
||||
const formatted = formatArgsDisplay(argsText);
|
||||
// Pass rawName to enable special formatting for file tools
|
||||
const formatted = formatArgsDisplay(argsText, rawName);
|
||||
const args = `(${formatted.display})`;
|
||||
|
||||
const rightWidth = Math.max(0, columns - 2); // gutter is 2 cols
|
||||
@@ -227,6 +258,120 @@ export const ToolCallMessage = memo(({ line }: { line: ToolCallLine }) => {
|
||||
// If MemoryDiffRenderer returns null, fall through to regular handling
|
||||
}
|
||||
|
||||
// Check if this is a file edit tool - show diff instead of success message
|
||||
if (isFileEditTool(rawName) && line.resultOk !== false && line.argsText) {
|
||||
try {
|
||||
const parsedArgs = JSON.parse(line.argsText);
|
||||
const filePath = parsedArgs.file_path || "";
|
||||
|
||||
// Multi-edit: has edits array
|
||||
if (parsedArgs.edits && Array.isArray(parsedArgs.edits)) {
|
||||
const edits = parsedArgs.edits.map(
|
||||
(e: { old_string?: string; new_string?: string }) => ({
|
||||
old_string: e.old_string || "",
|
||||
new_string: e.new_string || "",
|
||||
}),
|
||||
);
|
||||
return (
|
||||
<MultiEditRenderer
|
||||
filePath={filePath}
|
||||
edits={edits}
|
||||
showLineNumbers={false}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// Single edit: has old_string/new_string
|
||||
if (parsedArgs.old_string !== undefined) {
|
||||
return (
|
||||
<EditRenderer
|
||||
filePath={filePath}
|
||||
oldString={parsedArgs.old_string || ""}
|
||||
newString={parsedArgs.new_string || ""}
|
||||
showLineNumbers={false}
|
||||
/>
|
||||
);
|
||||
}
|
||||
} catch {
|
||||
// If parsing fails, fall through to regular handling
|
||||
}
|
||||
}
|
||||
|
||||
// Check if this is a file write tool - show written content
|
||||
if (isFileWriteTool(rawName) && line.resultOk !== false && line.argsText) {
|
||||
try {
|
||||
const parsedArgs = JSON.parse(line.argsText);
|
||||
const filePath = parsedArgs.file_path || "";
|
||||
const content = parsedArgs.content || "";
|
||||
|
||||
if (filePath && content) {
|
||||
return <WriteRenderer filePath={filePath} content={content} />;
|
||||
}
|
||||
} catch {
|
||||
// If parsing fails, fall through to regular handling
|
||||
}
|
||||
}
|
||||
|
||||
// Check if this is a patch tool - show diff/content based on operation type
|
||||
if (isPatchTool(rawName) && line.resultOk !== false && line.argsText) {
|
||||
try {
|
||||
const parsedArgs = JSON.parse(line.argsText);
|
||||
if (parsedArgs.input) {
|
||||
const operations = parsePatchOperations(parsedArgs.input);
|
||||
|
||||
if (operations.length > 0) {
|
||||
return (
|
||||
<Box flexDirection="column">
|
||||
{operations.map((op) => {
|
||||
if (op.kind === "add") {
|
||||
return (
|
||||
<WriteRenderer
|
||||
key={`patch-add-${op.path}`}
|
||||
filePath={op.path}
|
||||
content={op.content}
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (op.kind === "update") {
|
||||
return (
|
||||
<EditRenderer
|
||||
key={`patch-update-${op.path}`}
|
||||
filePath={op.path}
|
||||
oldString={op.oldString}
|
||||
newString={op.newString}
|
||||
showLineNumbers={false}
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (op.kind === "delete") {
|
||||
const gutterWidth = 4;
|
||||
return (
|
||||
<Box key={`patch-delete-${op.path}`} flexDirection="row">
|
||||
<Box width={gutterWidth} flexShrink={0}>
|
||||
<Text>
|
||||
{" "}
|
||||
<Text dimColor>⎿</Text>
|
||||
</Text>
|
||||
</Box>
|
||||
<Box flexGrow={1}>
|
||||
<Text wrap="wrap">
|
||||
Deleted <Text bold>{op.path}</Text>
|
||||
</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
})}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// If parsing fails, fall through to regular handling
|
||||
}
|
||||
}
|
||||
|
||||
// Regular result handling
|
||||
const isError = line.resultOk === false;
|
||||
|
||||
@@ -278,16 +423,22 @@ export const ToolCallMessage = memo(({ line }: { line: ToolCallLine }) => {
|
||||
<Text wrap="wrap">
|
||||
{isMemoryTool(rawName) ? (
|
||||
<>
|
||||
<Text color={colors.tool.memoryName}>{displayName}</Text>
|
||||
<Text bold color={colors.tool.memoryName}>
|
||||
{displayName}
|
||||
</Text>
|
||||
{args}
|
||||
</>
|
||||
) : (
|
||||
`${displayName}${args}`
|
||||
<>
|
||||
<Text bold>{displayName}</Text>
|
||||
{args}
|
||||
</>
|
||||
)}
|
||||
</Text>
|
||||
) : (
|
||||
<Box flexDirection="row">
|
||||
<Text
|
||||
bold
|
||||
color={
|
||||
isMemoryTool(rawName) ? colors.tool.memoryName : undefined
|
||||
}
|
||||
|
||||
@@ -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