diff --git a/src/agent/create.ts b/src/agent/create.ts index 10f2642..8217832 100644 --- a/src/agent/create.ts +++ b/src/agent/create.ts @@ -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, diff --git a/src/cli/App.tsx b/src/cli/App.tsx index 6f785f4..bd119e8 100644 --- a/src/cli/App.tsx +++ b/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(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" && ( + { + 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" && ( <> diff --git a/src/cli/components/PinDialog.tsx b/src/cli/components/PinDialog.tsx new file mode 100644 index 0000000..f1562c5 --- /dev/null +++ b/src/cli/components/PinDialog.tsx @@ -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 ( + + + + {isDefault ? "Name your agent" : "Rename your agent"} + + + + {isDefault && ( + + + Give your agent a memorable name before pinning {scopeText}. + + + )} + + + + Enter a name: + + + > + { + setNameInput(val); + setError(""); + }} + onSubmit={handleNameSubmit} + placeholder={isDefault ? "e.g., my-coding-agent" : currentName} + /> + + + + {error && ( + + {error} + + )} + + + + Press Enter to confirm {!isDefault && "• Esc to go back"} + {isDefault && "• Esc to cancel"} + + + + ); + } + + // Choice mode for custom names + return ( + + + + Pin agent {scopeText} + + + + + + Would you like to keep the current name or change it? + + + + + {[ + { label: `Keep name "${currentName}"`, value: "keep" }, + { label: "Change name", value: "change" }, + ].map((option, index) => { + const isSelected = index === selectedOption; + return ( + + + + {isSelected ? ">" : " "} + + + + {index + 1}. {option.label} + + + ); + })} + + + + ↑↓/jk to select • Enter to confirm • Esc to cancel + + + ); +} diff --git a/src/constants.ts b/src/constants.ts index 3d73772..964cb02 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -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";