From dfb7dc70695ce528bad952b1bb0a5131278e76ed Mon Sep 17 00:00:00 2001 From: Adib234 <30782825+Adib234@users.noreply.github.com> Date: Wed, 14 Jan 2026 10:22:21 -0500 Subject: [PATCH] feat: add Rewind Confirmation dialog and Rewind Viewer component (#15717) --- docs/cli/commands.md | 7 + docs/cli/keyboard-shortcuts.md | 3 +- docs/cli/rewind.md | 51 +++ docs/sidebar.json | 4 + packages/cli/src/config/keyBindings.ts | 4 + packages/cli/src/test-utils/render.tsx | 1 + .../cli/src/ui/components/Composer.test.tsx | 2 +- .../src/ui/components/InputPrompt.test.tsx | 20 +- .../cli/src/ui/components/InputPrompt.tsx | 11 +- .../ui/components/RewindConfirmation.test.tsx | 91 +++++ .../src/ui/components/RewindConfirmation.tsx | 156 +++++++++ .../src/ui/components/RewindViewer.test.tsx | 330 ++++++++++++++++++ .../cli/src/ui/components/RewindViewer.tsx | 211 +++++++++++ .../cli/src/ui/components/StatusDisplay.tsx | 2 +- .../RewindConfirmation.test.tsx.snap | 53 +++ .../__snapshots__/RewindViewer.test.tsx.snap | 265 ++++++++++++++ .../__snapshots__/StatusDisplay.test.tsx.snap | 2 +- packages/cli/src/ui/utils/formatters.test.ts | 98 +++++- packages/cli/src/ui/utils/formatters.ts | 34 ++ 19 files changed, 1318 insertions(+), 27 deletions(-) create mode 100644 docs/cli/rewind.md create mode 100644 packages/cli/src/ui/components/RewindConfirmation.test.tsx create mode 100644 packages/cli/src/ui/components/RewindConfirmation.tsx create mode 100644 packages/cli/src/ui/components/RewindViewer.test.tsx create mode 100644 packages/cli/src/ui/components/RewindViewer.tsx create mode 100644 packages/cli/src/ui/components/__snapshots__/RewindConfirmation.test.tsx.snap create mode 100644 packages/cli/src/ui/components/__snapshots__/RewindViewer.test.tsx.snap diff --git a/docs/cli/commands.md b/docs/cli/commands.md index da29410533..fb5da33133 100644 --- a/docs/cli/commands.md +++ b/docs/cli/commands.md @@ -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 diff --git a/docs/cli/keyboard-shortcuts.md b/docs/cli/keyboard-shortcuts.md index 831f74da80..e56b508d68 100644 --- a/docs/cli/keyboard-shortcuts.md +++ b/docs/cli/keyboard-shortcuts.md @@ -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 diff --git a/docs/cli/rewind.md b/docs/cli/rewind.md new file mode 100644 index 0000000000..e0e0cf15d7 --- /dev/null +++ b/docs/cli/rewind.md @@ -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. diff --git a/docs/sidebar.json b/docs/sidebar.json index a65bade052..d103ee4692 100644 --- a/docs/sidebar.json +++ b/docs/sidebar.json @@ -80,6 +80,10 @@ "label": "Model selection", "slug": "docs/cli/model" }, + { + "label": "Rewind", + "slug": "docs/cli/rewind" + }, { "label": "Sandbox", "slug": "docs/cli/sandbox" diff --git a/packages/cli/src/config/keyBindings.ts b/packages/cli/src/config/keyBindings.ts index 4e0daf5ae2..915128487a 100644 --- a/packages/cli/src/config/keyBindings.ts +++ b/packages/cli/src/config/keyBindings.ts @@ -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> = { [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.', }; diff --git a/packages/cli/src/test-utils/render.tsx b/packages/cli/src/test-utils/render.tsx index 3f77acd7a7..083b636a2f 100644 --- a/packages/cli/src/test-utils/render.tsx +++ b/packages/cli/src/test-utils/render.tsx @@ -133,6 +133,7 @@ const baseMockUiState = { streamingState: StreamingState.Idle, mainAreaWidth: 100, terminalWidth: 120, + terminalHeight: 40, currentModel: 'gemini-pro', terminalBackgroundColor: undefined, }; diff --git a/packages/cli/src/ui/components/Composer.test.tsx b/packages/cli/src/ui/components/Composer.test.tsx index e5e6e02830..c39d7c5ece 100644 --- a/packages/cli/src/ui/components/Composer.test.tsx +++ b/packages/cli/src/ui/components/Composer.test.tsx @@ -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'); }); }); diff --git a/packages/cli/src/ui/components/InputPrompt.test.tsx b/packages/cli/src/ui/components/InputPrompt.test.tsx index 68b044071d..b9a3d2622d 100644 --- a/packages/cli/src/ui/components/InputPrompt.test.tsx +++ b/packages/cli/src/ui/components/InputPrompt.test.tsx @@ -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( , @@ -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(); }); diff --git a/packages/cli/src/ui/components/InputPrompt.tsx b/packages/cli/src/ui/components/InputPrompt.tsx index 20d84ca650..762fc84b06 100644 --- a/packages/cli/src/ui/components/InputPrompt.tsx +++ b/packages/cli/src/ui/components/InputPrompt.tsx @@ -495,11 +495,8 @@ export const InputPrompt: React.FC = ({ 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 = ({ 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 = ({ kittyProtocol.enabled, tryLoadQueuedMessages, setBannerVisible, + onSubmit, activePtyId, setEmbeddedShellFocused, ], diff --git a/packages/cli/src/ui/components/RewindConfirmation.test.tsx b/packages/cli/src/ui/components/RewindConfirmation.test.tsx new file mode 100644 index 0000000000..5245a05490 --- /dev/null +++ b/packages/cli/src/ui/components/RewindConfirmation.test.tsx @@ -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( + , + { width: 80 }, + ); + + expect(lastFrame()).toMatchSnapshot(); + expect(lastFrame()).toContain('Revert code changes'); + }); + + it('renders correctly without stats', () => { + const onConfirm = vi.fn(); + const { lastFrame } = renderWithProviders( + , + { 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( + , + { 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( + , + { width: 80 }, + ); + + expect(lastFrame()).toMatchSnapshot(); + expect(lastFrame()).not.toContain('Revert code changes'); + }); +}); diff --git a/packages/cli/src/ui/components/RewindConfirmation.tsx b/packages/cli/src/ui/components/RewindConfirmation.tsx new file mode 100644 index 0000000000..5b9f4d8253 --- /dev/null +++ b/packages/cli/src/ui/components/RewindConfirmation.tsx @@ -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> = [ + { + 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 = ({ + 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 ( + + + Confirm Rewind + + + {stats && ( + + + {stats.fileCount === 1 + ? `File: ${stats.details?.at(0)?.fileName}` + : `${stats.fileCount} files affected`} + + + + Lines added: {stats.addedLines}{' '} + + + Lines removed: {stats.removedLines} + + {timestamp && ( + + {' '} + ({formatTimeAgo(timestamp)}) + + )} + + + + ℹ Rewinding does not affect files edited manually or by the shell + tool. + + + + )} + + {!stats && ( + + No code changes to revert. + {timestamp && ( + + {' '} + ({formatTimeAgo(timestamp)}) + + )} + + )} + + + Select an action: + + + + + ); +}; diff --git a/packages/cli/src/ui/components/RewindViewer.test.tsx b/packages/cli/src/ui/components/RewindViewer.test.tsx new file mode 100644 index 0000000000..649fbb4f4b --- /dev/null +++ b/packages/cli/src/ui/components/RewindViewer.test.tsx @@ -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(); + return { + ...original, + formatTimeAgo: () => 'some time ago', + }; +}); + +vi.mock('@google/gemini-cli-core', async (importOriginal) => { + const original = + await importOriginal(); + + 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( + , + ); + 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( + , + ); + + // 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( + , + ); + + 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( + , + ); + + // 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( + , + ); + + // 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( + , + ); + + 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( + , + ); + + 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( + , + ); + + expect(lastFrame2()).toMatchSnapshot('after-update'); + }); +}); diff --git a/packages/cli/src/ui/components/RewindViewer.tsx b/packages/cli/src/ui/components/RewindViewer.tsx new file mode 100644 index 0000000000..f33b3786f5 --- /dev/null +++ b/packages/cli/src/ui/components/RewindViewer.tsx @@ -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 = ({ + 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 ( + { + 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 ( + + + {'> '}Rewind + + + + { + 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 ( + + + + {cleanedText.split('\n').map((line, i) => ( + + + {line} + + + ))} + + + {stats ? ( + + + {stats.fileCount === 1 + ? firstFileName + ? firstFileName + : '1 file changed' + : `${stats.fileCount} files changed`}{' '} + + {stats.addedLines > 0 && ( + +{stats.addedLines} + )} + {stats.removedLines > 0 && ( + -{stats.removedLines} + )} + + ) : ( + + No files have been changed + + )} + + ); + }} + /> + + + + + (Use Enter to select a message, Esc to close) + + + + ); +}; diff --git a/packages/cli/src/ui/components/StatusDisplay.tsx b/packages/cli/src/ui/components/StatusDisplay.tsx index 40925fa5ba..96d2868830 100644 --- a/packages/cli/src/ui/components/StatusDisplay.tsx +++ b/packages/cli/src/ui/components/StatusDisplay.tsx @@ -45,7 +45,7 @@ export const StatusDisplay: React.FC = ({ } if (uiState.showEscapePrompt) { - return Press Esc again to clear.; + return Press Esc again to rewind.; } if (uiState.queueErrorMessage) { diff --git a/packages/cli/src/ui/components/__snapshots__/RewindConfirmation.test.tsx.snap b/packages/cli/src/ui/components/__snapshots__/RewindConfirmation.test.tsx.snap new file mode 100644 index 0000000000..643f2aaaeb --- /dev/null +++ b/packages/cli/src/ui/components/__snapshots__/RewindConfirmation.test.tsx.snap @@ -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) │ +│ │ +╰──────────────────────────────────────────────────────────────────────────────╯" +`; diff --git a/packages/cli/src/ui/components/__snapshots__/RewindViewer.test.tsx.snap b/packages/cli/src/ui/components/__snapshots__/RewindViewer.test.tsx.snap new file mode 100644 index 0000000000..7db1c1c507 --- /dev/null +++ b/packages/cli/src/ui/components/__snapshots__/RewindViewer.test.tsx.snap @@ -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) │ +│ │ +╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯" +`; diff --git a/packages/cli/src/ui/components/__snapshots__/StatusDisplay.test.tsx.snap b/packages/cli/src/ui/components/__snapshots__/StatusDisplay.test.tsx.snap index ee2c68fbd5..521f642a9a 100644 --- a/packages/cli/src/ui/components/__snapshots__/StatusDisplay.test.tsx.snap +++ b/packages/cli/src/ui/components/__snapshots__/StatusDisplay.test.tsx.snap @@ -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"`; diff --git a/packages/cli/src/ui/utils/formatters.test.ts b/packages/cli/src/ui/utils/formatters.test.ts index cb3d132421..48c0a2c605 100644 --- a/packages/cli/src/ui/utils/formatters.test.ts +++ b/packages/cli/src/ui/utils/formatters.test.ts @@ -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'); + }); + }); }); diff --git a/packages/cli/src/ui/utils/formatters.ts b/packages/cli/src/ui/utils/formatters.ts index 2b6af54598..6552f6c4f7 100644 --- a/packages/cli/src/ui/utils/formatters.ts +++ b/packages/cli/src/ui/utils/formatters.ts @@ -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(); +}