/** * @license * Copyright 2025 Google LLC * SPDX-License-Identifier: Apache-2.0 */ import type React from 'react'; import { useMemo, useState } from 'react'; import { Box, Text } from 'ink'; import { useUIState } from '../contexts/UIStateContext.js'; import { type ConversationRecord, type MessageRecord, partToString, } from '@google/gemini-cli-core'; import { BaseSelectionList } from './shared/BaseSelectionList.js'; import { theme } from '../semantic-colors.js'; import { useKeypress } from '../hooks/useKeypress.js'; import { useRewind } from '../hooks/useRewind.js'; import { RewindConfirmation, RewindOutcome } from './RewindConfirmation.js'; import { stripReferenceContent } from '../utils/formatters.js'; import { keyMatchers, Command } from '../keyMatchers.js'; import { CliSpinner } from './CliSpinner.js'; import { ExpandableText } from './shared/ExpandableText.js'; interface RewindViewerProps { conversation: ConversationRecord; onExit: () => void; onRewind: ( messageId: string, newText: string, outcome: RewindOutcome, ) => Promise; } const MAX_LINES_PER_BOX = 2; const getCleanedRewindText = (userPrompt: MessageRecord): string => { const contentToUse = userPrompt.displayContent || userPrompt.content; const originalUserText = contentToUse ? partToString(contentToUse) : ''; return userPrompt.displayContent ? originalUserText : stripReferenceContent(originalUserText); }; export const RewindViewer: React.FC = ({ conversation, onExit, onRewind, }) => { const [isRewinding, setIsRewinding] = useState(false); const { terminalWidth, terminalHeight } = useUIState(); const { selectedMessageId, getStats, confirmationStats, selectMessage, clearSelection, } = useRewind(conversation); const [highlightedMessageId, setHighlightedMessageId] = useState< string | null >(null); const [expandedMessageId, setExpandedMessageId] = useState( null, ); const interactions = useMemo( () => conversation.messages.filter((msg) => msg.type === 'user'), [conversation.messages], ); const items = useMemo(() => { const interactionItems = interactions.map((msg, idx) => ({ key: `${msg.id || 'msg'}-${idx}`, value: msg, index: idx, })); // Add "Current Position" as the last item return [ ...interactionItems, { key: 'current-position', value: { id: 'current-position', type: 'user', content: 'Stay at current position', timestamp: new Date().toISOString(), } as MessageRecord, index: interactionItems.length, }, ]; }, [interactions]); useKeypress( (key) => { if (!selectedMessageId) { if (keyMatchers[Command.ESCAPE](key)) { onExit(); return true; } if (keyMatchers[Command.EXPAND_SUGGESTION](key)) { if ( highlightedMessageId && highlightedMessageId !== 'current-position' ) { setExpandedMessageId(highlightedMessageId); return true; } } if (keyMatchers[Command.COLLAPSE_SUGGESTION](key)) { setExpandedMessageId(null); return true; } } return false; }, { isActive: true }, ); // Height constraint calculations const DIALOG_PADDING = 2; // Top/bottom padding const HEADER_HEIGHT = 2; // Title + margin const CONTROLS_HEIGHT = 2; // Controls text + margin const listHeight = Math.max( 5, terminalHeight - DIALOG_PADDING - HEADER_HEIGHT - CONTROLS_HEIGHT - 2, ); const maxItemsToShow = Math.max(1, Math.floor(listHeight / 4)); if (selectedMessageId) { if (isRewinding) { return ( Rewinding... ); } if (selectedMessageId === 'current-position') { onExit(); return null; } const selectedMessage = interactions.find( (m) => m.id === selectedMessageId, ); return ( { if (outcome === RewindOutcome.Cancel) { clearSelection(); } else { const userPrompt = interactions.find( (m) => m.id === selectedMessageId, ); if (userPrompt) { const cleanedText = getCleanedRewindText(userPrompt); setIsRewinding(true); await onRewind(selectedMessageId, cleanedText, outcome); } } }} /> ); } return ( {'> '}Rewind { const userPrompt = item; if (userPrompt && userPrompt.id) { if (userPrompt.id === 'current-position') { onExit(); } else { selectMessage(userPrompt.id); } } }} onHighlight={(item: MessageRecord) => { if (item.id) { setHighlightedMessageId(item.id); // Collapse when moving selection setExpandedMessageId(null); } }} maxItemsToShow={maxItemsToShow} renderItem={(itemWrapper, { isSelected }) => { const userPrompt = itemWrapper.value; if (userPrompt.id === 'current-position') { return ( {partToString( userPrompt.displayContent || userPrompt.content, )} Cancel rewind and stay here ); } const stats = getStats(userPrompt); const firstFileName = stats?.details?.at(0)?.fileName; const cleanedText = getCleanedRewindText(userPrompt); return ( {stats ? ( {stats.fileCount === 1 ? firstFileName ? firstFileName : '1 file changed' : `${stats.fileCount} files changed`}{' '} {stats.addedLines > 0 && ( +{stats.addedLines} )} {stats.removedLines > 0 && ( -{stats.removedLines} )} ) : ( No files have been changed )} ); }} /> (Use Enter to select a message, Esc to close, Right/Left to expand/collapse) ); };