feat: add interactive ProfileSelector UI for /profile command (#208)
Co-authored-by: Letta <noreply@letta.com>
This commit is contained in:
@@ -33,7 +33,6 @@ import {
|
||||
import {
|
||||
addCommandResult,
|
||||
handleProfileDelete,
|
||||
handleProfileList,
|
||||
handleProfileSave,
|
||||
handleProfileUsage,
|
||||
type ProfileCommandContext,
|
||||
@@ -49,6 +48,7 @@ import { Input } from "./components/InputRich";
|
||||
import { MessageSearch } from "./components/MessageSearch";
|
||||
import { ModelSelector } from "./components/ModelSelector";
|
||||
import { PlanModeDialog } from "./components/PlanModeDialog";
|
||||
import { ProfileSelector } from "./components/ProfileSelector";
|
||||
import { QuestionDialog } from "./components/QuestionDialog";
|
||||
import { ReasoningMessage } from "./components/ReasoningMessageRich";
|
||||
import { ResumeSelector } from "./components/ResumeSelector";
|
||||
@@ -364,6 +364,9 @@ export default function App({
|
||||
const [resumeSelectorOpen, setResumeSelectorOpen] = useState(false);
|
||||
const [messageSearchOpen, setMessageSearchOpen] = useState(false);
|
||||
|
||||
// Profile selector state
|
||||
const [profileSelectorOpen, setProfileSelectorOpen] = useState(false);
|
||||
|
||||
// Token streaming preference (can be toggled at runtime)
|
||||
const [tokenStreamingEnabled, setTokenStreamingEnabled] =
|
||||
useState(tokenStreaming);
|
||||
@@ -1264,6 +1267,26 @@ export default function App({
|
||||
async (targetAgentId: string, opts?: { profileName?: string }) => {
|
||||
setAgentSelectorOpen(false);
|
||||
|
||||
// Skip if already on this agent
|
||||
if (targetAgentId === agentId) {
|
||||
const isProfileLoad = !!opts?.profileName;
|
||||
const label = isProfileLoad ? opts.profileName : targetAgentId;
|
||||
const cmdId = uid("cmd");
|
||||
buffersRef.current.byId.set(cmdId, {
|
||||
kind: "command",
|
||||
id: cmdId,
|
||||
input: isProfileLoad
|
||||
? `/profile load ${opts.profileName}`
|
||||
: `/resume ${targetAgentId}`,
|
||||
output: `Already on "${agentName || label}"`,
|
||||
phase: "finished",
|
||||
success: true,
|
||||
});
|
||||
buffersRef.current.order.push(cmdId);
|
||||
refreshDerived();
|
||||
return;
|
||||
}
|
||||
|
||||
const isProfileLoad = !!opts?.profileName;
|
||||
const inputCmd = isProfileLoad
|
||||
? `/profile load ${opts.profileName}`
|
||||
@@ -1352,7 +1375,7 @@ export default function App({
|
||||
setCommandRunning(false);
|
||||
}
|
||||
},
|
||||
[refreshDerived, agentId],
|
||||
[refreshDerived, agentId, agentName],
|
||||
);
|
||||
|
||||
const onSubmit = useCallback(
|
||||
@@ -1879,9 +1902,9 @@ export default function App({
|
||||
setAgentName,
|
||||
};
|
||||
|
||||
// /profile - list all profiles
|
||||
// /profile - open profile selector
|
||||
if (!subcommand) {
|
||||
handleProfileList(profileCtx, msg);
|
||||
setProfileSelectorOpen(true);
|
||||
return { submitted: true };
|
||||
}
|
||||
|
||||
@@ -3515,6 +3538,7 @@ Plan file path: ${planFilePath}`;
|
||||
!systemPromptSelectorOpen &&
|
||||
!agentSelectorOpen &&
|
||||
!resumeSelectorOpen &&
|
||||
!profileSelectorOpen &&
|
||||
!messageSearchOpen
|
||||
}
|
||||
streaming={
|
||||
@@ -3591,6 +3615,48 @@ Plan file path: ${planFilePath}`;
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Profile Selector - conditionally mounted as overlay */}
|
||||
{profileSelectorOpen && (
|
||||
<ProfileSelector
|
||||
currentAgentId={agentId}
|
||||
onSelect={async (id, profileName) => {
|
||||
setProfileSelectorOpen(false);
|
||||
await handleAgentSelect(id, { profileName });
|
||||
}}
|
||||
onSave={async (profileName) => {
|
||||
setProfileSelectorOpen(false);
|
||||
const profileCtx: ProfileCommandContext = {
|
||||
buffersRef,
|
||||
refreshDerived,
|
||||
agentId,
|
||||
setCommandRunning,
|
||||
setAgentName,
|
||||
};
|
||||
await handleProfileSave(
|
||||
profileCtx,
|
||||
`/profile save ${profileName}`,
|
||||
profileName,
|
||||
);
|
||||
}}
|
||||
onDelete={(profileName) => {
|
||||
setProfileSelectorOpen(false);
|
||||
const profileCtx: ProfileCommandContext = {
|
||||
buffersRef,
|
||||
refreshDerived,
|
||||
agentId,
|
||||
setCommandRunning,
|
||||
setAgentName,
|
||||
};
|
||||
handleProfileDelete(
|
||||
profileCtx,
|
||||
`/profile delete ${profileName}`,
|
||||
profileName,
|
||||
);
|
||||
}}
|
||||
onCancel={() => setProfileSelectorOpen(false)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Message Search - conditionally mounted as overlay */}
|
||||
{messageSearchOpen && (
|
||||
<MessageSearch onClose={() => setMessageSearchOpen(false)} />
|
||||
|
||||
@@ -73,7 +73,7 @@ export const commands: Record<string, Command> = {
|
||||
},
|
||||
},
|
||||
"/toolset": {
|
||||
desc: "Switch toolset (codex/default)",
|
||||
desc: "Switch toolset",
|
||||
handler: () => {
|
||||
// Handled specially in App.tsx to access agent ID and client
|
||||
return "Opening toolset selector...";
|
||||
@@ -87,7 +87,7 @@ export const commands: Record<string, Command> = {
|
||||
},
|
||||
},
|
||||
"/download": {
|
||||
desc: "Download agent file locally",
|
||||
desc: "Download AgentFile (.af)",
|
||||
handler: () => {
|
||||
// Handled specially in App.tsx to access agent ID and client
|
||||
return "Downloading agent file...";
|
||||
@@ -115,7 +115,7 @@ export const commands: Record<string, Command> = {
|
||||
},
|
||||
},
|
||||
"/remember": {
|
||||
desc: "Remember something from the conversation (optionally: /remember <what to remember>)",
|
||||
desc: "Remember something from the conversation",
|
||||
handler: () => {
|
||||
// Handled specially in App.tsx to trigger memory update
|
||||
return "Processing memory request...";
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { Box, Text } from "ink";
|
||||
import Link from "ink-link";
|
||||
import { useMemo } from "react";
|
||||
import { getProfiles } from "../commands/profile";
|
||||
import { commands } from "../commands/registry";
|
||||
import { colors } from "./colors";
|
||||
|
||||
@@ -24,6 +26,16 @@ export function CommandPreview({
|
||||
agentName?: string | null;
|
||||
serverUrl?: string;
|
||||
}) {
|
||||
// Look up if current agent is saved as a profile
|
||||
const profileName = useMemo(() => {
|
||||
if (!agentId) return null;
|
||||
const profiles = getProfiles();
|
||||
for (const [name, id] of Object.entries(profiles)) {
|
||||
if (id === agentId) return name;
|
||||
}
|
||||
return null;
|
||||
}, [agentId]);
|
||||
|
||||
if (!currentInput.startsWith("/")) {
|
||||
return null;
|
||||
}
|
||||
@@ -46,21 +58,32 @@ export function CommandPreview({
|
||||
</Box>
|
||||
))}
|
||||
{showBottomBar && (
|
||||
<Box
|
||||
marginTop={1}
|
||||
paddingTop={1}
|
||||
borderTop
|
||||
borderColor="gray"
|
||||
flexDirection="column"
|
||||
>
|
||||
{agentName && <Text dimColor>Agent: {agentName}</Text>}
|
||||
{isCloudUser ? (
|
||||
<Link url={`https://app.letta.com/agents/${agentId}`}>
|
||||
<Text dimColor>View agent in ADE</Text>
|
||||
</Link>
|
||||
) : (
|
||||
<Text dimColor>Connected to agent located at {serverUrl}</Text>
|
||||
)}
|
||||
<Box marginTop={1} flexDirection="column">
|
||||
<Box>
|
||||
<Text color="gray">Current agent: </Text>
|
||||
<Text bold>{agentName || "Unnamed"}</Text>
|
||||
{profileName ? (
|
||||
<Text color="green"> (profile: {profileName} ✓)</Text>
|
||||
) : (
|
||||
<Text color="gray"> (type /profile to pin agent)</Text>
|
||||
)}
|
||||
</Box>
|
||||
<Box>
|
||||
<Text dimColor>{agentId}</Text>
|
||||
{isCloudUser && (
|
||||
<>
|
||||
<Text dimColor> · </Text>
|
||||
<Link url={`https://app.letta.com/agents/${agentId}`}>
|
||||
<Text color={colors.link.text}>Open in ADE ↗</Text>
|
||||
</Link>
|
||||
<Text dimColor>· </Text>
|
||||
<Link url="https://app.letta.com/settings/organization/usage">
|
||||
<Text color={colors.link.text}>View usage ↗</Text>
|
||||
</Link>
|
||||
</>
|
||||
)}
|
||||
{!isCloudUser && <Text dimColor> · {serverUrl}</Text>}
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
396
src/cli/components/ProfileSelector.tsx
Normal file
396
src/cli/components/ProfileSelector.tsx
Normal file
@@ -0,0 +1,396 @@
|
||||
import type { AgentState } from "@letta-ai/letta-client/resources/agents/agents";
|
||||
import { Box, Text, useInput } from "ink";
|
||||
import { memo, useCallback, useEffect, useState } from "react";
|
||||
import { getClient } from "../../agent/client";
|
||||
import { getProfiles } from "../commands/profile";
|
||||
import { useTerminalWidth } from "../hooks/useTerminalWidth";
|
||||
import { colors } from "./colors";
|
||||
|
||||
interface ProfileSelectorProps {
|
||||
currentAgentId: string;
|
||||
onSelect: (agentId: string, profileName: string) => void;
|
||||
onSave: (profileName: string) => void;
|
||||
onDelete: (profileName: string) => void;
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
interface ProfileData {
|
||||
name: string;
|
||||
agentId: string;
|
||||
agent: AgentState | null;
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
const DISPLAY_PAGE_SIZE = 5;
|
||||
|
||||
/**
|
||||
* Format a relative time string from a date
|
||||
*/
|
||||
function formatRelativeTime(dateStr: string | null | undefined): string {
|
||||
if (!dateStr) return "Never";
|
||||
|
||||
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} minute${diffMins === 1 ? "" : "s"} ago`;
|
||||
if (diffHours < 24)
|
||||
return `${diffHours} hour${diffHours === 1 ? "" : "s"} ago`;
|
||||
if (diffDays < 7) return `${diffDays} day${diffDays === 1 ? "" : "s"} ago`;
|
||||
return `${diffWeeks} week${diffWeeks === 1 ? "" : "s"} ago`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Truncate agent ID with middle ellipsis if it exceeds available width
|
||||
*/
|
||||
function truncateAgentId(id: string, availableWidth: number): string {
|
||||
if (id.length <= availableWidth) return id;
|
||||
if (availableWidth < 15) return id.slice(0, availableWidth);
|
||||
const prefixLen = Math.floor((availableWidth - 3) / 2);
|
||||
const suffixLen = availableWidth - 3 - prefixLen;
|
||||
return `${id.slice(0, prefixLen)}...${id.slice(-suffixLen)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Format model string to show provider/model-name
|
||||
*/
|
||||
function formatModel(agent: AgentState): string {
|
||||
if (agent.model) {
|
||||
return agent.model;
|
||||
}
|
||||
if (agent.llm_config?.model) {
|
||||
const provider = agent.llm_config.model_endpoint_type || "unknown";
|
||||
return `${provider}/${agent.llm_config.model}`;
|
||||
}
|
||||
return "unknown";
|
||||
}
|
||||
|
||||
type Mode = "browsing" | "saving" | "confirming-delete";
|
||||
|
||||
export const ProfileSelector = memo(function ProfileSelector({
|
||||
currentAgentId,
|
||||
onSelect,
|
||||
onSave,
|
||||
onDelete,
|
||||
onCancel,
|
||||
}: ProfileSelectorProps) {
|
||||
const terminalWidth = useTerminalWidth();
|
||||
const [profiles, setProfiles] = useState<ProfileData[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [selectedIndex, setSelectedIndex] = useState(0);
|
||||
const [currentPage, setCurrentPage] = useState(0);
|
||||
const [mode, setMode] = useState<Mode>("browsing");
|
||||
const [saveInput, setSaveInput] = useState("");
|
||||
const [deleteConfirmIndex, setDeleteConfirmIndex] = useState(0);
|
||||
|
||||
// Load profiles and fetch agent data
|
||||
const loadProfiles = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const profilesMap = getProfiles();
|
||||
const profileNames = Object.keys(profilesMap).sort();
|
||||
|
||||
if (profileNames.length === 0) {
|
||||
setProfiles([]);
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const client = await getClient();
|
||||
|
||||
// Fetch agent data for each profile
|
||||
const profileDataPromises = profileNames.map(async (name) => {
|
||||
const agentId = profilesMap[name] as string;
|
||||
try {
|
||||
const agent = await client.agents.retrieve(agentId, {
|
||||
include: ["agent.blocks"],
|
||||
});
|
||||
return { name, agentId, agent, error: null };
|
||||
} catch (_err) {
|
||||
return { name, agentId, agent: null, error: "Agent not found" };
|
||||
}
|
||||
});
|
||||
|
||||
const profileData = await Promise.all(profileDataPromises);
|
||||
setProfiles(profileData);
|
||||
} catch (_err) {
|
||||
setProfiles([]);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
loadProfiles();
|
||||
}, [loadProfiles]);
|
||||
|
||||
// Pagination
|
||||
const totalPages = Math.ceil(profiles.length / DISPLAY_PAGE_SIZE);
|
||||
const startIndex = currentPage * DISPLAY_PAGE_SIZE;
|
||||
const pageProfiles = profiles.slice(
|
||||
startIndex,
|
||||
startIndex + DISPLAY_PAGE_SIZE,
|
||||
);
|
||||
|
||||
// Get currently selected profile
|
||||
const selectedProfile = pageProfiles[selectedIndex];
|
||||
|
||||
useInput((input, key) => {
|
||||
if (loading) return;
|
||||
|
||||
// Handle save mode - capture text input inline (like ResumeSelector)
|
||||
if (mode === "saving") {
|
||||
if (key.return && saveInput.trim()) {
|
||||
// onSave closes the selector
|
||||
onSave(saveInput.trim());
|
||||
return;
|
||||
} else if (key.escape) {
|
||||
setMode("browsing");
|
||||
setSaveInput("");
|
||||
} else if (key.backspace || key.delete) {
|
||||
setSaveInput((prev) => prev.slice(0, -1));
|
||||
} else if (input && !key.ctrl && !key.meta) {
|
||||
setSaveInput((prev) => prev + input);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Handle delete confirmation mode
|
||||
if (mode === "confirming-delete") {
|
||||
if (key.upArrow || key.downArrow) {
|
||||
setDeleteConfirmIndex((prev) => (prev === 0 ? 1 : 0));
|
||||
} else if (key.return) {
|
||||
if (deleteConfirmIndex === 0 && selectedProfile) {
|
||||
// Yes - delete (onDelete closes the selector)
|
||||
onDelete(selectedProfile.name);
|
||||
return;
|
||||
} else {
|
||||
// No - cancel
|
||||
setMode("browsing");
|
||||
}
|
||||
} else if (key.escape) {
|
||||
setMode("browsing");
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Browsing mode
|
||||
if (key.upArrow) {
|
||||
setSelectedIndex((prev) => Math.max(0, prev - 1));
|
||||
} else if (key.downArrow) {
|
||||
setSelectedIndex((prev) => Math.min(pageProfiles.length - 1, prev + 1));
|
||||
} else if (key.return) {
|
||||
if (selectedProfile?.agent) {
|
||||
onSelect(selectedProfile.agentId, selectedProfile.name);
|
||||
}
|
||||
} else if (key.escape) {
|
||||
onCancel();
|
||||
} else if (input === "s" || input === "S") {
|
||||
setMode("saving");
|
||||
setSaveInput("");
|
||||
} else if (input === "d" || input === "D") {
|
||||
if (selectedProfile) {
|
||||
setMode("confirming-delete");
|
||||
setDeleteConfirmIndex(1); // Default to "No"
|
||||
}
|
||||
} 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);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Save mode UI
|
||||
if (mode === "saving") {
|
||||
return (
|
||||
<Box flexDirection="column" gap={1}>
|
||||
<Box>
|
||||
<Text bold color={colors.selector.title}>
|
||||
Save Current Agent as Profile
|
||||
</Text>
|
||||
</Box>
|
||||
<Box flexDirection="column">
|
||||
<Text>Enter profile name (Esc to cancel):</Text>
|
||||
<Box marginTop={1}>
|
||||
<Text>> </Text>
|
||||
<Text>{saveInput}</Text>
|
||||
<Text>█</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
// Delete confirmation UI
|
||||
if (mode === "confirming-delete" && selectedProfile) {
|
||||
const options = ["Yes, delete", "No, cancel"];
|
||||
return (
|
||||
<Box flexDirection="column" gap={1}>
|
||||
<Box>
|
||||
<Text bold color={colors.selector.title}>
|
||||
Delete Profile
|
||||
</Text>
|
||||
</Box>
|
||||
<Box>
|
||||
<Text>
|
||||
Are you sure you want to delete profile "{selectedProfile.name}"?
|
||||
</Text>
|
||||
</Box>
|
||||
<Box flexDirection="column" marginTop={1}>
|
||||
{options.map((option, index) => {
|
||||
const isSelected = index === deleteConfirmIndex;
|
||||
return (
|
||||
<Box key={option}>
|
||||
<Text
|
||||
color={
|
||||
isSelected ? colors.selector.itemHighlighted : undefined
|
||||
}
|
||||
bold={isSelected}
|
||||
>
|
||||
{isSelected ? ">" : " "} {option}
|
||||
</Text>
|
||||
</Box>
|
||||
);
|
||||
})}
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
// Main browsing UI
|
||||
return (
|
||||
<Box flexDirection="column" gap={1}>
|
||||
<Box>
|
||||
<Text bold color={colors.selector.title}>
|
||||
Profiles
|
||||
</Text>
|
||||
</Box>
|
||||
|
||||
{/* Loading state */}
|
||||
{loading && (
|
||||
<Box>
|
||||
<Text dimColor>Loading profiles...</Text>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* Empty state */}
|
||||
{!loading && profiles.length === 0 && (
|
||||
<Box flexDirection="column">
|
||||
<Text dimColor>No profiles saved.</Text>
|
||||
<Text dimColor>Press S to save the current agent as a profile.</Text>
|
||||
<Box marginTop={1}>
|
||||
<Text dimColor>Esc to close</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* Profile list */}
|
||||
{!loading && profiles.length > 0 && (
|
||||
<Box flexDirection="column">
|
||||
{pageProfiles.map((profile, index) => {
|
||||
const isSelected = index === selectedIndex;
|
||||
const isCurrent = profile.agentId === currentAgentId;
|
||||
const hasAgent = profile.agent !== null;
|
||||
|
||||
// Calculate available width for agent ID
|
||||
const nameLen = profile.name.length;
|
||||
const fixedChars = 2 + 3 + (isCurrent ? 10 : 0); // "> " + " · " + " (current)"
|
||||
const availableForId = Math.max(
|
||||
15,
|
||||
terminalWidth - nameLen - fixedChars,
|
||||
);
|
||||
const displayId = truncateAgentId(profile.agentId, availableForId);
|
||||
|
||||
return (
|
||||
<Box key={profile.name} flexDirection="column" marginBottom={1}>
|
||||
{/* Row 1: Selection indicator, profile name, and ID */}
|
||||
<Box flexDirection="row">
|
||||
<Text
|
||||
color={
|
||||
isSelected ? colors.selector.itemHighlighted : undefined
|
||||
}
|
||||
>
|
||||
{isSelected ? ">" : " "}
|
||||
</Text>
|
||||
<Text> </Text>
|
||||
<Text
|
||||
bold={isSelected}
|
||||
color={
|
||||
isSelected ? colors.selector.itemHighlighted : undefined
|
||||
}
|
||||
>
|
||||
{profile.name}
|
||||
</Text>
|
||||
<Text dimColor> · {displayId}</Text>
|
||||
{isCurrent && (
|
||||
<Text color={colors.selector.itemCurrent}> (current)</Text>
|
||||
)}
|
||||
</Box>
|
||||
{/* Row 2: Description or error */}
|
||||
<Box flexDirection="row" marginLeft={2}>
|
||||
{hasAgent ? (
|
||||
<Text dimColor italic>
|
||||
{profile.agent?.description || "No description"}
|
||||
</Text>
|
||||
) : (
|
||||
<Text color="red" italic>
|
||||
{profile.error}
|
||||
</Text>
|
||||
)}
|
||||
</Box>
|
||||
{/* Row 3: Metadata (only if agent exists) */}
|
||||
{hasAgent && profile.agent && (
|
||||
<Box flexDirection="row" marginLeft={2}>
|
||||
<Text dimColor>
|
||||
{formatRelativeTime(profile.agent.last_run_completion)} ·{" "}
|
||||
{profile.agent.blocks?.length ?? 0} memory block
|
||||
{(profile.agent.blocks?.length ?? 0) === 1 ? "" : "s"} ·{" "}
|
||||
{formatModel(profile.agent)}
|
||||
</Text>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
})}
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* Footer with pagination and controls */}
|
||||
{!loading && profiles.length > 0 && (
|
||||
<Box flexDirection="column" marginTop={1}>
|
||||
{totalPages > 1 && (
|
||||
<Box>
|
||||
<Text dimColor>
|
||||
Page {currentPage + 1}/{totalPages}
|
||||
</Text>
|
||||
</Box>
|
||||
)}
|
||||
<Box>
|
||||
<Text dimColor>
|
||||
↑↓ navigate · Enter load · S save · D delete · J/K page · Esc
|
||||
close
|
||||
</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* Footer for empty state already handled above */}
|
||||
</Box>
|
||||
);
|
||||
});
|
||||
|
||||
ProfileSelector.displayName = "ProfileSelector";
|
||||
@@ -92,6 +92,7 @@ export function ResumeSelector({
|
||||
|
||||
const agentList = await client.agents.list({
|
||||
limit: FETCH_PAGE_SIZE,
|
||||
tags: ["origin:letta-code"],
|
||||
include: ["agent.blocks"],
|
||||
order: "desc",
|
||||
order_by: "last_run_completion",
|
||||
@@ -236,7 +237,7 @@ export function ResumeSelector({
|
||||
<Box flexDirection="column" gap={1}>
|
||||
<Box>
|
||||
<Text bold color={colors.selector.title}>
|
||||
Resume Session
|
||||
Resume Session (showing most recent agents)
|
||||
</Text>
|
||||
</Box>
|
||||
|
||||
@@ -356,7 +357,8 @@ export function ResumeSelector({
|
||||
</Box>
|
||||
<Box>
|
||||
<Text dimColor>
|
||||
↑↓ navigate · Enter select · J/K page · Type + Enter to search
|
||||
↑↓ navigate · Enter to switch agents · J/K page · Type + Enter to
|
||||
search
|
||||
</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
@@ -119,7 +119,7 @@ export function WelcomeScreen({
|
||||
!!continueSession,
|
||||
agentId,
|
||||
);
|
||||
const pathLine = isMedium ? `Running in ${cwd}` : cwd;
|
||||
const pathLine = isMedium ? `${cwd}` : cwd;
|
||||
const agentUrl = agentId ? `https://app.letta.com/agents/${agentId}` : null;
|
||||
const hints =
|
||||
loadingState === "ready"
|
||||
|
||||
@@ -1,5 +1,42 @@
|
||||
import { APIError } from "@letta-ai/letta-client/core/error";
|
||||
|
||||
const LETTA_USAGE_URL = "https://app.letta.com/settings/organization/usage";
|
||||
|
||||
/**
|
||||
* Check if the error is a credit exhaustion error (402 with not-enough-credits)
|
||||
*/
|
||||
function isCreditExhaustedError(e: APIError): boolean {
|
||||
// Check status code
|
||||
if (e.status !== 402) return false;
|
||||
|
||||
// Check for "not-enough-credits" in various places it could appear
|
||||
const errorBody = e.error;
|
||||
if (errorBody && typeof errorBody === "object") {
|
||||
// Check reasons array: {"error":"Rate limited","reasons":["not-enough-credits"]}
|
||||
if ("reasons" in errorBody && Array.isArray(errorBody.reasons)) {
|
||||
if (errorBody.reasons.includes("not-enough-credits")) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
// Check nested error.reasons
|
||||
if ("error" in errorBody && typeof errorBody.error === "object") {
|
||||
const nested = errorBody.error as Record<string, unknown>;
|
||||
if ("reasons" in nested && Array.isArray(nested.reasons)) {
|
||||
if (nested.reasons.includes("not-enough-credits")) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Also check the message for "not-enough-credits" as a fallback
|
||||
if (e.message?.includes("not-enough-credits")) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract comprehensive error details from any error object
|
||||
* Handles APIError, Error, and other error types consistently
|
||||
@@ -11,6 +48,10 @@ export function formatErrorDetails(e: unknown, agentId?: string): string {
|
||||
|
||||
// Handle APIError from streaming (event: error)
|
||||
if (e instanceof APIError) {
|
||||
// Check for credit exhaustion error first - provide a friendly message
|
||||
if (isCreditExhaustedError(e)) {
|
||||
return `Your account is out of credits. Redeem additional credits or configure auto-recharge on your account page: ${LETTA_USAGE_URL}`;
|
||||
}
|
||||
// Check for nested error structure: e.error.error
|
||||
if (e.error && typeof e.error === "object" && "error" in e.error) {
|
||||
const errorData = e.error.error;
|
||||
|
||||
Reference in New Issue
Block a user