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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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