diff --git a/src/cli/App.tsx b/src/cli/App.tsx index ae1caeb..ada1c1a 100644 --- a/src/cli/App.tsx +++ b/src/cli/App.tsx @@ -50,6 +50,7 @@ import { EnterPlanModeDialog } from "./components/EnterPlanModeDialog"; import { ErrorMessage } from "./components/ErrorMessageRich"; import { FeedbackDialog } from "./components/FeedbackDialog"; import { Input } from "./components/InputRich"; +import { MemoryViewer } from "./components/MemoryViewer"; import { MessageSearch } from "./components/MessageSearch"; import { ModelSelector } from "./components/ModelSelector"; import { PlanModeDialog } from "./components/PlanModeDialog"; @@ -393,6 +394,7 @@ export default function App({ | "search" | "subagent" | "feedback" + | "memory" | null; const [activeOverlay, setActiveOverlay] = useState(null); const closeOverlay = useCallback(() => setActiveOverlay(null), []); @@ -1648,6 +1650,12 @@ export default function App({ return { submitted: true }; } + // Special handling for /memory command - opens memory viewer + if (trimmed === "/memory") { + setActiveOverlay("memory"); + return { submitted: true }; + } + // Special handling for /exit command - show stats and exit if (trimmed === "/exit") { const cmdId = uid("cmd"); @@ -4053,6 +4061,16 @@ Plan file path: ${planFilePath}`; /> )} + {/* Memory Viewer - conditionally mounted as overlay */} + {activeOverlay === "memory" && ( + + )} + {/* Plan Mode Dialog - for ExitPlanMode tool */} {currentApproval?.toolName === "ExitPlanMode" && ( <> diff --git a/src/cli/commands/registry.ts b/src/cli/commands/registry.ts index a503d11..7dd625b 100644 --- a/src/cli/commands/registry.ts +++ b/src/cli/commands/registry.ts @@ -173,6 +173,13 @@ export const commands: Record = { return "Sending feedback..."; }, }, + "/memory": { + desc: "View agent memory blocks", + handler: () => { + // Handled specially in App.tsx to open memory viewer + return "Opening memory viewer..."; + }, + }, }; /** diff --git a/src/cli/components/MemoryViewer.tsx b/src/cli/components/MemoryViewer.tsx new file mode 100644 index 0000000..0890344 --- /dev/null +++ b/src/cli/components/MemoryViewer.tsx @@ -0,0 +1,308 @@ +import type { Block } from "@letta-ai/letta-client/resources/agents/blocks"; +import { Box, Text, useInput } from "ink"; +import Link from "ink-link"; +import { useState } from "react"; +import { colors } from "./colors"; + +const PAGE_SIZE = 3; // Show 3 memory blocks per page +const PREVIEW_LINES = 3; // Show 3 lines of content preview +const DETAIL_DESCRIPTION_LINES = 3; // Max lines for description in detail view +const DETAIL_VALUE_LINES = 12; // Visible lines for value content in detail view + +interface MemoryViewerProps { + blocks: Block[]; + agentId: string; + agentName: string | null; + onClose: () => void; +} + +/** + * Truncate text to a certain number of lines + */ +function truncateToLines(text: string, maxLines: number): string[] { + const lines = text.split("\n").slice(0, maxLines); + return lines; +} + +/** + * Format character count as "current / limit" + */ +function formatCharCount(current: number, limit: number | null): string { + if (limit === null || limit === undefined) { + return `${current.toLocaleString()} chars`; + } + return `${current.toLocaleString()} / ${limit.toLocaleString()} chars`; +} + +export function MemoryViewer({ + blocks, + agentId, + agentName, + onClose, +}: MemoryViewerProps) { + // Construct ADE URL for this agent's memory + const adeUrl = `https://app.letta.com/agents/${agentId}?view=memory`; + const [selectedIndex, setSelectedIndex] = useState(0); + const [currentPage, setCurrentPage] = useState(0); + + // Detail view state + const [detailBlockIndex, setDetailBlockIndex] = useState(null); + const [scrollOffset, setScrollOffset] = useState(0); + + const totalPages = Math.ceil(blocks.length / PAGE_SIZE); + const startIndex = currentPage * PAGE_SIZE; + const visibleBlocks = blocks.slice(startIndex, startIndex + PAGE_SIZE); + + // Navigation within page and across pages + const navigateUp = () => { + if (selectedIndex > 0) { + setSelectedIndex(selectedIndex - 1); + } else if (currentPage > 0) { + setCurrentPage(currentPage - 1); + setSelectedIndex(PAGE_SIZE - 1); + } + }; + + const navigateDown = () => { + if (selectedIndex < visibleBlocks.length - 1) { + setSelectedIndex(selectedIndex + 1); + } else if (currentPage < totalPages - 1) { + setCurrentPage(currentPage + 1); + setSelectedIndex(0); + } + }; + + // Get the block being viewed in detail + const detailBlock = + detailBlockIndex !== null ? blocks[detailBlockIndex] : null; + const detailValueLines = detailBlock?.value?.split("\n") || []; + const maxScrollOffset = Math.max( + 0, + detailValueLines.length - DETAIL_VALUE_LINES, + ); + + useInput((input, key) => { + // ESC: exit detail view or close entirely + if (key.escape) { + if (detailBlockIndex !== null) { + setDetailBlockIndex(null); + setScrollOffset(0); + } else { + onClose(); + } + return; + } + + // Enter: open detail view for selected block + if (key.return && detailBlockIndex === null) { + const globalIndex = currentPage * PAGE_SIZE + selectedIndex; + if (globalIndex < blocks.length) { + setDetailBlockIndex(globalIndex); + setScrollOffset(0); + } + return; + } + + // j/k vim-style navigation (list or scroll) + if (input === "j" || key.downArrow) { + if (detailBlockIndex !== null) { + // Scroll down in detail view + setScrollOffset((prev) => Math.min(prev + 1, maxScrollOffset)); + } else { + navigateDown(); + } + } else if (input === "k" || key.upArrow) { + if (detailBlockIndex !== null) { + // Scroll up in detail view + setScrollOffset((prev) => Math.max(prev - 1, 0)); + } else { + navigateUp(); + } + } + }); + + if (blocks.length === 0) { + return ( + + + Memory Blocks + + No memory blocks attached to this agent. + Press ESC to close + + ); + } + + // Detail view for a single block + if (detailBlock) { + const charCount = (detailBlock.value || "").length; + const descriptionLines = truncateToLines( + detailBlock.description || "", + DETAIL_DESCRIPTION_LINES, + ); + const visibleValueLines = detailValueLines.slice( + scrollOffset, + scrollOffset + DETAIL_VALUE_LINES, + ); + const canScrollUp = scrollOffset > 0; + const canScrollDown = scrollOffset < maxScrollOffset; + const barColor = colors.selector.itemHighlighted; + + return ( + + {/* Header */} + + + Viewing the + + {detailBlock.label} + + block + {detailBlock.read_only && (read-only)} + + + {formatCharCount(charCount, detailBlock.limit ?? null)} + + + + View/edit in the ADE + + ↑↓/jk to scroll • ESC to go back + + {/* Description (up to 3 lines) */} + {descriptionLines.length > 0 && ( + + {descriptionLines.map((line) => ( + + {line} + + ))} + + )} + + {/* Scrollable value content */} + + {/* Scroll up indicator */} + {canScrollUp && ( + + ↑ {scrollOffset} more line{scrollOffset !== 1 ? "s" : ""} above + + )} + + {/* Value content with left border */} + + {visibleValueLines.join("\n")} + + + {/* Scroll down indicator */} + {canScrollDown && ( + + ↓ {maxScrollOffset - scrollOffset} more line + {maxScrollOffset - scrollOffset !== 1 ? "s" : ""} below + + )} + + + ); + } + + return ( + + {/* Header */} + + + Memory Blocks ({blocks.length} attached to {agentName || "agent"}) + + {totalPages > 1 && ( + + Page {currentPage + 1}/{totalPages} + + )} + + + View/edit in the ADE + + ↑↓/jk to navigate • Enter to view • ESC to close + + {/* Block list */} + + {visibleBlocks.map((block, index) => { + const isSelected = index === selectedIndex; + const contentLines = truncateToLines( + block.value || "", + PREVIEW_LINES, + ); + const charCount = (block.value || "").length; + + const barColor = isSelected + ? colors.selector.itemHighlighted + : colors.command.border; + const hasEllipsis = + (block.value || "").split("\n").length > PREVIEW_LINES; + + // Build content preview text + const previewText = contentLines + .map((line) => + line.length > 80 ? `${line.slice(0, 80)}...` : line, + ) + .join("\n"); + + return ( + + {/* Header row: label + char count */} + + + + {block.label} + + {block.read_only && (read-only)} + + + {formatCharCount(charCount, block.limit ?? null)} + + + + {/* Description (if available) */} + {block.description && ( + + {block.description.length > 60 + ? `${block.description.slice(0, 60)}...` + : block.description} + + )} + + {/* Content preview */} + {previewText} + + {/* Ellipsis if content is truncated */} + {hasEllipsis && ...} + + ); + })} + + + ); +}