"use client" import * as React from "react" import { motion, AnimatePresence } from "framer-motion" import { Trello, Grid, Trash2, Clipboard, Download, FolderOpen, FolderPlus, BookOpen, Sparkles, FolderDown, FolderInput, GitFork } from "lucide-react" import { Command } from "cmdk" import { useModKey } from "@/lib/utils" const ACTION_ITEMS = [ { id: "export-nodepad", icon: FolderDown, label: "Export", sub: ".nodepad" }, { id: "import-nodepad", icon: FolderInput, label: "Import", sub: ".nodepad" }, { id: "export-md", icon: Download, label: "Export", sub: "markdown" }, { id: "copy-md", icon: Clipboard, label: "Copy", sub: "markdown" }, { id: "clear", icon: Trash2, label: "Clear", sub: "canvas" }, ] // ─── Props ─────────────────────────────────────────────────────────────────── interface VimInputProps { onSubmit: (text: string) => void onCommand: (cmd: string, text?: string) => void isCommandKOpen: boolean setIsCommandKOpen: (open: boolean) => void } // ─── Component ─────────────────────────────────────────────────────────────── export function VimInput({ onSubmit, onCommand, isCommandKOpen, setIsCommandKOpen }: VimInputProps) { const [value, setValue] = React.useState("") const [search, setSearch] = React.useState("") const [focusedIdx, setFocusedIdx] = React.useState(0) const mod = useModKey() const mainInputRef = React.useRef(null) const searchInputRef = React.useRef(null) const itemRefs = React.useRef<(HTMLButtonElement | null)[]>([]) // ── Items (mod-key aware) ─────────────────────────────────────────────── const VIEW_ITEMS = React.useMemo(() => [ { id: "tiling", icon: Grid, label: "Tiling", sub: "" }, { id: "kanban", icon: Trello, label: "Kanban", sub: "" }, { id: "graph", icon: GitFork, label: "Graph", sub: "" }, ], []) const NAV_ITEMS = React.useMemo(() => [ { id: "open-projects", icon: FolderOpen, label: "Projects", sub: "" }, { id: "new-project", icon: FolderPlus, label: "New Project", sub: "" }, { id: "open-index", icon: BookOpen, label: "Index", sub: "" }, { id: "open-synthesis", icon: Sparkles, label: "Synthesis", sub: "" }, ], []) // ── Filtered items ────────────────────────────────────────────────────── const q = search.toLowerCase() const viewItems = q ? VIEW_ITEMS.filter(i => i.label.toLowerCase().includes(q) || i.sub.toLowerCase().includes(q)) : VIEW_ITEMS const navItems = q ? NAV_ITEMS.filter(i => i.label.toLowerCase().includes(q) || i.sub.toLowerCase().includes(q)) : NAV_ITEMS const actionItems = q ? ACTION_ITEMS.filter(i => i.label.toLowerCase().includes(q) || i.sub.toLowerCase().includes(q)) : ACTION_ITEMS const viewCount = viewItems.length const navCount = navItems.length const actionCount = actionItems.length const totalItems = viewCount + navCount + actionCount // Section boundaries for keyboard nav // Section 0: views [0 .. viewCount) // Section 1: nav [viewCount .. viewCount+navCount) // Section 2: actions [viewCount+navCount .. total) const sections = React.useMemo(() => [ { start: 0, count: viewCount, cols: 3 }, { start: viewCount, count: navCount, cols: 4 }, { start: viewCount + navCount, count: actionCount, cols: 5 }, ], [viewCount, navCount, actionCount]) const getSectionForIdx = React.useCallback((idx: number) => { return sections.find(s => idx >= s.start && idx < s.start + s.count) ?? sections[0] }, [sections]) // ── Lifecycle ─────────────────────────────────────────────────────────── React.useEffect(() => { if (isCommandKOpen) { setSearch("") setFocusedIdx(0) requestAnimationFrame(() => searchInputRef.current?.focus()) } }, [isCommandKOpen]) React.useEffect(() => { setFocusedIdx(0) }, [search]) // Scroll focused item into view React.useEffect(() => { itemRefs.current[focusedIdx]?.scrollIntoView({ block: "nearest", behavior: "smooth" }) }, [focusedIdx]) // ── Helpers ───────────────────────────────────────────────────────────── const close = React.useCallback(() => { setIsCommandKOpen(false) requestAnimationFrame(() => mainInputRef.current?.focus()) }, [setIsCommandKOpen]) const handleSelect = React.useCallback((cmd: string) => { onCommand(cmd, value) setSearch("") close() }, [onCommand, value, close]) // ── Grid keyboard navigation ───────────────────────────────────────────── const getItemAtIdx = React.useCallback((idx: number): string | null => { if (idx < viewCount) return viewItems[idx]?.id ?? null if (idx < viewCount + navCount) return navItems[idx - viewCount]?.id ?? null if (idx < viewCount + navCount + actionCount) return actionItems[idx - viewCount - navCount]?.id ?? null return null }, [viewCount, navCount, actionCount, viewItems, navItems, actionItems]) const handlePopupKeyDown = React.useCallback((e: React.KeyboardEvent) => { if (totalItems === 0) return const sec = getSectionForIdx(focusedIdx) const localIdx = focusedIdx - sec.start const rowStart = sec.start + Math.floor(localIdx / sec.cols) * sec.cols const rowEnd = Math.min(rowStart + sec.cols - 1, sec.start + sec.count - 1) switch (e.key) { case "Escape": e.preventDefault() close() break case "Enter": { e.preventDefault() const id = getItemAtIdx(focusedIdx) if (id) handleSelect(id) break } case "ArrowRight": e.preventDefault() setFocusedIdx(focusedIdx >= rowEnd ? rowStart : focusedIdx + 1) break case "ArrowLeft": e.preventDefault() setFocusedIdx(focusedIdx <= rowStart ? rowEnd : focusedIdx - 1) break case "ArrowDown": { e.preventDefault() const nextInSec = focusedIdx + sec.cols if (nextInSec < sec.start + sec.count) { setFocusedIdx(nextInSec) } else { // Move to first item of next section const nextSecStart = sec.start + sec.count if (nextSecStart < totalItems) { const col = localIdx % sec.cols const ns = getSectionForIdx(nextSecStart) setFocusedIdx(Math.min(nextSecStart + col, nextSecStart + ns.count - 1)) } } break } case "ArrowUp": { e.preventDefault() const prevInSec = focusedIdx - sec.cols if (prevInSec >= sec.start) { setFocusedIdx(prevInSec) } else if (sec.start > 0) { // Move to last row of previous section const prevSecEnd = sec.start - 1 const ps = getSectionForIdx(prevSecEnd) const col = localIdx % sec.cols const lastRowStart = ps.start + Math.floor((ps.count - 1) / ps.cols) * ps.cols setFocusedIdx(Math.min(lastRowStart + col, ps.start + ps.count - 1)) } break } } }, [focusedIdx, totalItems, getSectionForIdx, getItemAtIdx, close, handleSelect]) // ── Render ─────────────────────────────────────────────────────────────── return (
{ if (e.key === "Enter" && value.trim() && !isCommandKOpen) { onSubmit(value.trim()) setValue("") } if (e.key === "Escape") setIsCommandKOpen(false) }} > {/* ── Command Popup ──────────────────────────────────────────────── */} {isCommandKOpen && ( {/* Search input */}
{mod}K setSearch(e.target.value)} placeholder="Search commands…" className="flex-1 bg-transparent font-mono text-xs text-foreground/70 placeholder:text-muted-foreground outline-none" /> {search && ( )}
{/* ── Views ──────────────────────────────────────────────── */} {viewItems.length > 0 && (

Views

{viewItems.map((item, i) => { const focused = focusedIdx === i return ( ) })}
)} {/* ── Navigate ───────────────────────────────────────────── */} {navItems.length > 0 && (

Navigate

{navItems.map((item, i) => { const idx = viewCount + i const focused = focusedIdx === idx return ( ) })}
)} {/* ── Actions ────────────────────────────────────────────── */} {actionItems.length > 0 && (

Actions

{actionItems.map((item, i) => { const idx = viewCount + navCount + i const focused = focusedIdx === idx return ( ) })}
)} {/* ── Empty state ────────────────────────────────────────── */} {totalItems === 0 && (
No commands match
)}
{/* Footer hint */}
{[ ["↑↓", "rows"], ["←→", "tiles"], ["↵", "select"], ["esc","close"], ].map(([key, label]) => (
{key} {label}
))}
)}
{/* ── Main Input Bar ─────────────────────────────────────────────── */}
Entry
⌘ Z Undo
⌘ K Commands
) }