feat: /profile command (#203)

This commit is contained in:
Devansh Jain
2025-12-13 12:42:30 -08:00
committed by GitHub
parent 0dda14103a
commit d592fde824
7 changed files with 599 additions and 103 deletions

View File

@@ -169,9 +169,13 @@ While in a session, you can use these commands:
**Agent Management:**
- `/agent` - Show current agent link
- `/swap` - Switch to a different agent (prompts with agent selector)
- `/resume` - Switch to a different agent (prompts with agent selector)
- `/rename` - Rename the current agent
- `/download` - Download agent file locally (exports agent configuration as JSON)
- `/profile` - List saved profiles
- `/profile save <name>` - Save current agent to a named profile
- `/profile load <name>` - Load a saved profile (switches to that agent)
- `/profile delete <name>` - Delete a saved profile
**Configuration:**
- `/model` - Switch models (prompts with model selector)

View File

@@ -11,7 +11,7 @@ import type {
Message,
} from "@letta-ai/letta-client/resources/agents/messages";
import type { LlmConfig } from "@letta-ai/letta-client/resources/models/models";
import { Box, Static } from "ink";
import { Box, Static, Text } from "ink";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import type { ApprovalResult } from "../agent/approval-execution";
import { getResumeData } from "../agent/check-approval";
@@ -30,6 +30,15 @@ import {
executeTool,
savePermissionRule,
} from "../tools/manager";
import {
addCommandResult,
handleProfileDelete,
handleProfileList,
handleProfileSave,
handleProfileUsage,
type ProfileCommandContext,
validateProfileLoad,
} from "./commands/profile";
import { AgentSelector } from "./components/AgentSelector";
import { ApprovalDialog } from "./components/ApprovalDialogRich";
import { AssistantMessage } from "./components/AssistantMessageRich";
@@ -244,20 +253,34 @@ export default function App({
const [agentId, setAgentId] = useState(initialAgentId);
const [agentState, setAgentState] = useState(initialAgentState);
// Keep a ref to the current agentId for use in callbacks that need the latest value
const agentIdRef = useRef(agentId);
useEffect(() => {
agentIdRef.current = agentId;
}, [agentId]);
const resumeKey = useSuspend();
// Track previous prop values to detect actual prop changes (not internal state changes)
const prevInitialAgentIdRef = useRef(initialAgentId);
const prevInitialAgentStateRef = useRef(initialAgentState);
// Sync with prop changes (e.g., when parent updates from "loading" to actual ID)
// Only sync when the PROP actually changes, not when internal state changes
useEffect(() => {
if (initialAgentId !== agentId) {
if (initialAgentId !== prevInitialAgentIdRef.current) {
prevInitialAgentIdRef.current = initialAgentId;
agentIdRef.current = initialAgentId;
setAgentId(initialAgentId);
}
}, [initialAgentId, agentId]);
}, [initialAgentId]);
useEffect(() => {
if (initialAgentState !== agentState) {
if (initialAgentState !== prevInitialAgentStateRef.current) {
prevInitialAgentStateRef.current = initialAgentState;
setAgentState(initialAgentState);
}
}, [initialAgentState, agentState]);
}, [initialAgentState]);
// Whether a stream is in flight (disables input)
const [streaming, setStreaming] = useState(false);
@@ -268,6 +291,13 @@ export default function App({
// Whether a command is running (disables input but no streaming UI)
const [commandRunning, setCommandRunning] = useState(false);
// Profile load confirmation - when loading a profile and current agent is unsaved
const [profileConfirmPending, setProfileConfirmPending] = useState<{
name: string;
agentId: string;
cmdId: string;
} | null>(null);
// If we have approval requests, we should show the approval dialog instead of the input area
const [pendingApprovals, setPendingApprovals] = useState<ApprovalRequest[]>(
[],
@@ -630,8 +660,11 @@ export default function App({
return;
}
// Stream one turn
const stream = await sendMessageStream(agentId, currentInput);
// Stream one turn - use ref to always get the latest agentId
const stream = await sendMessageStream(
agentIdRef.current,
currentInput,
);
const { stopReason, approval, approvals, apiDurationMs, lastRunId } =
await drainStreamWithResume(
stream,
@@ -1072,7 +1105,10 @@ export default function App({
run_id: lastRunId,
},
};
const errorDetails = formatErrorDetails(errorObject, agentId);
const errorDetails = formatErrorDetails(
errorObject,
agentIdRef.current,
);
appendError(errorDetails);
} else {
// No error metadata, show generic error with run info
@@ -1111,7 +1147,7 @@ export default function App({
}
// Use comprehensive error formatting
const errorDetails = formatErrorDetails(e, agentId);
const errorDetails = formatErrorDetails(e, agentIdRef.current);
appendError(errorDetails);
setStreaming(false);
refreshDerived();
@@ -1119,7 +1155,7 @@ export default function App({
abortControllerRef.current = null;
}
},
[agentId, appendError, refreshDerived, refreshDerivedThrottled],
[appendError, refreshDerived, refreshDerivedThrottled],
);
const handleExit = useCallback(() => {
@@ -1223,9 +1259,134 @@ export default function App({
}
}, [streaming]);
const handleAgentSelect = useCallback(
async (targetAgentId: string, opts?: { profileName?: string }) => {
setAgentSelectorOpen(false);
const isProfileLoad = !!opts?.profileName;
const inputCmd = isProfileLoad
? `/profile load ${opts.profileName}`
: `/resume ${targetAgentId}`;
setCommandRunning(true);
try {
const client = await getClient();
// Fetch new agent
const agent = await client.agents.retrieve(targetAgentId);
// Fetch agent's message history
const messagesPage = await client.agents.messages.list(targetAgentId);
const messages = messagesPage.items;
// Update project settings with new agent
await updateProjectSettings({ lastAgent: targetAgentId });
// Clear current transcript and static items
buffersRef.current.byId.clear();
buffersRef.current.order = [];
buffersRef.current.tokenCount = 0;
emittedIdsRef.current.clear();
setStaticItems([]);
setStaticRenderEpoch((e) => e + 1);
// Update agent state - also update ref immediately for any code that runs before re-render
agentIdRef.current = targetAgentId;
setAgentId(targetAgentId);
setAgentState(agent);
setAgentName(agent.name);
setLlmConfig(agent.llm_config);
// 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 successItem: StaticItem = {
kind: "command",
id: uid("cmd"),
input: inputCmd,
output: successOutput,
phase: "finished",
success: true,
};
// Backfill message history with visual separator, then success command at end
if (messages.length > 0) {
hasBackfilledRef.current = false;
backfillBuffers(buffersRef.current, messages);
// Collect backfilled items
const backfilledItems: StaticItem[] = [];
for (const id of buffersRef.current.order) {
const ln = buffersRef.current.byId.get(id);
if (!ln) continue;
emittedIdsRef.current.add(id);
backfilledItems.push({ ...ln } as StaticItem);
}
// Add separator before backfilled messages, then success at end
const separator = {
kind: "separator" as const,
id: uid("sep"),
};
setStaticItems([separator, ...backfilledItems, successItem]);
setLines(toLines(buffersRef.current));
hasBackfilledRef.current = true;
} else {
setStaticItems([successItem]);
}
} catch (error) {
const errorDetails = formatErrorDetails(error, agentId);
const errorCmdId = uid("cmd");
buffersRef.current.byId.set(errorCmdId, {
kind: "command",
id: errorCmdId,
input: inputCmd,
output: `Failed: ${errorDetails}`,
phase: "finished",
success: false,
});
buffersRef.current.order.push(errorCmdId);
refreshDerived();
} finally {
setCommandRunning(false);
}
},
[refreshDerived, agentId],
);
const onSubmit = useCallback(
async (message?: string): Promise<{ submitted: boolean }> => {
const msg = message?.trim() ?? "";
// Handle profile load confirmation (Enter to continue)
if (profileConfirmPending && !msg) {
// User pressed Enter with empty input - proceed with loading
const { name, agentId: targetAgentId, cmdId } = profileConfirmPending;
buffersRef.current.byId.delete(cmdId);
const orderIdx = buffersRef.current.order.indexOf(cmdId);
if (orderIdx !== -1) buffersRef.current.order.splice(orderIdx, 1);
refreshDerived();
setProfileConfirmPending(null);
await handleAgentSelect(targetAgentId, { profileName: name });
return { submitted: true };
}
// Cancel profile confirmation if user types something else
if (profileConfirmPending && msg) {
const { cmdId } = profileConfirmPending;
buffersRef.current.byId.set(cmdId, {
kind: "command",
id: cmdId,
input: `/profile load ${profileConfirmPending.name}`,
output: "Cancelled",
phase: "finished",
success: false,
});
refreshDerived();
setProfileConfirmPending(null);
// Continue processing the new message
}
if (!msg) return { submitted: false };
// Block submission if waiting for explicit user action (approvals)
@@ -1703,6 +1864,81 @@ export default function App({
return { submitted: true };
}
// Special handling for /profile command - manage local profiles
if (msg.trim().startsWith("/profile")) {
const parts = msg.trim().split(/\s+/);
const subcommand = parts[1]?.toLowerCase();
const profileName = parts.slice(2).join(" ");
const profileCtx: ProfileCommandContext = {
buffersRef,
refreshDerived,
agentId,
setCommandRunning,
setAgentName,
};
// /profile - list all profiles
if (!subcommand) {
handleProfileList(profileCtx, msg);
return { submitted: true };
}
// /profile save <name>
if (subcommand === "save") {
await handleProfileSave(profileCtx, msg, profileName);
return { submitted: true };
}
// /profile load <name>
if (subcommand === "load") {
const validation = validateProfileLoad(
profileCtx,
msg,
profileName,
);
if (validation.errorMessage) {
return { submitted: true };
}
if (validation.needsConfirmation && validation.targetAgentId) {
// Show warning and wait for confirmation
const cmdId = addCommandResult(
buffersRef,
refreshDerived,
msg,
"Warning: Current agent is not saved to any profile.\nPress Enter to continue, or type anything to cancel.",
false,
"running",
);
setProfileConfirmPending({
name: profileName,
agentId: validation.targetAgentId,
cmdId,
});
return { submitted: true };
}
// Current agent is saved, proceed with loading
if (validation.targetAgentId) {
await handleAgentSelect(validation.targetAgentId, {
profileName,
});
}
return { submitted: true };
}
// /profile delete <name>
if (subcommand === "delete") {
handleProfileDelete(profileCtx, msg, profileName);
return { submitted: true };
}
// Unknown subcommand
handleProfileUsage(profileCtx, msg);
return { submitted: true };
}
// Special handling for /bashes command - show background shell processes
if (msg.trim() === "/bashes") {
const { backgroundProcesses } = await import(
@@ -2250,6 +2486,8 @@ ${recentCommits}
isExecutingTool,
queuedApprovalResults,
pendingApprovals,
profileConfirmPending,
handleAgentSelect,
tokenStreamingEnabled,
],
);
@@ -2876,100 +3114,22 @@ ${recentCommits}
[agentId, refreshDerived],
);
const handleAgentSelect = useCallback(
async (targetAgentId: string) => {
setAgentSelectorOpen(false);
const cmdId = uid("cmd");
// Handle escape when profile confirmation is pending
const handleProfileEscapeCancel = useCallback(() => {
if (profileConfirmPending) {
const { cmdId, name } = profileConfirmPending;
buffersRef.current.byId.set(cmdId, {
kind: "command",
id: cmdId,
input: `/resume ${targetAgentId}`,
output: `Switching to agent ${targetAgentId}...`,
phase: "running",
});
buffersRef.current.order.push(cmdId);
refreshDerived();
setCommandRunning(true);
try {
const client = await getClient();
// Fetch new agent
const agent = await client.agents.retrieve(targetAgentId);
// Fetch agent's message history
const messagesPage = await client.agents.messages.list(targetAgentId);
const messages = messagesPage.items;
// Update project settings with new agent
await updateProjectSettings({ lastAgent: targetAgentId });
// Clear current transcript
buffersRef.current.byId.clear();
buffersRef.current.order = [];
buffersRef.current.tokenCount = 0;
emittedIdsRef.current.clear();
setStaticItems([]);
// Update agent state
setAgentId(targetAgentId);
setAgentState(agent);
setAgentName(agent.name);
setLlmConfig(agent.llm_config);
// Add welcome screen for new agent
welcomeCommittedRef.current = false;
setStaticItems([
{
kind: "welcome",
id: `welcome-${Date.now().toString(36)}`,
snapshot: {
continueSession: true,
agentState: agent,
terminalWidth: columns,
},
},
]);
// Backfill message history
if (messages.length > 0) {
hasBackfilledRef.current = false;
backfillBuffers(buffersRef.current, messages);
refreshDerived();
commitEligibleLines(buffersRef.current);
hasBackfilledRef.current = true;
}
// Add success command to transcript
const successCmdId = uid("cmd");
buffersRef.current.byId.set(successCmdId, {
kind: "command",
id: successCmdId,
input: `/resume ${targetAgentId}`,
output: `✓ Switched to agent "${agent.name || targetAgentId}"`,
phase: "finished",
success: true,
});
buffersRef.current.order.push(successCmdId);
refreshDerived();
} catch (error) {
const errorDetails = formatErrorDetails(error, agentId);
buffersRef.current.byId.set(cmdId, {
kind: "command",
id: cmdId,
input: `/resume ${targetAgentId}`,
output: `Failed: ${errorDetails}`,
input: `/profile load ${name}`,
output: "Cancelled",
phase: "finished",
success: false,
});
refreshDerived();
} finally {
setCommandRunning(false);
setProfileConfirmPending(null);
}
},
[refreshDerived, commitEligibleLines, columns, agentId],
);
}, [profileConfirmPending, refreshDerived]);
// Track permission mode changes for UI updates
const [uiPermissionMode, setUiPermissionMode] = useState(
@@ -3287,6 +3447,8 @@ Plan file path: ${planFilePath}`;
<ErrorMessage line={item} />
) : item.kind === "status" ? (
<StatusMessage line={item} />
) : item.kind === "separator" ? (
<Text dimColor>{"─".repeat(columns)}</Text>
) : item.kind === "command" ? (
<CommandMessage line={item} />
) : null}
@@ -3370,6 +3532,9 @@ Plan file path: ${planFilePath}`;
currentModel={currentModelDisplay}
messageQueue={messageQueue}
onEnterQueueEditMode={handleEnterQueueEditMode}
onEscapeCancel={
profileConfirmPending ? handleProfileEscapeCancel : undefined
}
/>
{/* Model Selector - conditionally mounted as overlay */}
@@ -3417,9 +3582,9 @@ Plan file path: ${planFilePath}`;
{resumeSelectorOpen && (
<ResumeSelector
currentAgentId={agentId}
onSelect={(id) => {
onSelect={async (id) => {
setResumeSelectorOpen(false);
handleAgentSelect(id);
await handleAgentSelect(id);
}}
onCancel={() => setResumeSelectorOpen(false)}
/>

296
src/cli/commands/profile.ts Normal file
View File

@@ -0,0 +1,296 @@
// src/cli/commands/profile.ts
// Profile command handlers for managing local agent profiles
import { getClient } from "../../agent/client";
import { settingsManager } from "../../settings-manager";
import type { Buffers, Line } from "../helpers/accumulator";
import { formatErrorDetails } from "../helpers/errorFormatter";
// tiny helper for unique ids
function uid(prefix: string) {
return `${prefix}-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
}
// Helper type for command result
type CommandLine = Extract<Line, { kind: "command" }>;
// Context passed to profile handlers
export interface ProfileCommandContext {
buffersRef: { current: Buffers };
refreshDerived: () => void;
agentId: string;
setCommandRunning: (running: boolean) => void;
setAgentName: (name: string) => void;
}
// Helper to add a command result to buffers
export function addCommandResult(
buffersRef: { current: Buffers },
refreshDerived: () => void,
input: string,
output: string,
success: boolean,
phase: "running" | "finished" = "finished",
): string {
const cmdId = uid("cmd");
const line: CommandLine = {
kind: "command",
id: cmdId,
input,
output,
phase,
...(phase === "finished" && { success }),
};
buffersRef.current.byId.set(cmdId, line);
buffersRef.current.order.push(cmdId);
refreshDerived();
return cmdId;
}
// Helper to update an existing command result
export function updateCommandResult(
buffersRef: { current: Buffers },
refreshDerived: () => void,
cmdId: string,
input: string,
output: string,
success: boolean,
phase: "running" | "finished" = "finished",
): void {
const line: CommandLine = {
kind: "command",
id: cmdId,
input,
output,
phase,
...(phase === "finished" && { success }),
};
buffersRef.current.byId.set(cmdId, line);
refreshDerived();
}
// Get profiles from local settings
export function getProfiles(): Record<string, string> {
const localSettings = settingsManager.getLocalProjectSettings();
return localSettings.profiles || {};
}
// Check if a profile exists, returns error message if not found
export function validateProfileExists(
profileName: string,
profiles: Record<string, string>,
): string | null {
if (!profiles[profileName]) {
return `Profile "${profileName}" not found. Use /profile to list available profiles.`;
}
return null;
}
// Check if a profile name was provided, returns error message if not
export function validateProfileNameProvided(
profileName: string,
action: string,
): string | null {
if (!profileName) {
return `Please provide a profile name: /profile ${action} <name>`;
}
return null;
}
// /profile - list all profiles
export function handleProfileList(
ctx: ProfileCommandContext,
msg: string,
): void {
const profiles = getProfiles();
const profileNames = Object.keys(profiles);
let output: string;
if (profileNames.length === 0) {
output =
"No profiles saved. Use /profile save <name> to save the current agent.";
} else {
const lines = ["Saved profiles:"];
for (const name of profileNames.sort()) {
const profileAgentId = profiles[name];
const isCurrent = profileAgentId === ctx.agentId;
lines.push(
` ${name} -> ${profileAgentId}${isCurrent ? " (current)" : ""}`,
);
}
output = lines.join("\n");
}
addCommandResult(ctx.buffersRef, ctx.refreshDerived, msg, output, true);
}
// /profile save <name>
export async function handleProfileSave(
ctx: ProfileCommandContext,
msg: string,
profileName: string,
): Promise<void> {
const validationError = validateProfileNameProvided(profileName, "save");
if (validationError) {
addCommandResult(
ctx.buffersRef,
ctx.refreshDerived,
msg,
validationError,
false,
);
return;
}
const cmdId = addCommandResult(
ctx.buffersRef,
ctx.refreshDerived,
msg,
`Saving profile "${profileName}"...`,
false,
"running",
);
ctx.setCommandRunning(true);
try {
const client = await getClient();
// Update agent name via API
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,
});
updateCommandResult(
ctx.buffersRef,
ctx.refreshDerived,
cmdId,
msg,
`Saved profile "${profileName}" (agent ${ctx.agentId})`,
true,
);
} catch (error) {
const errorDetails = formatErrorDetails(error, ctx.agentId);
updateCommandResult(
ctx.buffersRef,
ctx.refreshDerived,
cmdId,
msg,
`Failed: ${errorDetails}`,
false,
);
} finally {
ctx.setCommandRunning(false);
}
}
// Result from profile load validation
export interface ProfileLoadValidation {
targetAgentId: string | null;
needsConfirmation: boolean;
errorMessage: string | null;
}
// /profile load <name> - validation step (returns whether confirmation is needed)
export function validateProfileLoad(
ctx: ProfileCommandContext,
msg: string,
profileName: string,
): ProfileLoadValidation {
const nameError = validateProfileNameProvided(profileName, "load");
if (nameError) {
addCommandResult(ctx.buffersRef, ctx.refreshDerived, msg, nameError, false);
return {
targetAgentId: null,
needsConfirmation: false,
errorMessage: nameError,
};
}
const profiles = getProfiles();
const existsError = validateProfileExists(profileName, profiles);
if (existsError) {
addCommandResult(
ctx.buffersRef,
ctx.refreshDerived,
msg,
existsError,
false,
);
return {
targetAgentId: null,
needsConfirmation: false,
errorMessage: existsError,
};
}
// We know the profile exists since validateProfileExists passed
const targetAgentId = profiles[profileName] as string;
// Check if current agent is saved to any profile
const currentAgentSaved = Object.values(profiles).includes(ctx.agentId);
if (!currentAgentSaved) {
return { targetAgentId, needsConfirmation: true, errorMessage: null };
}
return { targetAgentId, needsConfirmation: false, errorMessage: null };
}
// /profile delete <name>
export function handleProfileDelete(
ctx: ProfileCommandContext,
msg: string,
profileName: string,
): void {
const nameError = validateProfileNameProvided(profileName, "delete");
if (nameError) {
addCommandResult(ctx.buffersRef, ctx.refreshDerived, msg, nameError, false);
return;
}
const profiles = getProfiles();
const existsError = validateProfileExists(profileName, profiles);
if (existsError) {
addCommandResult(
ctx.buffersRef,
ctx.refreshDerived,
msg,
existsError,
false,
);
return;
}
const { [profileName]: _, ...remainingProfiles } = profiles;
settingsManager.updateLocalProjectSettings({
profiles: remainingProfiles,
});
addCommandResult(
ctx.buffersRef,
ctx.refreshDerived,
msg,
`Deleted profile "${profileName}"`,
true,
);
}
// Show usage help for unknown subcommand
export function handleProfileUsage(
ctx: ProfileCommandContext,
msg: string,
): void {
addCommandResult(
ctx.buffersRef,
ctx.refreshDerived,
msg,
"Usage: /profile [save|load|delete] <name>\n /profile - list profiles\n /profile save <name> - save current agent\n /profile load <name> - load a profile\n /profile delete <name> - delete a profile",
false,
);
}

View File

@@ -135,6 +135,13 @@ export const commands: Record<string, Command> = {
return "Opening message search...";
},
},
"/profile": {
desc: "Manage local profiles (save/load/delete)",
handler: () => {
// Handled specially in App.tsx for profile management
return "Managing profiles...";
},
},
};
/**

View File

@@ -37,6 +37,7 @@ export function Input({
currentModel,
messageQueue,
onEnterQueueEditMode,
onEscapeCancel,
}: {
visible?: boolean;
streaming: boolean;
@@ -53,6 +54,7 @@ export function Input({
currentModel?: string | null;
messageQueue?: string[];
onEnterQueueEditMode?: () => void;
onEscapeCancel?: () => void;
}) {
const [value, setValue] = useState("");
const [escapePressed, setEscapePressed] = useState(false);
@@ -115,9 +117,28 @@ export function Input({
settings.env?.LETTA_BASE_URL ||
LETTA_CLOUD_API_URL;
// Handle profile confirmation: Enter confirms, any other key cancels
// When onEscapeCancel is provided, TextInput is unfocused so we handle all keys here
useInput((_input, key) => {
if (!visible) return;
if (!onEscapeCancel) return;
// Enter key confirms the action - trigger submit with empty input
if (key.return) {
onSubmit("");
return;
}
// Any other key cancels
onEscapeCancel();
});
// Handle escape key for interrupt (when streaming) or double-escape-to-clear (when not)
useInput((_input, key) => {
if (!visible) return;
// Skip if onEscapeCancel is provided - handled by the confirmation handler above
if (onEscapeCancel) return;
if (key.escape) {
// When streaming, use Esc to interrupt
if (streaming && onInterrupt && !interruptRequested) {
@@ -509,6 +530,7 @@ export function Input({
onSubmit={handleSubmit}
cursorPosition={cursorPos}
onCursorMove={setCurrentCursorPosition}
focus={!onEscapeCancel}
/>
</Box>
</Box>

View File

@@ -49,7 +49,8 @@ export type Line =
kind: "status";
id: string;
lines: string[]; // Multi-line status message with arrow formatting
};
}
| { kind: "separator"; id: string };
// Top-level state object for all streaming events
export type Buffers = {

View File

@@ -30,6 +30,7 @@ export interface ProjectSettings {
export interface LocalProjectSettings {
lastAgent: string | null;
permissions?: PermissionRules;
profiles?: Record<string, string>; // profileName -> agentId
}
const DEFAULT_SETTINGS: Settings = {