({
title,
placeholder = 'Type to search…',
initialQuery,
items,
getKey,
renderItem,
renderPreview,
previewPosition = 'bottom',
visibleCount: requestedVisible = DEFAULT_VISIBLE,
direction = 'down',
onQueryChange,
onSelect,
onTab,
onShiftTab,
onFocus,
onCancel,
emptyMessage = 'No results',
matchLabel,
selectAction = 'select',
extraHints
}: Props<T>)
| 66 | const CHROME_ROWS = 10; |
| 67 | const MIN_VISIBLE = 2; |
| 68 | export function FuzzyPicker<T>({ |
| 69 | title, |
| 70 | placeholder = 'Type to search…', |
| 71 | initialQuery, |
| 72 | items, |
| 73 | getKey, |
| 74 | renderItem, |
| 75 | renderPreview, |
| 76 | previewPosition = 'bottom', |
| 77 | visibleCount: requestedVisible = DEFAULT_VISIBLE, |
| 78 | direction = 'down', |
| 79 | onQueryChange, |
| 80 | onSelect, |
| 81 | onTab, |
| 82 | onShiftTab, |
| 83 | onFocus, |
| 84 | onCancel, |
| 85 | emptyMessage = 'No results', |
| 86 | matchLabel, |
| 87 | selectAction = 'select', |
| 88 | extraHints |
| 89 | }: Props<T>): React.ReactNode { |
| 90 | const isTerminalFocused = useTerminalFocus(); |
| 91 | const { |
| 92 | rows, |
| 93 | columns |
| 94 | } = useTerminalSize(); |
| 95 | const [focusedIndex, setFocusedIndex] = useState(0); |
| 96 | |
| 97 | // Cap visibleCount so the picker never exceeds the terminal height. When it |
| 98 | // overflows, each re-render (arrow key, ctrl+p) mis-positions the cursor-up |
| 99 | // by the overflow amount and a previously-drawn line flashes blank. |
| 100 | const visibleCount = Math.max(MIN_VISIBLE, Math.min(requestedVisible, rows - CHROME_ROWS - (matchLabel ? 1 : 0))); |
| 101 | |
| 102 | // Full hint row with onTab+onShiftTab is ~100 chars and wraps inconsistently |
| 103 | // below that. Compact mode drops shift+tab and shortens labels. |
| 104 | const compact = columns < 120; |
| 105 | const step = (delta: 1 | -1) => { |
| 106 | setFocusedIndex(i => clamp(i + delta, 0, items.length - 1)); |
| 107 | }; |
| 108 | |
| 109 | // onKeyDown fires after useSearchInput's useInput, so onExit must be a |
| 110 | // no-op — return/downArrow are handled by handleKeyDown below. onCancel |
| 111 | // still covers escape/ctrl+c/ctrl+d. Backspace-on-empty is disabled so |
| 112 | // a held backspace doesn't eject the user from the dialog. |
| 113 | const { |
| 114 | query, |
| 115 | cursorOffset |
| 116 | } = useSearchInput({ |
| 117 | isActive: true, |
| 118 | onExit: () => {}, |
| 119 | onCancel, |
| 120 | initialQuery, |
| 121 | backspaceExitsOnEmpty: false |
| 122 | }); |
| 123 | const handleKeyDown = (e: KeyboardEvent) => { |
| 124 | if (e.key === 'up' || e.ctrl && e.key === 'p') { |
| 125 | e.preventDefault(); |
nothing calls this directly
no test coverage detected