import type { PasteEvent } from '@opentui/core'; import { useKeyboard, useRenderer, useTerminalDimensions } from '@opentui/react'; import { useCallback, useEffect, useRef, useState } from 'react'; import { sanitizeTerminalText } from '../../utils/terminal'; import { useAppDispatch, useAppState } from '../state/context'; import type { Action } from '../state/types'; import { limitGraphemes, removeLastGrapheme, terminalCellWidth, truncateTerminalText } from '../text'; import { colors } from '../theme'; export const MAX_SEARCH_GRAPHEMES = 512; export function commitSearch(query: string, dispatch: React.Dispatch): void { dispatch({ type: 'SET_SEARCH_QUERY', query }); dispatch({ type: 'SET_OVERLAY', overlay: 'none' }); dispatch({ type: 'SET_PANEL', panel: 'articles' }); } export function SearchBar() { const state = useAppState(); const dispatch = useAppDispatch(); const renderer = useRenderer(); const { width: terminalWidth } = useTerminalDimensions(); const initialQuery = limitGraphemes(sanitizeTerminalText(state.searchQuery), MAX_SEARCH_GRAPHEMES); const [query, setQuery] = useState(initialQuery); const initialQueryRef = useRef(initialQuery); const debounceRef = useRef | null>(null); const appendText = useCallback((rawText: string) => { const text = sanitizeTerminalText(rawText); if (text) setQuery((value) => limitGraphemes(value + text, MAX_SEARCH_GRAPHEMES)); }, []); const prefix = '/ '; const instruction = ' (Enter to confirm, Esc to cancel)'; const contentWidth = Math.max(0, terminalWidth - 1); const fixedWidth = terminalCellWidth(prefix) + 1; const showInstruction = contentWidth - fixedWidth >= terminalCellWidth(instruction) + 4; const queryWidth = Math.max(0, contentWidth - fixedWidth - (showInstruction ? terminalCellWidth(instruction) : 0)); useEffect(() => { const handlePaste = (event: PasteEvent) => { appendText(new TextDecoder().decode(event.bytes)); event.preventDefault(); }; renderer.keyInput.on('paste', handlePaste); return () => { renderer.keyInput.off('paste', handlePaste); }; }, [renderer, appendText]); // Debounced search useEffect(() => { if (debounceRef.current) { clearTimeout(debounceRef.current); } debounceRef.current = setTimeout(() => { dispatch({ type: 'SET_SEARCH_QUERY', query }); }, 150); return () => { if (debounceRef.current) clearTimeout(debounceRef.current); }; }, [query, dispatch]); useKeyboard((key) => { if (key.name === 'escape') { // Cancel search, restore previous query dispatch({ type: 'SET_SEARCH_QUERY', query: initialQueryRef.current }); dispatch({ type: 'SET_OVERLAY', overlay: 'none' }); return; } if (key.name === 'enter' || key.name === 'return') { // Confirm search commitSearch(query, dispatch); return; } if (key.name === 'backspace') { setQuery((value) => removeLastGrapheme(value)); return; } // Regular character input. Use key.sequence (not key.name) so shifted // letters keep their case — opentui lowercases key.name for them. if (!key.ctrl && !key.meta && key.name !== 'space') { appendText(key.sequence); return; } if (key.name === 'space') { appendText(' '); return; } }); return ( {prefix} {truncateTerminalText(query, queryWidth)} {queryWidth > 0 ? {'█'} : null} {showInstruction ? {instruction} : null} ); }