mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-02-01 22:48:03 +00:00
feat: add Rewind Confirmation dialog and Rewind Viewer component (#15717)
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
51
docs/cli/rewind.md
Normal 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.
|
||||
@@ -80,6 +80,10 @@
|
||||
"label": "Model selection",
|
||||
"slug": "docs/cli/model"
|
||||
},
|
||||
{
|
||||
"label": "Rewind",
|
||||
"slug": "docs/cli/rewind"
|
||||
},
|
||||
{
|
||||
"label": "Sandbox",
|
||||
"slug": "docs/cli/sandbox"
|
||||
|
||||
@@ -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.',
|
||||
};
|
||||
|
||||
@@ -133,6 +133,7 @@ const baseMockUiState = {
|
||||
streamingState: StreamingState.Idle,
|
||||
mainAreaWidth: 100,
|
||||
terminalWidth: 120,
|
||||
terminalHeight: 40,
|
||||
currentModel: 'gemini-pro',
|
||||
terminalBackgroundColor: undefined,
|
||||
};
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
],
|
||||
|
||||
91
packages/cli/src/ui/components/RewindConfirmation.test.tsx
Normal file
91
packages/cli/src/ui/components/RewindConfirmation.test.tsx
Normal 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');
|
||||
});
|
||||
});
|
||||
156
packages/cli/src/ui/components/RewindConfirmation.tsx
Normal file
156
packages/cli/src/ui/components/RewindConfirmation.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
330
packages/cli/src/ui/components/RewindViewer.test.tsx
Normal file
330
packages/cli/src/ui/components/RewindViewer.test.tsx
Normal 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');
|
||||
});
|
||||
});
|
||||
211
packages/cli/src/ui/components/RewindViewer.tsx
Normal file
211
packages/cli/src/ui/components/RewindViewer.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
@@ -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) {
|
||||
|
||||
@@ -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) │
|
||||
│ │
|
||||
╰──────────────────────────────────────────────────────────────────────────────╯"
|
||||
`;
|
||||
@@ -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) │
|
||||
│ │
|
||||
╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯"
|
||||
`;
|
||||
@@ -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"`;
|
||||
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user