import type { ContentBlockParam, TextBlockParam, } from '@anthropic-ai/sdk/resources/index.mjs' import { randomUUID, type UUID } from 'crypto' import figures from 'figures' import * as React from 'react' import { useCallback, useEffect, useMemo, useState } from 'react' import { type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, logEvent, } from 'src/services/analytics/index.js' import { useAppState } from 'src/state/AppState.js' import { type DiffStats, fileHistoryCanRestore, fileHistoryEnabled, fileHistoryGetDiffStats, } from 'src/utils/fileHistory.js' import { logError } from 'src/utils/log.js' import { useExitOnCtrlCDWithKeybindings } from '../hooks/useExitOnCtrlCDWithKeybindings.js' import { Box, Text, Divider } from '@anthropic/ink' import { useKeybinding, useKeybindings } from '../keybindings/useKeybinding.js' import type { Message, PartialCompactDirection, UserMessage, } from '../types/message.js' import { stripDisplayTags } from '../utils/displayTags.js' import { createUserMessage, extractTag, isEmptyMessageText, isSyntheticMessage, isToolUseResultMessage, } from '../utils/messages.js' import { type OptionWithDescription, Select } from './CustomSelect/select.js' import { Spinner } from './Spinner.js' function isTextBlock(block: ContentBlockParam): block is TextBlockParam { return block.type === 'text' } import * as path from 'path' import { useTerminalSize } from 'src/hooks/useTerminalSize.js' import type { FileEditOutput } from '@claude-code-best/builtin-tools/tools/FileEditTool/types.js' import type { Output as FileWriteToolOutput } from '@claude-code-best/builtin-tools/tools/FileWriteTool/FileWriteTool.js' import { BASH_STDERR_TAG, BASH_STDOUT_TAG, COMMAND_MESSAGE_TAG, LOCAL_COMMAND_STDERR_TAG, LOCAL_COMMAND_STDOUT_TAG, TASK_NOTIFICATION_TAG, TEAMMATE_MESSAGE_TAG, TICK_TAG, } from '../constants/xml.js' import { count } from '../utils/array.js' import { formatRelativeTimeAgo, truncate } from '../utils/format.js' import type { Theme } from '../utils/theme.js' type RestoreOption = | 'both' | 'conversation' | 'code' | 'summarize' | 'summarize_up_to' | 'nevermind' function isSummarizeOption( option: RestoreOption | null, ): option is 'summarize' | 'summarize_up_to' { return option === 'summarize' || option === 'summarize_up_to' } type Props = { messages: Message[] onPreRestore: () => void onRestoreMessage: (message: UserMessage) => Promise onRestoreCode: (message: UserMessage) => Promise onSummarize: ( message: UserMessage, feedback?: string, direction?: PartialCompactDirection, ) => Promise onClose: () => void /** Skip pick-list, land on confirm. Caller ran skip-check first. Esc closes fully (no back-to-list). */ preselectedMessage?: UserMessage } const MAX_VISIBLE_MESSAGES = 7 export function MessageSelector({ messages, onPreRestore, onRestoreMessage, onRestoreCode, onSummarize, onClose, preselectedMessage, }: Props): React.ReactNode { const fileHistory = useAppState(s => s.fileHistory) const [error, setError] = useState(undefined) const isFileHistoryEnabled = fileHistoryEnabled() // Add current prompt as a virtual message const currentUUID = useMemo(randomUUID, []) const messageOptions = useMemo( () => [ ...messages.filter(selectableUserMessagesFilter), { ...createUserMessage({ content: '', }), uuid: currentUUID, } as UserMessage, ], [messages, currentUUID], ) const [selectedIndex, setSelectedIndex] = useState(messageOptions.length - 1) // Orient the selected message as the middle of the visible options const firstVisibleIndex = Math.max( 0, Math.min( selectedIndex - Math.floor(MAX_VISIBLE_MESSAGES / 2), messageOptions.length - MAX_VISIBLE_MESSAGES, ), ) const hasMessagesToSelect = messageOptions.length > 1 const [messageToRestore, setMessageToRestore] = useState< UserMessage | undefined >(preselectedMessage) const [diffStatsForRestore, setDiffStatsForRestore] = useState< DiffStats | undefined >(undefined) useEffect(() => { if (!preselectedMessage || !isFileHistoryEnabled) return let cancelled = false void fileHistoryGetDiffStats(fileHistory, preselectedMessage.uuid).then( stats => { if (!cancelled) setDiffStatsForRestore(stats) }, ) return () => { cancelled = true } }, [preselectedMessage, isFileHistoryEnabled, fileHistory]) const [isRestoring, setIsRestoring] = useState(false) const [restoringOption, setRestoringOption] = useState( null, ) const [selectedRestoreOption, setSelectedRestoreOption] = useState('both') // Per-option feedback state; Select's internal inputValues Map persists // per-option text independently, so sharing one variable would desync. const [summarizeFromFeedback, setSummarizeFromFeedback] = useState('') const [summarizeUpToFeedback, setSummarizeUpToFeedback] = useState('') // Generate options with summarize as input type for inline context function getRestoreOptions( canRestoreCode: boolean, ): OptionWithDescription[] { const baseOptions: OptionWithDescription[] = canRestoreCode ? [ { value: 'both', label: 'Restore code and conversation' }, { value: 'conversation', label: 'Restore conversation' }, { value: 'code', label: 'Restore code' }, ] : [{ value: 'conversation', label: 'Restore conversation' }] const summarizeInputProps = { type: 'input' as const, placeholder: 'add context (optional)', initialValue: '', allowEmptySubmitToCancel: true, showLabelWithValue: true, labelValueSeparator: ': ', } baseOptions.push({ value: 'summarize', label: 'Summarize from here', ...summarizeInputProps, onChange: setSummarizeFromFeedback, }) if (process.env.USER_TYPE === 'ant') { baseOptions.push({ value: 'summarize_up_to', label: 'Summarize up to here', ...summarizeInputProps, onChange: setSummarizeUpToFeedback, }) } baseOptions.push({ value: 'nevermind', label: 'Never mind' }) return baseOptions } // Log when selector is opened useEffect(() => { logEvent('tengu_message_selector_opened', {}) }, []) // Helper to restore conversation without confirmation async function restoreConversationDirectly(message: UserMessage) { onPreRestore() setIsRestoring(true) try { await onRestoreMessage(message) setIsRestoring(false) onClose() } catch (error) { logError(error as Error) setIsRestoring(false) setError(`Failed to restore the conversation:\n${error}`) } } async function handleSelect(message: UserMessage) { const index = messages.indexOf(message) const indexFromEnd = messages.length - 1 - index logEvent('tengu_message_selector_selected', { index_from_end: indexFromEnd, message_type: message.type as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, is_current_prompt: false, }) // Do nothing if the message is not found if (!messages.includes(message)) { onClose() return } if (!isFileHistoryEnabled) { await restoreConversationDirectly(message) return } const diffStats = await fileHistoryGetDiffStats(fileHistory, message.uuid) setMessageToRestore(message) setDiffStatsForRestore(diffStats) } async function onSelectRestoreOption(option: RestoreOption) { logEvent('tengu_message_selector_restore_option_selected', { option: option as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, }) if (!messageToRestore) { setError('Message not found.') return } if (option === 'nevermind') { if (preselectedMessage) onClose() else setMessageToRestore(undefined) return } if (isSummarizeOption(option)) { onPreRestore() setIsRestoring(true) setRestoringOption(option) setError(undefined) try { const direction = option === 'summarize_up_to' ? 'up_to' : 'from' const feedback = (direction === 'up_to' ? summarizeUpToFeedback : summarizeFromFeedback ).trim() || undefined await onSummarize(messageToRestore, feedback, direction) setIsRestoring(false) setRestoringOption(null) setMessageToRestore(undefined) onClose() } catch (error) { logError(error as Error) setIsRestoring(false) setRestoringOption(null) setMessageToRestore(undefined) setError(`Failed to summarize:\n${error}`) } return } onPreRestore() setIsRestoring(true) setError(undefined) let codeError: Error | null = null let conversationError: Error | null = null if (option === 'code' || option === 'both') { try { await onRestoreCode(messageToRestore) } catch (error) { codeError = error as Error logError(codeError) } } if (option === 'conversation' || option === 'both') { try { await onRestoreMessage(messageToRestore) } catch (error) { conversationError = error as Error logError(conversationError) } } setIsRestoring(false) setMessageToRestore(undefined) // Handle errors if (conversationError && codeError) { setError( `Failed to restore the conversation and code:\n${conversationError}\n${codeError}`, ) } else if (conversationError) { setError(`Failed to restore the conversation:\n${conversationError}`) } else if (codeError) { setError(`Failed to restore the code:\n${codeError}`) } else { // Success - close the selector onClose() } } const exitState = useExitOnCtrlCDWithKeybindings() const handleEscape = useCallback(() => { if (messageToRestore && !preselectedMessage) { // Go back to message list instead of closing entirely setMessageToRestore(undefined) return } logEvent('tengu_message_selector_cancelled', {}) onClose() }, [onClose, messageToRestore, preselectedMessage]) const moveUp = useCallback( () => setSelectedIndex(prev => Math.max(0, prev - 1)), [], ) const moveDown = useCallback( () => setSelectedIndex(prev => Math.min(messageOptions.length - 1, prev + 1)), [messageOptions.length], ) const jumpToTop = useCallback(() => setSelectedIndex(0), []) const jumpToBottom = useCallback( () => setSelectedIndex(messageOptions.length - 1), [messageOptions.length], ) const handleSelectCurrent = useCallback(() => { const selected = messageOptions[selectedIndex] if (selected) { void handleSelect(selected) } }, [messageOptions, selectedIndex, handleSelect]) // Escape to close - uses Confirmation context where escape is bound useKeybinding('confirm:no', handleEscape, { context: 'Confirmation', isActive: !messageToRestore, }) // Message selector navigation keybindings useKeybindings( { 'messageSelector:up': moveUp, 'messageSelector:down': moveDown, 'messageSelector:top': jumpToTop, 'messageSelector:bottom': jumpToBottom, 'messageSelector:select': handleSelectCurrent, }, { context: 'MessageSelector', isActive: !isRestoring && !error && !messageToRestore && hasMessagesToSelect, }, ) const [fileHistoryMetadata, setFileHistoryMetadata] = useState< Record >({}) useEffect(() => { async function loadFileHistoryMetadata() { if (!isFileHistoryEnabled) { return } // Load file snapshot metadata void Promise.all( messageOptions.map(async (userMessage, itemIndex) => { if (userMessage.uuid !== currentUUID) { const canRestore = fileHistoryCanRestore( fileHistory, userMessage.uuid, ) const nextUserMessage = messageOptions.at(itemIndex + 1) const diffStats = canRestore ? computeDiffStatsBetweenMessages( messages, userMessage.uuid, nextUserMessage?.uuid !== currentUUID ? nextUserMessage?.uuid : undefined, ) : undefined if (diffStats !== undefined) { setFileHistoryMetadata(prev => ({ ...prev, [itemIndex]: diffStats, })) } else { setFileHistoryMetadata(prev => ({ ...prev, [itemIndex]: undefined, })) } } }), ) } void loadFileHistoryMetadata() }, [messageOptions, messages, currentUUID, fileHistory, isFileHistoryEnabled]) const canRestoreCode = isFileHistoryEnabled && diffStatsForRestore?.filesChanged && diffStatsForRestore.filesChanged.length > 0 const showPickList = !error && !messageToRestore && !preselectedMessage && hasMessagesToSelect return ( Rewind {error && ( <> Error: {error} )} {!hasMessagesToSelect && ( <> Nothing to rewind to yet. )} {!error && messageToRestore && hasMessagesToSelect && ( <> Confirm you want to restore{' '} {!diffStatsForRestore && 'the conversation '}to the point before you sent this message: ({formatRelativeTimeAgo(new Date(messageToRestore.timestamp as string | number | Date))}) {isRestoring && isSummarizeOption(restoringOption) ? ( Summarizing… ) : (