({
items,
selectedIndex,
maxVisible,
prefix = '/',
onItemClick,
}: SuggestionMenuProps)
| 22 | } |
| 23 | |
| 24 | export const SuggestionMenu = ({ |
| 25 | items, |
| 26 | selectedIndex, |
| 27 | maxVisible, |
| 28 | prefix = '/', |
| 29 | onItemClick, |
| 30 | }: SuggestionMenuProps) => { |
| 31 | const theme = useTheme() |
| 32 | const { terminalWidth } = useTerminalDimensions() |
| 33 | const screenPadding = 4 |
| 34 | const menuWidth = Math.max(10, terminalWidth - screenPadding * 2) |
| 35 | |
| 36 | // Hover state: only highlight on hover after user has moved mouse |
| 37 | const [hoveredIndex, setHoveredIndex] = useState<number | null>(null) |
| 38 | const [hasHoveredSinceOpen, setHasHoveredSinceOpen] = useState(false) |
| 39 | |
| 40 | // Reset hover state when items change (new menu session) |
| 41 | useEffect(() => { |
| 42 | setHasHoveredSinceOpen(false) |
| 43 | setHoveredIndex(null) |
| 44 | }, [items]) |
| 45 | |
| 46 | if (items.length === 0) { |
| 47 | return null |
| 48 | } |
| 49 | |
| 50 | const effectivePrefix = prefix ?? '' |
| 51 | |
| 52 | const clampedSelected = Math.min( |
| 53 | Math.max(selectedIndex, 0), |
| 54 | Math.max(items.length - 1, 0), |
| 55 | ) |
| 56 | const visibleCount = Math.min(Math.max(maxVisible, 1), items.length) |
| 57 | |
| 58 | const maxStart = Math.max(items.length - visibleCount, 0) |
| 59 | const idealStart = clampedSelected - Math.floor((visibleCount - 1) / 2) |
| 60 | const start = Math.max(0, Math.min(idealStart, maxStart)) |
| 61 | const visibleItems = items.slice(start, start + visibleCount) |
| 62 | |
| 63 | // Calculate max label length for alignment |
| 64 | const maxLabelLength = Math.max( |
| 65 | ...visibleItems.map( |
| 66 | (item) => effectivePrefix.length + item.label.length, |
| 67 | ), |
| 68 | ) |
| 69 | |
| 70 | // Find the longest description to determine if we can use same-line layout |
| 71 | const maxDescriptionLength = Math.max( |
| 72 | ...visibleItems.map((item) => item.description.length), |
| 73 | ) |
| 74 | |
| 75 | // Check if all items can fit on same line with aligned descriptions |
| 76 | const minWidthForSameLine = maxLabelLength + 2 + maxDescriptionLength |
| 77 | const useSameLine = menuWidth >= minWidthForSameLine |
| 78 | |
| 79 | const renderSuggestionItem = (item: SuggestionItem, idx: number) => { |
| 80 | const absoluteIndex = start + idx |
| 81 | const isSelected = absoluteIndex === clampedSelected |
nothing calls this directly
no test coverage detected