diff --git a/bun.lock b/bun.lock index bbacec7..77a3bc7 100644 --- a/bun.lock +++ b/bun.lock @@ -4,7 +4,7 @@ "": { "name": "@letta-ai/letta-code", "dependencies": { - "@letta-ai/letta-client": "^1.1.2", + "@letta-ai/letta-client": "^1.3.3", "glob": "^13.0.0", "ink-link": "^5.0.0", "open": "^10.2.0", @@ -36,7 +36,7 @@ "@isaacs/brace-expansion": ["@isaacs/brace-expansion@5.0.0", "", { "dependencies": { "@isaacs/balanced-match": "^4.0.1" } }, "sha512-ZT55BDLV0yv0RBm2czMiZ+SqCGO7AvmOM3G/w2xhVPH+te0aKgFjmBvGlL1dH+ql2tgGO3MVrbb3jCKyvpgnxA=="], - "@letta-ai/letta-client": ["@letta-ai/letta-client@1.1.2", "", {}, "sha512-p8YYdDoM4s0KY5eo7zByr3q3iIuEAZrFrwa9FgjfIMB6sRno33bjIY8sazCb3lhhQZ/2SUkus0ngZ2ImxAmMig=="], + "@letta-ai/letta-client": ["@letta-ai/letta-client@1.3.3", "", {}, "sha512-1pSmkmeuXAN9Lq8PXDO2YSLK0q6O39zh2BHlulVhtc8P3aNxAJF7XBteiAhL2hzb49wF2zv0mi2Uikr67CTUCw=="], "@types/bun": ["@types/bun@1.3.1", "", { "dependencies": { "bun-types": "1.3.1" } }, "sha512-4jNMk2/K9YJtfqwoAa28c8wK+T7nvJFOjxI4h/7sORWcypRNxBpr+TPNaCfVWq70tLCJsqoFwcf0oI0JU/fvMQ=="], diff --git a/package.json b/package.json index 9503759..c2932f5 100644 --- a/package.json +++ b/package.json @@ -22,7 +22,7 @@ "access": "public" }, "dependencies": { - "@letta-ai/letta-client": "^1.1.2", + "@letta-ai/letta-client": "^1.3.3", "glob": "^13.0.0", "ink-link": "^5.0.0", "open": "^10.2.0" diff --git a/src/cli/App.tsx b/src/cli/App.tsx index 043b5a2..b34f1c5 100644 --- a/src/cli/App.tsx +++ b/src/cli/App.tsx @@ -37,6 +37,7 @@ import { CommandMessage } from "./components/CommandMessage"; import { EnterPlanModeDialog } from "./components/EnterPlanModeDialog"; import { ErrorMessage } from "./components/ErrorMessageRich"; import { Input } from "./components/InputRich"; +import { MessageSearch } from "./components/MessageSearch"; import { ModelSelector } from "./components/ModelSelector"; import { PlanModeDialog } from "./components/PlanModeDialog"; import { QuestionDialog } from "./components/QuestionDialog"; @@ -330,6 +331,7 @@ export default function App({ // Resume selector state const [resumeSelectorOpen, setResumeSelectorOpen] = useState(false); + const [messageSearchOpen, setMessageSearchOpen] = useState(false); // Token streaming preference (can be toggled at runtime) const [tokenStreamingEnabled, setTokenStreamingEnabled] = @@ -1572,6 +1574,12 @@ export default function App({ return { submitted: true }; } + // Special handling for /search command - show message search + if (msg.trim() === "/search") { + setMessageSearchOpen(true); + return { submitted: true }; + } + // Special handling for /swap command - alias for /resume if (msg.trim().startsWith("/swap")) { const parts = msg.trim().split(/\s+/); @@ -3299,7 +3307,8 @@ Plan file path: ${planFilePath}`; !toolsetSelectorOpen && !systemPromptSelectorOpen && !agentSelectorOpen && - !resumeSelectorOpen + !resumeSelectorOpen && + !messageSearchOpen } streaming={ streaming && !abortControllerRef.current?.signal.aborted @@ -3372,6 +3381,11 @@ Plan file path: ${planFilePath}`; /> )} + {/* Message Search - conditionally mounted as overlay */} + {messageSearchOpen && ( + setMessageSearchOpen(false)} /> + )} + {/* Plan Mode Dialog - for ExitPlanMode tool */} {currentApproval?.toolName === "ExitPlanMode" && ( <> diff --git a/src/cli/commands/registry.ts b/src/cli/commands/registry.ts index b991727..2e0c937 100644 --- a/src/cli/commands/registry.ts +++ b/src/cli/commands/registry.ts @@ -143,6 +143,13 @@ export const commands: Record = { return "Opening session selector..."; }, }, + "/search": { + desc: "Search messages across all agents", + handler: () => { + // Handled specially in App.tsx to show message search + return "Opening message search..."; + }, + }, }; /** diff --git a/src/cli/components/MessageSearch.tsx b/src/cli/components/MessageSearch.tsx new file mode 100644 index 0000000..6374d0c --- /dev/null +++ b/src/cli/components/MessageSearch.tsx @@ -0,0 +1,347 @@ +import type { Letta } from "@letta-ai/letta-client"; +import type { MessageSearchResponse } from "@letta-ai/letta-client/resources/messages"; +import { Box, Text, useInput } from "ink"; +import { useCallback, useEffect, useRef, useState } from "react"; +import { getClient } from "../../agent/client"; +import { useTerminalWidth } from "../hooks/useTerminalWidth"; +import { colors } from "./colors"; + +interface MessageSearchProps { + onClose: () => void; +} + +const DISPLAY_PAGE_SIZE = 5; +const SEARCH_LIMIT = 100; // Max results from API + +type SearchMode = "hybrid" | "vector" | "fts"; +const SEARCH_MODES: SearchMode[] = ["hybrid", "vector", "fts"]; + +/** + * Format a relative time string from a date + */ +function formatRelativeTime(dateStr: string | null | undefined): string { + if (!dateStr) return ""; + + const date = new Date(dateStr); + const now = new Date(); + const diffMs = now.getTime() - date.getTime(); + const diffMins = Math.floor(diffMs / 60000); + const diffHours = Math.floor(diffMs / 3600000); + const diffDays = Math.floor(diffMs / 86400000); + const diffWeeks = Math.floor(diffDays / 7); + + if (diffMins < 1) return "just now"; + if (diffMins < 60) return `${diffMins}m ago`; + if (diffHours < 24) return `${diffHours}h ago`; + if (diffDays < 7) return `${diffDays}d ago`; + return `${diffWeeks}w ago`; +} + +/** + * 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 }: MessageSearchProps) { + const terminalWidth = useTerminalWidth(); + const [searchInput, setSearchInput] = useState(""); + const [activeQuery, setActiveQuery] = useState(""); + const [searchMode, setSearchMode] = useState("hybrid"); + const [results, setResults] = useState([]); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + const [currentPage, setCurrentPage] = useState(0); + const [selectedIndex, setSelectedIndex] = useState(0); + const clientRef = useRef(null); + + // Execute search + const executeSearch = useCallback(async (query: string, mode: SearchMode) => { + if (!query.trim()) return; + + setLoading(true); + setError(null); + + try { + const client = clientRef.current || (await getClient()); + clientRef.current = client; + + const searchResults = await client.messages.search({ + query: query.trim(), + search_mode: mode, + limit: SEARCH_LIMIT, + }); + + setResults(searchResults); + setCurrentPage(0); + setSelectedIndex(0); + } catch (err) { + setError(err instanceof Error ? err.message : String(err)); + setResults([]); + } finally { + setLoading(false); + } + }, []); + + // Submit search + const submitSearch = useCallback(() => { + if (searchInput.trim() && searchInput !== activeQuery) { + setActiveQuery(searchInput); + executeSearch(searchInput, searchMode); + } + }, [searchInput, activeQuery, searchMode, executeSearch]); + + // Clear search + const clearSearch = useCallback(() => { + setSearchInput(""); + setActiveQuery(""); + setResults([]); + setCurrentPage(0); + setSelectedIndex(0); + }, []); + + // Cycle search mode + const cycleSearchMode = useCallback(() => { + setSearchMode((current) => { + const currentIndex = SEARCH_MODES.indexOf(current); + const nextIndex = (currentIndex + 1) % SEARCH_MODES.length; + return SEARCH_MODES[nextIndex] as SearchMode; + }); + }, []); + + // Re-run search when mode changes (if there's an active query) + useEffect(() => { + if (activeQuery) { + executeSearch(activeQuery, searchMode); + } + }, [searchMode, activeQuery, executeSearch]); + + // Calculate pagination + const totalPages = Math.ceil(results.length / DISPLAY_PAGE_SIZE); + const startIndex = currentPage * DISPLAY_PAGE_SIZE; + const pageResults = results.slice(startIndex, startIndex + DISPLAY_PAGE_SIZE); + + useInput((input, key) => { + if (key.escape) { + if (searchInput || activeQuery) { + clearSearch(); + } else { + onClose(); + } + } else if (key.return) { + submitSearch(); + } else if (key.backspace || key.delete) { + setSearchInput((prev) => prev.slice(0, -1)); + } else if (key.tab) { + // Tab cycles search mode + cycleSearchMode(); + } else if (key.upArrow) { + setSelectedIndex((prev) => Math.max(0, prev - 1)); + } else if (key.downArrow) { + setSelectedIndex((prev) => Math.min(pageResults.length - 1, prev + 1)); + } else if (input === "j" || input === "J") { + // Previous page + if (currentPage > 0) { + setCurrentPage((prev) => prev - 1); + setSelectedIndex(0); + } + } else if (input === "k" || input === "K") { + // Next page + if (currentPage < totalPages - 1) { + setCurrentPage((prev) => prev + 1); + setSelectedIndex(0); + } + } else if (input && !key.ctrl && !key.meta) { + setSearchInput((prev) => prev + input); + } + }); + + return ( + + + + Search messages across all agents + + + + {/* Search input and mode */} + + + Search: + {searchInput ? ( + <> + {searchInput} + {searchInput !== activeQuery && ( + (press Enter to search) + )} + + ) : ( + + (type your query) + + )} + + + Mode: + {SEARCH_MODES.map((mode, i) => ( + + {i > 0 && · } + + {mode} + + + ))} + (Tab to change) + + + + {/* Error state */} + {error && ( + + Error: {error} + + )} + + {/* Loading state */} + {loading && ( + + Searching... + + )} + + {/* No results */} + {!loading && activeQuery && results.length === 0 && ( + + No results found for "{activeQuery}" + + )} + + {/* Results list */} + {!loading && results.length > 0 && ( + + {pageResults.map((msg, index) => { + const isSelected = index === selectedIndex; + const messageText = getMessageText(msg); + // All messages have a date field + const msgWithDate = msg as { date?: string }; + const timestamp = msgWithDate.date + ? formatRelativeTime(msgWithDate.date) + : ""; + const msgType = (msg.message_type || "unknown").replace( + "_message", + "", + ); + + // Calculate available width for message text + const metaWidth = timestamp.length + msgType.length + 10; // padding + const availableWidth = Math.max(20, terminalWidth - metaWidth - 4); + const displayText = truncateText( + messageText.replace(/\n/g, " "), + availableWidth, + ); + + // Use message id + index for guaranteed uniqueness (search can return same message multiple times) + const msgId = "id" in msg ? String(msg.id) : "result"; + const uniqueKey = `${msgId}-${startIndex + index}`; + + return ( + + + + {isSelected ? ">" : " "} + + + + {displayText} + + + + + {msgType} + {timestamp && ` · ${timestamp}`} + + + + ); + })} + + )} + + {/* Footer */} + + {results.length > 0 && ( + + + Page {currentPage + 1}/{totalPages || 1} ({results.length}{" "} + results) + + + )} + + + Type + Enter to search · Tab mode · J/K page · Esc close + + + + + ); +}