diff --git a/packages/cli/src/services/BuiltinCommandLoader.ts b/packages/cli/src/services/BuiltinCommandLoader.ts index cdde266b8..9d7fd4722 100644 --- a/packages/cli/src/services/BuiltinCommandLoader.ts +++ b/packages/cli/src/services/BuiltinCommandLoader.ts @@ -45,6 +45,7 @@ import { recapCommand } from '../ui/commands/recapCommand.js'; import { renameCommand } from '../ui/commands/renameCommand.js'; import { restoreCommand } from '../ui/commands/restoreCommand.js'; import { resumeCommand } from '../ui/commands/resumeCommand.js'; +import { rewindCommand } from '../ui/commands/rewindCommand.js'; import { settingsCommand } from '../ui/commands/settingsCommand.js'; import { skillsCommand } from '../ui/commands/skillsCommand.js'; import { statsCommand } from '../ui/commands/statsCommand.js'; @@ -126,6 +127,7 @@ export class BuiltinCommandLoader implements ICommandLoader { renameCommand, restoreCommand(this.config), resumeCommand, + rewindCommand, skillsCommand, statsCommand, summaryCommand, diff --git a/packages/cli/src/ui/AppContainer.tsx b/packages/cli/src/ui/AppContainer.tsx index 9df46b62c..5a3deeb27 100644 --- a/packages/cli/src/ui/AppContainer.tsx +++ b/packages/cli/src/ui/AppContainer.tsx @@ -74,6 +74,11 @@ import { useApprovalModeCommand } from './hooks/useApprovalModeCommand.js'; import { useResumeCommand } from './hooks/useResumeCommand.js'; import { useDeleteCommand } from './hooks/useDeleteCommand.js'; import { useSlashCommandProcessor } from './hooks/slashCommandProcessor.js'; +import { useDoublePress } from './hooks/useDoublePress.js'; +import { + computeApiTruncationIndex, + isRealUserTurn, +} from './utils/historyMapping.js'; import { useVimMode } from './contexts/VimModeContext.js'; import { CompactModeProvider } from './contexts/CompactModeContext.js'; import { useTerminalSize } from './hooks/useTerminalSize.js'; @@ -636,6 +641,12 @@ export const AppContainer = (props: AppContainerProps) => { const { isHooksDialogOpen, openHooksDialog, closeHooksDialog } = useHooksDialog(); + // Ref bridge: the guarded openRewindSelector callback is defined later + // (after useDoublePress), but slashCommandActions needs it now. The ref + // lets the useMemo capture a stable function pointer whose implementation + // is swapped in once the real callback exists. + const openRewindSelectorRef = useRef<() => void>(() => {}); + const slashCommandActions = useMemo( () => ({ openAuthDialog, @@ -664,6 +675,7 @@ export const AppContainer = (props: AppContainerProps) => { openMcpDialog, openHooksDialog, openResumeDialog, + openRewindSelector: () => openRewindSelectorRef.current(), handleResume, openDeleteDialog, }), @@ -1534,6 +1546,8 @@ export const AppContainer = (props: AppContainerProps) => { const [escapePressedOnce, setEscapePressedOnce] = useState(false); const escapeTimerRef = useRef(null); const dialogsVisibleRef = useRef(false); + const [isRewindSelectorOpen, setIsRewindSelectorOpen] = useState(false); + const [rewindEscPending, setRewindEscPending] = useState(false); const [constrainHeight, setConstrainHeight] = useState(true); const [ideContextState, setIdeContextState] = useState< IdeContext | undefined @@ -1581,6 +1595,98 @@ export const AppContainer = (props: AppContainerProps) => { setShowEscapePrompt(showPrompt); }, []); + // --- Rewind selector callbacks --- + const openRewindSelector = useCallback(() => { + if (streamingState !== StreamingState.Idle) return; + if (config.getIdeMode()) return; + if (dialogsVisibleRef.current) return; + const hasUserTurns = historyManager.history.some((h) => h.type === 'user'); + if (!hasUserTurns) return; + setIsRewindSelectorOpen(true); + }, [streamingState, config, historyManager.history]); + openRewindSelectorRef.current = openRewindSelector; + + const closeRewindSelector = useCallback(() => { + setIsRewindSelectorOpen(false); + }, []); + + const handleRewindConfirm = useCallback( + (userItem: HistoryItem) => { + const geminiClient = config.getGeminiClient(); + if (!geminiClient) return; + + // 1. Compute values from current history BEFORE truncation + const originalHistory = historyManager.history; + const originalLength = originalHistory.length; + + let targetTurnIndex = 0; + for (const h of originalHistory) { + if (h.id === userItem.id) break; + if (isRealUserTurn(h)) targetTurnIndex++; + } + + // 2. Compute API truncation point + const apiHistory = geminiClient.getHistory(); + const apiTruncateIndex = computeApiTruncationIndex( + originalHistory, + userItem.id, + apiHistory, + ); + + // Abort if the target turn is unreachable (e.g., absorbed by compression) + if (apiTruncateIndex < 0) { + historyManager.addItem( + { + type: 'error', + text: 'Cannot rewind to a turn that was compressed. Try a more recent turn.', + }, + Date.now(), + ); + setIsRewindSelectorOpen(false); + return; + } + + // 3. Truncate API history and strip stale thinking blocks + geminiClient.truncateHistory(apiTruncateIndex); + geminiClient.stripThoughtsFromHistory(); + + // 4. Truncate UI history (keep everything before the target item) + const truncatedUi = originalHistory.filter((h) => h.id < userItem.id); + historyManager.loadHistory(truncatedUi); + + // 5. Re-render the terminal + refreshStatic(); + + // 6. Pre-populate input with the original user text + if (userItem.type === 'user' && userItem.text) { + buffer.setText(userItem.text); + } + + // 7. Add info message + historyManager.addItem( + { + type: 'info', + text: 'Conversation rewound. Edit your prompt and press Enter to continue.', + }, + Date.now(), + ); + + // 8. Record the rewind event — re-roots the parentUuid chain so + // rewound messages end up on a dead branch during resume. + config.getChatRecordingService()?.rewindRecording(targetTurnIndex, { + truncatedCount: originalLength - truncatedUi.length, + }); + + // 9. Close the selector + setIsRewindSelectorOpen(false); + }, + [config, historyManager, refreshStatic, buffer], + ); + + const handleDoubleEscRewind = useDoublePress(openRewindSelector, (pending) => + setRewindEscPending(pending), + ); + const handleIdePromptComplete = useCallback( (result: IdeIntegrationNudgeResult) => { if (result.userSelection === 'yes') { @@ -1874,6 +1980,20 @@ export const AppContainer = (props: AppContainerProps) => { return; } + // Input is empty and idle — double-ESC opens rewind selector + if ( + streamingState === StreamingState.Idle && + !dialogsVisibleRef.current + ) { + if (escapeTimerRef.current) { + clearTimeout(escapeTimerRef.current); + escapeTimerRef.current = null; + } + setEscapePressedOnce(false); + handleDoubleEscRewind(); + return; + } + // No action available, reset the flag if (escapeTimerRef.current) { clearTimeout(escapeTimerRef.current); @@ -1970,6 +2090,7 @@ export const AppContainer = (props: AppContainerProps) => { compactMode, setCompactMode, refreshStatic, + handleDoubleEscRewind, ], ); @@ -2043,7 +2164,8 @@ export const AppContainer = (props: AppContainerProps) => { isApprovalModeDialogOpen || isResumeDialogOpen || isDeleteDialogOpen || - isExtensionsManagerDialogOpen; + isExtensionsManagerDialogOpen || + isRewindSelectorOpen; dialogsVisibleRef.current = dialogsVisible; // Drain queued messages when idle. `queueDrainNonce` re-fires the effect @@ -2210,6 +2332,9 @@ export const AppContainer = (props: AppContainerProps) => { // Prompt suggestion promptSuggestion, dismissPromptSuggestion, + // Rewind selector + isRewindSelectorOpen, + rewindEscPending, }), [ isThemeDialogOpen, @@ -2326,6 +2451,9 @@ export const AppContainer = (props: AppContainerProps) => { // Prompt suggestion promptSuggestion, dismissPromptSuggestion, + // Rewind selector + isRewindSelectorOpen, + rewindEscPending, ], ); @@ -2395,6 +2523,10 @@ export const AppContainer = (props: AppContainerProps) => { closeFeedbackDialog, temporaryCloseFeedbackDialog, submitFeedback, + // Rewind selector + openRewindSelector, + closeRewindSelector, + handleRewindConfirm, }), [ openThemeDialog, @@ -2459,6 +2591,10 @@ export const AppContainer = (props: AppContainerProps) => { closeFeedbackDialog, temporaryCloseFeedbackDialog, submitFeedback, + // Rewind selector + openRewindSelector, + closeRewindSelector, + handleRewindConfirm, ], ); diff --git a/packages/cli/src/ui/commands/rewindCommand.ts b/packages/cli/src/ui/commands/rewindCommand.ts new file mode 100644 index 000000000..07f78a1a5 --- /dev/null +++ b/packages/cli/src/ui/commands/rewindCommand.ts @@ -0,0 +1,22 @@ +/** + * @license + * Copyright 2026 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import type { SlashCommand, SlashCommandActionReturn } from './types.js'; +import { CommandKind } from './types.js'; +import { t } from '../../i18n/index.js'; + +export const rewindCommand: SlashCommand = { + name: 'rewind', + altNames: ['rollback'], + get description() { + return t('Rewind conversation to a previous turn'); + }, + kind: CommandKind.BUILT_IN, + action: async (): Promise => ({ + type: 'dialog', + dialog: 'rewind', + }), +}; diff --git a/packages/cli/src/ui/commands/types.ts b/packages/cli/src/ui/commands/types.ts index 01e6d6564..2e3f8df62 100644 --- a/packages/cli/src/ui/commands/types.ts +++ b/packages/cli/src/ui/commands/types.ts @@ -182,7 +182,8 @@ export interface OpenDialogActionReturn { | 'delete' | 'extensions_manage' | 'hooks' - | 'mcp'; + | 'mcp' + | 'rewind'; } /** diff --git a/packages/cli/src/ui/components/DialogManager.tsx b/packages/cli/src/ui/components/DialogManager.tsx index dc0c64d27..dc35c33d1 100644 --- a/packages/cli/src/ui/components/DialogManager.tsx +++ b/packages/cli/src/ui/components/DialogManager.tsx @@ -43,6 +43,7 @@ import { ExtensionsManagerDialog } from './extensions/ExtensionsManagerDialog.js import { MCPManagementDialog } from './mcp/MCPManagementDialog.js'; import { HooksManagementDialog } from './hooks/HooksManagementDialog.js'; import { SessionPicker } from './SessionPicker.js'; +import { RewindSelector } from './RewindSelector.js'; import { MemoryDialog } from './MemoryDialog.js'; import { t } from '../../i18n/index.js'; @@ -397,5 +398,15 @@ export const DialogManager = ({ ); } + if (uiState.isRewindSelectorOpen) { + return ( + + ); + } + return null; }; diff --git a/packages/cli/src/ui/components/Footer.tsx b/packages/cli/src/ui/components/Footer.tsx index 1debdcb9c..cb2e2f47f 100644 --- a/packages/cli/src/ui/components/Footer.tsx +++ b/packages/cli/src/ui/components/Footer.tsx @@ -100,6 +100,10 @@ export const Footer: React.FC = () => { {t('Press Ctrl+D again to exit.')} ) : uiState.showEscapePrompt ? ( {t('Press Esc again to clear.')} + ) : uiState.rewindEscPending ? ( + + {t('Press Esc again to rewind conversation.')} + ) : vimEnabled && vimMode === 'INSERT' ? ( -- INSERT -- ) : uiState.shellModeActive ? ( diff --git a/packages/cli/src/ui/components/RewindSelector.tsx b/packages/cli/src/ui/components/RewindSelector.tsx new file mode 100644 index 000000000..5da006cad --- /dev/null +++ b/packages/cli/src/ui/components/RewindSelector.tsx @@ -0,0 +1,328 @@ +/** + * @license + * Copyright 2025 Qwen Code + * SPDX-License-Identifier: Apache-2.0 + */ + +import { useState, useMemo, useCallback } from 'react'; +import { Box, Text } from 'ink'; +import type { HistoryItem } from '../types.js'; +import { theme } from '../semantic-colors.js'; +import { useTerminalSize } from '../hooks/useTerminalSize.js'; +import { useKeypress } from '../hooks/useKeypress.js'; +import { truncateText } from '../utils/sessionPickerUtils.js'; +import { isRealUserTurn } from '../utils/historyMapping.js'; +import { t } from '../../i18n/index.js'; + +export interface RewindSelectorProps { + history: HistoryItem[]; + onRewind: (userItem: HistoryItem) => void; + onCancel: () => void; +} + +const MAX_VISIBLE_ITEMS = 7; + +/** + * Extract user-type items from UI history for the rewind pick list. + */ +function getUserTurns(history: HistoryItem[]): HistoryItem[] { + return history.filter(isRealUserTurn); +} + +interface TurnItemViewProps { + item: HistoryItem; + isSelected: boolean; + isFirst: boolean; + isLast: boolean; + showScrollUp: boolean; + showScrollDown: boolean; + maxPromptWidth: number; + turnNumber: number; +} + +function TurnItemView({ + item, + isSelected, + isFirst, + isLast, + showScrollUp, + showScrollDown, + maxPromptWidth, + turnNumber, +}: TurnItemViewProps): React.JSX.Element { + const showUpIndicator = isFirst && showScrollUp; + const showDownIndicator = isLast && showScrollDown; + + const prefix = isSelected + ? '› ' + : showUpIndicator + ? '↑ ' + : showDownIndicator + ? '↓ ' + : ' '; + + const promptText = item.text || '(empty prompt)'; + const truncatedPrompt = truncateText(promptText, maxPromptWidth); + + return ( + + + + {prefix} + + {`#${turnNumber} `} + + {truncatedPrompt} + + + + ); +} + +/** + * Two-phase rewind selector: + * 1. Pick list — choose which user turn to rewind to + * 2. Confirm — confirm the rewind action + */ +export function RewindSelector({ + history, + onRewind, + onCancel, +}: RewindSelectorProps) { + const { columns: width, rows: height } = useTerminalSize(); + const userTurns = useMemo(() => getUserTurns(history), [history]); + + const [selectedIndex, setSelectedIndex] = useState(userTurns.length - 1); + const [confirmItem, setConfirmItem] = useState(null); + + const boxWidth = width - 4; + const maxVisibleItems = Math.min(MAX_VISIBLE_ITEMS, userTurns.length); + + // Centered scroll offset + const scrollOffset = useMemo(() => { + if (userTurns.length <= maxVisibleItems) return 0; + const halfVisible = Math.floor(maxVisibleItems / 2); + let offset = selectedIndex - halfVisible; + offset = Math.max(0, offset); + offset = Math.min(userTurns.length - maxVisibleItems, offset); + return offset; + }, [userTurns.length, maxVisibleItems, selectedIndex]); + + const visibleTurns = useMemo( + () => userTurns.slice(scrollOffset, scrollOffset + maxVisibleItems), + [userTurns, scrollOffset, maxVisibleItems], + ); + const showScrollUp = scrollOffset > 0; + const showScrollDown = scrollOffset + maxVisibleItems < userTurns.length; + + const handleConfirmSelect = useCallback( + (confirmed: boolean) => { + if (confirmed && confirmItem) { + onRewind(confirmItem); + } else { + setConfirmItem(null); + } + }, + [confirmItem, onRewind], + ); + + // Pick-list key handler + useKeypress( + (key) => { + const { name, ctrl } = key; + + if (name === 'escape' || (ctrl && name === 'c')) { + onCancel(); + return; + } + + if (name === 'return') { + const selected = userTurns[selectedIndex]; + if (selected) { + setConfirmItem(selected); + } + return; + } + + if (name === 'up' || name === 'k') { + setSelectedIndex((prev) => Math.max(0, prev - 1)); + return; + } + + if (name === 'down' || name === 'j') { + setSelectedIndex((prev) => Math.min(userTurns.length - 1, prev + 1)); + return; + } + }, + { isActive: confirmItem === null }, + ); + + // Confirm key handler + useKeypress( + (key) => { + const { name, ctrl, sequence } = key; + + if (name === 'escape' || (ctrl && name === 'c')) { + setConfirmItem(null); + return; + } + + if (name === 'return' || sequence === 'y' || sequence === 'Y') { + handleConfirmSelect(true); + return; + } + + if (sequence === 'n' || sequence === 'N') { + handleConfirmSelect(false); + return; + } + }, + { isActive: confirmItem !== null }, + ); + + if (userTurns.length === 0) { + return ( + + + + + {t('No user turns to rewind to.')} + + + + + ); + } + + // Confirm phase + if (confirmItem) { + const promptPreview = truncateText( + confirmItem.text || '(empty)', + boxWidth - 10, + ); + return ( + + + + + {t('Rewind Conversation')} + + + + {'─'.repeat(boxWidth - 2)} + + + + {t('Rewind to: ')} + + {promptPreview} + + + + {t( + 'This will remove all conversation after this turn. The prompt will be pre-populated in the input for editing.', + )} + + + + {'─'.repeat(boxWidth - 2)} + + + + {t('Enter/Y to confirm · Esc/N to go back')} + + + + + ); + } + + // Pick-list phase + return ( + + + {/* Header */} + + + {t('Rewind Conversation')} + + + {' '} + {t('({{count}} turns)', { count: String(userTurns.length) })} + + + + {/* Separator */} + + {'─'.repeat(boxWidth - 2)} + + + {/* Turn list */} + + {visibleTurns.map((item, visibleIndex) => { + const actualIndex = scrollOffset + visibleIndex; + return ( + + ); + })} + + + {/* Separator */} + + {'─'.repeat(boxWidth - 2)} + + + {/* Footer */} + + + {t('↑↓ to navigate · Enter to select · Esc to cancel')} + + + + + ); +} diff --git a/packages/cli/src/ui/contexts/UIActionsContext.tsx b/packages/cli/src/ui/contexts/UIActionsContext.tsx index 5aac4e66a..c035e6421 100644 --- a/packages/cli/src/ui/contexts/UIActionsContext.tsx +++ b/packages/cli/src/ui/contexts/UIActionsContext.tsx @@ -17,7 +17,7 @@ import { } from '@qwen-code/qwen-code-core'; import { type SettingScope } from '../../config/settings.js'; import { type AlibabaStandardRegion } from '../../constants/alibabaStandardApiKey.js'; -import type { AuthState } from '../types.js'; +import type { AuthState, HistoryItem } from '../types.js'; import { type ArenaDialogType } from '../hooks/useArenaCommand.js'; // OpenAICredentials type (previously imported from OpenAIKeyPrompt) export interface OpenAICredentials { @@ -110,6 +110,10 @@ export interface UIActions { closeFeedbackDialog: () => void; temporaryCloseFeedbackDialog: () => void; submitFeedback: (rating: number) => void; + // Rewind selector + openRewindSelector: () => void; + closeRewindSelector: () => void; + handleRewindConfirm: (userItem: HistoryItem) => void; } export const UIActionsContext = createContext(null); diff --git a/packages/cli/src/ui/contexts/UIStateContext.tsx b/packages/cli/src/ui/contexts/UIStateContext.tsx index b2450ec2c..a51961128 100644 --- a/packages/cli/src/ui/contexts/UIStateContext.tsx +++ b/packages/cli/src/ui/contexts/UIStateContext.tsx @@ -158,6 +158,9 @@ export interface UIState { promptSuggestion: string | null; /** Dismiss prompt suggestion (clears state, aborts speculation) */ dismissPromptSuggestion: () => void; + // Rewind selector + isRewindSelectorOpen: boolean; + rewindEscPending: boolean; } export const UIStateContext = createContext(null); diff --git a/packages/cli/src/ui/hooks/slashCommandProcessor.ts b/packages/cli/src/ui/hooks/slashCommandProcessor.ts index 135ec7d11..fea4b12a0 100644 --- a/packages/cli/src/ui/hooks/slashCommandProcessor.ts +++ b/packages/cli/src/ui/hooks/slashCommandProcessor.ts @@ -101,6 +101,7 @@ interface SlashCommandProcessorActions { openExtensionsManagerDialog: () => void; openMcpDialog: () => void; openHooksDialog: () => void; + openRewindSelector: () => void; } /** @@ -628,6 +629,9 @@ export const useSlashCommandProcessor = ( case 'extensions_manage': actions.openExtensionsManagerDialog(); return { type: 'handled' }; + case 'rewind': + actions.openRewindSelector(); + return { type: 'handled' }; case 'help': return { type: 'handled' }; default: { diff --git a/packages/cli/src/ui/hooks/useDoublePress.ts b/packages/cli/src/ui/hooks/useDoublePress.ts new file mode 100644 index 000000000..89d6c1b97 --- /dev/null +++ b/packages/cli/src/ui/hooks/useDoublePress.ts @@ -0,0 +1,56 @@ +/** + * @license + * Copyright 2025 Qwen Code + * SPDX-License-Identifier: Apache-2.0 + */ + +import { useRef, useCallback, useEffect } from 'react'; + +const DOUBLE_PRESS_TIMEOUT_MS = 800; + +/** + * Generic double-press detection hook. + * + * Returns a callback that should be invoked on each press. On the first + * press, optionally calls `onPending(true)` and starts a timer. If a + * second press arrives within 800ms, calls `onDoublePress`. Otherwise, + * the pending state is cleared after the timeout. + * + * @param onDoublePress Callback fired when a double-press is detected + * @param onPending Optional callback to update pending state (for UI hints) + * @returns A callback to invoke on each press + */ +export function useDoublePress( + onDoublePress: () => void, + onPending?: (pending: boolean) => void, +): () => void { + const timeoutRef = useRef | null>(null); + + // Clean up timer on unmount + useEffect( + () => () => { + if (timeoutRef.current !== null) { + clearTimeout(timeoutRef.current); + timeoutRef.current = null; + } + }, + [], + ); + + return useCallback(() => { + if (timeoutRef.current !== null) { + // Second press within the timeout window + clearTimeout(timeoutRef.current); + timeoutRef.current = null; + onPending?.(false); + onDoublePress(); + } else { + // First press — start the timer + onPending?.(true); + timeoutRef.current = setTimeout(() => { + timeoutRef.current = null; + onPending?.(false); + }, DOUBLE_PRESS_TIMEOUT_MS); + } + }, [onDoublePress, onPending]); +} diff --git a/packages/cli/src/ui/hooks/useHistoryManager.ts b/packages/cli/src/ui/hooks/useHistoryManager.ts index c25fc84a2..1d3fc8de1 100644 --- a/packages/cli/src/ui/hooks/useHistoryManager.ts +++ b/packages/cli/src/ui/hooks/useHistoryManager.ts @@ -21,6 +21,7 @@ export interface UseHistoryManagerReturn { ) => void; clearItems: () => void; loadHistory: (newHistory: HistoryItem[]) => void; + truncateToItem: (itemId: number) => void; } /** @@ -101,6 +102,14 @@ export function useHistory(): UseHistoryManagerReturn { messageIdCounterRef.current = 0; }, []); + // Truncates history to exclude the item with the given ID and everything after it. + const truncateToItem = useCallback((itemId: number) => { + setHistory((prev) => { + const index = prev.findIndex((h) => h.id === itemId); + return index === -1 ? prev : prev.slice(0, index); + }); + }, []); + return useMemo( () => ({ history, @@ -108,7 +117,8 @@ export function useHistory(): UseHistoryManagerReturn { updateItem, clearItems, loadHistory, + truncateToItem, }), - [history, addItem, updateItem, clearItems, loadHistory], + [history, addItem, updateItem, clearItems, loadHistory, truncateToItem], ); } diff --git a/packages/cli/src/ui/hooks/useResumeCommand.test.ts b/packages/cli/src/ui/hooks/useResumeCommand.test.ts index 4d5a5a68d..a346e0ad9 100644 --- a/packages/cli/src/ui/hooks/useResumeCommand.test.ts +++ b/packages/cli/src/ui/hooks/useResumeCommand.test.ts @@ -145,6 +145,7 @@ describe('useResumeCommand', () => { getTargetDir: () => '/tmp', getGeminiClient: () => geminiClient, startNewSession: vi.fn(), + getChatRecordingService: () => ({ rebuildTurnBoundaries: vi.fn() }), getDebugLogger: () => ({ warn: vi.fn(), debug: vi.fn(), diff --git a/packages/cli/src/ui/hooks/useResumeCommand.ts b/packages/cli/src/ui/hooks/useResumeCommand.ts index 037b75f3c..f9ca2df23 100644 --- a/packages/cli/src/ui/hooks/useResumeCommand.ts +++ b/packages/cli/src/ui/hooks/useResumeCommand.ts @@ -95,6 +95,10 @@ export function useResumeCommand( // Update session history core. config.startNewSession(sessionId, sessionData); + // Rebuild turn boundary tracking so rewind works within resumed sessions. + config + .getChatRecordingService() + ?.rebuildTurnBoundaries(sessionData.conversation.messages); await config.getGeminiClient()?.initialize?.(); // Fire SessionStart event after resuming session diff --git a/packages/cli/src/ui/utils/historyMapping.test.ts b/packages/cli/src/ui/utils/historyMapping.test.ts new file mode 100644 index 000000000..84c18a2ff --- /dev/null +++ b/packages/cli/src/ui/utils/historyMapping.test.ts @@ -0,0 +1,243 @@ +/** + * @license + * Copyright 2025 Qwen Code + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect } from 'vitest'; +import { computeApiTruncationIndex, isRealUserTurn } from './historyMapping.js'; +import type { HistoryItem } from '../types.js'; +import type { Content, Part } from '@google/genai'; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +function userContent(text: string): Content { + return { role: 'user', parts: [{ text } as Part] }; +} + +function modelContent(text: string): Content { + return { role: 'model', parts: [{ text } as Part] }; +} + +function functionResponseContent(): Content { + return { + role: 'user', + parts: [ + { + functionResponse: { name: 'tool', response: { result: 'ok' } }, + } as unknown as Part, + ], + }; +} + +function startupPair(): [Content, Content] { + return [ + userContent('Environment context...'), + modelContent('Got it. Thanks for the context!'), + ]; +} + +function userItem(id: number, text = `prompt ${id}`): HistoryItem { + return { type: 'user', id, text } as HistoryItem; +} + +function geminiItem(id: number): HistoryItem { + return { type: 'gemini', id, text: `response ${id}` } as HistoryItem; +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +describe('computeApiTruncationIndex', () => { + it('returns 0 for empty API history', () => { + const ui: HistoryItem[] = [userItem(1)]; + const api: Content[] = []; + expect(computeApiTruncationIndex(ui, 1, api)).toBe(0); + }); + + describe('without startup context', () => { + it('rewinds to the first user turn (keep nothing)', () => { + const ui: HistoryItem[] = [ + userItem(1), + geminiItem(2), + userItem(3), + geminiItem(4), + ]; + const api: Content[] = [ + userContent('prompt 1'), + modelContent('response 1'), + userContent('prompt 3'), + modelContent('response 3'), + ]; + // Rewind to turn 1 → keep 0 entries before it + expect(computeApiTruncationIndex(ui, 1, api)).toBe(0); + }); + + it('rewinds to the second user turn (keep first turn)', () => { + const ui: HistoryItem[] = [ + userItem(1), + geminiItem(2), + userItem(3), + geminiItem(4), + ]; + const api: Content[] = [ + userContent('prompt 1'), + modelContent('response 1'), + userContent('prompt 3'), + modelContent('response 3'), + ]; + // Rewind to turn 3 → keep entries before the second user Content + expect(computeApiTruncationIndex(ui, 3, api)).toBe(2); + }); + + it('rewinds to the third user turn', () => { + const ui: HistoryItem[] = [ + userItem(1), + geminiItem(2), + userItem(3), + geminiItem(4), + userItem(5), + geminiItem(6), + ]; + const api: Content[] = [ + userContent('prompt 1'), + modelContent('response 1'), + userContent('prompt 3'), + modelContent('response 3'), + userContent('prompt 5'), + modelContent('response 5'), + ]; + expect(computeApiTruncationIndex(ui, 5, api)).toBe(4); + }); + }); + + describe('with startup context pair', () => { + it('keeps startup context when rewinding to the first turn', () => { + const ui: HistoryItem[] = [userItem(1), geminiItem(2)]; + const api: Content[] = [ + ...startupPair(), + userContent('prompt 1'), + modelContent('response 1'), + ]; + // Rewind to turn 1 → keep startup pair (2 entries) + expect(computeApiTruncationIndex(ui, 1, api)).toBe(2); + }); + + it('keeps startup + first turn when rewinding to second turn', () => { + const ui: HistoryItem[] = [ + userItem(1), + geminiItem(2), + userItem(3), + geminiItem(4), + ]; + const api: Content[] = [ + ...startupPair(), + userContent('prompt 1'), + modelContent('response 1'), + userContent('prompt 3'), + modelContent('response 3'), + ]; + // startup(2) + turn1(2) = 4 entries to keep + expect(computeApiTruncationIndex(ui, 3, api)).toBe(4); + }); + }); + + describe('with tool call entries (functionResponse)', () => { + it('skips functionResponse entries when counting user prompts', () => { + const ui: HistoryItem[] = [ + userItem(1), + geminiItem(2), + // tool_group items are not type 'user', they don't affect the count + userItem(5), + geminiItem(6), + ]; + const api: Content[] = [ + userContent('prompt 1'), + modelContent('response with tool call'), + functionResponseContent(), // tool result — should be skipped + modelContent('response after tool'), + userContent('prompt 5'), + modelContent('response 5'), + ]; + // Rewind to turn 5: 1 user turn before it → find the 2nd user text + // API walk: idx 0 = user text (count=1), idx 4 = user text (count=2 > 1) → return 4 + expect(computeApiTruncationIndex(ui, 5, api)).toBe(4); + }); + }); + + describe('compression fallback', () => { + it('returns -1 when not enough user prompts found', () => { + const ui: HistoryItem[] = [ + userItem(1), + geminiItem(2), + userItem(3), + geminiItem(4), + userItem(5), + geminiItem(6), + ]; + // After compression, API history may be shorter than expected + const api: Content[] = [ + modelContent('compressed summary'), + userContent('prompt 5'), + modelContent('response 5'), + ]; + // Rewind to turn 5 → 2 user turns before it, but API only has 1 user text + expect(computeApiTruncationIndex(ui, 5, api)).toBe(-1); + }); + }); + + describe('with slash-command items in UI history', () => { + it('ignores slash-command items when counting user turns', () => { + const ui: HistoryItem[] = [ + userItem(1, 'hello'), + geminiItem(2), + userItem(3, '/help'), // slash command — should be skipped + userItem(5, 'world'), + geminiItem(6), + ]; + const api: Content[] = [ + userContent('hello'), + modelContent('response 1'), + userContent('world'), + modelContent('response 2'), + ]; + // Rewind to 'world' (id=5): 1 real user turn before it (id=1) + // Slash '/help' (id=3) should not be counted + expect(computeApiTruncationIndex(ui, 5, api)).toBe(2); + }); + }); + + describe('single turn', () => { + it('handles rewinding the only turn', () => { + const ui: HistoryItem[] = [userItem(1), geminiItem(2)]; + const api: Content[] = [ + userContent('prompt 1'), + modelContent('response 1'), + ]; + expect(computeApiTruncationIndex(ui, 1, api)).toBe(0); + }); + }); +}); + +describe('isRealUserTurn', () => { + it('returns true for normal user prompts', () => { + expect(isRealUserTurn(userItem(1, 'hello world'))).toBe(true); + }); + + it('returns false for slash commands', () => { + expect(isRealUserTurn(userItem(1, '/help'))).toBe(false); + expect(isRealUserTurn(userItem(1, '/rewind'))).toBe(false); + expect(isRealUserTurn(userItem(1, '/stats'))).toBe(false); + }); + + it('returns false for ? commands', () => { + expect(isRealUserTurn(userItem(1, '?help'))).toBe(false); + }); + + it('returns false for non-user items', () => { + expect(isRealUserTurn(geminiItem(1))).toBe(false); + }); +}); diff --git a/packages/cli/src/ui/utils/historyMapping.ts b/packages/cli/src/ui/utils/historyMapping.ts new file mode 100644 index 000000000..389acf2e9 --- /dev/null +++ b/packages/cli/src/ui/utils/historyMapping.ts @@ -0,0 +1,126 @@ +/** + * @license + * Copyright 2025 Qwen Code + * SPDX-License-Identifier: Apache-2.0 + */ + +import type { HistoryItem } from '../types.js'; +import type { Content } from '@google/genai'; + +/** + * Returns true when the history item represents a real user prompt that was + * sent to the model, as opposed to a slash-command invocation (`/help`, + * `/stats`, …) which is stored with `type: 'user'` in the UI but never + * reaches the API history or `turnParentUuids`. + */ +export function isRealUserTurn(item: HistoryItem): boolean { + if (item.type !== 'user' || !item.text) return false; + return !item.text.startsWith('/') && !item.text.startsWith('?'); +} + +/** + * The well-known startup context model acknowledgment. + * Used to identify the startup context pair in the API history. + */ +const STARTUP_CONTEXT_MODEL_ACK = 'Got it. Thanks for the context!'; + +/** + * Checks if a Content entry is a user-initiated text prompt + * as opposed to a tool result (functionResponse). + */ +function isUserTextContent(content: Content): boolean { + if (content.role !== 'user') return false; + if (!content.parts || content.parts.length === 0) return false; + + const hasFunctionResponse = content.parts.some( + (part) => 'functionResponse' in part, + ); + if (hasFunctionResponse) return false; + + return content.parts.some((part) => 'text' in part && part.text); +} + +/** + * Detects whether the API history starts with the startup context pair + * (user env context + model acknowledgment). + */ +function hasStartupContext(apiHistory: Content[]): boolean { + if (apiHistory.length < 2) return false; + const first = apiHistory[0]; + const second = apiHistory[1]; + if (first?.role !== 'user' || second?.role !== 'model') return false; + return ( + second.parts?.some( + (part) => 'text' in part && part.text === STARTUP_CONTEXT_MODEL_ACK, + ) ?? false + ); +} + +/** + * Computes the number of API Content[] entries to keep when rewinding + * to a specific user turn in the UI history. + * + * The API history may include: + * - A startup context pair: [user(env), model(ack)] at the beginning + * - User text prompts (corresponding to UI user turns) + * - Model responses (with optional functionCall parts) + * - Tool result entries: user(functionResponse) + model(response) + * + * This function counts user text Content entries (skipping tool results + * and the startup context pair) to find the API boundary corresponding + * to the target UI user turn. + * + * Note: In IDE mode, additional user Content entries may be injected for + * IDE context. This function does not account for those and will produce + * incorrect results. Rewind is therefore disabled in IDE mode (guarded + * in openRewindSelector). + * + * @param uiHistory The full UI history array + * @param targetUserItemId The ID of the user HistoryItem to rewind to + * @param apiHistory The current API Content[] array + * @returns The number of Content entries to keep, or -1 if the target turn + * could not be located (e.g., it was absorbed by chat compression). + */ +export function computeApiTruncationIndex( + uiHistory: HistoryItem[], + targetUserItemId: number, + apiHistory: Content[], +): number { + // Count how many UI user turns exist before the target + let uiUserTurnCount = 0; + for (const item of uiHistory) { + if (item.id === targetUserItemId) { + break; + } + if (isRealUserTurn(item)) { + uiUserTurnCount++; + } + } + + // Determine the starting index in the API history (skip startup context) + const startIndex = hasStartupContext(apiHistory) ? 2 : 0; + + if (uiUserTurnCount === 0) { + // Rewinding to the first user turn: keep only startup context (if any) + return startIndex; + } + + // Walk the API history from after the startup context, counting + // user text prompts to find the one corresponding to the target turn. + let realUserPromptCount = 0; + + for (let i = startIndex; i < apiHistory.length; i++) { + if (isUserTextContent(apiHistory[i]!)) { + realUserPromptCount++; + // The target turn is the (uiUserTurnCount + 1)th real user prompt. + // We want to truncate right before it. + if (realUserPromptCount > uiUserTurnCount) { + return i; + } + } + } + + // If we didn't find enough user prompts (e.g., after compression), + // signal that the target turn is unreachable. + return -1; +} diff --git a/packages/cli/src/ui/utils/resumeHistoryUtils.ts b/packages/cli/src/ui/utils/resumeHistoryUtils.ts index bf134785a..1807cfd85 100644 --- a/packages/cli/src/ui/utils/resumeHistoryUtils.ts +++ b/packages/cli/src/ui/utils/resumeHistoryUtils.ts @@ -252,6 +252,9 @@ function convertToHistoryItems( if (!payload) continue; pendingAtCommands.push(payload); } + if (record.subtype === 'rewind') { + items.push({ type: 'info', text: 'Conversation rewound.' }); + } continue; } switch (record.type) { diff --git a/packages/core/src/core/client.ts b/packages/core/src/core/client.ts index 797f61190..f8961ff04 100644 --- a/packages/core/src/core/client.ts +++ b/packages/core/src/core/client.ts @@ -217,6 +217,11 @@ export class GeminiClient { this.forceFullIdeContext = true; } + truncateHistory(keepCount: number) { + this.getChat().truncateHistory(keepCount); + this.forceFullIdeContext = true; + } + async setTools(): Promise { if (!this.isInitialized()) { return; diff --git a/packages/core/src/core/geminiChat.ts b/packages/core/src/core/geminiChat.ts index ff76eb5c6..cf6e37761 100644 --- a/packages/core/src/core/geminiChat.ts +++ b/packages/core/src/core/geminiChat.ts @@ -788,6 +788,10 @@ export class GeminiChat { this.history = history; } + truncateHistory(keepCount: number): void { + this.history = this.history.slice(0, keepCount); + } + stripThoughtsFromHistory(): void { this.history = this.history .map((content) => { diff --git a/packages/core/src/services/chatRecordingService.ts b/packages/core/src/services/chatRecordingService.ts index 7732be1a5..c7976d8ef 100644 --- a/packages/core/src/services/chatRecordingService.ts +++ b/packages/core/src/services/chatRecordingService.ts @@ -91,7 +91,8 @@ export interface ChatRecord { | 'at_command' | 'notification' | 'cron' - | 'custom_title'; + | 'custom_title' + | 'rewind'; /** Working directory at time of message */ cwd: string; /** CLI version for compatibility tracking */ @@ -133,7 +134,8 @@ export interface ChatRecord { | UiTelemetryRecordPayload | AtCommandRecordPayload | CustomTitleRecordPayload - | NotificationRecordPayload; + | NotificationRecordPayload + | RewindRecordPayload; } export interface NotificationRecordPayload { @@ -212,6 +214,14 @@ export interface UiTelemetryRecordPayload { uiEvent: UiEvent; } +/** + * Stored payload for conversation rewind events. + */ +export interface RewindRecordPayload { + /** Number of UI history items truncated. */ + truncatedCount: number; +} + /** * Service for recording the current chat session to disk. * @@ -240,6 +250,18 @@ export class ChatRecordingService { /** UUID of the last written record in the chain */ private lastRecordUuid: string | null = null; private readonly config: Config; + /** + * Tracks the `lastRecordUuid` value just before each user turn was recorded. + * Used by {@link rewindRecording} to re-root the parentUuid chain so that + * rewound messages end up on a dead branch in the tree, making + * `reconstructHistory()` skip them automatically on resume. + * + * Index `i` holds the UUID of the last record written before the (i+1)th + * user message was appended. For example, `turnParentUuids[0]` is the UUID + * right before the very first user message (often `null` or the startup + * context record). + */ + private turnParentUuids: Array = []; /** * Cached chats-dir / conversation-file path so per-record appendRecord * doesn't re-stat them on every write. The first call performs the @@ -463,6 +485,7 @@ export class ChatRecordingService { */ recordUserMessage(message: PartListUnion): void { try { + this.turnParentUuids.push(this.lastRecordUuid); const record: ChatRecord = { ...this.createBaseRecord('user'), message: createUserContent(message), @@ -739,6 +762,70 @@ export class ChatRecordingService { } } + /** + * Records a conversation rewind and re-roots the parentUuid chain. + * + * Sets `lastRecordUuid` back to the UUID that was current just before the + * target user turn was recorded, then appends a rewind system record. + * This makes all messages after that point sit on a dead branch in the + * UUID tree, so `reconstructHistory()` will skip them on resume. + * + * @param targetTurnIndex 0-based index of the user turn to rewind to. + * For example, 0 means rewind to the very first user message (keeping + * nothing before it), 1 means keep the first user turn, etc. + * @param payload Additional metadata to persist with the rewind record. + */ + rewindRecording(targetTurnIndex: number, payload: RewindRecordPayload): void { + try { + // Re-root: point back to the record just before the target user turn. + this.lastRecordUuid = this.turnParentUuids[targetTurnIndex] ?? null; + // Trim future boundaries — they no longer exist in the active branch. + this.turnParentUuids = this.turnParentUuids.slice(0, targetTurnIndex); + + const record: ChatRecord = { + ...this.createBaseRecord('system'), + type: 'system', + subtype: 'rewind', + systemPayload: payload, + }; + + this.appendRecord(record); + } catch (error) { + debugLogger.error('Error saving rewind record:', error); + } + } + + /** + * Rebuilds `turnParentUuids` from a reconstructed message list. + * + * Call this after resuming a session so that subsequent rewinds within + * the resumed session have correct boundary data. Also updates + * `lastRecordUuid` to the last record in the chain. + */ + rebuildTurnBoundaries(messages: ChatRecord[]): void { + this.turnParentUuids = []; + let prevUuid: string | null = + this.config.getResumedSessionData()?.lastCompletedUuid !== undefined + ? null + : this.lastRecordUuid; + + for (let i = 0; i < messages.length; i++) { + const record = messages[i]; + if ( + record.type === 'user' && + record.subtype !== 'notification' && + record.subtype !== 'cron' + ) { + this.turnParentUuids.push(prevUuid); + } + prevUuid = record.uuid; + } + // Ensure lastRecordUuid points to the end of the reconstructed chain. + if (messages.length > 0) { + this.lastRecordUuid = messages[messages.length - 1].uuid; + } + } + /** * Records a custom title for the session. * Appended as a system record so it persists with the session data. diff --git a/scripts/test-rewind-e2e.sh b/scripts/test-rewind-e2e.sh new file mode 100755 index 000000000..9b09f5cbe --- /dev/null +++ b/scripts/test-rewind-e2e.sh @@ -0,0 +1,473 @@ +#!/usr/bin/env bash +# ============================================================================= +# test-rewind-e2e.sh — tmux-based E2E verification for the conversation rewind +# feature (PR #3441). +# +# Covers all 5 manual test items from the PR description: +# 1. /rewind command → pick turn → UI truncated, input pre-populated +# 2. Double-ESC on empty prompt → selector opens → rewind → continue +# 3. ESC during streaming → cancels request, does NOT open selector +# 4. /rewind with no history → selector does not open +# 5. After rewind, model does not reference removed turns +# +# Prerequisites: +# - tmux installed +# - CLI already built: npm run build && npm run bundle +# - Valid model API credentials in environment +# +# Usage: +# bash scripts/test-rewind-e2e.sh +# ============================================================================= + +set -uo pipefail + +SESSION="test-rewind-$$" +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +PROJECT_DIR="$(cd "$SCRIPT_DIR/.." && pwd)" +BUNDLE="$PROJECT_DIR/dist/cli.js" +WORKDIR="$(mktemp -d)" +PASS_COUNT=0 +FAIL_COUNT=0 +TIMEOUT=${REWIND_TEST_TIMEOUT:-120} # seconds per wait_for call + +# Colors +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[0;33m' +BOLD='\033[1m' +RESET='\033[0m' + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +cleanup() { + tmux kill-session -t "$SESSION" 2>/dev/null || true + rm -rf "$WORKDIR" +} +trap cleanup EXIT + +start_session() { + # Deliver ESC immediately — without this, tmux holds ESC for up to 500ms + # thinking it might be the start of an escape sequence, which breaks + # double-ESC detection and other ESC-dependent interactions. + # Must be set as a server option (not session) in tmux 2.6+. + tmux set-option -sg escape-time 0 2>/dev/null || true + tmux new-session -d -s "$SESSION" -x 120 -y 40 \ + "cd '$WORKDIR' && node '$BUNDLE' --approval-mode yolo 2>'$WORKDIR/stderr.log'" + wait_for_prompt 60 +} + +kill_session() { + tmux kill-session -t "$SESSION" 2>/dev/null || true + sleep 1 +} + +# Capture entire pane including scrollback (for content assertions) +capture() { + tmux capture-pane -t "$SESSION" -p -S -200 2>/dev/null || true +} + +# Capture only the visible pane (for prompt detection) +capture_visible() { + tmux capture-pane -t "$SESSION" -p 2>/dev/null || true +} + +send() { + # Type text using literal mode then press Enter + tmux send-keys -t "$SESSION" -l "$1" + sleep 0.5 + tmux send-keys -t "$SESSION" Enter +} + +send_keys() { + tmux send-keys -t "$SESSION" "$@" +} + +# Wait for "Type your message" to appear on the visible pane. +wait_for_prompt() { + local timeout="${1:-$TIMEOUT}" + local elapsed=0 + + while [ $elapsed -lt "$timeout" ]; do + if capture_visible | grep -qF "Type your message"; then + return 0 + fi + sleep 2 + elapsed=$((elapsed + 2)) + done + echo -e "${RED}TIMEOUT waiting for prompt (Type your message)${RESET}" >&2 + echo "--- Visible pane ---" >&2 + capture_visible >&2 + echo "--- End ---" >&2 + return 1 +} + +# Wait for the CLI to be truly idle: +# 1. "Type your message" is visible (prompt ready) +# 2. No "esc to cancel" on screen (no btw/side-query running) +# 3. Screen content unchanged for 3 consecutive seconds +wait_idle() { + local timeout="${1:-$TIMEOUT}" + local elapsed=0 + local last_hash="" + local stable_count=0 + + while [ $elapsed -lt "$timeout" ]; do + local screen + screen=$(capture_visible) + + # Must have prompt visible + if ! echo "$screen" | grep -qF "Type your message"; then + stable_count=0 + last_hash="" + sleep 2 + elapsed=$((elapsed + 2)) + continue + fi + + # Must not have btw side-query running + if echo "$screen" | grep -qF "esc to cancel"; then + stable_count=0 + last_hash="" + sleep 2 + elapsed=$((elapsed + 2)) + continue + fi + + # Check screen stability + local current + current=$(echo "$screen" | md5sum | cut -d' ' -f1) + if [ "$current" = "$last_hash" ]; then + stable_count=$((stable_count + 1)) + if [ $stable_count -ge 3 ]; then + return 0 + fi + else + last_hash="$current" + stable_count=0 + fi + sleep 1 + elapsed=$((elapsed + 1)) + done + echo -e "${RED}TIMEOUT waiting for idle${RESET}" >&2 + echo "--- Visible pane ---" >&2 + capture_visible >&2 + echo "--- End ---" >&2 + return 1 +} + +# Wait for text to appear on the visible pane +wait_for() { + local text="$1" + local timeout="${2:-$TIMEOUT}" + local elapsed=0 + while [ $elapsed -lt "$timeout" ]; do + if capture_visible | grep -qF "$text"; then + return 0 + fi + sleep 2 + elapsed=$((elapsed + 2)) + done + echo -e "${RED}TIMEOUT waiting for: ${text}${RESET}" >&2 + echo "--- Visible pane ---" >&2 + capture_visible >&2 + echo "--- End ---" >&2 + return 1 +} + +# Assert text IS on visible pane +assert_screen() { + local text="$1" + if capture_visible | grep -qF "$text"; then + return 0 + fi + echo -e "${RED}ASSERT FAILED: expected '${text}' on screen${RESET}" >&2 + echo "--- Visible pane ---" >&2 + capture_visible >&2 + echo "--- End ---" >&2 + return 1 +} + +# Assert text IS on full capture (including scrollback) +assert_scrollback() { + local text="$1" + if capture | grep -qF "$text"; then + return 0 + fi + echo -e "${RED}ASSERT FAILED: expected '${text}' in scrollback${RESET}" >&2 + return 1 +} + +# Assert text is NOT on visible pane +assert_no_screen() { + local text="$1" + if capture_visible | grep -qF "$text"; then + echo -e "${RED}ASSERT FAILED: did NOT expect '${text}' on screen${RESET}" >&2 + echo "--- Visible pane ---" >&2 + capture_visible >&2 + echo "--- End ---" >&2 + return 1 + fi + return 0 +} + +pass() { + echo -e "${GREEN}[PASS]${RESET} $1" + PASS_COUNT=$((PASS_COUNT + 1)) +} + +fail() { + echo -e "${RED}[FAIL]${RESET} $1: $2" + FAIL_COUNT=$((FAIL_COUNT + 1)) +} + +# Run a test function, capturing its exit code properly. +# Usage: run_test "Test Name" test_function_name +run_test() { + local name="$1" + local func="$2" + local rc=0 + local errmsg="" + + errmsg=$($func 2>&1) || rc=$? + + if [ $rc -eq 0 ]; then + pass "$name" + else + # Extract last meaningful error line from stderr + local last_err + last_err=$(echo "$errmsg" | grep -E 'TIMEOUT|ASSERT FAILED' | tail -1) + fail "$name" "${last_err:-exit code $rc}" + echo "$errmsg" | head -30 + fi + + # Always clean up the session between tests + kill_session 2>/dev/null || true +} + +# --------------------------------------------------------------------------- +# Pre-flight checks +# --------------------------------------------------------------------------- + +if ! command -v tmux &>/dev/null; then + echo -e "${RED}Error: tmux is not installed${RESET}" >&2 + exit 1 +fi + +if [ ! -f "$BUNDLE" ]; then + echo -e "${YELLOW}Bundle not found at $BUNDLE, building...${RESET}" + (cd "$PROJECT_DIR" && npm run build && npm run bundle) +fi + +echo -e "${BOLD}=== Rewind Feature E2E Tests (tmux) ===${RESET}" +echo "Session: $SESSION" +echo "Workdir: $WORKDIR" +echo "" + +# --------------------------------------------------------------------------- +# Test 1: /rewind command flow +# --------------------------------------------------------------------------- + +test_rewind_command() { + start_session + + # Build 3-turn conversation with unique markers + send "say exactly ALPHA1 and nothing else" + wait_idle || return 1 + + send "say exactly BETA2 and nothing else" + wait_idle || return 1 + + send "say exactly GAMMA3 and nothing else" + wait_idle || return 1 + + # Open rewind selector via /rewind command + send "/rewind" + wait_for "Rewind Conversation" || return 1 + + # Navigate up to select BETA2 turn (selector starts at last turn GAMMA3) + send_keys Up + sleep 0.5 + + # Select the turn + send_keys Enter + sleep 1 + wait_for "confirm" 15 || return 1 + + # Confirm rewind + send_keys y + wait_for "Conversation rewound" || return 1 + + # After rewind: the input should be pre-populated with the selected turn's + # text ("say exactly GAMMA3..."). The GAMMA3 *response* turn should be gone + # from the conversation, but the text appears in the input bar — which is + # the correct pre-population behavior. + # Verify pre-population: the input bar should contain GAMMA3 text + assert_screen "say exactly GAMMA3" || return 1 + # Verify the earlier turns (ALPHA1, BETA2) are still in conversation + assert_scrollback "ALPHA1" || return 1 +} + +run_test "Test 1: /rewind command flow" test_rewind_command + +# --------------------------------------------------------------------------- +# Test 2: Double-ESC opens selector +# --------------------------------------------------------------------------- + +test_double_esc() { + start_session + + send "say exactly DELTA4 and nothing else" + wait_idle || return 1 + + send "say exactly EPSILON5 and nothing else" + wait_idle || return 1 + + # Double-ESC to open rewind selector. + # Complication: a btw side-question (prompt suggestion) may be active after + # the model responds. If btwItem is non-null, the first ESC cancels the btw + # (AppContainer.tsx:1896) and never reaches the rewind handler. We send + # 3 ESCs with proper timing to handle both btw-present and btw-absent cases: + # ESC #1: cancels btw (if present), or starts rewind pending (if absent) + # sleep 1.5s: >800ms to reset any rewind pending from ESC #1 + # ESC #2: starts rewind pending (btw now dismissed) + # sleep 0.3s: within 800ms window + # ESC #3: triggers rewind selector + send_keys Escape + sleep 1.5 + send_keys Escape + sleep 0.5 + wait_for "Esc again to rewind" 15 || return 1 + + # Third ESC within 800ms — should open selector + send_keys Escape + wait_for "Rewind Conversation" || return 1 + + # Select last turn (pre-selected) & confirm + send_keys Enter + sleep 1 + send_keys y + wait_for "Conversation rewound" || return 1 + + # Continue conversation after rewind — verify model still works + send "say exactly ZETA6 and nothing else" + wait_idle || return 1 + assert_scrollback "ZETA6" || return 1 +} + +run_test "Test 2: Double-ESC opens selector" test_double_esc + +# --------------------------------------------------------------------------- +# Test 3: ESC during streaming cancels (no rewind) +# --------------------------------------------------------------------------- + +test_esc_during_streaming() { + start_session + + # Send a prompt that will generate a long response + send "write a detailed 500 word essay about the history of computing from 1940 to 2000" + + # Wait for streaming to start (prompt disappears) + sleep 4 + + # Single ESC while streaming — should cancel, NOT open rewind + send_keys Escape + + # Verify rewind selector did NOT open + sleep 3 + assert_no_screen "Rewind Conversation" || return 1 + + # Should eventually return to idle + wait_idle || return 1 +} + +run_test "Test 3: ESC during streaming cancels (no rewind)" test_esc_during_streaming + +# --------------------------------------------------------------------------- +# Test 4: /rewind with no prior conversation +# --------------------------------------------------------------------------- + +test_rewind_no_history() { + start_session + + # Immediately try /rewind with no conversation history. + # The /rewind text itself gets recorded as a user turn before the slash + # command handler runs, so the guard (≥1 user turn) passes and the + # selector opens showing only the "/rewind" entry — which is not a + # meaningful rewindable turn. We verify the selector has only 1 turn. + send "/rewind" + sleep 3 + + # The selector may or may not open depending on implementation. + # If it opens, it should show exactly "1 turns" (only the /rewind itself). + if capture_visible | grep -qF "Rewind Conversation"; then + assert_screen "1 turns" || return 1 + # Close the selector with ESC + send_keys Escape + sleep 1 + fi + + # Either way, after dismissing we should be back at the prompt + wait_for_prompt 10 || return 1 +} + +run_test "Test 4: /rewind with no prior conversation" test_rewind_no_history + +# --------------------------------------------------------------------------- +# Test 5: After rewind, model ignores removed turns +# --------------------------------------------------------------------------- + +test_rewind_context_isolation() { + start_session + + # First turn: give model a unique fact + send "The secret code for this session is XRAY99. Just confirm you received it by saying OK." + wait_idle || return 1 + + # Second turn: different content + send "say exactly YANKEEZ and nothing else" + wait_idle || return 1 + + # Rewind to remove the YANKEEZ turn + send "/rewind" + wait_for "Rewind Conversation" || return 1 + + # Select the most recent turn (YANKEEZ) and confirm + send_keys Enter + sleep 1 + send_keys y + wait_for "Conversation rewound" || return 1 + + # Clear pre-populated input (Ctrl-U clears line in most terminals) + send_keys C-u + sleep 0.5 + + # Ask the model what it remembers + send "What was the secret code I told you? Reply with just the code, nothing else." + wait_idle || return 1 + + # Model should reference XRAY99 (surviving turn) + assert_scrollback "XRAY99" || return 1 +} + +run_test "Test 5: After rewind, model ignores removed turns" test_rewind_context_isolation + +# --------------------------------------------------------------------------- +# Summary +# --------------------------------------------------------------------------- + +echo "" +echo -e "${BOLD}=== Results ===${RESET}" +echo -e "${GREEN}Passed: ${PASS_COUNT}${RESET}" +if [ "$FAIL_COUNT" -gt 0 ]; then + echo -e "${RED}Failed: ${FAIL_COUNT}${RESET}" +else + echo -e "Failed: 0" +fi + +if [ "$FAIL_COUNT" -gt 0 ]; then + exit 1 +fi + +echo -e "${GREEN}All ${PASS_COUNT} tests passed.${RESET}"