Skip to content
Merged
2 changes: 2 additions & 0 deletions packages/cli/src/services/BuiltinCommandLoader.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -126,6 +127,7 @@ export class BuiltinCommandLoader implements ICommandLoader {
renameCommand,
restoreCommand(this.config),
resumeCommand,
rewindCommand,
skillsCommand,
statsCommand,
summaryCommand,
Expand Down
138 changes: 137 additions & 1 deletion packages/cli/src/ui/AppContainer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -632,6 +637,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,
Expand Down Expand Up @@ -660,6 +671,7 @@ export const AppContainer = (props: AppContainerProps) => {
openMcpDialog,
openHooksDialog,
openResumeDialog,
openRewindSelector: () => openRewindSelectorRef.current(),
handleResume,
openDeleteDialog,
}),
Expand Down Expand Up @@ -1530,6 +1542,8 @@ export const AppContainer = (props: AppContainerProps) => {
const [escapePressedOnce, setEscapePressedOnce] = useState(false);
const escapeTimerRef = useRef<NodeJS.Timeout | null>(null);
const dialogsVisibleRef = useRef(false);
const [isRewindSelectorOpen, setIsRewindSelectorOpen] = useState(false);
const [rewindEscPending, setRewindEscPending] = useState(false);
const [constrainHeight, setConstrainHeight] = useState<boolean>(true);
const [ideContextState, setIdeContextState] = useState<
IdeContext | undefined
Expand Down Expand Up @@ -1577,6 +1591,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') {
Expand Down Expand Up @@ -1870,6 +1976,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);
Expand Down Expand Up @@ -1966,6 +2086,7 @@ export const AppContainer = (props: AppContainerProps) => {
compactMode,
setCompactMode,
refreshStatic,
handleDoubleEscRewind,
],
);

Expand Down Expand Up @@ -2039,7 +2160,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
Expand Down Expand Up @@ -2206,6 +2328,9 @@ export const AppContainer = (props: AppContainerProps) => {
// Prompt suggestion
promptSuggestion,
dismissPromptSuggestion,
// Rewind selector
isRewindSelectorOpen,
rewindEscPending,
}),
[
isThemeDialogOpen,
Expand Down Expand Up @@ -2322,6 +2447,9 @@ export const AppContainer = (props: AppContainerProps) => {
// Prompt suggestion
promptSuggestion,
dismissPromptSuggestion,
// Rewind selector
isRewindSelectorOpen,
rewindEscPending,
],
);

Expand Down Expand Up @@ -2391,6 +2519,10 @@ export const AppContainer = (props: AppContainerProps) => {
closeFeedbackDialog,
temporaryCloseFeedbackDialog,
submitFeedback,
// Rewind selector
openRewindSelector,
closeRewindSelector,
handleRewindConfirm,
}),
[
openThemeDialog,
Expand Down Expand Up @@ -2455,6 +2587,10 @@ export const AppContainer = (props: AppContainerProps) => {
closeFeedbackDialog,
temporaryCloseFeedbackDialog,
submitFeedback,
// Rewind selector
openRewindSelector,
closeRewindSelector,
handleRewindConfirm,
],
);

Expand Down
22 changes: 22 additions & 0 deletions packages/cli/src/ui/commands/rewindCommand.ts
Original file line number Diff line number Diff line change
@@ -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<SlashCommandActionReturn> => ({
type: 'dialog',
dialog: 'rewind',
}),
};
3 changes: 2 additions & 1 deletion packages/cli/src/ui/commands/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -182,7 +182,8 @@ export interface OpenDialogActionReturn {
| 'delete'
| 'extensions_manage'
| 'hooks'
| 'mcp';
| 'mcp'
| 'rewind';
}

/**
Expand Down
11 changes: 11 additions & 0 deletions packages/cli/src/ui/components/DialogManager.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -397,5 +398,15 @@ export const DialogManager = ({
);
}

if (uiState.isRewindSelectorOpen) {
return (
<RewindSelector
history={uiState.history}
onRewind={uiActions.handleRewindConfirm}
onCancel={uiActions.closeRewindSelector}
/>
);
}

return null;
};
4 changes: 4 additions & 0 deletions packages/cli/src/ui/components/Footer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,10 @@ export const Footer: React.FC = () => {
<Text color={theme.status.warning}>{t('Press Ctrl+D again to exit.')}</Text>
) : uiState.showEscapePrompt ? (
<Text color={theme.text.secondary}>{t('Press Esc again to clear.')}</Text>
) : uiState.rewindEscPending ? (
<Text color={theme.text.secondary}>
{t('Press Esc again to rewind conversation.')}
</Text>
) : vimEnabled && vimMode === 'INSERT' ? (
<Text color={theme.text.secondary}>-- INSERT --</Text>
) : uiState.shellModeActive ? (
Expand Down
Loading
Loading