import { c as _c } from "react/compiler-runtime"; 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 } from '../ink.js'; 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 'src/tools/FileEditTool/types.js'; import type { Output as FileWriteToolOutput } from 'src/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'; import { Divider } from './design-system/Divider.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 ("external" === '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_0) { logError(error_0 as Error); setIsRestoring(false); setError(`Failed to restore the conversation:\n${error_0}`); } } async function handleSelect(message_0: UserMessage) { const index = messages.indexOf(message_0); const indexFromEnd = messages.length - 1 - index; logEvent('tengu_message_selector_selected', { index_from_end: indexFromEnd, message_type: message_0.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_0)) { onClose(); return; } if (!isFileHistoryEnabled) { await restoreConversationDirectly(message_0); return; } const diffStats = await fileHistoryGetDiffStats(fileHistory, message_0.uuid); setMessageToRestore(message_0); 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_1) { logError(error_1 as Error); setIsRestoring(false); setRestoringOption(null); setMessageToRestore(undefined); setError(`Failed to summarize:\n${error_1}`); } 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_2) { codeError = error_2 as Error; logError(codeError); } } if (option === 'conversation' || option === 'both') { try { await onRestoreMessage(messageToRestore); } catch (error_3) { conversationError = error_3 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_0 => Math.min(messageOptions.length - 1, prev_0 + 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_0 = canRestore ? computeDiffStatsBetweenMessages(messages, userMessage.uuid, nextUserMessage?.uuid !== currentUUID ? nextUserMessage?.uuid : undefined) : undefined; if (diffStats_0 !== undefined) { setFileHistoryMetadata(prev_1 => ({ ...prev_1, [itemIndex]: diffStats_0 })); } else { setFileHistoryMetadata(prev_2 => ({ ...prev_2, [itemIndex]: undefined })); } } })); } void loadFileHistoryMetadata(); }, [messageOptions, messages, currentUUID, fileHistory, isFileHistoryEnabled]); const canRestoreCode_0 = 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))}) {isRestoring && isSummarizeOption(restoringOption) ? Summarizing… :