From 2f1943f41defef1f2680e5af8c1f127ff2719c8e Mon Sep 17 00:00:00 2001 From: Charles Packer Date: Thu, 29 Jan 2026 20:23:51 -0800 Subject: [PATCH] fix: remove extra vertical spacing between memory block tabs (#749) Co-authored-by: Letta --- src/agent/memoryFilesystem.ts | 58 ++- src/cli/App.tsx | 39 +- src/cli/components/MemfsTreeViewer.tsx | 514 +++++++++++++++++++++++++ src/cli/components/MemoryTabViewer.tsx | 4 +- 4 files changed, 592 insertions(+), 23 deletions(-) create mode 100644 src/cli/components/MemfsTreeViewer.tsx diff --git a/src/agent/memoryFilesystem.ts b/src/agent/memoryFilesystem.ts index e77c2bb..042e18c 100644 --- a/src/agent/memoryFilesystem.ts +++ b/src/agent/memoryFilesystem.ts @@ -186,14 +186,22 @@ async function saveSyncState( await writeFile(statePath, JSON.stringify(state, null, 2)); } -async function scanMdFiles(dir: string, baseDir = dir): Promise { +async function scanMdFiles( + dir: string, + baseDir = dir, + excludeDirs: string[] = [], +): Promise { const entries = await readdir(dir, { withFileTypes: true }); const results: string[] = []; for (const entry of entries) { const fullPath = join(dir, entry.name); if (entry.isDirectory()) { - results.push(...(await scanMdFiles(fullPath, baseDir))); + // Skip excluded directories (e.g., "system" when scanning for detached files) + if (excludeDirs.includes(entry.name)) { + continue; + } + results.push(...(await scanMdFiles(fullPath, baseDir, excludeDirs))); } else if (entry.isFile() && entry.name.endsWith(".md")) { results.push(relative(baseDir, fullPath)); } @@ -254,8 +262,9 @@ export function parseBlockFromFileContent( async function readMemoryFiles( dir: string, + excludeDirs: string[] = [], ): Promise> { - const files = await scanMdFiles(dir); + const files = await scanMdFiles(dir, dir, excludeDirs); const entries = new Map(); for (const relativePath of files) { @@ -466,7 +475,7 @@ export async function syncMemoryFilesystem( const systemDir = getMemorySystemDir(agentId, homeDir); const detachedDir = getMemoryDetachedDir(agentId, homeDir); const systemFiles = await readMemoryFiles(systemDir); - const detachedFiles = await readMemoryFiles(detachedDir); + const detachedFiles = await readMemoryFiles(detachedDir, [MEMORY_SYSTEM_DIR]); systemFiles.delete(MEMORY_FILESYSTEM_BLOCK_LABEL); const attachedBlocks = await fetchAgentBlocks(agentId); @@ -513,6 +522,11 @@ export async function syncMemoryFilesystem( if (MANAGED_BLOCK_LABELS.has(block.label)) { continue; } + // Skip blocks whose label matches a system block (prevents duplicates) + // This can happen when a system block is detached but keeps its owner tag + if (systemBlockMap.has(block.label)) { + continue; + } detachedBlockIds[block.label] = block.id; detachedBlockMap.set(block.label, block); } @@ -613,8 +627,10 @@ export async function syncMemoryFilesystem( if (resolution?.resolution === "file") { if (blockEntry.id) { + // Parse frontmatter to extract just the body for the block value + const blockData = parseBlockFromFileContent(fileEntry.content, label); await client.blocks.update(blockEntry.id, { - value: fileEntry.content, + value: blockData.value, }); updatedBlocks.push(label); } @@ -630,8 +646,10 @@ export async function syncMemoryFilesystem( if (fileChanged && !blockChanged) { if (blockEntry.id) { try { + // Parse frontmatter to extract just the body for the block value + const blockData = parseBlockFromFileContent(fileEntry.content, label); await client.blocks.update(blockEntry.id, { - value: fileEntry.content, + value: blockData.value, }); updatedBlocks.push(label); } catch (err) { @@ -743,8 +761,10 @@ export async function syncMemoryFilesystem( if (resolution?.resolution === "file") { if (blockEntry.id) { + // Parse frontmatter to extract just the body for the block value + const blockData = parseBlockFromFileContent(fileEntry.content, label); await client.blocks.update(blockEntry.id, { - value: fileEntry.content, + value: blockData.value, label, }); } @@ -760,8 +780,10 @@ export async function syncMemoryFilesystem( if (fileChanged && !blockChanged) { if (blockEntry.id) { + // Parse frontmatter to extract just the body for the block value + const blockData = parseBlockFromFileContent(fileEntry.content, label); await client.blocks.update(blockEntry.id, { - value: fileEntry.content, + value: blockData.value, label, }); } @@ -788,7 +810,9 @@ export async function syncMemoryFilesystem( const updatedSystemFilesMap = await readMemoryFiles(systemDir); updatedSystemFilesMap.delete(MEMORY_FILESYSTEM_BLOCK_LABEL); - const updatedUserFilesMap = await readMemoryFiles(detachedDir); + const updatedUserFilesMap = await readMemoryFiles(detachedDir, [ + MEMORY_SYSTEM_DIR, + ]); const refreshedUserBlocks = new Map(); for (const [label, blockId] of Object.entries(detachedBlockIds)) { @@ -829,7 +853,7 @@ export async function updateMemoryFilesystemBlock( const detachedDir = getMemoryDetachedDir(agentId, homeDir); const systemFiles = await readMemoryFiles(systemDir); - const detachedFiles = await readMemoryFiles(detachedDir); + const detachedFiles = await readMemoryFiles(detachedDir, [MEMORY_SYSTEM_DIR]); const tree = renderMemoryFilesystemTree( Array.from(systemFiles.keys()).filter( @@ -838,6 +862,10 @@ export async function updateMemoryFilesystemBlock( Array.from(detachedFiles.keys()), ); + // Prepend memory directory path (tilde format for readability) + const memoryPath = `~/.letta/agents/${agentId}/memory`; + const content = `Memory Directory: ${memoryPath}\n\n${tree}`; + const client = await getClient(); const blocks = await fetchAgentBlocks(agentId); const memfsBlock = blocks.find( @@ -845,10 +873,10 @@ export async function updateMemoryFilesystemBlock( ); if (memfsBlock?.id) { - await client.blocks.update(memfsBlock.id, { value: tree }); + await client.blocks.update(memfsBlock.id, { value: content }); } - await writeMemoryFile(systemDir, MEMORY_FILESYSTEM_BLOCK_LABEL, tree); + await writeMemoryFile(systemDir, MEMORY_FILESYSTEM_BLOCK_LABEL, content); } export async function ensureMemoryFilesystemBlock(agentId: string) { @@ -933,7 +961,7 @@ export async function checkMemoryFilesystemStatus( const systemDir = getMemorySystemDir(agentId, homeDir); const detachedDir = getMemoryDetachedDir(agentId, homeDir); const systemFiles = await readMemoryFiles(systemDir); - const detachedFiles = await readMemoryFiles(detachedDir); + const detachedFiles = await readMemoryFiles(detachedDir, [MEMORY_SYSTEM_DIR]); systemFiles.delete(MEMORY_FILESYSTEM_BLOCK_LABEL); const attachedBlocks = await fetchAgentBlocks(agentId); @@ -964,6 +992,10 @@ export async function checkMemoryFilesystemStatus( if (MANAGED_BLOCK_LABELS.has(block.label)) { continue; } + // Skip blocks whose label matches a system block (prevents duplicates) + if (systemBlockMap.has(block.label)) { + continue; + } detachedBlockMap.set(block.label, block); } } diff --git a/src/cli/App.tsx b/src/cli/App.tsx index f068fdf..d0b6691 100644 --- a/src/cli/App.tsx +++ b/src/cli/App.tsx @@ -123,6 +123,7 @@ import { InlineQuestionApproval } from "./components/InlineQuestionApproval"; import { Input } from "./components/InputRich"; import { McpConnectFlow } from "./components/McpConnectFlow"; import { McpSelector } from "./components/McpSelector"; +import { MemfsTreeViewer } from "./components/MemfsTreeViewer"; import { MemoryTabViewer } from "./components/MemoryTabViewer"; import { MessageSearch } from "./components/MessageSearch"; import { ModelSelector } from "./components/ModelSelector"; @@ -1952,6 +1953,14 @@ export default function App({ return; } if (memorySyncInFlightRef.current) { + // If called from a command while another sync is in flight, update the UI + if (source === "command" && commandId) { + updateMemorySyncCommand( + commandId, + "Sync already in progress — try again in a moment", + false, + ); + } return; } @@ -6250,6 +6259,12 @@ export default function App({ try { await runMemoryFilesystemSync("command", cmdId); + } catch (error) { + // runMemoryFilesystemSync has its own error handling, but catch any + // unexpected errors that slip through + const errorText = + error instanceof Error ? error.message : String(error); + updateMemorySyncCommand(cmdId, `Failed: ${errorText}`, false); } finally { setCommandRunning(false); } @@ -10323,14 +10338,22 @@ Plan file path: ${planFilePath}`; )} {/* Memory Viewer - conditionally mounted as overlay */} - {activeOverlay === "memory" && ( - - )} + {/* Use tree view for memfs-enabled agents, tab view otherwise */} + {activeOverlay === "memory" && + (settingsManager.isMemfsEnabled(agentId) ? ( + + ) : ( + + ))} {/* Memory Sync Conflict Resolver */} {activeOverlay === "memfs-sync" && memorySyncConflicts && ( diff --git a/src/cli/components/MemfsTreeViewer.tsx b/src/cli/components/MemfsTreeViewer.tsx new file mode 100644 index 0000000..9dae8d5 --- /dev/null +++ b/src/cli/components/MemfsTreeViewer.tsx @@ -0,0 +1,514 @@ +import { existsSync, readdirSync, readFileSync, statSync } from "node:fs"; +import { join, relative } from "node:path"; +import { Box, Text, useInput } from "ink"; +import Link from "ink-link"; +import { useMemo, useState } from "react"; +import { + getMemoryFilesystemRoot, + MEMORY_FS_STATE_FILE, +} from "../../agent/memoryFilesystem"; +import { useTerminalWidth } from "../hooks/useTerminalWidth"; +import { colors } from "./colors"; + +// Line characters +const SOLID_LINE = "─"; +const DOTTED_LINE = "╌"; + +// Tree view constants +const TREE_VISIBLE_LINES = 15; +const FULL_VIEW_VISIBLE_LINES = 16; + +// Tree structure types +interface TreeNode { + name: string; // Display name (e.g., "git.md" or "dev_workflow/") + relativePath: string; // Relative path from memory root + fullPath: string; // Full filesystem path + isDirectory: boolean; + depth: number; + isLast: boolean; + parentIsLast: boolean[]; +} + +interface MemfsTreeViewerProps { + agentId: string; + onClose: () => void; + conversationId?: string; +} + +/** + * Scan the memory filesystem directory and build tree nodes + */ +function scanMemoryFilesystem(memoryRoot: string): TreeNode[] { + const nodes: TreeNode[] = []; + + const scanDir = (dir: string, depth: number, parentIsLast: boolean[]) => { + let entries: string[]; + try { + entries = readdirSync(dir); + } catch { + return; + } + + // Filter out hidden files and state file + const filtered = entries.filter( + (name) => !name.startsWith(".") && name !== MEMORY_FS_STATE_FILE, + ); + + // Sort: directories first, then alphabetically + const sorted = filtered.sort((a, b) => { + const aPath = join(dir, a); + const bPath = join(dir, b); + let aIsDir = false; + let bIsDir = false; + try { + aIsDir = statSync(aPath).isDirectory(); + } catch {} + try { + bIsDir = statSync(bPath).isDirectory(); + } catch {} + if (aIsDir !== bIsDir) return aIsDir ? -1 : 1; + return a.localeCompare(b); + }); + + sorted.forEach((name, index) => { + const fullPath = join(dir, name); + let isDir = false; + try { + isDir = statSync(fullPath).isDirectory(); + } catch { + return; // Skip if we can't stat + } + + const relativePath = relative(memoryRoot, fullPath); + const isLast = index === sorted.length - 1; + + nodes.push({ + name: isDir ? `${name}/` : name, + relativePath, + fullPath, + isDirectory: isDir, + depth, + isLast, + parentIsLast: [...parentIsLast], + }); + + if (isDir) { + scanDir(fullPath, depth + 1, [...parentIsLast, isLast]); + } + }); + }; + + scanDir(memoryRoot, 0, []); + return nodes; +} + +/** + * Get only file nodes (for navigation) + */ +function getFileNodes(nodes: TreeNode[]): TreeNode[] { + return nodes.filter((n) => !n.isDirectory); +} + +/** + * Render tree line prefix based on depth and parent status + */ +function renderTreePrefix(node: TreeNode): string { + let prefix = ""; + for (let i = 0; i < node.depth; i++) { + prefix += node.parentIsLast[i] ? " " : "│ "; + } + prefix += node.isLast ? "└── " : "├── "; + return prefix; +} + +/** + * Read file content safely + */ +function readFileContent(fullPath: string): string { + try { + return readFileSync(fullPath, "utf-8"); + } catch { + return "(unable to read file)"; + } +} + +export function MemfsTreeViewer({ + agentId, + onClose, + conversationId, +}: MemfsTreeViewerProps) { + 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 ? `&conversation=${conversationId}` : ""}`; + + // State + const [selectedIndex, setSelectedIndex] = useState(0); + const [treeScrollOffset, setTreeScrollOffset] = useState(0); + const [viewMode, setViewMode] = useState<"split" | "full">("split"); + const [fullViewScrollOffset, setFullViewScrollOffset] = useState(0); + + // Get memory filesystem root + const memoryRoot = getMemoryFilesystemRoot(agentId); + const memoryExists = existsSync(memoryRoot); + + // Scan filesystem and build tree + const treeNodes = useMemo( + () => (memoryExists ? scanMemoryFilesystem(memoryRoot) : []), + [memoryRoot, memoryExists], + ); + const fileNodes = useMemo(() => getFileNodes(treeNodes), [treeNodes]); + + // Get currently selected file and its content + const selectedFile = fileNodes[selectedIndex]; + const selectedContent = useMemo( + () => (selectedFile ? readFileContent(selectedFile.fullPath) : ""), + [selectedFile], + ); + + // Calculate scroll bounds + const contentLines = selectedContent.split("\n"); + const maxFullViewScroll = Math.max( + 0, + contentLines.length - FULL_VIEW_VISIBLE_LINES, + ); + + // Handle input + useInput((input, key) => { + // CTRL-C: immediately close + if (key.ctrl && input === "c") { + onClose(); + return; + } + + // ESC: close or return from full view + if (key.escape) { + if (viewMode === "full") { + setViewMode("split"); + setFullViewScrollOffset(0); + } else { + onClose(); + } + return; + } + + if (viewMode === "split") { + // Up/down to navigate files + if (key.upArrow) { + if (selectedIndex > 0) { + // Navigate to previous file + const newIndex = selectedIndex - 1; + setSelectedIndex(newIndex); + // Find where this file is in the full tree (including directories) + const newFile = fileNodes[newIndex]; + if (newFile) { + const nodeIndex = treeNodes.findIndex( + (n) => n.relativePath === newFile.relativePath, + ); + // Scroll up to show the selected node with context + if (nodeIndex >= 0) { + const desiredOffset = Math.max(0, nodeIndex - 1); + if (desiredOffset < treeScrollOffset) { + setTreeScrollOffset(desiredOffset); + } + } + } + } else if (treeScrollOffset > 0) { + // Already at first file, but can still scroll tree up to show context + setTreeScrollOffset(treeScrollOffset - 1); + } + } else if (key.downArrow && selectedIndex < fileNodes.length - 1) { + const newIndex = selectedIndex + 1; + setSelectedIndex(newIndex); + // Find where this file is in the full tree (including directories) + const newFile = fileNodes[newIndex]; + if (newFile) { + const nodeIndex = treeNodes.findIndex( + (n) => n.relativePath === newFile.relativePath, + ); + // Scroll down if the selected node is below the visible area + if ( + nodeIndex >= 0 && + nodeIndex >= treeScrollOffset + TREE_VISIBLE_LINES + ) { + setTreeScrollOffset(nodeIndex - TREE_VISIBLE_LINES + 1); + } + } + } else if (key.return) { + // Enter to view full file + if (selectedFile) { + setViewMode("full"); + setFullViewScrollOffset(0); + } + } + } else { + // Full view mode - scroll with up/down + if (key.upArrow) { + setFullViewScrollOffset((prev) => Math.max(0, prev - 1)); + } else if (key.downArrow) { + setFullViewScrollOffset((prev) => + Math.min(maxFullViewScroll, prev + 1), + ); + } + } + }); + + // No memfs directory + if (!memoryExists) { + return ( + + {"> /memory"} + {solidLine} + + + + View your agent's memory + + + + {" "}Memory filesystem not found at {memoryRoot} + + {" "}Run /memfs enable to set up. + + {" "}Esc cancel + + + ); + } + + // Empty state + if (treeNodes.length === 0) { + return ( + + {"> /memory"} + {solidLine} + + + + View your agent's memory + + + {" "}No files in memory filesystem. + + {" "}Esc cancel + + + ); + } + + // Full view mode + if (viewMode === "full" && selectedFile) { + const visibleLines = contentLines.slice( + fullViewScrollOffset, + fullViewScrollOffset + FULL_VIEW_VISIBLE_LINES, + ); + const canScrollDown = fullViewScrollOffset < maxFullViewScroll; + const charCount = selectedContent.length; + const barColor = colors.selector.itemHighlighted; + + return ( + + {"> /memory"} + {solidLine} + + + {/* Title with file path */} + + + {selectedFile.relativePath} + + + + {/* Content with left border */} + + {visibleLines.join("\n") || "(empty)"} + + + {/* Scroll indicator */} + {canScrollDown ? ( + + {" "}↓ {maxFullViewScroll - fullViewScrollOffset} more line + {maxFullViewScroll - fullViewScrollOffset !== 1 ? "s" : ""} below + + ) : maxFullViewScroll > 0 ? ( + + ) : null} + + {/* Footer */} + + + {" "} + {charCount.toLocaleString()} chars + + {" "}↑↓ scroll · Esc back + + + ); + } + + // Split view mode + const leftWidth = Math.floor((terminalWidth - 4) * 0.45); + const rightWidth = terminalWidth - leftWidth - 4; + + // Visible tree nodes + const visibleTreeNodes = treeNodes.slice( + treeScrollOffset, + treeScrollOffset + TREE_VISIBLE_LINES, + ); + + // Preview content - fills the space + // Layout: title (1) + content (TREE_VISIBLE_LINES - 1) + more indicator (1) = TREE_VISIBLE_LINES + 1 + // This matches the left panel: tree (TREE_VISIBLE_LINES) + more indicator (1) + const previewContentLines = TREE_VISIBLE_LINES - 1; + const previewLines = contentLines.slice(0, previewContentLines); + const hasMorePreviewLines = contentLines.length > previewContentLines; + + return ( + + {"> /memory"} + {solidLine} + + + {/* Title */} + + + View your agent's memory + + + + {/* Top dotted border - full width */} + {DOTTED_LINE.repeat(terminalWidth)} + + {/* Split view */} + + {/* Left panel - Tree */} + + {visibleTreeNodes.map((node) => { + const isSelected = + !node.isDirectory && + node.relativePath === selectedFile?.relativePath; + const prefix = renderTreePrefix(node); + + return ( + + + {prefix} + + + {node.name} + + + ); + })} + {/* Pad to fixed height */} + {visibleTreeNodes.length < TREE_VISIBLE_LINES && + Array.from({ + length: TREE_VISIBLE_LINES - visibleTreeNodes.length, + }).map((_, i) => ( + // biome-ignore lint/suspicious/noArrayIndexKey: static padding elements + + ))} + {/* More indicator - always on last row */} + {treeScrollOffset + TREE_VISIBLE_LINES < treeNodes.length ? ( + + ...{treeNodes.length - treeScrollOffset - TREE_VISIBLE_LINES} more + + ) : ( + + )} + + + {/* Separator */} + + {Array.from({ length: TREE_VISIBLE_LINES + 1 }).map((_, i) => ( + // biome-ignore lint/suspicious/noArrayIndexKey: static separator elements + + │ + + ))} + + + {/* Right panel - Preview */} + + {selectedFile ? ( + <> + {/* Content lines */} + {previewLines.map((line, idx) => ( + // biome-ignore lint/suspicious/noArrayIndexKey: content lines by position + + {line.length > rightWidth - 2 + ? `${line.slice(0, rightWidth - 5)}...` + : line || " "} + + ))} + {/* Padding to fill remaining content space */} + {Array.from({ + length: Math.max( + 0, + previewContentLines - previewLines.length + 1, + ), + }).map((_, i) => ( + // biome-ignore lint/suspicious/noArrayIndexKey: static padding elements + + ))} + {/* More indicator */} + {hasMorePreviewLines ? ( + + ...{contentLines.length - previewContentLines} more (enter to + view) + + ) : ( + + )} + + ) : ( + <> + No file selected + {/* Pad to match height */} + {Array.from({ length: TREE_VISIBLE_LINES }).map((_, i) => ( + // biome-ignore lint/suspicious/noArrayIndexKey: static padding elements + + ))} + + )} + + + + {/* Bottom dotted border - full width */} + {DOTTED_LINE.repeat(terminalWidth)} + + {/* Footer */} + + + {" "}↑↓ navigate · Enter view · + {!isTmux && ( + + Edit in ADE + + )} + {isTmux && Edit in ADE: {adeUrl}} + · Esc close + + + + ); +} diff --git a/src/cli/components/MemoryTabViewer.tsx b/src/cli/components/MemoryTabViewer.tsx index d627a8d..fdcbc0d 100644 --- a/src/cli/components/MemoryTabViewer.tsx +++ b/src/cli/components/MemoryTabViewer.tsx @@ -123,9 +123,9 @@ export function MemoryTabViewer({ } }); - // Render tab bar + // Render tab bar (no gap - spacing is handled by padding in each label) const renderTabBar = () => ( - + {displayBlocks.map((block, index) => { const isActive = index === selectedTabIndex; return (