diff --git a/docs/cli/keyboard-shortcuts.md b/docs/cli/keyboard-shortcuts.md index 831410b59c..16a6c9d765 100644 --- a/docs/cli/keyboard-shortcuts.md +++ b/docs/cli/keyboard-shortcuts.md @@ -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 diff --git a/packages/cli/src/config/keyBindings.ts b/packages/cli/src/config/keyBindings.ts index 553bfeff47..2165e622dd 100644 --- a/packages/cli/src/config/keyBindings.ts +++ b/packages/cli/src/config/keyBindings.ts @@ -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> = { [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.', diff --git a/packages/cli/src/services/BuiltinCommandLoader.ts b/packages/cli/src/services/BuiltinCommandLoader.ts index 5873aec22a..c7f94d02cb 100644 --- a/packages/cli/src/services/BuiltinCommandLoader.ts +++ b/packages/cli/src/services/BuiltinCommandLoader.ts @@ -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 diff --git a/packages/cli/src/ui/AppContainer.tsx b/packages/cli/src/ui/AppContainer.tsx index 34d528e330..22a43ed392 100644 --- a/packages/cli/src/ui/AppContainer.tsx +++ b/packages/cli/src/ui/AppContainer.tsx @@ -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, ], ); diff --git a/packages/cli/src/ui/commands/rewindCommand.test.tsx b/packages/cli/src/ui/commands/rewindCommand.test.tsx new file mode 100644 index 0000000000..b0236845bc --- /dev/null +++ b/packages/cli/src/ui/commands/rewindCommand.test.tsx @@ -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(); + 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; + 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; + + // 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; + 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; + 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; + 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; + 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; + 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; + 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.', + }); + }); +}); diff --git a/packages/cli/src/ui/commands/rewindCommand.tsx b/packages/cli/src/ui/commands/rewindCommand.tsx new file mode 100644 index 0000000000..c9b0424842 --- /dev/null +++ b/packages/cli/src/ui/commands/rewindCommand.tsx @@ -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: ( + { + 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); + } + }} + /> + ), + }; + }, +}; diff --git a/packages/cli/src/ui/commands/types.ts b/packages/cli/src/ui/commands/types.ts index a34ff960bb..613175c1be 100644 --- a/packages/cli/src/ui/commands/types.ts +++ b/packages/cli/src/ui/commands/types.ts @@ -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; diff --git a/packages/cli/src/ui/components/InputPrompt.test.tsx b/packages/cli/src/ui/components/InputPrompt.test.tsx index 06d8d60bb9..80ebb19567 100644 --- a/packages/cli/src/ui/components/InputPrompt.test.tsx +++ b/packages/cli/src/ui/components/InputPrompt.test.tsx @@ -2007,7 +2007,9 @@ describe('InputPrompt', () => { await act(async () => { stdin.write('\x1B\x1B'); vi.advanceTimersByTime(100); + }); + await waitFor(() => { expect(props.onSubmit).toHaveBeenCalledWith('/rewind'); }); unmount(); diff --git a/packages/cli/src/ui/components/InputPrompt.tsx b/packages/cli/src/ui/components/InputPrompt.tsx index ea9d51824d..8a24c6dcda 100644 --- a/packages/cli/src/ui/components/InputPrompt.tsx +++ b/packages/cli/src/ui/components/InputPrompt.tsx @@ -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 = ({ 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; } diff --git a/packages/cli/src/ui/components/RewindViewer.test.tsx b/packages/cli/src/ui/components/RewindViewer.test.tsx index 649fbb4f4b..8272fc9c9f 100644 --- a/packages/cli/src/ui/components/RewindViewer.test.tsx +++ b/packages/cli/src/ui/components/RewindViewer.test.tsx @@ -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([ diff --git a/packages/cli/src/ui/components/RewindViewer.tsx b/packages/cli/src/ui/components/RewindViewer.tsx index f33b3786f5..956d94ac91 100644 --- a/packages/cli/src/ui/components/RewindViewer.tsx +++ b/packages/cli/src/ui/components/RewindViewer.tsx @@ -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; } const MAX_LINES_PER_BOX = 2; @@ -39,6 +40,7 @@ export const RewindViewer: React.FC = ({ onExit, onRewind, }) => { + const [isRewinding, setIsRewinding] = useState(false); const { terminalWidth, terminalHeight } = useUIState(); const { selectedMessageId, @@ -48,28 +50,58 @@ export const RewindViewer: React.FC = ({ clearSelection, } = useRewind(conversation); + const [highlightedMessageId, setHighlightedMessageId] = useState< + string | null + >(null); + const [expandedMessageId, setExpandedMessageId] = useState( + 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 = ({ const maxItemsToShow = Math.max(1, Math.floor(listHeight / 4)); if (selectedMessageId) { + if (isRewinding) { + return ( + + + + + Rewinding... + + ); + } + + if (selectedMessageId === 'current-position') { + onExit(); + return null; + } + const selectedMessage = interactions.find( (m) => m.id === selectedMessageId, ); @@ -97,7 +151,7 @@ export const RewindViewer: React.FC = ({ 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 = ({ ? 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 = ({ 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 ( + + + {partToString(userPrompt.content)} + + + Cancel rewind and stay here + + + ); + } + const stats = getStats(userPrompt); const firstFileName = stats?.details?.at(0)?.fileName; const originalUserText = userPrompt.content @@ -154,25 +238,15 @@ export const RewindViewer: React.FC = ({ return ( - - {cleanedText.split('\n').map((line, i) => ( - - - {line} - - - ))} - + {stats ? ( @@ -203,7 +277,8 @@ export const RewindViewer: React.FC = ({ - (Use Enter to select a message, Esc to close) + (Use Enter to select a message, Esc to close, Right/Left to + expand/collapse) diff --git a/packages/cli/src/ui/components/SuggestionsDisplay.tsx b/packages/cli/src/ui/components/SuggestionsDisplay.tsx index 96eb554076..eb08997e4f 100644 --- a/packages/cli/src/ui/components/SuggestionsDisplay.tsx +++ b/packages/cli/src/ui/components/SuggestionsDisplay.tsx @@ -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 = ( - 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) │ │ │ ╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯" `; diff --git a/packages/cli/src/ui/components/PrepareLabel.test.tsx b/packages/cli/src/ui/components/shared/ExpandableText.test.tsx similarity index 80% rename from packages/cli/src/ui/components/PrepareLabel.test.tsx rename to packages/cli/src/ui/components/shared/ExpandableText.test.tsx index 5c06817836..1e46751f57 100644 --- a/packages/cli/src/ui/components/PrepareLabel.test.tsx +++ b/packages/cli/src/ui/components/shared/ExpandableText.test.tsx @@ -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( - { it('truncates long label when collapsed and no match', () => { const long = 'x'.repeat(MAX_WIDTH + 25); const { lastFrame, unmount } = render( - { it('shows full long label when expanded and no match', () => { const long = 'y'.repeat(MAX_WIDTH + 25); const { lastFrame, unmount } = render( - { const userInput = 'commit'; const matchedIndex = label.indexOf(userInput); const { lastFrame, unmount } = render( - { const label = prefix + core + suffix; const matchedIndex = prefix.length; const { lastFrame, unmount } = render( - { const label = prefix + core + suffix; const matchedIndex = prefix.length; const { lastFrame, unmount } = render( - { expect(out).toMatchSnapshot(); unmount(); }); + + it('respects custom maxWidth', () => { + const customWidth = 50; + const long = 'z'.repeat(100); + const { lastFrame, unmount } = render( + , + ); + const out = lastFrame(); + const f = flat(out); + expect(f.endsWith('...')).toBe(true); + expect(f.length).toBe(customWidth + 3); + expect(out).toMatchSnapshot(); + unmount(); + }); }); diff --git a/packages/cli/src/ui/components/PrepareLabel.tsx b/packages/cli/src/ui/components/shared/ExpandableText.tsx similarity index 64% rename from packages/cli/src/ui/components/PrepareLabel.tsx rename to packages/cli/src/ui/components/shared/ExpandableText.tsx index 759e84b100..69b4d4c661 100644 --- a/packages/cli/src/ui/components/PrepareLabel.tsx +++ b/packages/cli/src/ui/components/shared/ExpandableText.tsx @@ -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 = ({ +const _ExpandableText: React.FC = ({ 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 = ({ // 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 ( {display} @@ -51,18 +71,18 @@ const _PrepareLabel: React.FC = ({ 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 = ({ ); }; -export const PrepareLabel = React.memo(_PrepareLabel); +export const ExpandableText = React.memo(_ExpandableText); diff --git a/packages/cli/src/ui/components/shared/__snapshots__/ExpandablePrompt.test.tsx.snap b/packages/cli/src/ui/components/shared/__snapshots__/ExpandablePrompt.test.tsx.snap new file mode 100644 index 0000000000..6dcc8802b6 --- /dev/null +++ b/packages/cli/src/ui/components/shared/__snapshots__/ExpandablePrompt.test.tsx.snap @@ -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..." +`; diff --git a/packages/cli/src/ui/components/shared/__snapshots__/ExpandableText.test.tsx.snap b/packages/cli/src/ui/components/shared/__snapshots__/ExpandableText.test.tsx.snap new file mode 100644 index 0000000000..8716c962ea --- /dev/null +++ b/packages/cli/src/ui/components/shared/__snapshots__/ExpandableText.test.tsx.snap @@ -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..." +`; diff --git a/packages/cli/src/ui/hooks/atCommandProcessor.ts b/packages/cli/src/ui/hooks/atCommandProcessor.ts index f545c3e103..708c950907 100644 --- a/packages/cli/src/ui/hooks/atCommandProcessor.ts +++ b/packages/cli/src/ui/hooks/atCommandProcessor.ts @@ -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); diff --git a/packages/cli/src/ui/hooks/slashCommandProcessor.test.tsx b/packages/cli/src/ui/hooks/slashCommandProcessor.test.tsx index c66cd25f71..717da57805 100644 --- a/packages/cli/src/ui/hooks/slashCommandProcessor.test.tsx +++ b/packages/cli/src/ui/hooks/slashCommandProcessor.test.tsx @@ -195,6 +195,7 @@ describe('useSlashCommandProcessor', () => { toggleDebugProfiler: vi.fn(), dispatchExtensionStateUpdate: vi.fn(), addConfirmUpdateExtensionRequest: vi.fn(), + setText: vi.fn(), }, new Map(), // extensionsUpdateState true, // isConfigInitialized diff --git a/packages/cli/src/ui/hooks/slashCommandProcessor.ts b/packages/cli/src/ui/hooks/slashCommandProcessor.ts index 4eb8949db1..c2bb7ebcee 100644 --- a/packages/cli/src/ui/hooks/slashCommandProcessor.ts +++ b/packages/cli/src/ui/hooks/slashCommandProcessor.ts @@ -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, diff --git a/packages/cli/src/ui/utils/formatters.ts b/packages/cli/src/ui/utils/formatters.ts index 6552f6c4f7..419ad8d0e4 100644 --- a/packages/cli/src/ui/utils/formatters.ts +++ b/packages/cli/src/ui/utils/formatters.ts @@ -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. diff --git a/packages/cli/src/zed-integration/zedIntegration.ts b/packages/cli/src/zed-integration/zedIntegration.ts index 3cf00302cf..93a97571a4 100644 --- a/packages/cli/src/zed-integration/zedIntegration.ts +++ b/packages/cli/src/zed-integration/zedIntegration.ts @@ -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') { diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 775223f4e5..fdd54c5150 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -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'; diff --git a/packages/core/src/tools/read-many-files.ts b/packages/core/src/tools/read-many-files.ts index c1d8c18cd7..26ddf673a8 100644 --- a/packages/core/src/tools/read-many-files.ts +++ b/packages/core/src/tools/read-many-files.ts @@ -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, diff --git a/packages/core/src/utils/constants.ts b/packages/core/src/utils/constants.ts new file mode 100644 index 0000000000..e11cbb67c1 --- /dev/null +++ b/packages/core/src/utils/constants.ts @@ -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 ---'; diff --git a/scripts/generate-keybindings-doc.ts b/scripts/generate-keybindings-doc.ts index a23dfe530c..600a989936 100644 --- a/scripts/generate-keybindings-doc.ts +++ b/scripts/generate-keybindings-doc.ts @@ -27,6 +27,7 @@ const OUTPUT_RELATIVE_PATH = ['docs', 'cli', 'keyboard-shortcuts.md']; const KEY_NAME_OVERRIDES: Record = { return: 'Enter', escape: 'Esc', + 'double escape': 'Double Esc', tab: 'Tab', backspace: 'Backspace', delete: 'Delete',