Files
letta-code/src/cli/components/MessageSearch.tsx
2026-01-31 19:43:13 -08:00

642 lines
21 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import type { Letta } from "@letta-ai/letta-client";
import type { MessageSearchResponse } from "@letta-ai/letta-client/resources/messages";
import { Box, useInput } from "ink";
import { useCallback, useEffect, useRef, useState } from "react";
import { getClient } from "../../agent/client";
import { useTerminalWidth } from "../hooks/useTerminalWidth";
import { colors } from "./colors";
import { Text } from "./Text";
// Horizontal line character (matches approval dialogs)
const SOLID_LINE = "─";
interface MessageSearchProps {
onClose: () => void;
initialQuery?: string;
/** Current agent ID for "current agent" filter */
agentId?: string;
/** Current conversation ID for "current conv" filter */
conversationId?: string;
/** Callback when user wants to open a conversation */
onOpenConversation?: (agentId: string, conversationId?: string) => void;
}
const VISIBLE_ITEMS = 5;
const SEARCH_LIMIT = 100; // Max results from API
type SearchMode = "hybrid" | "vector" | "fts";
const SEARCH_MODES: SearchMode[] = ["fts", "vector", "hybrid"]; // Display order (hybrid is default)
type SearchRange = "all" | "agent" | "conv";
const SEARCH_RANGES: SearchRange[] = ["all", "agent", "conv"];
/**
* Format a timestamp in local timezone
*/
function formatLocalTime(dateStr: string | null | undefined): string {
if (!dateStr) return "";
const date = new Date(dateStr);
// Format: "Dec 15, 6:30 PM" or "Dec 15, 2024, 6:30 PM" depending on year
const now = new Date();
const sameYear = date.getFullYear() === now.getFullYear();
const options: Intl.DateTimeFormatOptions = {
month: "short",
day: "numeric",
...(sameYear ? {} : { year: "numeric" }),
hour: "numeric",
minute: "2-digit",
};
return date.toLocaleString(undefined, options);
}
/**
* Truncate text to fit width, adding ellipsis if needed
*/
function truncateText(text: string, maxWidth: number): string {
if (text.length <= maxWidth) return text;
return `${text.slice(0, maxWidth - 3)}...`;
}
/**
* Get display text from a message
*/
function getMessageText(msg: MessageSearchResponse[number]): string {
// Assistant message content
if ("content" in msg) {
const content = msg.content;
if (typeof content === "string") return content;
if (Array.isArray(content)) {
const textPart = content.find(
(c) => typeof c === "object" && c && "text" in c,
);
if (textPart && typeof textPart === "object" && "text" in textPart) {
return String(textPart.text);
}
}
}
// Text field (user messages, etc)
if ("text" in msg && typeof msg.text === "string") {
return msg.text;
}
// Reasoning messages
if ("reasoning" in msg && typeof msg.reasoning === "string") {
return msg.reasoning;
}
// Tool call messages
if ("tool_call" in msg && msg.tool_call) {
const tc = msg.tool_call as { name?: string; arguments?: string };
return `Tool: ${tc.name || "unknown"}`;
}
// Tool return messages - show tool name and preview of return
if ("tool_return" in msg) {
const toolName = "name" in msg ? (msg.name as string) : "tool";
const returnValue = msg.tool_return as string;
// Truncate long return values
const preview = returnValue?.slice(0, 100) || "";
return `${toolName}: ${preview}`;
}
return `[${msg.message_type || "unknown"}]`;
}
export function MessageSearch({
onClose,
initialQuery,
agentId,
conversationId,
onOpenConversation,
}: MessageSearchProps) {
const terminalWidth = useTerminalWidth();
const [searchInput, setSearchInput] = useState(initialQuery ?? "");
const [activeQuery, setActiveQuery] = useState(initialQuery ?? "");
const [searchMode, setSearchMode] = useState<SearchMode>("hybrid");
const [searchRange, setSearchRange] = useState<SearchRange>("all");
const [results, setResults] = useState<MessageSearchResponse>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [selectedIndex, setSelectedIndex] = useState(0);
const [expandedMessage, setExpandedMessage] = useState<
MessageSearchResponse[number] | null
>(null);
const clientRef = useRef<Letta | null>(null);
// Cache results per query+mode+range combination to avoid re-fetching
const resultsCache = useRef<Map<string, MessageSearchResponse>>(new Map());
// Get cache key for a specific query+mode+range combination
const getCacheKey = useCallback(
(query: string, mode: SearchMode, range: SearchRange) => {
const rangeKey =
range === "agent"
? agentId || "no-agent"
: range === "conv"
? conversationId || "no-conv"
: "all";
return `${query.trim()}-${mode}-${rangeKey}`;
},
[agentId, conversationId],
);
// Execute search for a single mode (returns results, doesn't set state)
const fetchSearchResults = useCallback(
async (
client: Letta,
query: string,
mode: SearchMode,
range: SearchRange,
) => {
const body: Record<string, unknown> = {
query: query.trim(),
search_mode: mode,
limit: SEARCH_LIMIT,
};
// Add filters based on range
if (range === "agent" && agentId) {
body.agent_id = agentId;
} else if (range === "conv" && conversationId) {
body.conversation_id = conversationId;
}
const searchResults = await client.post<MessageSearchResponse>(
"/v1/messages/search",
{ body },
);
return searchResults;
},
[agentId, conversationId],
);
// Execute search - fires all 9 combinations (3 modes × 3 ranges) in parallel
const executeSearch = useCallback(
async (query: string, mode: SearchMode, range: SearchRange) => {
if (!query.trim()) return;
const cacheKey = getCacheKey(query, mode, range);
// Check cache first
const cached = resultsCache.current.get(cacheKey);
if (cached) {
setResults(cached);
setSelectedIndex(0);
return;
}
setLoading(true);
setError(null);
try {
const client = clientRef.current || (await getClient());
clientRef.current = client;
// Helper to get cached or fetch
const getOrFetch = (m: SearchMode, r: SearchRange) => {
const key = getCacheKey(query, m, r);
return (
resultsCache.current.get(key) ??
fetchSearchResults(client, query, m, r)
);
};
// Fire all 9 combinations in parallel for instant mode/range switching
const [
hybridAll,
vectorAll,
ftsAll,
hybridAgent,
vectorAgent,
ftsAgent,
hybridConv,
vectorConv,
ftsConv,
] = await Promise.all([
getOrFetch("hybrid", "all"),
getOrFetch("vector", "all"),
getOrFetch("fts", "all"),
agentId ? getOrFetch("hybrid", "agent") : Promise.resolve([]),
agentId ? getOrFetch("vector", "agent") : Promise.resolve([]),
agentId ? getOrFetch("fts", "agent") : Promise.resolve([]),
conversationId ? getOrFetch("hybrid", "conv") : Promise.resolve([]),
conversationId ? getOrFetch("vector", "conv") : Promise.resolve([]),
conversationId ? getOrFetch("fts", "conv") : Promise.resolve([]),
]);
// Cache all results
resultsCache.current.set(
getCacheKey(query, "hybrid", "all"),
hybridAll,
);
resultsCache.current.set(
getCacheKey(query, "vector", "all"),
vectorAll,
);
resultsCache.current.set(getCacheKey(query, "fts", "all"), ftsAll);
if (agentId) {
resultsCache.current.set(
getCacheKey(query, "hybrid", "agent"),
hybridAgent,
);
resultsCache.current.set(
getCacheKey(query, "vector", "agent"),
vectorAgent,
);
resultsCache.current.set(
getCacheKey(query, "fts", "agent"),
ftsAgent,
);
}
if (conversationId) {
resultsCache.current.set(
getCacheKey(query, "hybrid", "conv"),
hybridConv,
);
resultsCache.current.set(
getCacheKey(query, "vector", "conv"),
vectorConv,
);
resultsCache.current.set(getCacheKey(query, "fts", "conv"), ftsConv);
}
// Set the results for the current mode+range
const resultMap: Record<
SearchMode,
Record<SearchRange, MessageSearchResponse>
> = {
hybrid: { all: hybridAll, agent: hybridAgent, conv: hybridConv },
vector: { all: vectorAll, agent: vectorAgent, conv: vectorConv },
fts: { all: ftsAll, agent: ftsAgent, conv: ftsConv },
};
setResults(resultMap[mode][range]);
setSelectedIndex(0);
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
setResults([]);
} finally {
setLoading(false);
}
},
[fetchSearchResults, getCacheKey, agentId, conversationId],
);
// Submit search (only when query changes)
const submitSearch = useCallback(() => {
if (searchInput.trim() && searchInput !== activeQuery) {
setActiveQuery(searchInput);
executeSearch(searchInput, searchMode, searchRange);
}
}, [searchInput, activeQuery, searchMode, searchRange, executeSearch]);
// Clear search
const clearSearch = useCallback(() => {
setSearchInput("");
setActiveQuery("");
setResults([]);
setSelectedIndex(0);
}, []);
// Cycle search mode (Shift+Tab)
const cycleSearchMode = useCallback((reverse = false) => {
setSearchMode((current) => {
const currentIndex = SEARCH_MODES.indexOf(current);
const nextIndex = reverse
? (currentIndex - 1 + SEARCH_MODES.length) % SEARCH_MODES.length
: (currentIndex + 1) % SEARCH_MODES.length;
return SEARCH_MODES[nextIndex] as SearchMode;
});
}, []);
// Cycle search range (Tab)
const cycleSearchRange = useCallback(() => {
setSearchRange((current) => {
const currentIndex = SEARCH_RANGES.indexOf(current);
const nextIndex = (currentIndex + 1) % SEARCH_RANGES.length;
return SEARCH_RANGES[nextIndex] as SearchRange;
});
}, []);
// Re-run search when mode or range changes (if there's an active query) - uses cache
useEffect(() => {
if (activeQuery) {
executeSearch(activeQuery, searchMode, searchRange);
}
}, [searchMode, searchRange, activeQuery, executeSearch]);
// Sliding window for visible items
const startIndex = Math.max(
0,
Math.min(selectedIndex - 2, results.length - VISIBLE_ITEMS),
);
const visibleResults = results.slice(startIndex, startIndex + VISIBLE_ITEMS);
useInput((input, key) => {
// CTRL-C: immediately close (bypasses search clearing)
if (key.ctrl && input === "c") {
onClose();
return;
}
// Handle expanded message view
if (expandedMessage) {
if (key.escape) {
setExpandedMessage(null);
} else if (key.return && onOpenConversation) {
const msgData = expandedMessage as {
agent_id?: string;
conversation_id?: string;
};
if (msgData.agent_id) {
onOpenConversation(msgData.agent_id, msgData.conversation_id);
}
}
return;
}
if (key.escape) {
if (searchInput || activeQuery) {
clearSearch();
} else {
onClose();
}
} else if (key.return) {
// If user has typed a new query, search first
if (searchInput.trim() && searchInput !== activeQuery) {
submitSearch();
} else if (results.length > 0 && results[selectedIndex]) {
// Otherwise expand the selected result
setExpandedMessage(results[selectedIndex]);
}
} else if (key.backspace || key.delete) {
setSearchInput((prev) => prev.slice(0, -1));
} else if (key.tab && key.shift) {
// Shift+Tab cycles search mode
cycleSearchMode();
} else if (key.tab) {
// Tab cycles search range
cycleSearchRange();
} else if (key.upArrow) {
setSelectedIndex((prev) => Math.max(0, prev - 1));
} else if (key.downArrow) {
setSelectedIndex((prev) => Math.min(results.length - 1, prev + 1));
} else if (input && !key.ctrl && !key.meta) {
setSearchInput((prev) => prev + input);
}
});
const solidLine = SOLID_LINE.repeat(Math.max(terminalWidth, 10));
// Range label helper
const getRangeLabel = (range: SearchRange) => {
switch (range) {
case "all":
return "all agents";
case "agent":
return "this agent";
case "conv":
return "this conversation";
}
};
return (
<Box flexDirection="column">
{/* Command header */}
<Text dimColor>{"> /search"}</Text>
<Text dimColor>{solidLine}</Text>
<Box height={1} />
{/* Expanded message view - hide title/controls */}
{expandedMessage &&
(() => {
const msgData = expandedMessage as {
date?: string;
created_at?: string;
agent_id?: string;
conversation_id?: string;
};
const fullText = getMessageText(expandedMessage);
const msgType = expandedMessage.message_type || "unknown";
const isAssistant =
msgType === "assistant_message" || msgType === "reasoning_message";
const typeLabel = isAssistant ? "Agent message" : "User message";
const timestamp = formatLocalTime(msgData.created_at || msgData.date);
return (
<>
{/* Full message text in quotes */}
<Box paddingLeft={2}>
<Text>"{fullText}"</Text>
</Box>
<Box height={1} />
{/* Metadata */}
<Box flexDirection="column" paddingLeft={2}>
<Text dimColor>
{typeLabel}, sent {timestamp}
</Text>
<Text dimColor>Agent ID: {msgData.agent_id || "unknown"}</Text>
{msgData.conversation_id && (
<Text dimColor>Conv ID: {msgData.conversation_id}</Text>
)}
</Box>
<Box height={1} />
{/* Footer */}
<Box paddingLeft={2}>
<Text dimColor>
{onOpenConversation
? "Enter to open conversation · Esc cancel"
: "Esc cancel"}
</Text>
</Box>
</>
);
})()}
{/* Title and search controls - hidden when expanded */}
{!expandedMessage && (
<Box flexDirection="column" gap={1} marginBottom={1}>
<Text bold color={colors.selector.title}>
Search messages across all agents
</Text>
<Box flexDirection="column" paddingLeft={1}>
{/* Search input */}
<Box flexDirection="row">
<Text dimColor> Search: </Text>
{searchInput ? (
<>
<Text>{searchInput}</Text>
{searchInput !== activeQuery && (
<Text dimColor> (press Enter to search)</Text>
)}
</>
) : (
<Text dimColor>(type to search)</Text>
)}
</Box>
<Box height={1} />
{/* Range tabs */}
<Box flexDirection="row">
<Text dimColor> Range (tab): </Text>
{SEARCH_RANGES.map((range, i) => {
const isActive = range === searchRange;
return (
<Text key={range}>
{i > 0 && <Text> </Text>}
<Text
backgroundColor={
isActive ? colors.selector.itemHighlighted : undefined
}
color={isActive ? "black" : undefined}
bold={isActive}
>
{` ${getRangeLabel(range)} `}
</Text>
</Text>
);
})}
</Box>
{/* Mode tabs */}
<Box flexDirection="row">
<Text dimColor> Mode (shift-tab): </Text>
{SEARCH_MODES.map((mode, i) => {
const isActive = mode === searchMode;
return (
<Text key={mode}>
{i > 0 && <Text> </Text>}
<Text
backgroundColor={
isActive ? colors.selector.itemHighlighted : undefined
}
color={isActive ? "black" : undefined}
bold={isActive}
>
{` ${mode} `}
</Text>
</Text>
);
})}
</Box>
</Box>
</Box>
)}
{/* Error state */}
{!expandedMessage && error && (
<Box paddingLeft={2}>
<Text color="red">Error: {error}</Text>
</Box>
)}
{/* Loading state */}
{!expandedMessage && loading && (
<Box paddingLeft={2}>
<Text dimColor>Searching...</Text>
</Box>
)}
{/* No results */}
{!expandedMessage && !loading && activeQuery && results.length === 0 && (
<Box paddingLeft={2}>
<Text dimColor>No results found for "{activeQuery}"</Text>
</Box>
)}
{/* Results list */}
{!expandedMessage && !loading && results.length > 0 && (
<Box flexDirection="column">
{visibleResults.map(
(msg: MessageSearchResponse[number], visibleIndex: number) => {
const actualIndex = startIndex + visibleIndex;
const isSelected = actualIndex === selectedIndex;
const messageText = getMessageText(msg);
// All messages have a date field
const msgWithDate = msg as {
date?: string;
created_at?: string;
agent_id?: string;
conversation_id?: string;
};
const msgType = msg.message_type || "unknown";
const agentIdFromMsg = msgWithDate.agent_id || "unknown";
const conversationIdFromMsg = msgWithDate.conversation_id;
const createdAt = formatLocalTime(
msgWithDate.created_at || msgWithDate.date,
);
// Determine emoji based on message type
const isAssistant =
msgType === "assistant_message" ||
msgType === "reasoning_message";
const emoji = isAssistant ? "👾" : "👤";
// Calculate available width for message text (account for emoji + spacing)
const availableWidth = Math.max(20, terminalWidth - 8);
const displayText = truncateText(
messageText.replace(/\n/g, " "),
availableWidth,
);
// Show conversation_id if exists, otherwise agent_id
const idToShow = conversationIdFromMsg || agentIdFromMsg;
// Use message id + index for guaranteed uniqueness (search can return same message multiple times)
const msgId =
"message_id" in msg ? String(msg.message_id) : "result";
const uniqueKey = `${msgId}-${actualIndex}`;
return (
<Box key={uniqueKey} flexDirection="column" marginBottom={1}>
<Box flexDirection="row">
<Text
color={
isSelected ? colors.selector.itemHighlighted : undefined
}
>
{isSelected ? ">" : " "}
</Text>
<Text> {emoji} </Text>
<Text
bold={isSelected}
italic
color={
isSelected ? colors.selector.itemHighlighted : undefined
}
>
{displayText}
</Text>
</Box>
<Box flexDirection="row" marginLeft={2}>
<Text dimColor>
{createdAt}
{idToShow && ` · ${idToShow}`}
</Text>
</Box>
</Box>
);
},
)}
</Box>
)}
{/* Footer */}
{!expandedMessage && (
<Box flexDirection="column" marginTop={1} paddingLeft={2}>
{results.length > 0 && (
<Text dimColor>
{selectedIndex + 1}/{results.length} results
</Text>
)}
<Text dimColor>Enter expand · navigate · Esc close</Text>
</Box>
)}
</Box>
);
}