import { Box, useInput } from "ink"; import { useState } from "react"; import { DEFAULT_AGENT_NAME } from "../../constants"; import { colors } from "./colors"; import { PasteAwareTextInput } from "./PasteAwareTextInput"; import { Text } from "./Text"; 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) => { // CTRL-C: immediately cancel (bypasses mode transitions) if (key.ctrl && input === "c") { onCancel(); return; } 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 ); }