feat: add naming dialog when pinning agents (#279)
Co-authored-by: Letta <noreply@letta.com>
This commit is contained in:
@@ -7,6 +7,7 @@ import type {
|
||||
AgentState,
|
||||
AgentType,
|
||||
} from "@letta-ai/letta-client/resources/agents/agents";
|
||||
import { DEFAULT_AGENT_NAME } from "../constants";
|
||||
import { getToolNames } from "../tools/manager";
|
||||
import { getClient } from "./client";
|
||||
import { getDefaultMemoryBlocks } from "./memory";
|
||||
@@ -45,7 +46,7 @@ export interface CreateAgentResult {
|
||||
}
|
||||
|
||||
export async function createAgent(
|
||||
name = "letta-code-agent",
|
||||
name = DEFAULT_AGENT_NAME,
|
||||
model?: string,
|
||||
embeddingModel = "openai/text-embedding-3-small",
|
||||
updateArgs?: Record<string, unknown>,
|
||||
|
||||
114
src/cli/App.tsx
114
src/cli/App.tsx
@@ -53,6 +53,7 @@ import { Input } from "./components/InputRich";
|
||||
import { MemoryViewer } from "./components/MemoryViewer";
|
||||
import { MessageSearch } from "./components/MessageSearch";
|
||||
import { ModelSelector } from "./components/ModelSelector";
|
||||
import { PinDialog, validateAgentName } from "./components/PinDialog";
|
||||
import { PlanModeDialog } from "./components/PlanModeDialog";
|
||||
import { ProfileSelector } from "./components/ProfileSelector";
|
||||
import { QuestionDialog } from "./components/QuestionDialog";
|
||||
@@ -407,10 +408,14 @@ export default function App({
|
||||
| "subagent"
|
||||
| "feedback"
|
||||
| "memory"
|
||||
| "pin"
|
||||
| null;
|
||||
const [activeOverlay, setActiveOverlay] = useState<ActiveOverlay>(null);
|
||||
const closeOverlay = useCallback(() => setActiveOverlay(null), []);
|
||||
|
||||
// Pin dialog state
|
||||
const [pinDialogLocal, setPinDialogLocal] = useState(false);
|
||||
|
||||
// Derived: check if any selector/overlay is open (blocks queue processing and hides input)
|
||||
const anySelectorOpen = activeOverlay !== null;
|
||||
|
||||
@@ -1886,6 +1891,23 @@ export default function App({
|
||||
return { submitted: true };
|
||||
}
|
||||
|
||||
// Validate the name before sending to API
|
||||
const validationError = validateAgentName(newName);
|
||||
if (validationError) {
|
||||
const cmdId = uid("cmd");
|
||||
buffersRef.current.byId.set(cmdId, {
|
||||
kind: "command",
|
||||
id: cmdId,
|
||||
input: msg,
|
||||
output: validationError,
|
||||
phase: "finished",
|
||||
success: false,
|
||||
});
|
||||
buffersRef.current.order.push(cmdId);
|
||||
refreshDerived();
|
||||
return { submitted: true };
|
||||
}
|
||||
|
||||
const cmdId = uid("cmd");
|
||||
buffersRef.current.byId.set(cmdId, {
|
||||
kind: "command",
|
||||
@@ -2091,6 +2113,29 @@ export default function App({
|
||||
|
||||
// Special handling for /pin command - pin current agent to project (or globally with -g)
|
||||
if (msg.trim() === "/pin" || msg.trim().startsWith("/pin ")) {
|
||||
const argsStr = msg.trim().slice(4).trim();
|
||||
|
||||
// Parse args to check if name was provided
|
||||
const parts = argsStr.split(/\s+/).filter(Boolean);
|
||||
let hasNameArg = false;
|
||||
let isLocal = false;
|
||||
|
||||
for (const part of parts) {
|
||||
if (part === "-l" || part === "--local") {
|
||||
isLocal = true;
|
||||
} else {
|
||||
hasNameArg = true;
|
||||
}
|
||||
}
|
||||
|
||||
// If no name provided, show the pin dialog
|
||||
if (!hasNameArg) {
|
||||
setPinDialogLocal(isLocal);
|
||||
setActiveOverlay("pin");
|
||||
return { submitted: true };
|
||||
}
|
||||
|
||||
// Name was provided, use existing behavior
|
||||
const profileCtx: ProfileCommandContext = {
|
||||
buffersRef,
|
||||
refreshDerived,
|
||||
@@ -2099,7 +2144,6 @@ export default function App({
|
||||
setCommandRunning,
|
||||
setAgentName,
|
||||
};
|
||||
const argsStr = msg.trim().slice(4).trim();
|
||||
await handlePin(profileCtx, msg, argsStr);
|
||||
return { submitted: true };
|
||||
}
|
||||
@@ -4086,6 +4130,74 @@ Plan file path: ${planFilePath}`;
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Pin Dialog - for naming agent before pinning */}
|
||||
{activeOverlay === "pin" && (
|
||||
<PinDialog
|
||||
currentName={agentName || ""}
|
||||
local={pinDialogLocal}
|
||||
onSubmit={async (newName) => {
|
||||
closeOverlay();
|
||||
setCommandRunning(true);
|
||||
|
||||
const cmdId = uid("cmd");
|
||||
const scopeText = pinDialogLocal
|
||||
? "to this project"
|
||||
: "globally";
|
||||
const displayName =
|
||||
newName || agentName || agentId.slice(0, 12);
|
||||
|
||||
buffersRef.current.byId.set(cmdId, {
|
||||
kind: "command",
|
||||
id: cmdId,
|
||||
input: "/pin",
|
||||
output: `Pinning "${displayName}" ${scopeText}...`,
|
||||
phase: "running",
|
||||
});
|
||||
buffersRef.current.order.push(cmdId);
|
||||
refreshDerived();
|
||||
|
||||
try {
|
||||
const client = await getClient();
|
||||
|
||||
// Rename if new name provided
|
||||
if (newName && newName !== agentName) {
|
||||
await client.agents.update(agentId, { name: newName });
|
||||
setAgentName(newName);
|
||||
}
|
||||
|
||||
// Pin the agent
|
||||
if (pinDialogLocal) {
|
||||
settingsManager.pinLocal(agentId);
|
||||
} else {
|
||||
settingsManager.pinGlobal(agentId);
|
||||
}
|
||||
|
||||
buffersRef.current.byId.set(cmdId, {
|
||||
kind: "command",
|
||||
id: cmdId,
|
||||
input: "/pin",
|
||||
output: `Pinned "${newName || agentName || agentId.slice(0, 12)}" ${scopeText}.`,
|
||||
phase: "finished",
|
||||
success: true,
|
||||
});
|
||||
} catch (error) {
|
||||
buffersRef.current.byId.set(cmdId, {
|
||||
kind: "command",
|
||||
id: cmdId,
|
||||
input: "/pin",
|
||||
output: `Failed to pin: ${error}`,
|
||||
phase: "finished",
|
||||
success: false,
|
||||
});
|
||||
} finally {
|
||||
setCommandRunning(false);
|
||||
refreshDerived();
|
||||
}
|
||||
}}
|
||||
onCancel={closeOverlay}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Plan Mode Dialog - for ExitPlanMode tool */}
|
||||
{currentApproval?.toolName === "ExitPlanMode" && (
|
||||
<>
|
||||
|
||||
199
src/cli/components/PinDialog.tsx
Normal file
199
src/cli/components/PinDialog.tsx
Normal file
@@ -0,0 +1,199 @@
|
||||
import { Box, Text, useInput } from "ink";
|
||||
import { useState } from "react";
|
||||
import { DEFAULT_AGENT_NAME } from "../../constants";
|
||||
import { colors } from "./colors";
|
||||
import { PasteAwareTextInput } from "./PasteAwareTextInput";
|
||||
|
||||
interface PinDialogProps {
|
||||
currentName: string;
|
||||
local: boolean;
|
||||
onSubmit: (newName: string | null) => void; // null means keep current name
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate agent name against backend rules.
|
||||
* Matches: Unicode letters, digits, underscores, spaces, hyphens, apostrophes
|
||||
* Blocks: / \ : * ? " < > |
|
||||
*/
|
||||
export function validateAgentName(name: string): string | null {
|
||||
if (!name || !name.trim()) {
|
||||
return "Name cannot be empty";
|
||||
}
|
||||
|
||||
const trimmed = name.trim();
|
||||
|
||||
// Match backend regex: ^[\w '-]+$ with unicode
|
||||
// \w matches Unicode letters, digits, and underscores
|
||||
// We also allow spaces, hyphens, and apostrophes
|
||||
const validPattern = /^[\w '-]+$/u;
|
||||
|
||||
if (!validPattern.test(trimmed)) {
|
||||
return "Name contains invalid characters. Only letters, digits, spaces, hyphens, underscores, and apostrophes are allowed.";
|
||||
}
|
||||
|
||||
if (trimmed.length > 100) {
|
||||
return "Name is too long (max 100 characters)";
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the name is the default Letta Code agent name.
|
||||
*/
|
||||
export function isDefaultAgentName(name: string): boolean {
|
||||
return name === DEFAULT_AGENT_NAME;
|
||||
}
|
||||
|
||||
export function PinDialog({
|
||||
currentName,
|
||||
local,
|
||||
onSubmit,
|
||||
onCancel,
|
||||
}: PinDialogProps) {
|
||||
const isDefault = isDefaultAgentName(currentName);
|
||||
const [mode, setMode] = useState<"choose" | "input">(
|
||||
isDefault ? "input" : "choose",
|
||||
);
|
||||
const [nameInput, setNameInput] = useState("");
|
||||
const [selectedOption, setSelectedOption] = useState(0);
|
||||
const [error, setError] = useState("");
|
||||
|
||||
const scopeText = local ? "to this project" : "globally";
|
||||
|
||||
useInput((input, key) => {
|
||||
if (key.escape) {
|
||||
if (mode === "input" && !isDefault) {
|
||||
// Go back to choose mode
|
||||
setMode("choose");
|
||||
setError("");
|
||||
} else {
|
||||
onCancel();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (mode === "choose") {
|
||||
if (input === "j" || key.downArrow) {
|
||||
setSelectedOption((prev) => Math.min(prev + 1, 1));
|
||||
} else if (input === "k" || key.upArrow) {
|
||||
setSelectedOption((prev) => Math.max(prev - 1, 0));
|
||||
} else if (key.return) {
|
||||
if (selectedOption === 0) {
|
||||
// Keep current name
|
||||
onSubmit(null);
|
||||
} else {
|
||||
// Change name
|
||||
setMode("input");
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const handleNameSubmit = (text: string) => {
|
||||
const trimmed = text.trim();
|
||||
const validationError = validateAgentName(trimmed);
|
||||
|
||||
if (validationError) {
|
||||
setError(validationError);
|
||||
return;
|
||||
}
|
||||
|
||||
onSubmit(trimmed);
|
||||
};
|
||||
|
||||
// Input-only mode for default names
|
||||
if (isDefault || mode === "input") {
|
||||
return (
|
||||
<Box flexDirection="column" paddingY={1}>
|
||||
<Box marginBottom={1}>
|
||||
<Text color={colors.approval.header} bold>
|
||||
{isDefault ? "Name your agent" : "Rename your agent"}
|
||||
</Text>
|
||||
</Box>
|
||||
|
||||
{isDefault && (
|
||||
<Box marginBottom={1}>
|
||||
<Text dimColor>
|
||||
Give your agent a memorable name before pinning {scopeText}.
|
||||
</Text>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
<Box flexDirection="column" marginBottom={1}>
|
||||
<Box marginBottom={1}>
|
||||
<Text>Enter a name:</Text>
|
||||
</Box>
|
||||
<Box>
|
||||
<Text color={colors.approval.header}>> </Text>
|
||||
<PasteAwareTextInput
|
||||
value={nameInput}
|
||||
onChange={(val) => {
|
||||
setNameInput(val);
|
||||
setError("");
|
||||
}}
|
||||
onSubmit={handleNameSubmit}
|
||||
placeholder={isDefault ? "e.g., my-coding-agent" : currentName}
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{error && (
|
||||
<Box marginBottom={1}>
|
||||
<Text color="red">{error}</Text>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
<Box>
|
||||
<Text dimColor>
|
||||
Press Enter to confirm {!isDefault && "• Esc to go back"}
|
||||
{isDefault && "• Esc to cancel"}
|
||||
</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
// Choice mode for custom names
|
||||
return (
|
||||
<Box flexDirection="column" paddingY={1}>
|
||||
<Box marginBottom={1}>
|
||||
<Text color={colors.approval.header} bold>
|
||||
Pin agent {scopeText}
|
||||
</Text>
|
||||
</Box>
|
||||
|
||||
<Box marginBottom={1}>
|
||||
<Text dimColor>
|
||||
Would you like to keep the current name or change it?
|
||||
</Text>
|
||||
</Box>
|
||||
|
||||
<Box flexDirection="column" marginBottom={1}>
|
||||
{[
|
||||
{ label: `Keep name "${currentName}"`, value: "keep" },
|
||||
{ label: "Change name", value: "change" },
|
||||
].map((option, index) => {
|
||||
const isSelected = index === selectedOption;
|
||||
return (
|
||||
<Box key={option.value} flexDirection="row">
|
||||
<Box width={2} flexShrink={0}>
|
||||
<Text color={isSelected ? colors.approval.header : undefined}>
|
||||
{isSelected ? ">" : " "}
|
||||
</Text>
|
||||
</Box>
|
||||
<Text color={isSelected ? colors.approval.header : undefined}>
|
||||
{index + 1}. {option.label}
|
||||
</Text>
|
||||
</Box>
|
||||
);
|
||||
})}
|
||||
</Box>
|
||||
|
||||
<Box>
|
||||
<Text dimColor>↑↓/jk to select • Enter to confirm • Esc to cancel</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -6,3 +6,8 @@
|
||||
* Default model ID to use when no model is specified
|
||||
*/
|
||||
export const DEFAULT_MODEL_ID = "sonnet-4.5";
|
||||
|
||||
/**
|
||||
* Default agent name when creating a new agent
|
||||
*/
|
||||
export const DEFAULT_AGENT_NAME = "letta-code-agent";
|
||||
|
||||
Reference in New Issue
Block a user