import * as React from 'react' import { useEffect, useState } from 'react' import { useSearchInput } from '../hooks/useSearchInput.js' import { useTerminalSize } from '../hooks/useTerminalSize.js' import type { KeyboardEvent } from '../core/events/keyboard-event.js' import { clamp } from '../core/layout/geometry.js' import { Box, Text, useTerminalFocus } from '../index.js' import { SearchBox } from './SearchBox.js' import { Byline } from './Byline.js' import { KeyboardShortcutHint } from './KeyboardShortcutHint.js' import { ListItem } from './ListItem.js' import { Pane } from './Pane.js' type PickerAction = { /** Hint label shown in the byline, e.g. "mention" → "Tab to mention". */ action: string handler: (item: T) => void } type Props = { title: string placeholder?: string initialQuery?: string items: readonly T[] getKey: (item: T) => string /** Keep to one line — preview handles overflow. */ renderItem: (item: T, isFocused: boolean) => React.ReactNode renderPreview?: (item: T) => React.ReactNode /** 'right' keeps hints stable (no bounce), but needs width. */ previewPosition?: 'bottom' | 'right' visibleCount?: number /** * 'up' puts items[0] at the bottom next to the input (atuin-style). Arrows * always match screen direction — ↑ walks visually up regardless. */ direction?: 'down' | 'up' /** Caller owns filtering: re-filter on each call and pass new items. */ onQueryChange: (query: string) => void /** Enter key. Primary action. */ onSelect: (item: T) => void /** * Tab key. If provided, Tab no longer aliases Enter — it gets its own * handler and hint. Shift+Tab falls through to this if onShiftTab is unset. */ onTab?: PickerAction /** Shift+Tab key. Gets its own hint. */ onShiftTab?: PickerAction /** * Fires when the focused item changes (via arrows or when items reset). * Useful for async preview loading — keeps I/O out of renderPreview. */ onFocus?: (item: T | undefined) => void onCancel: () => void /** Shown when items is empty. Caller bakes loading/searching state into this. */ emptyMessage?: string | ((query: string) => string) /** * Status line below the list, e.g. "500+ matches" or "42 matches…". * Caller decides when to show it — pass undefined to hide. */ matchLabel?: string selectAction?: string extraHints?: React.ReactNode } const DEFAULT_VISIBLE = 8 // Pane (paddingTop + Divider) + title + 3 gaps + SearchBox (rounded border = 3 // rows) + hints. matchLabel adds +1 when present, accounted for separately. const CHROME_ROWS = 10 const MIN_VISIBLE = 2 export function FuzzyPicker({ 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): React.ReactNode { const isTerminalFocused = useTerminalFocus() const { rows, columns } = useTerminalSize() const [focusedIndex, setFocusedIndex] = useState(0) // Cap visibleCount so the picker never exceeds the terminal height. When it // overflows, each re-render (arrow key, ctrl+p) mis-positions the cursor-up // by the overflow amount and a previously-drawn line flashes blank. const visibleCount = Math.max( MIN_VISIBLE, Math.min(requestedVisible, rows - CHROME_ROWS - (matchLabel ? 1 : 0)), ) // Full hint row with onTab+onShiftTab is ~100 chars and wraps inconsistently // below that. Compact mode drops shift+tab and shortens labels. const compact = columns < 120 const step = (delta: 1 | -1) => { setFocusedIndex(i => clamp(i + delta, 0, items.length - 1)) } // onKeyDown fires after useSearchInput's useInput, so onExit must be a // no-op — return/downArrow are handled by handleKeyDown below. onCancel // still covers escape/ctrl+c/ctrl+d. Backspace-on-empty is disabled so // a held backspace doesn't eject the user from the dialog. const { query, cursorOffset } = useSearchInput({ isActive: true, onExit: () => {}, onCancel, initialQuery, backspaceExitsOnEmpty: false, }) const handleKeyDown = (e: KeyboardEvent) => { if (e.key === 'up' || (e.ctrl && e.key === 'p')) { e.preventDefault() e.stopImmediatePropagation() step(direction === 'up' ? 1 : -1) return } if (e.key === 'down' || (e.ctrl && e.key === 'n')) { e.preventDefault() e.stopImmediatePropagation() step(direction === 'up' ? -1 : 1) return } if (e.key === 'return') { e.preventDefault() e.stopImmediatePropagation() const selected = items[focusedIndex] if (selected) onSelect(selected) return } if (e.key === 'tab') { e.preventDefault() e.stopImmediatePropagation() const selected = items[focusedIndex] if (!selected) return const tabAction = e.shift ? (onShiftTab ?? onTab) : onTab if (tabAction) { tabAction.handler(selected) } else { onSelect(selected) } } } useEffect(() => { onQueryChange(query) setFocusedIndex(0) // eslint-disable-next-line react-hooks/exhaustive-deps }, [query]) useEffect(() => { setFocusedIndex(i => clamp(i, 0, items.length - 1)) }, [items.length]) const focused = items[focusedIndex] useEffect(() => { onFocus?.(focused) // eslint-disable-next-line react-hooks/exhaustive-deps }, [focused]) const windowStart = clamp( focusedIndex - visibleCount + 1, 0, items.length - visibleCount, ) const visible = items.slice(windowStart, windowStart + visibleCount) const emptyText = typeof emptyMessage === 'function' ? emptyMessage(query) : emptyMessage const searchBox = ( ) const listBlock = ( ) const preview = renderPreview && focused ? ( {renderPreview(focused)} ) : null // Structure must not depend on preview truthiness — when focused goes // undefined (e.g. delete clears matches), switching row→fragment would // change both layout AND gap count, bouncing the searchBox below. const listGroup = renderPreview && previewPosition === 'right' ? ( {listBlock} {matchLabel && {matchLabel}} {preview ?? } ) : ( // Box (not fragment) so the outer gap={1} doesn't insert a blank line // between list/matchLabel/preview — that read as extra space above the // prompt in direction='up'. {listBlock} {matchLabel && {matchLabel}} {preview} ) const inputAbove = direction !== 'up' return ( {title} {inputAbove && searchBox} {listGroup} {!inputAbove && searchBox} {onTab && ( )} {onShiftTab && !compact && ( )} {extraHints} ) } type ListProps = Pick< Props, 'visibleCount' | 'direction' | 'getKey' | 'renderItem' > & { visible: readonly T[] windowStart: number total: number focusedIndex: number emptyText: string } function List({ visible, windowStart, visibleCount, total, focusedIndex, direction, getKey, renderItem, emptyText, }: ListProps): React.ReactNode { if (visible.length === 0) { return ( {emptyText} ) } const rows = visible.map((item, i) => { const actualIndex = windowStart + i const isFocused = actualIndex === focusedIndex const atLowEdge = i === 0 && windowStart > 0 const atHighEdge = i === visible.length - 1 && windowStart + visibleCount! < total return ( {renderItem(item, isFocused)} ) }) return ( {rows} ) } function firstWord(s: string): string { const i = s.indexOf(' ') return i === -1 ? s : s.slice(0, i) }