feat: add /help command with interactive dialog (#303)
Co-authored-by: Letta <noreply@letta.com>
This commit is contained in:
@@ -49,6 +49,7 @@ import { CommandMessage } from "./components/CommandMessage";
|
||||
import { EnterPlanModeDialog } from "./components/EnterPlanModeDialog";
|
||||
import { ErrorMessage } from "./components/ErrorMessageRich";
|
||||
import { FeedbackDialog } from "./components/FeedbackDialog";
|
||||
import { HelpDialog } from "./components/HelpDialog";
|
||||
import { Input } from "./components/InputRich";
|
||||
import { MemoryViewer } from "./components/MemoryViewer";
|
||||
import { MessageSearch } from "./components/MessageSearch";
|
||||
@@ -409,6 +410,7 @@ export default function App({
|
||||
| "feedback"
|
||||
| "memory"
|
||||
| "pin"
|
||||
| "help"
|
||||
| null;
|
||||
const [activeOverlay, setActiveOverlay] = useState<ActiveOverlay>(null);
|
||||
const closeOverlay = useCallback(() => setActiveOverlay(null), []);
|
||||
@@ -1753,6 +1755,12 @@ export default function App({
|
||||
return { submitted: true };
|
||||
}
|
||||
|
||||
// Special handling for /help command - opens help dialog
|
||||
if (trimmed === "/help") {
|
||||
setActiveOverlay("help");
|
||||
return { submitted: true };
|
||||
}
|
||||
|
||||
// Special handling for /usage command - show session stats
|
||||
if (trimmed === "/usage") {
|
||||
const cmdId = uid("cmd");
|
||||
@@ -4601,6 +4609,9 @@ Plan file path: ${planFilePath}`;
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Help Dialog - conditionally mounted as overlay */}
|
||||
{activeOverlay === "help" && <HelpDialog onClose={closeOverlay} />}
|
||||
|
||||
{/* Pin Dialog - for naming agent before pinning */}
|
||||
{activeOverlay === "pin" && (
|
||||
<PinDialog
|
||||
|
||||
@@ -54,14 +54,14 @@ export const commands: Record<string, Command> = {
|
||||
},
|
||||
},
|
||||
"/rename": {
|
||||
desc: "Rename the current agent",
|
||||
desc: "Rename the current agent (/rename <name>)",
|
||||
handler: () => {
|
||||
// Handled specially in App.tsx to access agent ID and client
|
||||
return "Renaming agent...";
|
||||
},
|
||||
},
|
||||
"/description": {
|
||||
desc: "Update the current agent's description",
|
||||
desc: "Update the current agent's description (/description <text>)",
|
||||
handler: () => {
|
||||
// Handled specially in App.tsx to access agent ID and client
|
||||
return "Updating description...";
|
||||
@@ -119,14 +119,14 @@ export const commands: Record<string, Command> = {
|
||||
},
|
||||
},
|
||||
"/skill": {
|
||||
desc: "Enter skill creation mode (optionally: /skill <description>)",
|
||||
desc: "Enter skill creation mode (/skill [description])",
|
||||
handler: () => {
|
||||
// Handled specially in App.tsx to trigger skill-creation workflow
|
||||
return "Starting skill creation...";
|
||||
},
|
||||
},
|
||||
"/remember": {
|
||||
desc: "Remember something from the conversation",
|
||||
desc: "Remember something from the conversation (/remember [instructions])",
|
||||
handler: () => {
|
||||
// Handled specially in App.tsx to trigger memory update
|
||||
return "Processing memory request...";
|
||||
@@ -147,14 +147,14 @@ export const commands: Record<string, Command> = {
|
||||
},
|
||||
},
|
||||
"/pin": {
|
||||
desc: "Pin current agent globally (use -l for local only)",
|
||||
desc: "Pin current agent globally, or use -l for local only",
|
||||
handler: () => {
|
||||
// Handled specially in App.tsx
|
||||
return "Pinning agent...";
|
||||
},
|
||||
},
|
||||
"/unpin": {
|
||||
desc: "Unpin current agent globally (use -l for local only)",
|
||||
desc: "Unpin current agent globally, or use -l for local only",
|
||||
handler: () => {
|
||||
// Handled specially in App.tsx
|
||||
return "Unpinning agent...";
|
||||
@@ -195,6 +195,13 @@ export const commands: Record<string, Command> = {
|
||||
return "Fetching usage statistics...";
|
||||
},
|
||||
},
|
||||
"/help": {
|
||||
desc: "Show available commands",
|
||||
handler: () => {
|
||||
// Handled specially in App.tsx to open help dialog
|
||||
return "Opening help...";
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
233
src/cli/components/HelpDialog.tsx
Normal file
233
src/cli/components/HelpDialog.tsx
Normal file
@@ -0,0 +1,233 @@
|
||||
import { Box, Text, useInput } from "ink";
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
import { getVersion } from "../../version";
|
||||
import { commands } from "../commands/registry";
|
||||
import { colors } from "./colors";
|
||||
|
||||
const PAGE_SIZE = 10;
|
||||
|
||||
type HelpTab = "commands" | "shortcuts";
|
||||
const HELP_TABS: HelpTab[] = ["commands", "shortcuts"];
|
||||
|
||||
interface CommandItem {
|
||||
name: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
interface ShortcutItem {
|
||||
keys: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
interface HelpDialogProps {
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export function HelpDialog({ onClose }: HelpDialogProps) {
|
||||
const [activeTab, setActiveTab] = useState<HelpTab>("commands");
|
||||
const [currentPage, setCurrentPage] = useState(0);
|
||||
const [selectedIndex, setSelectedIndex] = useState(0);
|
||||
|
||||
// Get all non-hidden commands
|
||||
const allCommands = useMemo<CommandItem[]>(() => {
|
||||
return Object.entries(commands)
|
||||
.filter(([_, cmd]) => !cmd.hidden)
|
||||
.map(([name, cmd]) => ({
|
||||
name,
|
||||
description: cmd.desc,
|
||||
}))
|
||||
.sort((a, b) => a.name.localeCompare(b.name));
|
||||
}, []);
|
||||
|
||||
// Keyboard shortcuts
|
||||
const shortcuts = useMemo<ShortcutItem[]>(() => {
|
||||
return [
|
||||
{ keys: "/", description: "Open command autocomplete" },
|
||||
{ keys: "@", description: "Open file autocomplete" },
|
||||
{
|
||||
keys: "Esc",
|
||||
description: "Cancel dialog / clear input (double press)",
|
||||
},
|
||||
{ keys: "Tab", description: "Autocomplete command or file path" },
|
||||
{ keys: "↓", description: "Navigate down / next command in history" },
|
||||
{ keys: "↑", description: "Navigate up / previous command in history" },
|
||||
{
|
||||
keys: "Ctrl+C",
|
||||
description: "Interrupt operation / exit (double press)",
|
||||
},
|
||||
{ keys: "Ctrl+V", description: "Paste content or image" },
|
||||
];
|
||||
}, []);
|
||||
|
||||
const cycleTab = useCallback(() => {
|
||||
setActiveTab((current) => {
|
||||
const idx = HELP_TABS.indexOf(current);
|
||||
return HELP_TABS[(idx + 1) % HELP_TABS.length] as HelpTab;
|
||||
});
|
||||
setCurrentPage(0);
|
||||
setSelectedIndex(0);
|
||||
}, []);
|
||||
|
||||
const visibleItems = activeTab === "commands" ? allCommands : shortcuts;
|
||||
|
||||
const totalPages = Math.ceil(visibleItems.length / PAGE_SIZE);
|
||||
const startIndex = currentPage * PAGE_SIZE;
|
||||
const visiblePageItems = visibleItems.slice(
|
||||
startIndex,
|
||||
startIndex + PAGE_SIZE,
|
||||
);
|
||||
|
||||
useInput(
|
||||
useCallback(
|
||||
(input, key) => {
|
||||
if (key.escape) {
|
||||
onClose();
|
||||
} else if (key.tab) {
|
||||
cycleTab();
|
||||
} else if (key.upArrow) {
|
||||
setSelectedIndex((prev) => Math.max(0, prev - 1));
|
||||
} else if (key.downArrow) {
|
||||
setSelectedIndex((prev) =>
|
||||
Math.min(visiblePageItems.length - 1, prev + 1),
|
||||
);
|
||||
} else if (input === "j" || input === "J") {
|
||||
// Previous page
|
||||
if (currentPage > 0) {
|
||||
setCurrentPage((prev) => prev - 1);
|
||||
setSelectedIndex(0);
|
||||
}
|
||||
} else if (input === "k" || input === "K") {
|
||||
// Next page
|
||||
if (currentPage < totalPages - 1) {
|
||||
setCurrentPage((prev) => prev + 1);
|
||||
setSelectedIndex(0);
|
||||
}
|
||||
} else if (key.leftArrow && currentPage > 0) {
|
||||
setCurrentPage((prev) => prev - 1);
|
||||
setSelectedIndex(0);
|
||||
} else if (key.rightArrow && currentPage < totalPages - 1) {
|
||||
setCurrentPage((prev) => prev + 1);
|
||||
setSelectedIndex(0);
|
||||
}
|
||||
},
|
||||
[currentPage, totalPages, visiblePageItems.length, onClose, cycleTab],
|
||||
),
|
||||
{ isActive: true },
|
||||
);
|
||||
|
||||
const version = getVersion();
|
||||
|
||||
const getTabLabel = (tab: HelpTab) => {
|
||||
if (tab === "commands") return `Commands (${allCommands.length})`;
|
||||
return `Shortcuts (${shortcuts.length})`;
|
||||
};
|
||||
|
||||
return (
|
||||
<Box flexDirection="column" gap={1}>
|
||||
<Box flexDirection="column">
|
||||
<Text bold color={colors.selector.title}>
|
||||
Letta Code v{version} (↑↓ navigate, ←→/jk page, ESC close)
|
||||
</Text>
|
||||
<Box>
|
||||
<Text dimColor>Tab: </Text>
|
||||
{HELP_TABS.map((tab, i) => (
|
||||
<Text key={tab}>
|
||||
{i > 0 && <Text dimColor> · </Text>}
|
||||
<Text
|
||||
bold={tab === activeTab}
|
||||
color={
|
||||
tab === activeTab
|
||||
? colors.selector.itemHighlighted
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
{getTabLabel(tab)}
|
||||
</Text>
|
||||
</Text>
|
||||
))}
|
||||
<Text dimColor> (Tab to switch)</Text>
|
||||
</Box>
|
||||
<Text dimColor>
|
||||
Page {currentPage + 1}/{totalPages}
|
||||
</Text>
|
||||
</Box>
|
||||
|
||||
<Box flexDirection="column">
|
||||
{activeTab === "commands" &&
|
||||
(visiblePageItems as CommandItem[]).map((command, index) => {
|
||||
const isSelected = index === selectedIndex;
|
||||
|
||||
return (
|
||||
<Box key={command.name} flexDirection="row" gap={1}>
|
||||
<Text
|
||||
color={
|
||||
isSelected ? colors.selector.itemHighlighted : undefined
|
||||
}
|
||||
>
|
||||
{isSelected ? "›" : " "}
|
||||
</Text>
|
||||
<Box flexDirection="column">
|
||||
<Box flexDirection="row">
|
||||
<Text
|
||||
bold={isSelected}
|
||||
color={
|
||||
isSelected ? colors.selector.itemHighlighted : undefined
|
||||
}
|
||||
>
|
||||
{command.name}
|
||||
</Text>
|
||||
<Text dimColor> {command.description}</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
})}
|
||||
|
||||
{activeTab === "shortcuts" &&
|
||||
(visiblePageItems as ShortcutItem[]).map((shortcut, index) => {
|
||||
const isSelected = index === selectedIndex;
|
||||
|
||||
return (
|
||||
<Box key={shortcut.keys} flexDirection="row" gap={1}>
|
||||
<Text
|
||||
color={
|
||||
isSelected ? colors.selector.itemHighlighted : undefined
|
||||
}
|
||||
>
|
||||
{isSelected ? "›" : " "}
|
||||
</Text>
|
||||
<Box flexDirection="column">
|
||||
<Box flexDirection="row">
|
||||
<Text
|
||||
bold={isSelected}
|
||||
color={
|
||||
isSelected ? colors.selector.itemHighlighted : undefined
|
||||
}
|
||||
>
|
||||
{shortcut.keys}
|
||||
</Text>
|
||||
<Text dimColor> {shortcut.description}</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
})}
|
||||
</Box>
|
||||
|
||||
<Box flexDirection="column" marginTop={1}>
|
||||
<Text dimColor>Getting started:</Text>
|
||||
<Text dimColor>
|
||||
• Run <Text bold>/init</Text> to initialize agent memory for this
|
||||
project
|
||||
</Text>
|
||||
<Text dimColor>
|
||||
• Press <Text bold>/</Text> at any time to see command autocomplete
|
||||
</Text>
|
||||
<Text dimColor>
|
||||
• Visit <Text bold>https://docs.letta.com/letta-code</Text> for more
|
||||
help
|
||||
</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user