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

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));
}