From e0f0952a9e5fec05c9cdcc09ded01e6a9d21413d Mon Sep 17 00:00:00 2001 From: Aryan Kumar <154001177+tusaryan@users.noreply.github.com> Date: Sat, 11 Apr 2026 02:22:43 +0530 Subject: [PATCH 1/2] feat(cli): add /rewind argument (#24933) Adds support for an optional integer index argument to the `/rewind` command. This enables non-interactive, automated workflows to trim conversation history and preserve prompt caches without navigating the TUI. Supports python-style negative indexing. --- docs/cli/rewind.md | 25 ++++ .../src/ui/commands/rewindCommand.test.tsx | 128 ++++++++++++++++-- .../cli/src/ui/commands/rewindCommand.tsx | 78 ++++++++++- 3 files changed, 218 insertions(+), 13 deletions(-) diff --git a/docs/cli/rewind.md b/docs/cli/rewind.md index 98ed72a8253..b92ab205c14 100644 --- a/docs/cli/rewind.md +++ b/docs/cli/rewind.md @@ -12,6 +12,31 @@ To use the rewind feature, simply type `/rewind` into the input prompt and press Alternatively, you can use the keyboard shortcut: **Press `Esc` twice**. +### Non-interactive usage + +You can also rewind directly to a specific user message by passing an index: + +``` +/rewind +``` + +The index is **0-based** and supports **negative indexing** (Python-style): + +| Command | Effect | +| ------------ | ------------------------------------------- | +| `/rewind 0` | Rewind to before the first user message | +| `/rewind 1` | Rewind to before the second user message | +| `/rewind -1` | Rewind to before the last user message | +| `/rewind -2` | Rewind to before the second-to-last message | + +This is useful for **stdin-driven orchestrators** and automated workflows where +navigating the interactive TUI is not practical. The rewound message's prompt +text is restored into the input buffer, matching the behavior of the interactive +TUI. + +> **Note:** Non-interactive rewind only rewinds the conversation history. To +> revert file changes, use the interactive TUI (`/rewind` without arguments). + ## Interface When you trigger a rewind, an interactive list of your previous interactions diff --git a/packages/cli/src/ui/commands/rewindCommand.test.tsx b/packages/cli/src/ui/commands/rewindCommand.test.tsx index aa5e6cfa6fe..262f159fbbb 100644 --- a/packages/cli/src/ui/commands/rewindCommand.test.tsx +++ b/packages/cli/src/ui/commands/rewindCommand.test.tsx @@ -281,10 +281,10 @@ describe('rewindCommand', () => { }); }); - it('should fail if config is missing', () => { + it('should fail if config is missing', async () => { const context = { services: {} } as CommandContext; - const result = rewindCommand.action!(context, ''); + const result = await rewindCommand.action!(context, ''); expect(result).toEqual({ type: 'message', @@ -293,7 +293,7 @@ describe('rewindCommand', () => { }); }); - it('should fail if client is not initialized', () => { + it('should fail if client is not initialized', async () => { const context = createMockCommandContext({ services: { agentContext: { @@ -305,7 +305,7 @@ describe('rewindCommand', () => { }, }) as unknown as CommandContext; - const result = rewindCommand.action!(context, ''); + const result = await rewindCommand.action!(context, ''); expect(result).toEqual({ type: 'message', @@ -314,7 +314,7 @@ describe('rewindCommand', () => { }); }); - it('should fail if recording service is unavailable', () => { + it('should fail if recording service is unavailable', async () => { const context = createMockCommandContext({ services: { agentContext: { @@ -326,7 +326,7 @@ describe('rewindCommand', () => { }, }) as unknown as CommandContext; - const result = rewindCommand.action!(context, ''); + const result = await rewindCommand.action!(context, ''); expect(result).toEqual({ type: 'message', @@ -335,10 +335,10 @@ describe('rewindCommand', () => { }); }); - it('should return info if no conversation found', () => { + it('should return info if no conversation found', async () => { mockGetConversation.mockReturnValue(null); - const result = rewindCommand.action!(mockContext, ''); + const result = await rewindCommand.action!(mockContext, ''); expect(result).toEqual({ type: 'message', @@ -347,13 +347,13 @@ describe('rewindCommand', () => { }); }); - it('should return info if no user interactions found', () => { + it('should return info if no user interactions found', async () => { mockGetConversation.mockReturnValue({ messages: [{ id: 'msg-1', type: 'gemini', content: 'hello' }], sessionId: 'test-session', }); - const result = rewindCommand.action!(mockContext, ''); + const result = await rewindCommand.action!(mockContext, ''); expect(result).toEqual({ type: 'message', @@ -361,4 +361,112 @@ describe('rewindCommand', () => { content: 'Nothing to rewind to.', }); }); + + describe('index-based rewind (/rewind )', () => { + beforeEach(() => { + mockGetConversation.mockReturnValue({ + messages: [ + { id: 'msg-u1', type: 'user', content: 'first prompt' }, + { id: 'msg-g1', type: 'gemini', content: 'response 1' }, + { id: 'msg-u2', type: 'user', content: 'second prompt' }, + { id: 'msg-g2', type: 'gemini', content: 'response 2' }, + { id: 'msg-u3', type: 'user', content: 'third prompt' }, + { id: 'msg-g3', type: 'gemini', content: 'response 3' }, + ], + sessionId: 'test-session', + }); + }); + + it('should rewind to the first user message with /rewind 0', async () => { + const result = await rewindCommand.action!(mockContext, '0'); + + expect(mockRewindTo).toHaveBeenCalledWith('msg-u1'); + expect(mockSetHistory).toHaveBeenCalled(); + expect(mockLoadHistory).toHaveBeenCalled(); + expect(result).toEqual({ + type: 'message', + messageType: 'info', + content: 'Rewound to before user message 0.', + }); + }); + + it('should rewind to the second user message with /rewind 1', async () => { + const result = await rewindCommand.action!(mockContext, '1'); + + expect(mockRewindTo).toHaveBeenCalledWith('msg-u2'); + expect(result).toEqual({ + type: 'message', + messageType: 'info', + content: 'Rewound to before user message 1.', + }); + }); + + it('should resolve negative index: /rewind -1 targets last user message', async () => { + const result = await rewindCommand.action!(mockContext, '-1'); + + expect(mockRewindTo).toHaveBeenCalledWith('msg-u3'); + expect(result).toEqual({ + type: 'message', + messageType: 'info', + content: 'Rewound to before user message 2.', + }); + }); + + it('should resolve negative index: /rewind -2 targets second-to-last', async () => { + const result = await rewindCommand.action!(mockContext, '-2'); + + expect(mockRewindTo).toHaveBeenCalledWith('msg-u2'); + expect(result).toEqual({ + type: 'message', + messageType: 'info', + content: 'Rewound to before user message 1.', + }); + }); + + it('should return error for non-integer argument', async () => { + const result = await rewindCommand.action!(mockContext, 'abc'); + + expect(mockRewindTo).not.toHaveBeenCalled(); + expect(result).toEqual({ + type: 'message', + messageType: 'error', + content: + 'Invalid argument. Usage: /rewind (integer, supports negative indexing)', + }); + }); + + it('should return error for out-of-range positive index', async () => { + const result = await rewindCommand.action!(mockContext, '999'); + + expect(mockRewindTo).not.toHaveBeenCalled(); + expect(result).toEqual({ + type: 'message', + messageType: 'error', + content: 'Invalid index. Valid range: 0 to 2 (or -3 to -1).', + }); + }); + + it('should return error for out-of-range negative index', async () => { + const result = await rewindCommand.action!(mockContext, '-999'); + + expect(mockRewindTo).not.toHaveBeenCalled(); + expect(result).toEqual({ + type: 'message', + messageType: 'error', + content: 'Invalid index. Valid range: 0 to 2 (or -3 to -1).', + }); + }); + + it('should not return success message when rewind fails', async () => { + mockRewindTo.mockReturnValue(null); + + const result = await rewindCommand.action!(mockContext, '0'); + + expect(coreEvents.emitFeedback).toHaveBeenCalledWith( + 'error', + 'Could not fetch conversation file', + ); + expect(result).toBeUndefined(); + }); + }); }); diff --git a/packages/cli/src/ui/commands/rewindCommand.tsx b/packages/cli/src/ui/commands/rewindCommand.tsx index f703323c1bd..72c684f5767 100644 --- a/packages/cli/src/ui/commands/rewindCommand.tsx +++ b/packages/cli/src/ui/commands/rewindCommand.tsx @@ -20,11 +20,26 @@ import { coreEvents, debugLogger, logRewind, + partToString, RewindEvent, type ChatRecordingService, type GeminiClient, + type MessageRecord, convertSessionToClientHistory, } from '@google/gemini-cli-core'; +import { stripReferenceContent } from '../utils/formatters.js'; + +/** + * Extracts the cleaned prompt text from a user message, matching the logic + * used by the RewindViewer TUI to restore the input buffer after a rewind. + */ +function getCleanedRewindText(userMessage: MessageRecord): string { + const contentToUse = userMessage.displayContent || userMessage.content; + const originalUserText = contentToUse ? partToString(contentToUse) : ''; + return userMessage.displayContent + ? originalUserText + : stripReferenceContent(originalUserText); +} /** * Helper function to handle the core logic of rewinding a conversation. @@ -36,6 +51,7 @@ import { * @param recordingService The chat recording service. * @param messageId The ID of the message to rewind to. * @param newText The new text for the input field after rewinding. + * @returns true if the rewind was successful, false otherwise. */ async function rewindConversation( context: CommandContext, @@ -43,7 +59,7 @@ async function rewindConversation( recordingService: ChatRecordingService, messageId: string, newText: string, -) { +): Promise { try { const conversation = recordingService.rewindTo(messageId); if (!conversation) { @@ -51,7 +67,7 @@ async function rewindConversation( debugLogger.error(errorMsg); context.ui.removeComponent(); coreEvents.emitFeedback('error', errorMsg); - return; + return false; } // Convert to UI and Client formats @@ -81,6 +97,7 @@ async function rewindConversation( // 2. Load the rewound history and set the input context.ui.loadHistory(historyWithIds, newText); + return true; } catch (error) { // If an error occurs, we still want to remove the component if possible context.ui.removeComponent(); @@ -88,6 +105,7 @@ async function rewindConversation( 'error', error instanceof Error ? error.message : 'Unknown error during rewind', ); + return false; } } @@ -95,7 +113,7 @@ export const rewindCommand: SlashCommand = { name: 'rewind', description: 'Jump back to a specific message and restart the conversation', kind: CommandKind.BUILT_IN, - action: (context) => { + action: async (context, args) => { const agentContext = context.services.agentContext; const config = agentContext?.config; if (!config) @@ -140,6 +158,60 @@ export const rewindCommand: SlashCommand = { }; } + // Non-interactive index-based rewind: /rewind + const argTrimmed = args?.trim() ?? ''; + if (argTrimmed) { + if (!/^-?\d+$/.test(argTrimmed)) { + return { + type: 'message', + messageType: 'error', + content: + 'Invalid argument. Usage: /rewind (integer, supports negative indexing)', + }; + } + + const userMessages = conversation.messages.filter( + (msg) => msg.type === 'user', + ); + let index = parseInt(argTrimmed, 10); + + // Resolve negative index (Python-style: -1 = last) + if (index < 0) { + index += userMessages.length; + } + + if (index < 0 || index >= userMessages.length) { + return { + type: 'message', + messageType: 'error', + content: `Invalid index. Valid range: 0 to ${userMessages.length - 1} (or -${userMessages.length} to -1).`, + }; + } + + const targetMessage = userMessages[index]; + const cleanedText = getCleanedRewindText(targetMessage); + + logRewind(config, new RewindEvent(RewindOutcome.RewindOnly)); + + const success = await rewindConversation( + context, + client, + recordingService, + targetMessage.id, + cleanedText, + ); + + if (success) { + return { + type: 'message', + messageType: 'info', + content: `Rewound to before user message ${index}.`, + }; + } + // Error already emitted by rewindConversation via coreEvents + return; + } + return { type: 'custom_dialog', component: ( From 970321d8dcb758c7ee7b1f17172cf803aa5d4390 Mon Sep 17 00:00:00 2001 From: Aryan Kumar <154001177+tusaryan@users.noreply.github.com> Date: Sat, 11 Apr 2026 03:21:03 +0530 Subject: [PATCH 2/2] refactor(cli): centralize getCleanedRewindText logic Moves duplicated text cleaning logic into a shared utility file (formatters.ts) to ensure consistency between interactive and non-interactive rewind paths. --- packages/cli/src/ui/commands/rewindCommand.tsx | 17 +---------------- packages/cli/src/ui/components/RewindViewer.tsx | 10 +--------- packages/cli/src/ui/utils/formatters.ts | 13 +++++++++++++ 3 files changed, 15 insertions(+), 25 deletions(-) diff --git a/packages/cli/src/ui/commands/rewindCommand.tsx b/packages/cli/src/ui/commands/rewindCommand.tsx index 72c684f5767..9863c606aae 100644 --- a/packages/cli/src/ui/commands/rewindCommand.tsx +++ b/packages/cli/src/ui/commands/rewindCommand.tsx @@ -20,27 +20,12 @@ import { coreEvents, debugLogger, logRewind, - partToString, RewindEvent, type ChatRecordingService, type GeminiClient, - type MessageRecord, convertSessionToClientHistory, } from '@google/gemini-cli-core'; -import { stripReferenceContent } from '../utils/formatters.js'; - -/** - * Extracts the cleaned prompt text from a user message, matching the logic - * used by the RewindViewer TUI to restore the input buffer after a rewind. - */ -function getCleanedRewindText(userMessage: MessageRecord): string { - const contentToUse = userMessage.displayContent || userMessage.content; - const originalUserText = contentToUse ? partToString(contentToUse) : ''; - return userMessage.displayContent - ? originalUserText - : stripReferenceContent(originalUserText); -} - +import { getCleanedRewindText } from '../utils/formatters.js'; /** * Helper function to handle the core logic of rewinding a conversation. * This function encapsulates the steps needed to rewind the conversation, diff --git a/packages/cli/src/ui/components/RewindViewer.tsx b/packages/cli/src/ui/components/RewindViewer.tsx index e77b17db32f..0e66e226c2e 100644 --- a/packages/cli/src/ui/components/RewindViewer.tsx +++ b/packages/cli/src/ui/components/RewindViewer.tsx @@ -18,7 +18,7 @@ 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 { getCleanedRewindText } from '../utils/formatters.js'; import { Command } from '../key/keyMatchers.js'; import { CliSpinner } from './CliSpinner.js'; import { ExpandableText } from './shared/ExpandableText.js'; @@ -36,14 +36,6 @@ interface RewindViewerProps { 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, diff --git a/packages/cli/src/ui/utils/formatters.ts b/packages/cli/src/ui/utils/formatters.ts index 5a3f926dbe8..c220658b5a1 100644 --- a/packages/cli/src/ui/utils/formatters.ts +++ b/packages/cli/src/ui/utils/formatters.ts @@ -7,6 +7,8 @@ import { REFERENCE_CONTENT_START, REFERENCE_CONTENT_END, + type MessageRecord, + partToString, } from '@google/gemini-cli-core'; export const formatBytes = (bytes: number): string => { @@ -153,3 +155,14 @@ export const formatResetTime = ( return `${duration} at ${timeStr}`; }; + +/** + * Extracts the cleaned prompt text from a user message. + */ +export const getCleanedRewindText = (userMessage: MessageRecord): string => { + const contentToUse = userMessage.displayContent || userMessage.content; + const originalUserText = contentToUse ? partToString(contentToUse) : ''; + return userMessage.displayContent + ? originalUserText + : stripReferenceContent(originalUserText); +};