mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-05-14 05:42:54 -07: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
|
- **Note:** Only available if checkpointing is configured via
|
||||||
[settings](../get-started/configuration.md). See
|
[settings](../get-started/configuration.md). See
|
||||||
[Checkpointing documentation](../cli/checkpointing.md) for more details.
|
[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`**
|
- **`/resume`**
|
||||||
- **Description:** Browse and resume previous conversation sessions. Opens an
|
- **Description:** Browse and resume previous conversation sessions. Opens an
|
||||||
interactive session browser where you can search, filter, and select from
|
interactive session browser where you can search, filter, and select from
|
||||||
|
|||||||
@@ -64,6 +64,7 @@ available combinations.
|
|||||||
| Start reverse search through history. | `Ctrl + R` |
|
| Start reverse search through history. | `Ctrl + R` |
|
||||||
| Submit the selected reverse-search match. | `Enter (no Ctrl)` |
|
| Submit the selected reverse-search match. | `Enter (no Ctrl)` |
|
||||||
| Accept a suggestion while reverse searching. | `Tab` |
|
| Accept a suggestion while reverse searching. | `Tab` |
|
||||||
|
| Browse and rewind previous interactions. | `Esc (×2)` |
|
||||||
|
|
||||||
#### Navigation
|
#### Navigation
|
||||||
|
|
||||||
@@ -129,7 +130,7 @@ available combinations.
|
|||||||
- `!` on an empty prompt: Enter or exit shell mode.
|
- `!` on an empty prompt: Enter or exit shell mode.
|
||||||
- `\` (at end of a line) + `Enter`: Insert a newline without leaving single-line
|
- `\` (at end of a line) + `Enter`: Insert a newline without leaving single-line
|
||||||
mode.
|
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
|
- `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.
|
single-line input, navigate backward or forward through prompt history.
|
||||||
- `Number keys (1-9, multi-digit)` inside selection dialogs: Jump directly to
|
- `Number keys (1-9, multi-digit)` inside selection dialogs: Jump directly to
|
||||||
|
|||||||
@@ -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",
|
"label": "Model selection",
|
||||||
"slug": "docs/cli/model"
|
"slug": "docs/cli/model"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"label": "Rewind",
|
||||||
|
"slug": "docs/cli/rewind"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"label": "Sandbox",
|
"label": "Sandbox",
|
||||||
"slug": "docs/cli/sandbox"
|
"slug": "docs/cli/sandbox"
|
||||||
|
|||||||
@@ -76,6 +76,7 @@ export enum Command {
|
|||||||
QUIT = 'quit',
|
QUIT = 'quit',
|
||||||
EXIT = 'exit',
|
EXIT = 'exit',
|
||||||
SHOW_MORE_LINES = 'showMoreLines',
|
SHOW_MORE_LINES = 'showMoreLines',
|
||||||
|
REWIND = 'rewind',
|
||||||
|
|
||||||
// Shell commands
|
// Shell commands
|
||||||
REVERSE_SEARCH = 'reverseSearch',
|
REVERSE_SEARCH = 'reverseSearch',
|
||||||
@@ -264,6 +265,7 @@ export const defaultKeyBindings: KeyBindingConfig = {
|
|||||||
// Suggestion expansion
|
// Suggestion expansion
|
||||||
[Command.EXPAND_SUGGESTION]: [{ key: 'right' }],
|
[Command.EXPAND_SUGGESTION]: [{ key: 'right' }],
|
||||||
[Command.COLLAPSE_SUGGESTION]: [{ key: 'left' }],
|
[Command.COLLAPSE_SUGGESTION]: [{ key: 'left' }],
|
||||||
|
[Command.REWIND]: [{ key: 'Esc (×2)' }],
|
||||||
};
|
};
|
||||||
|
|
||||||
interface CommandCategory {
|
interface CommandCategory {
|
||||||
@@ -327,6 +329,7 @@ export const commandCategories: readonly CommandCategory[] = [
|
|||||||
Command.REVERSE_SEARCH,
|
Command.REVERSE_SEARCH,
|
||||||
Command.SUBMIT_REVERSE_SEARCH,
|
Command.SUBMIT_REVERSE_SEARCH,
|
||||||
Command.ACCEPT_SUGGESTION_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.UNFOCUS_SHELL_INPUT]: 'Focus the Gemini input from the shell input.',
|
||||||
[Command.EXPAND_SUGGESTION]: 'Expand an inline suggestion.',
|
[Command.EXPAND_SUGGESTION]: 'Expand an inline suggestion.',
|
||||||
[Command.COLLAPSE_SUGGESTION]: 'Collapse 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,
|
streamingState: StreamingState.Idle,
|
||||||
mainAreaWidth: 100,
|
mainAreaWidth: 100,
|
||||||
terminalWidth: 120,
|
terminalWidth: 120,
|
||||||
|
terminalHeight: 40,
|
||||||
currentModel: 'gemini-pro',
|
currentModel: 'gemini-pro',
|
||||||
terminalBackgroundColor: undefined,
|
terminalBackgroundColor: undefined,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -385,7 +385,7 @@ describe('Composer', () => {
|
|||||||
|
|
||||||
const { lastFrame } = renderComposer(uiState);
|
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());
|
beforeEach(() => vi.useFakeTimers());
|
||||||
afterEach(() => vi.useRealTimers());
|
afterEach(() => vi.useRealTimers());
|
||||||
|
|
||||||
it('should clear buffer on second ESC press', async () => {
|
it('should clear buffer on Ctrl-C', async () => {
|
||||||
const onEscapePromptChange = vi.fn();
|
const onEscapePromptChange = vi.fn();
|
||||||
props.onEscapePromptChange = onEscapePromptChange;
|
props.onEscapePromptChange = onEscapePromptChange;
|
||||||
props.buffer.setText('text to clear');
|
props.buffer.setText('text to clear');
|
||||||
@@ -1884,14 +1884,7 @@ describe('InputPrompt', () => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
stdin.write('\x1B');
|
stdin.write('\x03');
|
||||||
vi.advanceTimersByTime(100);
|
|
||||||
|
|
||||||
expect(onEscapePromptChange).toHaveBeenCalledWith(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
await act(async () => {
|
|
||||||
stdin.write('\x1B');
|
|
||||||
vi.advanceTimersByTime(100);
|
vi.advanceTimersByTime(100);
|
||||||
|
|
||||||
expect(props.buffer.setText).toHaveBeenCalledWith('');
|
expect(props.buffer.setText).toHaveBeenCalledWith('');
|
||||||
@@ -1900,10 +1893,10 @@ describe('InputPrompt', () => {
|
|||||||
unmount();
|
unmount();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should clear buffer on double ESC', async () => {
|
it('should submit /rewind on double ESC', async () => {
|
||||||
const onEscapePromptChange = vi.fn();
|
const onEscapePromptChange = vi.fn();
|
||||||
props.onEscapePromptChange = onEscapePromptChange;
|
props.onEscapePromptChange = onEscapePromptChange;
|
||||||
props.buffer.setText('text to clear');
|
props.buffer.setText('some text');
|
||||||
|
|
||||||
const { stdin, unmount } = renderWithProviders(
|
const { stdin, unmount } = renderWithProviders(
|
||||||
<InputPrompt {...props} />,
|
<InputPrompt {...props} />,
|
||||||
@@ -1913,8 +1906,7 @@ describe('InputPrompt', () => {
|
|||||||
stdin.write('\x1B\x1B');
|
stdin.write('\x1B\x1B');
|
||||||
vi.advanceTimersByTime(100);
|
vi.advanceTimersByTime(100);
|
||||||
|
|
||||||
expect(props.buffer.setText).toHaveBeenCalledWith('');
|
expect(props.onSubmit).toHaveBeenCalledWith('/rewind');
|
||||||
expect(mockCommandCompletion.resetCompletionState).toHaveBeenCalled();
|
|
||||||
});
|
});
|
||||||
unmount();
|
unmount();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -495,11 +495,8 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle double ESC for clearing input
|
// Handle double ESC for rewind
|
||||||
if (escPressCount.current === 0) {
|
if (escPressCount.current === 0) {
|
||||||
if (buffer.text === '') {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
escPressCount.current = 1;
|
escPressCount.current = 1;
|
||||||
setShowEscapePrompt(true);
|
setShowEscapePrompt(true);
|
||||||
if (escapeTimerRef.current) {
|
if (escapeTimerRef.current) {
|
||||||
@@ -509,10 +506,9 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
|||||||
resetEscapeState();
|
resetEscapeState();
|
||||||
}, 500);
|
}, 500);
|
||||||
} else {
|
} else {
|
||||||
// clear input and immediately reset state
|
// Second ESC triggers rewind
|
||||||
buffer.setText('');
|
|
||||||
resetCompletionState();
|
|
||||||
resetEscapeState();
|
resetEscapeState();
|
||||||
|
onSubmit('/rewind');
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -881,6 +877,7 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
|||||||
kittyProtocol.enabled,
|
kittyProtocol.enabled,
|
||||||
tryLoadQueuedMessages,
|
tryLoadQueuedMessages,
|
||||||
setBannerVisible,
|
setBannerVisible,
|
||||||
|
onSubmit,
|
||||||
activePtyId,
|
activePtyId,
|
||||||
setEmbeddedShellFocused,
|
setEmbeddedShellFocused,
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -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');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -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');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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) {
|
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) {
|
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 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"`;
|
exports[`StatusDisplay > renders HookStatusDisplay when hooks are active 1`] = `"Mock Hook Status Display"`;
|
||||||
|
|
||||||
|
|||||||
@@ -4,8 +4,13 @@
|
|||||||
* SPDX-License-Identifier: Apache-2.0
|
* SPDX-License-Identifier: Apache-2.0
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { describe, it, expect } from 'vitest';
|
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||||
import { formatDuration, formatMemoryUsage } from './formatters.js';
|
import {
|
||||||
|
formatDuration,
|
||||||
|
formatMemoryUsage,
|
||||||
|
formatTimeAgo,
|
||||||
|
stripReferenceContent,
|
||||||
|
} from './formatters.js';
|
||||||
|
|
||||||
describe('formatters', () => {
|
describe('formatters', () => {
|
||||||
describe('formatMemoryUsage', () => {
|
describe('formatMemoryUsage', () => {
|
||||||
@@ -69,4 +74,93 @@ describe('formatters', () => {
|
|||||||
expect(formatDuration(-100)).toBe('0s');
|
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(' ');
|
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