Files
letta-code/src/cli/components/MemoryDiffRenderer.tsx
2025-12-25 18:52:51 -08:00

616 lines
18 KiB
TypeScript

import * as Diff from "diff";
import { Box, Text } from "ink";
import { useTerminalWidth } from "../hooks/useTerminalWidth";
import { colors } from "./colors";
interface MemoryDiffRendererProps {
argsText: string;
toolName: string;
}
/**
* Renders a diff view for memory tool operations.
* Handles both `memory` (command-based) and `memory_apply_patch` (unified diff) tools.
*/
export function MemoryDiffRenderer({
argsText,
toolName,
}: MemoryDiffRendererProps) {
const columns = useTerminalWidth();
try {
const args = JSON.parse(argsText);
// Handle memory_apply_patch tool (unified diff format)
if (toolName === "memory_apply_patch") {
const label = args.label || "unknown";
const patch = args.patch || "";
return (
<PatchDiffRenderer label={label} patch={patch} columns={columns} />
);
}
// Handle memory tool (command-based)
const command = args.command as string;
const path = args.path || args.old_path || "unknown";
// Extract just the block name from the path (e.g., "/memories/project" -> "project")
const blockName = path.split("/").pop() || path;
switch (command) {
case "str_replace": {
const oldStr = args.old_str || "";
const newStr = args.new_str || "";
return (
<MemoryStrReplaceDiff
blockName={blockName}
oldStr={oldStr}
newStr={newStr}
columns={columns}
/>
);
}
case "insert": {
const insertText = args.insert_text || "";
const insertLine = args.insert_line;
const prefixWidth = 4; // " " indent
const contentWidth = Math.max(0, columns - prefixWidth);
return (
<Box flexDirection="column">
<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)}`}
flexDirection="row"
>
<Box width={prefixWidth} flexShrink={0}>
<Text>{" "}</Text>
</Box>
<Box flexGrow={1} width={contentWidth}>
<Text
backgroundColor={colors.diff.addedLineBg}
color={colors.diff.textOnDark}
wrap="wrap"
>
{`+ ${line}`}
</Text>
</Box>
</Box>
))}
</Box>
);
}
case "create": {
const description = args.description || "";
const fileText = args.file_text || "";
const prefixWidth = 4; // " " indent
const contentWidth = Math.max(0, columns - prefixWidth);
return (
<Box flexDirection="column">
<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)
.map((line: string, i: number) => (
<Box
key={`create-${i}-${line.substring(0, 20)}`}
flexDirection="row"
>
<Box width={prefixWidth} flexShrink={0}>
<Text>{" "}</Text>
</Box>
<Box flexGrow={1} width={contentWidth}>
<Text
backgroundColor={colors.diff.addedLineBg}
color={colors.diff.textOnDark}
wrap="wrap"
>
{`+ ${truncate(line, 60)}`}
</Text>
</Box>
</Box>
))}
{fileText && fileText.split("\n").length > 3 && (
<Text dimColor>
{" "}... and {fileText.split("\n").length - 3} more lines
</Text>
)}
</Box>
);
}
case "delete": {
const prefixWidth = 4;
const contentWidth = Math.max(0, columns - prefixWidth);
return (
<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>
);
}
case "rename": {
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 (
<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 (
<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: {
const defaultPrefixWidth = 4;
const defaultContentWidth = Math.max(0, columns - defaultPrefixWidth);
return (
<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
return null;
}
}
/**
* 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,
oldStr,
newStr,
columns,
}: {
blockName: string;
oldStr: string;
newStr: string;
columns: number;
}) {
// 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 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">
<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>
{displayRows.map((row, i) => (
<DiffLine
key={`diff-${i}-${row.type}-${row.content.substring(0, 20)}`}
type={row.type}
content={row.content}
compareContent={row.pairContent}
columns={columns}
/>
))}
{hasMore && (
<Text dimColor>
{" "}... {rows.length - maxRows} more lines
</Text>
)}
</Box>
);
}
/**
* Single diff line with word-level highlighting
*/
function DiffLine({
type,
content,
compareContent,
columns,
}: {
type: "add" | "remove";
content: string;
compareContent?: string;
columns: number;
}) {
const prefix = type === "add" ? "+" : "-";
const lineBg =
type === "add" ? colors.diff.addedLineBg : colors.diff.removedLineBg;
const wordBg =
type === "add" ? colors.diff.addedWordBg : colors.diff.removedWordBg;
const prefixWidth = 4; // " " indent
const contentWidth = Math.max(0, columns - prefixWidth);
// Word-level diff if we have something to compare
if (compareContent !== undefined && content.trim() && compareContent.trim()) {
const wordDiffs =
type === "add"
? Diff.diffWords(compareContent, content)
: Diff.diffWords(content, compareContent);
return (
<Box flexDirection="row">
<Box width={prefixWidth} flexShrink={0}>
<Text>{" "}</Text>
</Box>
<Box flexGrow={1} width={contentWidth}>
<Text wrap="wrap">
<Text backgroundColor={lineBg} color={colors.diff.textOnDark}>
{`${prefix} `}
</Text>
{wordDiffs.map((part, i) => {
if (part.added && type === "add") {
return (
<Text
key={`w-${i}-${part.value.substring(0, 10)}`}
backgroundColor={wordBg}
color={colors.diff.textOnHighlight}
>
{part.value}
</Text>
);
} else if (part.removed && type === "remove") {
return (
<Text
key={`w-${i}-${part.value.substring(0, 10)}`}
backgroundColor={wordBg}
color={colors.diff.textOnHighlight}
>
{part.value}
</Text>
);
} else if (!part.added && !part.removed) {
return (
<Text
key={`w-${i}-${part.value.substring(0, 10)}`}
backgroundColor={lineBg}
color={colors.diff.textOnDark}
>
{part.value}
</Text>
);
}
return null;
})}
</Text>
</Box>
</Box>
);
}
// Simple line without word diff
return (
<Box flexDirection="row">
<Box width={prefixWidth} flexShrink={0}>
<Text>{" "}</Text>
</Box>
<Box flexGrow={1} width={contentWidth}>
<Text
backgroundColor={lineBg}
color={colors.diff.textOnDark}
wrap="wrap"
>
{`${prefix} ${content}`}
</Text>
</Box>
</Box>
);
}
function truncate(str: string, maxLen: number): string {
if (str.length <= maxLen) return str;
return `${str.slice(0, maxLen - 1)}`;
}
/**
* Renders a unified-diff patch from memory_apply_patch tool
*/
function PatchDiffRenderer({
label,
patch,
columns,
}: {
label: string;
patch: string;
columns: number;
}) {
const lines = patch.split("\n");
const maxLines = 8;
const displayLines = lines.slice(0, maxLines);
const hasMore = lines.length > maxLines;
const prefixWidth = 4; // " " indent
const contentWidth = Math.max(0, columns - prefixWidth);
return (
<Box flexDirection="column">
<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("@@")) {
return null;
}
const firstChar = line[0];
const content = line.slice(1); // Remove the prefix character
if (firstChar === "+") {
return (
<Box
key={`patch-${i}-${line.substring(0, 20)}`}
flexDirection="row"
>
<Box width={prefixWidth} flexShrink={0}>
<Text>{" "}</Text>
</Box>
<Box flexGrow={1} width={contentWidth}>
<Text
backgroundColor={colors.diff.addedLineBg}
color={colors.diff.textOnDark}
wrap="wrap"
>
{`+ ${content}`}
</Text>
</Box>
</Box>
);
} else if (firstChar === "-") {
return (
<Box
key={`patch-${i}-${line.substring(0, 20)}`}
flexDirection="row"
>
<Box width={prefixWidth} flexShrink={0}>
<Text>{" "}</Text>
</Box>
<Box flexGrow={1} width={contentWidth}>
<Text
backgroundColor={colors.diff.removedLineBg}
color={colors.diff.textOnDark}
wrap="wrap"
>
{`- ${content}`}
</Text>
</Box>
</Box>
);
} else if (firstChar === " ") {
// Context line - show dimmed
return (
<Box
key={`patch-${i}-${line.substring(0, 20)}`}
flexDirection="row"
>
<Box width={prefixWidth + 2} flexShrink={0}>
<Text dimColor>{" "}</Text>
</Box>
<Box flexGrow={1} width={Math.max(0, columns - prefixWidth - 2)}>
<Text dimColor wrap="wrap">
{content}
</Text>
</Box>
</Box>
);
}
// Unknown format, show as-is
return (
<Box key={`patch-${i}-${line.substring(0, 20)}`} flexDirection="row">
<Box width={prefixWidth} flexShrink={0}>
<Text dimColor>{" "}</Text>
</Box>
<Box flexGrow={1} width={contentWidth}>
<Text dimColor wrap="wrap">
{line}
</Text>
</Box>
</Box>
);
})}
{hasMore && (
<Text dimColor>
{" "}... {lines.length - maxLines} more lines
</Text>
)}
</Box>
);
}