diff --git a/src/cli/App.tsx b/src/cli/App.tsx index 8157baf..22416ca 100644 --- a/src/cli/App.tsx +++ b/src/cli/App.tsx @@ -135,6 +135,7 @@ import { PinDialog, validateAgentName } from "./components/PinDialog"; import { ProviderSelector } from "./components/ProviderSelector"; import { ReasoningMessage } from "./components/ReasoningMessageRich"; import { formatDuration, formatUsageStats } from "./components/SessionStats"; +import { SkillsDialog } from "./components/SkillsDialog"; import { SleeptimeSelector } from "./components/SleeptimeSelector"; // InlinePlanApproval kept for easy rollback if needed // import { InlinePlanApproval } from "./components/InlinePlanApproval"; @@ -1190,6 +1191,7 @@ export default function App({ | "help" | "hooks" | "connect" + | "skills" | null; const [activeOverlay, setActiveOverlay] = useState(null); const pendingOverlayCommandRef = useRef<{ @@ -7551,9 +7553,23 @@ export default function App({ return { submitted: true }; } - // Special handling for /skill command - enter skill creation mode - if (trimmed.startsWith("/skill")) { - // Extract optional description after `/skill` + // /skills - browse available skills overlay + if (trimmed === "/skills") { + startOverlayCommand( + "skills", + "/skills", + "Opening skills browser...", + "Skills browser dismissed", + ); + setActiveOverlay("skills"); + return { submitted: true }; + } + + // /skill-creator - enter skill creation mode + if ( + trimmed === "/skill-creator" || + trimmed.startsWith("/skill-creator ") + ) { const [, ...rest] = trimmed.split(/\s+/); const description = rest.join(" ").trim(); @@ -7567,7 +7583,7 @@ export default function App({ const approvalCheck = await checkPendingApprovalsForSlashCommand(); if (approvalCheck.blocked) { cmd.fail( - "Pending approval(s). Resolve approvals before running /skill.", + "Pending approval(s). Resolve approvals before running /skill-creator.", ); return { submitted: false }; // Keep /skill in input box, user handles approval first } @@ -7583,7 +7599,7 @@ export default function App({ // Build system-reminder content for skill creation const userDescriptionLine = description ? `\n\nUser-provided skill description:\n${description}` - : "\n\nThe user did not provide a description with /skill. Ask what kind of skill they want to create before proceeding."; + : "\n\nThe user did not provide a description with /skill-creator. Ask what kind of skill they want to create before proceeding."; const skillMessage = `${SYSTEM_REMINDER_OPEN}\n${SKILL_CREATOR_PROMPT}${userDescriptionLine}\n${SYSTEM_REMINDER_CLOSE}`; @@ -11676,6 +11692,11 @@ Plan file path: ${planFilePath}`; {/* Help Dialog - conditionally mounted as overlay */} {activeOverlay === "help" && } + {/* Skills Dialog - browse available skills */} + {activeOverlay === "skills" && ( + + )} + {/* Hooks Manager - for managing hooks configuration */} {activeOverlay === "hooks" && ( diff --git a/src/cli/commands/registry.ts b/src/cli/commands/registry.ts index 0e34a55..7d33031 100644 --- a/src/cli/commands/registry.ts +++ b/src/cli/commands/registry.ts @@ -44,9 +44,17 @@ export const commands: Record = { return "Processing memory request..."; }, }, - "/skill": { - desc: "Enter skill creation mode (/skill [description])", - order: 28, // Advanced feature, moved below visible commands + "/skills": { + desc: "Browse available skills", + order: 28, + handler: () => { + // Handled specially in App.tsx to open skills browser overlay + return "Opening skills browser..."; + }, + }, + "/skill-creator": { + desc: "Enter skill creation mode (/skill-creator [description])", + order: 28.5, handler: () => { // Handled specially in App.tsx to trigger skill-creation workflow return "Starting skill creation..."; diff --git a/src/cli/components/SkillsDialog.tsx b/src/cli/components/SkillsDialog.tsx new file mode 100644 index 0000000..98a951a --- /dev/null +++ b/src/cli/components/SkillsDialog.tsx @@ -0,0 +1,255 @@ +import { Box, useInput } from "ink"; +import { useCallback, useEffect, useMemo, useState } from "react"; +import type { Skill, SkillSource } from "../../agent/skills"; +import { charsToTokens } from "../helpers/format"; +import { useTerminalWidth } from "../hooks/useTerminalWidth"; +import { colors } from "./colors"; +import { Text } from "./Text"; + +const SOLID_LINE = "─"; +const VISIBLE_ITEMS = 5; + +type SkillTab = SkillSource; + +const TAB_ORDER: SkillTab[] = ["project", "agent", "global", "bundled"]; + +const TAB_LABELS: Record = { + project: "Project", + agent: "Agent", + global: "Global", + bundled: "Bundled", +}; + +function getTabDescription(tab: SkillTab, agentId: string): string { + const shortId = agentId.length > 20 ? `${agentId.slice(0, 20)}...` : agentId; + switch (tab) { + case "project": + return ".skills/"; + case "agent": + return `~/.letta/agents/${shortId}/skills/`; + case "global": + return "~/.letta/skills/"; + case "bundled": + return "Built-in skills shipped with Letta Code"; + } +} + +interface SkillsDialogProps { + onClose: () => void; + agentId: string; +} + +export function SkillsDialog({ onClose, agentId }: SkillsDialogProps) { + const terminalWidth = useTerminalWidth(); + const solidLine = SOLID_LINE.repeat(Math.max(terminalWidth, 10)); + const [skills, setSkills] = useState([]); + const [loading, setLoading] = useState(true); + const [scrollOffset, setScrollOffset] = useState(0); + + useEffect(() => { + (async () => { + try { + const { discoverSkills, SKILLS_DIR } = await import( + "../../agent/skills" + ); + const { getSkillsDirectory, getNoSkills } = await import( + "../../agent/context" + ); + const { join } = await import("node:path"); + const skillsDir = + getSkillsDirectory() || join(process.cwd(), SKILLS_DIR); + const result = await discoverSkills(skillsDir, agentId, { + skipBundled: getNoSkills(), + }); + setSkills(result.skills); + } catch { + setSkills([]); + } finally { + setLoading(false); + } + })(); + }, [agentId]); + + // Group skills by source + const skillsBySource = useMemo(() => { + const grouped = new Map(); + for (const skill of skills) { + const list = grouped.get(skill.source) ?? []; + list.push(skill); + grouped.set(skill.source, list); + } + return grouped; + }, [skills]); + + // Only show tabs that have skills + const availableTabs = useMemo( + () => TAB_ORDER.filter((tab) => (skillsBySource.get(tab)?.length ?? 0) > 0), + [skillsBySource], + ); + + const [activeTab, setActiveTab] = useState(null); + + // Set initial tab once skills load + useEffect(() => { + if (!loading && availableTabs.length > 0 && activeTab === null) { + setActiveTab(availableTabs[0] ?? null); + } + }, [loading, availableTabs, activeTab]); + + const cycleTab = useCallback( + (direction: 1 | -1) => { + if (availableTabs.length === 0) return; + setActiveTab((current) => { + const idx = current ? availableTabs.indexOf(current) : 0; + const next = + (idx + direction + availableTabs.length) % availableTabs.length; + return availableTabs[next] ?? current; + }); + setScrollOffset(0); + }, + [availableTabs], + ); + + const currentSkills = useMemo( + () => (activeTab ? (skillsBySource.get(activeTab) ?? []) : []), + [activeTab, skillsBySource], + ); + + const visibleSkills = useMemo( + () => currentSkills.slice(scrollOffset, scrollOffset + VISIBLE_ITEMS), + [currentSkills, scrollOffset], + ); + + const showScrollDown = scrollOffset + VISIBLE_ITEMS < currentSkills.length; + const itemsBelow = currentSkills.length - scrollOffset - VISIBLE_ITEMS; + + useInput( + useCallback( + (input, key) => { + if (key.ctrl && input === "c") { + onClose(); + return; + } + if (key.escape) { + onClose(); + } else if (key.tab || key.rightArrow) { + cycleTab(1); + } else if (key.leftArrow) { + cycleTab(-1); + } else if (key.downArrow) { + setScrollOffset((prev) => + Math.min( + prev + 1, + Math.max(0, currentSkills.length - VISIBLE_ITEMS), + ), + ); + } else if (key.upArrow) { + setScrollOffset((prev) => Math.max(0, prev - 1)); + } + }, + [onClose, cycleTab, currentSkills.length], + ), + { isActive: true }, + ); + + const getTabLabel = (tab: SkillTab) => { + const count = skillsBySource.get(tab)?.length ?? 0; + return `${TAB_LABELS[tab]} [${count}]`; + }; + + const renderTabBar = () => ( + + {availableTabs.map((tab) => { + const isActive = tab === activeTab; + return ( + + {` ${getTabLabel(tab)} `} + + ); + })} + + ); + + // Count currently loaded skills (skills in the loaded_skills memory block) + // For now, use total count since we don't track loaded state here + const loadedCount = skills.length; + + return ( + + {/* Command header */} + {"> /skills"} + {solidLine} + + + + {/* Title and tabs */} + + + Skills ({loadedCount} currently available) + + + {loading && ( + + Loading skills... + + )} + + {!loading && skills.length === 0 && ( + + No skills found + Create skills in .skills/ or ~/.letta/skills/ + + )} + + {!loading && skills.length > 0 && ( + + {renderTabBar()} + {activeTab && ( + {getTabDescription(activeTab, agentId)} + )} + + )} + + + {/* Skill list for active tab */} + {!loading && currentSkills.length > 0 && ( + + {visibleSkills.map((skill) => { + const tokens = charsToTokens(skill.description.length); + return ( + + {" "} + {skill.id} + · ~{tokens} description tokens + + ); + })} + {showScrollDown ? ( + + {" "}↓ {itemsBelow} more below + + ) : currentSkills.length > VISIBLE_ITEMS ? ( + + ) : null} + + )} + + {/* Footer */} + + + {" "} + {availableTabs.length > 1 + ? "↑↓ scroll · ←→/Tab switch · Esc to close" + : "Esc to close"} + + + + ); +}