claude-code-best/src/components/HistorySearchDialog.tsx
2026-05-01 21:39:30 +08:00

140 lines
4.5 KiB
TypeScript

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<Item[] | null>(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 (
<FuzzyPicker
title="Search prompts"
placeholder="Filter history…"
initialQuery={initialQuery}
items={filtered}
getKey={item => 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) => (
<Text>
<Text dimColor>{item.age}</Text>
<Text color={isFocused ? 'suggestion' : undefined}> {truncateToWidth(item.firstLine, rowWidth)}</Text>
</Text>
)}
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 (
<Box flexDirection="column" borderStyle="round" borderDimColor paddingX={1} height={PREVIEW_ROWS + 2}>
{shown.map((row, i) => (
<Text key={i} dimColor>
{row}
</Text>
))}
{more > 0 && <Text dimColor>{`… +${more} more lines`}</Text>}
</Box>
);
}}
/>
);
}
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;
}