Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 25 additions & 0 deletions docs/cli/rewind.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <index>
```

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
Expand Down
128 changes: 118 additions & 10 deletions packages/cli/src/ui/commands/rewindCommand.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand All @@ -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: {
Expand All @@ -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',
Expand All @@ -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: {
Expand All @@ -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',
Expand All @@ -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',
Expand All @@ -347,18 +347,126 @@ 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',
messageType: 'info',
content: 'Nothing to rewind to.',
});
});

describe('index-based rewind (/rewind <N>)', () => {
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 <index> (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();
});
});
});
65 changes: 61 additions & 4 deletions packages/cli/src/ui/commands/rewindCommand.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ import {
type GeminiClient,
convertSessionToClientHistory,
} from '@google/gemini-cli-core';

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,
Expand All @@ -36,22 +36,23 @@ 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,
client: GeminiClient,
recordingService: ChatRecordingService,
messageId: string,
newText: string,
) {
): Promise<boolean> {
try {
const conversation = recordingService.rewindTo(messageId);
if (!conversation) {
const errorMsg = 'Could not fetch conversation file';
debugLogger.error(errorMsg);
context.ui.removeComponent();
coreEvents.emitFeedback('error', errorMsg);
return;
return false;
}

// Convert to UI and Client formats
Expand Down Expand Up @@ -81,21 +82,23 @@ 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();
coreEvents.emitFeedback(
'error',
error instanceof Error ? error.message : 'Unknown error during rewind',
);
return false;
}
}

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)
Expand Down Expand Up @@ -140,6 +143,60 @@ export const rewindCommand: SlashCommand = {
};
}

// Non-interactive index-based rewind: /rewind <N>
const argTrimmed = args?.trim() ?? '';
if (argTrimmed) {
if (!/^-?\d+$/.test(argTrimmed)) {
return {
type: 'message',
messageType: 'error',
content:
'Invalid argument. Usage: /rewind <index> (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: (
Expand Down
10 changes: 1 addition & 9 deletions packages/cli/src/ui/components/RewindViewer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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<RewindViewerProps> = ({
conversation,
onExit,
Expand Down
13 changes: 13 additions & 0 deletions packages/cli/src/ui/utils/formatters.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 => {
Expand Down Expand Up @@ -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);
};