feat: improve visibility of memory tool with colored name and diff ou… (#311)
Co-authored-by: Vedant020000 <vedantsondur020@gmail.com> Co-authored-by: Letta <noreply@letta.com>
This commit is contained in:
373
src/cli/components/MemoryDiffRenderer.tsx
Normal file
373
src/cli/components/MemoryDiffRenderer.tsx
Normal file
@@ -0,0 +1,373 @@
|
||||
import * as Diff from "diff";
|
||||
import { Box, Text } from "ink";
|
||||
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) {
|
||||
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} />;
|
||||
}
|
||||
|
||||
// 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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
case "insert": {
|
||||
const insertText = args.insert_text || "";
|
||||
const insertLine = args.insert_line;
|
||||
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>
|
||||
{insertText.split("\n").map((line: string, i: number) => (
|
||||
<Box key={`insert-${i}-${line.substring(0, 20)}`}>
|
||||
<Text> </Text>
|
||||
<Text
|
||||
backgroundColor={colors.diff.addedLineBg}
|
||||
color={colors.diff.textOnDark}
|
||||
>
|
||||
{`+ ${line}`}
|
||||
</Text>
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
case "create": {
|
||||
const description = args.description || "";
|
||||
const fileText = args.file_text || "";
|
||||
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>
|
||||
{fileText
|
||||
?.split("\n")
|
||||
.slice(0, 3)
|
||||
.map((line: string, i: number) => (
|
||||
<Box key={`create-${i}-${line.substring(0, 20)}`}>
|
||||
<Text> </Text>
|
||||
<Text
|
||||
backgroundColor={colors.diff.addedLineBg}
|
||||
color={colors.diff.textOnDark}
|
||||
>
|
||||
{`+ ${truncate(line, 60)}`}
|
||||
</Text>
|
||||
</Box>
|
||||
))}
|
||||
{fileText && fileText.split("\n").length > 3 && (
|
||||
<Text dimColor>
|
||||
{" "}... and {fileText.split("\n").length - 3} more lines
|
||||
</Text>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
case "delete": {
|
||||
return (
|
||||
<Text>
|
||||
{" "}
|
||||
<Text dimColor>⎿</Text> Deleted memory block{" "}
|
||||
<Text color={colors.tool.memoryName}>{blockName}</Text>
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
|
||||
case "rename": {
|
||||
const newPath = args.new_path || "";
|
||||
const newBlockName = newPath.split("/").pop() || newPath;
|
||||
const description = args.description;
|
||||
if (description) {
|
||||
return (
|
||||
<Text>
|
||||
{" "}
|
||||
<Text dimColor>⎿</Text> Updated description of{" "}
|
||||
<Text color={colors.tool.memoryName}>{blockName}</Text>
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<Text>
|
||||
{" "}
|
||||
<Text dimColor>⎿</Text> Renamed{" "}
|
||||
<Text color={colors.tool.memoryName}>{blockName}</Text> to{" "}
|
||||
<Text color={colors.tool.memoryName}>{newBlockName}</Text>
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
|
||||
default:
|
||||
return (
|
||||
<Text>
|
||||
{" "}
|
||||
<Text dimColor>⎿</Text> Memory operation: {command} on{" "}
|
||||
<Text color={colors.tool.memoryName}>{blockName}</Text>
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
} catch {
|
||||
// If parsing fails, return null to fall through to regular handling
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders a str_replace diff with word-level highlighting
|
||||
*/
|
||||
function MemoryStrReplaceDiff({
|
||||
blockName,
|
||||
oldStr,
|
||||
newStr,
|
||||
}: {
|
||||
blockName: string;
|
||||
oldStr: string;
|
||||
newStr: string;
|
||||
}) {
|
||||
const oldLines = oldStr.split("\n");
|
||||
const newLines = newStr.split("\n");
|
||||
const singleLine = oldLines.length === 1 && newLines.length === 1;
|
||||
|
||||
// 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;
|
||||
|
||||
return (
|
||||
<Box flexDirection="column">
|
||||
<Text>
|
||||
{" "}
|
||||
<Text dimColor>⎿</Text> Updated memory block{" "}
|
||||
<Text color={colors.tool.memoryName}>{blockName}</Text>
|
||||
</Text>
|
||||
|
||||
{/* Removals */}
|
||||
{oldTruncated.map((line, i) => (
|
||||
<DiffLine
|
||||
key={`old-${i}-${line.substring(0, 20)}`}
|
||||
type="remove"
|
||||
content={line}
|
||||
compareContent={singleLine ? newLines[0] : undefined}
|
||||
/>
|
||||
))}
|
||||
|
||||
{/* Additions */}
|
||||
{newTruncated.map((line, i) => (
|
||||
<DiffLine
|
||||
key={`new-${i}-${line.substring(0, 20)}`}
|
||||
type="add"
|
||||
content={line}
|
||||
compareContent={singleLine ? oldLines[0] : undefined}
|
||||
/>
|
||||
))}
|
||||
|
||||
{hasMore && <Text dimColor>{" "}... diff truncated</Text>}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Single diff line with word-level highlighting
|
||||
*/
|
||||
function DiffLine({
|
||||
type,
|
||||
content,
|
||||
compareContent,
|
||||
}: {
|
||||
type: "add" | "remove";
|
||||
content: string;
|
||||
compareContent?: string;
|
||||
}) {
|
||||
const prefix = type === "add" ? "+" : "-";
|
||||
const lineBg =
|
||||
type === "add" ? colors.diff.addedLineBg : colors.diff.removedLineBg;
|
||||
const wordBg =
|
||||
type === "add" ? colors.diff.addedWordBg : colors.diff.removedWordBg;
|
||||
|
||||
// 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>
|
||||
<Text>{" "}</Text>
|
||||
<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;
|
||||
})}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
// Simple line without word diff
|
||||
return (
|
||||
<Box>
|
||||
<Text>{" "}</Text>
|
||||
<Text backgroundColor={lineBg} color={colors.diff.textOnDark}>
|
||||
{`${prefix} ${content}`}
|
||||
</Text>
|
||||
</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 }: { label: string; patch: string }) {
|
||||
const lines = patch.split("\n");
|
||||
const maxLines = 8;
|
||||
const displayLines = lines.slice(0, maxLines);
|
||||
const hasMore = lines.length > maxLines;
|
||||
|
||||
return (
|
||||
<Box flexDirection="column">
|
||||
<Text>
|
||||
{" "}
|
||||
<Text dimColor>⎿</Text> Patched memory block{" "}
|
||||
<Text color={colors.tool.memoryName}>{label}</Text>
|
||||
</Text>
|
||||
{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)}`}>
|
||||
<Text>{" "}</Text>
|
||||
<Text
|
||||
backgroundColor={colors.diff.addedLineBg}
|
||||
color={colors.diff.textOnDark}
|
||||
>
|
||||
{`+ ${content}`}
|
||||
</Text>
|
||||
</Box>
|
||||
);
|
||||
} else if (firstChar === "-") {
|
||||
return (
|
||||
<Box key={`patch-${i}-${line.substring(0, 20)}`}>
|
||||
<Text>{" "}</Text>
|
||||
<Text
|
||||
backgroundColor={colors.diff.removedLineBg}
|
||||
color={colors.diff.textOnDark}
|
||||
>
|
||||
{`- ${content}`}
|
||||
</Text>
|
||||
</Box>
|
||||
);
|
||||
} else if (firstChar === " ") {
|
||||
// Context line - show dimmed
|
||||
return (
|
||||
<Box key={`patch-${i}-${line.substring(0, 20)}`}>
|
||||
<Text dimColor>
|
||||
{" "}
|
||||
{content}
|
||||
</Text>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
// Unknown format, show as-is
|
||||
return (
|
||||
<Text key={`patch-${i}-${line.substring(0, 20)}`} dimColor>
|
||||
{" "}
|
||||
{line}
|
||||
</Text>
|
||||
);
|
||||
})}
|
||||
{hasMore && (
|
||||
<Text dimColor>
|
||||
{" "}... {lines.length - maxLines} more lines
|
||||
</Text>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import { clipToolReturn } from "../../tools/manager.js";
|
||||
import { formatArgsDisplay } from "../helpers/formatArgsDisplay.js";
|
||||
import {
|
||||
getDisplayToolName,
|
||||
isMemoryTool,
|
||||
isPlanTool,
|
||||
isTaskTool,
|
||||
isTodoTool,
|
||||
@@ -12,6 +13,7 @@ import { useTerminalWidth } from "../hooks/useTerminalWidth";
|
||||
import { BlinkDot } from "./BlinkDot.js";
|
||||
import { colors } from "./colors.js";
|
||||
import { MarkdownDisplay } from "./MarkdownDisplay.js";
|
||||
import { MemoryDiffRenderer } from "./MemoryDiffRenderer.js";
|
||||
import { PlanRenderer } from "./PlanRenderer.js";
|
||||
import { TodoRenderer } from "./TodoRenderer.js";
|
||||
|
||||
@@ -207,6 +209,17 @@ export const ToolCallMessage = memo(({ line }: { line: ToolCallLine }) => {
|
||||
}
|
||||
}
|
||||
|
||||
// Check if this is a memory tool - show diff instead of raw result
|
||||
if (isMemoryTool(rawName) && line.resultOk !== false && line.argsText) {
|
||||
const memoryDiff = (
|
||||
<MemoryDiffRenderer argsText={line.argsText} toolName={rawName} />
|
||||
);
|
||||
if (memoryDiff) {
|
||||
return memoryDiff;
|
||||
}
|
||||
// If MemoryDiffRenderer returns null, fall through to regular handling
|
||||
}
|
||||
|
||||
// Regular result handling
|
||||
const isError = line.resultOk === false;
|
||||
|
||||
@@ -255,10 +268,25 @@ export const ToolCallMessage = memo(({ line }: { line: ToolCallLine }) => {
|
||||
</Box>
|
||||
<Box flexGrow={1} width={rightWidth}>
|
||||
{fallback ? (
|
||||
<Text wrap="wrap">{`${displayName}${args}`}</Text>
|
||||
<Text wrap="wrap">
|
||||
{isMemoryTool(rawName) ? (
|
||||
<>
|
||||
<Text color={colors.tool.memoryName}>{displayName}</Text>
|
||||
{args}
|
||||
</>
|
||||
) : (
|
||||
`${displayName}${args}`
|
||||
)}
|
||||
</Text>
|
||||
) : (
|
||||
<Box flexDirection="row">
|
||||
<Text>{displayName}</Text>
|
||||
<Text
|
||||
color={
|
||||
isMemoryTool(rawName) ? colors.tool.memoryName : undefined
|
||||
}
|
||||
>
|
||||
{displayName}
|
||||
</Text>
|
||||
{args ? (
|
||||
<Box
|
||||
flexGrow={1}
|
||||
|
||||
@@ -99,6 +99,7 @@ export const colors = {
|
||||
streaming: brandColors.textDisabled, // solid gray dot (streaming/in progress)
|
||||
running: brandColors.statusWarning, // blinking yellow dot (executing)
|
||||
error: brandColors.statusError, // solid red dot (failed)
|
||||
memoryName: brandColors.orange, // memory tool name highlight
|
||||
},
|
||||
|
||||
// Input box
|
||||
|
||||
@@ -112,3 +112,10 @@ export function isFancyUITool(name: string): boolean {
|
||||
name === "ExitPlanMode"
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if a tool is a memory tool (server-side memory management)
|
||||
*/
|
||||
export function isMemoryTool(name: string): boolean {
|
||||
return name === "memory" || name === "memory_apply_patch";
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user