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

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

View File

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

View File

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

View File

@@ -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("@@")) {

View File

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

View File

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

View File

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