feat: model selector search (#651)
Co-authored-by: Letta <noreply@letta.com>
This commit is contained in:
5
bun.lock
5
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=="],
|
||||
|
||||
|
||||
8
package-lock.json
generated
8
package-lock.json
generated
@@ -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": {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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(),
|
||||
|
||||
212
src/cli/App.tsx
212
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" ? (
|
||||
<StatusMessage line={item} />
|
||||
) : item.kind === "separator" ? (
|
||||
<Text dimColor>{"─".repeat(columns)}</Text>
|
||||
<Box marginTop={1}>
|
||||
<Text dimColor>{"─".repeat(columns)}</Text>
|
||||
</Box>
|
||||
) : item.kind === "command" ? (
|
||||
<CommandMessage line={item} />
|
||||
) : item.kind === "bash_command" ? (
|
||||
@@ -9298,6 +9309,185 @@ Plan file path: ${planFilePath}`;
|
||||
<MessageSearch
|
||||
onClose={closeOverlay}
|
||||
initialQuery={searchQuery || undefined}
|
||||
agentId={agentId}
|
||||
conversationId={conversationId}
|
||||
onOpenConversation={async (targetAgentId, targetConvId) => {
|
||||
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<string, unknown>
|
||||
>(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);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
||||
@@ -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<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 [currentPage, setCurrentPage] = useState(0);
|
||||
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());
|
||||
|
||||
// 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<string, unknown> = {
|
||||
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<MessageSearchResponse>(
|
||||
"/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<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);
|
||||
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 (
|
||||
<Box flexDirection="column" gap={1}>
|
||||
<Box>
|
||||
<Text bold color={colors.selector.title}>
|
||||
Search messages across all agents
|
||||
</Text>
|
||||
</Box>
|
||||
const solidLine = SOLID_LINE.repeat(Math.max(terminalWidth, 10));
|
||||
|
||||
{/* Search input and mode */}
|
||||
<Box flexDirection="column">
|
||||
<Box>
|
||||
<Text dimColor>Search: </Text>
|
||||
{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 (
|
||||
<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 (
|
||||
<>
|
||||
<Text>{searchInput}</Text>
|
||||
{searchInput !== activeQuery && (
|
||||
<Text dimColor> (press Enter to search)</Text>
|
||||
)}
|
||||
{/* 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>
|
||||
</>
|
||||
) : (
|
||||
<Text dimColor italic>
|
||||
(type your query)
|
||||
</Text>
|
||||
)}
|
||||
);
|
||||
})()}
|
||||
|
||||
{/* 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>
|
||||
<Box>
|
||||
<Text dimColor>Mode: </Text>
|
||||
{SEARCH_MODES.map((mode, i) => (
|
||||
<Text key={mode}>
|
||||
{i > 0 && <Text dimColor> · </Text>}
|
||||
<Text
|
||||
bold={mode === searchMode}
|
||||
color={
|
||||
mode === searchMode
|
||||
? colors.selector.itemHighlighted
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
{mode}
|
||||
</Text>
|
||||
</Text>
|
||||
))}
|
||||
<Text dimColor> (Tab to change)</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* Error state */}
|
||||
{error && (
|
||||
<Box>
|
||||
{!expandedMessage && error && (
|
||||
<Box paddingLeft={2}>
|
||||
<Text color="red">Error: {error}</Text>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* Loading state */}
|
||||
{loading && (
|
||||
<Box>
|
||||
{!expandedMessage && loading && (
|
||||
<Box paddingLeft={2}>
|
||||
<Text dimColor>Searching...</Text>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* No results */}
|
||||
{!loading && activeQuery && results.length === 0 && (
|
||||
<Box>
|
||||
{!expandedMessage && !loading && activeQuery && results.length === 0 && (
|
||||
<Box paddingLeft={2}>
|
||||
<Text dimColor>No results found for "{activeQuery}"</Text>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* Results list */}
|
||||
{!loading && results.length > 0 && (
|
||||
{!expandedMessage && !loading && results.length > 0 && (
|
||||
<Box flexDirection="column">
|
||||
{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 (
|
||||
<Box key={uniqueKey} flexDirection="column" marginBottom={1}>
|
||||
@@ -353,9 +600,10 @@ export function MessageSearch({ onClose, initialQuery }: MessageSearchProps) {
|
||||
>
|
||||
{isSelected ? ">" : " "}
|
||||
</Text>
|
||||
<Text> </Text>
|
||||
<Text> {emoji} </Text>
|
||||
<Text
|
||||
bold={isSelected}
|
||||
italic
|
||||
color={
|
||||
isSelected ? colors.selector.itemHighlighted : undefined
|
||||
}
|
||||
@@ -365,26 +613,9 @@ export function MessageSearch({ onClose, initialQuery }: MessageSearchProps) {
|
||||
</Box>
|
||||
<Box flexDirection="row" marginLeft={2}>
|
||||
<Text dimColor>
|
||||
{msgType}
|
||||
{timestamp && ` · ${timestamp}`}
|
||||
{createdAt}
|
||||
{idToShow && ` · ${idToShow}`}
|
||||
</Text>
|
||||
{agentId && (
|
||||
<>
|
||||
<Text dimColor> · </Text>
|
||||
<Link
|
||||
url={`https://app.letta.com/projects/default-project/agents/${agentId}?searchTerm=${encodeURIComponent(activeQuery)}&messageId=${msgId}${conversationId ? `&conversation=${encodeURIComponent(conversationId)}` : ""}`}
|
||||
>
|
||||
<Text color={colors.link.text}>view message</Text>
|
||||
</Link>
|
||||
<Text dimColor> · agent: </Text>
|
||||
<Link
|
||||
url={`https://app.letta.com/projects/default-project/agents/${agentId}${conversationId ? `?conversation=${encodeURIComponent(conversationId)}` : ""}`}
|
||||
>
|
||||
<Text color={colors.link.text}>{agentId}</Text>
|
||||
</Link>
|
||||
</>
|
||||
)}
|
||||
{createdAt && <Text dimColor> · {createdAt}</Text>}
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
@@ -394,21 +625,16 @@ export function MessageSearch({ onClose, initialQuery }: MessageSearchProps) {
|
||||
)}
|
||||
|
||||
{/* Footer */}
|
||||
<Box flexDirection="column" marginTop={1}>
|
||||
{results.length > 0 && (
|
||||
<Box>
|
||||
{!expandedMessage && (
|
||||
<Box flexDirection="column" marginTop={1} paddingLeft={2}>
|
||||
{results.length > 0 && (
|
||||
<Text dimColor>
|
||||
Page {currentPage + 1}/{totalPages || 1} ({results.length}{" "}
|
||||
results)
|
||||
{selectedIndex + 1}/{results.length} results
|
||||
</Text>
|
||||
</Box>
|
||||
)}
|
||||
<Box>
|
||||
<Text dimColor>
|
||||
Type + Enter to search · Tab mode · J/K page · Esc close
|
||||
</Text>
|
||||
)}
|
||||
<Text dimColor>Enter expand · ↑↓ navigate · Esc close</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user