fix: remove extra vertical spacing between memory block tabs (#749)

Co-authored-by: Letta <noreply@letta.com>
This commit is contained in:
Charles Packer
2026-01-29 20:23:51 -08:00
committed by GitHub
parent 382a7d34f5
commit 2f1943f41d
4 changed files with 592 additions and 23 deletions

View File

@@ -186,14 +186,22 @@ async function saveSyncState(
await writeFile(statePath, JSON.stringify(state, null, 2));
}
async function scanMdFiles(dir: string, baseDir = dir): Promise<string[]> {
async function scanMdFiles(
dir: string,
baseDir = dir,
excludeDirs: string[] = [],
): Promise<string[]> {
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<Map<string, { content: string; path: string }>> {
const files = await scanMdFiles(dir);
const files = await scanMdFiles(dir, dir, excludeDirs);
const entries = new Map<string, { content: string; path: string }>();
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<string, { value: string }>();
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);
}
}

View File

@@ -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" && (
<MemoryTabViewer
blocks={agentState?.memory?.blocks || []}
agentId={agentId}
onClose={closeOverlay}
conversationId={conversationId}
/>
)}
{/* Use tree view for memfs-enabled agents, tab view otherwise */}
{activeOverlay === "memory" &&
(settingsManager.isMemfsEnabled(agentId) ? (
<MemfsTreeViewer
agentId={agentId}
onClose={closeOverlay}
conversationId={conversationId}
/>
) : (
<MemoryTabViewer
blocks={agentState?.memory?.blocks || []}
agentId={agentId}
onClose={closeOverlay}
conversationId={conversationId}
/>
))}
{/* Memory Sync Conflict Resolver */}
{activeOverlay === "memfs-sync" && memorySyncConflicts && (

View File

@@ -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 (
<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>
{" "}Memory filesystem not found at {memoryRoot}
</Text>
<Text dimColor>{" "}Run /memfs enable to set up.</Text>
<Box marginTop={1}>
<Text dimColor>{" "}Esc cancel</Text>
</Box>
</Box>
);
}
// Empty state
if (treeNodes.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 files in memory filesystem.</Text>
<Box marginTop={1}>
<Text dimColor>{" "}Esc cancel</Text>
</Box>
</Box>
);
}
// 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 (
<Box flexDirection="column">
<Text dimColor>{"> /memory"}</Text>
<Text dimColor>{solidLine}</Text>
<Box height={1} />
{/* Title with file path */}
<Box marginBottom={1}>
<Text bold color={colors.selector.title}>
{selectedFile.relativePath}
</Text>
</Box>
{/* Content with left border */}
<Box
borderStyle="single"
borderLeft
borderTop={false}
borderBottom={false}
borderRight={false}
borderLeftColor={barColor}
paddingLeft={1}
>
<Text>{visibleLines.join("\n") || "(empty)"}</Text>
</Box>
{/* Scroll indicator */}
{canScrollDown ? (
<Text dimColor>
{" "} {maxFullViewScroll - fullViewScrollOffset} more line
{maxFullViewScroll - fullViewScrollOffset !== 1 ? "s" : ""} below
</Text>
) : maxFullViewScroll > 0 ? (
<Text> </Text>
) : null}
{/* Footer */}
<Box flexDirection="column" marginTop={1}>
<Text dimColor>
{" "}
{charCount.toLocaleString()} chars
</Text>
<Text dimColor>{" "} scroll · Esc back</Text>
</Box>
</Box>
);
}
// 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 (
<Box flexDirection="column">
<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>
{/* Top dotted border - full width */}
<Text dimColor>{DOTTED_LINE.repeat(terminalWidth)}</Text>
{/* Split view */}
<Box flexDirection="row">
{/* Left panel - Tree */}
<Box flexDirection="column" width={leftWidth}>
{visibleTreeNodes.map((node) => {
const isSelected =
!node.isDirectory &&
node.relativePath === selectedFile?.relativePath;
const prefix = renderTreePrefix(node);
return (
<Box key={node.relativePath} flexDirection="row">
<Text
backgroundColor={
isSelected ? colors.selector.itemHighlighted : undefined
}
color={isSelected ? "black" : undefined}
>
{prefix}
</Text>
<Text
backgroundColor={
isSelected ? colors.selector.itemHighlighted : undefined
}
color={isSelected ? "black" : undefined}
dimColor={node.isDirectory}
>
{node.name}
</Text>
</Box>
);
})}
{/* 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
<Text key={`pad-${i}`}> </Text>
))}
{/* More indicator - always on last row */}
{treeScrollOffset + TREE_VISIBLE_LINES < treeNodes.length ? (
<Text dimColor>
...{treeNodes.length - treeScrollOffset - TREE_VISIBLE_LINES} more
</Text>
) : (
<Text> </Text>
)}
</Box>
{/* Separator */}
<Box flexDirection="column" marginLeft={1} marginRight={1}>
{Array.from({ length: TREE_VISIBLE_LINES + 1 }).map((_, i) => (
// biome-ignore lint/suspicious/noArrayIndexKey: static separator elements
<Text key={i} dimColor>
</Text>
))}
</Box>
{/* Right panel - Preview */}
<Box flexDirection="column" width={rightWidth}>
{selectedFile ? (
<>
{/* Content lines */}
{previewLines.map((line, idx) => (
// biome-ignore lint/suspicious/noArrayIndexKey: content lines by position
<Text key={idx}>
{line.length > rightWidth - 2
? `${line.slice(0, rightWidth - 5)}...`
: line || " "}
</Text>
))}
{/* 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
<Text key={`pad-${i}`}> </Text>
))}
{/* More indicator */}
{hasMorePreviewLines ? (
<Text dimColor>
...{contentLines.length - previewContentLines} more (enter to
view)
</Text>
) : (
<Text> </Text>
)}
</>
) : (
<>
<Text dimColor>No file selected</Text>
{/* Pad to match height */}
{Array.from({ length: TREE_VISIBLE_LINES }).map((_, i) => (
// biome-ignore lint/suspicious/noArrayIndexKey: static padding elements
<Text key={`pad-${i}`}> </Text>
))}
</>
)}
</Box>
</Box>
{/* Bottom dotted border - full width */}
<Text dimColor>{DOTTED_LINE.repeat(terminalWidth)}</Text>
{/* Footer */}
<Box flexDirection="column" marginTop={1}>
<Box>
<Text dimColor>{" "} navigate · Enter view · </Text>
{!isTmux && (
<Link url={adeUrl}>
<Text dimColor>Edit in ADE</Text>
</Link>
)}
{isTmux && <Text dimColor>Edit in ADE: {adeUrl}</Text>}
<Text dimColor> · Esc close</Text>
</Box>
</Box>
</Box>
);
}

View File

@@ -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 = () => (
<Box flexDirection="row" gap={1} flexWrap="wrap">
<Box flexDirection="row" flexWrap="wrap">
{displayBlocks.map((block, index) => {
const isActive = index === selectedTabIndex;
return (