diff --git a/packages/cli/src/ui/AppContainer.tsx b/packages/cli/src/ui/AppContainer.tsx index 5749e0511..0f423bfcd 100644 --- a/packages/cli/src/ui/AppContainer.tsx +++ b/packages/cli/src/ui/AppContainer.tsx @@ -75,7 +75,10 @@ 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 } from './utils/historyMapping.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'; @@ -634,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, @@ -662,7 +671,7 @@ export const AppContainer = (props: AppContainerProps) => { openMcpDialog, openHooksDialog, openResumeDialog, - openRewindSelector: () => setIsRewindSelectorOpen(true), + openRewindSelector: () => openRewindSelectorRef.current(), handleResume, openDeleteDialog, }), @@ -1591,6 +1600,7 @@ export const AppContainer = (props: AppContainerProps) => { if (!hasUserTurns) return; setIsRewindSelectorOpen(true); }, [streamingState, config, historyManager.history]); + openRewindSelectorRef.current = openRewindSelector; const closeRewindSelector = useCallback(() => { setIsRewindSelectorOpen(false); @@ -1608,7 +1618,7 @@ export const AppContainer = (props: AppContainerProps) => { let targetTurnIndex = 0; for (const h of originalHistory) { if (h.id === userItem.id) break; - if (h.type === 'user') targetTurnIndex++; + if (isRealUserTurn(h)) targetTurnIndex++; } // 2. Compute API truncation point @@ -1619,6 +1629,19 @@ export const AppContainer = (props: AppContainerProps) => { 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(); diff --git a/packages/cli/src/ui/components/RewindSelector.tsx b/packages/cli/src/ui/components/RewindSelector.tsx index d447d84cf..5da006cad 100644 --- a/packages/cli/src/ui/components/RewindSelector.tsx +++ b/packages/cli/src/ui/components/RewindSelector.tsx @@ -11,6 +11,7 @@ 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 { @@ -25,7 +26,7 @@ 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((item) => item.type === 'user'); + return history.filter(isRealUserTurn); } interface TurnItemViewProps { diff --git a/packages/cli/src/ui/utils/historyMapping.test.ts b/packages/cli/src/ui/utils/historyMapping.test.ts index db0913d3d..84c18a2ff 100644 --- a/packages/cli/src/ui/utils/historyMapping.test.ts +++ b/packages/cli/src/ui/utils/historyMapping.test.ts @@ -5,7 +5,7 @@ */ import { describe, it, expect } from 'vitest'; -import { computeApiTruncationIndex } from './historyMapping.js'; +import { computeApiTruncationIndex, isRealUserTurn } from './historyMapping.js'; import type { HistoryItem } from '../types.js'; import type { Content, Part } from '@google/genai'; @@ -169,7 +169,7 @@ describe('computeApiTruncationIndex', () => { }); describe('compression fallback', () => { - it('returns apiHistory.length when not enough user prompts found', () => { + it('returns -1 when not enough user prompts found', () => { const ui: HistoryItem[] = [ userItem(1), geminiItem(2), @@ -185,7 +185,28 @@ describe('computeApiTruncationIndex', () => { 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(api.length); + 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); }); }); @@ -200,3 +221,23 @@ describe('computeApiTruncationIndex', () => { }); }); }); + +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 index b422d0161..389acf2e9 100644 --- a/packages/cli/src/ui/utils/historyMapping.ts +++ b/packages/cli/src/ui/utils/historyMapping.ts @@ -7,6 +7,17 @@ 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. @@ -67,7 +78,8 @@ function hasStartupContext(apiHistory: Content[]): boolean { * @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 in the API history + * @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[], @@ -80,7 +92,7 @@ export function computeApiTruncationIndex( if (item.id === targetUserItemId) { break; } - if (item.type === 'user') { + if (isRealUserTurn(item)) { uiUserTurnCount++; } } @@ -109,6 +121,6 @@ export function computeApiTruncationIndex( } // If we didn't find enough user prompts (e.g., after compression), - // return the full API history length - return apiHistory.length; + // signal that the target turn is unreachable. + return -1; }