import * as React from 'react' import { useEffect, useMemo, useState } from 'react' import { useRegisterOverlay } from '../context/overlayContext.js' import { getTimestampedHistory, type TimestampedHistoryEntry, } from '../history.js' import { useTerminalSize } from '../hooks/useTerminalSize.js' import { Box, Text, stringWidth, wrapAnsi } from '@anthropic/ink' import { logEvent } from '../services/analytics/index.js' import type { HistoryEntry } from '../utils/config.js' import { formatRelativeTimeAgo, truncateToWidth } from '../utils/format.js' import { FuzzyPicker } from '@anthropic/ink' type Props = { initialQuery?: string onSelect: (entry: HistoryEntry) => void onCancel: () => void } const PREVIEW_ROWS = 6 const AGE_WIDTH = 8 type Item = { entry: TimestampedHistoryEntry display: string lower: string firstLine: string age: string } export function HistorySearchDialog({ initialQuery, onSelect, onCancel, }: Props): React.ReactNode { useRegisterOverlay('history-search') const { columns } = useTerminalSize() const [items, setItems] = useState(null) const [query, setQuery] = useState(initialQuery ?? '') useEffect(() => { let cancelled = false void (async () => { const reader = getTimestampedHistory() const loaded: Item[] = [] for await (const entry of reader) { if (cancelled) { void reader.return(undefined) return } const display = entry.display const nl = display.indexOf('\n') const age = formatRelativeTimeAgo(new Date(entry.timestamp)) loaded.push({ entry, display, lower: display.toLowerCase(), firstLine: nl === -1 ? display : display.slice(0, nl), age: age + ' '.repeat(Math.max(0, AGE_WIDTH - stringWidth(age))), }) } if (!cancelled) setItems(loaded) })() return () => { cancelled = true } }, []) const filtered = useMemo(() => { if (!items) return [] const q = query.trim().toLowerCase() if (!q) return items const exact: Item[] = [] const fuzzy: Item[] = [] for (const item of items) { if (item.lower.includes(q)) { exact.push(item) } else if (isSubsequence(item.lower, q)) { fuzzy.push(item) } } return exact.concat(fuzzy) }, [items, query]) const previewOnRight = columns >= 100 const listWidth = previewOnRight ? Math.floor((columns - 6) * 0.5) : columns - 6 const rowWidth = Math.max(20, listWidth - AGE_WIDTH - 1) const previewWidth = previewOnRight ? Math.max(20, columns - listWidth - 12) : Math.max(20, columns - 10) return ( String(item.entry.timestamp)} onQueryChange={setQuery} onSelect={item => { logEvent('tengu_history_picker_select', { result_count: filtered.length, query_length: query.length, }) void item.entry.resolve().then(onSelect) }} onCancel={onCancel} emptyMessage={q => items === null ? 'Loading…' : q ? 'No matching prompts' : 'No history yet' } selectAction="use" direction="up" previewPosition={previewOnRight ? 'right' : 'bottom'} renderItem={(item, isFocused) => ( {item.age} {' '} {truncateToWidth(item.firstLine, rowWidth)} )} renderPreview={item => { const wrapped = wrapAnsi(item.display, previewWidth, { hard: true }) .split('\n') .filter(l => l.trim() !== '') const overflow = wrapped.length > PREVIEW_ROWS const shown = wrapped.slice( 0, overflow ? PREVIEW_ROWS - 1 : PREVIEW_ROWS, ) const more = wrapped.length - shown.length return ( {shown.map((row, i) => ( {row} ))} {more > 0 && {`… +${more} more lines`}} ) }} /> ) } function isSubsequence(text: string, query: string): boolean { let j = 0 for (let i = 0; i < text.length && j < query.length; i++) { if (text[i] === query[j]) j++ } return j === query.length }