feat(cli): add /skills overlay to browse available skills (#961)
Co-authored-by: Letta <noreply@letta.com>
This commit is contained in:
@@ -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<ActiveOverlay>(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" && <HelpDialog onClose={closeOverlay} />}
|
||||
|
||||
{/* Skills Dialog - browse available skills */}
|
||||
{activeOverlay === "skills" && (
|
||||
<SkillsDialog onClose={closeOverlay} agentId={agentId} />
|
||||
)}
|
||||
|
||||
{/* Hooks Manager - for managing hooks configuration */}
|
||||
{activeOverlay === "hooks" && (
|
||||
<HooksManager onClose={closeOverlay} agentId={agentId} />
|
||||
|
||||
@@ -44,9 +44,17 @@ export const commands: Record<string, Command> = {
|
||||
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...";
|
||||
|
||||
255
src/cli/components/SkillsDialog.tsx
Normal file
255
src/cli/components/SkillsDialog.tsx
Normal file
@@ -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<SkillTab, string> = {
|
||||
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<Skill[]>([]);
|
||||
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<SkillSource, Skill[]>();
|
||||
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<SkillTab | null>(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 = () => (
|
||||
<Box flexDirection="row" gap={2}>
|
||||
{availableTabs.map((tab) => {
|
||||
const isActive = tab === activeTab;
|
||||
return (
|
||||
<Text
|
||||
key={tab}
|
||||
backgroundColor={
|
||||
isActive ? colors.selector.itemHighlighted : undefined
|
||||
}
|
||||
color={isActive ? "black" : undefined}
|
||||
bold={isActive}
|
||||
>
|
||||
{` ${getTabLabel(tab)} `}
|
||||
</Text>
|
||||
);
|
||||
})}
|
||||
</Box>
|
||||
);
|
||||
|
||||
// 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 (
|
||||
<Box flexDirection="column">
|
||||
{/* Command header */}
|
||||
<Text dimColor>{"> /skills"}</Text>
|
||||
<Text dimColor>{solidLine}</Text>
|
||||
|
||||
<Box height={1} />
|
||||
|
||||
{/* Title and tabs */}
|
||||
<Box flexDirection="column" gap={1} marginBottom={1}>
|
||||
<Text bold color={colors.selector.title}>
|
||||
Skills ({loadedCount} currently available)
|
||||
</Text>
|
||||
|
||||
{loading && (
|
||||
<Box paddingLeft={2}>
|
||||
<Text dimColor>Loading skills...</Text>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{!loading && skills.length === 0 && (
|
||||
<Box flexDirection="column" paddingLeft={2}>
|
||||
<Text dimColor>No skills found</Text>
|
||||
<Text dimColor>Create skills in .skills/ or ~/.letta/skills/</Text>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{!loading && skills.length > 0 && (
|
||||
<Box flexDirection="column" paddingLeft={1}>
|
||||
{renderTabBar()}
|
||||
{activeTab && (
|
||||
<Text dimColor> {getTabDescription(activeTab, agentId)}</Text>
|
||||
)}
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{/* Skill list for active tab */}
|
||||
{!loading && currentSkills.length > 0 && (
|
||||
<Box flexDirection="column">
|
||||
{visibleSkills.map((skill) => {
|
||||
const tokens = charsToTokens(skill.description.length);
|
||||
return (
|
||||
<Text key={skill.id}>
|
||||
{" "}
|
||||
{skill.id}
|
||||
<Text dimColor> · ~{tokens} description tokens</Text>
|
||||
</Text>
|
||||
);
|
||||
})}
|
||||
{showScrollDown ? (
|
||||
<Text dimColor>
|
||||
{" "}↓ {itemsBelow} more below
|
||||
</Text>
|
||||
) : currentSkills.length > VISIBLE_ITEMS ? (
|
||||
<Text> </Text>
|
||||
) : null}
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* Footer */}
|
||||
<Box marginTop={1}>
|
||||
<Text dimColor>
|
||||
{" "}
|
||||
{availableTabs.length > 1
|
||||
? "↑↓ scroll · ←→/Tab switch · Esc to close"
|
||||
: "Esc to close"}
|
||||
</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user