Files
letta-code/src/cli/profile-selection.tsx
2026-01-31 19:43:13 -08:00

544 lines
17 KiB
TypeScript

/**
* Profile selection flow - runs before main app starts
* Similar pattern to auth/setup.ts
*/
import type { AgentState } from "@letta-ai/letta-client/resources/agents/agents";
import { Box, useInput } from "ink";
import React, { useCallback, useEffect, useState } from "react";
import { getClient } from "../agent/client";
import { settingsManager } from "../settings-manager";
import { colors } from "./components/colors";
import { Text } from "./components/Text";
import { WelcomeScreen } from "./components/WelcomeScreen";
interface ProfileOption {
name: string | null;
agentId: string;
isLocal: boolean;
isLru: boolean;
agent: AgentState | null;
}
interface ProfileSelectionResult {
type: "select" | "new" | "new_with_model" | "exit";
agentId?: string;
profileName?: string | null;
model?: string;
}
const MAX_DISPLAY = 3;
const MAX_VISIBLE_MODELS = 8;
const MODEL_SEARCH_THRESHOLD = 10; // Show search input when more than this many models
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);
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 `${Math.floor(diffDays / 7)} week${Math.floor(diffDays / 7) === 1 ? "" : "s"} ago`;
}
function formatModel(agent: AgentState): string {
if (agent.model) {
const parts = agent.model.split("/");
return parts[parts.length - 1] || agent.model;
}
return agent.llm_config?.model || "unknown";
}
function getLabel(option: ProfileOption, freshRepoMode?: boolean): string {
const parts: string[] = [];
if (option.isLru) parts.push("last used");
if (option.isLocal) parts.push("pinned");
else if (!option.isLru && !freshRepoMode) parts.push("global"); // Pinned globally but not locally
return parts.length > 0 ? ` (${parts.join(", ")})` : "";
}
function ProfileSelectionUI({
lruAgentId,
externalLoading,
externalFreshRepoMode,
failedAgentMessage,
serverModelsForNewAgent,
defaultModelHandle: _defaultModelHandle,
serverBaseUrl,
onComplete,
}: {
lruAgentId: string | null;
externalLoading?: boolean;
externalFreshRepoMode?: boolean;
failedAgentMessage?: string;
serverModelsForNewAgent?: string[];
defaultModelHandle?: string;
serverBaseUrl?: string;
onComplete: (result: ProfileSelectionResult) => void;
}) {
const [options, setOptions] = useState<ProfileOption[]>([]);
const [internalLoading, setInternalLoading] = useState(true);
const loading = externalLoading || internalLoading;
const [selectedIndex, setSelectedIndex] = useState(0);
const [showAll, setShowAll] = useState(false);
// Model selection mode for self-hosted servers
// Start in model selection mode if serverModelsForNewAgent is provided and no agents to show
const [selectingModel, setSelectingModel] = useState(
!!(serverModelsForNewAgent && serverModelsForNewAgent.length > 0),
);
const [modelSelectedIndex, setModelSelectedIndex] = useState(0);
const [modelSearchQuery, setModelSearchQuery] = useState("");
const loadOptions = useCallback(async () => {
setInternalLoading(true);
try {
const mergedPinned = settingsManager.getMergedPinnedAgents();
const client = await getClient();
const optionsToFetch: ProfileOption[] = [];
const seenAgentIds = new Set<string>();
// First: LRU agent
if (lruAgentId) {
const matchingPinned = mergedPinned.find(
(p) => p.agentId === lruAgentId,
);
optionsToFetch.push({
name: null, // Will be fetched from server
agentId: lruAgentId,
isLocal: matchingPinned?.isLocal || false,
isLru: true,
agent: null,
});
seenAgentIds.add(lruAgentId);
}
// Then: Other pinned agents
for (const pinned of mergedPinned) {
if (!seenAgentIds.has(pinned.agentId)) {
optionsToFetch.push({
name: null, // Will be fetched from server
agentId: pinned.agentId,
isLocal: pinned.isLocal,
isLru: false,
agent: null,
});
seenAgentIds.add(pinned.agentId);
}
}
// Fetch agent data
const fetchedOptions = await Promise.all(
optionsToFetch.map(async (opt) => {
try {
const agent = await client.agents.retrieve(opt.agentId, {
include: ["agent.blocks"],
});
return { ...opt, agent };
} catch {
return { ...opt, agent: null };
}
}),
);
setOptions(fetchedOptions.filter((opt) => opt.agent !== null));
} catch {
setOptions([]);
} finally {
setInternalLoading(false);
}
}, [lruAgentId]);
useEffect(() => {
loadOptions();
}, [loadOptions]);
const displayOptions = showAll ? options : options.slice(0, MAX_DISPLAY);
const hasMore = options.length > MAX_DISPLAY;
const totalItems = displayOptions.length + 1 + (hasMore && !showAll ? 1 : 0);
// Model selection - filter out legacy models and apply search
const allServerModels =
serverModelsForNewAgent?.filter((h) => h !== "letta/letta-free") ?? [];
const showModelSearch = allServerModels.length > MODEL_SEARCH_THRESHOLD;
const filteredModels = modelSearchQuery
? allServerModels.filter((h) =>
h.toLowerCase().includes(modelSearchQuery.toLowerCase()),
)
: allServerModels;
const modelCount = filteredModels.length;
// Model selection scrolling
const modelStartIndex = Math.max(
0,
Math.min(
modelSelectedIndex - MAX_VISIBLE_MODELS + 1,
modelCount - MAX_VISIBLE_MODELS,
),
);
const visibleModels = filteredModels.slice(
modelStartIndex,
modelStartIndex + MAX_VISIBLE_MODELS,
);
const showModelScrollDown = modelStartIndex + MAX_VISIBLE_MODELS < modelCount;
const modelsBelow = modelCount - modelStartIndex - MAX_VISIBLE_MODELS;
useInput((_input, key) => {
if (loading) return;
// Model selection mode
if (selectingModel && serverModelsForNewAgent) {
if (key.upArrow) {
setModelSelectedIndex((prev) => Math.max(0, prev - 1));
} else if (key.downArrow) {
setModelSelectedIndex((prev) =>
Math.min(filteredModels.length - 1, prev + 1),
);
} else if (key.return) {
const selected = filteredModels[modelSelectedIndex];
if (selected) {
onComplete({ type: "new_with_model", model: selected });
}
} else if (key.escape || (key.ctrl && _input === "c")) {
// Go back to agent selection or exit
if (options.length > 0) {
setSelectingModel(false);
setModelSearchQuery("");
setModelSelectedIndex(0);
} else {
onComplete({ type: "exit" });
}
} else if (key.backspace || key.delete) {
// Handle backspace for search
if (showModelSearch && modelSearchQuery.length > 0) {
setModelSearchQuery((prev) => prev.slice(0, -1));
setModelSelectedIndex(0);
}
} else if (
showModelSearch &&
_input &&
_input.length === 1 &&
!key.ctrl &&
!key.meta
) {
// Handle typing for search
setModelSearchQuery((prev) => prev + _input);
setModelSelectedIndex(0);
}
return;
}
// Agent selection mode
if (key.upArrow) {
setSelectedIndex((prev) => Math.max(0, prev - 1));
} else if (key.downArrow) {
setSelectedIndex((prev) => Math.min(totalItems - 1, prev + 1));
} else if (key.return) {
if (selectedIndex < displayOptions.length) {
const selected = displayOptions[selectedIndex];
if (selected) {
onComplete({
type: "select",
agentId: selected.agentId,
profileName: selected.name,
});
}
} else if (
hasMore &&
!showAll &&
selectedIndex === displayOptions.length
) {
setShowAll(true);
setSelectedIndex(0);
} else {
// "Create new agent" selected
if (serverModelsForNewAgent && serverModelsForNewAgent.length > 0) {
// Need to pick a model first
setSelectingModel(true);
setModelSelectedIndex(0);
} else {
onComplete({ type: "new" });
}
}
} else if (key.escape || (key.ctrl && _input === "c")) {
onComplete({ type: "exit" });
}
});
const hasLocalDir = settingsManager.hasLocalLettaDir();
const contextMessage = externalFreshRepoMode
? `${options.length} pinned agent${options.length !== 1 ? "s" : ""} available.`
: hasLocalDir
? "Existing `.letta` folder detected."
: `${options.length} agent profile${options.length !== 1 ? "s" : ""} detected.`;
return (
<Box flexDirection="column">
{/* Welcome Screen */}
<WelcomeScreen
loadingState="ready"
continueSession={false}
agentState={null}
agentProvenance={null}
/>
<Box height={1} />
{failedAgentMessage && (
<>
<Text color="yellow">{failedAgentMessage}</Text>
<Box height={1} />
</>
)}
{loading ? (
<Text dimColor>Loading pinned agents...</Text>
) : selectingModel && serverModelsForNewAgent ? (
// Model selection mode
<Box flexDirection="column" gap={1}>
<Text bold color={colors.selector.title}>
Select a model
</Text>
{showModelSearch && (
<Box>
<Text dimColor>Search: </Text>
<Text>{modelSearchQuery || ""}</Text>
<Text dimColor></Text>
</Box>
)}
{allServerModels.length === 0 ? (
<Box flexDirection="column">
<Text color="yellow">No models found on server.</Text>
<Text dimColor>Server: {serverBaseUrl || "unknown"}</Text>
<Text dimColor>
Did you remember to start the server with your LLM API keys?
</Text>
</Box>
) : filteredModels.length === 0 ? (
<Text dimColor>No models matching "{modelSearchQuery}"</Text>
) : (
<Box flexDirection="column">
{visibleModels.map((handle, index) => {
const actualIndex = modelStartIndex + index;
const isSelected = actualIndex === modelSelectedIndex;
return (
<Box key={handle}>
<Text
color={
isSelected ? colors.selector.itemHighlighted : undefined
}
>
{isSelected ? "> " : " "}
{handle}
</Text>
</Box>
);
})}
{/* Phantom space or scroll indicator - always reserve the line */}
{showModelScrollDown ? (
<Text dimColor> {modelsBelow} more</Text>
) : modelCount > MAX_VISIBLE_MODELS ? (
<Text> </Text>
) : null}
</Box>
)}
<Box>
<Text dimColor>
navigate · Enter select
{showModelSearch ? " · Type to search" : ""} · Esc{" "}
{options.length > 0 ? "back" : "exit"}
</Text>
</Box>
</Box>
) : (
// Agent selection mode
<Box flexDirection="column" gap={1}>
<Text dimColor>{contextMessage}</Text>
{options.length > 0 && (
<Text bold>Which agent would you like to use?</Text>
)}
<Box flexDirection="column" gap={1}>
{displayOptions.map((option, index) => {
const isSelected = index === selectedIndex;
const displayName =
option.agent?.name || option.agentId.slice(0, 20);
const label = getLabel(option, externalFreshRepoMode);
return (
<Box key={option.agentId} flexDirection="column">
<Box>
<Text
color={
isSelected ? colors.selector.itemHighlighted : undefined
}
>
{isSelected ? "> " : " "}
</Text>
<Text
bold={isSelected}
color={
isSelected ? colors.selector.itemHighlighted : undefined
}
>
Resume{" "}
</Text>
<Text
bold
color={
isSelected ? colors.selector.itemHighlighted : undefined
}
>
{displayName}
</Text>
<Text dimColor>{label}</Text>
</Box>
{option.agent && (
<Box marginLeft={4}>
<Text dimColor>
{formatRelativeTime(option.agent.last_run_completion)} ·{" "}
{option.agent.memory?.blocks?.length || 0} memory blocks
· {formatModel(option.agent)}
</Text>
</Box>
)}
</Box>
);
})}
{hasMore && !showAll && (
<Box>
<Text
color={
selectedIndex === displayOptions.length
? colors.selector.itemHighlighted
: undefined
}
>
{selectedIndex === displayOptions.length ? "> " : " "}
View all {options.length} profiles
</Text>
</Box>
)}
<Box>
<Text
color={
selectedIndex === totalItems - 1
? colors.selector.itemHighlighted
: undefined
}
>
{selectedIndex === totalItems - 1 ? "> " : " "}
Create a new agent
</Text>
<Text dimColor> (--new)</Text>
</Box>
</Box>
<Box>
<Text dimColor> navigate · Enter select · Esc exit</Text>
</Box>
</Box>
)}
</Box>
);
}
/**
* Inline profile selection component - used within LoadingApp
*/
export function ProfileSelectionInline({
lruAgentId,
loading: externalLoading,
freshRepoMode,
failedAgentMessage,
serverModelsForNewAgent,
defaultModelHandle,
serverBaseUrl,
onSelect,
onCreateNew,
onCreateNewWithModel,
onExit,
}: {
lruAgentId: string | null;
loading?: boolean;
freshRepoMode?: boolean;
failedAgentMessage?: string;
/** If provided, show model selector when user clicks "Create new" */
serverModelsForNewAgent?: string[];
/** The default model handle that wasn't available */
defaultModelHandle?: string;
/** The server base URL for error messages */
serverBaseUrl?: string;
onSelect: (agentId: string) => void;
onCreateNew: () => void;
/** Called when user selects a model from serverModelsForNewAgent */
onCreateNewWithModel?: (model: string) => void;
onExit: () => void;
}) {
const handleComplete = (result: ProfileSelectionResult) => {
if (result.type === "exit") {
onExit();
} else if (result.type === "select" && result.agentId) {
onSelect(result.agentId);
} else if (result.type === "new_with_model" && result.model) {
onCreateNewWithModel?.(result.model);
} else {
onCreateNew();
}
};
return React.createElement(ProfileSelectionUI, {
lruAgentId,
externalLoading,
externalFreshRepoMode: freshRepoMode,
failedAgentMessage,
serverModelsForNewAgent,
defaultModelHandle,
serverBaseUrl,
onComplete: handleComplete,
});
}
/**
* Check if profile selection is needed
*/
export async function shouldShowProfileSelection(
forceNew: boolean,
agentIdArg: string | null,
fromAfFile: string | undefined,
): Promise<{ show: boolean; lruAgentId: string | null }> {
// Skip for explicit flags
if (forceNew || agentIdArg || fromAfFile) {
return { show: false, lruAgentId: null };
}
// Load settings
await settingsManager.loadLocalProjectSettings();
const localSettings = settingsManager.getLocalProjectSettings();
const globalProfiles = settingsManager.getGlobalProfiles();
const localProfiles = localSettings.profiles || {};
const hasProfiles =
Object.keys(globalProfiles).length > 0 ||
Object.keys(localProfiles).length > 0;
const lru = localSettings.lastAgent || null;
// Show selector if there are choices
return {
show: hasProfiles || !!lru,
lruAgentId: lru,
};
}