616 lines
18 KiB
TypeScript
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>
|
|
);
|
|
}
|