feat: add Rewind Confirmation dialog and Rewind Viewer component (#15717)

This commit is contained in:
Adib234
2026-01-14 10:22:21 -05:00
committed by GitHub
parent 3b55581aaf
commit dfb7dc7069
19 changed files with 1318 additions and 27 deletions

View File

@@ -167,6 +167,13 @@ Slash commands provide meta-level control over the CLI itself.
- **Note:** Only available if checkpointing is configured via
[settings](../get-started/configuration.md). See
[Checkpointing documentation](../cli/checkpointing.md) for more details.
- [**`/rewind`**](./rewind.md)
- **Description:** Browse and rewind previous interactions. Allows you to
rewind the conversation, revert file changes, or both. Provides an
interactive interface to select the exact point to rewind to.
- **Keyboard shortcut:** Press **Esc** twice.
- **`/resume`**
- **Description:** Browse and resume previous conversation sessions. Opens an
interactive session browser where you can search, filter, and select from

View File

@@ -64,6 +64,7 @@ available combinations.
| Start reverse search through history. | `Ctrl + R` |
| Submit the selected reverse-search match. | `Enter (no Ctrl)` |
| Accept a suggestion while reverse searching. | `Tab` |
| Browse and rewind previous interactions. | `Esc (×2)` |
#### Navigation
@@ -129,7 +130,7 @@ available combinations.
- `!` on an empty prompt: Enter or exit shell mode.
- `\` (at end of a line) + `Enter`: Insert a newline without leaving single-line
mode.
- `Esc` pressed twice quickly: Clear the current input buffer.
- `Esc` pressed twice quickly: Browse and rewind previous interactions.
- `Up Arrow` / `Down Arrow`: When the cursor is at the top or bottom of a
single-line input, navigate backward or forward through prompt history.
- `Number keys (1-9, multi-digit)` inside selection dialogs: Jump directly to

51
docs/cli/rewind.md Normal file
View File

@@ -0,0 +1,51 @@
# Rewind
The `/rewind` command allows you to go back to a previous state in your
conversation and, optionally, revert any file changes made by the AI during
those interactions. This is a powerful tool for undoing mistakes, exploring
different approaches, or simply cleaning up your session history.
## Usage
To use the rewind feature, simply type `/rewind` into the input prompt and press
**Enter**.
Alternatively, you can use the keyboard shortcut: **Press `Esc` twice**.
## Interface
When you trigger a rewind, an interactive list of your previous interactions
appears.
1. **Select Interaction:** Use the **Up/Down arrow keys** to navigate through
the list. The most recent interactions are at the bottom.
2. **Preview:** As you select an interaction, you'll see a preview of the user
prompt and, if applicable, the number of files changed during that step.
3. **Confirm Selection:** Press **Enter** on the interaction you want to rewind
back to.
4. **Action Selection:** After selecting an interaction, you'll be presented
with a confirmation dialog with up to three options:
- **Rewind conversation and revert code changes:** Reverts both the chat
history and the file modifications to the state before the selected
interaction.
- **Rewind conversation:** Only reverts the chat history. File changes are
kept.
- **Revert code changes:** Only reverts the file modifications. The chat
history is kept.
- **Do nothing (esc):** Cancels the rewind operation.
If no code changes were made since the selected point, the options related to
reverting code changes will be hidden.
## Key Considerations
- **Destructive Action:** Rewinding is a destructive action for your current
session history and potentially your files. Use it with care.
- **Agent Awareness:** When you rewind the conversation, the AI model loses all
memory of the interactions that were removed. If you only revert code changes,
you may need to inform the model that the files have changed.
- **Manual Edits:** Rewinding only affects file changes made by the AI's edit
tools. It does **not** undo manual edits you've made or changes triggered by
the shell tool (`!`).
- **Compression:** Rewind works across chat compression points by reconstructing
the history from stored session data.

View File

@@ -80,6 +80,10 @@
"label": "Model selection",
"slug": "docs/cli/model"
},
{
"label": "Rewind",
"slug": "docs/cli/rewind"
},
{
"label": "Sandbox",
"slug": "docs/cli/sandbox"

View File

@@ -76,6 +76,7 @@ export enum Command {
QUIT = 'quit',
EXIT = 'exit',
SHOW_MORE_LINES = 'showMoreLines',
REWIND = 'rewind',
// Shell commands
REVERSE_SEARCH = 'reverseSearch',
@@ -264,6 +265,7 @@ export const defaultKeyBindings: KeyBindingConfig = {
// Suggestion expansion
[Command.EXPAND_SUGGESTION]: [{ key: 'right' }],
[Command.COLLAPSE_SUGGESTION]: [{ key: 'left' }],
[Command.REWIND]: [{ key: 'Esc (×2)' }],
};
interface CommandCategory {
@@ -327,6 +329,7 @@ export const commandCategories: readonly CommandCategory[] = [
Command.REVERSE_SEARCH,
Command.SUBMIT_REVERSE_SEARCH,
Command.ACCEPT_SUGGESTION_REVERSE_SEARCH,
Command.REWIND,
],
},
{
@@ -439,4 +442,5 @@ export const commandDescriptions: Readonly<Record<Command, string>> = {
[Command.UNFOCUS_SHELL_INPUT]: 'Focus the Gemini input from the shell input.',
[Command.EXPAND_SUGGESTION]: 'Expand an inline suggestion.',
[Command.COLLAPSE_SUGGESTION]: 'Collapse an inline suggestion.',
[Command.REWIND]: 'Browse and rewind previous interactions.',
};

View File

@@ -133,6 +133,7 @@ const baseMockUiState = {
streamingState: StreamingState.Idle,
mainAreaWidth: 100,
terminalWidth: 120,
terminalHeight: 40,
currentModel: 'gemini-pro',
terminalBackgroundColor: undefined,
};

View File

@@ -385,7 +385,7 @@ describe('Composer', () => {
const { lastFrame } = renderComposer(uiState);
expect(lastFrame()).toContain('Press Esc again to clear');
expect(lastFrame()).toContain('Press Esc again to rewind');
});
});

View File

@@ -1870,11 +1870,11 @@ describe('InputPrompt', () => {
});
});
describe('enhanced input UX - double ESC clear functionality', () => {
describe('enhanced input UX - keyboard shortcuts', () => {
beforeEach(() => vi.useFakeTimers());
afterEach(() => vi.useRealTimers());
it('should clear buffer on second ESC press', async () => {
it('should clear buffer on Ctrl-C', async () => {
const onEscapePromptChange = vi.fn();
props.onEscapePromptChange = onEscapePromptChange;
props.buffer.setText('text to clear');
@@ -1884,14 +1884,7 @@ describe('InputPrompt', () => {
);
await act(async () => {
stdin.write('\x1B');
vi.advanceTimersByTime(100);
expect(onEscapePromptChange).toHaveBeenCalledWith(false);
});
await act(async () => {
stdin.write('\x1B');
stdin.write('\x03');
vi.advanceTimersByTime(100);
expect(props.buffer.setText).toHaveBeenCalledWith('');
@@ -1900,10 +1893,10 @@ describe('InputPrompt', () => {
unmount();
});
it('should clear buffer on double ESC', async () => {
it('should submit /rewind on double ESC', async () => {
const onEscapePromptChange = vi.fn();
props.onEscapePromptChange = onEscapePromptChange;
props.buffer.setText('text to clear');
props.buffer.setText('some text');
const { stdin, unmount } = renderWithProviders(
<InputPrompt {...props} />,
@@ -1913,8 +1906,7 @@ describe('InputPrompt', () => {
stdin.write('\x1B\x1B');
vi.advanceTimersByTime(100);
expect(props.buffer.setText).toHaveBeenCalledWith('');
expect(mockCommandCompletion.resetCompletionState).toHaveBeenCalled();
expect(props.onSubmit).toHaveBeenCalledWith('/rewind');
});
unmount();
});

View File

@@ -495,11 +495,8 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
return;
}
// Handle double ESC for clearing input
// Handle double ESC for rewind
if (escPressCount.current === 0) {
if (buffer.text === '') {
return;
}
escPressCount.current = 1;
setShowEscapePrompt(true);
if (escapeTimerRef.current) {
@@ -509,10 +506,9 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
resetEscapeState();
}, 500);
} else {
// clear input and immediately reset state
buffer.setText('');
resetCompletionState();
// Second ESC triggers rewind
resetEscapeState();
onSubmit('/rewind');
}
return;
}
@@ -881,6 +877,7 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
kittyProtocol.enabled,
tryLoadQueuedMessages,
setBannerVisible,
onSubmit,
activePtyId,
setEmbeddedShellFocused,
],

View File

@@ -0,0 +1,91 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect, vi, afterEach } from 'vitest';
import { act } from 'react';
import { renderWithProviders } from '../../test-utils/render.js';
import { waitFor } from '../../test-utils/async.js';
import { RewindConfirmation, RewindOutcome } from './RewindConfirmation.js';
describe('RewindConfirmation', () => {
afterEach(() => {
vi.restoreAllMocks();
});
it('renders correctly with stats', () => {
const stats = {
addedLines: 10,
removedLines: 5,
fileCount: 1,
details: [{ fileName: 'test.ts', diff: '' }],
};
const onConfirm = vi.fn();
const { lastFrame } = renderWithProviders(
<RewindConfirmation
stats={stats}
onConfirm={onConfirm}
terminalWidth={80}
/>,
{ width: 80 },
);
expect(lastFrame()).toMatchSnapshot();
expect(lastFrame()).toContain('Revert code changes');
});
it('renders correctly without stats', () => {
const onConfirm = vi.fn();
const { lastFrame } = renderWithProviders(
<RewindConfirmation
stats={null}
onConfirm={onConfirm}
terminalWidth={80}
/>,
{ width: 80 },
);
expect(lastFrame()).toMatchSnapshot();
expect(lastFrame()).not.toContain('Revert code changes');
expect(lastFrame()).toContain('Rewind conversation');
});
it('calls onConfirm with Cancel on Escape', async () => {
const onConfirm = vi.fn();
const { stdin } = renderWithProviders(
<RewindConfirmation
stats={null}
onConfirm={onConfirm}
terminalWidth={80}
/>,
{ width: 80 },
);
await act(async () => {
stdin.write('\x1b');
});
await waitFor(() => {
expect(onConfirm).toHaveBeenCalledWith(RewindOutcome.Cancel);
});
});
it('renders timestamp when provided', () => {
const onConfirm = vi.fn();
const timestamp = new Date().toISOString();
const { lastFrame } = renderWithProviders(
<RewindConfirmation
stats={null}
onConfirm={onConfirm}
terminalWidth={80}
timestamp={timestamp}
/>,
{ width: 80 },
);
expect(lastFrame()).toMatchSnapshot();
expect(lastFrame()).not.toContain('Revert code changes');
});
});

View File

@@ -0,0 +1,156 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { Box, Text } from 'ink';
import type React from 'react';
import { useMemo } from 'react';
import { theme } from '../semantic-colors.js';
import { RadioButtonSelect } from './shared/RadioButtonSelect.js';
import type { RadioSelectItem } from './shared/RadioButtonSelect.js';
import type { FileChangeStats } from '../utils/rewindFileOps.js';
import { useKeypress } from '../hooks/useKeypress.js';
import { formatTimeAgo } from '../utils/formatters.js';
import { keyMatchers, Command } from '../keyMatchers.js';
export enum RewindOutcome {
RewindAndRevert = 'rewind_and_revert',
RewindOnly = 'rewind_only',
RevertOnly = 'revert_only',
Cancel = 'cancel',
}
const REWIND_OPTIONS: Array<RadioSelectItem<RewindOutcome>> = [
{
label: 'Rewind conversation and revert code changes',
value: RewindOutcome.RewindAndRevert,
key: 'Rewind conversation and revert code changes',
},
{
label: 'Rewind conversation',
value: RewindOutcome.RewindOnly,
key: 'Rewind conversation',
},
{
label: 'Revert code changes',
value: RewindOutcome.RevertOnly,
key: 'Revert code changes',
},
{
label: 'Do nothing (esc)',
value: RewindOutcome.Cancel,
key: 'Do nothing (esc)',
},
];
interface RewindConfirmationProps {
stats: FileChangeStats | null;
onConfirm: (outcome: RewindOutcome) => void;
terminalWidth: number;
timestamp?: string;
}
export const RewindConfirmation: React.FC<RewindConfirmationProps> = ({
stats,
onConfirm,
terminalWidth,
timestamp,
}) => {
useKeypress(
(key) => {
if (keyMatchers[Command.ESCAPE](key)) {
onConfirm(RewindOutcome.Cancel);
}
},
{ isActive: true },
);
const handleSelect = (outcome: RewindOutcome) => {
onConfirm(outcome);
};
const options = useMemo(() => {
if (stats) {
return REWIND_OPTIONS;
}
return REWIND_OPTIONS.filter(
(option) =>
option.value !== RewindOutcome.RewindAndRevert &&
option.value !== RewindOutcome.RevertOnly,
);
}, [stats]);
return (
<Box
flexDirection="column"
borderStyle="round"
borderColor={theme.border.default}
padding={1}
width={terminalWidth}
>
<Box marginBottom={1}>
<Text bold>Confirm Rewind</Text>
</Box>
{stats && (
<Box
flexDirection="column"
marginBottom={1}
borderStyle="single"
borderColor={theme.border.default}
paddingX={1}
>
<Text color={theme.text.primary}>
{stats.fileCount === 1
? `File: ${stats.details?.at(0)?.fileName}`
: `${stats.fileCount} files affected`}
</Text>
<Box flexDirection="row">
<Text color={theme.status.success}>
Lines added: {stats.addedLines}{' '}
</Text>
<Text color={theme.status.error}>
Lines removed: {stats.removedLines}
</Text>
{timestamp && (
<Text color={theme.text.secondary}>
{' '}
({formatTimeAgo(timestamp)})
</Text>
)}
</Box>
<Box marginTop={1}>
<Text color={theme.status.warning}>
Rewinding does not affect files edited manually or by the shell
tool.
</Text>
</Box>
</Box>
)}
{!stats && (
<Box marginBottom={1}>
<Text color={theme.text.secondary}>No code changes to revert.</Text>
{timestamp && (
<Text color={theme.text.secondary}>
{' '}
({formatTimeAgo(timestamp)})
</Text>
)}
</Box>
)}
<Box marginBottom={1}>
<Text>Select an action:</Text>
</Box>
<RadioButtonSelect
items={options}
onSelect={handleSelect}
isFocused={true}
/>
</Box>
);
};

View File

@@ -0,0 +1,330 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect, vi, afterEach } from 'vitest';
import { act } from 'react';
import { renderWithProviders } from '../../test-utils/render.js';
import { RewindViewer } from './RewindViewer.js';
import { waitFor } from '../../test-utils/async.js';
import type {
ConversationRecord,
MessageRecord,
} from '@google/gemini-cli-core';
vi.mock('../utils/formatters.js', async (importOriginal) => {
const original =
await importOriginal<typeof import('../utils/formatters.js')>();
return {
...original,
formatTimeAgo: () => 'some time ago',
};
});
vi.mock('@google/gemini-cli-core', async (importOriginal) => {
const original =
await importOriginal<typeof import('@google/gemini-cli-core')>();
const partToStringRecursive = (part: unknown): string => {
if (!part) {
return '';
}
if (typeof part === 'string') {
return part;
}
if (Array.isArray(part)) {
return part.map(partToStringRecursive).join('');
}
if (typeof part === 'object' && part !== null && 'text' in part) {
return (part as { text: string }).text ?? '';
}
return '';
};
return {
...original,
partToString: (part: string | JSON) => partToStringRecursive(part),
};
});
const createConversation = (messages: MessageRecord[]): ConversationRecord => ({
sessionId: 'test-session',
projectHash: 'hash',
startTime: new Date().toISOString(),
lastUpdated: new Date().toISOString(),
messages,
});
describe('RewindViewer', () => {
afterEach(() => {
vi.restoreAllMocks();
});
describe('Rendering', () => {
it.each([
{ name: 'nothing interesting for empty conversation', messages: [] },
{
name: 'a single interaction',
messages: [
{ type: 'user', content: 'Hello', id: '1', timestamp: '1' },
{ type: 'gemini', content: 'Hi there!', id: '1', timestamp: '1' },
],
},
{
name: 'full text for selected item',
messages: [
{
type: 'user',
content: '1\n2\n3\n4\n5\n6\n7',
id: '1',
timestamp: '1',
},
],
},
])('renders $name', ({ messages }) => {
const conversation = createConversation(messages as MessageRecord[]);
const onExit = vi.fn();
const onRewind = vi.fn();
const { lastFrame } = renderWithProviders(
<RewindViewer
conversation={conversation}
onExit={onExit}
onRewind={onRewind}
/>,
);
expect(lastFrame()).toMatchSnapshot();
});
});
it('updates selection and expansion on navigation', async () => {
const longText1 = 'Line A\nLine B\nLine C\nLine D\nLine E\nLine F\nLine G';
const longText2 = 'Line 1\nLine 2\nLine 3\nLine 4\nLine 5\nLine 6\nLine 7';
const conversation = createConversation([
{ type: 'user', content: longText1, id: '1', timestamp: '1' },
{ type: 'gemini', content: 'Response 1', id: '1', timestamp: '1' },
{ type: 'user', content: longText2, id: '2', timestamp: '1' },
{ type: 'gemini', content: 'Response 2', id: '2', timestamp: '1' },
]);
const onExit = vi.fn();
const onRewind = vi.fn();
const { lastFrame, stdin } = renderWithProviders(
<RewindViewer
conversation={conversation}
onExit={onExit}
onRewind={onRewind}
/>,
);
// Initial state
expect(lastFrame()).toMatchSnapshot('initial-state');
// Move down to select Item 1 (older message)
act(() => {
stdin.write('\x1b[B');
});
await waitFor(() => {
expect(lastFrame()).toMatchSnapshot('after-down');
});
});
describe('Navigation', () => {
it.each([
{ name: 'down', sequence: '\x1b[B', expectedSnapshot: 'after-down' },
{ name: 'up', sequence: '\x1b[A', expectedSnapshot: 'after-up' },
])('handles $name navigation', async ({ sequence, expectedSnapshot }) => {
const conversation = createConversation([
{ type: 'user', content: 'Q1', id: '1', timestamp: '1' },
{ type: 'user', content: 'Q2', id: '2', timestamp: '1' },
{ type: 'user', content: 'Q3', id: '3', timestamp: '1' },
]);
const { lastFrame, stdin } = renderWithProviders(
<RewindViewer
conversation={conversation}
onExit={vi.fn()}
onRewind={vi.fn()}
/>,
);
act(() => {
stdin.write(sequence);
});
await waitFor(() => {
expect(lastFrame()).toMatchSnapshot(expectedSnapshot);
});
});
it('handles cyclic navigation', async () => {
const conversation = createConversation([
{ type: 'user', content: 'Q1', id: '1', timestamp: '1' },
{ type: 'user', content: 'Q2', id: '2', timestamp: '1' },
{ type: 'user', content: 'Q3', id: '3', timestamp: '1' },
]);
const { lastFrame, stdin } = renderWithProviders(
<RewindViewer
conversation={conversation}
onExit={vi.fn()}
onRewind={vi.fn()}
/>,
);
// Up from first -> Last
act(() => {
stdin.write('\x1b[A');
});
await waitFor(() => {
expect(lastFrame()).toMatchSnapshot('cyclic-up');
});
// Down from last -> First
act(() => {
stdin.write('\x1b[B');
});
await waitFor(() => {
expect(lastFrame()).toMatchSnapshot('cyclic-down');
});
});
});
describe('Interaction Selection', () => {
it.each([
{
name: 'confirms on Enter',
actionStep: async (
stdin: { write: (data: string) => void },
lastFrame: () => string | undefined,
) => {
// Wait for confirmation dialog to be rendered and interactive
await waitFor(() => {
expect(lastFrame()).toContain('Confirm Rewind');
});
act(() => {
stdin.write('\r');
});
},
},
{
name: 'cancels on Escape',
actionStep: async (
stdin: { write: (data: string) => void },
lastFrame: () => string | undefined,
) => {
// Wait for confirmation dialog
await waitFor(() => {
expect(lastFrame()).toContain('Confirm Rewind');
});
act(() => {
stdin.write('\x1b');
});
// Wait for return to main view
await waitFor(() => {
expect(lastFrame()).toContain('> Rewind');
});
},
},
])('$name', async ({ actionStep }) => {
const conversation = createConversation([
{ type: 'user', content: 'Original Prompt', id: '1', timestamp: '1' },
]);
const onRewind = vi.fn();
const { lastFrame, stdin } = renderWithProviders(
<RewindViewer
conversation={conversation}
onExit={vi.fn()}
onRewind={onRewind}
/>,
);
// Select
act(() => {
stdin.write('\r');
});
expect(lastFrame()).toMatchSnapshot('confirmation-dialog');
// Act
await actionStep(stdin, lastFrame);
});
});
describe('Content Filtering', () => {
it.each([
{
description: 'removes reference markers',
prompt:
'some command @file\n--- Content from referenced files ---\nContent from file:\nblah blah\n--- End of content ---',
},
{
description: 'strips expanded MCP resource content',
prompt:
'read @server3:mcp://demo-resource hello\n' +
'--- Content from referenced files ---\n' +
'\nContent from @server3:mcp://demo-resource:\n' +
'This is the content of the demo resource.\n' +
'--- End of content ---',
},
])('$description', async ({ prompt }) => {
const conversation = createConversation([
{ type: 'user', content: prompt, id: '1', timestamp: '1' },
]);
const onRewind = vi.fn();
const { lastFrame, stdin } = renderWithProviders(
<RewindViewer
conversation={conversation}
onExit={vi.fn()}
onRewind={onRewind}
/>,
);
expect(lastFrame()).toMatchSnapshot();
// Select
act(() => {
stdin.write('\r'); // Select
});
// Wait for confirmation dialog
await waitFor(() => {
expect(lastFrame()).toContain('Confirm Rewind');
});
});
});
it('updates content when conversation changes (background update)', () => {
const messages: MessageRecord[] = [
{ type: 'user', content: 'Message 1', id: '1', timestamp: '1' },
];
let conversation = createConversation(messages);
const onExit = vi.fn();
const onRewind = vi.fn();
const { lastFrame, unmount } = renderWithProviders(
<RewindViewer
conversation={conversation}
onExit={onExit}
onRewind={onRewind}
/>,
);
expect(lastFrame()).toMatchSnapshot('initial');
unmount();
const newMessages: MessageRecord[] = [
...messages,
{ type: 'user', content: 'Message 2', id: '2', timestamp: '2' },
];
conversation = createConversation(newMessages);
const { lastFrame: lastFrame2 } = renderWithProviders(
<RewindViewer
conversation={conversation}
onExit={onExit}
onRewind={onRewind}
/>,
);
expect(lastFrame2()).toMatchSnapshot('after-update');
});
});

View File

@@ -0,0 +1,211 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import type React from 'react';
import { useMemo } from 'react';
import { Box, Text } from 'ink';
import { useUIState } from '../contexts/UIStateContext.js';
import {
type ConversationRecord,
type MessageRecord,
partToString,
} from '@google/gemini-cli-core';
import { BaseSelectionList } from './shared/BaseSelectionList.js';
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 { MaxSizedBox } from './shared/MaxSizedBox.js';
import { keyMatchers, Command } from '../keyMatchers.js';
interface RewindViewerProps {
conversation: ConversationRecord;
onExit: () => void;
onRewind: (
messageId: string,
newText: string,
outcome: RewindOutcome,
) => void;
}
const MAX_LINES_PER_BOX = 2;
export const RewindViewer: React.FC<RewindViewerProps> = ({
conversation,
onExit,
onRewind,
}) => {
const { terminalWidth, terminalHeight } = useUIState();
const {
selectedMessageId,
getStats,
confirmationStats,
selectMessage,
clearSelection,
} = useRewind(conversation);
const interactions = useMemo(
() => conversation.messages.filter((msg) => msg.type === 'user'),
[conversation.messages],
);
const items = useMemo(
() =>
interactions
.map((msg, idx) => ({
key: `${msg.id || 'msg'}-${idx}`,
value: msg,
index: idx,
}))
.reverse(),
[interactions],
);
useKeypress(
(key) => {
if (!selectedMessageId) {
if (keyMatchers[Command.ESCAPE](key)) {
onExit();
}
}
},
{ isActive: true },
);
// Height constraint calculations
const DIALOG_PADDING = 2; // Top/bottom padding
const HEADER_HEIGHT = 2; // Title + margin
const CONTROLS_HEIGHT = 2; // Controls text + margin
const listHeight = Math.max(
5,
terminalHeight - DIALOG_PADDING - HEADER_HEIGHT - CONTROLS_HEIGHT - 2,
);
const maxItemsToShow = Math.max(1, Math.floor(listHeight / 4));
if (selectedMessageId) {
const selectedMessage = interactions.find(
(m) => m.id === selectedMessageId,
);
return (
<RewindConfirmation
stats={confirmationStats}
terminalWidth={terminalWidth}
timestamp={selectedMessage?.timestamp}
onConfirm={(outcome) => {
if (outcome === RewindOutcome.Cancel) {
clearSelection();
} else {
const userPrompt = interactions.find(
(m) => m.id === selectedMessageId,
);
if (userPrompt) {
const originalUserText = userPrompt.content
? partToString(userPrompt.content)
: '';
const cleanedText = stripReferenceContent(originalUserText);
onRewind(selectedMessageId, cleanedText, outcome);
}
}
}}
/>
);
}
return (
<Box
borderStyle="round"
borderColor={theme.border.default}
flexDirection="column"
width={terminalWidth}
paddingX={1}
paddingY={1}
>
<Box marginBottom={1}>
<Text bold>{'> '}Rewind</Text>
</Box>
<Box flexDirection="column" flexGrow={1}>
<BaseSelectionList
items={items}
isFocused={true}
showNumbers={false}
onSelect={(item: MessageRecord) => {
const userPrompt = item;
if (userPrompt && userPrompt.id) {
selectMessage(userPrompt.id);
}
}}
maxItemsToShow={maxItemsToShow}
renderItem={(itemWrapper, { isSelected }) => {
const userPrompt = itemWrapper.value;
const stats = getStats(userPrompt);
const firstFileName = stats?.details?.at(0)?.fileName;
const originalUserText = userPrompt.content
? partToString(userPrompt.content)
: '';
const cleanedText = stripReferenceContent(originalUserText);
return (
<Box flexDirection="column" marginBottom={1}>
<Box>
<MaxSizedBox
maxWidth={terminalWidth - 4}
maxHeight={isSelected ? undefined : MAX_LINES_PER_BOX + 1}
overflowDirection="bottom"
>
{cleanedText.split('\n').map((line, i) => (
<Box key={i}>
<Text
color={
isSelected
? theme.status.success
: theme.text.primary
}
>
{line}
</Text>
</Box>
))}
</MaxSizedBox>
</Box>
{stats ? (
<Box flexDirection="row">
<Text color={theme.text.secondary}>
{stats.fileCount === 1
? firstFileName
? firstFileName
: '1 file changed'
: `${stats.fileCount} files changed`}{' '}
</Text>
{stats.addedLines > 0 && (
<Text color="green">+{stats.addedLines} </Text>
)}
{stats.removedLines > 0 && (
<Text color="red">-{stats.removedLines}</Text>
)}
</Box>
) : (
<Text color={theme.text.secondary}>
No files have been changed
</Text>
)}
</Box>
);
}}
/>
</Box>
<Box marginTop={1}>
<Text color={theme.text.secondary}>
(Use Enter to select a message, Esc to close)
</Text>
</Box>
</Box>
);
};

View File

@@ -45,7 +45,7 @@ export const StatusDisplay: React.FC<StatusDisplayProps> = ({
}
if (uiState.showEscapePrompt) {
return <Text color={theme.text.secondary}>Press Esc again to clear.</Text>;
return <Text color={theme.text.secondary}>Press Esc again to rewind.</Text>;
}
if (uiState.queueErrorMessage) {

View File

@@ -0,0 +1,53 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`RewindConfirmation > renders correctly with stats 1`] = `
"╭──────────────────────────────────────────────────────────────────────────────╮
│ │
│ Confirm Rewind │
│ │
│ ┌──────────────────────────────────────────────────────────────────────────┐ │
│ │ File: test.ts │ │
│ │ Lines added: 10 Lines removed: 5 │ │
│ │ │ │
│ │ Rewinding does not affect files edited manually or by the shell tool. │ │
│ └──────────────────────────────────────────────────────────────────────────┘ │
│ │
│ Select an action: │
│ │
│ ● 1. Rewind conversation and revert code changes │
│ 2. Rewind conversation │
│ 3. Revert code changes │
│ 4. Do nothing (esc) │
│ │
╰──────────────────────────────────────────────────────────────────────────────╯"
`;
exports[`RewindConfirmation > renders correctly without stats 1`] = `
"╭──────────────────────────────────────────────────────────────────────────────╮
│ │
│ Confirm Rewind │
│ │
│ No code changes to revert. │
│ │
│ Select an action: │
│ │
│ ● 1. Rewind conversation │
│ 2. Do nothing (esc) │
│ │
╰──────────────────────────────────────────────────────────────────────────────╯"
`;
exports[`RewindConfirmation > renders timestamp when provided 1`] = `
"╭──────────────────────────────────────────────────────────────────────────────╮
│ │
│ Confirm Rewind │
│ │
│ No code changes to revert. (just now) │
│ │
│ Select an action: │
│ │
│ ● 1. Rewind conversation │
│ 2. Do nothing (esc) │
│ │
╰──────────────────────────────────────────────────────────────────────────────╯"
`;

View File

@@ -0,0 +1,265 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`RewindViewer > Content Filtering > 'removes reference markers' 1`] = `
"╭──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮
│ │
│ > Rewind │
│ │
│ ● some command @file │
│ No files have been changed │
│ │
│ │
│ (Use Enter to select a message, Esc to close) │
│ │
╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯"
`;
exports[`RewindViewer > Content Filtering > 'strips expanded MCP resource content' 1`] = `
"╭──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮
│ │
│ > Rewind │
│ │
│ ● read @server3:mcp://demo-resource hello │
│ No files have been changed │
│ │
│ │
│ (Use Enter to select a message, Esc to close) │
│ │
╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯"
`;
exports[`RewindViewer > Interaction Selection > 'cancels on Escape' > confirmation-dialog 1`] = `
"╭──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮
│ │
│ Confirm Rewind │
│ │
│ No code changes to revert. (some time ago) │
│ │
│ Select an action: │
│ │
│ ● 1. Rewind conversation │
│ 2. Do nothing (esc) │
│ │
╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯"
`;
exports[`RewindViewer > Interaction Selection > 'confirms on Enter' > confirmation-dialog 1`] = `
"╭──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮
│ │
│ Confirm Rewind │
│ │
│ No code changes to revert. (some time ago) │
│ │
│ Select an action: │
│ │
│ ● 1. Rewind conversation │
│ 2. Do nothing (esc) │
│ │
╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯"
`;
exports[`RewindViewer > Navigation > handles 'down' navigation > after-down 1`] = `
"╭──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮
│ │
│ > Rewind │
│ │
│ Q3 │
│ No files have been changed │
│ │
│ ● Q2 │
│ No files have been changed │
│ │
│ Q1 │
│ No files have been changed │
│ │
│ │
│ (Use Enter to select a message, Esc to close) │
│ │
╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯"
`;
exports[`RewindViewer > Navigation > handles 'up' navigation > after-up 1`] = `
"╭──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮
│ │
│ > Rewind │
│ │
│ Q3 │
│ No files have been changed │
│ │
│ Q2 │
│ No files have been changed │
│ │
│ ● Q1 │
│ No files have been changed │
│ │
│ │
│ (Use Enter to select a message, Esc to close) │
│ │
╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯"
`;
exports[`RewindViewer > Navigation > handles cyclic navigation > cyclic-down 1`] = `
"╭──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮
│ │
│ > Rewind │
│ │
│ ● Q3 │
│ No files have been changed │
│ │
│ Q2 │
│ No files have been changed │
│ │
│ Q1 │
│ No files have been changed │
│ │
│ │
│ (Use Enter to select a message, Esc to close) │
│ │
╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯"
`;
exports[`RewindViewer > Navigation > handles cyclic navigation > cyclic-up 1`] = `
"╭──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮
│ │
│ > Rewind │
│ │
│ Q3 │
│ No files have been changed │
│ │
│ Q2 │
│ No files have been changed │
│ │
│ ● Q1 │
│ No files have been changed │
│ │
│ │
│ (Use Enter to select a message, Esc to close) │
│ │
╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯"
`;
exports[`RewindViewer > Rendering > renders 'a single interaction' 1`] = `
"╭──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮
│ │
│ > Rewind │
│ │
│ ● Hello │
│ No files have been changed │
│ │
│ │
│ (Use Enter to select a message, Esc to close) │
│ │
╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯"
`;
exports[`RewindViewer > Rendering > renders 'full text for selected item' 1`] = `
"╭──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮
│ │
│ > Rewind │
│ │
│ ● 1 │
│ 2 │
│ 3 │
│ 4 │
│ 5 │
│ 6 │
│ 7 │
│ No files have been changed │
│ │
│ │
│ (Use Enter to select a message, Esc to close) │
│ │
╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯"
`;
exports[`RewindViewer > Rendering > renders 'nothing interesting for empty convers…' 1`] = `
"╭──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮
│ │
│ > Rewind │
│ │
│ │
│ (Use Enter to select a message, Esc to close) │
│ │
╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯"
`;
exports[`RewindViewer > updates content when conversation changes (background update) > after-update 1`] = `
"╭──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮
│ │
│ > Rewind │
│ │
│ ● Message 2 │
│ No files have been changed │
│ │
│ Message 1 │
│ No files have been changed │
│ │
│ │
│ (Use Enter to select a message, Esc to close) │
│ │
╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯"
`;
exports[`RewindViewer > updates content when conversation changes (background update) > initial 1`] = `
"╭──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮
│ │
│ > Rewind │
│ │
│ ● Message 1 │
│ No files have been changed │
│ │
│ │
│ (Use Enter to select a message, Esc to close) │
│ │
╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯"
`;
exports[`RewindViewer > updates selection and expansion on navigation > after-down 1`] = `
"╭──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮
│ │
│ > Rewind │
│ │
│ Line 1 │
│ Line 2 │
│ ... last 5 lines hidden ... │
│ No files have been changed │
│ │
│ ● Line A │
│ Line B │
│ Line C │
│ Line D │
│ Line E │
│ Line F │
│ Line G │
│ No files have been changed │
│ │
│ │
│ (Use Enter to select a message, Esc to close) │
│ │
╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯"
`;
exports[`RewindViewer > updates selection and expansion on navigation > initial-state 1`] = `
"╭──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮
│ │
│ > Rewind │
│ │
│ ● Line 1 │
│ Line 2 │
│ Line 3 │
│ Line 4 │
│ Line 5 │
│ Line 6 │
│ Line 7 │
│ No files have been changed │
│ │
│ Line A │
│ Line B │
│ ... last 5 lines hidden ... │
│ No files have been changed │
│ │
│ │
│ (Use Enter to select a message, Esc to close) │
│ │
╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯"
`;

View File

@@ -10,7 +10,7 @@ exports[`StatusDisplay > renders ContextSummaryDisplay by default 1`] = `"Mock C
exports[`StatusDisplay > renders Ctrl+D prompt 1`] = `"Press Ctrl+D again to exit."`;
exports[`StatusDisplay > renders Escape prompt 1`] = `"Press Esc again to clear."`;
exports[`StatusDisplay > renders Escape prompt 1`] = `"Press Esc again to rewind."`;
exports[`StatusDisplay > renders HookStatusDisplay when hooks are active 1`] = `"Mock Hook Status Display"`;

View File

@@ -4,8 +4,13 @@
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect } from 'vitest';
import { formatDuration, formatMemoryUsage } from './formatters.js';
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import {
formatDuration,
formatMemoryUsage,
formatTimeAgo,
stripReferenceContent,
} from './formatters.js';
describe('formatters', () => {
describe('formatMemoryUsage', () => {
@@ -69,4 +74,93 @@ describe('formatters', () => {
expect(formatDuration(-100)).toBe('0s');
});
});
describe('formatTimeAgo', () => {
const NOW = new Date('2025-01-01T12:00:00Z');
beforeEach(() => {
vi.useFakeTimers();
vi.setSystemTime(NOW);
});
afterEach(() => {
vi.useRealTimers();
});
it('should return "just now" for dates less than a minute ago', () => {
const past = new Date(NOW.getTime() - 30 * 1000);
expect(formatTimeAgo(past)).toBe('just now');
});
it('should return minutes ago', () => {
const past = new Date(NOW.getTime() - 5 * 60 * 1000);
expect(formatTimeAgo(past)).toBe('5m ago');
});
it('should return hours ago', () => {
const past = new Date(NOW.getTime() - 3 * 60 * 60 * 1000);
expect(formatTimeAgo(past)).toBe('3h ago');
});
it('should return days ago', () => {
const past = new Date(NOW.getTime() - 2 * 24 * 60 * 60 * 1000);
expect(formatTimeAgo(past)).toBe('48h ago');
});
it('should handle string dates', () => {
const past = '2025-01-01T11:00:00Z'; // 1 hour ago
expect(formatTimeAgo(past)).toBe('1h ago');
});
it('should handle number timestamps', () => {
const past = NOW.getTime() - 10 * 60 * 1000; // 10 minutes ago
expect(formatTimeAgo(past)).toBe('10m ago');
});
it('should handle invalid timestamps', () => {
const past = 'hello';
expect(formatTimeAgo(past)).toBe('invalid date');
});
});
describe('stripReferenceContent', () => {
it('should return the original text if no markers are present', () => {
const text = 'Hello world';
expect(stripReferenceContent(text)).toBe(text);
});
it('should strip content between markers', () => {
const text =
'Prompt @file.txt\n--- Content from referenced files ---\nFile content here\n--- End of content ---';
expect(stripReferenceContent(text)).toBe('Prompt @file.txt');
});
it('should strip content and keep text after the markers', () => {
const text =
'Before\n--- Content from referenced files ---\nMiddle\n--- End of content ---\nAfter';
expect(stripReferenceContent(text)).toBe('Before\nAfter');
});
it('should handle missing end marker gracefully', () => {
const text = 'Before\n--- Content from referenced files ---\nMiddle';
expect(stripReferenceContent(text)).toBe(text);
});
it('should handle end marker before start marker gracefully', () => {
const text =
'--- End of content ---\n--- Content from referenced files ---';
expect(stripReferenceContent(text)).toBe(text);
});
it('should strip even if markers are on the same line (though unlikely)', () => {
const text =
'A--- Content from referenced files ---B--- End of content ---C';
expect(stripReferenceContent(text)).toBe('AC');
});
it('should strip multiple blocks correctly and preserve text in between', () => {
const text =
'Start\n--- Content from referenced files ---\nBlock1\n--- End of content ---\nMiddle\n--- Content from referenced files ---\nBlock2\n--- End of content ---\nEnd';
expect(stripReferenceContent(text)).toBe('Start\nMiddle\nEnd');
});
});
});

View File

@@ -61,3 +61,37 @@ export const formatDuration = (milliseconds: number): string => {
return parts.join(' ');
};
export const formatTimeAgo = (date: string | number | Date): string => {
const past = new Date(date);
if (isNaN(past.getTime())) {
return 'invalid date';
}
const now = new Date();
const diffMs = now.getTime() - past.getTime();
if (diffMs < 60000) {
return 'just now';
}
return `${formatDuration(diffMs)} ago`;
};
const REFERENCE_CONTENT_START = '--- Content from referenced files ---';
const REFERENCE_CONTENT_END = '--- End of content ---';
/**
* Removes content bounded by reference content markers from the given text.
* The markers are "--- Content from referenced files ---" and "--- End of content ---".
*
* @param text The input text containing potential reference blocks.
* @returns The text with reference blocks removed and trimmed.
*/
export function stripReferenceContent(text: string): string {
// Match optional newline, the start marker, content (non-greedy), and the end marker
const pattern = new RegExp(
`\\n?${REFERENCE_CONTENT_START}[\\s\\S]*?${REFERENCE_CONTENT_END}`,
'g',
);
return text.replace(pattern, '').trim();
}