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

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