import type { Block } from "@letta-ai/letta-client/resources/agents/blocks"; import { Box, useInput } from "ink"; import Link from "ink-link"; import { useEffect, useState } from "react"; import { getClient } from "../../agent/client"; import { debugLog } from "../../utils/debug"; import { buildChatUrl } from "../helpers/appUrls"; import { useTerminalWidth } from "../hooks/useTerminalWidth"; import { colors } from "./colors"; import { MarkdownDisplay } from "./MarkdownDisplay"; import { Text } from "./Text"; // Horizontal line character (matches approval dialogs) const SOLID_LINE = "─"; const VISIBLE_LINES = 12; // Visible lines for value content interface MemoryTabViewerProps { blocks: Block[]; agentId: string; onClose: () => void; conversationId?: string; } /** * 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 MemoryTabViewer({ blocks, agentId, onClose, conversationId, }: MemoryTabViewerProps) { const terminalWidth = useTerminalWidth(); const solidLine = SOLID_LINE.repeat(Math.max(terminalWidth, 10)); const isTmux = Boolean(process.env.TMUX); const adeUrl = buildChatUrl(agentId, { view: "memory", conversationId }); const [selectedTabIndex, setSelectedTabIndex] = useState(0); const [scrollOffset, setScrollOffset] = useState(0); const [freshBlocks, setFreshBlocks] = useState(null); const [isLoading, setIsLoading] = useState(true); // Fetch fresh memory blocks from the API when component mounts useEffect(() => { const fetchBlocks = async () => { try { const client = await getClient(); const agent = await client.agents.retrieve(agentId, { include: ["agent.blocks"], }); setFreshBlocks(agent.memory?.blocks || []); } catch (error) { debugLog("memory-tab", "Failed to fetch memory blocks: %O", error); // Fall back to passed-in blocks if fetch fails setFreshBlocks(blocks); } finally { setIsLoading(false); } }; fetchBlocks(); }, [agentId, blocks]); // Use fresh blocks if available, otherwise fall back to passed-in blocks const displayBlocks = freshBlocks ?? blocks; // Get current block const currentBlock = displayBlocks[selectedTabIndex]; const valueLines = currentBlock?.value?.split("\n") || []; const maxScrollOffset = Math.max(0, valueLines.length - VISIBLE_LINES); // Reset scroll when switching tabs const switchTab = (newIndex: number) => { setSelectedTabIndex(newIndex); setScrollOffset(0); }; useInput((input, key) => { // CTRL-C: immediately close if (key.ctrl && input === "c") { onClose(); return; } // ESC: close if (key.escape) { onClose(); return; } // Tab or left/right to switch tabs if (key.tab) { const nextIndex = (selectedTabIndex + 1) % displayBlocks.length; switchTab(nextIndex); return; } if (key.leftArrow) { const prevIndex = selectedTabIndex === 0 ? displayBlocks.length - 1 : selectedTabIndex - 1; switchTab(prevIndex); return; } if (key.rightArrow) { const nextIndex = (selectedTabIndex + 1) % displayBlocks.length; switchTab(nextIndex); return; } // Up/down to scroll content if (key.upArrow) { setScrollOffset((prev) => Math.max(prev - 1, 0)); } else if (key.downArrow) { setScrollOffset((prev) => Math.min(prev + 1, maxScrollOffset)); } }); // Render tab bar (no gap - spacing is handled by padding in each label) const renderTabBar = () => ( {displayBlocks.map((block, index) => { const isActive = index === selectedTabIndex; return ( {` ${block.label} `} ); })} ); // Loading state if (isLoading) { return ( {"> /memory"} {solidLine} View your agent's memory {" "}Loading memory... {" "}Esc cancel ); } // Empty state if (displayBlocks.length === 0) { return ( {"> /memory"} {solidLine} View your agent's memory {" "}No memory attached to this agent. {" "}Esc cancel ); } const charCount = (currentBlock?.value || "").length; const visibleValueLines = valueLines.slice( scrollOffset, scrollOffset + VISIBLE_LINES, ); const canScrollDown = scrollOffset < maxScrollOffset; const barColor = colors.selector.itemHighlighted; return ( {/* Command header */} {"> /memory"} {solidLine} {/* Title */} View your agent's memory {/* Tab bar */} {renderTabBar()} {currentBlock?.description && ( )} {/* Content area */} {/* Value content with left border */} {visibleValueLines.join("\n") || "(empty)"} {/* Scroll down indicator or phantom row */} {canScrollDown ? ( {" "}↓ {maxScrollOffset - scrollOffset} more line {maxScrollOffset - scrollOffset !== 1 ? "s" : ""} below ) : maxScrollOffset > 0 ? ( ) : null} {/* Footer */} {" "} {formatCharCount(charCount, currentBlock?.limit ?? null)} {currentBlock?.read_only ? " · read-only" : " · read/write"} {" "}←→/Tab switch · ↑↓ scroll · {!isTmux && ( Edit in ADE )} {isTmux && Edit in ADE: {adeUrl}} · Esc cancel ); }