Files
claude-code/src/components/LogSelector.tsx
2026-04-07 16:17:48 +08:00

1442 lines
46 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))
}