4
bun.lock
4
bun.lock
@@ -4,7 +4,7 @@
|
|||||||
"": {
|
"": {
|
||||||
"name": "@letta-ai/letta-code",
|
"name": "@letta-ai/letta-code",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@letta-ai/letta-client": "^1.1.2",
|
"@letta-ai/letta-client": "^1.3.3",
|
||||||
"glob": "^13.0.0",
|
"glob": "^13.0.0",
|
||||||
"ink-link": "^5.0.0",
|
"ink-link": "^5.0.0",
|
||||||
"open": "^10.2.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=="],
|
"@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=="],
|
"@types/bun": ["@types/bun@1.3.1", "", { "dependencies": { "bun-types": "1.3.1" } }, "sha512-4jNMk2/K9YJtfqwoAa28c8wK+T7nvJFOjxI4h/7sORWcypRNxBpr+TPNaCfVWq70tLCJsqoFwcf0oI0JU/fvMQ=="],
|
||||||
|
|
||||||
|
|||||||
@@ -22,7 +22,7 @@
|
|||||||
"access": "public"
|
"access": "public"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@letta-ai/letta-client": "^1.1.2",
|
"@letta-ai/letta-client": "^1.3.3",
|
||||||
"glob": "^13.0.0",
|
"glob": "^13.0.0",
|
||||||
"ink-link": "^5.0.0",
|
"ink-link": "^5.0.0",
|
||||||
"open": "^10.2.0"
|
"open": "^10.2.0"
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ import { CommandMessage } from "./components/CommandMessage";
|
|||||||
import { EnterPlanModeDialog } from "./components/EnterPlanModeDialog";
|
import { EnterPlanModeDialog } from "./components/EnterPlanModeDialog";
|
||||||
import { ErrorMessage } from "./components/ErrorMessageRich";
|
import { ErrorMessage } from "./components/ErrorMessageRich";
|
||||||
import { Input } from "./components/InputRich";
|
import { Input } from "./components/InputRich";
|
||||||
|
import { MessageSearch } from "./components/MessageSearch";
|
||||||
import { ModelSelector } from "./components/ModelSelector";
|
import { ModelSelector } from "./components/ModelSelector";
|
||||||
import { PlanModeDialog } from "./components/PlanModeDialog";
|
import { PlanModeDialog } from "./components/PlanModeDialog";
|
||||||
import { QuestionDialog } from "./components/QuestionDialog";
|
import { QuestionDialog } from "./components/QuestionDialog";
|
||||||
@@ -330,6 +331,7 @@ export default function App({
|
|||||||
|
|
||||||
// Resume selector state
|
// Resume selector state
|
||||||
const [resumeSelectorOpen, setResumeSelectorOpen] = useState(false);
|
const [resumeSelectorOpen, setResumeSelectorOpen] = useState(false);
|
||||||
|
const [messageSearchOpen, setMessageSearchOpen] = useState(false);
|
||||||
|
|
||||||
// Token streaming preference (can be toggled at runtime)
|
// Token streaming preference (can be toggled at runtime)
|
||||||
const [tokenStreamingEnabled, setTokenStreamingEnabled] =
|
const [tokenStreamingEnabled, setTokenStreamingEnabled] =
|
||||||
@@ -1572,6 +1574,12 @@ export default function App({
|
|||||||
return { submitted: true };
|
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
|
// Special handling for /swap command - alias for /resume
|
||||||
if (msg.trim().startsWith("/swap")) {
|
if (msg.trim().startsWith("/swap")) {
|
||||||
const parts = msg.trim().split(/\s+/);
|
const parts = msg.trim().split(/\s+/);
|
||||||
@@ -3299,7 +3307,8 @@ Plan file path: ${planFilePath}`;
|
|||||||
!toolsetSelectorOpen &&
|
!toolsetSelectorOpen &&
|
||||||
!systemPromptSelectorOpen &&
|
!systemPromptSelectorOpen &&
|
||||||
!agentSelectorOpen &&
|
!agentSelectorOpen &&
|
||||||
!resumeSelectorOpen
|
!resumeSelectorOpen &&
|
||||||
|
!messageSearchOpen
|
||||||
}
|
}
|
||||||
streaming={
|
streaming={
|
||||||
streaming && !abortControllerRef.current?.signal.aborted
|
streaming && !abortControllerRef.current?.signal.aborted
|
||||||
@@ -3372,6 +3381,11 @@ Plan file path: ${planFilePath}`;
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Message Search - conditionally mounted as overlay */}
|
||||||
|
{messageSearchOpen && (
|
||||||
|
<MessageSearch onClose={() => setMessageSearchOpen(false)} />
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Plan Mode Dialog - for ExitPlanMode tool */}
|
{/* Plan Mode Dialog - for ExitPlanMode tool */}
|
||||||
{currentApproval?.toolName === "ExitPlanMode" && (
|
{currentApproval?.toolName === "ExitPlanMode" && (
|
||||||
<>
|
<>
|
||||||
|
|||||||
@@ -143,6 +143,13 @@ export const commands: Record<string, Command> = {
|
|||||||
return "Opening session selector...";
|
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...";
|
||||||
|
},
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
347
src/cli/components/MessageSearch.tsx
Normal file
347
src/cli/components/MessageSearch.tsx
Normal file
@@ -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<SearchMode>("hybrid");
|
||||||
|
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 clientRef = useRef<Letta | null>(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 (
|
||||||
|
<Box flexDirection="column" gap={1}>
|
||||||
|
<Box>
|
||||||
|
<Text bold color={colors.selector.title}>
|
||||||
|
Search messages across all agents
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* Search input and mode */}
|
||||||
|
<Box flexDirection="column">
|
||||||
|
<Box>
|
||||||
|
<Text dimColor>Search: </Text>
|
||||||
|
{searchInput ? (
|
||||||
|
<>
|
||||||
|
<Text>{searchInput}</Text>
|
||||||
|
{searchInput !== activeQuery && (
|
||||||
|
<Text dimColor> (press Enter to search)</Text>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<Text dimColor italic>
|
||||||
|
(type your query)
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</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>
|
||||||
|
<Text color="red">Error: {error}</Text>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Loading state */}
|
||||||
|
{loading && (
|
||||||
|
<Box>
|
||||||
|
<Text dimColor>Searching...</Text>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* No results */}
|
||||||
|
{!loading && activeQuery && results.length === 0 && (
|
||||||
|
<Box>
|
||||||
|
<Text dimColor>No results found for "{activeQuery}"</Text>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Results list */}
|
||||||
|
{!loading && results.length > 0 && (
|
||||||
|
<Box flexDirection="column">
|
||||||
|
{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 (
|
||||||
|
<Box key={uniqueKey} flexDirection="column" marginBottom={1}>
|
||||||
|
<Box flexDirection="row">
|
||||||
|
<Text
|
||||||
|
color={
|
||||||
|
isSelected ? colors.selector.itemHighlighted : undefined
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{isSelected ? ">" : " "}
|
||||||
|
</Text>
|
||||||
|
<Text> </Text>
|
||||||
|
<Text
|
||||||
|
bold={isSelected}
|
||||||
|
color={
|
||||||
|
isSelected ? colors.selector.itemHighlighted : undefined
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{displayText}
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
<Box flexDirection="row" marginLeft={2}>
|
||||||
|
<Text dimColor>
|
||||||
|
{msgType}
|
||||||
|
{timestamp && ` · ${timestamp}`}
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Footer */}
|
||||||
|
<Box flexDirection="column" marginTop={1}>
|
||||||
|
{results.length > 0 && (
|
||||||
|
<Box>
|
||||||
|
<Text dimColor>
|
||||||
|
Page {currentPage + 1}/{totalPages || 1} ({results.length}{" "}
|
||||||
|
results)
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
<Box>
|
||||||
|
<Text dimColor>
|
||||||
|
Type + Enter to search · Tab mode · J/K page · Esc close
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user