feat: implement /rewind command (#15720)

This commit is contained in:
Adib234
2026-01-22 10:26:52 -05:00
committed by GitHub
parent ff9c77925e
commit 3b9f580fa4
26 changed files with 931 additions and 145 deletions

View File

@@ -62,6 +62,7 @@ available combinations.
| Start reverse search through history. | `Ctrl + R` |
| Submit the selected reverse-search match. | `Enter (no Ctrl)` |
| Accept a suggestion while reverse searching. | `Tab` |
| Browse and rewind previous interactions. | `Double Esc` |
#### Navigation

View File

@@ -49,6 +49,7 @@ export enum Command {
REVERSE_SEARCH = 'history.search.start',
SUBMIT_REVERSE_SEARCH = 'history.search.submit',
ACCEPT_SUGGESTION_REVERSE_SEARCH = 'history.search.accept',
REWIND = 'history.rewind',
// Navigation
NAVIGATION_UP = 'nav.up',
@@ -188,6 +189,7 @@ export const defaultKeyBindings: KeyBindingConfig = {
[Command.HISTORY_UP]: [{ key: 'p', shift: false, ctrl: true }],
[Command.HISTORY_DOWN]: [{ key: 'n', shift: false, ctrl: true }],
[Command.REVERSE_SEARCH]: [{ key: 'r', ctrl: true }],
[Command.REWIND]: [{ key: 'double escape' }],
[Command.SUBMIT_REVERSE_SEARCH]: [{ key: 'return', ctrl: false }],
[Command.ACCEPT_SUGGESTION_REVERSE_SEARCH]: [{ key: 'tab' }],
@@ -317,6 +319,7 @@ export const commandCategories: readonly CommandCategory[] = [
Command.REVERSE_SEARCH,
Command.SUBMIT_REVERSE_SEARCH,
Command.ACCEPT_SUGGESTION_REVERSE_SEARCH,
Command.REWIND,
],
},
{
@@ -413,6 +416,7 @@ export const commandDescriptions: Readonly<Record<Command, string>> = {
[Command.SUBMIT_REVERSE_SEARCH]: 'Submit the selected reverse-search match.',
[Command.ACCEPT_SUGGESTION_REVERSE_SEARCH]:
'Accept a suggestion while reverse searching.',
[Command.REWIND]: 'Browse and rewind previous interactions.',
// Navigation
[Command.NAVIGATION_UP]: 'Move selection up in lists.',

View File

@@ -27,6 +27,7 @@ import { directoryCommand } from '../ui/commands/directoryCommand.js';
import { editorCommand } from '../ui/commands/editorCommand.js';
import { extensionsCommand } from '../ui/commands/extensionsCommand.js';
import { helpCommand } from '../ui/commands/helpCommand.js';
import { rewindCommand } from '../ui/commands/rewindCommand.js';
import { hooksCommand } from '../ui/commands/hooksCommand.js';
import { ideCommand } from '../ui/commands/ideCommand.js';
import { initCommand } from '../ui/commands/initCommand.js';
@@ -106,6 +107,7 @@ export class BuiltinCommandLoader implements ICommandLoader {
: [extensionsCommand(this.config?.getEnableExtensionReloading())]),
helpCommand,
...(this.config?.getEnableHooksUI() ? [hooksCommand] : []),
rewindCommand,
await ideCommand(),
initCommand,
...(this.config?.getMcpEnabled() === false

View File

@@ -692,6 +692,7 @@ Logging in with Google... Restarting Gemini CLI to continue.
toggleDebugProfiler,
dispatchExtensionStateUpdate,
addConfirmUpdateExtensionRequest,
setText: (text: string) => buffer.setText(text),
}),
[
setAuthState,
@@ -708,6 +709,7 @@ Logging in with Google... Restarting Gemini CLI to continue.
openPermissionsDialog,
addConfirmUpdateExtensionRequest,
toggleDebugProfiler,
buffer,
],
);

View File

@@ -0,0 +1,351 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { rewindCommand } from './rewindCommand.js';
import { createMockCommandContext } from '../../test-utils/mockCommandContext.js';
import { waitFor } from '../../test-utils/async.js';
import { RewindOutcome } from '../components/RewindConfirmation.js';
import {
type OpenCustomDialogActionReturn,
type CommandContext,
} from './types.js';
import type { ReactElement } from 'react';
import { coreEvents } from '@google/gemini-cli-core';
// Mock dependencies
const mockRewindTo = vi.fn();
const mockRecordMessage = vi.fn();
const mockSetHistory = vi.fn();
const mockSendMessageStream = vi.fn();
const mockGetChatRecordingService = vi.fn();
const mockGetConversation = vi.fn();
const mockRemoveComponent = vi.fn();
const mockLoadHistory = vi.fn();
const mockAddItem = vi.fn();
const mockSetPendingItem = vi.fn();
const mockResetContext = vi.fn();
const mockSetInput = vi.fn();
const mockRevertFileChanges = vi.fn();
const mockGetProjectRoot = vi.fn().mockReturnValue('/mock/root');
vi.mock('@google/gemini-cli-core', async (importOriginal) => {
const actual =
await importOriginal<typeof import('@google/gemini-cli-core')>();
return {
...actual,
coreEvents: {
...actual.coreEvents,
emitFeedback: vi.fn(),
},
};
});
vi.mock('../components/RewindViewer.js', () => ({
RewindViewer: () => null,
}));
vi.mock('../hooks/useSessionBrowser.js', () => ({
convertSessionToHistoryFormats: vi.fn().mockReturnValue({
uiHistory: [
{ type: 'user', text: 'old user' },
{ type: 'gemini', text: 'old gemini' },
],
clientHistory: [{ role: 'user', parts: [{ text: 'old user' }] }],
}),
}));
vi.mock('../utils/rewindFileOps.js', () => ({
revertFileChanges: (...args: unknown[]) => mockRevertFileChanges(...args),
}));
interface RewindViewerProps {
onRewind: (
messageId: string,
newText: string,
outcome: RewindOutcome,
) => Promise<void>;
conversation: unknown;
onExit: () => void;
}
describe('rewindCommand', () => {
let mockContext: CommandContext;
beforeEach(() => {
vi.clearAllMocks();
mockGetConversation.mockReturnValue({
messages: [{ id: 'msg-1', type: 'user', content: 'hello' }],
sessionId: 'test-session',
});
mockRewindTo.mockReturnValue({
messages: [], // Mocked rewound messages
});
mockGetChatRecordingService.mockReturnValue({
getConversation: mockGetConversation,
rewindTo: mockRewindTo,
recordMessage: mockRecordMessage,
});
mockContext = createMockCommandContext({
services: {
config: {
getGeminiClient: () => ({
getChatRecordingService: mockGetChatRecordingService,
setHistory: mockSetHistory,
sendMessageStream: mockSendMessageStream,
}),
getSessionId: () => 'test-session-id',
getContextManager: () => ({ refresh: mockResetContext }),
getProjectRoot: mockGetProjectRoot,
},
},
ui: {
removeComponent: mockRemoveComponent,
loadHistory: mockLoadHistory,
addItem: mockAddItem,
setPendingItem: mockSetPendingItem,
},
}) as unknown as CommandContext;
});
it('should initialize successfully', async () => {
const result = await rewindCommand.action!(mockContext, '');
expect(result).toHaveProperty('type', 'custom_dialog');
});
it('should handle RewindOnly correctly', async () => {
// 1. Run the command to get the component
const result = (await rewindCommand.action!(
mockContext,
'',
)) as OpenCustomDialogActionReturn;
const component = result.component as ReactElement<RewindViewerProps>;
// Access onRewind from props
const onRewind = component.props.onRewind;
expect(onRewind).toBeDefined();
await onRewind('msg-id-123', 'New Prompt', RewindOutcome.RewindOnly);
await waitFor(() => {
expect(mockRevertFileChanges).not.toHaveBeenCalled();
expect(mockRewindTo).toHaveBeenCalledWith('msg-id-123');
expect(mockSetHistory).toHaveBeenCalled();
expect(mockResetContext).toHaveBeenCalled();
expect(mockLoadHistory).toHaveBeenCalledWith(
[
expect.objectContaining({ text: 'old user', id: 1 }),
expect.objectContaining({ text: 'old gemini', id: 2 }),
],
'New Prompt',
);
expect(mockRemoveComponent).toHaveBeenCalled();
});
// Verify setInput was NOT called directly (it's handled via loadHistory now)
expect(mockSetInput).not.toHaveBeenCalled();
});
it('should handle RewindAndRevert correctly', async () => {
const result = (await rewindCommand.action!(
mockContext,
'',
)) as OpenCustomDialogActionReturn;
const component = result.component as ReactElement<RewindViewerProps>;
const onRewind = component.props.onRewind;
await onRewind('msg-id-123', 'New Prompt', RewindOutcome.RewindAndRevert);
await waitFor(() => {
expect(mockRevertFileChanges).toHaveBeenCalledWith(
mockGetConversation(),
'msg-id-123',
);
expect(mockRewindTo).toHaveBeenCalledWith('msg-id-123');
expect(mockLoadHistory).toHaveBeenCalledWith(
expect.any(Array),
'New Prompt',
);
});
expect(mockSetInput).not.toHaveBeenCalled();
});
it('should handle RevertOnly correctly', async () => {
const result = (await rewindCommand.action!(
mockContext,
'',
)) as OpenCustomDialogActionReturn;
const component = result.component as ReactElement<RewindViewerProps>;
const onRewind = component.props.onRewind;
await onRewind('msg-id-123', 'New Prompt', RewindOutcome.RevertOnly);
await waitFor(() => {
expect(mockRevertFileChanges).toHaveBeenCalledWith(
mockGetConversation(),
'msg-id-123',
);
expect(mockRewindTo).not.toHaveBeenCalled();
expect(mockRemoveComponent).toHaveBeenCalled();
expect(coreEvents.emitFeedback).toHaveBeenCalledWith(
'info',
'File changes reverted.',
);
});
expect(mockSetInput).not.toHaveBeenCalled();
});
it('should handle Cancel correctly', async () => {
const result = (await rewindCommand.action!(
mockContext,
'',
)) as OpenCustomDialogActionReturn;
const component = result.component as ReactElement<RewindViewerProps>;
const onRewind = component.props.onRewind;
await onRewind('msg-id-123', 'New Prompt', RewindOutcome.Cancel);
await waitFor(() => {
expect(mockRevertFileChanges).not.toHaveBeenCalled();
expect(mockRewindTo).not.toHaveBeenCalled();
expect(mockRemoveComponent).toHaveBeenCalled();
});
expect(mockSetInput).not.toHaveBeenCalled();
});
it('should handle onExit correctly', async () => {
const result = (await rewindCommand.action!(
mockContext,
'',
)) as OpenCustomDialogActionReturn;
const component = result.component as ReactElement<RewindViewerProps>;
const onExit = component.props.onExit;
onExit();
expect(mockRemoveComponent).toHaveBeenCalled();
});
it('should handle rewind error correctly', async () => {
const result = (await rewindCommand.action!(
mockContext,
'',
)) as OpenCustomDialogActionReturn;
const component = result.component as ReactElement<RewindViewerProps>;
const onRewind = component.props.onRewind;
mockRewindTo.mockImplementation(() => {
throw new Error('Rewind Failed');
});
await onRewind('msg-1', 'Prompt', RewindOutcome.RewindOnly);
await waitFor(() => {
expect(coreEvents.emitFeedback).toHaveBeenCalledWith(
'error',
'Rewind Failed',
);
});
});
it('should handle null conversation from rewindTo', async () => {
const result = (await rewindCommand.action!(
mockContext,
'',
)) as OpenCustomDialogActionReturn;
const component = result.component as ReactElement<RewindViewerProps>;
const onRewind = component.props.onRewind;
mockRewindTo.mockReturnValue(null);
await onRewind('msg-1', 'Prompt', RewindOutcome.RewindOnly);
await waitFor(() => {
expect(coreEvents.emitFeedback).toHaveBeenCalledWith(
'error',
'Could not fetch conversation file',
);
expect(mockRemoveComponent).toHaveBeenCalled();
});
});
it('should fail if config is missing', () => {
const context = { services: {} } as CommandContext;
const result = rewindCommand.action!(context, '');
expect(result).toEqual({
type: 'message',
messageType: 'error',
content: 'Config not found',
});
});
it('should fail if client is not initialized', () => {
const context = createMockCommandContext({
services: {
config: { getGeminiClient: () => undefined },
},
}) as unknown as CommandContext;
const result = rewindCommand.action!(context, '');
expect(result).toEqual({
type: 'message',
messageType: 'error',
content: 'Client not initialized',
});
});
it('should fail if recording service is unavailable', () => {
const context = createMockCommandContext({
services: {
config: {
getGeminiClient: () => ({ getChatRecordingService: () => undefined }),
},
},
}) as unknown as CommandContext;
const result = rewindCommand.action!(context, '');
expect(result).toEqual({
type: 'message',
messageType: 'error',
content: 'Recording service unavailable',
});
});
it('should return info if no conversation found', () => {
mockGetConversation.mockReturnValue(null);
const result = rewindCommand.action!(mockContext, '');
expect(result).toEqual({
type: 'message',
messageType: 'info',
content: 'No conversation found.',
});
});
it('should return info if no user interactions found', () => {
mockGetConversation.mockReturnValue({
messages: [{ id: 'msg-1', type: 'gemini', content: 'hello' }],
sessionId: 'test-session',
});
const result = rewindCommand.action!(mockContext, '');
expect(result).toEqual({
type: 'message',
messageType: 'info',
content: 'Nothing to rewind to.',
});
});
});

View File

@@ -0,0 +1,191 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import {
CommandKind,
type CommandContext,
type SlashCommand,
} from './types.js';
import { RewindViewer } from '../components/RewindViewer.js';
import { type HistoryItem } from '../types.js';
import { convertSessionToHistoryFormats } from '../hooks/useSessionBrowser.js';
import { revertFileChanges } from '../utils/rewindFileOps.js';
import { RewindOutcome } from '../components/RewindConfirmation.js';
import { checkExhaustive } from '../../utils/checks.js';
import type { Content } from '@google/genai';
import type {
ChatRecordingService,
GeminiClient,
} from '@google/gemini-cli-core';
import { coreEvents, debugLogger } from '@google/gemini-cli-core';
/**
* Helper function to handle the core logic of rewinding a conversation.
* This function encapsulates the steps needed to rewind the conversation,
* update the client and UI history, and clear the component.
*
* @param context The command context.
* @param client Gemini client
* @param recordingService The chat recording service.
* @param messageId The ID of the message to rewind to.
* @param newText The new text for the input field after rewinding.
*/
async function rewindConversation(
context: CommandContext,
client: GeminiClient,
recordingService: ChatRecordingService,
messageId: string,
newText: string,
) {
try {
const conversation = recordingService.rewindTo(messageId);
if (!conversation) {
const errorMsg = 'Could not fetch conversation file';
debugLogger.error(errorMsg);
context.ui.removeComponent();
coreEvents.emitFeedback('error', errorMsg);
return;
}
// Convert to UI and Client formats
const { uiHistory, clientHistory } = convertSessionToHistoryFormats(
conversation.messages,
);
client.setHistory(clientHistory as Content[]);
// Reset context manager as we are rewinding history
await context.services.config?.getContextManager()?.refresh();
// Update UI History
// We generate IDs based on index for the rewind history
const startId = 1;
const historyWithIds = uiHistory.map(
(item, idx) =>
({
...item,
id: startId + idx,
}) as HistoryItem,
);
// 1. Remove component FIRST to avoid flicker and clear the stage
context.ui.removeComponent();
// 2. Load the rewound history and set the input
context.ui.loadHistory(historyWithIds, newText);
} catch (error) {
// If an error occurs, we still want to remove the component if possible
context.ui.removeComponent();
coreEvents.emitFeedback(
'error',
error instanceof Error ? error.message : 'Unknown error during rewind',
);
}
}
export const rewindCommand: SlashCommand = {
name: 'rewind',
description: 'Jump back to a specific message and restart the conversation',
kind: CommandKind.BUILT_IN,
action: (context) => {
const config = context.services.config;
if (!config)
return {
type: 'message',
messageType: 'error',
content: 'Config not found',
};
const client = config.getGeminiClient();
if (!client)
return {
type: 'message',
messageType: 'error',
content: 'Client not initialized',
};
const recordingService = client.getChatRecordingService();
if (!recordingService)
return {
type: 'message',
messageType: 'error',
content: 'Recording service unavailable',
};
const conversation = recordingService.getConversation();
if (!conversation)
return {
type: 'message',
messageType: 'info',
content: 'No conversation found.',
};
const hasUserInteractions = conversation.messages.some(
(msg) => msg.type === 'user',
);
if (!hasUserInteractions) {
return {
type: 'message',
messageType: 'info',
content: 'Nothing to rewind to.',
};
}
return {
type: 'custom_dialog',
component: (
<RewindViewer
conversation={conversation}
onExit={() => {
context.ui.removeComponent();
}}
onRewind={async (messageId, newText, outcome) => {
switch (outcome) {
case RewindOutcome.Cancel:
context.ui.removeComponent();
return;
case RewindOutcome.RevertOnly:
if (conversation) {
await revertFileChanges(conversation, messageId);
}
context.ui.removeComponent();
coreEvents.emitFeedback('info', 'File changes reverted.');
return;
case RewindOutcome.RewindAndRevert:
if (conversation) {
await revertFileChanges(conversation, messageId);
}
await rewindConversation(
context,
client,
recordingService,
messageId,
newText,
);
return;
case RewindOutcome.RewindOnly:
await rewindConversation(
context,
client,
recordingService,
messageId,
newText,
);
return;
default:
checkExhaustive(outcome);
}
}}
/>
),
};
},
};

View File

@@ -66,8 +66,9 @@ export interface CommandContext {
* Loads a new set of history items, replacing the current history.
*
* @param history The array of history items to load.
* @param postLoadInput Optional text to set in the input buffer after loading history.
*/
loadHistory: UseHistoryManagerReturn['loadHistory'];
loadHistory: (history: HistoryItem[], postLoadInput?: string) => void;
/** Toggles a special display mode. */
toggleCorgiMode: () => void;
toggleDebugProfiler: () => void;

View File

@@ -2007,7 +2007,9 @@ describe('InputPrompt', () => {
await act(async () => {
stdin.write('\x1B\x1B');
vi.advanceTimersByTime(100);
});
await waitFor(() => {
expect(props.onSubmit).toHaveBeenCalledWith('/rewind');
});
unmount();

View File

@@ -30,7 +30,7 @@ import { useKeypress } from '../hooks/useKeypress.js';
import { keyMatchers, Command } from '../keyMatchers.js';
import type { CommandContext, SlashCommand } from '../commands/types.js';
import type { Config } from '@google/gemini-cli-core';
import { ApprovalMode, debugLogger } from '@google/gemini-cli-core';
import { ApprovalMode, coreEvents, debugLogger } from '@google/gemini-cli-core';
import {
parseInputForHighlighting,
parseSegmentsFromTokens,
@@ -516,18 +516,20 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
escapeTimerRef.current = setTimeout(() => {
resetEscapeState();
}, 500);
} else {
// Second ESC
resetEscapeState();
if (buffer.text.length > 0) {
buffer.setText('');
resetCompletionState();
} else {
if (history.length > 0) {
onSubmit('/rewind');
}
}
return;
}
// Second ESC
resetEscapeState();
if (buffer.text.length > 0) {
buffer.setText('');
resetCompletionState();
return;
} else if (history.length > 0) {
onSubmit('/rewind');
return;
}
coreEvents.emitFeedback('info', 'Nothing to rewind to');
return;
}

View File

@@ -252,17 +252,16 @@ describe('RewindViewer', () => {
it.each([
{
description: 'removes reference markers',
prompt:
'some command @file\n--- Content from referenced files ---\nContent from file:\nblah blah\n--- End of content ---',
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' +
`--- Content from referenced files ---\n` +
'\nContent from @server3:mcp://demo-resource:\n' +
'This is the content of the demo resource.\n' +
'--- End of content ---',
`--- End of content ---`,
},
])('$description', async ({ prompt }) => {
const conversation = createConversation([

View File

@@ -5,7 +5,7 @@
*/
import type React from 'react';
import { useMemo } from 'react';
import { useMemo, useState } from 'react';
import { Box, Text } from 'ink';
import { useUIState } from '../contexts/UIStateContext.js';
import {
@@ -19,8 +19,9 @@ 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';
import { CliSpinner } from './CliSpinner.js';
import { ExpandableText } from './shared/ExpandableText.js';
interface RewindViewerProps {
conversation: ConversationRecord;
@@ -29,7 +30,7 @@ interface RewindViewerProps {
messageId: string,
newText: string,
outcome: RewindOutcome,
) => void;
) => Promise<void>;
}
const MAX_LINES_PER_BOX = 2;
@@ -39,6 +40,7 @@ export const RewindViewer: React.FC<RewindViewerProps> = ({
onExit,
onRewind,
}) => {
const [isRewinding, setIsRewinding] = useState(false);
const { terminalWidth, terminalHeight } = useUIState();
const {
selectedMessageId,
@@ -48,28 +50,58 @@ export const RewindViewer: React.FC<RewindViewerProps> = ({
clearSelection,
} = useRewind(conversation);
const [highlightedMessageId, setHighlightedMessageId] = useState<
string | null
>(null);
const [expandedMessageId, setExpandedMessageId] = useState<string | null>(
null,
);
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],
);
const items = useMemo(() => {
const interactionItems = interactions.map((msg, idx) => ({
key: `${msg.id || 'msg'}-${idx}`,
value: msg,
index: idx,
}));
// Add "Current Position" as the last item
return [
...interactionItems,
{
key: 'current-position',
value: {
id: 'current-position',
type: 'user',
content: 'Stay at current position',
timestamp: new Date().toISOString(),
} as MessageRecord,
index: interactionItems.length,
},
];
}, [interactions]);
useKeypress(
(key) => {
if (!selectedMessageId) {
if (keyMatchers[Command.ESCAPE](key)) {
onExit();
return;
}
if (keyMatchers[Command.EXPAND_SUGGESTION](key)) {
if (
highlightedMessageId &&
highlightedMessageId !== 'current-position'
) {
setExpandedMessageId(highlightedMessageId);
}
}
if (keyMatchers[Command.COLLAPSE_SUGGESTION](key)) {
setExpandedMessageId(null);
}
}
},
@@ -89,6 +121,28 @@ export const RewindViewer: React.FC<RewindViewerProps> = ({
const maxItemsToShow = Math.max(1, Math.floor(listHeight / 4));
if (selectedMessageId) {
if (isRewinding) {
return (
<Box
borderStyle="round"
borderColor={theme.border.default}
padding={1}
width={terminalWidth}
flexDirection="row"
>
<Box>
<CliSpinner />
</Box>
<Text>Rewinding...</Text>
</Box>
);
}
if (selectedMessageId === 'current-position') {
onExit();
return null;
}
const selectedMessage = interactions.find(
(m) => m.id === selectedMessageId,
);
@@ -97,7 +151,7 @@ export const RewindViewer: React.FC<RewindViewerProps> = ({
stats={confirmationStats}
terminalWidth={terminalWidth}
timestamp={selectedMessage?.timestamp}
onConfirm={(outcome) => {
onConfirm={async (outcome) => {
if (outcome === RewindOutcome.Cancel) {
clearSelection();
} else {
@@ -109,7 +163,8 @@ export const RewindViewer: React.FC<RewindViewerProps> = ({
? partToString(userPrompt.content)
: '';
const cleanedText = stripReferenceContent(originalUserText);
onRewind(selectedMessageId, cleanedText, outcome);
setIsRewinding(true);
await onRewind(selectedMessageId, cleanedText, outcome);
}
}
}}
@@ -138,12 +193,41 @@ export const RewindViewer: React.FC<RewindViewerProps> = ({
onSelect={(item: MessageRecord) => {
const userPrompt = item;
if (userPrompt && userPrompt.id) {
selectMessage(userPrompt.id);
if (userPrompt.id === 'current-position') {
onExit();
} else {
selectMessage(userPrompt.id);
}
}
}}
onHighlight={(item: MessageRecord) => {
if (item.id) {
setHighlightedMessageId(item.id);
// Collapse when moving selection
setExpandedMessageId(null);
}
}}
maxItemsToShow={maxItemsToShow}
renderItem={(itemWrapper, { isSelected }) => {
const userPrompt = itemWrapper.value;
if (userPrompt.id === 'current-position') {
return (
<Box flexDirection="column" marginBottom={1}>
<Text
color={
isSelected ? theme.status.success : theme.text.primary
}
>
{partToString(userPrompt.content)}
</Text>
<Text color={theme.text.secondary}>
Cancel rewind and stay here
</Text>
</Box>
);
}
const stats = getStats(userPrompt);
const firstFileName = stats?.details?.at(0)?.fileName;
const originalUserText = userPrompt.content
@@ -154,25 +238,15 @@ export const RewindViewer: React.FC<RewindViewerProps> = ({
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>
<ExpandableText
label={cleanedText}
isExpanded={expandedMessageId === userPrompt.id}
textColor={
isSelected ? theme.status.success : theme.text.primary
}
maxWidth={(terminalWidth - 4) * MAX_LINES_PER_BOX}
maxLines={MAX_LINES_PER_BOX}
/>
</Box>
{stats ? (
<Box flexDirection="row">
@@ -203,7 +277,8 @@ export const RewindViewer: React.FC<RewindViewerProps> = ({
<Box marginTop={1}>
<Text color={theme.text.secondary}>
(Use Enter to select a message, Esc to close)
(Use Enter to select a message, Esc to close, Right/Left to
expand/collapse)
</Text>
</Box>
</Box>

View File

@@ -6,7 +6,7 @@
import { Box, Text } from 'ink';
import { theme } from '../semantic-colors.js';
import { PrepareLabel, MAX_WIDTH } from './PrepareLabel.js';
import { ExpandableText, MAX_WIDTH } from './shared/ExpandableText.js';
import { CommandKind } from '../commands/types.js';
import { Colors } from '../colors.js';
export interface Suggestion {
@@ -85,7 +85,7 @@ export function SuggestionsDisplay({
const textColor = isActive ? theme.text.accent : theme.text.secondary;
const isLong = suggestion.value.length >= MAX_WIDTH;
const labelElement = (
<PrepareLabel
<ExpandableText
label={suggestion.value}
matchedIndex={suggestion.matchedIndex}
userInput={userInput}

View File

@@ -8,8 +8,11 @@ exports[`RewindViewer > Content Filtering > 'removes reference markers' 1`] = `
│ ● some command @file │
│ No files have been changed │
│ │
│ Stay at current position │
│ Cancel rewind and stay here │
│ │
(Use Enter to select a message, Esc to close)
│ (Use Enter to select a message, Esc to close, Right/Left to expand/collapse) │
│ │
╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯"
`;
@@ -22,8 +25,11 @@ exports[`RewindViewer > Content Filtering > 'strips expanded MCP resource conten
│ ● read @server3:mcp://demo-resource hello │
│ No files have been changed │
│ │
│ Stay at current position │
│ Cancel rewind and stay here │
│ │
(Use Enter to select a message, Esc to close)
│ (Use Enter to select a message, Esc to close, Right/Left to expand/collapse) │
│ │
╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯"
`;
@@ -63,17 +69,20 @@ exports[`RewindViewer > Navigation > handles 'down' navigation > after-down 1`]
│ │
│ > Rewind │
│ │
│ Q3
│ Q1
│ No files have been changed │
│ │
│ ● Q2 │
│ No files have been changed │
│ │
│ Q1
│ Q3
│ No files have been changed │
│ │
│ Stay at current position │
│ Cancel rewind and stay here │
│ │
(Use Enter to select a message, Esc to close)
│ (Use Enter to select a message, Esc to close, Right/Left to expand/collapse) │
│ │
╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯"
`;
@@ -83,17 +92,20 @@ exports[`RewindViewer > Navigation > handles 'up' navigation > after-up 1`] = `
│ │
│ > Rewind │
│ │
│ Q3
│ Q1
│ No files have been changed │
│ │
│ Q2 │
│ No files have been changed │
│ │
Q1
Q3
│ No files have been changed │
│ │
│ ● Stay at current position │
│ Cancel rewind and stay here │
│ │
(Use Enter to select a message, Esc to close)
│ (Use Enter to select a message, Esc to close, Right/Left to expand/collapse) │
│ │
╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯"
`;
@@ -103,17 +115,20 @@ exports[`RewindViewer > Navigation > handles cyclic navigation > cyclic-down 1`]
│ │
│ > Rewind │
│ │
│ ● Q3
│ ● Q1
│ No files have been changed │
│ │
│ Q2 │
│ No files have been changed │
│ │
│ Q1
│ Q3
│ No files have been changed │
│ │
│ Stay at current position │
│ Cancel rewind and stay here │
│ │
(Use Enter to select a message, Esc to close)
│ (Use Enter to select a message, Esc to close, Right/Left to expand/collapse) │
│ │
╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯"
`;
@@ -123,17 +138,20 @@ exports[`RewindViewer > Navigation > handles cyclic navigation > cyclic-up 1`] =
│ │
│ > Rewind │
│ │
│ Q3
│ Q1
│ No files have been changed │
│ │
│ Q2 │
│ No files have been changed │
│ │
Q1
Q3
│ No files have been changed │
│ │
│ ● Stay at current position │
│ Cancel rewind and stay here │
│ │
(Use Enter to select a message, Esc to close)
│ (Use Enter to select a message, Esc to close, Right/Left to expand/collapse) │
│ │
╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯"
`;
@@ -146,8 +164,11 @@ exports[`RewindViewer > Rendering > renders 'a single interaction' 1`] = `
│ ● Hello │
│ No files have been changed │
│ │
│ Stay at current position │
│ Cancel rewind and stay here │
│ │
(Use Enter to select a message, Esc to close)
│ (Use Enter to select a message, Esc to close, Right/Left to expand/collapse) │
│ │
╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯"
`;
@@ -158,16 +179,14 @@ exports[`RewindViewer > Rendering > renders 'full text for selected item' 1`] =
│ > Rewind │
│ │
│ ● 1 │
│ 2
│ 3 │
│ 4 │
│ 5 │
│ 6 │
│ 7 │
│ 2...
│ No files have been changed │
│ │
│ Stay at current position │
│ Cancel rewind and stay here │
│ │
(Use Enter to select a message, Esc to close)
│ (Use Enter to select a message, Esc to close, Right/Left to expand/collapse) │
│ │
╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯"
`;
@@ -177,8 +196,11 @@ exports[`RewindViewer > Rendering > renders 'nothing interesting for empty conve
│ │
│ > Rewind │
│ │
│ ● Stay at current position │
│ Cancel rewind and stay here │
│ │
(Use Enter to select a message, Esc to close)
│ (Use Enter to select a message, Esc to close, Right/Left to expand/collapse) │
│ │
╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯"
`;
@@ -188,14 +210,17 @@ exports[`RewindViewer > updates content when conversation changes (background up
│ │
│ > Rewind │
│ │
│ ● Message 2
│ ● Message 1
│ No files have been changed │
│ │
│ Message 1
│ Message 2
│ No files have been changed │
│ │
│ Stay at current position │
│ Cancel rewind and stay here │
│ │
(Use Enter to select a message, Esc to close)
│ (Use Enter to select a message, Esc to close, Right/Left to expand/collapse) │
│ │
╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯"
`;
@@ -208,8 +233,11 @@ exports[`RewindViewer > updates content when conversation changes (background up
│ ● Message 1 │
│ No files have been changed │
│ │
│ Stay at current position │
│ Cancel rewind and stay here │
│ │
(Use Enter to select a message, Esc to close)
│ (Use Enter to select a message, Esc to close, Right/Left to expand/collapse) │
│ │
╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯"
`;
@@ -219,22 +247,19 @@ exports[`RewindViewer > updates selection and expansion on navigation > after-do
│ │
│ > Rewind │
│ │
│ Line 1
│ Line 2
│ ... last 5 lines hidden ... │
│ Line A
│ Line B...
│ No files have been changed │
│ │
│ ● Line A
│ Line B
│ Line C │
│ Line D │
│ Line E │
│ Line F │
│ Line G │
│ ● Line 1
│ Line 2...
│ No files have been changed │
│ │
│ Stay at current position │
│ Cancel rewind and stay here │
│ │
(Use Enter to select a message, Esc to close)
│ (Use Enter to select a message, Esc to close, Right/Left to expand/collapse) │
│ │
╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯"
`;
@@ -244,22 +269,19 @@ exports[`RewindViewer > updates selection and expansion on navigation > initial-
│ │
│ > Rewind │
│ │
│ ● Line 1
│ Line 2
│ Line 3 │
│ Line 4 │
│ Line 5 │
│ Line 6 │
│ Line 7 │
│ ● Line A
│ Line B...
│ No files have been changed │
│ │
│ Line A
│ Line B
│ ... last 5 lines hidden ... │
│ Line 1
│ Line 2...
│ No files have been changed │
│ │
│ Stay at current position │
│ Cancel rewind and stay here │
│ │
(Use Enter to select a message, Esc to close)
│ (Use Enter to select a message, Esc to close, Right/Left to expand/collapse) │
│ │
╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯"
`;

View File

@@ -1,20 +1,20 @@
/**
* @license
* Copyright 2025 Google LLC
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect } from 'vitest';
import { render } from '../../test-utils/render.js';
import { PrepareLabel, MAX_WIDTH } from './PrepareLabel.js';
import { render } from '../../../test-utils/render.js';
import { ExpandableText, MAX_WIDTH } from './ExpandableText.js';
describe('PrepareLabel', () => {
describe('ExpandableText', () => {
const color = 'white';
const flat = (s: string | undefined) => (s ?? '').replace(/\n/g, '');
it('renders plain label when no match (short label)', () => {
const { lastFrame, unmount } = render(
<PrepareLabel
<ExpandableText
label="simple command"
userInput=""
matchedIndex={undefined}
@@ -29,7 +29,7 @@ describe('PrepareLabel', () => {
it('truncates long label when collapsed and no match', () => {
const long = 'x'.repeat(MAX_WIDTH + 25);
const { lastFrame, unmount } = render(
<PrepareLabel
<ExpandableText
label={long}
userInput=""
textColor={color}
@@ -47,7 +47,7 @@ describe('PrepareLabel', () => {
it('shows full long label when expanded and no match', () => {
const long = 'y'.repeat(MAX_WIDTH + 25);
const { lastFrame, unmount } = render(
<PrepareLabel
<ExpandableText
label={long}
userInput=""
textColor={color}
@@ -66,7 +66,7 @@ describe('PrepareLabel', () => {
const userInput = 'commit';
const matchedIndex = label.indexOf(userInput);
const { lastFrame, unmount } = render(
<PrepareLabel
<ExpandableText
label={label}
userInput={userInput}
matchedIndex={matchedIndex}
@@ -86,7 +86,7 @@ describe('PrepareLabel', () => {
const label = prefix + core + suffix;
const matchedIndex = prefix.length;
const { lastFrame, unmount } = render(
<PrepareLabel
<ExpandableText
label={label}
userInput={core}
matchedIndex={matchedIndex}
@@ -111,7 +111,7 @@ describe('PrepareLabel', () => {
const label = prefix + core + suffix;
const matchedIndex = prefix.length;
const { lastFrame, unmount } = render(
<PrepareLabel
<ExpandableText
label={label}
userInput={core}
matchedIndex={matchedIndex}
@@ -128,4 +128,24 @@ describe('PrepareLabel', () => {
expect(out).toMatchSnapshot();
unmount();
});
it('respects custom maxWidth', () => {
const customWidth = 50;
const long = 'z'.repeat(100);
const { lastFrame, unmount } = render(
<ExpandableText
label={long}
userInput=""
textColor={color}
isExpanded={false}
maxWidth={customWidth}
/>,
);
const out = lastFrame();
const f = flat(out);
expect(f.endsWith('...')).toBe(true);
expect(f.length).toBe(customWidth + 3);
expect(out).toMatchSnapshot();
unmount();
});
});

View File

@@ -1,29 +1,33 @@
/**
* @license
* Copyright 2025 Google LLC
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import React from 'react';
import { Text } from 'ink';
import { theme } from '../semantic-colors.js';
import { theme } from '../../semantic-colors.js';
export const MAX_WIDTH = 150; // Maximum width for the text that is shown
export const MAX_WIDTH = 150;
export interface PrepareLabelProps {
export interface ExpandableTextProps {
label: string;
matchedIndex?: number;
userInput: string;
textColor: string;
userInput?: string;
textColor?: string;
isExpanded?: boolean;
maxWidth?: number;
maxLines?: number;
}
const _PrepareLabel: React.FC<PrepareLabelProps> = ({
const _ExpandableText: React.FC<ExpandableTextProps> = ({
label,
matchedIndex,
userInput,
textColor,
userInput = '',
textColor = theme.text.primary,
isExpanded = false,
maxWidth = MAX_WIDTH,
maxLines,
}) => {
const hasMatch =
matchedIndex !== undefined &&
@@ -33,11 +37,27 @@ const _PrepareLabel: React.FC<PrepareLabelProps> = ({
// Render the plain label if there's no match
if (!hasMatch) {
const display = isExpanded
? label
: label.length > MAX_WIDTH
? label.slice(0, MAX_WIDTH) + '...'
: label;
let display = label;
if (!isExpanded) {
if (maxLines !== undefined) {
const lines = label.split('\n');
// 1. Truncate by logical lines
let truncated = lines.slice(0, maxLines).join('\n');
const hasMoreLines = lines.length > maxLines;
// 2. Truncate by characters (visual approximation) to prevent massive wrapping
if (truncated.length > maxWidth) {
truncated = truncated.slice(0, maxWidth) + '...';
} else if (hasMoreLines) {
truncated += '...';
}
display = truncated;
} else if (label.length > maxWidth) {
display = label.slice(0, maxWidth) + '...';
}
}
return (
<Text wrap="wrap" color={textColor}>
{display}
@@ -51,18 +71,18 @@ const _PrepareLabel: React.FC<PrepareLabelProps> = ({
let after = '';
// Case 1: Show the full string if it's expanded or already fits
if (isExpanded || label.length <= MAX_WIDTH) {
if (isExpanded || label.length <= maxWidth) {
before = label.slice(0, matchedIndex);
match = label.slice(matchedIndex, matchedIndex + matchLength);
after = label.slice(matchedIndex + matchLength);
}
// Case 2: The match itself is too long, so we only show a truncated portion of the match
else if (matchLength >= MAX_WIDTH) {
match = label.slice(matchedIndex, matchedIndex + MAX_WIDTH - 1) + '...';
else if (matchLength >= maxWidth) {
match = label.slice(matchedIndex, matchedIndex + maxWidth - 1) + '...';
}
// Case 3: Truncate the string to create a window around the match
else {
const contextSpace = MAX_WIDTH - matchLength;
const contextSpace = maxWidth - matchLength;
const beforeSpace = Math.floor(contextSpace / 2);
const afterSpace = Math.ceil(contextSpace / 2);
@@ -113,4 +133,4 @@ const _PrepareLabel: React.FC<PrepareLabelProps> = ({
);
};
export const PrepareLabel = React.memo(_PrepareLabel);
export const ExpandableText = React.memo(_ExpandableText);

View File

@@ -0,0 +1,27 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`ExpandablePrompt > creates centered window around match when collapsed 1`] = `
"...ry/long/path/that/keeps/going/cd_/very/long/path/that/keeps/going/search-here/and/then/some/more/
components//and/then/some/more/components//and/..."
`;
exports[`ExpandablePrompt > highlights matched substring when expanded (text only visible) 1`] = `"run: git commit -m "feat: add search""`;
exports[`ExpandablePrompt > renders plain label when no match (short label) 1`] = `"simple command"`;
exports[`ExpandablePrompt > respects custom maxWidth 1`] = `"zzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzz..."`;
exports[`ExpandablePrompt > shows full long label when expanded and no match 1`] = `
"yyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyy
yyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyy"
`;
exports[`ExpandablePrompt > truncates long label when collapsed and no match 1`] = `
"xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx..."
`;
exports[`ExpandablePrompt > truncates match itself when match is very long 1`] = `
"xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx..."
`;

View File

@@ -0,0 +1,27 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`ExpandableText > creates centered window around match when collapsed 1`] = `
"...ry/long/path/that/keeps/going/cd_/very/long/path/that/keeps/going/search-here/and/then/some/more/
components//and/then/some/more/components//and/..."
`;
exports[`ExpandableText > highlights matched substring when expanded (text only visible) 1`] = `"run: git commit -m "feat: add search""`;
exports[`ExpandableText > renders plain label when no match (short label) 1`] = `"simple command"`;
exports[`ExpandableText > respects custom maxWidth 1`] = `"zzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzz..."`;
exports[`ExpandableText > shows full long label when expanded and no match 1`] = `
"yyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyy
yyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyy"
`;
exports[`ExpandableText > truncates long label when collapsed and no match 1`] = `
"xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx..."
`;
exports[`ExpandableText > truncates match itself when match is very long 1`] = `
"xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx..."
`;

View File

@@ -18,12 +18,17 @@ import {
isNodeError,
unescapePath,
ReadManyFilesTool,
REFERENCE_CONTENT_START,
REFERENCE_CONTENT_END,
} from '@google/gemini-cli-core';
import { Buffer } from 'node:buffer';
import type { HistoryItem, IndividualToolCallDisplay } from '../types.js';
import { ToolCallStatus } from '../types.js';
import type { UseHistoryManagerReturn } from './useHistoryManager.js';
const REF_CONTENT_HEADER = `\n${REFERENCE_CONTENT_START}`;
const REF_CONTENT_FOOTER = `\n${REFERENCE_CONTENT_END}`;
interface HandleAtCommandParams {
query: string;
config: Config;
@@ -499,10 +504,17 @@ export async function handleAtCommand({
const resourceResults = await Promise.all(resourcePromises);
const resourceReadDisplays: IndividualToolCallDisplay[] = [];
let resourceErrorOccurred = false;
let hasAddedReferenceHeader = false;
for (const result of resourceResults) {
resourceReadDisplays.push(result.display);
if (result.success) {
if (!hasAddedReferenceHeader) {
processedQueryParts.push({
text: REF_CONTENT_HEADER,
});
hasAddedReferenceHeader = true;
}
processedQueryParts.push({ text: `\nContent from @${result.uri}:\n` });
processedQueryParts.push(...result.parts);
} else {
@@ -540,6 +552,9 @@ export async function handleAtCommand({
userMessageTimestamp,
);
}
if (hasAddedReferenceHeader) {
processedQueryParts.push({ text: REF_CONTENT_FOOTER });
}
return { processedQuery: processedQueryParts };
}
@@ -570,9 +585,12 @@ export async function handleAtCommand({
if (Array.isArray(result.llmContent)) {
const fileContentRegex = /^--- (.*?) ---\n\n([\s\S]*?)\n\n$/;
processedQueryParts.push({
text: '\n--- Content from referenced files ---',
});
if (!hasAddedReferenceHeader) {
processedQueryParts.push({
text: REF_CONTENT_HEADER,
});
hasAddedReferenceHeader = true;
}
for (const part of result.llmContent) {
if (typeof part === 'string') {
const match = fileContentRegex.exec(part);

View File

@@ -195,6 +195,7 @@ describe('useSlashCommandProcessor', () => {
toggleDebugProfiler: vi.fn(),
dispatchExtensionStateUpdate: vi.fn(),
addConfirmUpdateExtensionRequest: vi.fn(),
setText: vi.fn(),
},
new Map(), // extensionsUpdateState
true, // isConfigInitialized

View File

@@ -76,6 +76,7 @@ interface SlashCommandProcessorActions {
toggleDebugProfiler: () => void;
dispatchExtensionStateUpdate: (action: ExtensionUpdateAction) => void;
addConfirmUpdateExtensionRequest: (request: ConfirmationRequest) => void;
setText: (text: string) => void;
}
/**
@@ -210,7 +211,12 @@ export const useSlashCommandProcessor = (
refreshStatic();
setBannerVisible(false);
},
loadHistory,
loadHistory: (history, postLoadInput) => {
loadHistory(history);
if (postLoadInput !== undefined) {
actions.setText(postLoadInput);
}
},
setDebugMessage: actions.setDebugMessage,
pendingItem,
setPendingItem,

View File

@@ -4,6 +4,11 @@
* SPDX-License-Identifier: Apache-2.0
*/
import {
REFERENCE_CONTENT_START,
REFERENCE_CONTENT_END,
} from '@google/gemini-cli-core';
export const formatMemoryUsage = (bytes: number): string => {
const gb = bytes / (1024 * 1024 * 1024);
if (bytes < 1024 * 1024) {
@@ -76,12 +81,9 @@ export const formatTimeAgo = (date: string | number | Date): string => {
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 ---".
* The markers are "${REFERENCE_CONTENT_START}" and "${REFERENCE_CONTENT_END}".
*
* @param text The input text containing potential reference blocks.
* @returns The text with reference blocks removed and trimmed.

View File

@@ -27,6 +27,7 @@ import {
ToolCallEvent,
debugLogger,
ReadManyFilesTool,
REFERENCE_CONTENT_START,
resolveModel,
createWorkingStdio,
startupProfiler,
@@ -817,7 +818,7 @@ export class Session {
if (Array.isArray(result.llmContent)) {
const fileContentRegex = /^--- (.*?) ---\n\n([\s\S]*?)\n\n$/;
processedQueryParts.push({
text: '\n--- Content from referenced files ---',
text: `\n${REFERENCE_CONTENT_START}`,
});
for (const part of result.llmContent) {
if (typeof part === 'string') {

View File

@@ -94,6 +94,7 @@ export * from './utils/checkpointUtils.js';
export * from './utils/secure-browser-launcher.js';
export * from './utils/apiConversionUtils.js';
export * from './utils/channel.js';
export * from './utils/constants.js';
// Export services
export * from './services/fileDiscoveryService.js';

View File

@@ -30,6 +30,8 @@ import { FileOperationEvent } from '../telemetry/types.js';
import { ToolErrorType } from './tool-error.js';
import { READ_MANY_FILES_TOOL_NAME } from './tool-names.js';
import { REFERENCE_CONTENT_END } from '../utils/constants.js';
/**
* Parameters for the ReadManyFilesTool.
*/
@@ -98,7 +100,7 @@ function getDefaultExcludes(config?: Config): string[] {
}
const DEFAULT_OUTPUT_SEPARATOR_FORMAT = '--- {filePath} ---';
const DEFAULT_OUTPUT_TERMINATOR = '\n--- End of content ---';
const DEFAULT_OUTPUT_TERMINATOR = `\n${REFERENCE_CONTENT_END}`;
class ReadManyFilesToolInvocation extends BaseToolInvocation<
ReadManyFilesParams,
@@ -517,7 +519,7 @@ This tool is useful when you need to understand or analyze a collection of files
- Gathering context from multiple configuration files.
- When the user asks to "read all files in X directory" or "show me the content of all Y files".
Use this tool when the user's query implies needing the content of several files simultaneously for context, analysis, or summarization. For text files, it uses default UTF-8 encoding and a '--- {filePath} ---' separator between file contents. The tool inserts a '--- End of content ---' after the last file. Ensure glob patterns are relative to the target directory. Glob patterns like 'src/**/*.js' are supported. Avoid using for single files if a more specific single-file reading tool is available, unless the user specifically requests to process a list containing just one file via this tool. Other binary files (not explicitly requested as image/audio/PDF) are generally skipped. Default excludes apply to common non-text files (except for explicitly requested images/audio/PDFs) and large dependency directories unless 'useDefaultExcludes' is false.`,
Use this tool when the user's query implies needing the content of several files simultaneously for context, analysis, or summarization. For text files, it uses default UTF-8 encoding and a '--- {filePath} ---' separator between file contents. The tool inserts a '${REFERENCE_CONTENT_END}' after the last file. Ensure glob patterns are relative to the target directory. Glob patterns like 'src/**/*.js' are supported. Avoid using for single files if a more specific single-file reading tool is available, unless the user specifically requests to process a list containing just one file via this tool. Other binary files (not explicitly requested as image/audio/PDF) are generally skipped. Default excludes apply to common non-text files (except for explicitly requested images/audio/PDFs) and large dependency directories unless 'useDefaultExcludes' is false.`,
Kind.Read,
parameterSchema,
messageBus,

View File

@@ -0,0 +1,8 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
export const REFERENCE_CONTENT_START = '--- Content from referenced files ---';
export const REFERENCE_CONTENT_END = '--- End of content ---';

View File

@@ -27,6 +27,7 @@ const OUTPUT_RELATIVE_PATH = ['docs', 'cli', 'keyboard-shortcuts.md'];
const KEY_NAME_OVERRIDES: Record<string, string> = {
return: 'Enter',
escape: 'Esc',
'double escape': 'Double Esc',
tab: 'Tab',
backspace: 'Backspace',
delete: 'Delete',