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 } 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(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 >(new Set()) const [focusedNode, setFocusedNode] = React.useState(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(null) const prevFocusedIdRef = React.useRef(null) const [selectedTagIndex, setSelectedTagIndex] = React.useState(0) // Agentic search state const [agenticSearchState, setAgenticSearchState] = React.useState({ 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(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() // 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(() => { 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 ( { setViewMode('list') setPreviewLog(null) }} onSelect={onSelect} /> ) } return ( {hasTags ? ( ) : ( Resume Session {viewMode === 'list' && displayedLogs.length > visibleCount && ( {' '} ({focusedIndex} of {displayedLogs.length}) )} )} {filterIndicators.length > 0 && viewMode !== 'search' && ( {filterIndicators} )} {/* Agentic search loading state */} {agenticSearchState.status === 'searching' && ( Searching… )} {/* Results header when agentic search completed with results */} {agenticSearchState.status === 'results' && agenticSearchState.results.length > 0 && ( Claude found these results: )} {/* Fallback message when agentic search found no results and deep search also has nothing */} {agenticSearchState.status === 'results' && agenticSearchState.results.length === 0 && filteredLogs.length === 0 && ( No matching sessions found. )} {/* Error message when agentic search failed and deep search also has nothing */} {agenticSearchState.status === 'error' && filteredLogs.length === 0 && ( No matching sessions found. )} {/* Agentic search option - first item in list when searching */} {Boolean(searchQuery.trim()) && onAgenticSearch && isAgenticSearchEnabled && agenticSearchState.status !== 'searching' && agenticSearchState.status !== 'results' && agenticSearchState.status !== 'error' && ( {isAgenticSearchOptionFocused ? figures.pointer : ' '} Search deeply using Claude → )} {/* Hide session list when agentic search is in progress */} {agenticSearchState.status === 'searching' ? null : viewMode === 'rename' && focusedLog ? ( Rename session: ) : isResumeWithRenameEnabled ? ( { 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} /> ) : (