feat: profile-based persistence with startup selector (#212)

Co-authored-by: Letta <noreply@letta.com>
This commit is contained in:
Charles Packer
2025-12-15 11:13:43 -08:00
committed by GitHub
parent 32584fed2d
commit 2f21893ef5
13 changed files with 957 additions and 496 deletions

View File

@@ -23,6 +23,7 @@ import { SessionStats } from "../agent/stats";
import type { ApprovalContext } from "../permissions/analyzer";
import { permissionMode } from "../permissions/mode";
import { updateProjectSettings } from "../settings";
import { settingsManager } from "../settings-manager";
import type { ToolExecutionResult } from "../tools/manager";
import {
analyzeToolApproval,
@@ -32,9 +33,11 @@ import {
} from "../tools/manager";
import {
addCommandResult,
handlePin,
handleProfileDelete,
handleProfileSave,
handleProfileUsage,
handleUnpin,
type ProfileCommandContext,
validateProfileLoad,
} from "./commands/profile";
@@ -568,23 +571,27 @@ export default function App({
// Use backfillBuffers to properly populate the transcript from history
backfillBuffers(buffersRef.current, messageHistory);
// Inject "showing N messages" status at the START of backfilled history
// Add status line showing resumed agent info
const backfillStatusId = `status-backfill-${Date.now().toString(36)}`;
// Add combined status at the END so user sees it without scrolling
const statusId = `status-resumed-${Date.now().toString(36)}`;
const cwd = process.cwd();
const shortCwd = cwd.startsWith(process.env.HOME || "")
? `~${cwd.slice((process.env.HOME || "").length)}`
: cwd;
const agentUrl = agentState?.id
? `https://app.letta.com/agents/${agentState.id}`
: null;
const backfillLines = [
"Resumed agent",
const statusLines = [
`Connecting to last used agent in ${shortCwd}`,
agentState?.name ? `→ Agent: ${agentState.name}` : "",
agentUrl ? `${agentUrl}` : "",
"→ Use /pinned or /resume to switch agents",
].filter(Boolean);
buffersRef.current.byId.set(backfillStatusId, {
buffersRef.current.byId.set(statusId, {
kind: "status",
id: backfillStatusId,
lines: backfillLines,
id: statusId,
lines: statusLines,
});
// Insert at the beginning of the order array
buffersRef.current.order.unshift(backfillStatusId);
buffersRef.current.order.push(statusId);
refreshDerived();
commitEligibleLines(buffersRef.current);
@@ -1269,21 +1276,18 @@ export default function App({
}, [streaming]);
const handleAgentSelect = useCallback(
async (targetAgentId: string, opts?: { profileName?: string }) => {
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 label = agentName || targetAgentId.slice(0, 12);
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}"`,
input: "/pinned",
output: `Already on "${label}"`,
phase: "finished",
success: true,
});
@@ -1292,10 +1296,7 @@ export default function App({
return;
}
const isProfileLoad = !!opts?.profileName;
const inputCmd = isProfileLoad
? `/profile load ${opts.profileName}`
: `/resume ${targetAgentId}`;
const inputCmd = "/pinned";
setCommandRunning(true);
@@ -1328,9 +1329,7 @@ export default function App({
// Build success command
const agentUrl = `https://app.letta.com/projects/default-project/agents/${targetAgentId}`;
const successOutput = isProfileLoad
? `Loaded "${agent.name || targetAgentId}"\n⎿ ${agentUrl}`
: `Resumed "${agent.name || targetAgentId}"\n⎿ ${agentUrl}`;
const successOutput = `Resumed "${agent.name || targetAgentId}"\n⎿ ${agentUrl}`;
const successItem: StaticItem = {
kind: "command",
id: uid("cmd"),
@@ -1882,7 +1881,7 @@ export default function App({
}
// Special handling for /resume command - show session resume selector
if (msg.trim() === "/resume") {
if (msg.trim() === "/agents" || msg.trim() === "/resume") {
setResumeSelectorOpen(true);
return { submitted: true };
}
@@ -1903,6 +1902,7 @@ export default function App({
buffersRef,
refreshDerived,
agentId,
agentName: agentName || "",
setCommandRunning,
setAgentName,
};
@@ -1968,6 +1968,42 @@ export default function App({
return { submitted: true };
}
// Special handling for /profiles and /pinned commands - open pinned agents selector
if (msg.trim() === "/profiles" || msg.trim() === "/pinned") {
setProfileSelectorOpen(true);
return { submitted: true };
}
// Special handling for /pin command - pin current agent to project (or globally with -g)
if (msg.trim() === "/pin" || msg.trim().startsWith("/pin ")) {
const profileCtx: ProfileCommandContext = {
buffersRef,
refreshDerived,
agentId,
agentName: agentName || "",
setCommandRunning,
setAgentName,
};
const argsStr = msg.trim().slice(4).trim();
await handlePin(profileCtx, msg, argsStr);
return { submitted: true };
}
// Special handling for /unpin command - unpin current agent from project (or globally with -g)
if (msg.trim() === "/unpin" || msg.trim().startsWith("/unpin ")) {
const profileCtx: ProfileCommandContext = {
buffersRef,
refreshDerived,
agentId,
agentName: agentName || "",
setCommandRunning,
setAgentName,
};
const argsStr = msg.trim().slice(6).trim();
handleUnpin(profileCtx, msg, argsStr);
return { submitted: true };
}
// Special handling for /bashes command - show background shell processes
if (msg.trim() === "/bashes") {
const { backgroundProcesses } = await import(
@@ -2511,6 +2547,7 @@ ${recentCommits}
processConversation,
refreshDerived,
agentId,
agentName,
handleExit,
isExecutingTool,
queuedApprovalResults,
@@ -3468,8 +3505,21 @@ Plan file path: ${planFilePath}`;
agentState,
agentProvenance,
);
// For resumed agents, show the agent name if it has one (profile name)
const resumedMessage = continueSession
? agentState?.name
? `Resumed **${agentState.name}**`
: "Resumed agent"
: "Created a new agent (use /pin to save, /pinned or /resume to switch)";
const agentNameLine =
!continueSession && agentState?.name
? `→ Agent: ${agentState.name} (use /name to rename)`
: "";
const statusLines = [
continueSession ? "Resumed agent" : "Created new agent",
resumedMessage,
agentNameLine,
agentUrl ? `${agentUrl}` : "",
...hints,
].filter(Boolean);
@@ -3663,39 +3713,24 @@ Plan file path: ${planFilePath}`;
{profileSelectorOpen && (
<ProfileSelector
currentAgentId={agentId}
onSelect={async (id, profileName) => {
onSelect={async (id) => {
setProfileSelectorOpen(false);
await handleAgentSelect(id, { profileName });
await handleAgentSelect(id);
}}
onSave={async (profileName) => {
onUnpin={(unpinAgentId) => {
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,
);
settingsManager.unpinBoth(unpinAgentId);
const cmdId = uid("cmd");
buffersRef.current.byId.set(cmdId, {
kind: "command",
id: cmdId,
input: "/pinned",
output: `Unpinned agent ${unpinAgentId.slice(0, 12)}`,
phase: "finished",
success: true,
});
buffersRef.current.order.push(cmdId);
refreshDerived();
}}
onCancel={() => setProfileSelectorOpen(false)}
/>

View File

@@ -19,6 +19,7 @@ export interface ProfileCommandContext {
buffersRef: { current: Buffers };
refreshDerived: () => void;
agentId: string;
agentName: string;
setCommandRunning: (running: boolean) => void;
setAgentName: (name: string) => void;
}
@@ -69,10 +70,15 @@ export function updateCommandResult(
refreshDerived();
}
// Get profiles from local settings
// Get all profiles (merged from global + local, local takes precedence)
export function getProfiles(): Record<string, string> {
const localSettings = settingsManager.getLocalProjectSettings();
return localSettings.profiles || {};
const merged = settingsManager.getMergedProfiles();
// Convert array format back to Record
const result: Record<string, string> = {};
for (const profile of merged) {
result[profile.name] = profile.agentId;
}
return result;
}
// Check if a profile exists, returns error message if not found
@@ -159,19 +165,15 @@ export async function handleProfileSave(
await client.agents.update(ctx.agentId, { name: profileName });
ctx.setAgentName(profileName);
// Save profile to local settings
const profiles = getProfiles();
const updatedProfiles = { ...profiles, [profileName]: ctx.agentId };
settingsManager.updateLocalProjectSettings({
profiles: updatedProfiles,
});
// Save profile to BOTH local and global settings
settingsManager.saveProfile(profileName, ctx.agentId);
updateCommandResult(
ctx.buffersRef,
ctx.refreshDerived,
cmdId,
msg,
`Saved profile "${profileName}" (agent ${ctx.agentId})`,
`Pinned "${profileName}" locally and globally.`,
true,
);
} catch (error) {
@@ -294,3 +296,152 @@ export function handleProfileUsage(
false,
);
}
// Parse /pin or /unpin args: [-l|--local] [name]
// Default is global, use -l for local-only
function parsePinArgs(argsStr: string): { local: boolean; name?: string } {
const parts = argsStr.trim().split(/\s+/).filter(Boolean);
let local = false;
let name: string | undefined;
for (const part of parts) {
if (part === "-l" || part === "--local") {
local = true;
} else if (!name) {
name = part;
}
}
return { local, name };
}
// /pin [-l] [name] - Pin the current agent globally (or locally with -l)
// If name is provided, renames the agent first
export async function handlePin(
ctx: ProfileCommandContext,
msg: string,
argsStr: string,
): Promise<void> {
const { local, name } = parsePinArgs(argsStr);
const localPinned = settingsManager.getLocalPinnedAgents();
const globalPinned = settingsManager.getGlobalPinnedAgents();
// If user provided a name, rename the agent first
if (name && name !== ctx.agentName) {
try {
const { getClient } = await import("../../agent/client");
const client = await getClient();
await client.agents.update(ctx.agentId, { name });
ctx.setAgentName(name);
} catch (error) {
addCommandResult(
ctx.buffersRef,
ctx.refreshDerived,
msg,
`Failed to rename agent: ${error}`,
false,
);
return;
}
}
const displayName = name || ctx.agentName || ctx.agentId.slice(0, 12);
if (local) {
// Pin locally only
if (localPinned.includes(ctx.agentId)) {
addCommandResult(
ctx.buffersRef,
ctx.refreshDerived,
msg,
"This agent is already pinned to this project.",
false,
);
return;
}
settingsManager.pinLocal(ctx.agentId);
addCommandResult(
ctx.buffersRef,
ctx.refreshDerived,
msg,
`Pinned "${displayName}" to this project.`,
true,
);
} else {
// Pin globally (default)
if (globalPinned.includes(ctx.agentId)) {
addCommandResult(
ctx.buffersRef,
ctx.refreshDerived,
msg,
"This agent is already pinned globally.",
false,
);
return;
}
settingsManager.pinGlobal(ctx.agentId);
addCommandResult(
ctx.buffersRef,
ctx.refreshDerived,
msg,
`Pinned "${displayName}" globally.`,
true,
);
}
}
// /unpin [-l] - Unpin the current agent globally (or locally with -l)
export function handleUnpin(
ctx: ProfileCommandContext,
msg: string,
argsStr: string,
): void {
const { local } = parsePinArgs(argsStr);
const localPinned = settingsManager.getLocalPinnedAgents();
const globalPinned = settingsManager.getGlobalPinnedAgents();
const displayName = ctx.agentName || ctx.agentId.slice(0, 12);
if (local) {
// Unpin locally only
if (!localPinned.includes(ctx.agentId)) {
addCommandResult(
ctx.buffersRef,
ctx.refreshDerived,
msg,
"This agent isn't pinned to this project.",
false,
);
return;
}
settingsManager.unpinLocal(ctx.agentId);
addCommandResult(
ctx.buffersRef,
ctx.refreshDerived,
msg,
`Unpinned "${displayName}" from this project.`,
true,
);
} else {
// Unpin globally (default)
if (!globalPinned.includes(ctx.agentId)) {
addCommandResult(
ctx.buffersRef,
ctx.refreshDerived,
msg,
"This agent isn't pinned globally.",
false,
);
return;
}
settingsManager.unpinGlobal(ctx.agentId);
addCommandResult(
ctx.buffersRef,
ctx.refreshDerived,
msg,
`Unpinned "${displayName}" globally.`,
true,
);
}
}

View File

@@ -122,10 +122,10 @@ export const commands: Record<string, Command> = {
},
},
"/resume": {
desc: "Resume a previous agent session",
desc: "Browse and switch to another agent",
handler: () => {
// Handled specially in App.tsx to show resume selector
return "Opening session selector...";
// Handled specially in App.tsx to show agent selector
return "Opening agent selector...";
},
},
"/search": {
@@ -135,11 +135,25 @@ export const commands: Record<string, Command> = {
return "Opening message search...";
},
},
"/profile": {
desc: "Manage local profiles (save/load/delete)",
"/pin": {
desc: "Pin current agent globally (use -l for local only)",
handler: () => {
// Handled specially in App.tsx for profile management
return "Managing profiles...";
// Handled specially in App.tsx
return "Pinning agent...";
},
},
"/unpin": {
desc: "Unpin current agent globally (use -l for local only)",
handler: () => {
// Handled specially in App.tsx
return "Unpinning agent...";
},
},
"/pinned": {
desc: "Show pinned agents",
handler: () => {
// Handled specially in App.tsx to open pinned agents selector
return "Opening pinned agents...";
},
},
};

View File

@@ -1,7 +1,7 @@
import { Box, Text } from "ink";
import Link from "ink-link";
import { useMemo } from "react";
import { getProfiles } from "../commands/profile";
import { settingsManager } from "../../settings-manager";
import { commands } from "../commands/registry";
import { colors } from "./colors";
@@ -26,14 +26,12 @@ 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;
// Check if current agent is pinned
const isPinned = useMemo(() => {
if (!agentId) return false;
const localPinned = settingsManager.getLocalPinnedAgents();
const globalPinned = settingsManager.getGlobalPinnedAgents();
return localPinned.includes(agentId) || globalPinned.includes(agentId);
}, [agentId]);
if (!currentInput.startsWith("/")) {
@@ -62,10 +60,10 @@ export function CommandPreview({
<Box>
<Text color="gray">Current agent: </Text>
<Text bold>{agentName || "Unnamed"}</Text>
{profileName ? (
<Text color="green"> (profile: {profileName} )</Text>
{isPinned ? (
<Text color="green"> (pinned )</Text>
) : (
<Text color="gray"> (type /profile to pin agent)</Text>
<Text color="gray"> (type /pin to pin agent)</Text>
)}
</Box>
<Box>

View File

@@ -2,15 +2,14 @@ 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 { settingsManager } from "../../settings-manager";
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;
onSelect: (agentId: string) => void;
onUnpin: (agentId: string) => void;
onCancel: () => void;
}
@@ -19,6 +18,7 @@ interface ProfileData {
agentId: string;
agent: AgentState | null;
error: string | null;
isPinned: boolean;
}
const DISPLAY_PAGE_SIZE = 5;
@@ -71,13 +71,12 @@ function formatModel(agent: AgentState): string {
return "unknown";
}
type Mode = "browsing" | "saving" | "confirming-delete";
type Mode = "browsing" | "confirming-delete";
export const ProfileSelector = memo(function ProfileSelector({
currentAgentId,
onSelect,
onSave,
onDelete,
onUnpin,
onCancel,
}: ProfileSelectorProps) {
const terminalWidth = useTerminalWidth();
@@ -86,17 +85,16 @@ export const ProfileSelector = memo(function ProfileSelector({
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
// Load pinned agents and fetch agent data
const loadProfiles = useCallback(async () => {
setLoading(true);
try {
const profilesMap = getProfiles();
const profileNames = Object.keys(profilesMap).sort();
const mergedPinned = settingsManager.getMergedPinnedAgents();
const localPinned = settingsManager.getLocalPinnedAgents();
if (profileNames.length === 0) {
if (mergedPinned.length === 0) {
setProfiles([]);
setLoading(false);
return;
@@ -104,16 +102,23 @@ export const ProfileSelector = memo(function ProfileSelector({
const client = await getClient();
// Fetch agent data for each profile
const profileDataPromises = profileNames.map(async (name) => {
const agentId = profilesMap[name] as string;
// Fetch agent data for each pinned agent
const profileDataPromises = mergedPinned.map(async ({ agentId }) => {
const isPinned = localPinned.includes(agentId);
try {
const agent = await client.agents.retrieve(agentId, {
include: ["agent.blocks"],
});
return { name, agentId, agent, error: null };
// Use agent name from server
return { name: agent.name, agentId, agent, error: null, isPinned };
} catch (_err) {
return { name, agentId, agent: null, error: "Agent not found" };
return {
name: agentId.slice(0, 12),
agentId,
agent: null,
error: "Agent not found",
isPinned,
};
}
});
@@ -144,31 +149,14 @@ export const ProfileSelector = memo(function ProfileSelector({
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);
// Yes - unpin (onUnpin closes the selector)
onUnpin(selectedProfile.agentId);
return;
} else {
// No - cancel
@@ -187,13 +175,10 @@ export const ProfileSelector = memo(function ProfileSelector({
setSelectedIndex((prev) => Math.min(pageProfiles.length - 1, prev + 1));
} else if (key.return) {
if (selectedProfile?.agent) {
onSelect(selectedProfile.agentId, selectedProfile.name);
onSelect(selectedProfile.agentId);
}
} 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");
@@ -211,44 +196,35 @@ export const ProfileSelector = memo(function ProfileSelector({
setCurrentPage((prev) => prev + 1);
setSelectedIndex(0);
}
} else if (input === "p" || input === "P") {
if (selectedProfile) {
// Toggle pin/unpin for selected profile
if (selectedProfile.isPinned) {
settingsManager.unpinLocal(selectedProfile.agentId);
} else {
settingsManager.pinLocal(selectedProfile.agentId);
}
} else {
// No profiles - pin the current agent
settingsManager.pinLocal(currentAgentId);
}
// Reload profiles to reflect change
loadProfiles();
}
});
// 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
// Unpin confirmation UI
if (mode === "confirming-delete" && selectedProfile) {
const options = ["Yes, delete", "No, cancel"];
const options = ["Yes, unpin", "No, cancel"];
return (
<Box flexDirection="column" gap={1}>
<Box>
<Text bold color={colors.selector.title}>
Delete Profile
Unpin Agent
</Text>
</Box>
<Box>
<Text>
Are you sure you want to delete profile "{selectedProfile.name}"?
</Text>
<Text>Unpin "{selectedProfile.name}" from all locations?</Text>
</Box>
<Box flexDirection="column" marginTop={1}>
{options.map((option, index) => {
@@ -276,22 +252,22 @@ export const ProfileSelector = memo(function ProfileSelector({
<Box flexDirection="column" gap={1}>
<Box>
<Text bold color={colors.selector.title}>
Profiles
Pinned Agents
</Text>
</Box>
{/* Loading state */}
{loading && (
<Box>
<Text dimColor>Loading profiles...</Text>
<Text dimColor>Loading pinned agents...</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>
<Text dimColor>No agents pinned.</Text>
<Text dimColor>Press P to pin the current agent.</Text>
<Box marginTop={1}>
<Text dimColor>Esc to close</Text>
</Box>
@@ -335,6 +311,9 @@ export const ProfileSelector = memo(function ProfileSelector({
>
{profile.name}
</Text>
{profile.isPinned && (
<Text color={colors.selector.itemCurrent}> (pinned)</Text>
)}
<Text dimColor> · {displayId}</Text>
{isCurrent && (
<Text color={colors.selector.itemCurrent}> (current)</Text>
@@ -381,8 +360,7 @@ export const ProfileSelector = memo(function ProfileSelector({
)}
<Box>
<Text dimColor>
navigate · Enter load · S save · D delete · J/K page · Esc
close
navigate · Enter load · P pin/unpin · D unpin all · Esc close
</Text>
</Box>
</Box>

View File

@@ -237,7 +237,7 @@ export function ResumeSelector({
<Box flexDirection="column" gap={1}>
<Box>
<Text bold color={colors.selector.title}>
Resume Session (showing most recent agents)
Browsing Agents (sorting by last run)
</Text>
</Box>

View File

@@ -84,46 +84,10 @@ export function getAgentStatusHints(
return hints;
}
// For new agents with provenance, show block sources
if (agentProvenance) {
// Blocks reused from existing storage
const reusedGlobalBlocks = agentProvenance.blocks
.filter((b) => b.source === "global")
.map((b) => b.label);
const reusedProjectBlocks = agentProvenance.blocks
.filter((b) => b.source === "project")
.map((b) => b.label);
// New blocks - categorize by where they'll be stored
// (project/skills → .letta/, others → ~/.letta/)
const newBlocks = agentProvenance.blocks.filter((b) => b.source === "new");
const newGlobalBlocks = newBlocks
.filter((b) => b.label !== "project" && b.label !== "skills")
.map((b) => b.label);
const newProjectBlocks = newBlocks
.filter((b) => b.label === "project" || b.label === "skills")
.map((b) => b.label);
if (reusedGlobalBlocks.length > 0) {
hints.push(
`→ Reusing from global (~/.letta/): ${reusedGlobalBlocks.join(", ")}`,
);
}
if (newGlobalBlocks.length > 0) {
hints.push(
`→ Created in global (~/.letta/): ${newGlobalBlocks.join(", ")}`,
);
}
if (reusedProjectBlocks.length > 0) {
hints.push(
`→ Reusing from project (.letta/): ${reusedProjectBlocks.join(", ")}`,
);
}
if (newProjectBlocks.length > 0) {
hints.push(
`→ Created in project (.letta/): ${newProjectBlocks.join(", ")}`,
);
}
// For new agents, just show memory block labels
if (agentProvenance && agentProvenance.blocks.length > 0) {
const blockLabels = agentProvenance.blocks.map((b) => b.label).join(", ");
hints.push(`→ Memory blocks: ${blockLabels}`);
}
return hints;

View File

@@ -0,0 +1,349 @@
/**
* 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, Text, 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 { WelcomeScreen } from "./components/WelcomeScreen";
interface ProfileOption {
name: string | null;
agentId: string;
isLocal: boolean;
isLru: boolean;
agent: AgentState | null;
}
interface ProfileSelectionResult {
type: "select" | "new" | "exit";
agentId?: string;
profileName?: string | null;
}
const MAX_DISPLAY = 3;
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): string {
const parts: string[] = [];
if (option.isLru) parts.push("last used");
if (option.isLocal) parts.push("pinned");
else if (!option.isLru) parts.push("global"); // Pinned globally but not locally
return parts.length > 0 ? ` (${parts.join(", ")})` : "";
}
function ProfileSelectionUI({
lruAgentId,
externalLoading,
onComplete,
}: {
lruAgentId: string | null;
externalLoading?: boolean;
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);
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);
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);
useInput((_input, key) => {
if (loading) return;
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 {
onComplete({ type: "new" });
}
} else if (key.escape) {
onComplete({ type: "exit" });
}
});
const hasLocalDir = settingsManager.hasLocalLettaDir();
const contextMessage = 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} />
{loading ? (
<Text dimColor>Loading pinned agents...</Text>
) : (
<Box flexDirection="column" gap={1}>
<Text dimColor>{contextMessage}</Text>
<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);
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 marginTop={1}>
<Text dimColor> navigate · Enter select · Esc exit</Text>
</Box>
</Box>
)}
</Box>
);
}
/**
* Inline profile selection component - used within LoadingApp
*/
export function ProfileSelectionInline({
lruAgentId,
loading: externalLoading,
onSelect,
onCreateNew,
onExit,
}: {
lruAgentId: string | null;
loading?: boolean;
onSelect: (agentId: string) => void;
onCreateNew: () => void;
onExit: () => void;
}) {
const handleComplete = (result: ProfileSelectionResult) => {
if (result.type === "exit") {
onExit();
} else if (result.type === "select" && result.agentId) {
onSelect(result.agentId);
} else {
onCreateNew();
}
};
return React.createElement(ProfileSelectionUI, {
lruAgentId,
externalLoading,
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,
};
}