1257 lines
45 KiB
TypeScript
1257 lines
45 KiB
TypeScript
import chalk from 'chalk';
|
|
import figures from 'figures';
|
|
import Fuse from 'fuse.js';
|
|
import React from 'react';
|
|
import { getOriginalCwd, getSessionId } from '../bootstrap/state.js';
|
|
import { useExitOnCtrlCDWithKeybindings } from '../hooks/useExitOnCtrlCDWithKeybindings.js';
|
|
import { useSearchInput } from '../hooks/useSearchInput.js';
|
|
import { useTerminalSize } from '../hooks/useTerminalSize.js';
|
|
import {
|
|
applyColor,
|
|
Box,
|
|
Text,
|
|
useInput,
|
|
useTerminalFocus,
|
|
useTheme,
|
|
type Color,
|
|
Byline,
|
|
Divider,
|
|
KeyboardShortcutHint,
|
|
} from '@anthropic/ink';
|
|
import { useKeybinding } from '../keybindings/useKeybinding.js';
|
|
import { logEvent } from '../services/analytics/index.js';
|
|
import type { LogOption, SerializedMessage } from '../types/logs.js';
|
|
import { formatLogMetadata, truncateToWidth } from '../utils/format.js';
|
|
import { getWorktreePaths } from '../utils/getWorktreePaths.js';
|
|
import { getBranch } from '../utils/git.js';
|
|
import { getLogDisplayTitle } from '../utils/log.js';
|
|
import {
|
|
getFirstMeaningfulUserMessageTextContent,
|
|
getSessionIdFromLog,
|
|
isCustomTitleEnabled,
|
|
saveCustomTitle,
|
|
} from '../utils/sessionStorage.js';
|
|
import { getTheme } from '../utils/theme.js';
|
|
import { ConfigurableShortcutHint } from './ConfigurableShortcutHint.js';
|
|
import { Select } from './CustomSelect/select.js';
|
|
import { SearchBox } from './SearchBox.js';
|
|
import { SessionPreview } from './SessionPreview.js';
|
|
import { Spinner } from './Spinner.js';
|
|
import { TagTabs } from './TagTabs.js';
|
|
import TextInput from './TextInput.js';
|
|
import { type TreeNode, TreeSelect } from './ui/TreeSelect.js';
|
|
|
|
type AgenticSearchState =
|
|
| { status: 'idle' }
|
|
| { status: 'searching' }
|
|
| { status: 'results'; results: LogOption[]; query: string }
|
|
| { status: 'error'; message: string };
|
|
|
|
export type LogSelectorProps = {
|
|
logs: LogOption[];
|
|
maxHeight?: number;
|
|
forceWidth?: number;
|
|
onCancel?: () => void;
|
|
onSelect: (log: LogOption) => void;
|
|
onLogsChanged?: () => void;
|
|
onLoadMore?: (count: number) => void;
|
|
initialSearchQuery?: string;
|
|
showAllProjects?: boolean;
|
|
onToggleAllProjects?: () => void;
|
|
onAgenticSearch?: (query: string, logs: LogOption[], signal?: AbortSignal) => Promise<LogOption[]>;
|
|
};
|
|
|
|
type LogTreeNode = TreeNode<{ log: LogOption; indexInFiltered: number }>;
|
|
|
|
function normalizeAndTruncateToWidth(text: string, maxWidth: number): string {
|
|
const normalized = text.replace(/\s+/g, ' ').trim();
|
|
return truncateToWidth(normalized, maxWidth);
|
|
}
|
|
|
|
// Width of prefixes that TreeSelect will add
|
|
const PARENT_PREFIX_WIDTH = 2; // '▼ ' or '▶ '
|
|
const CHILD_PREFIX_WIDTH = 4; // ' ▸ '
|
|
|
|
// Deep search constants
|
|
const DEEP_SEARCH_MAX_MESSAGES = 2000;
|
|
const DEEP_SEARCH_CROP_SIZE = 1000;
|
|
const DEEP_SEARCH_MAX_TEXT_LENGTH = 50000; // Cap searchable text per session
|
|
const FUSE_THRESHOLD = 0.3;
|
|
const DATE_TIE_THRESHOLD_MS = 60 * 1000; // 1 minute - use relevance as tie-breaker within this window
|
|
const SNIPPET_CONTEXT_CHARS = 50; // Characters to show before/after match
|
|
|
|
type Snippet = { before: string; match: string; after: string };
|
|
|
|
function formatSnippet({ before, match, after }: Snippet, highlightColor: (text: string) => string): string {
|
|
return chalk.dim(before) + highlightColor(match) + chalk.dim(after);
|
|
}
|
|
|
|
function extractSnippet(text: string, query: string, contextChars: number): Snippet | null {
|
|
// Find exact query occurrence (case-insensitive).
|
|
// Note: Fuse does fuzzy matching, so this may miss some fuzzy matches.
|
|
// This is acceptable for now - in the future we could use Fuse's includeMatches
|
|
// option and work with the match indices directly.
|
|
const matchIndex = text.toLowerCase().indexOf(query.toLowerCase());
|
|
if (matchIndex === -1) return null;
|
|
|
|
const matchEnd = matchIndex + query.length;
|
|
const snippetStart = Math.max(0, matchIndex - contextChars);
|
|
const snippetEnd = Math.min(text.length, matchEnd + contextChars);
|
|
|
|
const beforeRaw = text.slice(snippetStart, matchIndex);
|
|
const matchText = text.slice(matchIndex, matchEnd);
|
|
const afterRaw = text.slice(matchEnd, snippetEnd);
|
|
|
|
return {
|
|
before: (snippetStart > 0 ? '…' : '') + beforeRaw.replace(/\s+/g, ' ').trimStart(),
|
|
match: matchText.trim(),
|
|
after: afterRaw.replace(/\s+/g, ' ').trimEnd() + (snippetEnd < text.length ? '…' : ''),
|
|
};
|
|
}
|
|
|
|
function buildLogLabel(
|
|
log: LogOption,
|
|
maxLabelWidth: number,
|
|
options?: {
|
|
isGroupHeader?: boolean;
|
|
isChild?: boolean;
|
|
forkCount?: number;
|
|
},
|
|
): string {
|
|
const { isGroupHeader = false, isChild = false, forkCount = 0 } = options || {};
|
|
|
|
// TreeSelect will add the prefix, so we just need to account for its width
|
|
const prefixWidth = isGroupHeader && forkCount > 0 ? PARENT_PREFIX_WIDTH : isChild ? CHILD_PREFIX_WIDTH : 0;
|
|
|
|
const sessionCountSuffix =
|
|
isGroupHeader && forkCount > 0 ? ` (+${forkCount} other ${forkCount === 1 ? 'session' : 'sessions'})` : '';
|
|
|
|
const sidechainSuffix = log.isSidechain ? ' (sidechain)' : '';
|
|
|
|
const maxSummaryWidth = maxLabelWidth - prefixWidth - sidechainSuffix.length - sessionCountSuffix.length;
|
|
const truncatedSummary = normalizeAndTruncateToWidth(getLogDisplayTitle(log), maxSummaryWidth);
|
|
return `${truncatedSummary}${sidechainSuffix}${sessionCountSuffix}`;
|
|
}
|
|
|
|
function buildLogMetadata(log: LogOption, options?: { isChild?: boolean; showProjectPath?: boolean }): string {
|
|
const { isChild = false, showProjectPath = false } = options || {};
|
|
// Match the child prefix width for proper alignment
|
|
const childPadding = isChild ? ' ' : ''; // 4 spaces to match ' ▸ '
|
|
const baseMetadata = formatLogMetadata(log);
|
|
const projectSuffix = showProjectPath && log.projectPath ? ` · ${log.projectPath}` : '';
|
|
return childPadding + baseMetadata + projectSuffix;
|
|
}
|
|
|
|
export function LogSelector({
|
|
logs,
|
|
maxHeight = Infinity,
|
|
forceWidth,
|
|
onCancel,
|
|
onSelect,
|
|
onLogsChanged,
|
|
onLoadMore,
|
|
initialSearchQuery,
|
|
showAllProjects = false,
|
|
onToggleAllProjects,
|
|
onAgenticSearch,
|
|
}: LogSelectorProps): React.ReactNode {
|
|
const terminalSize = useTerminalSize();
|
|
const columns = forceWidth === undefined ? terminalSize.columns : forceWidth;
|
|
const exitState = useExitOnCtrlCDWithKeybindings(onCancel);
|
|
const isTerminalFocused = useTerminalFocus();
|
|
const isResumeWithRenameEnabled = isCustomTitleEnabled();
|
|
const isDeepSearchEnabled = process.env.USER_TYPE === 'ant';
|
|
const [themeName] = useTheme();
|
|
const theme = getTheme(themeName);
|
|
const highlightColor = React.useMemo(
|
|
() => (text: string) => applyColor(text, theme.warning as Color),
|
|
[theme.warning],
|
|
);
|
|
const isAgenticSearchEnabled = process.env.USER_TYPE === 'ant';
|
|
|
|
const [currentBranch, setCurrentBranch] = React.useState<string | null>(null);
|
|
const [branchFilterEnabled, setBranchFilterEnabled] = React.useState(false);
|
|
const [showAllWorktrees, setShowAllWorktrees] = React.useState(false);
|
|
const [hasMultipleWorktrees, setHasMultipleWorktrees] = React.useState(false);
|
|
const currentCwd = React.useMemo(() => getOriginalCwd(), []);
|
|
const [renameValue, setRenameValue] = React.useState('');
|
|
const [renameCursorOffset, setRenameCursorOffset] = React.useState(0);
|
|
const [expandedGroupSessionIds, setExpandedGroupSessionIds] = React.useState<Set<string>>(new Set());
|
|
const [focusedNode, setFocusedNode] = React.useState<LogTreeNode | null>(null);
|
|
// Track focused index for scroll position display in title
|
|
const [focusedIndex, setFocusedIndex] = React.useState(1);
|
|
const [viewMode, setViewMode] = React.useState<'list' | 'preview' | 'rename' | 'search'>('list');
|
|
const [previewLog, setPreviewLog] = React.useState<LogOption | null>(null);
|
|
const prevFocusedIdRef = React.useRef<string | null>(null);
|
|
const [selectedTagIndex, setSelectedTagIndex] = React.useState(0);
|
|
|
|
// Agentic search state
|
|
const [agenticSearchState, setAgenticSearchState] = React.useState<AgenticSearchState>({ status: 'idle' });
|
|
// Track if the "Search deeply using Claude" option is focused
|
|
const [isAgenticSearchOptionFocused, setIsAgenticSearchOptionFocused] = React.useState(false);
|
|
// AbortController for cancelling agentic search
|
|
const agenticSearchAbortRef = React.useRef<AbortController | null>(null);
|
|
|
|
const {
|
|
query: searchQuery,
|
|
setQuery: setSearchQuery,
|
|
cursorOffset: searchCursorOffset,
|
|
} = useSearchInput({
|
|
isActive: viewMode === 'search' && agenticSearchState.status !== 'searching',
|
|
onExit: () => {
|
|
setViewMode('list');
|
|
logEvent('tengu_session_search_toggled', { enabled: false });
|
|
},
|
|
onExitUp: () => {
|
|
setViewMode('list');
|
|
logEvent('tengu_session_search_toggled', { enabled: false });
|
|
},
|
|
passthroughCtrlKeys: ['n'],
|
|
initialQuery: initialSearchQuery || '',
|
|
});
|
|
|
|
// Debounce transcript search for performance (title search is instant)
|
|
const deferredSearchQuery = React.useDeferredValue(searchQuery);
|
|
|
|
// Additional debounce for deep search - wait 300ms after typing stops
|
|
const [debouncedDeepSearchQuery, setDebouncedDeepSearchQuery] = React.useState('');
|
|
React.useEffect(() => {
|
|
if (!deferredSearchQuery) {
|
|
setDebouncedDeepSearchQuery('');
|
|
return;
|
|
}
|
|
const timeoutId = setTimeout(setDebouncedDeepSearchQuery, 300, deferredSearchQuery);
|
|
return () => clearTimeout(timeoutId);
|
|
}, [deferredSearchQuery]);
|
|
|
|
// State for async deep search results
|
|
const [deepSearchResults, setDeepSearchResults] = React.useState<{
|
|
results: Array<{ log: LogOption; score?: number; searchableText: string }>;
|
|
query: string;
|
|
} | null>(null);
|
|
const [isSearching, setIsSearching] = React.useState(false);
|
|
|
|
React.useEffect(() => {
|
|
void getBranch().then(branch => setCurrentBranch(branch));
|
|
void getWorktreePaths(currentCwd).then(paths => {
|
|
setHasMultipleWorktrees(paths.length > 1);
|
|
});
|
|
}, [currentCwd]);
|
|
|
|
// Memoize searchable text extraction - only recompute when logs change
|
|
const searchableTextByLog = React.useMemo(() => new Map(logs.map(log => [log, buildSearchableText(log)])), [logs]);
|
|
|
|
// Pre-build Fuse index once when logs change (not on every search query)
|
|
const fuseIndex = React.useMemo(() => {
|
|
if (!isDeepSearchEnabled) return null;
|
|
|
|
const logsWithText = logs
|
|
.map(log => ({
|
|
log,
|
|
searchableText: searchableTextByLog.get(log) ?? '',
|
|
}))
|
|
.filter(item => item.searchableText);
|
|
|
|
return new Fuse(logsWithText, {
|
|
keys: ['searchableText'],
|
|
threshold: FUSE_THRESHOLD,
|
|
ignoreLocation: true,
|
|
includeScore: true,
|
|
});
|
|
}, [logs, searchableTextByLog, isDeepSearchEnabled]);
|
|
|
|
// Compute unique tags from logs (before any filtering)
|
|
const uniqueTags = React.useMemo(() => getUniqueTags(logs), [logs]);
|
|
const hasTags = uniqueTags.length > 0;
|
|
const tagTabs = React.useMemo(() => (hasTags ? ['All', ...uniqueTags] : []), [hasTags, uniqueTags]);
|
|
|
|
// Clamp out-of-bounds index (e.g., after logs change) without an extra render
|
|
const effectiveTagIndex = tagTabs.length > 0 && selectedTagIndex < tagTabs.length ? selectedTagIndex : 0;
|
|
const selectedTab = tagTabs[effectiveTagIndex];
|
|
const tagFilter = selectedTab === 'All' ? undefined : selectedTab;
|
|
|
|
// Tag tabs are now a single line with horizontal scrolling
|
|
const tagTabsLines = hasTags ? 1 : 0;
|
|
|
|
// Base filtering (instant) - applies tag, branch, and resume filters
|
|
const baseFilteredLogs = React.useMemo(() => {
|
|
let filtered = logs;
|
|
if (isResumeWithRenameEnabled) {
|
|
filtered = logs.filter(log => {
|
|
const currentSessionId = getSessionId();
|
|
const logSessionId = getSessionIdFromLog(log);
|
|
const isCurrentSession = currentSessionId && logSessionId === currentSessionId;
|
|
// Always show current session
|
|
if (isCurrentSession) {
|
|
return true;
|
|
}
|
|
// Always show sessions with custom titles (e.g., loop mode sessions)
|
|
if (log.customTitle) {
|
|
return true;
|
|
}
|
|
// For full logs, check messages array
|
|
const fromMessages = getFirstMeaningfulUserMessageTextContent(log.messages);
|
|
if (fromMessages) {
|
|
return true;
|
|
}
|
|
// All logs reaching this component are enriched — include if
|
|
// they have a prompt or custom title
|
|
if (log.firstPrompt || log.customTitle) {
|
|
return true;
|
|
}
|
|
return false;
|
|
});
|
|
}
|
|
|
|
// Apply tag filter if specified
|
|
if (tagFilter !== undefined) {
|
|
filtered = filtered.filter(log => log.tag === tagFilter);
|
|
}
|
|
|
|
if (branchFilterEnabled && currentBranch) {
|
|
filtered = filtered.filter(log => log.gitBranch === currentBranch);
|
|
}
|
|
|
|
if (hasMultipleWorktrees && !showAllWorktrees) {
|
|
filtered = filtered.filter(log => log.projectPath === currentCwd);
|
|
}
|
|
|
|
return filtered;
|
|
}, [
|
|
logs,
|
|
isResumeWithRenameEnabled,
|
|
tagFilter,
|
|
branchFilterEnabled,
|
|
currentBranch,
|
|
hasMultipleWorktrees,
|
|
showAllWorktrees,
|
|
currentCwd,
|
|
]);
|
|
|
|
// Instant title/branch/tag/PR filtering (runs on every keystroke, but is fast)
|
|
const titleFilteredLogs = React.useMemo(() => {
|
|
if (!searchQuery) {
|
|
return baseFilteredLogs;
|
|
}
|
|
const query = searchQuery.toLowerCase();
|
|
return baseFilteredLogs.filter(log => {
|
|
const displayedTitle = getLogDisplayTitle(log).toLowerCase();
|
|
const branch = (log.gitBranch || '').toLowerCase();
|
|
const tag = (log.tag || '').toLowerCase();
|
|
const prInfo = log.prNumber ? `pr #${log.prNumber} ${log.prRepository || ''}`.toLowerCase() : '';
|
|
return displayedTitle.includes(query) || branch.includes(query) || tag.includes(query) || prInfo.includes(query);
|
|
});
|
|
}, [baseFilteredLogs, searchQuery]);
|
|
|
|
// Show searching indicator when query is pending debounce
|
|
React.useEffect(() => {
|
|
if (isDeepSearchEnabled && deferredSearchQuery && deferredSearchQuery !== debouncedDeepSearchQuery) {
|
|
setIsSearching(true);
|
|
}
|
|
}, [deferredSearchQuery, debouncedDeepSearchQuery, isDeepSearchEnabled]);
|
|
|
|
// Async deep search effect - runs after 300ms debounce
|
|
React.useEffect(() => {
|
|
if (!isDeepSearchEnabled || !debouncedDeepSearchQuery || !fuseIndex) {
|
|
setDeepSearchResults(null);
|
|
setIsSearching(false);
|
|
return;
|
|
}
|
|
|
|
// Use setTimeout(0) to yield to the event loop - prevents UI freeze
|
|
const timeoutId = setTimeout(
|
|
(fuseIndex, debouncedDeepSearchQuery, setDeepSearchResults, setIsSearching) => {
|
|
const results = fuseIndex.search(debouncedDeepSearchQuery);
|
|
|
|
// Sort by date (newest first), with relevance as tie-breaker within same minute
|
|
results.sort((a, b) => {
|
|
const aTime = new Date(a.item.log.modified).getTime();
|
|
const bTime = new Date(b.item.log.modified).getTime();
|
|
const timeDiff = bTime - aTime;
|
|
if (Math.abs(timeDiff) > DATE_TIE_THRESHOLD_MS) {
|
|
return timeDiff;
|
|
}
|
|
// Within same minute window, use relevance score (lower is better)
|
|
return (a.score ?? 1) - (b.score ?? 1);
|
|
});
|
|
|
|
setDeepSearchResults({
|
|
results: results.map(r => ({
|
|
log: r.item.log,
|
|
score: r.score,
|
|
searchableText: r.item.searchableText,
|
|
})),
|
|
query: debouncedDeepSearchQuery,
|
|
});
|
|
setIsSearching(false);
|
|
},
|
|
0,
|
|
fuseIndex,
|
|
debouncedDeepSearchQuery,
|
|
setDeepSearchResults,
|
|
setIsSearching,
|
|
);
|
|
|
|
return () => {
|
|
clearTimeout(timeoutId);
|
|
};
|
|
}, [debouncedDeepSearchQuery, fuseIndex, isDeepSearchEnabled]);
|
|
|
|
// Merge title matches with async deep search results
|
|
const { filteredLogs, snippets } = React.useMemo(() => {
|
|
const snippetMap = new Map<LogOption, Snippet>();
|
|
|
|
// Start with instant title matches
|
|
let filtered = titleFilteredLogs;
|
|
|
|
// Merge in deep search results if available and query matches
|
|
if (deepSearchResults && debouncedDeepSearchQuery && deepSearchResults.query === debouncedDeepSearchQuery) {
|
|
// Extract snippets from deep search results
|
|
for (const result of deepSearchResults.results) {
|
|
if (result.searchableText) {
|
|
const snippet = extractSnippet(result.searchableText, debouncedDeepSearchQuery, SNIPPET_CONTEXT_CHARS);
|
|
if (snippet) {
|
|
snippetMap.set(result.log, snippet);
|
|
}
|
|
}
|
|
}
|
|
|
|
// Add transcript-only matches (not already in title matches)
|
|
const titleMatchIds = new Set(filtered.map(log => log.messages[0]?.uuid));
|
|
const transcriptOnlyMatches = deepSearchResults.results
|
|
.map(r => r.log)
|
|
.filter(log => !titleMatchIds.has(log.messages[0]?.uuid));
|
|
filtered = [...filtered, ...transcriptOnlyMatches];
|
|
}
|
|
|
|
return { filteredLogs: filtered, snippets: snippetMap };
|
|
}, [titleFilteredLogs, deepSearchResults, debouncedDeepSearchQuery]);
|
|
|
|
// Use agentic search results when available and non-empty, otherwise use regular filtered logs
|
|
const displayedLogs = React.useMemo(() => {
|
|
if (agenticSearchState.status === 'results' && agenticSearchState.results.length > 0) {
|
|
return agenticSearchState.results;
|
|
}
|
|
return filteredLogs;
|
|
}, [agenticSearchState, filteredLogs]);
|
|
|
|
// Calculate available width for the summary text
|
|
const maxLabelWidth = Math.max(30, columns - 4);
|
|
|
|
// Build tree nodes for grouped view
|
|
const treeNodes = React.useMemo<LogTreeNode[]>(() => {
|
|
if (!isResumeWithRenameEnabled) {
|
|
return [];
|
|
}
|
|
|
|
const sessionGroups = groupLogsBySessionId(displayedLogs);
|
|
|
|
return Array.from(sessionGroups.entries()).map(([sessionId, groupLogs]): LogTreeNode => {
|
|
const latestLog = groupLogs[0]!;
|
|
const indexInFiltered = displayedLogs.indexOf(latestLog);
|
|
const snippet = snippets.get(latestLog);
|
|
const snippetStr = snippet ? formatSnippet(snippet, highlightColor) : null;
|
|
|
|
if (groupLogs.length === 1) {
|
|
// Single log - no children
|
|
const metadata = buildLogMetadata(latestLog, {
|
|
showProjectPath: showAllProjects,
|
|
});
|
|
return {
|
|
id: `log:${sessionId}:0`,
|
|
value: { log: latestLog, indexInFiltered },
|
|
label: buildLogLabel(latestLog, maxLabelWidth),
|
|
description: snippetStr ? `${metadata}\n ${snippetStr}` : metadata,
|
|
dimDescription: true,
|
|
};
|
|
}
|
|
|
|
// Multiple logs - parent with children
|
|
const forkCount = groupLogs.length - 1;
|
|
const children: LogTreeNode[] = groupLogs.slice(1).map((log, index) => {
|
|
const childIndexInFiltered = displayedLogs.indexOf(log);
|
|
const childSnippet = snippets.get(log);
|
|
const childSnippetStr = childSnippet ? formatSnippet(childSnippet, highlightColor) : null;
|
|
const childMetadata = buildLogMetadata(log, {
|
|
isChild: true,
|
|
showProjectPath: showAllProjects,
|
|
});
|
|
return {
|
|
id: `log:${sessionId}:${index + 1}`,
|
|
value: { log, indexInFiltered: childIndexInFiltered },
|
|
label: buildLogLabel(log, maxLabelWidth, { isChild: true }),
|
|
description: childSnippetStr ? `${childMetadata}\n ${childSnippetStr}` : childMetadata,
|
|
dimDescription: true,
|
|
};
|
|
});
|
|
|
|
const parentMetadata = buildLogMetadata(latestLog, {
|
|
showProjectPath: showAllProjects,
|
|
});
|
|
return {
|
|
id: `group:${sessionId}`,
|
|
value: { log: latestLog, indexInFiltered },
|
|
label: buildLogLabel(latestLog, maxLabelWidth, {
|
|
isGroupHeader: true,
|
|
forkCount,
|
|
}),
|
|
description: snippetStr ? `${parentMetadata}\n ${snippetStr}` : parentMetadata,
|
|
dimDescription: true,
|
|
children,
|
|
};
|
|
});
|
|
}, [isResumeWithRenameEnabled, displayedLogs, maxLabelWidth, showAllProjects, snippets, highlightColor]);
|
|
|
|
// Build options for old flat list view
|
|
const flatOptions = React.useMemo(() => {
|
|
if (isResumeWithRenameEnabled) {
|
|
return [];
|
|
}
|
|
|
|
return displayedLogs.map((log, index) => {
|
|
const rawSummary = getLogDisplayTitle(log);
|
|
const summaryWithSidechain = rawSummary + (log.isSidechain ? ' (sidechain)' : '');
|
|
const summary = normalizeAndTruncateToWidth(summaryWithSidechain, maxLabelWidth);
|
|
|
|
const baseDescription = formatLogMetadata(log);
|
|
const projectSuffix = showAllProjects && log.projectPath ? ` · ${log.projectPath}` : '';
|
|
const snippet = snippets.get(log);
|
|
const snippetStr = snippet ? formatSnippet(snippet, highlightColor) : null;
|
|
|
|
return {
|
|
label: summary,
|
|
description: snippetStr
|
|
? `${baseDescription}${projectSuffix}\n ${snippetStr}`
|
|
: baseDescription + projectSuffix,
|
|
dimDescription: true,
|
|
value: index.toString(),
|
|
};
|
|
});
|
|
}, [isResumeWithRenameEnabled, displayedLogs, highlightColor, maxLabelWidth, showAllProjects, snippets]);
|
|
|
|
// Derive the focused log from focusedNode
|
|
const focusedLog = focusedNode?.value.log ?? null;
|
|
|
|
const getExpandCollapseHint = (): string => {
|
|
if (!isResumeWithRenameEnabled || !focusedLog) return '';
|
|
const sessionId = getSessionIdFromLog(focusedLog);
|
|
if (!sessionId) return '';
|
|
|
|
const sessionLogs = displayedLogs.filter(log => getSessionIdFromLog(log) === sessionId);
|
|
const hasMultipleLogs = sessionLogs.length > 1;
|
|
|
|
if (!hasMultipleLogs) return '';
|
|
|
|
const isExpanded = expandedGroupSessionIds.has(sessionId);
|
|
const isChildNode = sessionLogs.indexOf(focusedLog) > 0;
|
|
|
|
if (isChildNode) {
|
|
return '← to collapse';
|
|
}
|
|
|
|
return isExpanded ? '← to collapse' : '→ to expand';
|
|
};
|
|
|
|
const handleRenameSubmit = React.useCallback(async () => {
|
|
const sessionId = focusedLog ? getSessionIdFromLog(focusedLog) : undefined;
|
|
if (!focusedLog || !sessionId) {
|
|
setViewMode('list');
|
|
setRenameValue('');
|
|
return;
|
|
}
|
|
|
|
if (renameValue.trim()) {
|
|
// Pass fullPath for cross-project sessions (different worktrees)
|
|
await saveCustomTitle(sessionId, renameValue.trim(), focusedLog.fullPath);
|
|
if (isResumeWithRenameEnabled && onLogsChanged) {
|
|
onLogsChanged();
|
|
}
|
|
}
|
|
setViewMode('list');
|
|
setRenameValue('');
|
|
}, [focusedLog, renameValue, onLogsChanged, isResumeWithRenameEnabled]);
|
|
|
|
const exitSearchMode = React.useCallback(() => {
|
|
setViewMode('list');
|
|
logEvent('tengu_session_search_toggled', { enabled: false });
|
|
}, []);
|
|
|
|
const enterSearchMode = React.useCallback(() => {
|
|
setViewMode('search');
|
|
logEvent('tengu_session_search_toggled', { enabled: true });
|
|
}, []);
|
|
|
|
// Handler for triggering agentic search
|
|
const handleAgenticSearch = React.useCallback(async () => {
|
|
if (!searchQuery.trim() || !onAgenticSearch || !isAgenticSearchEnabled) {
|
|
return;
|
|
}
|
|
|
|
// Abort any previous search
|
|
agenticSearchAbortRef.current?.abort();
|
|
const abortController = new AbortController();
|
|
agenticSearchAbortRef.current = abortController;
|
|
|
|
setAgenticSearchState({ status: 'searching' });
|
|
logEvent('tengu_agentic_search_started', {
|
|
query_length: searchQuery.length,
|
|
});
|
|
|
|
try {
|
|
const results = await onAgenticSearch(searchQuery, logs, abortController.signal);
|
|
// Check if aborted before updating state
|
|
if (abortController.signal.aborted) {
|
|
return;
|
|
}
|
|
setAgenticSearchState({ status: 'results', results, query: searchQuery });
|
|
logEvent('tengu_agentic_search_completed', {
|
|
query_length: searchQuery.length,
|
|
results_count: results.length,
|
|
});
|
|
} catch (error) {
|
|
// Don't show error for aborted requests
|
|
if (abortController.signal.aborted) {
|
|
return;
|
|
}
|
|
setAgenticSearchState({
|
|
status: 'error',
|
|
message: error instanceof Error ? error.message : 'Search failed',
|
|
});
|
|
logEvent('tengu_agentic_search_error', {
|
|
query_length: searchQuery.length,
|
|
});
|
|
}
|
|
}, [searchQuery, onAgenticSearch, isAgenticSearchEnabled, logs]);
|
|
|
|
// Clear agentic search results/error when query changes
|
|
React.useEffect(() => {
|
|
if (agenticSearchState.status !== 'idle' && agenticSearchState.status !== 'searching') {
|
|
// Clear if the query has changed from the one used for results/error
|
|
if (
|
|
(agenticSearchState.status === 'results' && agenticSearchState.query !== searchQuery) ||
|
|
agenticSearchState.status === 'error'
|
|
) {
|
|
setAgenticSearchState({ status: 'idle' });
|
|
}
|
|
}
|
|
}, [searchQuery, agenticSearchState]);
|
|
|
|
// Cleanup: abort any in-progress agentic search on unmount
|
|
React.useEffect(() => {
|
|
return () => {
|
|
agenticSearchAbortRef.current?.abort();
|
|
};
|
|
}, []);
|
|
|
|
// Focus first item when agentic search completes with results
|
|
const prevAgenticStatusRef = React.useRef(agenticSearchState.status);
|
|
React.useEffect(() => {
|
|
const prevStatus = prevAgenticStatusRef.current;
|
|
prevAgenticStatusRef.current = agenticSearchState.status;
|
|
|
|
// When search just completed, focus the first item in the list
|
|
if (prevStatus === 'searching' && agenticSearchState.status === 'results') {
|
|
if (isResumeWithRenameEnabled && treeNodes.length > 0) {
|
|
setFocusedNode(treeNodes[0]!);
|
|
} else if (!isResumeWithRenameEnabled && displayedLogs.length > 0) {
|
|
const firstLog = displayedLogs[0]!;
|
|
setFocusedNode({
|
|
id: '0',
|
|
value: { log: firstLog, indexInFiltered: 0 },
|
|
label: '',
|
|
});
|
|
}
|
|
}
|
|
}, [agenticSearchState.status, isResumeWithRenameEnabled, treeNodes, displayedLogs]);
|
|
|
|
const handleFlatOptionsSelectFocus = React.useCallback(
|
|
(value: string) => {
|
|
const index = parseInt(value, 10);
|
|
const log = displayedLogs[index];
|
|
if (!log || prevFocusedIdRef.current === index.toString()) {
|
|
return;
|
|
}
|
|
prevFocusedIdRef.current = index.toString();
|
|
setFocusedNode({
|
|
id: index.toString(),
|
|
value: { log, indexInFiltered: index },
|
|
label: '',
|
|
});
|
|
setFocusedIndex(index + 1);
|
|
},
|
|
[displayedLogs],
|
|
);
|
|
|
|
const handleTreeSelectFocus = React.useCallback(
|
|
(node: LogTreeNode) => {
|
|
setFocusedNode(node);
|
|
// Update focused index for scroll position display
|
|
const index = displayedLogs.findIndex(log => getSessionIdFromLog(log) === getSessionIdFromLog(node.value.log));
|
|
if (index >= 0) {
|
|
setFocusedIndex(index + 1);
|
|
}
|
|
},
|
|
[displayedLogs],
|
|
);
|
|
|
|
// Escape to abort agentic search in progress
|
|
useKeybinding(
|
|
'confirm:no',
|
|
() => {
|
|
agenticSearchAbortRef.current?.abort();
|
|
setAgenticSearchState({ status: 'idle' });
|
|
logEvent('tengu_agentic_search_cancelled', {});
|
|
},
|
|
{
|
|
context: 'Confirmation',
|
|
isActive: viewMode !== 'preview' && agenticSearchState.status === 'searching',
|
|
},
|
|
);
|
|
|
|
// Escape in rename mode - exit rename mode
|
|
// Use Settings context so 'n' key doesn't exit (allows typing 'n' in rename input)
|
|
useKeybinding(
|
|
'confirm:no',
|
|
() => {
|
|
setViewMode('list');
|
|
setRenameValue('');
|
|
},
|
|
{
|
|
context: 'Settings',
|
|
isActive: viewMode === 'rename' && agenticSearchState.status !== 'searching',
|
|
},
|
|
);
|
|
|
|
// Escape when agentic search option focused - clear and cancel
|
|
useKeybinding(
|
|
'confirm:no',
|
|
() => {
|
|
setSearchQuery('');
|
|
setIsAgenticSearchOptionFocused(false);
|
|
onCancel?.();
|
|
},
|
|
{
|
|
context: 'Confirmation',
|
|
isActive:
|
|
viewMode !== 'preview' &&
|
|
viewMode !== 'rename' &&
|
|
viewMode !== 'search' &&
|
|
isAgenticSearchOptionFocused &&
|
|
agenticSearchState.status !== 'searching',
|
|
},
|
|
);
|
|
|
|
// Handle non-escape input
|
|
useInput(
|
|
(input, key) => {
|
|
if (viewMode === 'preview') {
|
|
// Preview mode handles its own input
|
|
return;
|
|
}
|
|
|
|
// Agentic search abort is now handled via keybinding
|
|
if (agenticSearchState.status === 'searching') {
|
|
return;
|
|
}
|
|
|
|
if (viewMode === 'rename') {
|
|
// Rename mode escape is now handled via keybinding
|
|
// This branch only handles non-escape input in rename mode (via TextInput)
|
|
} else if (viewMode === 'search') {
|
|
// Text input is handled by useSearchInput hook
|
|
if (input.toLowerCase() === 'n' && key.ctrl) {
|
|
exitSearchMode();
|
|
} else if (key.return || key.downArrow) {
|
|
// Focus agentic search option if applicable
|
|
if (
|
|
searchQuery.trim() &&
|
|
onAgenticSearch &&
|
|
isAgenticSearchEnabled &&
|
|
agenticSearchState.status !== 'results'
|
|
) {
|
|
setIsAgenticSearchOptionFocused(true);
|
|
}
|
|
}
|
|
} else {
|
|
// Handle agentic search option when focused (escape handled via keybinding)
|
|
if (isAgenticSearchOptionFocused) {
|
|
if (key.return) {
|
|
// Trigger agentic search
|
|
void handleAgenticSearch();
|
|
setIsAgenticSearchOptionFocused(false);
|
|
return;
|
|
} else if (key.downArrow) {
|
|
// Move focus to the session list
|
|
setIsAgenticSearchOptionFocused(false);
|
|
return;
|
|
} else if (key.upArrow) {
|
|
// Go back to search mode
|
|
setViewMode('search');
|
|
setIsAgenticSearchOptionFocused(false);
|
|
return;
|
|
}
|
|
}
|
|
|
|
// Handle tab cycling for tag tabs
|
|
if (hasTags && key.tab) {
|
|
const offset = key.shift ? -1 : 1;
|
|
setSelectedTagIndex(prev => {
|
|
const current = prev < tagTabs.length ? prev : 0;
|
|
const newIndex = (current + tagTabs.length + offset) % tagTabs.length;
|
|
const newTab = tagTabs[newIndex];
|
|
logEvent('tengu_session_tag_filter_changed', {
|
|
is_all: newTab === 'All',
|
|
tag_count: uniqueTags.length,
|
|
});
|
|
return newIndex;
|
|
});
|
|
return;
|
|
}
|
|
|
|
const keyIsNotCtrlOrMeta = !key.ctrl && !key.meta;
|
|
const lowerInput = input.toLowerCase();
|
|
// Ctrl+letter shortcuts for actions (freeing up plain letters for type-to-search)
|
|
if (lowerInput === 'a' && key.ctrl && onToggleAllProjects) {
|
|
onToggleAllProjects();
|
|
logEvent('tengu_session_all_projects_toggled', {
|
|
enabled: !showAllProjects,
|
|
});
|
|
} else if (lowerInput === 'b' && key.ctrl) {
|
|
const newEnabled = !branchFilterEnabled;
|
|
setBranchFilterEnabled(newEnabled);
|
|
logEvent('tengu_session_branch_filter_toggled', {
|
|
enabled: newEnabled,
|
|
});
|
|
} else if (lowerInput === 'w' && key.ctrl && hasMultipleWorktrees) {
|
|
const newValue = !showAllWorktrees;
|
|
setShowAllWorktrees(newValue);
|
|
logEvent('tengu_session_worktree_filter_toggled', {
|
|
enabled: newValue,
|
|
});
|
|
} else if (lowerInput === '/' && keyIsNotCtrlOrMeta) {
|
|
setViewMode('search');
|
|
logEvent('tengu_session_search_toggled', { enabled: true });
|
|
} else if (lowerInput === 'r' && key.ctrl && focusedLog) {
|
|
setViewMode('rename');
|
|
setRenameValue('');
|
|
logEvent('tengu_session_rename_started', {});
|
|
} else if (lowerInput === 'v' && key.ctrl && focusedLog) {
|
|
setPreviewLog(focusedLog);
|
|
setViewMode('preview');
|
|
logEvent('tengu_session_preview_opened', {
|
|
messageCount: focusedLog.messageCount,
|
|
});
|
|
} else if (focusedLog && keyIsNotCtrlOrMeta && input.length > 0 && !/^\s+$/.test(input)) {
|
|
// Any printable character enters search mode and starts typing
|
|
setViewMode('search');
|
|
setSearchQuery(input);
|
|
logEvent('tengu_session_search_toggled', { enabled: true });
|
|
}
|
|
}
|
|
},
|
|
{ isActive: true },
|
|
);
|
|
|
|
const filterIndicators = [];
|
|
if (branchFilterEnabled && currentBranch) {
|
|
filterIndicators.push(currentBranch);
|
|
}
|
|
if (hasMultipleWorktrees && !showAllWorktrees) {
|
|
filterIndicators.push('current worktree');
|
|
}
|
|
|
|
const showAdditionalFilterLine = filterIndicators.length > 0 && viewMode !== 'search';
|
|
|
|
// Search box takes 3 lines (border top, content, border bottom)
|
|
const searchBoxLines = 3;
|
|
const headerLines = 5 + searchBoxLines + (showAdditionalFilterLine ? 1 : 0) + tagTabsLines;
|
|
const footerLines = 2;
|
|
const visibleCount = Math.max(1, Math.floor((maxHeight - headerLines - footerLines) / 3));
|
|
|
|
// Progressive loading: request more logs when user scrolls near the end
|
|
React.useEffect(() => {
|
|
if (!onLoadMore) return;
|
|
const buffer = visibleCount * 2;
|
|
if (focusedIndex + buffer >= displayedLogs.length) {
|
|
onLoadMore(visibleCount * 3);
|
|
}
|
|
}, [focusedIndex, visibleCount, displayedLogs.length, onLoadMore]);
|
|
|
|
// Early return if no logs
|
|
if (logs.length === 0) {
|
|
return null;
|
|
}
|
|
|
|
// Show preview mode if active
|
|
if (viewMode === 'preview' && previewLog && isResumeWithRenameEnabled) {
|
|
return (
|
|
<SessionPreview
|
|
log={previewLog}
|
|
onExit={() => {
|
|
setViewMode('list');
|
|
setPreviewLog(null);
|
|
}}
|
|
onSelect={onSelect}
|
|
/>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<Box flexDirection="column" height={maxHeight - 1}>
|
|
<Box flexShrink={0}>
|
|
<Divider color="suggestion" />
|
|
</Box>
|
|
<Box flexShrink={0}>
|
|
<Text> </Text>
|
|
</Box>
|
|
|
|
{hasTags ? (
|
|
<TagTabs
|
|
tabs={tagTabs}
|
|
selectedIndex={effectiveTagIndex}
|
|
availableWidth={columns}
|
|
showAllProjects={showAllProjects}
|
|
/>
|
|
) : (
|
|
<Box flexShrink={0}>
|
|
<Text bold color="suggestion">
|
|
Resume Session
|
|
{viewMode === 'list' && displayedLogs.length > visibleCount && (
|
|
<Text dimColor>
|
|
{' '}
|
|
({focusedIndex} of {displayedLogs.length})
|
|
</Text>
|
|
)}
|
|
</Text>
|
|
</Box>
|
|
)}
|
|
<SearchBox
|
|
query={searchQuery}
|
|
isFocused={viewMode === 'search'}
|
|
isTerminalFocused={isTerminalFocused}
|
|
cursorOffset={searchCursorOffset}
|
|
/>
|
|
{filterIndicators.length > 0 && viewMode !== 'search' && (
|
|
<Box flexShrink={0} paddingLeft={2}>
|
|
<Text dimColor>
|
|
<Byline>{filterIndicators}</Byline>
|
|
</Text>
|
|
</Box>
|
|
)}
|
|
<Box flexShrink={0}>
|
|
<Text> </Text>
|
|
</Box>
|
|
|
|
{/* Agentic search loading state */}
|
|
{agenticSearchState.status === 'searching' && (
|
|
<Box paddingLeft={1} flexShrink={0}>
|
|
<Spinner />
|
|
<Text> Searching…</Text>
|
|
</Box>
|
|
)}
|
|
|
|
{/* Results header when agentic search completed with results */}
|
|
{agenticSearchState.status === 'results' && agenticSearchState.results.length > 0 && (
|
|
<Box paddingLeft={1} marginBottom={1} flexShrink={0}>
|
|
<Text dimColor italic>
|
|
Claude found these results:
|
|
</Text>
|
|
</Box>
|
|
)}
|
|
|
|
{/* Fallback message when agentic search found no results and deep search also has nothing */}
|
|
{agenticSearchState.status === 'results' &&
|
|
agenticSearchState.results.length === 0 &&
|
|
filteredLogs.length === 0 && (
|
|
<Box paddingLeft={1} marginBottom={1} flexShrink={0}>
|
|
<Text dimColor italic>
|
|
No matching sessions found.
|
|
</Text>
|
|
</Box>
|
|
)}
|
|
|
|
{/* Error message when agentic search failed and deep search also has nothing */}
|
|
{agenticSearchState.status === 'error' && filteredLogs.length === 0 && (
|
|
<Box paddingLeft={1} marginBottom={1} flexShrink={0}>
|
|
<Text dimColor italic>
|
|
No matching sessions found.
|
|
</Text>
|
|
</Box>
|
|
)}
|
|
|
|
{/* Agentic search option - first item in list when searching */}
|
|
{Boolean(searchQuery.trim()) &&
|
|
onAgenticSearch &&
|
|
isAgenticSearchEnabled &&
|
|
agenticSearchState.status !== 'searching' &&
|
|
agenticSearchState.status !== 'results' &&
|
|
agenticSearchState.status !== 'error' && (
|
|
<Box flexShrink={0} flexDirection="column">
|
|
<Box flexDirection="row" gap={1}>
|
|
<Text color={isAgenticSearchOptionFocused ? 'suggestion' : undefined}>
|
|
{isAgenticSearchOptionFocused ? figures.pointer : ' '}
|
|
</Text>
|
|
<Text color={isAgenticSearchOptionFocused ? 'suggestion' : undefined} bold={isAgenticSearchOptionFocused}>
|
|
Search deeply using Claude →
|
|
</Text>
|
|
</Box>
|
|
<Box height={1} />
|
|
</Box>
|
|
)}
|
|
|
|
{/* Hide session list when agentic search is in progress */}
|
|
{agenticSearchState.status === 'searching' ? null : viewMode === 'rename' && focusedLog ? (
|
|
<Box paddingLeft={2} flexDirection="column">
|
|
<Text bold>Rename session:</Text>
|
|
<Box paddingTop={1}>
|
|
<TextInput
|
|
value={renameValue}
|
|
onChange={setRenameValue}
|
|
onSubmit={handleRenameSubmit}
|
|
placeholder={getLogDisplayTitle(focusedLog!, 'Enter new session name')}
|
|
columns={columns}
|
|
cursorOffset={renameCursorOffset}
|
|
onChangeCursorOffset={setRenameCursorOffset}
|
|
showCursor={true}
|
|
/>
|
|
</Box>
|
|
</Box>
|
|
) : isResumeWithRenameEnabled ? (
|
|
<TreeSelect
|
|
nodes={treeNodes}
|
|
onSelect={node => {
|
|
onSelect(node.value.log);
|
|
}}
|
|
onFocus={handleTreeSelectFocus}
|
|
onCancel={onCancel}
|
|
focusNodeId={focusedNode?.id}
|
|
visibleOptionCount={visibleCount}
|
|
layout="expanded"
|
|
isDisabled={viewMode === 'search' || isAgenticSearchOptionFocused}
|
|
hideIndexes={false}
|
|
isNodeExpanded={nodeId => {
|
|
// Always expand if in search or branch filter mode
|
|
if (viewMode === 'search' || branchFilterEnabled) {
|
|
return true;
|
|
}
|
|
// Extract sessionId from node ID (format: "group:sessionId")
|
|
const sessionId = typeof nodeId === 'string' && nodeId.startsWith('group:') ? nodeId.substring(6) : null;
|
|
return sessionId ? expandedGroupSessionIds.has(sessionId) : false;
|
|
}}
|
|
onExpand={nodeId => {
|
|
const sessionId = typeof nodeId === 'string' && nodeId.startsWith('group:') ? nodeId.substring(6) : null;
|
|
if (sessionId) {
|
|
setExpandedGroupSessionIds(prev => new Set(prev).add(sessionId));
|
|
logEvent('tengu_session_group_expanded', {});
|
|
}
|
|
}}
|
|
onCollapse={nodeId => {
|
|
const sessionId = typeof nodeId === 'string' && nodeId.startsWith('group:') ? nodeId.substring(6) : null;
|
|
if (sessionId) {
|
|
setExpandedGroupSessionIds(prev => {
|
|
const newSet = new Set(prev);
|
|
newSet.delete(sessionId);
|
|
return newSet;
|
|
});
|
|
}
|
|
}}
|
|
onUpFromFirstItem={enterSearchMode}
|
|
/>
|
|
) : (
|
|
<Select
|
|
options={flatOptions}
|
|
onChange={value => {
|
|
// Old flat list mode - index directly maps to displayedLogs
|
|
const itemIndex = parseInt(value, 10);
|
|
const log = displayedLogs[itemIndex];
|
|
if (log) {
|
|
onSelect(log);
|
|
}
|
|
}}
|
|
visibleOptionCount={visibleCount}
|
|
onCancel={onCancel}
|
|
onFocus={handleFlatOptionsSelectFocus}
|
|
defaultFocusValue={focusedNode?.id.toString()}
|
|
layout="expanded"
|
|
isDisabled={viewMode === 'search' || isAgenticSearchOptionFocused}
|
|
onUpFromFirstItem={enterSearchMode}
|
|
/>
|
|
)}
|
|
<Box paddingLeft={2}>
|
|
{exitState.pending ? (
|
|
<Text dimColor>Press {exitState.keyName} again to exit</Text>
|
|
) : viewMode === 'rename' ? (
|
|
<Text dimColor>
|
|
<Byline>
|
|
<KeyboardShortcutHint shortcut="Enter" action="save" />
|
|
<ConfigurableShortcutHint
|
|
action="confirm:no"
|
|
context="Confirmation"
|
|
fallback="Esc"
|
|
description="cancel"
|
|
/>
|
|
</Byline>
|
|
</Text>
|
|
) : agenticSearchState.status === 'searching' ? (
|
|
<Text dimColor>
|
|
<Byline>
|
|
<Text>Searching with Claude…</Text>
|
|
<ConfigurableShortcutHint
|
|
action="confirm:no"
|
|
context="Confirmation"
|
|
fallback="Esc"
|
|
description="cancel"
|
|
/>
|
|
</Byline>
|
|
</Text>
|
|
) : isAgenticSearchOptionFocused ? (
|
|
<Text dimColor>
|
|
<Byline>
|
|
<KeyboardShortcutHint shortcut="Enter" action="search" />
|
|
<KeyboardShortcutHint shortcut="↓" action="skip" />
|
|
<ConfigurableShortcutHint
|
|
action="confirm:no"
|
|
context="Confirmation"
|
|
fallback="Esc"
|
|
description="cancel"
|
|
/>
|
|
</Byline>
|
|
</Text>
|
|
) : viewMode === 'search' ? (
|
|
<Text dimColor>
|
|
<Byline>
|
|
<Text>{isSearching && isDeepSearchEnabled ? 'Searching…' : 'Type to Search'}</Text>
|
|
<KeyboardShortcutHint shortcut="Enter" action="select" />
|
|
<ConfigurableShortcutHint action="confirm:no" context="Confirmation" fallback="Esc" description="clear" />
|
|
</Byline>
|
|
</Text>
|
|
) : (
|
|
<Text dimColor>
|
|
<Byline>
|
|
{onToggleAllProjects && (
|
|
<KeyboardShortcutHint
|
|
shortcut="Ctrl+A"
|
|
action={`show ${showAllProjects ? 'current dir' : 'all projects'}`}
|
|
/>
|
|
)}
|
|
{currentBranch && <KeyboardShortcutHint shortcut="Ctrl+B" action="toggle branch" />}
|
|
{hasMultipleWorktrees && (
|
|
<KeyboardShortcutHint
|
|
shortcut="Ctrl+W"
|
|
action={`show ${showAllWorktrees ? 'current worktree' : 'all worktrees'}`}
|
|
/>
|
|
)}
|
|
<KeyboardShortcutHint shortcut="Ctrl+V" action="preview" />
|
|
<KeyboardShortcutHint shortcut="Ctrl+R" action="rename" />
|
|
<Text>Type to search</Text>
|
|
<ConfigurableShortcutHint
|
|
action="confirm:no"
|
|
context="Confirmation"
|
|
fallback="Esc"
|
|
description="cancel"
|
|
/>
|
|
{getExpandCollapseHint() && <Text>{getExpandCollapseHint()}</Text>}
|
|
</Byline>
|
|
</Text>
|
|
)}
|
|
</Box>
|
|
</Box>
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Extracts searchable text content from a message.
|
|
* Handles both string content and structured content blocks.
|
|
*/
|
|
function extractSearchableText(message: SerializedMessage): string {
|
|
// Only extract from user and assistant messages that have content
|
|
if (message.type !== 'user' && message.type !== 'assistant') {
|
|
return '';
|
|
}
|
|
|
|
const content = 'message' in message ? message.message?.content : undefined;
|
|
if (!content) return '';
|
|
|
|
// Handle string content (simple messages)
|
|
if (typeof content === 'string') {
|
|
return content;
|
|
}
|
|
|
|
// Handle array of content blocks
|
|
if (Array.isArray(content)) {
|
|
return content
|
|
.map(block => {
|
|
if (typeof block === 'string') return block;
|
|
if ('text' in block && typeof block.text === 'string') return block.text;
|
|
return '';
|
|
// we don't return thinking blocks and tool names here;
|
|
// they're not useful for search, as they can add noise to the fuzzy matching
|
|
})
|
|
.filter(Boolean)
|
|
.join(' ');
|
|
}
|
|
|
|
return '';
|
|
}
|
|
|
|
/**
|
|
* Builds searchable text for a log including messages, titles, summaries, and metadata.
|
|
* Crops long transcripts to first/last N messages for performance.
|
|
*/
|
|
function buildSearchableText(log: LogOption): string {
|
|
const searchableMessages =
|
|
log.messages.length <= DEEP_SEARCH_MAX_MESSAGES
|
|
? log.messages
|
|
: [...log.messages.slice(0, DEEP_SEARCH_CROP_SIZE), ...log.messages.slice(-DEEP_SEARCH_CROP_SIZE)];
|
|
const messageText = searchableMessages.map(extractSearchableText).filter(Boolean).join(' ');
|
|
|
|
const metadata = [
|
|
log.customTitle,
|
|
log.summary,
|
|
log.firstPrompt,
|
|
log.gitBranch,
|
|
log.tag,
|
|
log.prNumber ? `PR #${log.prNumber}` : undefined,
|
|
log.prRepository,
|
|
]
|
|
.filter(Boolean)
|
|
.join(' ');
|
|
|
|
const fullText = `${metadata} ${messageText}`.trim();
|
|
return fullText.length > DEEP_SEARCH_MAX_TEXT_LENGTH ? fullText.slice(0, DEEP_SEARCH_MAX_TEXT_LENGTH) : fullText;
|
|
}
|
|
|
|
function groupLogsBySessionId(filteredLogs: LogOption[]): Map<string, LogOption[]> {
|
|
const groups = new Map<string, LogOption[]>();
|
|
|
|
for (const log of filteredLogs) {
|
|
const sessionId = getSessionIdFromLog(log);
|
|
if (sessionId) {
|
|
const existing = groups.get(sessionId);
|
|
if (existing) {
|
|
existing.push(log);
|
|
} else {
|
|
groups.set(sessionId, [log]);
|
|
}
|
|
}
|
|
}
|
|
|
|
// Sort logs within each group by modified date (newest first)
|
|
groups.forEach(logs => logs.sort((a, b) => new Date(b.modified).getTime() - new Date(a.modified).getTime()));
|
|
|
|
return groups;
|
|
}
|
|
|
|
/**
|
|
* Get unique tags from a list of logs, sorted alphabetically
|
|
*/
|
|
function getUniqueTags(logs: LogOption[]): string[] {
|
|
const tags = new Set<string>();
|
|
for (const log of logs) {
|
|
if (log.tag) {
|
|
tags.add(log.tag);
|
|
}
|
|
}
|
|
return Array.from(tags).sort((a, b) => a.localeCompare(b));
|
|
}
|