diff --git a/bun.lock b/bun.lock index 0c01747..1e0e21c 100644 --- a/bun.lock +++ b/bun.lock @@ -12,6 +12,10 @@ "@ai-sdk/openai": "^3.0.52", "@ai-sdk/xai": "^3.0.82", "@base-ui/react": "^1.3.0", + "@dnd-kit/core": "^6.3.1", + "@dnd-kit/modifiers": "^9.0.0", + "@dnd-kit/sortable": "^10.0.0", + "@dnd-kit/utilities": "^3.2.2", "@modelcontextprotocol/sdk": "^1.29.0", "@radix-ui/react-hover-card": "^1.1.15", "@radix-ui/react-label": "^2.1.8", @@ -206,6 +210,16 @@ "@date-fns/tz": ["@date-fns/tz@1.4.1", "", {}, "sha512-P5LUNhtbj6YfI3iJjw5EL9eUAG6OitD0W3fWQcpQjDRc/QIsL0tRNuO1PcDvPccWL1fSTXXdE1ds+l95DV/OFA=="], + "@dnd-kit/accessibility": ["@dnd-kit/accessibility@3.1.1", "", { "dependencies": { "tslib": "^2.0.0" }, "peerDependencies": { "react": ">=16.8.0" } }, "sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw=="], + + "@dnd-kit/core": ["@dnd-kit/core@6.3.1", "", { "dependencies": { "@dnd-kit/accessibility": "^3.1.1", "@dnd-kit/utilities": "^3.2.2", "tslib": "^2.0.0" }, "peerDependencies": { "react": ">=16.8.0", "react-dom": ">=16.8.0" } }, "sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ=="], + + "@dnd-kit/modifiers": ["@dnd-kit/modifiers@9.0.0", "", { "dependencies": { "@dnd-kit/utilities": "^3.2.2", "tslib": "^2.0.0" }, "peerDependencies": { "@dnd-kit/core": "^6.3.0", "react": ">=16.8.0" } }, "sha512-ybiLc66qRGuZoC20wdSSG6pDXFikui/dCNGthxv4Ndy8ylErY0N3KVxY2bgo7AWwIbxDmXDg3ylAFmnrjcbVvw=="], + + "@dnd-kit/sortable": ["@dnd-kit/sortable@10.0.0", "", { "dependencies": { "@dnd-kit/utilities": "^3.2.2", "tslib": "^2.0.0" }, "peerDependencies": { "@dnd-kit/core": "^6.3.0", "react": ">=16.8.0" } }, "sha512-+xqhmIIzvAYMGfBYYnbKuNicfSsk4RksY2XdmJhT+HAC01nix6fHCztU68jooFiMUB01Ky3F0FyOvhG/BZrWkg=="], + + "@dnd-kit/utilities": ["@dnd-kit/utilities@3.2.2", "", { "dependencies": { "tslib": "^2.0.0" }, "peerDependencies": { "react": ">=16.8.0" } }, "sha512-+MKAJEOfaBe5SmV6t34p80MMKhjvUz0vRrvVJbPT0WElzaOJ/1xs+D+KDv+tD/NE5ujfrChEcshd4fLn0wpiqg=="], + "@dotenvx/dotenvx": ["@dotenvx/dotenvx@1.61.0", "", { "dependencies": { "commander": "^11.1.0", "dotenv": "^17.2.1", "eciesjs": "^0.4.10", "execa": "^5.1.1", "fdir": "^6.2.0", "ignore": "^5.3.0", "object-treeify": "1.1.33", "picomatch": "^4.0.2", "which": "^4.0.0", "yocto-spinner": "^1.1.0" }, "bin": { "dotenvx": "src/cli/dotenvx.js" } }, "sha512-utL3cpZoFzflyqUkjYbxYujI6STBTmO5LFn4bbin/NZnRWN6wQ7eErhr3/Vpa5h/jicPFC6kTa42r940mQftJQ=="], "@ecies/ciphers": ["@ecies/ciphers@0.2.6", "", { "peerDependencies": { "@noble/ciphers": "^1.0.0" } }, "sha512-patgsRPKGkhhoBjETV4XxD0En4ui5fbX0hzayqI3M8tvNMGUoUvmyYAIWwlxBc1KX5cturfqByYdj5bYGRpN9g=="], diff --git a/components.json b/components.json index c27d912..4c284e8 100644 --- a/components.json +++ b/components.json @@ -12,6 +12,8 @@ }, "iconLibrary": "lucide", "rtl": false, + "menuColor": "default", + "menuAccent": "subtle", "aliases": { "components": "@/components", "utils": "@/lib/utils", @@ -19,9 +21,8 @@ "lib": "@/lib", "hooks": "@/hooks" }, - "menuColor": "default", - "menuAccent": "subtle", "registries": { - "@ai-elements": "https://ai-sdk.dev/elements/api/registry/{name}.json" + "@ai-elements": "https://ai-sdk.dev/elements/api/registry/{name}.json", + "@diceui": "https://diceui.com/r/{name}.json" } } diff --git a/package.json b/package.json index 91c5271..a897852 100644 --- a/package.json +++ b/package.json @@ -29,6 +29,10 @@ "@ai-sdk/openai": "^3.0.52", "@ai-sdk/xai": "^3.0.82", "@base-ui/react": "^1.3.0", + "@dnd-kit/core": "^6.3.1", + "@dnd-kit/modifiers": "^9.0.0", + "@dnd-kit/sortable": "^10.0.0", + "@dnd-kit/utilities": "^3.2.2", "@modelcontextprotocol/sdk": "^1.29.0", "@radix-ui/react-hover-card": "^1.1.15", "@radix-ui/react-label": "^2.1.8", diff --git a/src/components/tool-editor/command-tree.tsx b/src/components/tool-editor/command-tree.tsx index c902046..c22a4f9 100644 --- a/src/components/tool-editor/command-tree.tsx +++ b/src/components/tool-editor/command-tree.tsx @@ -5,7 +5,7 @@ import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import { ScrollArea } from "@/components/ui/scroll-area"; import { ChevronDownIcon, ChevronRightIcon, Edit2Icon, PlusIcon, Trash2Icon } from "lucide-react"; -import { useState, useEffect } from "react"; +import { useState } from "react"; interface CommandNodeProps { command: Command; @@ -141,24 +141,15 @@ function CommandNode({ } export function CommandTree() { - const { - tool, - selectedCommand, - editingCommand, - addSubcommand, - setSelectedCommand, - setEditingCommand, - deleteCommand, - } = useToolBuilder(); + const { tool, selectedCommand, addCommand, setSelectedCommand, updateCommand, deleteCommand } = + useToolBuilder(); const [expandedCommands, setExpandedCommands] = useState>( new Set([tool.commands[0]?.key]), ); const [isDialogOpen, setIsDialogOpen] = useState(false); - const [lastAddedCommand, setLastAddedCommand] = useState<{ - key: string; - parentKey?: string; - } | null>(null); + const [dialogCommand, setDialogCommand] = useState(undefined); + const [pendingParentKey, setPendingParentKey] = useState(undefined); const toggleExpanded = (commandKey: string) => { setExpandedCommands((prev) => { @@ -173,30 +164,31 @@ export function CommandTree() { }; const handleAddSubcommand = (parentKey?: string) => { - const newKey = addSubcommand(parentKey); - setLastAddedCommand({ key: newKey, parentKey }); + setDialogCommand(undefined); + setPendingParentKey(parentKey); + setIsDialogOpen(true); + }; + + const handleEdit = (command: Command) => { + setDialogCommand(command); + setPendingParentKey(undefined); + setIsDialogOpen(true); }; - useEffect(() => { - if (!lastAddedCommand) return; - const { key, parentKey } = lastAddedCommand; - const newCmd = tool.commands.find((cmd) => cmd.key === key); - if (newCmd) { - setSelectedCommand(newCmd); - if (parentKey) { + const handleDialogSave = (savedCommand: Command) => { + if (!dialogCommand) { + addCommand(savedCommand); + setSelectedCommand(savedCommand); + if (savedCommand.parentCommandKey) { setExpandedCommands((prev) => { const newSet = new Set(prev); - newSet.add(parentKey); + newSet.add(savedCommand.parentCommandKey!); return newSet; }); } - setLastAddedCommand(null); + } else { + updateCommand(savedCommand.key, savedCommand); } - }, [tool.commands, lastAddedCommand, setSelectedCommand]); - - const handleEdit = (command: Command) => { - setEditingCommand(command); - setIsDialogOpen(true); }; const rootCommands = tool.commands.filter((cmd) => !cmd.parentCommandKey); @@ -231,15 +223,21 @@ export function CommandTree() { - {editingCommand && ( - { - setIsDialogOpen(open); - setEditingCommand(null); - }} - /> - )} + { + setIsDialogOpen(open); + if (!open) { + setDialogCommand(undefined); + setPendingParentKey(undefined); + } + }} + command={dialogCommand} + parentKey={pendingParentKey} + toolName={tool.name} + onSave={handleDialogSave} + /> ); } diff --git a/src/components/tool-editor/dialogs/command-dialog.tsx b/src/components/tool-editor/dialogs/command-dialog.tsx index ba37993..88a0a56 100644 --- a/src/components/tool-editor/dialogs/command-dialog.tsx +++ b/src/components/tool-editor/dialogs/command-dialog.tsx @@ -1,5 +1,5 @@ -import { useToolBuilder } from "../tool-editor.context"; import { Command } from "@/components/commandly/types/flat"; +import { slugify } from "@/components/commandly/utils/flat"; import { Button } from "@/components/ui/button"; import { Dialog, @@ -19,13 +19,32 @@ import { useState } from "react"; interface CommandDialogProps { isOpen: boolean; onOpenChange: (open: boolean) => void; + command?: Command; + parentKey?: string; + toolName: string; + onSave: (command: Command) => void; } -export function CommandDialog({ isOpen, onOpenChange }: CommandDialogProps) { - const { editingCommand, tool, updateCommand } = useToolBuilder(); - const command = editingCommand; - const toolName = tool.name; - const [editCommand, setCommand] = useState(command!); +export function CommandDialog({ + isOpen, + onOpenChange, + command, + parentKey, + toolName, + onSave, +}: CommandDialogProps) { + const isNewCommand = !command; + const [editCommand, setCommand] = useState( + () => + command ?? { + key: "", + name: "", + description: "", + isDefault: false, + sortOrder: 0, + parentCommandKey: parentKey, + }, + ); return ( - Edit Command Settings + {isNewCommand ? "Add Command" : "Edit Command Settings"} Dialog for editing command details @@ -47,7 +66,7 @@ export function CommandDialog({ isOpen, onOpenChange }: CommandDialogProps) { setCommand((prev) => ({ ...prev, @@ -76,8 +95,8 @@ export function CommandDialog({ isOpen, onOpenChange }: CommandDialogProps) {
{ setCommand((prev) => ({ ...prev, @@ -115,12 +134,16 @@ export function CommandDialog({ isOpen, onOpenChange }: CommandDialogProps) { diff --git a/src/components/tool-editor/dialogs/parameter-details-dialog.tsx b/src/components/tool-editor/dialogs/parameter-details-dialog.tsx index 6af40eb..39f380a 100644 --- a/src/components/tool-editor/dialogs/parameter-details-dialog.tsx +++ b/src/components/tool-editor/dialogs/parameter-details-dialog.tsx @@ -11,7 +11,7 @@ import { ParameterValidationType, } from "@/components/commandly/types/flat"; import { TagsInput } from "@/components/commandly/ui/tags-input"; -import { slugify } from "@/components/commandly/utils/flat"; +import { createNewParameter, slugify } from "@/components/commandly/utils/flat"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import { @@ -32,40 +32,62 @@ import { SelectValue, } from "@/components/ui/select"; import { Separator } from "@/components/ui/separator"; +import { + Sortable, + SortableContent, + SortableItem, + SortableItemHandle, + SortableOverlay, +} from "@/components/ui/sortable"; import { Switch } from "@/components/ui/switch"; import { Textarea } from "@/components/ui/textarea"; import { FlagIcon, FileTextIcon, + GripVerticalIcon, HashIcon, LinkIcon, PlusIcon, ShieldIcon, Trash2Icon, } from "lucide-react"; -import { useEffect, useState } from "react"; +import { useRef, useState } from "react"; + +const DATA_TYPE_OPTIONS: Record = { + Flag: ["Boolean"], + Option: ["String", "Number", "Boolean", "Enum"], + Argument: ["String", "Number", "Boolean", "Enum"], +}; + +interface ParameterDetailsDialogProps { + parameter?: Parameter; + isGlobal?: boolean; + onSave: (parameter: Parameter) => void; +} -export function ParameterDetailsDialog() { - const { selectedParameter, selectedCommand, tool, setSelectedParameter, upsertParameter } = - useToolBuilder(); +export function ParameterDetailsDialog({ + parameter, + isGlobal = false, + onSave, +}: ParameterDetailsDialogProps) { + const { selectedCommand, tool, setSelectedParameter } = useToolBuilder(); const commandKey = selectedCommand?.key; + const isNewParameter = !parameter; - const availableParameters = selectedParameter - ? tool.parameters.filter((p) => { - if (p.key === selectedParameter.key) return false; - if (selectedParameter.isGlobal) return p.isGlobal; - return p.isGlobal || p.commandKey === commandKey; - }) - : []; - - const [parameter, setParameter] = useState(selectedParameter!); + const [internalParameter, setParameter] = useState( + () => parameter ?? createNewParameter(isGlobal, commandKey), + ); const [hasChanges, setHasChanges] = useState(false); + const enumValueIdsRef = useRef( + (parameter?.enum?.values ?? []).map(() => Math.random().toString(36).slice(2, 9)), + ); - useEffect(() => { - setParameter(selectedParameter!); - setHasChanges(false); - }, [selectedParameter]); + const availableParameters = tool.parameters.filter((p) => { + if (parameter && p.key === parameter.key) return false; + if (parameter?.isGlobal) return p.isGlobal; + return p.isGlobal || p.commandKey === commandKey; + }); const getParameterIcon = (parameterType: string) => { switch (parameterType) { @@ -80,19 +102,13 @@ export function ParameterDetailsDialog() { } }; - const isOpen = !!selectedParameter; - const handleClose = () => { setSelectedParameter(null); setHasChanges(false); }; const handleSave = () => { - if (parameter) { - upsertParameter(parameter); - setHasChanges(false); - handleClose(); - } + onSave(internalParameter); }; const updateParameter = (updates: Partial) => { @@ -102,7 +118,7 @@ export function ParameterDetailsDialog() { let generatedKey = slugify(next.longFlag || next.name); const existingParam = tool.parameters.find( - (p) => p.key === generatedKey && p.key !== selectedParameter?.key, + (p) => p.key === generatedKey && p.key !== parameter?.key, ); // If duplicate exists and parameter is not global, prefix with command key if (existingParam && !next.isGlobal && commandKey) { @@ -165,50 +181,54 @@ export function ParameterDetailsDialog() { const addEnumValue = () => { if (!parameter) return; const newEnumValue: ParameterEnumValue = { - value: "new-value", - displayName: "New Value", + value: "", + displayName: "", isDefault: false, - sortOrder: 0, + sortOrder: parameter.enum?.values?.length ?? 0, }; + enumValueIdsRef.current = [...enumValueIdsRef.current, Math.random().toString(36).slice(2, 9)]; const existing = parameter.enum ?? { values: [], allowMultiple: false }; updateParameter({ enum: { ...existing, values: [...existing.values, newEnumValue] }, }); }; - if (!parameter) return null; - const canSaveChanges = () => { if (!hasChanges) return false; - if (!parameter.name.trim() || !parameter.longFlag?.trim()) { + if (!internalParameter.name.trim() || !internalParameter.longFlag?.trim()) { return false; } if ( availableParameters.some( (p) => - p.name.trim() === parameter.name.trim() || - parameter.longFlag == p.longFlag || - (parameter.shortFlag && parameter.shortFlag == p.shortFlag), + p.name.trim() === internalParameter.name.trim() || + internalParameter.longFlag == p.longFlag || + (internalParameter.shortFlag && internalParameter.shortFlag == p.shortFlag), ) ) { return false; } + if (internalParameter.dataType === "Enum") { + if (!internalParameter.enum?.values?.length) return false; + if (internalParameter.enum.values.some((v) => !v.value.trim())) return false; + } + return true; }; return ( - {getParameterIcon(parameter.parameterType)} - {parameter.name} - {parameter.isGlobal && ( + {getParameterIcon(internalParameter.parameterType)} + {internalParameter.name} + {internalParameter.isGlobal && ( * updateParameter({ name: e.target.value })} />
@@ -238,8 +258,17 @@ export function ParameterDetailsDialog() {
updateParameter({ dataType: value })} > - String - Number - Boolean - Enum + {DATA_TYPE_OPTIONS[internalParameter.parameterType].map((dataType) => ( + + {dataType} + + ))}
updateParameter({ isGlobal: checked })} />
- {(parameter.parameterType === "Flag" || parameter.parameterType === "Option") && ( + {(internalParameter.parameterType === "Flag" || + internalParameter.parameterType === "Option") && (
updateParameter({ shortFlag: e.target.value })} placeholder="-v" /> @@ -293,7 +327,7 @@ export function ParameterDetailsDialog() { Long Flag (include prefix) * updateParameter({ longFlag: e.target.value })} placeholder="--verbose" /> @@ -301,27 +335,27 @@ export function ParameterDetailsDialog() {
)} - {parameter.parameterType === "Option" && ( + {internalParameter.parameterType === "Option" && (
updateParameter({ keyValueSeparator: e.target.value })} placeholder="Default is single space" />
)} - {parameter.parameterType === "Argument" && ( + {internalParameter.parameterType === "Argument" && (
updateParameter({ position: Number.parseInt(e.target.value) || 0, @@ -335,7 +369,7 @@ export function ParameterDetailsDialog() {