Files
letta-code/src/cli/components/MemoryTabViewer.tsx

274 lines
7.7 KiB
TypeScript

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 { 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 = `https://app.letta.com/agents/${agentId}?view=memory${conversationId && conversationId !== "default" ? `&conversation=${conversationId}` : ""}`;
const [selectedTabIndex, setSelectedTabIndex] = useState(0);
const [scrollOffset, setScrollOffset] = useState(0);
const [freshBlocks, setFreshBlocks] = useState<Block[] | null>(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 = () => (
<Box flexDirection="row" flexWrap="wrap">
{displayBlocks.map((block, index) => {
const isActive = index === selectedTabIndex;
return (
<Text
key={block.id || block.label}
backgroundColor={
isActive ? colors.selector.itemHighlighted : undefined
}
color={isActive ? "white" : undefined}
bold={isActive}
>
{` ${block.label} `}
</Text>
);
})}
</Box>
);
// Loading state
if (isLoading) {
return (
<Box flexDirection="column">
<Text dimColor>{"> /memory"}</Text>
<Text dimColor>{solidLine}</Text>
<Box height={1} />
<Box marginBottom={1}>
<Text bold color={colors.selector.title}>
View your agent's memory
</Text>
</Box>
<Text dimColor>{" "}Loading memory...</Text>
<Box marginTop={1}>
<Text dimColor>{" "}Esc cancel</Text>
</Box>
</Box>
);
}
// Empty state
if (displayBlocks.length === 0) {
return (
<Box flexDirection="column">
<Text dimColor>{"> /memory"}</Text>
<Text dimColor>{solidLine}</Text>
<Box height={1} />
<Box marginBottom={1}>
<Text bold color={colors.selector.title}>
View your agent's memory
</Text>
</Box>
<Text dimColor>{" "}No memory attached to this agent.</Text>
<Box marginTop={1}>
<Text dimColor>{" "}Esc cancel</Text>
</Box>
</Box>
);
}
const charCount = (currentBlock?.value || "").length;
const visibleValueLines = valueLines.slice(
scrollOffset,
scrollOffset + VISIBLE_LINES,
);
const canScrollDown = scrollOffset < maxScrollOffset;
const barColor = colors.selector.itemHighlighted;
return (
<Box flexDirection="column">
{/* Command header */}
<Text dimColor>{"> /memory"}</Text>
<Text dimColor>{solidLine}</Text>
<Box height={1} />
{/* Title */}
<Box marginBottom={1}>
<Text bold color={colors.selector.title}>
View your agent's memory
</Text>
</Box>
{/* Tab bar */}
<Box flexDirection="column" paddingLeft={1} marginBottom={1}>
{renderTabBar()}
{currentBlock?.description && (
<Box width={terminalWidth - 2}>
<Text dimColor> </Text>
<MarkdownDisplay text={currentBlock.description} dimColor />
</Box>
)}
</Box>
{/* Content area */}
<Box flexDirection="column">
{/* Value content with left border */}
<Box
borderStyle="single"
borderLeft
borderTop={false}
borderBottom={false}
borderRight={false}
borderLeftColor={barColor}
paddingLeft={1}
>
<Text>{visibleValueLines.join("\n") || "(empty)"}</Text>
</Box>
{/* Scroll down indicator or phantom row */}
{canScrollDown ? (
<Text dimColor>
{" "} {maxScrollOffset - scrollOffset} more line
{maxScrollOffset - scrollOffset !== 1 ? "s" : ""} below
</Text>
) : maxScrollOffset > 0 ? (
<Text> </Text>
) : null}
</Box>
{/* Footer */}
<Box flexDirection="column" marginTop={1}>
<Text dimColor>
{" "}
{formatCharCount(charCount, currentBlock?.limit ?? null)}
{currentBlock?.read_only ? " · read-only" : " · read/write"}
</Text>
<Box>
<Text dimColor>{" "}/Tab switch · scroll · </Text>
{!isTmux && (
<Link url={adeUrl}>
<Text dimColor>Edit in ADE</Text>
</Link>
)}
{isTmux && <Text dimColor>Edit in ADE: {adeUrl}</Text>}
<Text dimColor> · Esc cancel</Text>
</Box>
</Box>
</Box>
);
}