feat: add interactive ProfileSelector UI for /profile command (#208)
Co-authored-by: Letta <noreply@letta.com>
This commit is contained in:
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user