claude-code/components/HistorySearchDialog.tsx

118 lines
20 KiB
TypeScript
Raw Permalink Normal View History

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 { stringWidth } from '../ink/stringWidth.js';
import { wrapAnsi } from '../ink/wrapAnsi.js';
import { Box, Text } from '../ink.js';
import { logEvent } from '../services/analytics/index.js';
import type { HistoryEntry } from '../utils/config.js';
import { formatRelativeTimeAgo, truncateToWidth } from '../utils/format.js';
import { FuzzyPicker } from './design-system/FuzzyPicker.js';
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_0 => String(item_0.entry.timestamp)} onQueryChange={setQuery} onSelect={item_1 => {
logEvent('tengu_history_picker_select', {
result_count: filtered.length,
query_length: query.length
});
void item_1.entry.resolve().then(onSelect);
}} onCancel={onCancel} emptyMessage={q_0 => items === null ? 'Loading…' : q_0 ? 'No matching prompts' : 'No history yet'} selectAction="use" direction="up" previewPosition={previewOnRight ? 'right' : 'bottom'} renderItem={(item_2, isFocused) => <Text>
<Text dimColor>{item_2.age}</Text>
<Text color={isFocused ? 'suggestion' : undefined}>
{' '}
{truncateToWidth(item_2.firstLine, rowWidth)}
</Text>
</Text>} renderPreview={item_3 => {
const wrapped = wrapAnsi(item_3.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;
}
//# sourceMappingURL=data:application/json;charset=utf-8;base64,eyJ2ZXJzaW9uIjozLCJuYW1lcyI6WyJSZWFjdCIsInVzZUVmZmVjdCIsInVzZU1lbW8iLCJ1c2VTdGF0ZSIsInVzZVJlZ2lzdGVyT3ZlcmxheSIsImdldFRpbWVzdGFtcGVkSGlzdG9yeSIsIlRpbWVzdGFtcGVkSGlzdG9yeUVudHJ5IiwidXNlVGVybWluYWxTaXplIiwic3RyaW5nV2lkdGgiLCJ3cmFwQW5zaSIsIkJveCIsIlRleHQiLCJsb2dFdmVudCIsIkhpc3RvcnlFbnRyeSIsImZvcm1hdFJlbGF0aXZlVGltZUFnbyIsInRydW5jYXRlVG9XaWR0aCIsIkZ1enp5UGlja2VyIiwiUHJvcHMiLCJpbml0aWFsUXVlcnkiLCJvblNlbGVjdCIsImVudHJ5Iiwib25DYW5jZWwiLCJQUkVWSUVXX1JPV1MiLCJBR0VfV0lEVEgiLCJJdGVtIiwiZGlzcGxheSIsImxvd2VyIiwiZmlyc3RMaW5lIiwiYWdlIiwiSGlzdG9yeVNlYXJjaERpYWxvZyIsIlJlYWN0Tm9kZSIsImNvbHVtbnMiLCJpdGVtcyIsInNldEl0ZW1zIiwicXVlcnkiLCJzZXRRdWVyeSIsImNhbmNlbGxlZCIsInJlYWRlciIsImxvYWRlZCIsInJldHVybiIsInVuZGVmaW5lZCIsIm5sIiwiaW5kZXhPZiIsIkRhdGUiLCJ0aW1lc3RhbXAiLCJwdXNoIiwidG9Mb3dlckNhc2UiLCJzbGljZSIsInJlcGVhdCIsIk1hdGgiLCJtYXgiLCJmaWx0ZXJlZCIsInEiLCJ0cmltIiwiZXhhY3QiLCJmdXp6eSIsIml0ZW0iLCJpbmNsdWRlcyIsImlzU3Vic2VxdWVuY2UiLCJjb25jYXQiLCJwcmV2aWV3T25SaWdodCIsImxpc3RXaWR0aCIsImZsb29yIiwicm93V2lkdGgiLCJwcmV2aWV3V2lkdGgiLCJTdHJpbmciLCJyZXN1bHRfY291bnQiLCJsZW5ndGgiLCJxdWVyeV9sZW5ndGgiLCJyZXNvbHZlIiwidGhlbiIsImlzRm9jdXNlZCIsIndyYXBwZWQiLCJoYXJkIiwic3BsaXQiLCJmaWx0ZXIiLCJsIiwib3ZlcmZsb3ciLCJzaG93biIsIm1vcmUiLCJtYXAiLCJyb3ciLCJpIiwidGV4dCIsImoiXSwic291cmNlcyI6WyJIaXN0b3J5U2VhcmNoRGlhbG9nLnRzeCJdLCJzb3VyY2VzQ29udGVudCI6WyJpbXBvcnQgKiBhcyBSZWFjdCBmcm9tICdyZWFjdCdcbmltcG9ydCB7IHVzZUVmZmVjdCwgdXNlTWVtbywgdXNlU3RhdGUgfSBmcm9tICdyZWFjdCdcbmltcG9ydCB7IHVzZVJlZ2lzdGVyT3ZlcmxheSB9IGZyb20gJy4uL2NvbnRleHQvb3ZlcmxheUNvbnRleHQuanMnXG5pbXBvcnQge1xuICBnZXRUaW1lc3RhbXBlZEhpc3RvcnksXG4gIHR5cGUgVGltZXN0YW1wZWRIaXN0b3J5RW50cnksXG59IGZyb20gJy4uL2hpc3RvcnkuanMnXG5pbXBvcnQgeyB1c2VUZXJtaW5hbFNpemUgfSBmcm9tICcuLi9ob29rcy91c2VUZXJtaW5hbFNpemUuanMnXG5pbXBvcnQgeyBzdHJpbmdXaWR0aCB9IGZyb20gJy4uL2luay9zdHJpbmdXaWR0aC5qcydcbmltcG9ydCB7IHdyYXBBbnNpIH0gZnJvbSAnLi4vaW5rL3dyYXBBbnNpLmpzJ1xuaW1wb3J0IHsgQm94LCBUZXh0IH0gZnJvbSAnLi4vaW5rLmpzJ1xuaW1wb3J0IHsgbG9nRXZlbnQgfSBmcm9tICcuLi9zZXJ2aWNlcy9hbmFseXRpY3MvaW5kZXguanMnXG5pbXBvcnQgdHlwZSB7IEhpc3RvcnlFbnRyeSB9IGZyb20gJy4uL3V0aWxzL2NvbmZpZy5qcydcbmltcG9ydCB7IGZvcm1hdFJlbGF0aXZlVGltZUFnbywgdHJ1bmNhdGVUb1dpZHRoIH0gZnJvbSAnLi4vdXRpbHMvZm9ybWF0LmpzJ1xuaW1wb3J0IHsgRnV6enlQaWNrZXIgfSBmcm9tICcuL2Rlc2lnbi1zeXN0ZW0vRnV6enlQaWNrZXIuanMnXG5cbnR5cGUgUHJvcHMgPSB7XG4gIGluaXRpYWxRdWVyeT86IHN0cmluZ1xuICBvblNlbGVjdDogKGVudHJ5OiBIaXN0b3J5RW50cnkpID0+IHZvaWRcbiAgb25DYW5jZWw6ICgpID0+IHZvaWRcbn1cblxuY29uc3QgUFJFVklFV19ST1dTID0gNlxuY29uc3QgQUdFX1dJRFRIID0gOFxuXG50eXBlIEl0ZW0gPSB7XG4gIGVudHJ5OiBUaW1lc3RhbXBlZEhpc3RvcnlFbnRyeVxuICBkaXNwbGF5OiBzdHJpbmdcbiAgbG93ZXI6IHN0cmluZ1xuICBmaXJzdExpbmU6IHN0cmluZ1xuICBhZ2U6IHN0cmluZ1xufVxuXG5leHBvcnQgZnVuY3Rpb24gSGlzdG9yeVNlYXJjaERpYWxvZyh7XG4gIGluaXRpYWxRdWVyeSxcbiAgb25TZWxlY3QsXG4gIG9uQ2FuY2VsLFxufTogUHJvcHMpOiBSZWFjdC5SZWFjdE5vZGUge1xuICB1c2VSZWdpc3Rlck92ZXJsYXkoJ2hpc3Rvcnktc2VhcmNoJylcbiAgY29uc3QgeyBjb2x1bW5zIH0gPSB1c2VUZXJtaW5hbFNpemUoKVxuXG4gIGNvbnN0IFtpdGVtcywgc2V0SXRlbXNdID0gdXNlU3RhdGU8SXRlbVtdIHwgbnVsbD4obnVsbClcbiAgY29uc3QgW3F1ZXJ5LCBzZXRRdWVyeV0gPSB1c2VTdGF0ZShpbml0aWFsUXVlcnkgPz8gJycpXG5cbiAgdXNlRWZmZWN0KCgpID0+IHtcbiAgICBsZXQgY2FuY2VsbGVkID0gZmFsc2VcbiAgICB2b2lkIChhc3luYyAoKSA9PiB7XG4gICAgICBjb25zdCByZWFkZXIgPSBnZXRUaW1lc3RhbXBlZEhpc3RvcnkoKVxuICAgICAgY29uc3QgbG9hZGVkOiBJdGVtW10gPSBbXVxuICAgICAgZm9yIGF3YWl0IChjb25zdCBlbnRyeSBvZiByZWFkZXIpIHtcbiAgICAgICAgaWYgKGNhbmNlbGxlZCkge1xuICAgICAgICAgIHZvaWQgcmVhZGVyLnJldHVybih1bmRlZmluZWQpXG4gICAgICAgICAgcmV0dXJuXG4gICAgICAgIH1cbiAgICAgICAgY29uc3QgZGlzcGxheSA9IGVudHJ5LmRpc3BsYXlcbiAgICAgICAgY29uc3QgbmwgPSBkaXNwbGF5LmluZGV4T2YoJ1xcbicpXG4gICAgICAgIGNvbnN0IGFnZSA9IGZvcm1hdFJlbGF0aXZlVGltZUFnbyhuZXcgRGF0ZShlbnRyeS50aW1lc3RhbXApKVxuICAgICAgICBsb2FkZWQucHVzaCh7XG4gICAgICAgICAgZW50cnksXG4gICAgICAgICAgZGlzcGxheSxcbiAgICAgICAgICBsb3dlcjogZGlzcGxheS50b0xvd2VyQ2FzZSgpLFxuICAgICAgICAgIGZpcnN0TGluZTogbmwgPT09IC0xID8gZGlzcGxheSA6IGRpc3BsYXkuc2xpY2UoMCwgbmwpLFxuICAgICAgICAgIGFnZTogYWdlICsgJyAnLnJlcGVhdChNYXRoLm1heCgwLCBBR0VfV0lEVEggLSBzdHJpbmdXaWR0aChhZ2UpKSksXG4gICAgICAgIH0pXG4gICAgICB9XG4gICAgICBpZiAoIWNhbmNlbGxlZCkgc2V0SXR