diff --git a/src/cli/App.tsx b/src/cli/App.tsx index 590b4dc..3855869 100644 --- a/src/cli/App.tsx +++ b/src/cli/App.tsx @@ -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(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" && } + {/* Pin Dialog - for naming agent before pinning */} {activeOverlay === "pin" && ( = { }, }, "/rename": { - desc: "Rename the current agent", + desc: "Rename the current agent (/rename )", 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 )", handler: () => { // Handled specially in App.tsx to access agent ID and client return "Updating description..."; @@ -119,14 +119,14 @@ export const commands: Record = { }, }, "/skill": { - desc: "Enter skill creation mode (optionally: /skill )", + 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 = { }, }, "/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 = { return "Fetching usage statistics..."; }, }, + "/help": { + desc: "Show available commands", + handler: () => { + // Handled specially in App.tsx to open help dialog + return "Opening help..."; + }, + }, }; /** diff --git a/src/cli/components/HelpDialog.tsx b/src/cli/components/HelpDialog.tsx new file mode 100644 index 0000000..b9042f7 --- /dev/null +++ b/src/cli/components/HelpDialog.tsx @@ -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("commands"); + const [currentPage, setCurrentPage] = useState(0); + const [selectedIndex, setSelectedIndex] = useState(0); + + // Get all non-hidden commands + const allCommands = useMemo(() => { + 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(() => { + 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 ( + + + + Letta Code v{version} (↑↓ navigate, ←→/jk page, ESC close) + + + Tab: + {HELP_TABS.map((tab, i) => ( + + {i > 0 && · } + + {getTabLabel(tab)} + + + ))} + (Tab to switch) + + + Page {currentPage + 1}/{totalPages} + + + + + {activeTab === "commands" && + (visiblePageItems as CommandItem[]).map((command, index) => { + const isSelected = index === selectedIndex; + + return ( + + + {isSelected ? "›" : " "} + + + + + {command.name} + + {command.description} + + + + ); + })} + + {activeTab === "shortcuts" && + (visiblePageItems as ShortcutItem[]).map((shortcut, index) => { + const isSelected = index === selectedIndex; + + return ( + + + {isSelected ? "›" : " "} + + + + + {shortcut.keys} + + {shortcut.description} + + + + ); + })} + + + + Getting started: + + • Run /init to initialize agent memory for this + project + + + • Press / at any time to see command autocomplete + + + • Visit https://docs.letta.com/letta-code for more + help + + + + ); +}