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(preselectedMessage); const [diffStatsForRestore, setDiffStatsForRestore] = useState(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>({}); 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… ) : (