From c97591eaf2fdd5927e9d7d1dc7fb801999e139a9 Mon Sep 17 00:00:00 2001 From: Charles Packer Date: Thu, 22 Jan 2026 21:54:36 -0800 Subject: [PATCH] feat: model selector search (#651) Co-authored-by: Letta --- bun.lock | 5 +- package-lock.json | 8 +- package.json | 2 +- src/agent/message.ts | 2 +- src/cli/App.tsx | 212 +++++++++- src/cli/components/MessageSearch.tsx | 586 +++++++++++++++++++-------- 6 files changed, 615 insertions(+), 200 deletions(-) diff --git a/bun.lock b/bun.lock index f41c82d..5f16797 100644 --- a/bun.lock +++ b/bun.lock @@ -1,11 +1,10 @@ { "lockfileVersion": 1, - "configVersion": 0, "workspaces": { "": { "name": "@letta-ai/letta-code", "dependencies": { - "@letta-ai/letta-client": "^1.7.2", + "@letta-ai/letta-client": "1.7.5", "glob": "^13.0.0", "ink-link": "^5.0.0", "open": "^10.2.0", @@ -91,7 +90,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.7.2", "", {}, "sha512-88XLWwacqTcqL1uZ8QCKmxPIzTlgHAPVbyxVrNLMKeDdqp6Vs7y1WIRFBdEvGy2WJmUgOt5HTUJSnJZevhPX7A=="], + "@letta-ai/letta-client": ["@letta-ai/letta-client@1.7.5", "", {}, "sha512-fyzJ9Bj+8Jf/LGDsPoijwKkddXJl3lII8FDUNkQipV6MQS6vgR+7vrL0QtwMgpwXZr1f47MNb5+Y0O1/TDDsJA=="], "@types/bun": ["@types/bun@1.3.1", "", { "dependencies": { "bun-types": "1.3.1" } }, "sha512-4jNMk2/K9YJtfqwoAa28c8wK+T7nvJFOjxI4h/7sORWcypRNxBpr+TPNaCfVWq70tLCJsqoFwcf0oI0JU/fvMQ=="], diff --git a/package-lock.json b/package-lock.json index 14a0450..93f1ee3 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,7 +10,7 @@ "hasInstallScript": true, "license": "Apache-2.0", "dependencies": { - "@letta-ai/letta-client": "^1.7.2", + "@letta-ai/letta-client": "^1.7.4", "glob": "^13.0.0", "ink-link": "^5.0.0", "open": "^10.2.0", @@ -550,9 +550,9 @@ } }, "node_modules/@letta-ai/letta-client": { - "version": "1.7.2", - "resolved": "https://registry.npmjs.org/@letta-ai/letta-client/-/letta-client-1.7.2.tgz", - "integrity": "sha512-88XLWwacqTcqL1uZ8QCKmxPIzTlgHAPVbyxVrNLMKeDdqp6Vs7y1WIRFBdEvGy2WJmUgOt5HTUJSnJZevhPX7A==", + "version": "1.7.4", + "resolved": "https://registry.npmjs.org/@letta-ai/letta-client/-/letta-client-1.7.4.tgz", + "integrity": "sha512-TLaXDUDCuW6f0LVEOX7wskjlw5MBqkIozz/JPnVhuK2Rn8ZKns5qVQeZUYG5a536+nxmoGT9ZgkGkXAVrN9WKQ==", "license": "Apache-2.0" }, "node_modules/@types/bun": { diff --git a/package.json b/package.json index 9078c15..c381c09 100644 --- a/package.json +++ b/package.json @@ -30,7 +30,7 @@ "access": "public" }, "dependencies": { - "@letta-ai/letta-client": "^1.7.2", + "@letta-ai/letta-client": "1.7.5", "glob": "^13.0.0", "ink-link": "^5.0.0", "open": "^10.2.0", diff --git a/src/agent/message.ts b/src/agent/message.ts index f964430..6b7bb51 100644 --- a/src/agent/message.ts +++ b/src/agent/message.ts @@ -81,7 +81,7 @@ export async function sendMessageStream( conversationId, { messages: messages, - streaming: true, + stream: true, stream_tokens: opts.streamTokens ?? true, background: opts.background ?? true, client_tools: getClientToolsFromRegistry(), diff --git a/src/cli/App.tsx b/src/cli/App.tsx index 248b4ff..c31bab5 100644 --- a/src/cli/App.tsx +++ b/src/cli/App.tsx @@ -3670,7 +3670,10 @@ export default function App({ }, [processConversation]); const handleAgentSelect = useCallback( - async (targetAgentId: string, _opts?: { profileName?: string }) => { + async ( + targetAgentId: string, + opts?: { profileName?: string; conversationId?: string }, + ) => { // Close selector immediately setActiveOverlay(null); @@ -3733,9 +3736,8 @@ export default function App({ // Fetch new agent const agent = await client.agents.retrieve(targetAgentId); - // Use the agent's default conversation when switching agents - // User can /new to start a fresh conversation if needed - const targetConversationId = "default"; + // Use specified conversation or default to the agent's default conversation + const targetConversationId = opts?.conversationId ?? "default"; // Update project settings with new agent await updateProjectSettings({ lastAgent: targetAgentId }); @@ -3768,13 +3770,20 @@ export default function App({ setLlmConfig(agent.llm_config); setConversationId(targetConversationId); - // Build success message - resumed default conversation + // Build success message const agentLabel = agent.name || targetAgentId; - const successOutput = [ - `Resumed the default conversation with **${agentLabel}**.`, - `⎿ Type /resume to browse all conversations`, - `⎿ Type /new to start a new conversation`, - ].join("\n"); + const isSpecificConv = + opts?.conversationId && opts.conversationId !== "default"; + const successOutput = isSpecificConv + ? [ + `Switched to **${agentLabel}**`, + `⎿ Conversation: ${opts.conversationId}`, + ].join("\n") + : [ + `Resumed the default conversation with **${agentLabel}**.`, + `⎿ Type /resume to browse all conversations`, + `⎿ Type /new to start a new conversation`, + ].join("\n"); const successItem: StaticItem = { kind: "command", id: uid("cmd"), @@ -8693,7 +8702,9 @@ Plan file path: ${planFilePath}`; ) : item.kind === "status" ? ( ) : item.kind === "separator" ? ( - {"─".repeat(columns)} + + {"─".repeat(columns)} + ) : item.kind === "command" ? ( ) : item.kind === "bash_command" ? ( @@ -9298,6 +9309,185 @@ Plan file path: ${planFilePath}`; { + closeOverlay(); + + // Different agent: use handleAgentSelect (which supports optional conversationId) + if (targetAgentId !== agentId) { + await handleAgentSelect(targetAgentId, { + conversationId: targetConvId, + }); + return; + } + + // Normalize undefined/null to "default" + const actualTargetConv = targetConvId || "default"; + + // Same agent, same conversation: nothing to do + if (actualTargetConv === conversationId) { + return; + } + + // Same agent, different conversation: switch conversation + // (Reuses ConversationSelector's onSelect logic pattern) + if (isAgentBusy()) { + setQueuedOverlayAction({ + type: "switch_conversation", + conversationId: actualTargetConv, + }); + const cmdId = uid("cmd"); + buffersRef.current.byId.set(cmdId, { + kind: "command", + id: cmdId, + input: "/search", + output: `Conversation switch queued – will switch after current task completes`, + phase: "finished", + success: true, + }); + buffersRef.current.order.push(cmdId); + refreshDerived(); + return; + } + + setCommandRunning(true); + const cmdId = uid("cmd"); + buffersRef.current.byId.set(cmdId, { + kind: "command", + id: cmdId, + input: "/search", + output: "Switching conversation...", + phase: "running", + }); + buffersRef.current.order.push(cmdId); + refreshDerived(); + + try { + if (agentState) { + const client = await getClient(); + const resumeData = await getResumeData( + client, + agentState, + actualTargetConv, + ); + + setConversationId(actualTargetConv); + settingsManager.setLocalLastSession( + { agentId, conversationId: actualTargetConv }, + process.cwd(), + ); + settingsManager.setGlobalLastSession({ + agentId, + conversationId: actualTargetConv, + }); + + // Clear current transcript and static items + buffersRef.current.byId.clear(); + buffersRef.current.order = []; + buffersRef.current.tokenCount = 0; + emittedIdsRef.current.clear(); + setStaticItems([]); + setStaticRenderEpoch((e) => e + 1); + + const currentAgentName = + agentState.name || "Unnamed Agent"; + const successOutput = [ + `Switched to conversation with "${currentAgentName}"`, + `⎿ Conversation: ${actualTargetConv}`, + ].join("\n"); + const successItem: StaticItem = { + kind: "command", + id: uid("cmd"), + input: "/search", + output: successOutput, + phase: "finished", + success: true, + }; + + // Backfill message history + if (resumeData.messageHistory.length > 0) { + hasBackfilledRef.current = false; + backfillBuffers( + buffersRef.current, + resumeData.messageHistory, + ); + const backfilledItems: StaticItem[] = []; + for (const id of buffersRef.current.order) { + const ln = buffersRef.current.byId.get(id); + if (!ln) continue; + emittedIdsRef.current.add(id); + backfilledItems.push({ ...ln } as StaticItem); + } + const separator = { + kind: "separator" as const, + id: uid("sep"), + }; + setStaticItems([ + separator, + ...backfilledItems, + successItem, + ]); + setLines(toLines(buffersRef.current)); + hasBackfilledRef.current = true; + } else { + const separator = { + kind: "separator" as const, + id: uid("sep"), + }; + setStaticItems([separator, successItem]); + setLines(toLines(buffersRef.current)); + } + + // Restore pending approvals if any + if (resumeData.pendingApprovals.length > 0) { + setPendingApprovals(resumeData.pendingApprovals); + try { + const contexts = await Promise.all( + resumeData.pendingApprovals.map( + async (approval) => { + const parsedArgs = safeJsonParseOr< + Record + >(approval.toolArgs, {}); + return await analyzeToolApproval( + approval.toolName, + parsedArgs, + ); + }, + ), + ); + setApprovalContexts(contexts); + } catch { + // If analysis fails, leave context as null + } + } + } + } catch (error) { + let errorMsg = "Unknown error"; + if (error instanceof APIError) { + if (error.status === 404) { + errorMsg = "Conversation not found"; + } else if (error.status === 422) { + errorMsg = "Invalid conversation ID"; + } else { + errorMsg = error.message; + } + } else if (error instanceof Error) { + errorMsg = error.message; + } + buffersRef.current.byId.set(cmdId, { + kind: "command", + id: cmdId, + input: "/search", + output: `Failed: ${errorMsg}`, + phase: "finished", + success: false, + }); + refreshDerived(); + } finally { + setCommandRunning(false); + } + }} /> )} diff --git a/src/cli/components/MessageSearch.tsx b/src/cli/components/MessageSearch.tsx index 5389bef..5148a6b 100644 --- a/src/cli/components/MessageSearch.tsx +++ b/src/cli/components/MessageSearch.tsx @@ -1,43 +1,33 @@ 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 Link from "ink-link"; import { useCallback, useEffect, useRef, useState } from "react"; import { getClient } from "../../agent/client"; import { useTerminalWidth } from "../hooks/useTerminalWidth"; import { colors } from "./colors"; +// 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 DISPLAY_PAGE_SIZE = 5; +const VISIBLE_ITEMS = 5; const SEARCH_LIMIT = 100; // Max results from API type SearchMode = "hybrid" | "vector" | "fts"; -const SEARCH_MODES: SearchMode[] = ["hybrid", "vector", "fts"]; +const SEARCH_MODES: SearchMode[] = ["fts", "vector", "hybrid"]; // Display order (hybrid is default) -/** - * 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`; -} +type SearchRange = "all" | "agent" | "conv"; +const SEARCH_RANGES: SearchRange[] = ["all", "agent", "conv"]; /** * Format a timestamp in local timezone @@ -110,89 +100,234 @@ function getMessageText(msg: MessageSearchResponse[number]): string { return `[${msg.message_type || "unknown"}]`; } -export function MessageSearch({ onClose, initialQuery }: MessageSearchProps) { +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("hybrid"); + const [searchRange, setSearchRange] = useState("all"); 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 [expandedMessage, setExpandedMessage] = useState< + MessageSearchResponse[number] | null + >(null); const clientRef = useRef(null); + // Cache results per query+mode+range combination to avoid re-fetching + const resultsCache = useRef>(new Map()); - // Execute search - const executeSearch = useCallback(async (query: string, mode: SearchMode) => { - if (!query.trim()) return; + // 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], + ); - setLoading(true); - setError(null); + // 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 = { + query: query.trim(), + search_mode: mode, + limit: SEARCH_LIMIT, + }; - try { - const client = clientRef.current || (await getClient()); - clientRef.current = client; + // Add filters based on range + if (range === "agent" && agentId) { + body.agent_id = agentId; + } else if (range === "conv" && conversationId) { + body.conversation_id = conversationId; + } - // Direct API call since client.messages.search doesn't exist yet in SDK const searchResults = await client.post( "/v1/messages/search", - { - body: { - query: query.trim(), - search_mode: mode, - limit: SEARCH_LIMIT, - }, - }, + { body }, ); + return searchResults; + }, + [agentId, conversationId], + ); - setResults(searchResults); - setCurrentPage(0); - setSelectedIndex(0); - } catch (err) { - setError(err instanceof Error ? err.message : String(err)); - setResults([]); - } finally { - setLoading(false); - } - }, []); + // 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; - // Submit search + 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 + > = { + 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); + executeSearch(searchInput, searchMode, searchRange); } - }, [searchInput, activeQuery, searchMode, executeSearch]); + }, [searchInput, activeQuery, searchMode, searchRange, executeSearch]); // Clear search const clearSearch = useCallback(() => { setSearchInput(""); setActiveQuery(""); setResults([]); - setCurrentPage(0); setSelectedIndex(0); }, []); - // Cycle search mode - const cycleSearchMode = useCallback(() => { + // Cycle search mode (Shift+Tab) + const cycleSearchMode = useCallback((reverse = false) => { setSearchMode((current) => { const currentIndex = SEARCH_MODES.indexOf(current); - const nextIndex = (currentIndex + 1) % SEARCH_MODES.length; + const nextIndex = reverse + ? (currentIndex - 1 + SEARCH_MODES.length) % SEARCH_MODES.length + : (currentIndex + 1) % SEARCH_MODES.length; return SEARCH_MODES[nextIndex] as SearchMode; }); }, []); - // Re-run search when mode changes (if there's an active query) + // 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); + executeSearch(activeQuery, searchMode, searchRange); } - }, [searchMode, activeQuery, executeSearch]); + }, [searchMode, searchRange, 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); + // 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) @@ -201,6 +336,22 @@ export function MessageSearch({ onClose, initialQuery }: MessageSearchProps) { 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(); @@ -208,106 +359,201 @@ export function MessageSearch({ onClose, initialQuery }: MessageSearchProps) { onClose(); } } else if (key.return) { - submitSearch(); + // 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) { - // Tab cycles search mode + } 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(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); - } + setSelectedIndex((prev) => Math.min(results.length - 1, prev + 1)); } else if (input && !key.ctrl && !key.meta) { setSearchInput((prev) => prev + input); } }); - return ( - - - - Search messages across all agents - - + const solidLine = SOLID_LINE.repeat(Math.max(terminalWidth, 10)); - {/* Search input and mode */} - - - Search: - {searchInput ? ( + // 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 ( + + {/* Command header */} + {"> /search"} + {solidLine} + + + + {/* 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 ( <> - {searchInput} - {searchInput !== activeQuery && ( - (press Enter to search) - )} + {/* Full message text in quotes */} + + "{fullText}" + + + + + {/* Metadata */} + + + {typeLabel}, sent {timestamp} + + Agent ID: {msgData.agent_id || "unknown"} + {msgData.conversation_id && ( + Conv ID: {msgData.conversation_id} + )} + + + + + {/* Footer */} + + + {onOpenConversation + ? "Enter to open conversation · Esc cancel" + : "Esc cancel"} + + - ) : ( - - (type your query) - - )} + ); + })()} + + {/* Title and search controls - hidden when expanded */} + {!expandedMessage && ( + + + Search messages across all agents + + + {/* Search input */} + + Search: + {searchInput ? ( + <> + {searchInput} + {searchInput !== activeQuery && ( + (press Enter to search) + )} + + ) : ( + (type to search) + )} + + + + + {/* Range tabs */} + + Range (tab): + {SEARCH_RANGES.map((range, i) => { + const isActive = range === searchRange; + return ( + + {i > 0 && } + + {` ${getRangeLabel(range)} `} + + + ); + })} + + + {/* Mode tabs */} + + Mode (shift-tab): + {SEARCH_MODES.map((mode, i) => { + const isActive = mode === searchMode; + return ( + + {i > 0 && } + + {` ${mode} `} + + + ); + })} + + - - Mode: - {SEARCH_MODES.map((mode, i) => ( - - {i > 0 && · } - - {mode} - - - ))} - (Tab to change) - - + )} {/* Error state */} - {error && ( - + {!expandedMessage && error && ( + Error: {error} )} {/* Loading state */} - {loading && ( - + {!expandedMessage && loading && ( + Searching... )} {/* No results */} - {!loading && activeQuery && results.length === 0 && ( - + {!expandedMessage && !loading && activeQuery && results.length === 0 && ( + No results found for "{activeQuery}" )} {/* Results list */} - {!loading && results.length > 0 && ( + {!expandedMessage && !loading && results.length > 0 && ( - {pageResults.map( - (msg: MessageSearchResponse[number], index: number) => { - const isSelected = index === selectedIndex; + {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 { @@ -316,32 +562,33 @@ export function MessageSearch({ onClose, initialQuery }: MessageSearchProps) { agent_id?: string; conversation_id?: string; }; - const timestamp = msgWithDate.date - ? formatRelativeTime(msgWithDate.date) - : ""; - const msgType = (msg.message_type || "unknown").replace( - "_message", - "", + 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, ); - const agentId = msgWithDate.agent_id || "unknown"; - const conversationId = msgWithDate.conversation_id; - const createdAt = formatLocalTime(msgWithDate.created_at); - // Calculate available width for message text - const metaWidth = timestamp.length + msgType.length + 10; // padding - const availableWidth = Math.max( - 20, - terminalWidth - metaWidth - 4, - ); + // 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}-${startIndex + index}`; + const uniqueKey = `${msgId}-${actualIndex}`; return ( @@ -353,9 +600,10 @@ export function MessageSearch({ onClose, initialQuery }: MessageSearchProps) { > {isSelected ? ">" : " "} - + {emoji} - {msgType} - {timestamp && ` · ${timestamp}`} + {createdAt} + {idToShow && ` · ${idToShow}`} - {agentId && ( - <> - · - - view message - - · agent: - - {agentId} - - - )} - {createdAt && · {createdAt}} ); @@ -394,21 +625,16 @@ export function MessageSearch({ onClose, initialQuery }: MessageSearchProps) { )} {/* Footer */} - - {results.length > 0 && ( - + {!expandedMessage && ( + + {results.length > 0 && ( - Page {currentPage + 1}/{totalPages || 1} ({results.length}{" "} - results) + {selectedIndex + 1}/{results.length} results - - )} - - - Type + Enter to search · Tab mode · J/K page · Esc close - + )} + Enter expand · ↑↓ navigate · Esc close - + )} ); }