feat: add interactive ProfileSelector UI for /profile command (#208)

Co-authored-by: Letta <noreply@letta.com>
This commit is contained in:
Charles Packer
2025-12-14 18:05:23 -08:00
committed by GitHub
parent fdcc54d095
commit cac76adb31
7 changed files with 553 additions and 25 deletions

View File

@@ -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)} />

View File

@@ -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...";

View File

@@ -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>

View 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>&gt; </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";

View File

@@ -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>

View File

@@ -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"

View File

@@ -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;