From dd00d5d47b1a9f235ad3ea39a8e972f40f630a18 Mon Sep 17 00:00:00 2001 From: "A.K.M. Adib" Date: Tue, 30 Dec 2025 10:23:31 -0500 Subject: [PATCH 01/42] complete --- packages/core/src/core/client.test.ts | 69 ++++++++------------------- 1 file changed, 20 insertions(+), 49 deletions(-) diff --git a/packages/core/src/core/client.test.ts b/packages/core/src/core/client.test.ts index 50ee7f765ae..bb5b37b3ae6 100644 --- a/packages/core/src/core/client.test.ts +++ b/packages/core/src/core/client.test.ts @@ -480,64 +480,35 @@ describe('Gemini Client (client.ts)', () => { newTokenCount: estimatedNewTokenCount, originalTokenCount: 100, }); - // IMPORTANT: The change in client.ts means setLastPromptTokenCount is NOT called on failure - expect( - uiTelemetryService.setLastPromptTokenCount, - ).not.toHaveBeenCalled(); }); - it('does not manipulate the source chat', async () => { + it('resumes the session file when compression succeeds', async () => { const { client, mockOriginalChat } = setup({ - originalTokenCount: 100, - newTokenCount: 200, - compressionStatus: - CompressionStatus.COMPRESSION_FAILED_INFLATED_TOKEN_COUNT, + compressionStatus: CompressionStatus.COMPRESSED, }); - await client.tryCompressChat('prompt-id-4', false); + const mockRecordingService = { + getConversation: vi + .fn() + .mockReturnValue({ sessionId: 'session-123' }), + getConversationFilePath: vi + .fn() + .mockReturnValue('/path/to/session.json'), + }; - // On failure, the chat should NOT be replaced - expect(client['chat']).toBe(mockOriginalChat); - }); + // Ensure the original chat has the recording service available + mockOriginalChat.getChatRecordingService = vi + .fn() + .mockReturnValue(mockRecordingService); - it.skip('will not attempt to compress context after a failure', async () => { - const { client } = setup({ - originalTokenCount: 100, - newTokenCount: 200, - compressionStatus: - CompressionStatus.COMPRESSION_FAILED_INFLATED_TOKEN_COUNT, - }); + await client.tryCompressChat('prompt-id-resume', false); - await client.tryCompressChat('prompt-id-4', false); // This fails and sets hasFailedCompressionAttempt = true - - // Mock the next call to return NOOP - vi.mocked( - ChatCompressionService.prototype.compress, - ).mockResolvedValueOnce({ - newHistory: null, - info: { - originalTokenCount: 0, - newTokenCount: 0, - compressionStatus: CompressionStatus.NOOP, + expect(client['startChat']).toHaveBeenCalledWith( + expect.anything(), // new history + { + conversation: { sessionId: 'session-123' }, + filePath: '/path/to/session.json', }, - }); - - // This call should now be a NOOP - const result = await client.tryCompressChat('prompt-id-5', false); - - expect(result.compressionStatus).toBe(CompressionStatus.NOOP); - expect(ChatCompressionService.prototype.compress).toHaveBeenCalledTimes( - 2, - ); - expect( - ChatCompressionService.prototype.compress, - ).toHaveBeenLastCalledWith( - expect.anything(), - 'prompt-id-5', - false, - expect.anything(), - expect.anything(), - true, // hasFailedCompressionAttempt ); }); }); From 2f219753c55dde46e2428efed9673624c2324db1 Mon Sep 17 00:00:00 2001 From: "A.K.M. Adib" Date: Tue, 30 Dec 2025 10:41:19 -0500 Subject: [PATCH 02/42] fix tests --- packages/core/src/core/client.test.ts | 69 +++++++++++++++++++-------- 1 file changed, 49 insertions(+), 20 deletions(-) diff --git a/packages/core/src/core/client.test.ts b/packages/core/src/core/client.test.ts index bb5b37b3ae6..50ee7f765ae 100644 --- a/packages/core/src/core/client.test.ts +++ b/packages/core/src/core/client.test.ts @@ -480,35 +480,64 @@ describe('Gemini Client (client.ts)', () => { newTokenCount: estimatedNewTokenCount, originalTokenCount: 100, }); + // IMPORTANT: The change in client.ts means setLastPromptTokenCount is NOT called on failure + expect( + uiTelemetryService.setLastPromptTokenCount, + ).not.toHaveBeenCalled(); }); - it('resumes the session file when compression succeeds', async () => { + it('does not manipulate the source chat', async () => { const { client, mockOriginalChat } = setup({ - compressionStatus: CompressionStatus.COMPRESSED, + originalTokenCount: 100, + newTokenCount: 200, + compressionStatus: + CompressionStatus.COMPRESSION_FAILED_INFLATED_TOKEN_COUNT, }); - const mockRecordingService = { - getConversation: vi - .fn() - .mockReturnValue({ sessionId: 'session-123' }), - getConversationFilePath: vi - .fn() - .mockReturnValue('/path/to/session.json'), - }; + await client.tryCompressChat('prompt-id-4', false); - // Ensure the original chat has the recording service available - mockOriginalChat.getChatRecordingService = vi - .fn() - .mockReturnValue(mockRecordingService); + // On failure, the chat should NOT be replaced + expect(client['chat']).toBe(mockOriginalChat); + }); - await client.tryCompressChat('prompt-id-resume', false); + it.skip('will not attempt to compress context after a failure', async () => { + const { client } = setup({ + originalTokenCount: 100, + newTokenCount: 200, + compressionStatus: + CompressionStatus.COMPRESSION_FAILED_INFLATED_TOKEN_COUNT, + }); - expect(client['startChat']).toHaveBeenCalledWith( - expect.anything(), // new history - { - conversation: { sessionId: 'session-123' }, - filePath: '/path/to/session.json', + await client.tryCompressChat('prompt-id-4', false); // This fails and sets hasFailedCompressionAttempt = true + + // Mock the next call to return NOOP + vi.mocked( + ChatCompressionService.prototype.compress, + ).mockResolvedValueOnce({ + newHistory: null, + info: { + originalTokenCount: 0, + newTokenCount: 0, + compressionStatus: CompressionStatus.NOOP, }, + }); + + // This call should now be a NOOP + const result = await client.tryCompressChat('prompt-id-5', false); + + expect(result.compressionStatus).toBe(CompressionStatus.NOOP); + expect(ChatCompressionService.prototype.compress).toHaveBeenCalledTimes( + 2, + ); + expect( + ChatCompressionService.prototype.compress, + ).toHaveBeenLastCalledWith( + expect.anything(), + 'prompt-id-5', + false, + expect.anything(), + expect.anything(), + true, // hasFailedCompressionAttempt ); }); }); From 9921a211805be7b5a4f053c6d6a94b127d7c3e40 Mon Sep 17 00:00:00 2001 From: "A.K.M. Adib" Date: Tue, 30 Dec 2025 10:57:47 -0500 Subject: [PATCH 03/42] completed useRewindLogic hook --- .../cli/src/ui/hooks/useRewindLogic.test.ts | 145 ++++++++++++++++++ packages/cli/src/ui/hooks/useRewindLogic.ts | 60 ++++++++ 2 files changed, 205 insertions(+) create mode 100644 packages/cli/src/ui/hooks/useRewindLogic.test.ts create mode 100644 packages/cli/src/ui/hooks/useRewindLogic.ts diff --git a/packages/cli/src/ui/hooks/useRewindLogic.test.ts b/packages/cli/src/ui/hooks/useRewindLogic.test.ts new file mode 100644 index 00000000000..55daf55a6b1 --- /dev/null +++ b/packages/cli/src/ui/hooks/useRewindLogic.test.ts @@ -0,0 +1,145 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { act } from 'react'; +import { renderHook } from '../../test-utils/render.js'; +import { useRewindLogic } from './useRewindLogic.js'; +import * as rewindFileOps from '../utils/rewindFileOps.js'; +import type { FileChangeStats } from '../utils/rewindFileOps.js'; +import type { + ConversationRecord, + MessageRecord, +} from '@google/gemini-cli-core'; + +// Mock the dependency +vi.mock('../utils/rewindFileOps.js', () => ({ + calculateTurnStats: vi.fn(), + calculateRewindImpact: vi.fn(), +})); + +describe('useRewindLogic', () => { + const mockUserMessage: MessageRecord = { + id: 'msg-1', + type: 'user', + content: 'Hello', + timestamp: 1000, + }; + + const mockModelMessage: MessageRecord = { + id: 'msg-2', + type: 'model', + content: 'Hi there', + timestamp: 1001, + }; + + const mockConversation: ConversationRecord = { + id: 'conv-1', + title: 'Test Conversation', + messages: [mockUserMessage, mockModelMessage], + created: 1000, + updated: 1001, + metadata: {}, + }; + + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('should filter interactions to only include user messages', () => { + const { result } = renderHook(() => useRewindLogic(mockConversation)); + + expect(result.current.interactions).toHaveLength(1); + expect(result.current.interactions[0]).toEqual(mockUserMessage); + }); + + it('should initialize with no selection', () => { + const { result } = renderHook(() => useRewindLogic(mockConversation)); + + expect(result.current.selectedMessageId).toBeNull(); + expect(result.current.confirmationStats).toBeNull(); + }); + + it('should update state when a message is selected', () => { + const mockStats: FileChangeStats = { + fileCount: 1, + addedLines: 5, + removedLines: 0, + firstFileName: 'file.ts', + }; + vi.mocked(rewindFileOps.calculateRewindImpact).mockReturnValue(mockStats); + + const { result } = renderHook(() => useRewindLogic(mockConversation)); + + act(() => { + result.current.selectMessage('msg-1'); + }); + + expect(result.current.selectedMessageId).toBe('msg-1'); + expect(result.current.confirmationStats).toEqual(mockStats); + expect(rewindFileOps.calculateRewindImpact).toHaveBeenCalledWith( + mockConversation, + mockUserMessage, + ); + }); + + it('should not update state if selected message is not found', () => { + const { result } = renderHook(() => useRewindLogic(mockConversation)); + + act(() => { + result.current.selectMessage('non-existent-id'); + }); + + expect(result.current.selectedMessageId).toBeNull(); + expect(result.current.confirmationStats).toBeNull(); + }); + + it('should clear selection correctly', () => { + const mockStats: FileChangeStats = { + fileCount: 1, + addedLines: 5, + removedLines: 0, + firstFileName: 'file.ts', + }; + vi.mocked(rewindFileOps.calculateRewindImpact).mockReturnValue(mockStats); + + const { result } = renderHook(() => useRewindLogic(mockConversation)); + + // Select first + act(() => { + result.current.selectMessage('msg-1'); + }); + expect(result.current.selectedMessageId).toBe('msg-1'); + + // Then clear + act(() => { + result.current.clearSelection(); + }); + + expect(result.current.selectedMessageId).toBeNull(); + expect(result.current.confirmationStats).toBeNull(); + }); + + it('should proxy getStats call to utility function', () => { + const mockStats: FileChangeStats = { + fileCount: 2, + addedLines: 10, + removedLines: 2, + firstFileName: 'file.ts', + }; + vi.mocked(rewindFileOps.calculateTurnStats).mockReturnValue(mockStats); + + const { result } = renderHook(() => useRewindLogic(mockConversation)); + + const stats = result.current.getStats(mockUserMessage); + + expect(stats).toEqual(mockStats); + expect(rewindFileOps.calculateTurnStats).toHaveBeenCalledWith( + mockConversation, + mockUserMessage, + ); + }); +}); diff --git a/packages/cli/src/ui/hooks/useRewindLogic.ts b/packages/cli/src/ui/hooks/useRewindLogic.ts new file mode 100644 index 00000000000..d9a72d6e45e --- /dev/null +++ b/packages/cli/src/ui/hooks/useRewindLogic.ts @@ -0,0 +1,60 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { useMemo, useState } from 'react'; +import type { + ConversationRecord, + MessageRecord, +} from '@google/gemini-cli-core'; +import { + calculateTurnStats, + calculateRewindImpact, + type FileChangeStats, +} from '../utils/rewindFileOps.js'; + +export function useRewindLogic(conversation: ConversationRecord) { + const [selectedMessageId, setSelectedMessageId] = useState( + null, + ); + const [confirmationStats, setConfirmationStats] = + useState(null); + + const interactions = useMemo(() => { + const prompts: MessageRecord[] = []; + + for (const msg of conversation.messages) { + if (msg.type === 'user') { + prompts.push(msg); + } + } + return prompts; + }, [conversation.messages]); + + const getStats = (userMessage: MessageRecord) => + calculateTurnStats(conversation, userMessage); + + const selectMessage = (messageId: string) => { + const msg = conversation.messages.find((m) => m.id === messageId); + if (msg) { + setSelectedMessageId(messageId); + setConfirmationStats(calculateRewindImpact(conversation, msg)); + } + }; + + const clearSelection = () => { + setSelectedMessageId(null); + setConfirmationStats(null); + }; + + return { + interactions, + selectedMessageId, + getStats, + confirmationStats, + selectMessage, + clearSelection, + }; +} From 224c48ca68f282f5b7315556e7f29888bd3c1a32 Mon Sep 17 00:00:00 2001 From: "A.K.M. Adib" Date: Tue, 30 Dec 2025 11:30:24 -0500 Subject: [PATCH 04/42] fix build --- packages/cli/src/ui/hooks/useRewindLogic.test.ts | 15 +++++++-------- 1 file changed, 7 insertions(+), 8 deletions(-) diff --git a/packages/cli/src/ui/hooks/useRewindLogic.test.ts b/packages/cli/src/ui/hooks/useRewindLogic.test.ts index 55daf55a6b1..60a4257be68 100644 --- a/packages/cli/src/ui/hooks/useRewindLogic.test.ts +++ b/packages/cli/src/ui/hooks/useRewindLogic.test.ts @@ -26,23 +26,22 @@ describe('useRewindLogic', () => { id: 'msg-1', type: 'user', content: 'Hello', - timestamp: 1000, + timestamp: new Date(1000).toISOString(), }; const mockModelMessage: MessageRecord = { id: 'msg-2', - type: 'model', + type: 'gemini', content: 'Hi there', - timestamp: 1001, + timestamp: new Date(1001).toISOString(), }; const mockConversation: ConversationRecord = { - id: 'conv-1', - title: 'Test Conversation', + sessionId: 'conv-1', + projectHash: 'hash-1', + startTime: new Date(1000).toISOString(), + lastUpdated: new Date(1001).toISOString(), messages: [mockUserMessage, mockModelMessage], - created: 1000, - updated: 1001, - metadata: {}, }; beforeEach(() => { From abe45d47a7770e6dec00b11edcb2eb8eac15f54f Mon Sep 17 00:00:00 2001 From: "A.K.M. Adib" Date: Tue, 30 Dec 2025 12:40:50 -0500 Subject: [PATCH 05/42] address nit by bot --- packages/cli/src/ui/hooks/useRewindLogic.ts | 30 ++++++++++++--------- 1 file changed, 18 insertions(+), 12 deletions(-) diff --git a/packages/cli/src/ui/hooks/useRewindLogic.ts b/packages/cli/src/ui/hooks/useRewindLogic.ts index d9a72d6e45e..f26e5a431fa 100644 --- a/packages/cli/src/ui/hooks/useRewindLogic.ts +++ b/packages/cli/src/ui/hooks/useRewindLogic.ts @@ -4,7 +4,7 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { useMemo, useState } from 'react'; +import { useMemo, useState, useCallback } from 'react'; import type { ConversationRecord, MessageRecord, @@ -33,21 +33,27 @@ export function useRewindLogic(conversation: ConversationRecord) { return prompts; }, [conversation.messages]); - const getStats = (userMessage: MessageRecord) => - calculateTurnStats(conversation, userMessage); + const getStats = useCallback( + (userMessage: MessageRecord) => + calculateTurnStats(conversation, userMessage), + [conversation], + ); - const selectMessage = (messageId: string) => { - const msg = conversation.messages.find((m) => m.id === messageId); - if (msg) { - setSelectedMessageId(messageId); - setConfirmationStats(calculateRewindImpact(conversation, msg)); - } - }; + const selectMessage = useCallback( + (messageId: string) => { + const msg = interactions.find((m) => m.id === messageId); + if (msg) { + setSelectedMessageId(messageId); + setConfirmationStats(calculateRewindImpact(conversation, msg)); + } + }, + [conversation, interactions], + ); - const clearSelection = () => { + const clearSelection = useCallback(() => { setSelectedMessageId(null); setConfirmationStats(null); - }; + }, []); return { interactions, From b4c545c363837b320b49e5c44871bea4c7f4ff1a Mon Sep 17 00:00:00 2001 From: "A.K.M. Adib" Date: Thu, 8 Jan 2026 17:35:55 -0500 Subject: [PATCH 06/42] fix tests --- packages/cli/src/ui/hooks/useRewindLogic.test.ts | 3 --- 1 file changed, 3 deletions(-) diff --git a/packages/cli/src/ui/hooks/useRewindLogic.test.ts b/packages/cli/src/ui/hooks/useRewindLogic.test.ts index 60a4257be68..b675ef862fb 100644 --- a/packages/cli/src/ui/hooks/useRewindLogic.test.ts +++ b/packages/cli/src/ui/hooks/useRewindLogic.test.ts @@ -67,7 +67,6 @@ describe('useRewindLogic', () => { fileCount: 1, addedLines: 5, removedLines: 0, - firstFileName: 'file.ts', }; vi.mocked(rewindFileOps.calculateRewindImpact).mockReturnValue(mockStats); @@ -101,7 +100,6 @@ describe('useRewindLogic', () => { fileCount: 1, addedLines: 5, removedLines: 0, - firstFileName: 'file.ts', }; vi.mocked(rewindFileOps.calculateRewindImpact).mockReturnValue(mockStats); @@ -127,7 +125,6 @@ describe('useRewindLogic', () => { fileCount: 2, addedLines: 10, removedLines: 2, - firstFileName: 'file.ts', }; vi.mocked(rewindFileOps.calculateTurnStats).mockReturnValue(mockStats); From 16208b9db5b08fb68512689e6d11e1583517acfe Mon Sep 17 00:00:00 2001 From: "A.K.M. Adib" Date: Fri, 9 Jan 2026 13:45:57 -0500 Subject: [PATCH 07/42] address feedback --- .../cli/src/ui/hooks/useRewindLogic.test.ts | 141 ------------------ packages/cli/src/ui/hooks/useRewindLogic.ts | 66 -------- 2 files changed, 207 deletions(-) delete mode 100644 packages/cli/src/ui/hooks/useRewindLogic.test.ts delete mode 100644 packages/cli/src/ui/hooks/useRewindLogic.ts diff --git a/packages/cli/src/ui/hooks/useRewindLogic.test.ts b/packages/cli/src/ui/hooks/useRewindLogic.test.ts deleted file mode 100644 index b675ef862fb..00000000000 --- a/packages/cli/src/ui/hooks/useRewindLogic.test.ts +++ /dev/null @@ -1,141 +0,0 @@ -/** - * @license - * Copyright 2025 Google LLC - * SPDX-License-Identifier: Apache-2.0 - */ - -import { describe, it, expect, vi, beforeEach } from 'vitest'; -import { act } from 'react'; -import { renderHook } from '../../test-utils/render.js'; -import { useRewindLogic } from './useRewindLogic.js'; -import * as rewindFileOps from '../utils/rewindFileOps.js'; -import type { FileChangeStats } from '../utils/rewindFileOps.js'; -import type { - ConversationRecord, - MessageRecord, -} from '@google/gemini-cli-core'; - -// Mock the dependency -vi.mock('../utils/rewindFileOps.js', () => ({ - calculateTurnStats: vi.fn(), - calculateRewindImpact: vi.fn(), -})); - -describe('useRewindLogic', () => { - const mockUserMessage: MessageRecord = { - id: 'msg-1', - type: 'user', - content: 'Hello', - timestamp: new Date(1000).toISOString(), - }; - - const mockModelMessage: MessageRecord = { - id: 'msg-2', - type: 'gemini', - content: 'Hi there', - timestamp: new Date(1001).toISOString(), - }; - - const mockConversation: ConversationRecord = { - sessionId: 'conv-1', - projectHash: 'hash-1', - startTime: new Date(1000).toISOString(), - lastUpdated: new Date(1001).toISOString(), - messages: [mockUserMessage, mockModelMessage], - }; - - beforeEach(() => { - vi.clearAllMocks(); - }); - - it('should filter interactions to only include user messages', () => { - const { result } = renderHook(() => useRewindLogic(mockConversation)); - - expect(result.current.interactions).toHaveLength(1); - expect(result.current.interactions[0]).toEqual(mockUserMessage); - }); - - it('should initialize with no selection', () => { - const { result } = renderHook(() => useRewindLogic(mockConversation)); - - expect(result.current.selectedMessageId).toBeNull(); - expect(result.current.confirmationStats).toBeNull(); - }); - - it('should update state when a message is selected', () => { - const mockStats: FileChangeStats = { - fileCount: 1, - addedLines: 5, - removedLines: 0, - }; - vi.mocked(rewindFileOps.calculateRewindImpact).mockReturnValue(mockStats); - - const { result } = renderHook(() => useRewindLogic(mockConversation)); - - act(() => { - result.current.selectMessage('msg-1'); - }); - - expect(result.current.selectedMessageId).toBe('msg-1'); - expect(result.current.confirmationStats).toEqual(mockStats); - expect(rewindFileOps.calculateRewindImpact).toHaveBeenCalledWith( - mockConversation, - mockUserMessage, - ); - }); - - it('should not update state if selected message is not found', () => { - const { result } = renderHook(() => useRewindLogic(mockConversation)); - - act(() => { - result.current.selectMessage('non-existent-id'); - }); - - expect(result.current.selectedMessageId).toBeNull(); - expect(result.current.confirmationStats).toBeNull(); - }); - - it('should clear selection correctly', () => { - const mockStats: FileChangeStats = { - fileCount: 1, - addedLines: 5, - removedLines: 0, - }; - vi.mocked(rewindFileOps.calculateRewindImpact).mockReturnValue(mockStats); - - const { result } = renderHook(() => useRewindLogic(mockConversation)); - - // Select first - act(() => { - result.current.selectMessage('msg-1'); - }); - expect(result.current.selectedMessageId).toBe('msg-1'); - - // Then clear - act(() => { - result.current.clearSelection(); - }); - - expect(result.current.selectedMessageId).toBeNull(); - expect(result.current.confirmationStats).toBeNull(); - }); - - it('should proxy getStats call to utility function', () => { - const mockStats: FileChangeStats = { - fileCount: 2, - addedLines: 10, - removedLines: 2, - }; - vi.mocked(rewindFileOps.calculateTurnStats).mockReturnValue(mockStats); - - const { result } = renderHook(() => useRewindLogic(mockConversation)); - - const stats = result.current.getStats(mockUserMessage); - - expect(stats).toEqual(mockStats); - expect(rewindFileOps.calculateTurnStats).toHaveBeenCalledWith( - mockConversation, - mockUserMessage, - ); - }); -}); diff --git a/packages/cli/src/ui/hooks/useRewindLogic.ts b/packages/cli/src/ui/hooks/useRewindLogic.ts deleted file mode 100644 index f26e5a431fa..00000000000 --- a/packages/cli/src/ui/hooks/useRewindLogic.ts +++ /dev/null @@ -1,66 +0,0 @@ -/** - * @license - * Copyright 2025 Google LLC - * SPDX-License-Identifier: Apache-2.0 - */ - -import { useMemo, useState, useCallback } from 'react'; -import type { - ConversationRecord, - MessageRecord, -} from '@google/gemini-cli-core'; -import { - calculateTurnStats, - calculateRewindImpact, - type FileChangeStats, -} from '../utils/rewindFileOps.js'; - -export function useRewindLogic(conversation: ConversationRecord) { - const [selectedMessageId, setSelectedMessageId] = useState( - null, - ); - const [confirmationStats, setConfirmationStats] = - useState(null); - - const interactions = useMemo(() => { - const prompts: MessageRecord[] = []; - - for (const msg of conversation.messages) { - if (msg.type === 'user') { - prompts.push(msg); - } - } - return prompts; - }, [conversation.messages]); - - const getStats = useCallback( - (userMessage: MessageRecord) => - calculateTurnStats(conversation, userMessage), - [conversation], - ); - - const selectMessage = useCallback( - (messageId: string) => { - const msg = interactions.find((m) => m.id === messageId); - if (msg) { - setSelectedMessageId(messageId); - setConfirmationStats(calculateRewindImpact(conversation, msg)); - } - }, - [conversation, interactions], - ); - - const clearSelection = useCallback(() => { - setSelectedMessageId(null); - setConfirmationStats(null); - }, []); - - return { - interactions, - selectedMessageId, - getStats, - confirmationStats, - selectMessage, - clearSelection, - }; -} From ac24cfbc951340883ec4ee9bc42b6f101e72da39 Mon Sep 17 00:00:00 2001 From: "A.K.M. Adib" Date: Tue, 30 Dec 2025 10:23:31 -0500 Subject: [PATCH 08/42] complete --- packages/core/src/core/client.test.ts | 69 ++++++------------- .../core/src/services/chatRecordingService.ts | 4 ++ 2 files changed, 24 insertions(+), 49 deletions(-) diff --git a/packages/core/src/core/client.test.ts b/packages/core/src/core/client.test.ts index 50ee7f765ae..bb5b37b3ae6 100644 --- a/packages/core/src/core/client.test.ts +++ b/packages/core/src/core/client.test.ts @@ -480,64 +480,35 @@ describe('Gemini Client (client.ts)', () => { newTokenCount: estimatedNewTokenCount, originalTokenCount: 100, }); - // IMPORTANT: The change in client.ts means setLastPromptTokenCount is NOT called on failure - expect( - uiTelemetryService.setLastPromptTokenCount, - ).not.toHaveBeenCalled(); }); - it('does not manipulate the source chat', async () => { + it('resumes the session file when compression succeeds', async () => { const { client, mockOriginalChat } = setup({ - originalTokenCount: 100, - newTokenCount: 200, - compressionStatus: - CompressionStatus.COMPRESSION_FAILED_INFLATED_TOKEN_COUNT, + compressionStatus: CompressionStatus.COMPRESSED, }); - await client.tryCompressChat('prompt-id-4', false); + const mockRecordingService = { + getConversation: vi + .fn() + .mockReturnValue({ sessionId: 'session-123' }), + getConversationFilePath: vi + .fn() + .mockReturnValue('/path/to/session.json'), + }; - // On failure, the chat should NOT be replaced - expect(client['chat']).toBe(mockOriginalChat); - }); + // Ensure the original chat has the recording service available + mockOriginalChat.getChatRecordingService = vi + .fn() + .mockReturnValue(mockRecordingService); - it.skip('will not attempt to compress context after a failure', async () => { - const { client } = setup({ - originalTokenCount: 100, - newTokenCount: 200, - compressionStatus: - CompressionStatus.COMPRESSION_FAILED_INFLATED_TOKEN_COUNT, - }); + await client.tryCompressChat('prompt-id-resume', false); - await client.tryCompressChat('prompt-id-4', false); // This fails and sets hasFailedCompressionAttempt = true - - // Mock the next call to return NOOP - vi.mocked( - ChatCompressionService.prototype.compress, - ).mockResolvedValueOnce({ - newHistory: null, - info: { - originalTokenCount: 0, - newTokenCount: 0, - compressionStatus: CompressionStatus.NOOP, + expect(client['startChat']).toHaveBeenCalledWith( + expect.anything(), // new history + { + conversation: { sessionId: 'session-123' }, + filePath: '/path/to/session.json', }, - }); - - // This call should now be a NOOP - const result = await client.tryCompressChat('prompt-id-5', false); - - expect(result.compressionStatus).toBe(CompressionStatus.NOOP); - expect(ChatCompressionService.prototype.compress).toHaveBeenCalledTimes( - 2, - ); - expect( - ChatCompressionService.prototype.compress, - ).toHaveBeenLastCalledWith( - expect.anything(), - 'prompt-id-5', - false, - expect.anything(), - expect.anything(), - true, // hasFailedCompressionAttempt ); }); }); diff --git a/packages/core/src/services/chatRecordingService.ts b/packages/core/src/services/chatRecordingService.ts index b308cce789e..02c3a57e1b8 100644 --- a/packages/core/src/services/chatRecordingService.ts +++ b/packages/core/src/services/chatRecordingService.ts @@ -410,7 +410,11 @@ export class ChatRecordingService { */ private writeConversation( conversation: ConversationRecord, +<<<<<<< HEAD { allowEmpty = false }: { allowEmpty?: boolean } = {}, +======= + allowEmpty: boolean = false, +>>>>>>> b0447b052 (complete) ): void { try { if (!this.conversationFile) return; From ce265ea9fae91ff0984b583dc02880fc6ef33368 Mon Sep 17 00:00:00 2001 From: "A.K.M. Adib" Date: Tue, 30 Dec 2025 10:41:19 -0500 Subject: [PATCH 09/42] fix tests --- packages/core/src/core/client.test.ts | 69 +++++++++++++++++++-------- 1 file changed, 49 insertions(+), 20 deletions(-) diff --git a/packages/core/src/core/client.test.ts b/packages/core/src/core/client.test.ts index bb5b37b3ae6..50ee7f765ae 100644 --- a/packages/core/src/core/client.test.ts +++ b/packages/core/src/core/client.test.ts @@ -480,35 +480,64 @@ describe('Gemini Client (client.ts)', () => { newTokenCount: estimatedNewTokenCount, originalTokenCount: 100, }); + // IMPORTANT: The change in client.ts means setLastPromptTokenCount is NOT called on failure + expect( + uiTelemetryService.setLastPromptTokenCount, + ).not.toHaveBeenCalled(); }); - it('resumes the session file when compression succeeds', async () => { + it('does not manipulate the source chat', async () => { const { client, mockOriginalChat } = setup({ - compressionStatus: CompressionStatus.COMPRESSED, + originalTokenCount: 100, + newTokenCount: 200, + compressionStatus: + CompressionStatus.COMPRESSION_FAILED_INFLATED_TOKEN_COUNT, }); - const mockRecordingService = { - getConversation: vi - .fn() - .mockReturnValue({ sessionId: 'session-123' }), - getConversationFilePath: vi - .fn() - .mockReturnValue('/path/to/session.json'), - }; + await client.tryCompressChat('prompt-id-4', false); - // Ensure the original chat has the recording service available - mockOriginalChat.getChatRecordingService = vi - .fn() - .mockReturnValue(mockRecordingService); + // On failure, the chat should NOT be replaced + expect(client['chat']).toBe(mockOriginalChat); + }); - await client.tryCompressChat('prompt-id-resume', false); + it.skip('will not attempt to compress context after a failure', async () => { + const { client } = setup({ + originalTokenCount: 100, + newTokenCount: 200, + compressionStatus: + CompressionStatus.COMPRESSION_FAILED_INFLATED_TOKEN_COUNT, + }); - expect(client['startChat']).toHaveBeenCalledWith( - expect.anything(), // new history - { - conversation: { sessionId: 'session-123' }, - filePath: '/path/to/session.json', + await client.tryCompressChat('prompt-id-4', false); // This fails and sets hasFailedCompressionAttempt = true + + // Mock the next call to return NOOP + vi.mocked( + ChatCompressionService.prototype.compress, + ).mockResolvedValueOnce({ + newHistory: null, + info: { + originalTokenCount: 0, + newTokenCount: 0, + compressionStatus: CompressionStatus.NOOP, }, + }); + + // This call should now be a NOOP + const result = await client.tryCompressChat('prompt-id-5', false); + + expect(result.compressionStatus).toBe(CompressionStatus.NOOP); + expect(ChatCompressionService.prototype.compress).toHaveBeenCalledTimes( + 2, + ); + expect( + ChatCompressionService.prototype.compress, + ).toHaveBeenLastCalledWith( + expect.anything(), + 'prompt-id-5', + false, + expect.anything(), + expect.anything(), + true, // hasFailedCompressionAttempt ); }); }); From 1a06bb6f017c240b3fd02756c366b6ee1a6636fb Mon Sep 17 00:00:00 2001 From: "A.K.M. Adib" Date: Fri, 9 Jan 2026 15:38:36 -0500 Subject: [PATCH 10/42] forgot to rebase this change --- packages/core/src/services/chatRecordingService.ts | 4 ---- 1 file changed, 4 deletions(-) diff --git a/packages/core/src/services/chatRecordingService.ts b/packages/core/src/services/chatRecordingService.ts index 02c3a57e1b8..b308cce789e 100644 --- a/packages/core/src/services/chatRecordingService.ts +++ b/packages/core/src/services/chatRecordingService.ts @@ -410,11 +410,7 @@ export class ChatRecordingService { */ private writeConversation( conversation: ConversationRecord, -<<<<<<< HEAD { allowEmpty = false }: { allowEmpty?: boolean } = {}, -======= - allowEmpty: boolean = false, ->>>>>>> b0447b052 (complete) ): void { try { if (!this.conversationFile) return; From 1f1c5f27c01451d28ff42b54f4ab2c8bb683fde7 Mon Sep 17 00:00:00 2001 From: "A.K.M. Adib" Date: Mon, 12 Jan 2026 15:11:15 -0500 Subject: [PATCH 11/42] address feedback from first round of review --- .../cli/src/ui/components/RewindViewer.test.tsx | 9 +++++++++ packages/cli/src/ui/components/RewindViewer.tsx | 3 +++ .../__snapshots__/RewindConfirmation.test.tsx.snap | 14 ++++++++++++++ packages/cli/src/ui/utils/formatters.test.ts | 3 +++ 4 files changed, 29 insertions(+) diff --git a/packages/cli/src/ui/components/RewindViewer.test.tsx b/packages/cli/src/ui/components/RewindViewer.test.tsx index 649fbb4f4b3..556d0cc46d4 100644 --- a/packages/cli/src/ui/components/RewindViewer.test.tsx +++ b/packages/cli/src/ui/components/RewindViewer.test.tsx @@ -23,6 +23,15 @@ vi.mock('../utils/formatters.js', async (importOriginal) => { }; }); +vi.mock('../utils/formatters.js', async (importOriginal) => { + const original = + await importOriginal(); + return { + ...original, + formatTimeAgo: () => 'some time ago', + }; +}); + vi.mock('@google/gemini-cli-core', async (importOriginal) => { const original = await importOriginal(); diff --git a/packages/cli/src/ui/components/RewindViewer.tsx b/packages/cli/src/ui/components/RewindViewer.tsx index f33b3786f5f..2e1c548178b 100644 --- a/packages/cli/src/ui/components/RewindViewer.tsx +++ b/packages/cli/src/ui/components/RewindViewer.tsx @@ -19,8 +19,11 @@ import { useKeypress } from '../hooks/useKeypress.js'; import { useRewind } from '../hooks/useRewind.js'; import { RewindConfirmation, RewindOutcome } from './RewindConfirmation.js'; import { stripReferenceContent } from '../utils/formatters.js'; +<<<<<<< HEAD import { MaxSizedBox } from './shared/MaxSizedBox.js'; import { keyMatchers, Command } from '../keyMatchers.js'; +======= +>>>>>>> 51fa436b2 (address feedback from first round of review) interface RewindViewerProps { conversation: ConversationRecord; diff --git a/packages/cli/src/ui/components/__snapshots__/RewindConfirmation.test.tsx.snap b/packages/cli/src/ui/components/__snapshots__/RewindConfirmation.test.tsx.snap index 643f2aaaeb0..2f0ede99bbb 100644 --- a/packages/cli/src/ui/components/__snapshots__/RewindConfirmation.test.tsx.snap +++ b/packages/cli/src/ui/components/__snapshots__/RewindConfirmation.test.tsx.snap @@ -31,8 +31,15 @@ exports[`RewindConfirmation > renders correctly without stats 1`] = ` │ │ │ Select an action: │ │ │ +<<<<<<< HEAD │ ● 1. Rewind conversation │ │ 2. Do nothing (esc) │ +======= +│ ● 1. Rewind conversation and revert code changes │ +│ 2. Rewind conversation │ +│ 3. Revert code changes │ +│ 4. Do nothing (esc) │ +>>>>>>> 51fa436b2 (address feedback from first round of review) │ │ ╰──────────────────────────────────────────────────────────────────────────────╯" `; @@ -46,8 +53,15 @@ exports[`RewindConfirmation > renders timestamp when provided 1`] = ` │ │ │ Select an action: │ │ │ +<<<<<<< HEAD │ ● 1. Rewind conversation │ │ 2. Do nothing (esc) │ +======= +│ ● 1. Rewind conversation and revert code changes │ +│ 2. Rewind conversation │ +│ 3. Revert code changes │ +│ 4. Do nothing (esc) │ +>>>>>>> 51fa436b2 (address feedback from first round of review) │ │ ╰──────────────────────────────────────────────────────────────────────────────╯" `; diff --git a/packages/cli/src/ui/utils/formatters.test.ts b/packages/cli/src/ui/utils/formatters.test.ts index 48c0a2c6059..be1d04ee595 100644 --- a/packages/cli/src/ui/utils/formatters.test.ts +++ b/packages/cli/src/ui/utils/formatters.test.ts @@ -156,11 +156,14 @@ describe('formatters', () => { 'A--- Content from referenced files ---B--- End of content ---C'; expect(stripReferenceContent(text)).toBe('AC'); }); +<<<<<<< HEAD it('should strip multiple blocks correctly and preserve text in between', () => { const text = 'Start\n--- Content from referenced files ---\nBlock1\n--- End of content ---\nMiddle\n--- Content from referenced files ---\nBlock2\n--- End of content ---\nEnd'; expect(stripReferenceContent(text)).toBe('Start\nMiddle\nEnd'); }); +======= +>>>>>>> 51fa436b2 (address feedback from first round of review) }); }); From d9848cae3c298ac642a8918423bcd831e2f3df61 Mon Sep 17 00:00:00 2001 From: "A.K.M. Adib" Date: Mon, 12 Jan 2026 16:13:17 -0500 Subject: [PATCH 12/42] address feedback from second round of review --- packages/cli/src/ui/components/RewindConfirmation.tsx | 2 +- packages/cli/src/ui/components/RewindViewer.tsx | 4 ++++ .../ui/components/__snapshots__/RewindViewer.test.tsx.snap | 3 +++ 3 files changed, 8 insertions(+), 1 deletion(-) diff --git a/packages/cli/src/ui/components/RewindConfirmation.tsx b/packages/cli/src/ui/components/RewindConfirmation.tsx index 5b9f4d82532..8c0a2d83c96 100644 --- a/packages/cli/src/ui/components/RewindConfirmation.tsx +++ b/packages/cli/src/ui/components/RewindConfirmation.tsx @@ -147,7 +147,7 @@ export const RewindConfirmation: React.FC = ({ diff --git a/packages/cli/src/ui/components/RewindViewer.tsx b/packages/cli/src/ui/components/RewindViewer.tsx index 2e1c548178b..ef75ea78eba 100644 --- a/packages/cli/src/ui/components/RewindViewer.tsx +++ b/packages/cli/src/ui/components/RewindViewer.tsx @@ -20,10 +20,14 @@ import { useRewind } from '../hooks/useRewind.js'; import { RewindConfirmation, RewindOutcome } from './RewindConfirmation.js'; import { stripReferenceContent } from '../utils/formatters.js'; <<<<<<< HEAD +<<<<<<< HEAD import { MaxSizedBox } from './shared/MaxSizedBox.js'; import { keyMatchers, Command } from '../keyMatchers.js'; ======= >>>>>>> 51fa436b2 (address feedback from first round of review) +======= +import { MaxSizedBox } from './shared/MaxSizedBox.js'; +>>>>>>> d501c94a8 (address feedback from second round of review) interface RewindViewerProps { conversation: ConversationRecord; diff --git a/packages/cli/src/ui/components/__snapshots__/RewindViewer.test.tsx.snap b/packages/cli/src/ui/components/__snapshots__/RewindViewer.test.tsx.snap index 7db1c1c5073..75d347c414e 100644 --- a/packages/cli/src/ui/components/__snapshots__/RewindViewer.test.tsx.snap +++ b/packages/cli/src/ui/components/__snapshots__/RewindViewer.test.tsx.snap @@ -22,6 +22,9 @@ exports[`RewindViewer > Content Filtering > 'strips expanded MCP resource conten │ ● read @server3:mcp://demo-resource hello │ │ No files have been changed │ │ │ +│ ● Original Prompt │ +│ No files have been changed │ +│ │ │ │ │ (Use Enter to select a message, Esc to close) │ │ │ From ab382cbbdab90f94333f69d8a9aabd541aa96a9f Mon Sep 17 00:00:00 2001 From: "A.K.M. Adib" Date: Mon, 12 Jan 2026 16:39:00 -0500 Subject: [PATCH 13/42] fix tests --- .../__snapshots__/RewindViewer.test.tsx.snap | 85 +++++++++++++++++++ 1 file changed, 85 insertions(+) diff --git a/packages/cli/src/ui/components/__snapshots__/RewindViewer.test.tsx.snap b/packages/cli/src/ui/components/__snapshots__/RewindViewer.test.tsx.snap index 75d347c414e..dfa4a622d2b 100644 --- a/packages/cli/src/ui/components/__snapshots__/RewindViewer.test.tsx.snap +++ b/packages/cli/src/ui/components/__snapshots__/RewindViewer.test.tsx.snap @@ -81,6 +81,26 @@ exports[`RewindViewer > Navigation > handles 'down' navigation > after-down 1`] ╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯" `; +exports[`RewindViewer > Navigation > handles 'down' navigation > after-down 2`] = ` +"╭──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ +│ │ +│ > Rewind │ +│ │ +│ Q3 │ +│ No files have been changed │ +│ │ +│ ● Q2 │ +│ No files have been changed │ +│ │ +│ Q1 │ +│ No files have been changed │ +│ │ +│ │ +│ (Use Enter to select a message, Esc to close) │ +│ │ +╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯" +`; + exports[`RewindViewer > Navigation > handles 'up' navigation > after-up 1`] = ` "╭──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │ │ @@ -101,6 +121,26 @@ exports[`RewindViewer > Navigation > handles 'up' navigation > after-up 1`] = ` ╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯" `; +exports[`RewindViewer > Navigation > handles 'up' navigation > after-up 2`] = ` +"╭──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ +│ │ +│ > Rewind │ +│ │ +│ Q3 │ +│ No files have been changed │ +│ │ +│ Q2 │ +│ No files have been changed │ +│ │ +│ ● Q1 │ +│ No files have been changed │ +│ │ +│ │ +│ (Use Enter to select a message, Esc to close) │ +│ │ +╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯" +`; + exports[`RewindViewer > Navigation > handles cyclic navigation > cyclic-down 1`] = ` "╭──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │ │ @@ -141,6 +181,26 @@ exports[`RewindViewer > Navigation > handles cyclic navigation > cyclic-up 1`] = ╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯" `; +exports[`RewindViewer > Navigation > handles cyclic navigation > cyclic-up 2`] = ` +"╭──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ +│ │ +│ > Rewind │ +│ │ +│ Q3 │ +│ No files have been changed │ +│ │ +│ Q2 │ +│ No files have been changed │ +│ │ +│ ● Q1 │ +│ No files have been changed │ +│ │ +│ │ +│ (Use Enter to select a message, Esc to close) │ +│ │ +╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯" +`; + exports[`RewindViewer > Rendering > renders 'a single interaction' 1`] = ` "╭──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │ │ @@ -242,6 +302,31 @@ exports[`RewindViewer > updates selection and expansion on navigation > after-do ╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯" `; +exports[`RewindViewer > updates selection and expansion on navigation > after-down 2`] = ` +"╭──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ +│ │ +│ > Rewind │ +│ │ +│ Line 1 │ +│ Line 2 │ +│ ... last 5 lines hidden ... │ +│ No files have been changed │ +│ │ +│ ● Line A │ +│ Line B │ +│ Line C │ +│ Line D │ +│ Line E │ +│ Line F │ +│ Line G │ +│ No files have been changed │ +│ │ +│ │ +│ (Use Enter to select a message, Esc to close) │ +│ │ +╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯" +`; + exports[`RewindViewer > updates selection and expansion on navigation > initial-state 1`] = ` "╭──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │ │ From 72be8aa9c522e4ede2f45adfe68884f7a2aa53d8 Mon Sep 17 00:00:00 2001 From: "A.K.M. Adib" Date: Mon, 12 Jan 2026 17:05:53 -0500 Subject: [PATCH 14/42] fix tests --- packages/cli/src/ui/components/RewindViewer.tsx | 7 ------- .../__snapshots__/RewindConfirmation.test.tsx.snap | 14 -------------- packages/cli/src/ui/utils/formatters.test.ts | 3 --- 3 files changed, 24 deletions(-) diff --git a/packages/cli/src/ui/components/RewindViewer.tsx b/packages/cli/src/ui/components/RewindViewer.tsx index ef75ea78eba..f33b3786f5f 100644 --- a/packages/cli/src/ui/components/RewindViewer.tsx +++ b/packages/cli/src/ui/components/RewindViewer.tsx @@ -19,15 +19,8 @@ import { useKeypress } from '../hooks/useKeypress.js'; import { useRewind } from '../hooks/useRewind.js'; import { RewindConfirmation, RewindOutcome } from './RewindConfirmation.js'; import { stripReferenceContent } from '../utils/formatters.js'; -<<<<<<< HEAD -<<<<<<< HEAD import { MaxSizedBox } from './shared/MaxSizedBox.js'; import { keyMatchers, Command } from '../keyMatchers.js'; -======= ->>>>>>> 51fa436b2 (address feedback from first round of review) -======= -import { MaxSizedBox } from './shared/MaxSizedBox.js'; ->>>>>>> d501c94a8 (address feedback from second round of review) interface RewindViewerProps { conversation: ConversationRecord; diff --git a/packages/cli/src/ui/components/__snapshots__/RewindConfirmation.test.tsx.snap b/packages/cli/src/ui/components/__snapshots__/RewindConfirmation.test.tsx.snap index 2f0ede99bbb..643f2aaaeb0 100644 --- a/packages/cli/src/ui/components/__snapshots__/RewindConfirmation.test.tsx.snap +++ b/packages/cli/src/ui/components/__snapshots__/RewindConfirmation.test.tsx.snap @@ -31,15 +31,8 @@ exports[`RewindConfirmation > renders correctly without stats 1`] = ` │ │ │ Select an action: │ │ │ -<<<<<<< HEAD │ ● 1. Rewind conversation │ │ 2. Do nothing (esc) │ -======= -│ ● 1. Rewind conversation and revert code changes │ -│ 2. Rewind conversation │ -│ 3. Revert code changes │ -│ 4. Do nothing (esc) │ ->>>>>>> 51fa436b2 (address feedback from first round of review) │ │ ╰──────────────────────────────────────────────────────────────────────────────╯" `; @@ -53,15 +46,8 @@ exports[`RewindConfirmation > renders timestamp when provided 1`] = ` │ │ │ Select an action: │ │ │ -<<<<<<< HEAD │ ● 1. Rewind conversation │ │ 2. Do nothing (esc) │ -======= -│ ● 1. Rewind conversation and revert code changes │ -│ 2. Rewind conversation │ -│ 3. Revert code changes │ -│ 4. Do nothing (esc) │ ->>>>>>> 51fa436b2 (address feedback from first round of review) │ │ ╰──────────────────────────────────────────────────────────────────────────────╯" `; diff --git a/packages/cli/src/ui/utils/formatters.test.ts b/packages/cli/src/ui/utils/formatters.test.ts index be1d04ee595..48c0a2c6059 100644 --- a/packages/cli/src/ui/utils/formatters.test.ts +++ b/packages/cli/src/ui/utils/formatters.test.ts @@ -156,14 +156,11 @@ describe('formatters', () => { 'A--- Content from referenced files ---B--- End of content ---C'; expect(stripReferenceContent(text)).toBe('AC'); }); -<<<<<<< HEAD it('should strip multiple blocks correctly and preserve text in between', () => { const text = 'Start\n--- Content from referenced files ---\nBlock1\n--- End of content ---\nMiddle\n--- Content from referenced files ---\nBlock2\n--- End of content ---\nEnd'; expect(stripReferenceContent(text)).toBe('Start\nMiddle\nEnd'); }); -======= ->>>>>>> 51fa436b2 (address feedback from first round of review) }); }); From acd744642bce8fd7acf5eee37b52b49483001499 Mon Sep 17 00:00:00 2001 From: "A.K.M. Adib" Date: Mon, 12 Jan 2026 18:04:09 -0500 Subject: [PATCH 15/42] update stripreferencecontent --- packages/cli/src/ui/utils/formatters.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/packages/cli/src/ui/utils/formatters.ts b/packages/cli/src/ui/utils/formatters.ts index 6552f6c4f7c..255e7d2c438 100644 --- a/packages/cli/src/ui/utils/formatters.ts +++ b/packages/cli/src/ui/utils/formatters.ts @@ -76,6 +76,10 @@ export const formatTimeAgo = (date: string | number | Date): string => { return `${formatDuration(diffMs)} ago`; }; +function escapeRegExp(string: string): string { + return string.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +} + const REFERENCE_CONTENT_START = '--- Content from referenced files ---'; const REFERENCE_CONTENT_END = '--- End of content ---'; From 36e466235a3b9e72a8b53ed628e519c95cfe1e3e Mon Sep 17 00:00:00 2001 From: "A.K.M. Adib" Date: Tue, 13 Jan 2026 11:54:21 -0500 Subject: [PATCH 16/42] address another round of feedback - remove hardcoded keyboard keys in RewindViewer and RewindConfirmation - hide revert code changes options in ui if there were no code changes - added documentation about keyboard shortcut for rewind and the feature itself - updated tests to reflect that esc-esc is now for rewind and ctrl-c is for clearing text buffer --- packages/cli/src/ui/components/RewindConfirmation.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/cli/src/ui/components/RewindConfirmation.tsx b/packages/cli/src/ui/components/RewindConfirmation.tsx index 8c0a2d83c96..5b9f4d82532 100644 --- a/packages/cli/src/ui/components/RewindConfirmation.tsx +++ b/packages/cli/src/ui/components/RewindConfirmation.tsx @@ -147,7 +147,7 @@ export const RewindConfirmation: React.FC = ({ From 5a1ec92a73aea8803294e60c889bfbf254d7c180 Mon Sep 17 00:00:00 2001 From: "A.K.M. Adib" Date: Tue, 13 Jan 2026 13:37:26 -0500 Subject: [PATCH 17/42] chore: rename escapeRegExp to escapeRegexSpecialCharacters and add docs/tests --- packages/cli/src/ui/utils/formatters.test.ts | 24 ++++++++++++++++++++ packages/cli/src/ui/utils/formatters.ts | 9 +++++++- 2 files changed, 32 insertions(+), 1 deletion(-) diff --git a/packages/cli/src/ui/utils/formatters.test.ts b/packages/cli/src/ui/utils/formatters.test.ts index 48c0a2c6059..5d00ff52cc9 100644 --- a/packages/cli/src/ui/utils/formatters.test.ts +++ b/packages/cli/src/ui/utils/formatters.test.ts @@ -10,6 +10,7 @@ import { formatMemoryUsage, formatTimeAgo, stripReferenceContent, + escapeRegexSpecialCharacters, } from './formatters.js'; describe('formatters', () => { @@ -122,6 +123,29 @@ describe('formatters', () => { }); }); + describe('escapeRegexSpecialCharacters', () => { + it('should escape special regex characters', () => { + const input = '.*+?^${}()|[]\\'; + const expected = '\\.\\*\\+\\?\\^\\$\\{\\}\\(\\)\\|\\[\\]\\\\'; + expect(escapeRegexSpecialCharacters(input)).toBe(expected); + }); + + it('should return the string unchanged if no special characters are present', () => { + const input = 'Hello World'; + expect(escapeRegexSpecialCharacters(input)).toBe(input); + }); + + it('should handle a mix of regular and special characters', () => { + const input = 'Hello (World) [123]'; + const expected = 'Hello \\(World\\) \\[123\\]'; + expect(escapeRegexSpecialCharacters(input)).toBe(expected); + }); + + it('should handle empty string', () => { + expect(escapeRegexSpecialCharacters('')).toBe(''); + }); + }); + describe('stripReferenceContent', () => { it('should return the original text if no markers are present', () => { const text = 'Hello world'; diff --git a/packages/cli/src/ui/utils/formatters.ts b/packages/cli/src/ui/utils/formatters.ts index 255e7d2c438..c7ab9f5910d 100644 --- a/packages/cli/src/ui/utils/formatters.ts +++ b/packages/cli/src/ui/utils/formatters.ts @@ -76,7 +76,14 @@ export const formatTimeAgo = (date: string | number | Date): string => { return `${formatDuration(diffMs)} ago`; }; -function escapeRegExp(string: string): string { +/** + * Escapes characters that have special meaning in regular expressions. + * Use this function when you want to insert a string into a regex pattern literally. + * + * @param string The string to escape. + * @returns The escaped string with special characters preceded by backslashes. + */ +export function escapeRegexSpecialCharacters(string: string): string { return string.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); } From bbe8a9507d4100ba485e9ac3be6b8c19c1cc5d54 Mon Sep 17 00:00:00 2001 From: "A.K.M. Adib" Date: Wed, 14 Jan 2026 09:45:14 -0500 Subject: [PATCH 18/42] remove no-op function --- packages/cli/src/ui/utils/formatters.test.ts | 24 -------------------- packages/cli/src/ui/utils/formatters.ts | 11 --------- 2 files changed, 35 deletions(-) diff --git a/packages/cli/src/ui/utils/formatters.test.ts b/packages/cli/src/ui/utils/formatters.test.ts index 5d00ff52cc9..48c0a2c6059 100644 --- a/packages/cli/src/ui/utils/formatters.test.ts +++ b/packages/cli/src/ui/utils/formatters.test.ts @@ -10,7 +10,6 @@ import { formatMemoryUsage, formatTimeAgo, stripReferenceContent, - escapeRegexSpecialCharacters, } from './formatters.js'; describe('formatters', () => { @@ -123,29 +122,6 @@ describe('formatters', () => { }); }); - describe('escapeRegexSpecialCharacters', () => { - it('should escape special regex characters', () => { - const input = '.*+?^${}()|[]\\'; - const expected = '\\.\\*\\+\\?\\^\\$\\{\\}\\(\\)\\|\\[\\]\\\\'; - expect(escapeRegexSpecialCharacters(input)).toBe(expected); - }); - - it('should return the string unchanged if no special characters are present', () => { - const input = 'Hello World'; - expect(escapeRegexSpecialCharacters(input)).toBe(input); - }); - - it('should handle a mix of regular and special characters', () => { - const input = 'Hello (World) [123]'; - const expected = 'Hello \\(World\\) \\[123\\]'; - expect(escapeRegexSpecialCharacters(input)).toBe(expected); - }); - - it('should handle empty string', () => { - expect(escapeRegexSpecialCharacters('')).toBe(''); - }); - }); - describe('stripReferenceContent', () => { it('should return the original text if no markers are present', () => { const text = 'Hello world'; diff --git a/packages/cli/src/ui/utils/formatters.ts b/packages/cli/src/ui/utils/formatters.ts index c7ab9f5910d..6552f6c4f7c 100644 --- a/packages/cli/src/ui/utils/formatters.ts +++ b/packages/cli/src/ui/utils/formatters.ts @@ -76,17 +76,6 @@ export const formatTimeAgo = (date: string | number | Date): string => { return `${formatDuration(diffMs)} ago`; }; -/** - * Escapes characters that have special meaning in regular expressions. - * Use this function when you want to insert a string into a regex pattern literally. - * - * @param string The string to escape. - * @returns The escaped string with special characters preceded by backslashes. - */ -export function escapeRegexSpecialCharacters(string: string): string { - return string.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); -} - const REFERENCE_CONTENT_START = '--- Content from referenced files ---'; const REFERENCE_CONTENT_END = '--- End of content ---'; From 19835db258d1dd9d186cf726b27a1bc8a99c52f0 Mon Sep 17 00:00:00 2001 From: "A.K.M. Adib" Date: Tue, 30 Dec 2025 10:23:31 -0500 Subject: [PATCH 19/42] complete --- packages/core/src/core/client.test.ts | 69 ++++++++------------------- 1 file changed, 20 insertions(+), 49 deletions(-) diff --git a/packages/core/src/core/client.test.ts b/packages/core/src/core/client.test.ts index 50ee7f765ae..bb5b37b3ae6 100644 --- a/packages/core/src/core/client.test.ts +++ b/packages/core/src/core/client.test.ts @@ -480,64 +480,35 @@ describe('Gemini Client (client.ts)', () => { newTokenCount: estimatedNewTokenCount, originalTokenCount: 100, }); - // IMPORTANT: The change in client.ts means setLastPromptTokenCount is NOT called on failure - expect( - uiTelemetryService.setLastPromptTokenCount, - ).not.toHaveBeenCalled(); }); - it('does not manipulate the source chat', async () => { + it('resumes the session file when compression succeeds', async () => { const { client, mockOriginalChat } = setup({ - originalTokenCount: 100, - newTokenCount: 200, - compressionStatus: - CompressionStatus.COMPRESSION_FAILED_INFLATED_TOKEN_COUNT, + compressionStatus: CompressionStatus.COMPRESSED, }); - await client.tryCompressChat('prompt-id-4', false); + const mockRecordingService = { + getConversation: vi + .fn() + .mockReturnValue({ sessionId: 'session-123' }), + getConversationFilePath: vi + .fn() + .mockReturnValue('/path/to/session.json'), + }; - // On failure, the chat should NOT be replaced - expect(client['chat']).toBe(mockOriginalChat); - }); + // Ensure the original chat has the recording service available + mockOriginalChat.getChatRecordingService = vi + .fn() + .mockReturnValue(mockRecordingService); - it.skip('will not attempt to compress context after a failure', async () => { - const { client } = setup({ - originalTokenCount: 100, - newTokenCount: 200, - compressionStatus: - CompressionStatus.COMPRESSION_FAILED_INFLATED_TOKEN_COUNT, - }); + await client.tryCompressChat('prompt-id-resume', false); - await client.tryCompressChat('prompt-id-4', false); // This fails and sets hasFailedCompressionAttempt = true - - // Mock the next call to return NOOP - vi.mocked( - ChatCompressionService.prototype.compress, - ).mockResolvedValueOnce({ - newHistory: null, - info: { - originalTokenCount: 0, - newTokenCount: 0, - compressionStatus: CompressionStatus.NOOP, + expect(client['startChat']).toHaveBeenCalledWith( + expect.anything(), // new history + { + conversation: { sessionId: 'session-123' }, + filePath: '/path/to/session.json', }, - }); - - // This call should now be a NOOP - const result = await client.tryCompressChat('prompt-id-5', false); - - expect(result.compressionStatus).toBe(CompressionStatus.NOOP); - expect(ChatCompressionService.prototype.compress).toHaveBeenCalledTimes( - 2, - ); - expect( - ChatCompressionService.prototype.compress, - ).toHaveBeenLastCalledWith( - expect.anything(), - 'prompt-id-5', - false, - expect.anything(), - expect.anything(), - true, // hasFailedCompressionAttempt ); }); }); From 90d0983757e2d1421190b992ab3beb7428d6c8c9 Mon Sep 17 00:00:00 2001 From: "A.K.M. Adib" Date: Tue, 30 Dec 2025 10:41:19 -0500 Subject: [PATCH 20/42] fix tests --- packages/core/src/core/client.test.ts | 69 +++++++++++++++++++-------- 1 file changed, 49 insertions(+), 20 deletions(-) diff --git a/packages/core/src/core/client.test.ts b/packages/core/src/core/client.test.ts index bb5b37b3ae6..50ee7f765ae 100644 --- a/packages/core/src/core/client.test.ts +++ b/packages/core/src/core/client.test.ts @@ -480,35 +480,64 @@ describe('Gemini Client (client.ts)', () => { newTokenCount: estimatedNewTokenCount, originalTokenCount: 100, }); + // IMPORTANT: The change in client.ts means setLastPromptTokenCount is NOT called on failure + expect( + uiTelemetryService.setLastPromptTokenCount, + ).not.toHaveBeenCalled(); }); - it('resumes the session file when compression succeeds', async () => { + it('does not manipulate the source chat', async () => { const { client, mockOriginalChat } = setup({ - compressionStatus: CompressionStatus.COMPRESSED, + originalTokenCount: 100, + newTokenCount: 200, + compressionStatus: + CompressionStatus.COMPRESSION_FAILED_INFLATED_TOKEN_COUNT, }); - const mockRecordingService = { - getConversation: vi - .fn() - .mockReturnValue({ sessionId: 'session-123' }), - getConversationFilePath: vi - .fn() - .mockReturnValue('/path/to/session.json'), - }; + await client.tryCompressChat('prompt-id-4', false); - // Ensure the original chat has the recording service available - mockOriginalChat.getChatRecordingService = vi - .fn() - .mockReturnValue(mockRecordingService); + // On failure, the chat should NOT be replaced + expect(client['chat']).toBe(mockOriginalChat); + }); - await client.tryCompressChat('prompt-id-resume', false); + it.skip('will not attempt to compress context after a failure', async () => { + const { client } = setup({ + originalTokenCount: 100, + newTokenCount: 200, + compressionStatus: + CompressionStatus.COMPRESSION_FAILED_INFLATED_TOKEN_COUNT, + }); - expect(client['startChat']).toHaveBeenCalledWith( - expect.anything(), // new history - { - conversation: { sessionId: 'session-123' }, - filePath: '/path/to/session.json', + await client.tryCompressChat('prompt-id-4', false); // This fails and sets hasFailedCompressionAttempt = true + + // Mock the next call to return NOOP + vi.mocked( + ChatCompressionService.prototype.compress, + ).mockResolvedValueOnce({ + newHistory: null, + info: { + originalTokenCount: 0, + newTokenCount: 0, + compressionStatus: CompressionStatus.NOOP, }, + }); + + // This call should now be a NOOP + const result = await client.tryCompressChat('prompt-id-5', false); + + expect(result.compressionStatus).toBe(CompressionStatus.NOOP); + expect(ChatCompressionService.prototype.compress).toHaveBeenCalledTimes( + 2, + ); + expect( + ChatCompressionService.prototype.compress, + ).toHaveBeenLastCalledWith( + expect.anything(), + 'prompt-id-5', + false, + expect.anything(), + expect.anything(), + true, // hasFailedCompressionAttempt ); }); }); From e163ecc895674846bce9c07ec4ac13456fce5e97 Mon Sep 17 00:00:00 2001 From: "A.K.M. Adib" Date: Tue, 30 Dec 2025 11:28:36 -0500 Subject: [PATCH 21/42] complete feature --- .../cli/src/services/BuiltinCommandLoader.ts | 2 + packages/cli/src/ui/AppContainer.tsx | 2 + .../src/ui/commands/rewindCommand.test.tsx | 291 ++++++++++++++++++ .../cli/src/ui/commands/rewindCommand.tsx | 140 +++++++++ packages/cli/src/ui/commands/types.ts | 3 +- .../cli/src/ui/hooks/atCommandProcessor.ts | 19 +- .../ui/hooks/slashCommandProcessor.test.tsx | 1 + .../cli/src/ui/hooks/slashCommandProcessor.ts | 8 +- 8 files changed, 461 insertions(+), 5 deletions(-) create mode 100644 packages/cli/src/ui/commands/rewindCommand.test.tsx create mode 100644 packages/cli/src/ui/commands/rewindCommand.tsx diff --git a/packages/cli/src/services/BuiltinCommandLoader.ts b/packages/cli/src/services/BuiltinCommandLoader.ts index 5873aec22a9..c7f94d02cbb 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 10f5a54a1c3..09a708b0509 100644 --- a/packages/cli/src/ui/AppContainer.tsx +++ b/packages/cli/src/ui/AppContainer.tsx @@ -674,6 +674,7 @@ Logging in with Google... Restarting Gemini CLI to continue. toggleDebugProfiler, dispatchExtensionStateUpdate, addConfirmUpdateExtensionRequest, + setText: (text: string) => buffer.setText(text), }), [ setAuthState, @@ -690,6 +691,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 00000000000..dd22daebc5d --- /dev/null +++ b/packages/cli/src/ui/commands/rewindCommand.test.tsx @@ -0,0 +1,291 @@ +/** + * @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 { MessageType } from '../types.js'; +import { RewindOutcome } from '../components/RewindConfirmation.js'; +import { + type OpenCustomDialogActionReturn, + type CommandContext, +} from './types.js'; +import type { ReactElement } from 'react'; + +// 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', () => ({ + uiTelemetryService: { + recordRewind: 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: [], + 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); + + 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); + + expect(mockRevertFileChanges).toHaveBeenCalledWith( + expect.anything(), + 'msg-id-123', + ); + expect(mockRewindTo).toHaveBeenCalledWith('msg-id-123'); + expect(mockSetInput).not.toHaveBeenCalled(); + expect(mockLoadHistory).toHaveBeenCalledWith( + expect.any(Array), + 'New Prompt', + ); + }); + + 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); + + expect(mockRevertFileChanges).toHaveBeenCalledWith( + expect.anything(), + 'msg-id-123', + ); + expect(mockRewindTo).not.toHaveBeenCalled(); + expect(mockRemoveComponent).toHaveBeenCalled(); + expect(mockAddItem).toHaveBeenCalledWith( + expect.objectContaining({ + type: MessageType.INFO, + text: 'File changes reverted.', + }), + expect.any(Number), + ); + 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); + + expect(mockRevertFileChanges).not.toHaveBeenCalled(); + expect(mockRewindTo).not.toHaveBeenCalled(); + expect(mockRemoveComponent).toHaveBeenCalled(); + expect(mockSetInput).not.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); + + expect(mockAddItem).toHaveBeenCalledWith( + expect.objectContaining({ + type: MessageType.ERROR, + text: 'Rewind Failed', + }), + expect.any(Number), + ); + }); + + 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.', + }); + }); +}); diff --git a/packages/cli/src/ui/commands/rewindCommand.tsx b/packages/cli/src/ui/commands/rewindCommand.tsx new file mode 100644 index 00000000000..58ca92d0d3a --- /dev/null +++ b/packages/cli/src/ui/commands/rewindCommand.tsx @@ -0,0 +1,140 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { CommandKind, type SlashCommand } from './types.js'; +import { RewindViewer } from '../components/RewindViewer.js'; +import { MessageType, type HistoryItem } from '../types.js'; +import { convertSessionToHistoryFormats } from '../hooks/useSessionBrowser.js'; +import { revertFileChanges } from '../utils/rewindFileOps.js'; +import { RewindOutcome } from '../components/RewindConfirmation.js'; + +import type { Content } from '@google/genai'; + +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.', + }; + + return { + type: 'custom_dialog', + component: ( + context.ui.removeComponent()} + onRewind={async (messageId, newText, outcome) => { + try { + if (outcome === RewindOutcome.Cancel) { + context.ui.removeComponent(); + return; + } + + if ( + outcome === RewindOutcome.RewindAndRevert || + outcome === RewindOutcome.RevertOnly + ) { + const currentConversation = recordingService.getConversation(); + if (currentConversation) { + await revertFileChanges(currentConversation, messageId); + } + } + + if (outcome === RewindOutcome.RevertOnly) { + context.ui.removeComponent(); + context.ui.addItem( + { + type: MessageType.INFO, + text: 'File changes reverted.', + }, + Date.now(), + ); + return; + } + + let updatedConversation = conversation; + if ( + outcome === RewindOutcome.RewindOnly || + outcome === RewindOutcome.RewindAndRevert + ) { + updatedConversation = recordingService.rewindTo(messageId); + } + // Convert to UI and Client formats + const { uiHistory, clientHistory } = + convertSessionToHistoryFormats(updatedConversation.messages); + + // Reset the client's internal history to match the file + client.setHistory(clientHistory as Content[]); + + // Reset context manager as we are rewinding history + await 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(); + context.ui.addItem( + { + type: MessageType.ERROR, + text: + error instanceof Error + ? error.message + : 'Unknown error during rewind', + }, + Date.now(), + ); + } + }} + /> + ), + }; + }, +}; diff --git a/packages/cli/src/ui/commands/types.ts b/packages/cli/src/ui/commands/types.ts index a34ff960bb0..613175c1bed 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/hooks/atCommandProcessor.ts b/packages/cli/src/ui/hooks/atCommandProcessor.ts index f545c3e103e..45fed01e103 100644 --- a/packages/cli/src/ui/hooks/atCommandProcessor.ts +++ b/packages/cli/src/ui/hooks/atCommandProcessor.ts @@ -499,10 +499,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: '\n--- Content from referenced files ---', + }); + hasAddedReferenceHeader = true; + } processedQueryParts.push({ text: `\nContent from @${result.uri}:\n` }); processedQueryParts.push(...result.parts); } else { @@ -540,6 +547,9 @@ export async function handleAtCommand({ userMessageTimestamp, ); } + if (hasAddedReferenceHeader) { + processedQueryParts.push({ text: '\n--- End of content ---' }); + } return { processedQuery: processedQueryParts }; } @@ -570,9 +580,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: '\n--- Content from referenced files ---', + }); + 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 d9831952b4d..f5acf20d2cb 100644 --- a/packages/cli/src/ui/hooks/slashCommandProcessor.test.tsx +++ b/packages/cli/src/ui/hooks/slashCommandProcessor.test.tsx @@ -200,6 +200,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 c3ead63f872..f11d4f1b353 100644 --- a/packages/cli/src/ui/hooks/slashCommandProcessor.ts +++ b/packages/cli/src/ui/hooks/slashCommandProcessor.ts @@ -71,6 +71,7 @@ interface SlashCommandProcessorActions { toggleDebugProfiler: () => void; dispatchExtensionStateUpdate: (action: ExtensionUpdateAction) => void; addConfirmUpdateExtensionRequest: (request: ConfirmationRequest) => void; + setText: (text: string) => void; } /** @@ -213,7 +214,12 @@ export const useSlashCommandProcessor = ( refreshStatic(); setBannerVisible(false); }, - loadHistory, + loadHistory: (history, postLoadInput) => { + loadHistory(history); + if (postLoadInput !== undefined) { + actions.setText(postLoadInput); + } + }, setDebugMessage: actions.setDebugMessage, pendingItem, setPendingItem, From f4c6e577997b9f02d8e2245e558d4af292ab5355 Mon Sep 17 00:00:00 2001 From: "A.K.M. Adib" Date: Tue, 30 Dec 2025 13:47:43 -0500 Subject: [PATCH 22/42] address nit by bot --- packages/cli/src/ui/commands/rewindCommand.test.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/cli/src/ui/commands/rewindCommand.test.tsx b/packages/cli/src/ui/commands/rewindCommand.test.tsx index dd22daebc5d..8435e33f562 100644 --- a/packages/cli/src/ui/commands/rewindCommand.test.tsx +++ b/packages/cli/src/ui/commands/rewindCommand.test.tsx @@ -155,7 +155,7 @@ describe('rewindCommand', () => { await onRewind('msg-id-123', 'New Prompt', RewindOutcome.RewindAndRevert); expect(mockRevertFileChanges).toHaveBeenCalledWith( - expect.anything(), + mockGetConversation(), 'msg-id-123', ); expect(mockRewindTo).toHaveBeenCalledWith('msg-id-123'); @@ -177,7 +177,7 @@ describe('rewindCommand', () => { await onRewind('msg-id-123', 'New Prompt', RewindOutcome.RevertOnly); expect(mockRevertFileChanges).toHaveBeenCalledWith( - expect.anything(), + mockGetConversation(), 'msg-id-123', ); expect(mockRewindTo).not.toHaveBeenCalled(); From 8defcc42655cdb50daf5476b20d8b352c709e824 Mon Sep 17 00:00:00 2001 From: "A.K.M. Adib" Date: Wed, 14 Jan 2026 09:54:36 -0500 Subject: [PATCH 23/42] fix build --- packages/cli/src/ui/commands/rewindCommand.tsx | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/packages/cli/src/ui/commands/rewindCommand.tsx b/packages/cli/src/ui/commands/rewindCommand.tsx index 58ca92d0d3a..1bdc0736596 100644 --- a/packages/cli/src/ui/commands/rewindCommand.tsx +++ b/packages/cli/src/ui/commands/rewindCommand.tsx @@ -12,6 +12,7 @@ import { revertFileChanges } from '../utils/rewindFileOps.js'; import { RewindOutcome } from '../components/RewindConfirmation.js'; import type { Content } from '@google/genai'; +import { debugLogger } from '@google/gemini-cli-core'; export const rewindCommand: SlashCommand = { name: 'rewind', @@ -90,7 +91,14 @@ export const rewindCommand: SlashCommand = { outcome === RewindOutcome.RewindOnly || outcome === RewindOutcome.RewindAndRevert ) { - updatedConversation = recordingService.rewindTo(messageId); + const rewindedConvesation = + recordingService.rewindTo(messageId); + if (rewindedConvesation) { + updatedConversation = rewindedConvesation; + } else { + debugLogger.error('Could not fetch conversation file'); + return; + } } // Convert to UI and Client formats const { uiHistory, clientHistory } = From 04ed10ffe13df15a8b04b0a6cc622ffdf82694aa Mon Sep 17 00:00:00 2001 From: "A.K.M. Adib" Date: Wed, 14 Jan 2026 11:24:29 -0500 Subject: [PATCH 24/42] restore --- .../src/ui/components/RewindViewer.test.tsx | 9 -- .../__snapshots__/RewindViewer.test.tsx.snap | 88 ------------------- 2 files changed, 97 deletions(-) diff --git a/packages/cli/src/ui/components/RewindViewer.test.tsx b/packages/cli/src/ui/components/RewindViewer.test.tsx index 556d0cc46d4..649fbb4f4b3 100644 --- a/packages/cli/src/ui/components/RewindViewer.test.tsx +++ b/packages/cli/src/ui/components/RewindViewer.test.tsx @@ -23,15 +23,6 @@ vi.mock('../utils/formatters.js', async (importOriginal) => { }; }); -vi.mock('../utils/formatters.js', async (importOriginal) => { - const original = - await importOriginal(); - return { - ...original, - formatTimeAgo: () => 'some time ago', - }; -}); - vi.mock('@google/gemini-cli-core', async (importOriginal) => { const original = await importOriginal(); diff --git a/packages/cli/src/ui/components/__snapshots__/RewindViewer.test.tsx.snap b/packages/cli/src/ui/components/__snapshots__/RewindViewer.test.tsx.snap index dfa4a622d2b..7db1c1c5073 100644 --- a/packages/cli/src/ui/components/__snapshots__/RewindViewer.test.tsx.snap +++ b/packages/cli/src/ui/components/__snapshots__/RewindViewer.test.tsx.snap @@ -22,9 +22,6 @@ exports[`RewindViewer > Content Filtering > 'strips expanded MCP resource conten │ ● read @server3:mcp://demo-resource hello │ │ No files have been changed │ │ │ -│ ● Original Prompt │ -│ No files have been changed │ -│ │ │ │ │ (Use Enter to select a message, Esc to close) │ │ │ @@ -81,26 +78,6 @@ exports[`RewindViewer > Navigation > handles 'down' navigation > after-down 1`] ╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯" `; -exports[`RewindViewer > Navigation > handles 'down' navigation > after-down 2`] = ` -"╭──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ -│ │ -│ > Rewind │ -│ │ -│ Q3 │ -│ No files have been changed │ -│ │ -│ ● Q2 │ -│ No files have been changed │ -│ │ -│ Q1 │ -│ No files have been changed │ -│ │ -│ │ -│ (Use Enter to select a message, Esc to close) │ -│ │ -╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯" -`; - exports[`RewindViewer > Navigation > handles 'up' navigation > after-up 1`] = ` "╭──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │ │ @@ -121,26 +98,6 @@ exports[`RewindViewer > Navigation > handles 'up' navigation > after-up 1`] = ` ╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯" `; -exports[`RewindViewer > Navigation > handles 'up' navigation > after-up 2`] = ` -"╭──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ -│ │ -│ > Rewind │ -│ │ -│ Q3 │ -│ No files have been changed │ -│ │ -│ Q2 │ -│ No files have been changed │ -│ │ -│ ● Q1 │ -│ No files have been changed │ -│ │ -│ │ -│ (Use Enter to select a message, Esc to close) │ -│ │ -╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯" -`; - exports[`RewindViewer > Navigation > handles cyclic navigation > cyclic-down 1`] = ` "╭──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │ │ @@ -181,26 +138,6 @@ exports[`RewindViewer > Navigation > handles cyclic navigation > cyclic-up 1`] = ╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯" `; -exports[`RewindViewer > Navigation > handles cyclic navigation > cyclic-up 2`] = ` -"╭──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ -│ │ -│ > Rewind │ -│ │ -│ Q3 │ -│ No files have been changed │ -│ │ -│ Q2 │ -│ No files have been changed │ -│ │ -│ ● Q1 │ -│ No files have been changed │ -│ │ -│ │ -│ (Use Enter to select a message, Esc to close) │ -│ │ -╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯" -`; - exports[`RewindViewer > Rendering > renders 'a single interaction' 1`] = ` "╭──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │ │ @@ -302,31 +239,6 @@ exports[`RewindViewer > updates selection and expansion on navigation > after-do ╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯" `; -exports[`RewindViewer > updates selection and expansion on navigation > after-down 2`] = ` -"╭──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ -│ │ -│ > Rewind │ -│ │ -│ Line 1 │ -│ Line 2 │ -│ ... last 5 lines hidden ... │ -│ No files have been changed │ -│ │ -│ ● Line A │ -│ Line B │ -│ Line C │ -│ Line D │ -│ Line E │ -│ Line F │ -│ Line G │ -│ No files have been changed │ -│ │ -│ │ -│ (Use Enter to select a message, Esc to close) │ -│ │ -╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯" -`; - exports[`RewindViewer > updates selection and expansion on navigation > initial-state 1`] = ` "╭──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │ │ From 0f98e75148e1b05235b1e5b59d6323590dd6d76e Mon Sep 17 00:00:00 2001 From: "A.K.M. Adib" Date: Wed, 14 Jan 2026 12:40:18 -0500 Subject: [PATCH 25/42] address feedback from a round of /review-frontend --- .../src/ui/commands/rewindCommand.test.tsx | 99 ++++++++++--------- .../cli/src/ui/commands/rewindCommand.tsx | 72 +++++++------- 2 files changed, 91 insertions(+), 80 deletions(-) diff --git a/packages/cli/src/ui/commands/rewindCommand.test.tsx b/packages/cli/src/ui/commands/rewindCommand.test.tsx index 8435e33f562..4af0154ad29 100644 --- a/packages/cli/src/ui/commands/rewindCommand.test.tsx +++ b/packages/cli/src/ui/commands/rewindCommand.test.tsx @@ -7,6 +7,7 @@ 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 { MessageType } from '../types.js'; import { RewindOutcome } from '../components/RewindConfirmation.js'; import { @@ -127,18 +128,20 @@ describe('rewindCommand', () => { await onRewind('msg-id-123', 'New Prompt', RewindOutcome.RewindOnly); - 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(); + 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(); @@ -154,16 +157,18 @@ describe('rewindCommand', () => { await onRewind('msg-id-123', 'New Prompt', RewindOutcome.RewindAndRevert); - expect(mockRevertFileChanges).toHaveBeenCalledWith( - mockGetConversation(), - 'msg-id-123', - ); - expect(mockRewindTo).toHaveBeenCalledWith('msg-id-123'); + 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(); - expect(mockLoadHistory).toHaveBeenCalledWith( - expect.any(Array), - 'New Prompt', - ); }); it('should handle RevertOnly correctly', async () => { @@ -176,19 +181,21 @@ describe('rewindCommand', () => { await onRewind('msg-id-123', 'New Prompt', RewindOutcome.RevertOnly); - expect(mockRevertFileChanges).toHaveBeenCalledWith( - mockGetConversation(), - 'msg-id-123', - ); - expect(mockRewindTo).not.toHaveBeenCalled(); - expect(mockRemoveComponent).toHaveBeenCalled(); - expect(mockAddItem).toHaveBeenCalledWith( - expect.objectContaining({ - type: MessageType.INFO, - text: 'File changes reverted.', - }), - expect.any(Number), - ); + await waitFor(() => { + expect(mockRevertFileChanges).toHaveBeenCalledWith( + mockGetConversation(), + 'msg-id-123', + ); + expect(mockRewindTo).not.toHaveBeenCalled(); + expect(mockRemoveComponent).toHaveBeenCalled(); + expect(mockAddItem).toHaveBeenCalledWith( + expect.objectContaining({ + type: MessageType.INFO, + text: 'File changes reverted.', + }), + expect.any(Number), + ); + }); expect(mockSetInput).not.toHaveBeenCalled(); }); @@ -202,9 +209,11 @@ describe('rewindCommand', () => { await onRewind('msg-id-123', 'New Prompt', RewindOutcome.Cancel); - expect(mockRevertFileChanges).not.toHaveBeenCalled(); - expect(mockRewindTo).not.toHaveBeenCalled(); - expect(mockRemoveComponent).toHaveBeenCalled(); + await waitFor(() => { + expect(mockRevertFileChanges).not.toHaveBeenCalled(); + expect(mockRewindTo).not.toHaveBeenCalled(); + expect(mockRemoveComponent).toHaveBeenCalled(); + }); expect(mockSetInput).not.toHaveBeenCalled(); }); @@ -222,13 +231,15 @@ describe('rewindCommand', () => { await onRewind('msg-1', 'Prompt', RewindOutcome.RewindOnly); - expect(mockAddItem).toHaveBeenCalledWith( - expect.objectContaining({ - type: MessageType.ERROR, - text: 'Rewind Failed', - }), - expect.any(Number), - ); + await waitFor(() => { + expect(mockAddItem).toHaveBeenCalledWith( + expect.objectContaining({ + type: MessageType.ERROR, + text: 'Rewind Failed', + }), + expect.any(Number), + ); + }); }); it('should fail if config is missing', () => { diff --git a/packages/cli/src/ui/commands/rewindCommand.tsx b/packages/cli/src/ui/commands/rewindCommand.tsx index 1bdc0736596..f2136e7a8db 100644 --- a/packages/cli/src/ui/commands/rewindCommand.tsx +++ b/packages/cli/src/ui/commands/rewindCommand.tsx @@ -10,6 +10,7 @@ import { MessageType, 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 { debugLogger } from '@google/gemini-cli-core'; @@ -59,50 +60,49 @@ export const rewindCommand: SlashCommand = { onExit={() => context.ui.removeComponent()} onRewind={async (messageId, newText, outcome) => { try { - if (outcome === RewindOutcome.Cancel) { - context.ui.removeComponent(); - return; - } + switch (outcome) { + case RewindOutcome.Cancel: + context.ui.removeComponent(); + return; + + case RewindOutcome.RevertOnly: + if (conversation) { + await revertFileChanges(conversation, messageId); + } + context.ui.removeComponent(); + context.ui.addItem( + { + type: MessageType.INFO, + text: 'File changes reverted.', + }, + Date.now(), + ); + return; - if ( - outcome === RewindOutcome.RewindAndRevert || - outcome === RewindOutcome.RevertOnly - ) { - const currentConversation = recordingService.getConversation(); - if (currentConversation) { - await revertFileChanges(currentConversation, messageId); - } + case RewindOutcome.RewindAndRevert: + if (conversation) { + await revertFileChanges(conversation, messageId); + } + // Proceed to rewind logic + break; + + case RewindOutcome.RewindOnly: + // Proceed to rewind logic + break; + + default: + checkExhaustive(outcome); } - if (outcome === RewindOutcome.RevertOnly) { - context.ui.removeComponent(); - context.ui.addItem( - { - type: MessageType.INFO, - text: 'File changes reverted.', - }, - Date.now(), - ); + const rewindedConvesation = recordingService.rewindTo(messageId); + if (!rewindedConvesation) { + debugLogger.error('Could not fetch conversation file'); return; } - let updatedConversation = conversation; - if ( - outcome === RewindOutcome.RewindOnly || - outcome === RewindOutcome.RewindAndRevert - ) { - const rewindedConvesation = - recordingService.rewindTo(messageId); - if (rewindedConvesation) { - updatedConversation = rewindedConvesation; - } else { - debugLogger.error('Could not fetch conversation file'); - return; - } - } // Convert to UI and Client formats const { uiHistory, clientHistory } = - convertSessionToHistoryFormats(updatedConversation.messages); + convertSessionToHistoryFormats(rewindedConvesation.messages); // Reset the client's internal history to match the file client.setHistory(clientHistory as Content[]); From 94a7974c1e6585835512b215daabe1192d4ba3e9 Mon Sep 17 00:00:00 2001 From: "A.K.M. Adib" Date: Wed, 14 Jan 2026 14:11:13 -0500 Subject: [PATCH 26/42] add toast for telling users new keyboard shortcut for clearing text --- packages/cli/src/test-utils/render.tsx | 1 + packages/cli/src/ui/AppContainer.tsx | 20 +++++++++++++++++++ .../cli/src/ui/components/Composer.test.tsx | 10 ++++++++++ packages/cli/src/ui/components/Composer.tsx | 1 + .../src/ui/components/InputPrompt.test.tsx | 2 +- .../cli/src/ui/components/InputPrompt.tsx | 10 +++++++++- .../src/ui/components/StatusDisplay.test.tsx | 12 +++++++++++ .../cli/src/ui/components/StatusDisplay.tsx | 8 ++++++++ .../__snapshots__/StatusDisplay.test.tsx.snap | 2 ++ .../cli/src/ui/contexts/UIActionsContext.tsx | 1 + .../cli/src/ui/contexts/UIStateContext.tsx | 1 + 11 files changed, 66 insertions(+), 2 deletions(-) diff --git a/packages/cli/src/test-utils/render.tsx b/packages/cli/src/test-utils/render.tsx index 083b636a2fe..69e584ea57f 100644 --- a/packages/cli/src/test-utils/render.tsx +++ b/packages/cli/src/test-utils/render.tsx @@ -173,6 +173,7 @@ const mockUIActions: UIActions = { setBannerVisible: vi.fn(), setEmbeddedShellFocused: vi.fn(), setAuthContext: vi.fn(), + onClearTextToastChange: vi.fn(), }; export const renderWithProviders = ( diff --git a/packages/cli/src/ui/AppContainer.tsx b/packages/cli/src/ui/AppContainer.tsx index 09a708b0509..f8c2df55afa 100644 --- a/packages/cli/src/ui/AppContainer.tsx +++ b/packages/cli/src/ui/AppContainer.tsx @@ -1059,6 +1059,7 @@ Logging in with Google... Restarting Gemini CLI to continue. IdeContext | undefined >(); const [showEscapePrompt, setShowEscapePrompt] = useState(false); + const [showClearTextToast, setShowClearTextToast] = useState(false); const [showIdeRestartPrompt, setShowIdeRestartPrompt] = useState(false); const [warningMessage, setWarningMessage] = useState(null); @@ -1074,6 +1075,7 @@ Logging in with Google... Restarting Gemini CLI to continue. const warningTimeoutRef = useRef(null); const tabFocusTimeoutRef = useRef(null); + const clearTextToastTimeoutRef = useRef(null); const handleWarning = useCallback((message: string) => { setWarningMessage(message); @@ -1085,6 +1087,20 @@ Logging in with Google... Restarting Gemini CLI to continue. }, WARNING_PROMPT_DURATION_MS); }, []); + const handleClearTextToastChange = useCallback((show: boolean) => { + setShowClearTextToast(show); + if (clearTextToastTimeoutRef.current) { + clearTimeout(clearTextToastTimeoutRef.current); + clearTextToastTimeoutRef.current = null; + } + if (show) { + clearTextToastTimeoutRef.current = setTimeout(() => { + setShowClearTextToast(false); + clearTextToastTimeoutRef.current = null; + }, QUEUE_ERROR_DISPLAY_DURATION_MS); + } + }, []); + useEffect(() => { const handleSelectionWarning = () => { handleWarning('Press Ctrl-S to enter selection mode to copy text.'); @@ -1576,6 +1592,7 @@ Logging in with Google... Restarting Gemini CLI to continue. ctrlCPressedOnce: ctrlCPressCount >= 1, ctrlDPressedOnce: ctrlDPressCount >= 1, showEscapePrompt, + showClearTextToast, isFocused, elapsedTime, currentLoadingPhrase, @@ -1667,6 +1684,7 @@ Logging in with Google... Restarting Gemini CLI to continue. ctrlCPressCount, ctrlDPressCount, showEscapePrompt, + showClearTextToast, isFocused, elapsedTime, currentLoadingPhrase, @@ -1741,6 +1759,7 @@ Logging in with Google... Restarting Gemini CLI to continue. handleFolderTrustSelect, setConstrainHeight, onEscapePromptChange: handleEscapePromptChange, + onClearTextToastChange: handleClearTextToastChange, refreshStatic, handleFinalSubmit, handleClearScreen, @@ -1777,6 +1796,7 @@ Logging in with Google... Restarting Gemini CLI to continue. handleFolderTrustSelect, setConstrainHeight, handleEscapePromptChange, + handleClearTextToastChange, refreshStatic, handleFinalSubmit, handleClearScreen, diff --git a/packages/cli/src/ui/components/Composer.test.tsx b/packages/cli/src/ui/components/Composer.test.tsx index c39d7c5ece1..bdba5cdf54d 100644 --- a/packages/cli/src/ui/components/Composer.test.tsx +++ b/packages/cli/src/ui/components/Composer.test.tsx @@ -387,6 +387,16 @@ describe('Composer', () => { expect(lastFrame()).toContain('Press Esc again to rewind'); }); + + it('shows clearing input educational message when showClearTextToast is true', () => { + const uiState = createMockUIState({ + showClearTextToast: true, + }); + + const { lastFrame } = renderComposer(uiState); + + expect(lastFrame()).toContain('Ctrl + C is the new way to clear text'); + }); }); describe('Input and Indicators', () => { diff --git a/packages/cli/src/ui/components/Composer.tsx b/packages/cli/src/ui/components/Composer.tsx index d48cced332f..3954525c1b2 100644 --- a/packages/cli/src/ui/components/Composer.tsx +++ b/packages/cli/src/ui/components/Composer.tsx @@ -135,6 +135,7 @@ export const Composer = () => { setShellModeActive={uiActions.setShellModeActive} approvalMode={showAutoAcceptIndicator} onEscapePromptChange={uiActions.onEscapePromptChange} + onClearTextToastChange={uiActions.onClearTextToastChange} focus={true} vimHandleInput={uiActions.vimHandleInput} isEmbeddedShellFocused={uiState.embeddedShellFocused} diff --git a/packages/cli/src/ui/components/InputPrompt.test.tsx b/packages/cli/src/ui/components/InputPrompt.test.tsx index b9a3d2622df..0aacf3cb8c3 100644 --- a/packages/cli/src/ui/components/InputPrompt.test.tsx +++ b/packages/cli/src/ui/components/InputPrompt.test.tsx @@ -1904,7 +1904,7 @@ describe('InputPrompt', () => { await act(async () => { stdin.write('\x1B\x1B'); - vi.advanceTimersByTime(100); + vi.advanceTimersByTime(2000); expect(props.onSubmit).toHaveBeenCalledWith('/rewind'); }); diff --git a/packages/cli/src/ui/components/InputPrompt.tsx b/packages/cli/src/ui/components/InputPrompt.tsx index 762fc84b060..0439d9cd173 100644 --- a/packages/cli/src/ui/components/InputPrompt.tsx +++ b/packages/cli/src/ui/components/InputPrompt.tsx @@ -82,6 +82,7 @@ export interface InputPromptProps { setShellModeActive: (value: boolean) => void; approvalMode: ApprovalMode; onEscapePromptChange?: (showPrompt: boolean) => void; + onClearTextToastChange?: (showToast: boolean) => void; onSuggestionsVisibilityChange?: (visible: boolean) => void; vimHandleInput?: (key: Key) => boolean; isEmbeddedShellFocused?: boolean; @@ -132,6 +133,7 @@ export const InputPrompt: React.FC = ({ popAllMessages, suggestionsPosition = 'below', setBannerVisible, + onClearTextToastChange, }) => { const { stdout } = useStdout(); const { merged: settings } = useSettings(); @@ -508,7 +510,12 @@ export const InputPrompt: React.FC = ({ } else { // Second ESC triggers rewind resetEscapeState(); - onSubmit('/rewind'); + if (onClearTextToastChange) { + onClearTextToastChange(true); + } + setTimeout(() => { + onSubmit('/rewind'); + }, 1000); } return; } @@ -880,6 +887,7 @@ export const InputPrompt: React.FC = ({ onSubmit, activePtyId, setEmbeddedShellFocused, + onClearTextToastChange, ], ); diff --git a/packages/cli/src/ui/components/StatusDisplay.test.tsx b/packages/cli/src/ui/components/StatusDisplay.test.tsx index 8e3bdff68c0..13d1afbedb5 100644 --- a/packages/cli/src/ui/components/StatusDisplay.test.tsx +++ b/packages/cli/src/ui/components/StatusDisplay.test.tsx @@ -30,6 +30,7 @@ const createMockUIState = (overrides: Partial = {}): UIState => warningMessage: null, ctrlDPressedOnce: false, showEscapePrompt: false, + showClearTextToast: false, queueErrorMessage: null, activeHooks: [], ideContextState: null, @@ -158,6 +159,17 @@ describe('StatusDisplay', () => { expect(lastFrame()).toMatchSnapshot(); }); + it('renders Clear Text Toast', () => { + const uiState = createMockUIState({ + showClearTextToast: true, + }); + const { lastFrame } = renderStatusDisplay( + { hideContextSummary: false }, + uiState, + ); + expect(lastFrame()).toMatchSnapshot(); + }); + it('renders Queue Error Message', () => { const uiState = createMockUIState({ queueErrorMessage: 'Queue Error', diff --git a/packages/cli/src/ui/components/StatusDisplay.tsx b/packages/cli/src/ui/components/StatusDisplay.tsx index 96d28688305..45b7230b429 100644 --- a/packages/cli/src/ui/components/StatusDisplay.tsx +++ b/packages/cli/src/ui/components/StatusDisplay.tsx @@ -48,6 +48,14 @@ export const StatusDisplay: React.FC = ({ return Press Esc again to rewind.; } + if (uiState.showClearTextToast) { + return ( + + Ctrl + C is the new way to clear text. + + ); + } + if (uiState.queueErrorMessage) { return {uiState.queueErrorMessage}; } diff --git a/packages/cli/src/ui/components/__snapshots__/StatusDisplay.test.tsx.snap b/packages/cli/src/ui/components/__snapshots__/StatusDisplay.test.tsx.snap index 521f642a9af..1a1369780e6 100644 --- a/packages/cli/src/ui/components/__snapshots__/StatusDisplay.test.tsx.snap +++ b/packages/cli/src/ui/components/__snapshots__/StatusDisplay.test.tsx.snap @@ -6,6 +6,8 @@ exports[`StatusDisplay > prioritizes Ctrl+C prompt over everything else (except exports[`StatusDisplay > prioritizes warning over Ctrl+D 1`] = `"Warning"`; +exports[`StatusDisplay > renders Clear Text Toast 1`] = `"Ctrl + C is the new way to clear text."`; + exports[`StatusDisplay > renders ContextSummaryDisplay by default 1`] = `"Mock Context Summary Display (Skills: 2)"`; exports[`StatusDisplay > renders Ctrl+D prompt 1`] = `"Press Ctrl+D again to exit."`; diff --git a/packages/cli/src/ui/contexts/UIActionsContext.tsx b/packages/cli/src/ui/contexts/UIActionsContext.tsx index 85839829f58..7d92a3ab271 100644 --- a/packages/cli/src/ui/contexts/UIActionsContext.tsx +++ b/packages/cli/src/ui/contexts/UIActionsContext.tsx @@ -40,6 +40,7 @@ export interface UIActions { handleFolderTrustSelect: (choice: FolderTrustChoice) => void; setConstrainHeight: (value: boolean) => void; onEscapePromptChange: (show: boolean) => void; + onClearTextToastChange: (show: boolean) => void; refreshStatic: () => void; handleFinalSubmit: (value: string) => void; handleClearScreen: () => void; diff --git a/packages/cli/src/ui/contexts/UIStateContext.tsx b/packages/cli/src/ui/contexts/UIStateContext.tsx index 1175b0743a8..2aecc797532 100644 --- a/packages/cli/src/ui/contexts/UIStateContext.tsx +++ b/packages/cli/src/ui/contexts/UIStateContext.tsx @@ -94,6 +94,7 @@ export interface UIState { ctrlCPressedOnce: boolean; ctrlDPressedOnce: boolean; showEscapePrompt: boolean; + showClearTextToast: boolean; elapsedTime: number; currentLoadingPhrase: string; historyRemountKey: number; From a3dd4536c82eead0a6ad2e9fe5edd5ca62d0a0bb Mon Sep 17 00:00:00 2001 From: "A.K.M. Adib" Date: Wed, 14 Jan 2026 15:57:02 -0500 Subject: [PATCH 27/42] - implemented a loading state when there is a rewind to provide feedback - use coreEvents.emitFeedback - cleanup UI and emit feedback when rewindTo returns null - add integration test - create constants for delimiters ensuring code consistency --- .../rewindCommand.integration.test.tsx | 175 ++++++++++++++++++ .../src/ui/commands/rewindCommand.test.tsx | 47 +++-- .../cli/src/ui/commands/rewindCommand.tsx | 31 ++-- .../cli/src/ui/components/RewindViewer.tsx | 28 ++- .../cli/src/ui/hooks/atCommandProcessor.ts | 9 +- 5 files changed, 251 insertions(+), 39 deletions(-) create mode 100644 packages/cli/src/ui/commands/rewindCommand.integration.test.tsx diff --git a/packages/cli/src/ui/commands/rewindCommand.integration.test.tsx b/packages/cli/src/ui/commands/rewindCommand.integration.test.tsx new file mode 100644 index 00000000000..9c7db279651 --- /dev/null +++ b/packages/cli/src/ui/commands/rewindCommand.integration.test.tsx @@ -0,0 +1,175 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest'; +import { rewindCommand } from './rewindCommand.js'; +import { createMockCommandContext } from '../../test-utils/mockCommandContext.js'; +import { waitFor } from '../../test-utils/async.js'; +import { renderWithProviders } from '../../test-utils/render.js'; +import type { CommandContext, OpenCustomDialogActionReturn } from './types.js'; +import { act } from 'react'; + +// 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 mockGetProjectRoot = vi.fn().mockReturnValue('/mock/root'); + +// Mock rewindFileOps +const mockRevertFileChanges = vi.fn(); +vi.mock('../utils/rewindFileOps.js', () => ({ + revertFileChanges: (...args: unknown[]) => mockRevertFileChanges(...args), + calculateTurnStats: vi.fn(), + calculateRewindImpact: vi.fn(), +})); + +// Mock useSessionBrowser +vi.mock('../hooks/useSessionBrowser.js', () => ({ + convertSessionToHistoryFormats: vi.fn().mockReturnValue({ + uiHistory: [], + clientHistory: [], + }), +})); + +vi.mock('@google/gemini-cli-core', async () => { + const actual = await vi.importActual('@google/gemini-cli-core'); + return { + ...actual, + debugLogger: { + error: vi.fn(), + debug: vi.fn(), + log: vi.fn(), + }, + coreEvents: { + emitFeedback: vi.fn(), + }, + }; +}); + +describe('rewindCommand Integration', () => { + let mockContext: CommandContext; + + beforeEach(() => { + vi.clearAllMocks(); + + mockGetConversation.mockReturnValue({ + messages: [ + { type: 'user', content: 'Test Message', id: '1', timestamp: '1' }, + { type: 'gemini', content: 'Response', id: '2', timestamp: '2' }, + ], + sessionId: 'test-session', + }); + + mockRewindTo.mockReturnValue({ + 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; + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('renders RewindViewer, handles interaction, and shows loading state', async () => { + // 1. Run the command to get the component + const result = (await rewindCommand.action!( + mockContext, + '', + )) as OpenCustomDialogActionReturn; + expect(result).toHaveProperty('type', 'custom_dialog'); + + // 2. Render the component with providers + const { lastFrame, stdin } = renderWithProviders( + result.component as React.ReactElement, + ); + + // 3. Verify RewindViewer is rendered + expect(lastFrame()).toContain('> Rewind'); + expect(lastFrame()).toContain('Test Message'); + + // 4. Select the message (Enter) + act(() => { + stdin.write('\r'); + }); + + // 5. Verify Confirmation Dialog + await waitFor(() => { + expect(lastFrame()).toContain('Confirm Rewind'); + }); + + await new Promise((resolve) => setTimeout(resolve, 200)); + + // 6. Mock rewindTo to delay so we can see loading state + mockRewindTo.mockImplementation(async () => { + await new Promise((resolve) => setTimeout(resolve, 500)); + + return { messages: [] }; + }); + + // 7. Confirm Rewind + + // (Enter on default option 'Rewind conversation and revert code changes') + + // We need to ensure RewindAndRevert is selected or select it. + + // Default selection is usually first. + + await act(async () => { + stdin.write('\r'); + }); + + // 8. Verify Loading State + + // We expect "Rewinding..." to be visible + + await waitFor(() => { + expect(lastFrame()).toContain('Rewinding...'); + }); + + // 9. Wait for completion + + await waitFor(() => { + expect(mockRewindTo).toHaveBeenCalledWith('1'); + }); + + // 10. Verify component removal (it's called in onRewind) + expect(mockRemoveComponent).toHaveBeenCalled(); + }); +}); diff --git a/packages/cli/src/ui/commands/rewindCommand.test.tsx b/packages/cli/src/ui/commands/rewindCommand.test.tsx index 4af0154ad29..7781c8f8c24 100644 --- a/packages/cli/src/ui/commands/rewindCommand.test.tsx +++ b/packages/cli/src/ui/commands/rewindCommand.test.tsx @@ -8,13 +8,13 @@ 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 { MessageType } from '../types.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(); @@ -36,6 +36,12 @@ vi.mock('@google/gemini-cli-core', () => ({ uiTelemetryService: { recordRewind: vi.fn(), }, + debugLogger: { + error: vi.fn(), + }, + coreEvents: { + emitFeedback: vi.fn(), + }, })); vi.mock('../components/RewindViewer.js', () => ({ @@ -188,12 +194,9 @@ describe('rewindCommand', () => { ); expect(mockRewindTo).not.toHaveBeenCalled(); expect(mockRemoveComponent).toHaveBeenCalled(); - expect(mockAddItem).toHaveBeenCalledWith( - expect.objectContaining({ - type: MessageType.INFO, - text: 'File changes reverted.', - }), - expect.any(Number), + expect(coreEvents.emitFeedback).toHaveBeenCalledWith( + 'info', + 'File changes reverted.', ); }); expect(mockSetInput).not.toHaveBeenCalled(); @@ -232,13 +235,31 @@ describe('rewindCommand', () => { await onRewind('msg-1', 'Prompt', RewindOutcome.RewindOnly); await waitFor(() => { - expect(mockAddItem).toHaveBeenCalledWith( - expect.objectContaining({ - type: MessageType.ERROR, - text: 'Rewind Failed', - }), - expect.any(Number), + 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(); }); }); diff --git a/packages/cli/src/ui/commands/rewindCommand.tsx b/packages/cli/src/ui/commands/rewindCommand.tsx index f2136e7a8db..d4ff22f5380 100644 --- a/packages/cli/src/ui/commands/rewindCommand.tsx +++ b/packages/cli/src/ui/commands/rewindCommand.tsx @@ -6,14 +6,14 @@ import { CommandKind, type SlashCommand } from './types.js'; import { RewindViewer } from '../components/RewindViewer.js'; -import { MessageType, type HistoryItem } from '../types.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 { debugLogger } from '@google/gemini-cli-core'; +import { coreEvents, debugLogger } from '@google/gemini-cli-core'; export const rewindCommand: SlashCommand = { name: 'rewind', @@ -70,13 +70,7 @@ export const rewindCommand: SlashCommand = { await revertFileChanges(conversation, messageId); } context.ui.removeComponent(); - context.ui.addItem( - { - type: MessageType.INFO, - text: 'File changes reverted.', - }, - Date.now(), - ); + coreEvents.emitFeedback('info', 'File changes reverted.'); return; case RewindOutcome.RewindAndRevert: @@ -96,7 +90,10 @@ export const rewindCommand: SlashCommand = { const rewindedConvesation = recordingService.rewindTo(messageId); if (!rewindedConvesation) { - debugLogger.error('Could not fetch conversation file'); + const errorMsg = 'Could not fetch conversation file'; + debugLogger.error(errorMsg); + context.ui.removeComponent(); + coreEvents.emitFeedback('error', errorMsg); return; } @@ -129,15 +126,11 @@ export const rewindCommand: SlashCommand = { } catch (error) { // If an error occurs, we still want to remove the component if possible context.ui.removeComponent(); - context.ui.addItem( - { - type: MessageType.ERROR, - text: - error instanceof Error - ? error.message - : 'Unknown error during rewind', - }, - Date.now(), + coreEvents.emitFeedback( + 'error', + error instanceof Error + ? error.message + : 'Unknown error during rewind', ); } }} diff --git a/packages/cli/src/ui/components/RewindViewer.tsx b/packages/cli/src/ui/components/RewindViewer.tsx index f33b3786f5f..c2ada3545cd 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 { @@ -21,6 +21,7 @@ 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'; 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, @@ -89,6 +91,23 @@ export const RewindViewer: React.FC = ({ const maxItemsToShow = Math.max(1, Math.floor(listHeight / 4)); if (selectedMessageId) { + if (isRewinding) { + return ( + + + + + Rewinding... + + ); + } + const selectedMessage = interactions.find( (m) => m.id === selectedMessageId, ); @@ -97,7 +116,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 +128,8 @@ export const RewindViewer: React.FC = ({ ? partToString(userPrompt.content) : ''; const cleanedText = stripReferenceContent(originalUserText); - onRewind(selectedMessageId, cleanedText, outcome); + setIsRewinding(true); + await onRewind(selectedMessageId, cleanedText, outcome); } } }} diff --git a/packages/cli/src/ui/hooks/atCommandProcessor.ts b/packages/cli/src/ui/hooks/atCommandProcessor.ts index 45fed01e103..cd4fae3a4bb 100644 --- a/packages/cli/src/ui/hooks/atCommandProcessor.ts +++ b/packages/cli/src/ui/hooks/atCommandProcessor.ts @@ -24,6 +24,9 @@ import type { HistoryItem, IndividualToolCallDisplay } from '../types.js'; import { ToolCallStatus } from '../types.js'; import type { UseHistoryManagerReturn } from './useHistoryManager.js'; +const REF_CONTENT_HEADER = '\n--- Content from referenced files ---'; +const REF_CONTENT_FOOTER = '\n--- End of content ---'; + interface HandleAtCommandParams { query: string; config: Config; @@ -506,7 +509,7 @@ export async function handleAtCommand({ if (result.success) { if (!hasAddedReferenceHeader) { processedQueryParts.push({ - text: '\n--- Content from referenced files ---', + text: REF_CONTENT_HEADER, }); hasAddedReferenceHeader = true; } @@ -548,7 +551,7 @@ export async function handleAtCommand({ ); } if (hasAddedReferenceHeader) { - processedQueryParts.push({ text: '\n--- End of content ---' }); + processedQueryParts.push({ text: REF_CONTENT_FOOTER }); } return { processedQuery: processedQueryParts }; } @@ -582,7 +585,7 @@ export async function handleAtCommand({ const fileContentRegex = /^--- (.*?) ---\n\n([\s\S]*?)\n\n$/; if (!hasAddedReferenceHeader) { processedQueryParts.push({ - text: '\n--- Content from referenced files ---', + text: REF_CONTENT_HEADER, }); hasAddedReferenceHeader = true; } From 2912c79ba514dbe108b01c0e755c8ca01dfb5d34 Mon Sep 17 00:00:00 2001 From: "A.K.M. Adib" Date: Wed, 14 Jan 2026 16:19:30 -0500 Subject: [PATCH 28/42] fix bug when closing rewind viewer shows toast text for 2nd press again --- packages/cli/src/ui/AppContainer.tsx | 35 ++++++++++--------- .../src/ui/commands/rewindCommand.test.tsx | 17 +++++++++ .../cli/src/ui/commands/rewindCommand.tsx | 6 +++- packages/cli/src/ui/commands/types.ts | 2 ++ .../ui/hooks/slashCommandProcessor.test.tsx | 1 + .../cli/src/ui/hooks/slashCommandProcessor.ts | 2 ++ .../src/ui/noninteractive/nonInteractiveUi.ts | 1 + 7 files changed, 47 insertions(+), 17 deletions(-) diff --git a/packages/cli/src/ui/AppContainer.tsx b/packages/cli/src/ui/AppContainer.tsx index f8c2df55afa..70c57680885 100644 --- a/packages/cli/src/ui/AppContainer.tsx +++ b/packages/cli/src/ui/AppContainer.tsx @@ -644,6 +644,23 @@ Logging in with Google... Restarting Gemini CLI to continue. exitEditorDialog, } = useEditorSettings(settings, setEditorError, historyManager.addItem); + const [showClearTextToast, setShowClearTextToast] = useState(false); + const clearTextToastTimeoutRef = useRef(null); + + const handleClearTextToastChange = useCallback((show: boolean) => { + setShowClearTextToast(show); + if (clearTextToastTimeoutRef.current) { + clearTimeout(clearTextToastTimeoutRef.current); + clearTextToastTimeoutRef.current = null; + } + if (show) { + clearTextToastTimeoutRef.current = setTimeout(() => { + setShowClearTextToast(false); + clearTextToastTimeoutRef.current = null; + }, QUEUE_ERROR_DISPLAY_DURATION_MS); + } + }, []); + const { isSettingsDialogOpen, openSettingsDialog, closeSettingsDialog } = useSettingsCommand(); @@ -675,6 +692,7 @@ Logging in with Google... Restarting Gemini CLI to continue. dispatchExtensionStateUpdate, addConfirmUpdateExtensionRequest, setText: (text: string) => buffer.setText(text), + clearTextToast: () => handleClearTextToastChange(false), }), [ setAuthState, @@ -692,6 +710,7 @@ Logging in with Google... Restarting Gemini CLI to continue. addConfirmUpdateExtensionRequest, toggleDebugProfiler, buffer, + handleClearTextToastChange, ], ); @@ -1059,7 +1078,6 @@ Logging in with Google... Restarting Gemini CLI to continue. IdeContext | undefined >(); const [showEscapePrompt, setShowEscapePrompt] = useState(false); - const [showClearTextToast, setShowClearTextToast] = useState(false); const [showIdeRestartPrompt, setShowIdeRestartPrompt] = useState(false); const [warningMessage, setWarningMessage] = useState(null); @@ -1075,7 +1093,6 @@ Logging in with Google... Restarting Gemini CLI to continue. const warningTimeoutRef = useRef(null); const tabFocusTimeoutRef = useRef(null); - const clearTextToastTimeoutRef = useRef(null); const handleWarning = useCallback((message: string) => { setWarningMessage(message); @@ -1087,20 +1104,6 @@ Logging in with Google... Restarting Gemini CLI to continue. }, WARNING_PROMPT_DURATION_MS); }, []); - const handleClearTextToastChange = useCallback((show: boolean) => { - setShowClearTextToast(show); - if (clearTextToastTimeoutRef.current) { - clearTimeout(clearTextToastTimeoutRef.current); - clearTextToastTimeoutRef.current = null; - } - if (show) { - clearTextToastTimeoutRef.current = setTimeout(() => { - setShowClearTextToast(false); - clearTextToastTimeoutRef.current = null; - }, QUEUE_ERROR_DISPLAY_DURATION_MS); - } - }, []); - useEffect(() => { const handleSelectionWarning = () => { handleWarning('Press Ctrl-S to enter selection mode to copy text.'); diff --git a/packages/cli/src/ui/commands/rewindCommand.test.tsx b/packages/cli/src/ui/commands/rewindCommand.test.tsx index 7781c8f8c24..3a407534128 100644 --- a/packages/cli/src/ui/commands/rewindCommand.test.tsx +++ b/packages/cli/src/ui/commands/rewindCommand.test.tsx @@ -31,6 +31,7 @@ const mockResetContext = vi.fn(); const mockSetInput = vi.fn(); const mockRevertFileChanges = vi.fn(); const mockGetProjectRoot = vi.fn().mockReturnValue('/mock/root'); +const mockClearTextToast = vi.fn(); vi.mock('@google/gemini-cli-core', () => ({ uiTelemetryService: { @@ -111,6 +112,7 @@ describe('rewindCommand', () => { loadHistory: mockLoadHistory, addItem: mockAddItem, setPendingItem: mockSetPendingItem, + clearTextToast: mockClearTextToast, }, }) as unknown as CommandContext; }); @@ -216,10 +218,25 @@ describe('rewindCommand', () => { expect(mockRevertFileChanges).not.toHaveBeenCalled(); expect(mockRewindTo).not.toHaveBeenCalled(); expect(mockRemoveComponent).toHaveBeenCalled(); + expect(mockClearTextToast).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(); + expect(mockClearTextToast).toHaveBeenCalled(); + }); + it('should handle rewind error correctly', async () => { const result = (await rewindCommand.action!( mockContext, diff --git a/packages/cli/src/ui/commands/rewindCommand.tsx b/packages/cli/src/ui/commands/rewindCommand.tsx index d4ff22f5380..53bd0b6d7f8 100644 --- a/packages/cli/src/ui/commands/rewindCommand.tsx +++ b/packages/cli/src/ui/commands/rewindCommand.tsx @@ -57,12 +57,16 @@ export const rewindCommand: SlashCommand = { component: ( context.ui.removeComponent()} + onExit={() => { + context.ui.removeComponent(); + context.ui.clearTextToast(); + }} onRewind={async (messageId, newText, outcome) => { try { switch (outcome) { case RewindOutcome.Cancel: context.ui.removeComponent(); + context.ui.clearTextToast(); return; case RewindOutcome.RevertOnly: diff --git a/packages/cli/src/ui/commands/types.ts b/packages/cli/src/ui/commands/types.ts index 613175c1bed..97fe767f98f 100644 --- a/packages/cli/src/ui/commands/types.ts +++ b/packages/cli/src/ui/commands/types.ts @@ -78,6 +78,8 @@ export interface CommandContext { dispatchExtensionStateUpdate: (action: ExtensionUpdateAction) => void; addConfirmUpdateExtensionRequest: (value: ConfirmationRequest) => void; removeComponent: () => void; + /** Clears the "Ctrl+C to clear text" toast if it is visible. */ + clearTextToast: () => void; }; // Session-specific data session: { diff --git a/packages/cli/src/ui/hooks/slashCommandProcessor.test.tsx b/packages/cli/src/ui/hooks/slashCommandProcessor.test.tsx index f5acf20d2cb..1916ec640e2 100644 --- a/packages/cli/src/ui/hooks/slashCommandProcessor.test.tsx +++ b/packages/cli/src/ui/hooks/slashCommandProcessor.test.tsx @@ -201,6 +201,7 @@ describe('useSlashCommandProcessor', () => { dispatchExtensionStateUpdate: vi.fn(), addConfirmUpdateExtensionRequest: vi.fn(), setText: vi.fn(), + clearTextToast: 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 f11d4f1b353..f6ef5a882ab 100644 --- a/packages/cli/src/ui/hooks/slashCommandProcessor.ts +++ b/packages/cli/src/ui/hooks/slashCommandProcessor.ts @@ -72,6 +72,7 @@ interface SlashCommandProcessorActions { dispatchExtensionStateUpdate: (action: ExtensionUpdateAction) => void; addConfirmUpdateExtensionRequest: (request: ConfirmationRequest) => void; setText: (text: string) => void; + clearTextToast: () => void; } /** @@ -232,6 +233,7 @@ export const useSlashCommandProcessor = ( addConfirmUpdateExtensionRequest: actions.addConfirmUpdateExtensionRequest, removeComponent: () => setCustomDialog(null), + clearTextToast: actions.clearTextToast, }, session: { stats: session.stats, diff --git a/packages/cli/src/ui/noninteractive/nonInteractiveUi.ts b/packages/cli/src/ui/noninteractive/nonInteractiveUi.ts index 542ed16bdb1..9eea5b8c999 100644 --- a/packages/cli/src/ui/noninteractive/nonInteractiveUi.ts +++ b/packages/cli/src/ui/noninteractive/nonInteractiveUi.ts @@ -28,5 +28,6 @@ export function createNonInteractiveUI(): CommandContext['ui'] { dispatchExtensionStateUpdate: (_action: ExtensionUpdateAction) => {}, addConfirmUpdateExtensionRequest: (_request) => {}, removeComponent: () => {}, + clearTextToast: () => {}, }; } From 80e862bdbbe3d5673aad21cf67da46dd647997e3 Mon Sep 17 00:00:00 2001 From: "A.K.M. Adib" Date: Wed, 14 Jan 2026 16:43:28 -0500 Subject: [PATCH 29/42] create shared component for expandable prompt --- .../cli/src/ui/components/PrepareLabel.tsx | 107 ++-------------- .../cli/src/ui/components/RewindViewer.tsx | 29 ++--- .../ExpandablePrompt.test.tsx} | 50 +++++--- .../ui/components/shared/ExpandablePrompt.tsx | 118 ++++++++++++++++++ .../ExpandablePrompt.test.tsx.snap | 27 ++++ 5 files changed, 196 insertions(+), 135 deletions(-) rename packages/cli/src/ui/components/{PrepareLabel.test.tsx => shared/ExpandablePrompt.test.tsx} (74%) create mode 100644 packages/cli/src/ui/components/shared/ExpandablePrompt.tsx create mode 100644 packages/cli/src/ui/components/shared/__snapshots__/ExpandablePrompt.test.tsx.snap diff --git a/packages/cli/src/ui/components/PrepareLabel.tsx b/packages/cli/src/ui/components/PrepareLabel.tsx index 759e84b1005..a28778ab82b 100644 --- a/packages/cli/src/ui/components/PrepareLabel.tsx +++ b/packages/cli/src/ui/components/PrepareLabel.tsx @@ -4,11 +4,13 @@ * SPDX-License-Identifier: Apache-2.0 */ -import React from 'react'; -import { Text } from 'ink'; -import { theme } from '../semantic-colors.js'; +import type React from 'react'; +import { + ExpandablePrompt, + DEFAULT_MAX_WIDTH, +} from './shared/ExpandablePrompt.js'; -export const MAX_WIDTH = 150; // Maximum width for the text that is shown +export const MAX_WIDTH = DEFAULT_MAX_WIDTH; export interface PrepareLabelProps { label: string; @@ -18,99 +20,4 @@ export interface PrepareLabelProps { isExpanded?: boolean; } -const _PrepareLabel: React.FC = ({ - label, - matchedIndex, - userInput, - textColor, - isExpanded = false, -}) => { - const hasMatch = - matchedIndex !== undefined && - matchedIndex >= 0 && - matchedIndex < label.length && - userInput.length > 0; - - // 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; - return ( - - {display} - - ); - } - - const matchLength = userInput.length; - let before = ''; - let match = ''; - let after = ''; - - // Case 1: Show the full string if it's expanded or already fits - if (isExpanded || label.length <= MAX_WIDTH) { - 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) + '...'; - } - // Case 3: Truncate the string to create a window around the match - else { - const contextSpace = MAX_WIDTH - matchLength; - const beforeSpace = Math.floor(contextSpace / 2); - const afterSpace = Math.ceil(contextSpace / 2); - - let start = matchedIndex - beforeSpace; - let end = matchedIndex + matchLength + afterSpace; - - if (start < 0) { - end += -start; // Slide window right - start = 0; - } - if (end > label.length) { - start -= end - label.length; // Slide window left - end = label.length; - } - start = Math.max(0, start); - - const finalMatchIndex = matchedIndex - start; - const slicedLabel = label.slice(start, end); - - before = slicedLabel.slice(0, finalMatchIndex); - match = slicedLabel.slice(finalMatchIndex, finalMatchIndex + matchLength); - after = slicedLabel.slice(finalMatchIndex + matchLength); - - if (start > 0) { - before = before.length >= 3 ? '...' + before.slice(3) : '...'; - } - if (end < label.length) { - after = after.length >= 3 ? after.slice(0, -3) + '...' : '...'; - } - } - - return ( - - {before} - {match - ? match.split(/(\s+)/).map((part, index) => ( - - {part} - - )) - : null} - {after} - - ); -}; - -export const PrepareLabel = React.memo(_PrepareLabel); +export const PrepareLabel: React.FC = (props) => ; diff --git a/packages/cli/src/ui/components/RewindViewer.tsx b/packages/cli/src/ui/components/RewindViewer.tsx index c2ada3545cd..7be22351ea7 100644 --- a/packages/cli/src/ui/components/RewindViewer.tsx +++ b/packages/cli/src/ui/components/RewindViewer.tsx @@ -19,9 +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 { ExpandablePrompt } from './shared/ExpandablePrompt.js'; interface RewindViewerProps { conversation: ConversationRecord; @@ -174,25 +174,14 @@ export const RewindViewer: React.FC = ({ return ( - - {cleanedText.split('\n').map((line, i) => ( - - - {line} - - - ))} - + {stats ? ( diff --git a/packages/cli/src/ui/components/PrepareLabel.test.tsx b/packages/cli/src/ui/components/shared/ExpandablePrompt.test.tsx similarity index 74% rename from packages/cli/src/ui/components/PrepareLabel.test.tsx rename to packages/cli/src/ui/components/shared/ExpandablePrompt.test.tsx index 5c06817836d..45ad4fe1c2f 100644 --- a/packages/cli/src/ui/components/PrepareLabel.test.tsx +++ b/packages/cli/src/ui/components/shared/ExpandablePrompt.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 { ExpandablePrompt, DEFAULT_MAX_WIDTH } from './ExpandablePrompt.js'; -describe('PrepareLabel', () => { +describe('ExpandablePrompt', () => { 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 long = 'x'.repeat(DEFAULT_MAX_WIDTH + 25); const { lastFrame, unmount } = render( - { const out = lastFrame(); const f = flat(out); expect(f.endsWith('...')).toBe(true); - expect(f.length).toBe(MAX_WIDTH + 3); + expect(f.length).toBe(DEFAULT_MAX_WIDTH + 3); expect(out).toMatchSnapshot(); unmount(); }); it('shows full long label when expanded and no match', () => { - const long = 'y'.repeat(MAX_WIDTH + 25); + const long = 'y'.repeat(DEFAULT_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( - { it('truncates match itself when match is very long', () => { const prefix = 'find '; - const core = 'x'.repeat(MAX_WIDTH + 25); + const core = 'x'.repeat(DEFAULT_MAX_WIDTH + 25); const suffix = ' in this text'; const label = prefix + core + suffix; const matchedIndex = prefix.length; const { lastFrame, unmount } = render( - { expect(f.includes('...')).toBe(true); expect(f.startsWith('...')).toBe(false); expect(f.endsWith('...')).toBe(true); - expect(f.length).toBe(MAX_WIDTH + 2); + expect(f.length).toBe(DEFAULT_MAX_WIDTH + 2); + 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/shared/ExpandablePrompt.tsx b/packages/cli/src/ui/components/shared/ExpandablePrompt.tsx new file mode 100644 index 00000000000..28e68b1eca6 --- /dev/null +++ b/packages/cli/src/ui/components/shared/ExpandablePrompt.tsx @@ -0,0 +1,118 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import React from 'react'; +import { Text } from 'ink'; +import { theme } from '../../semantic-colors.js'; + +export const DEFAULT_MAX_WIDTH = 150; + +export interface ExpandablePromptProps { + label: string; + matchedIndex?: number; + userInput?: string; + textColor?: string; + isExpanded?: boolean; + maxWidth?: number; +} + +const _ExpandablePrompt: React.FC = ({ + label, + matchedIndex, + userInput = '', + textColor = theme.text.primary, + isExpanded = false, + maxWidth = DEFAULT_MAX_WIDTH, +}) => { + const hasMatch = + matchedIndex !== undefined && + matchedIndex >= 0 && + matchedIndex < label.length && + userInput.length > 0; + + // Render the plain label if there's no match + if (!hasMatch) { + const display = isExpanded + ? label + : label.length > maxWidth + ? label.slice(0, maxWidth) + '...' + : label; + return ( + + {display} + + ); + } + + const matchLength = userInput.length; + let before = ''; + let match = ''; + let after = ''; + + // Case 1: Show the full string if it's expanded or already fits + 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 >= maxWidth) { + match = label.slice(matchedIndex, matchedIndex + maxWidth - 1) + '...'; + } + // Case 3: Truncate the string to create a window around the match + else { + const contextSpace = maxWidth - matchLength; + const beforeSpace = Math.floor(contextSpace / 2); + const afterSpace = Math.ceil(contextSpace / 2); + + let start = matchedIndex - beforeSpace; + let end = matchedIndex + matchLength + afterSpace; + + if (start < 0) { + end += -start; // Slide window right + start = 0; + } + if (end > label.length) { + start -= end - label.length; // Slide window left + end = label.length; + } + start = Math.max(0, start); + + const finalMatchIndex = matchedIndex - start; + const slicedLabel = label.slice(start, end); + + before = slicedLabel.slice(0, finalMatchIndex); + match = slicedLabel.slice(finalMatchIndex, finalMatchIndex + matchLength); + after = slicedLabel.slice(finalMatchIndex + matchLength); + + if (start > 0) { + before = before.length >= 3 ? '...' + before.slice(3) : '...'; + } + if (end < label.length) { + after = after.length >= 3 ? after.slice(0, -3) + '...' : '...'; + } + } + + return ( + + {before} + {match + ? match.split(/(\s+)/).map((part, index) => ( + + {part} + + )) + : null} + {after} + + ); +}; + +export const ExpandablePrompt = React.memo(_ExpandablePrompt); 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 00000000000..6dcc8802b68 --- /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..." +`; From eb9573a5553e6d6486908ce9c8540cea7bab8f7b Mon Sep 17 00:00:00 2001 From: "A.K.M. Adib" Date: Wed, 14 Jan 2026 16:51:09 -0500 Subject: [PATCH 30/42] update test --- package-lock.json | 41 ++++++++++++------- .../__snapshots__/RewindViewer.test.tsx.snap | 12 +++++- 2 files changed, 36 insertions(+), 17 deletions(-) diff --git a/package-lock.json b/package-lock.json index 6fb8dad5a0d..c17c7685963 100644 --- a/package-lock.json +++ b/package-lock.json @@ -2473,6 +2473,7 @@ "integrity": "sha512-t54CUOsFMappY1Jbzb7fetWeO0n6K0k/4+/ZpkS+3Joz8I4VcvY9OiEBFRYISqaI2fq5sCiPtAjRDOzVYG8m+Q==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@octokit/auth-token": "^6.0.0", "@octokit/graphql": "^9.0.2", @@ -2653,6 +2654,7 @@ "resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.0.tgz", "integrity": "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg==", "license": "Apache-2.0", + "peer": true, "engines": { "node": ">=8.0.0" } @@ -2686,6 +2688,7 @@ "resolved": "https://registry.npmjs.org/@opentelemetry/core/-/core-2.0.1.tgz", "integrity": "sha512-MaZk9SJIDgo1peKevlbhP6+IwIiNPNmswNL4AF0WaQJLbHXjr9SrZMgS12+iqr9ToV4ZVosCcc0f8Rg67LXjxw==", "license": "Apache-2.0", + "peer": true, "dependencies": { "@opentelemetry/semantic-conventions": "^1.29.0" }, @@ -3054,6 +3057,7 @@ "resolved": "https://registry.npmjs.org/@opentelemetry/resources/-/resources-2.0.1.tgz", "integrity": "sha512-dZOB3R6zvBwDKnHDTB4X1xtMArB/d324VsbiPkX/Yu0Q8T2xceRthoIVFhJdvgVM2QhGVUyX9tzwiNxGtoBJUw==", "license": "Apache-2.0", + "peer": true, "dependencies": { "@opentelemetry/core": "2.0.1", "@opentelemetry/semantic-conventions": "^1.29.0" @@ -3087,6 +3091,7 @@ "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-metrics/-/sdk-metrics-2.0.1.tgz", "integrity": "sha512-wf8OaJoSnujMAHWR3g+/hGvNcsC16rf9s1So4JlMiFaFHiE4HpIA3oUh+uWZQ7CNuK8gVW/pQSkgoa5HkkOl0g==", "license": "Apache-2.0", + "peer": true, "dependencies": { "@opentelemetry/core": "2.0.1", "@opentelemetry/resources": "2.0.1" @@ -3139,6 +3144,7 @@ "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-trace-base/-/sdk-trace-base-2.0.1.tgz", "integrity": "sha512-xYLlvk/xdScGx1aEqvxLwf6sXQLXCjk3/1SQT9X9AoN5rXRhkdvIFShuNNmtTEPRBqcsMbS4p/gJLNI2wXaDuQ==", "license": "Apache-2.0", + "peer": true, "dependencies": { "@opentelemetry/core": "2.0.1", "@opentelemetry/resources": "2.0.1", @@ -4351,6 +4357,7 @@ "integrity": "sha512-6mDvHUFSjyT2B2yeNx2nUgMxh9LtOWvkhIU3uePn2I2oyNymUAX1NIsdgviM4CH+JSrp2D2hsMvJOkxY+0wNRA==", "devOptional": true, "license": "MIT", + "peer": true, "dependencies": { "csstype": "^3.0.2" } @@ -4628,6 +4635,7 @@ "integrity": "sha512-6sMvZePQrnZH2/cJkwRpkT7DxoAWh+g6+GFRK6bV3YQo7ogi3SX5rgF6099r5Q53Ma5qeT7LGmOmuIutF4t3lA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.35.0", "@typescript-eslint/types": "8.35.0", @@ -5632,6 +5640,7 @@ "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "license": "MIT", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -6076,8 +6085,7 @@ "version": "1.1.1", "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz", "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/array-includes": { "version": "3.1.9", @@ -7361,7 +7369,6 @@ "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz", "integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==", "license": "MIT", - "peer": true, "dependencies": { "safe-buffer": "5.2.1" }, @@ -8681,6 +8688,7 @@ "integrity": "sha512-GsGizj2Y1rCWDu6XoEekL3RLilp0voSePurjZIkxL3wlm5o5EC9VpgaP7lrCvjnkuLvzFBQWB3vWB3K5KQTveQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.2.0", "@eslint-community/regexpp": "^4.12.1", @@ -9283,7 +9291,6 @@ "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.1.tgz", "integrity": "sha512-6DnInpx7SJ2AK3+CTUE/ZM0vWTUboZCegxhC2xiIydHR9jNuTAASBrfEpHhiGOZw/nX51bHt6YQl8jsGo4y/0w==", "license": "MIT", - "peer": true, "engines": { "node": ">= 0.6" } @@ -9293,7 +9300,6 @@ "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", "license": "MIT", - "peer": true, "dependencies": { "ms": "2.0.0" } @@ -9303,7 +9309,6 @@ "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.1.tgz", "integrity": "sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==", "license": "MIT", - "peer": true, "engines": { "node": ">= 0.8" } @@ -9557,7 +9562,6 @@ "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.1.tgz", "integrity": "sha512-6BN9trH7bp3qvnrRyzsBz+g3lZxTNZTbVO2EV1CS0WIcDbawYVdYvGflME/9QP0h0pYlCDBCTjYa9nZzMDpyxQ==", "license": "MIT", - "peer": true, "dependencies": { "debug": "2.6.9", "encodeurl": "~2.0.0", @@ -9576,7 +9580,6 @@ "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", "license": "MIT", - "peer": true, "dependencies": { "ms": "2.0.0" } @@ -9585,15 +9588,13 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/finalhandler/node_modules/statuses": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.1.tgz", "integrity": "sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==", "license": "MIT", - "peer": true, "engines": { "node": ">= 0.8" } @@ -10876,6 +10877,7 @@ "resolved": "https://registry.npmjs.org/@jrichman/ink/-/ink-6.4.7.tgz", "integrity": "sha512-QHyxhNF5VonF5cRmdAJD/UPucB9nRx3FozWMjQrDGfBxfAL9lpyu72/MlFPgloS1TMTGsOt7YN6dTPPA6mh0Aw==", "license": "MIT", + "peer": true, "dependencies": { "@alcalzone/ansi-tokenize": "^0.2.1", "ansi-escapes": "^7.0.0", @@ -14060,8 +14062,7 @@ "version": "0.1.12", "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.12.tgz", "integrity": "sha512-RA1GjUVMnvYFxuqovrEqZoxxW5NUZqbwKtYz/Tt7nXerk0LbLblQmrsgdeOxV5SFHf0UDggjS/bSeOZwt1pmEQ==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/path-type": { "version": "3.0.0", @@ -14638,6 +14639,7 @@ "resolved": "https://registry.npmjs.org/react/-/react-19.2.0.tgz", "integrity": "sha512-tmbWg6W31tQLeB5cdIBOicJDJRR2KzXsV7uSK9iNfLWQ5bIZfxuPEHp7M8wiHyHnn0DD1i7w3Zmin0FtkrwoCQ==", "license": "MIT", + "peer": true, "engines": { "node": ">=0.10.0" } @@ -14648,6 +14650,7 @@ "integrity": "sha512-ePrwPfxAnB+7hgnEr8vpKxL9cmnp7F322t8oqcPshbIQQhDKgFDW4tjhF2wjVbdXF9O/nyuy3sQWd9JGpiLPvA==", "devOptional": true, "license": "MIT", + "peer": true, "dependencies": { "shell-quote": "^1.6.1", "ws": "^7" @@ -16907,6 +16910,7 @@ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -17130,7 +17134,8 @@ "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", "dev": true, - "license": "0BSD" + "license": "0BSD", + "peer": true }, "node_modules/tsx": { "version": "4.20.3", @@ -17138,6 +17143,7 @@ "integrity": "sha512-qjbnuR9Tr+FJOMBqJCW5ehvIo/buZq7vH7qD7JziU98h6l3qGy0a/yPFjwO+y0/T7GFpNgNAvEcPPVfyT8rrPQ==", "devOptional": true, "license": "MIT", + "peer": true, "dependencies": { "esbuild": "~0.25.0", "get-tsconfig": "^4.7.5" @@ -17321,6 +17327,7 @@ "integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==", "devOptional": true, "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -17483,7 +17490,6 @@ "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz", "integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==", "license": "MIT", - "peer": true, "engines": { "node": ">= 0.4.0" } @@ -17538,6 +17544,7 @@ "resolved": "https://registry.npmjs.org/vite/-/vite-7.2.2.tgz", "integrity": "sha512-BxAKBWmIbrDgrokdGZH1IgkIk/5mMHDreLDmCJ0qpyJaAteP8NvMhkwr/ZCQNqNH97bw/dANTE9PDzqwJghfMQ==", "license": "MIT", + "peer": true, "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.5.0", @@ -17651,6 +17658,7 @@ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -17663,6 +17671,7 @@ "resolved": "https://registry.npmjs.org/vitest/-/vitest-3.2.4.tgz", "integrity": "sha512-LUCP5ev3GURDysTWiP47wRRUpLKMOfPh+yKTx3kVIEiu5KOMeqzpnYNsKyOoVrULivR8tLcks4+lga33Whn90A==", "license": "MIT", + "peer": true, "dependencies": { "@types/chai": "^5.2.2", "@vitest/expect": "3.2.4", @@ -18367,6 +18376,7 @@ "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", "license": "MIT", + "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } @@ -18932,6 +18942,7 @@ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, diff --git a/packages/cli/src/ui/components/__snapshots__/RewindViewer.test.tsx.snap b/packages/cli/src/ui/components/__snapshots__/RewindViewer.test.tsx.snap index 7db1c1c5073..95fc99b866b 100644 --- a/packages/cli/src/ui/components/__snapshots__/RewindViewer.test.tsx.snap +++ b/packages/cli/src/ui/components/__snapshots__/RewindViewer.test.tsx.snap @@ -221,7 +221,11 @@ exports[`RewindViewer > updates selection and expansion on navigation > after-do │ │ │ Line 1 │ │ Line 2 │ -│ ... last 5 lines hidden ... │ +│ Line 3 │ +│ Line 4 │ +│ Line 5 │ +│ Line 6 │ +│ Line 7 │ │ No files have been changed │ │ │ │ ● Line A │ @@ -255,7 +259,11 @@ exports[`RewindViewer > updates selection and expansion on navigation > initial- │ │ │ Line A │ │ Line B │ -│ ... last 5 lines hidden ... │ +│ Line C │ +│ Line D │ +│ Line E │ +│ Line F │ +│ Line G │ │ No files have been changed │ │ │ │ │ From 7a0d9e4f8963c007438939cfd8fe6420967590cf Mon Sep 17 00:00:00 2001 From: "A.K.M. Adib" Date: Wed, 14 Jan 2026 16:52:21 -0500 Subject: [PATCH 31/42] format file --- packages/cli/src/ui/components/PrepareLabel.tsx | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/cli/src/ui/components/PrepareLabel.tsx b/packages/cli/src/ui/components/PrepareLabel.tsx index a28778ab82b..88bc91b53e1 100644 --- a/packages/cli/src/ui/components/PrepareLabel.tsx +++ b/packages/cli/src/ui/components/PrepareLabel.tsx @@ -20,4 +20,6 @@ export interface PrepareLabelProps { isExpanded?: boolean; } -export const PrepareLabel: React.FC = (props) => ; +export const PrepareLabel: React.FC = (props) => ( + +); From f441924a15a2d9803bc66a75feac3612b7619600 Mon Sep 17 00:00:00 2001 From: "A.K.M. Adib" Date: Wed, 14 Jan 2026 18:01:35 -0500 Subject: [PATCH 32/42] restore package-lock.json --- package-lock.json | 41 +++++++++++++++-------------------------- 1 file changed, 15 insertions(+), 26 deletions(-) diff --git a/package-lock.json b/package-lock.json index c17c7685963..6fb8dad5a0d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -2473,7 +2473,6 @@ "integrity": "sha512-t54CUOsFMappY1Jbzb7fetWeO0n6K0k/4+/ZpkS+3Joz8I4VcvY9OiEBFRYISqaI2fq5sCiPtAjRDOzVYG8m+Q==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@octokit/auth-token": "^6.0.0", "@octokit/graphql": "^9.0.2", @@ -2654,7 +2653,6 @@ "resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.0.tgz", "integrity": "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg==", "license": "Apache-2.0", - "peer": true, "engines": { "node": ">=8.0.0" } @@ -2688,7 +2686,6 @@ "resolved": "https://registry.npmjs.org/@opentelemetry/core/-/core-2.0.1.tgz", "integrity": "sha512-MaZk9SJIDgo1peKevlbhP6+IwIiNPNmswNL4AF0WaQJLbHXjr9SrZMgS12+iqr9ToV4ZVosCcc0f8Rg67LXjxw==", "license": "Apache-2.0", - "peer": true, "dependencies": { "@opentelemetry/semantic-conventions": "^1.29.0" }, @@ -3057,7 +3054,6 @@ "resolved": "https://registry.npmjs.org/@opentelemetry/resources/-/resources-2.0.1.tgz", "integrity": "sha512-dZOB3R6zvBwDKnHDTB4X1xtMArB/d324VsbiPkX/Yu0Q8T2xceRthoIVFhJdvgVM2QhGVUyX9tzwiNxGtoBJUw==", "license": "Apache-2.0", - "peer": true, "dependencies": { "@opentelemetry/core": "2.0.1", "@opentelemetry/semantic-conventions": "^1.29.0" @@ -3091,7 +3087,6 @@ "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-metrics/-/sdk-metrics-2.0.1.tgz", "integrity": "sha512-wf8OaJoSnujMAHWR3g+/hGvNcsC16rf9s1So4JlMiFaFHiE4HpIA3oUh+uWZQ7CNuK8gVW/pQSkgoa5HkkOl0g==", "license": "Apache-2.0", - "peer": true, "dependencies": { "@opentelemetry/core": "2.0.1", "@opentelemetry/resources": "2.0.1" @@ -3144,7 +3139,6 @@ "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-trace-base/-/sdk-trace-base-2.0.1.tgz", "integrity": "sha512-xYLlvk/xdScGx1aEqvxLwf6sXQLXCjk3/1SQT9X9AoN5rXRhkdvIFShuNNmtTEPRBqcsMbS4p/gJLNI2wXaDuQ==", "license": "Apache-2.0", - "peer": true, "dependencies": { "@opentelemetry/core": "2.0.1", "@opentelemetry/resources": "2.0.1", @@ -4357,7 +4351,6 @@ "integrity": "sha512-6mDvHUFSjyT2B2yeNx2nUgMxh9LtOWvkhIU3uePn2I2oyNymUAX1NIsdgviM4CH+JSrp2D2hsMvJOkxY+0wNRA==", "devOptional": true, "license": "MIT", - "peer": true, "dependencies": { "csstype": "^3.0.2" } @@ -4635,7 +4628,6 @@ "integrity": "sha512-6sMvZePQrnZH2/cJkwRpkT7DxoAWh+g6+GFRK6bV3YQo7ogi3SX5rgF6099r5Q53Ma5qeT7LGmOmuIutF4t3lA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.35.0", "@typescript-eslint/types": "8.35.0", @@ -5640,7 +5632,6 @@ "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "license": "MIT", - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -6085,7 +6076,8 @@ "version": "1.1.1", "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz", "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/array-includes": { "version": "3.1.9", @@ -7369,6 +7361,7 @@ "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz", "integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==", "license": "MIT", + "peer": true, "dependencies": { "safe-buffer": "5.2.1" }, @@ -8688,7 +8681,6 @@ "integrity": "sha512-GsGizj2Y1rCWDu6XoEekL3RLilp0voSePurjZIkxL3wlm5o5EC9VpgaP7lrCvjnkuLvzFBQWB3vWB3K5KQTveQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.2.0", "@eslint-community/regexpp": "^4.12.1", @@ -9291,6 +9283,7 @@ "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.1.tgz", "integrity": "sha512-6DnInpx7SJ2AK3+CTUE/ZM0vWTUboZCegxhC2xiIydHR9jNuTAASBrfEpHhiGOZw/nX51bHt6YQl8jsGo4y/0w==", "license": "MIT", + "peer": true, "engines": { "node": ">= 0.6" } @@ -9300,6 +9293,7 @@ "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", "license": "MIT", + "peer": true, "dependencies": { "ms": "2.0.0" } @@ -9309,6 +9303,7 @@ "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.1.tgz", "integrity": "sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==", "license": "MIT", + "peer": true, "engines": { "node": ">= 0.8" } @@ -9562,6 +9557,7 @@ "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.1.tgz", "integrity": "sha512-6BN9trH7bp3qvnrRyzsBz+g3lZxTNZTbVO2EV1CS0WIcDbawYVdYvGflME/9QP0h0pYlCDBCTjYa9nZzMDpyxQ==", "license": "MIT", + "peer": true, "dependencies": { "debug": "2.6.9", "encodeurl": "~2.0.0", @@ -9580,6 +9576,7 @@ "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", "license": "MIT", + "peer": true, "dependencies": { "ms": "2.0.0" } @@ -9588,13 +9585,15 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/finalhandler/node_modules/statuses": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.1.tgz", "integrity": "sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==", "license": "MIT", + "peer": true, "engines": { "node": ">= 0.8" } @@ -10877,7 +10876,6 @@ "resolved": "https://registry.npmjs.org/@jrichman/ink/-/ink-6.4.7.tgz", "integrity": "sha512-QHyxhNF5VonF5cRmdAJD/UPucB9nRx3FozWMjQrDGfBxfAL9lpyu72/MlFPgloS1TMTGsOt7YN6dTPPA6mh0Aw==", "license": "MIT", - "peer": true, "dependencies": { "@alcalzone/ansi-tokenize": "^0.2.1", "ansi-escapes": "^7.0.0", @@ -14062,7 +14060,8 @@ "version": "0.1.12", "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.12.tgz", "integrity": "sha512-RA1GjUVMnvYFxuqovrEqZoxxW5NUZqbwKtYz/Tt7nXerk0LbLblQmrsgdeOxV5SFHf0UDggjS/bSeOZwt1pmEQ==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/path-type": { "version": "3.0.0", @@ -14639,7 +14638,6 @@ "resolved": "https://registry.npmjs.org/react/-/react-19.2.0.tgz", "integrity": "sha512-tmbWg6W31tQLeB5cdIBOicJDJRR2KzXsV7uSK9iNfLWQ5bIZfxuPEHp7M8wiHyHnn0DD1i7w3Zmin0FtkrwoCQ==", "license": "MIT", - "peer": true, "engines": { "node": ">=0.10.0" } @@ -14650,7 +14648,6 @@ "integrity": "sha512-ePrwPfxAnB+7hgnEr8vpKxL9cmnp7F322t8oqcPshbIQQhDKgFDW4tjhF2wjVbdXF9O/nyuy3sQWd9JGpiLPvA==", "devOptional": true, "license": "MIT", - "peer": true, "dependencies": { "shell-quote": "^1.6.1", "ws": "^7" @@ -16910,7 +16907,6 @@ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -17134,8 +17130,7 @@ "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", "dev": true, - "license": "0BSD", - "peer": true + "license": "0BSD" }, "node_modules/tsx": { "version": "4.20.3", @@ -17143,7 +17138,6 @@ "integrity": "sha512-qjbnuR9Tr+FJOMBqJCW5ehvIo/buZq7vH7qD7JziU98h6l3qGy0a/yPFjwO+y0/T7GFpNgNAvEcPPVfyT8rrPQ==", "devOptional": true, "license": "MIT", - "peer": true, "dependencies": { "esbuild": "~0.25.0", "get-tsconfig": "^4.7.5" @@ -17327,7 +17321,6 @@ "integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==", "devOptional": true, "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -17490,6 +17483,7 @@ "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz", "integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==", "license": "MIT", + "peer": true, "engines": { "node": ">= 0.4.0" } @@ -17544,7 +17538,6 @@ "resolved": "https://registry.npmjs.org/vite/-/vite-7.2.2.tgz", "integrity": "sha512-BxAKBWmIbrDgrokdGZH1IgkIk/5mMHDreLDmCJ0qpyJaAteP8NvMhkwr/ZCQNqNH97bw/dANTE9PDzqwJghfMQ==", "license": "MIT", - "peer": true, "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.5.0", @@ -17658,7 +17651,6 @@ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -17671,7 +17663,6 @@ "resolved": "https://registry.npmjs.org/vitest/-/vitest-3.2.4.tgz", "integrity": "sha512-LUCP5ev3GURDysTWiP47wRRUpLKMOfPh+yKTx3kVIEiu5KOMeqzpnYNsKyOoVrULivR8tLcks4+lga33Whn90A==", "license": "MIT", - "peer": true, "dependencies": { "@types/chai": "^5.2.2", "@vitest/expect": "3.2.4", @@ -18376,7 +18367,6 @@ "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", "license": "MIT", - "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } @@ -18942,7 +18932,6 @@ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, From 485ac88c76c5d33ad968e0a64e5cd0473e113b13 Mon Sep 17 00:00:00 2001 From: "A.K.M. Adib" Date: Fri, 16 Jan 2026 13:09:46 -0500 Subject: [PATCH 33/42] make prompt expandable not by default --- package-lock.json | 41 ++++++++----- .../cli/src/ui/components/RewindViewer.tsx | 41 ++++++++++++- .../__snapshots__/RewindViewer.test.tsx.snap | 61 ++++++------------- .../ui/components/shared/ExpandablePrompt.tsx | 28 +++++++-- 4 files changed, 105 insertions(+), 66 deletions(-) diff --git a/package-lock.json b/package-lock.json index 6fb8dad5a0d..c17c7685963 100644 --- a/package-lock.json +++ b/package-lock.json @@ -2473,6 +2473,7 @@ "integrity": "sha512-t54CUOsFMappY1Jbzb7fetWeO0n6K0k/4+/ZpkS+3Joz8I4VcvY9OiEBFRYISqaI2fq5sCiPtAjRDOzVYG8m+Q==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@octokit/auth-token": "^6.0.0", "@octokit/graphql": "^9.0.2", @@ -2653,6 +2654,7 @@ "resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.0.tgz", "integrity": "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg==", "license": "Apache-2.0", + "peer": true, "engines": { "node": ">=8.0.0" } @@ -2686,6 +2688,7 @@ "resolved": "https://registry.npmjs.org/@opentelemetry/core/-/core-2.0.1.tgz", "integrity": "sha512-MaZk9SJIDgo1peKevlbhP6+IwIiNPNmswNL4AF0WaQJLbHXjr9SrZMgS12+iqr9ToV4ZVosCcc0f8Rg67LXjxw==", "license": "Apache-2.0", + "peer": true, "dependencies": { "@opentelemetry/semantic-conventions": "^1.29.0" }, @@ -3054,6 +3057,7 @@ "resolved": "https://registry.npmjs.org/@opentelemetry/resources/-/resources-2.0.1.tgz", "integrity": "sha512-dZOB3R6zvBwDKnHDTB4X1xtMArB/d324VsbiPkX/Yu0Q8T2xceRthoIVFhJdvgVM2QhGVUyX9tzwiNxGtoBJUw==", "license": "Apache-2.0", + "peer": true, "dependencies": { "@opentelemetry/core": "2.0.1", "@opentelemetry/semantic-conventions": "^1.29.0" @@ -3087,6 +3091,7 @@ "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-metrics/-/sdk-metrics-2.0.1.tgz", "integrity": "sha512-wf8OaJoSnujMAHWR3g+/hGvNcsC16rf9s1So4JlMiFaFHiE4HpIA3oUh+uWZQ7CNuK8gVW/pQSkgoa5HkkOl0g==", "license": "Apache-2.0", + "peer": true, "dependencies": { "@opentelemetry/core": "2.0.1", "@opentelemetry/resources": "2.0.1" @@ -3139,6 +3144,7 @@ "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-trace-base/-/sdk-trace-base-2.0.1.tgz", "integrity": "sha512-xYLlvk/xdScGx1aEqvxLwf6sXQLXCjk3/1SQT9X9AoN5rXRhkdvIFShuNNmtTEPRBqcsMbS4p/gJLNI2wXaDuQ==", "license": "Apache-2.0", + "peer": true, "dependencies": { "@opentelemetry/core": "2.0.1", "@opentelemetry/resources": "2.0.1", @@ -4351,6 +4357,7 @@ "integrity": "sha512-6mDvHUFSjyT2B2yeNx2nUgMxh9LtOWvkhIU3uePn2I2oyNymUAX1NIsdgviM4CH+JSrp2D2hsMvJOkxY+0wNRA==", "devOptional": true, "license": "MIT", + "peer": true, "dependencies": { "csstype": "^3.0.2" } @@ -4628,6 +4635,7 @@ "integrity": "sha512-6sMvZePQrnZH2/cJkwRpkT7DxoAWh+g6+GFRK6bV3YQo7ogi3SX5rgF6099r5Q53Ma5qeT7LGmOmuIutF4t3lA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.35.0", "@typescript-eslint/types": "8.35.0", @@ -5632,6 +5640,7 @@ "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "license": "MIT", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -6076,8 +6085,7 @@ "version": "1.1.1", "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz", "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/array-includes": { "version": "3.1.9", @@ -7361,7 +7369,6 @@ "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz", "integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==", "license": "MIT", - "peer": true, "dependencies": { "safe-buffer": "5.2.1" }, @@ -8681,6 +8688,7 @@ "integrity": "sha512-GsGizj2Y1rCWDu6XoEekL3RLilp0voSePurjZIkxL3wlm5o5EC9VpgaP7lrCvjnkuLvzFBQWB3vWB3K5KQTveQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.2.0", "@eslint-community/regexpp": "^4.12.1", @@ -9283,7 +9291,6 @@ "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.1.tgz", "integrity": "sha512-6DnInpx7SJ2AK3+CTUE/ZM0vWTUboZCegxhC2xiIydHR9jNuTAASBrfEpHhiGOZw/nX51bHt6YQl8jsGo4y/0w==", "license": "MIT", - "peer": true, "engines": { "node": ">= 0.6" } @@ -9293,7 +9300,6 @@ "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", "license": "MIT", - "peer": true, "dependencies": { "ms": "2.0.0" } @@ -9303,7 +9309,6 @@ "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.1.tgz", "integrity": "sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==", "license": "MIT", - "peer": true, "engines": { "node": ">= 0.8" } @@ -9557,7 +9562,6 @@ "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.1.tgz", "integrity": "sha512-6BN9trH7bp3qvnrRyzsBz+g3lZxTNZTbVO2EV1CS0WIcDbawYVdYvGflME/9QP0h0pYlCDBCTjYa9nZzMDpyxQ==", "license": "MIT", - "peer": true, "dependencies": { "debug": "2.6.9", "encodeurl": "~2.0.0", @@ -9576,7 +9580,6 @@ "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", "license": "MIT", - "peer": true, "dependencies": { "ms": "2.0.0" } @@ -9585,15 +9588,13 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/finalhandler/node_modules/statuses": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.1.tgz", "integrity": "sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==", "license": "MIT", - "peer": true, "engines": { "node": ">= 0.8" } @@ -10876,6 +10877,7 @@ "resolved": "https://registry.npmjs.org/@jrichman/ink/-/ink-6.4.7.tgz", "integrity": "sha512-QHyxhNF5VonF5cRmdAJD/UPucB9nRx3FozWMjQrDGfBxfAL9lpyu72/MlFPgloS1TMTGsOt7YN6dTPPA6mh0Aw==", "license": "MIT", + "peer": true, "dependencies": { "@alcalzone/ansi-tokenize": "^0.2.1", "ansi-escapes": "^7.0.0", @@ -14060,8 +14062,7 @@ "version": "0.1.12", "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.12.tgz", "integrity": "sha512-RA1GjUVMnvYFxuqovrEqZoxxW5NUZqbwKtYz/Tt7nXerk0LbLblQmrsgdeOxV5SFHf0UDggjS/bSeOZwt1pmEQ==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/path-type": { "version": "3.0.0", @@ -14638,6 +14639,7 @@ "resolved": "https://registry.npmjs.org/react/-/react-19.2.0.tgz", "integrity": "sha512-tmbWg6W31tQLeB5cdIBOicJDJRR2KzXsV7uSK9iNfLWQ5bIZfxuPEHp7M8wiHyHnn0DD1i7w3Zmin0FtkrwoCQ==", "license": "MIT", + "peer": true, "engines": { "node": ">=0.10.0" } @@ -14648,6 +14650,7 @@ "integrity": "sha512-ePrwPfxAnB+7hgnEr8vpKxL9cmnp7F322t8oqcPshbIQQhDKgFDW4tjhF2wjVbdXF9O/nyuy3sQWd9JGpiLPvA==", "devOptional": true, "license": "MIT", + "peer": true, "dependencies": { "shell-quote": "^1.6.1", "ws": "^7" @@ -16907,6 +16910,7 @@ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -17130,7 +17134,8 @@ "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", "dev": true, - "license": "0BSD" + "license": "0BSD", + "peer": true }, "node_modules/tsx": { "version": "4.20.3", @@ -17138,6 +17143,7 @@ "integrity": "sha512-qjbnuR9Tr+FJOMBqJCW5ehvIo/buZq7vH7qD7JziU98h6l3qGy0a/yPFjwO+y0/T7GFpNgNAvEcPPVfyT8rrPQ==", "devOptional": true, "license": "MIT", + "peer": true, "dependencies": { "esbuild": "~0.25.0", "get-tsconfig": "^4.7.5" @@ -17321,6 +17327,7 @@ "integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==", "devOptional": true, "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -17483,7 +17490,6 @@ "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz", "integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==", "license": "MIT", - "peer": true, "engines": { "node": ">= 0.4.0" } @@ -17538,6 +17544,7 @@ "resolved": "https://registry.npmjs.org/vite/-/vite-7.2.2.tgz", "integrity": "sha512-BxAKBWmIbrDgrokdGZH1IgkIk/5mMHDreLDmCJ0qpyJaAteP8NvMhkwr/ZCQNqNH97bw/dANTE9PDzqwJghfMQ==", "license": "MIT", + "peer": true, "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.5.0", @@ -17651,6 +17658,7 @@ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -17663,6 +17671,7 @@ "resolved": "https://registry.npmjs.org/vitest/-/vitest-3.2.4.tgz", "integrity": "sha512-LUCP5ev3GURDysTWiP47wRRUpLKMOfPh+yKTx3kVIEiu5KOMeqzpnYNsKyOoVrULivR8tLcks4+lga33Whn90A==", "license": "MIT", + "peer": true, "dependencies": { "@types/chai": "^5.2.2", "@vitest/expect": "3.2.4", @@ -18367,6 +18376,7 @@ "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", "license": "MIT", + "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } @@ -18932,6 +18942,7 @@ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, diff --git a/packages/cli/src/ui/components/RewindViewer.tsx b/packages/cli/src/ui/components/RewindViewer.tsx index 7be22351ea7..f53644efd1d 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, useState } from 'react'; +import { useMemo, useState, useEffect } from 'react'; import { Box, Text } from 'ink'; import { useUIState } from '../contexts/UIStateContext.js'; import { @@ -50,6 +50,13 @@ 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], @@ -67,11 +74,30 @@ export const RewindViewer: React.FC = ({ [interactions], ); + useEffect(() => { + if (items.length > 0 && !highlightedMessageId) { + // Initialize with the first item (most recent) + const firstItem = items[0]; + if (firstItem?.value.id) { + setHighlightedMessageId(firstItem.value.id); + } + } + }, [items, highlightedMessageId]); + useKeypress( (key) => { if (!selectedMessageId) { if (keyMatchers[Command.ESCAPE](key)) { onExit(); + return; + } + if (keyMatchers[Command.EXPAND_SUGGESTION](key)) { + if (highlightedMessageId) { + setExpandedMessageId(highlightedMessageId); + } + } + if (keyMatchers[Command.COLLAPSE_SUGGESTION](key)) { + setExpandedMessageId(null); } } }, @@ -161,6 +187,13 @@ export const RewindViewer: React.FC = ({ 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; @@ -176,11 +209,12 @@ export const RewindViewer: React.FC = ({ {stats ? ( @@ -212,7 +246,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/__snapshots__/RewindViewer.test.tsx.snap b/packages/cli/src/ui/components/__snapshots__/RewindViewer.test.tsx.snap index 95fc99b866b..a20302676e7 100644 --- a/packages/cli/src/ui/components/__snapshots__/RewindViewer.test.tsx.snap +++ b/packages/cli/src/ui/components/__snapshots__/RewindViewer.test.tsx.snap @@ -9,7 +9,7 @@ exports[`RewindViewer > Content Filtering > 'removes reference markers' 1`] = ` │ No files have been changed │ │ │ │ │ -│ (Use Enter to select a message, Esc to close) │ +│ (Use Enter to select a message, Esc to close, Right/Left to expand/collapse) │ │ │ ╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯" `; @@ -23,7 +23,7 @@ exports[`RewindViewer > Content Filtering > 'strips expanded MCP resource conten │ No files have been changed │ │ │ │ │ -│ (Use Enter to select a message, Esc to close) │ +│ (Use Enter to select a message, Esc to close, Right/Left to expand/collapse) │ │ │ ╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯" `; @@ -73,7 +73,7 @@ exports[`RewindViewer > Navigation > handles 'down' navigation > after-down 1`] │ No files have been changed │ │ │ │ │ -│ (Use Enter to select a message, Esc to close) │ +│ (Use Enter to select a message, Esc to close, Right/Left to expand/collapse) │ │ │ ╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯" `; @@ -93,7 +93,7 @@ exports[`RewindViewer > Navigation > handles 'up' navigation > after-up 1`] = ` │ No files have been changed │ │ │ │ │ -│ (Use Enter to select a message, Esc to close) │ +│ (Use Enter to select a message, Esc to close, Right/Left to expand/collapse) │ │ │ ╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯" `; @@ -113,7 +113,7 @@ exports[`RewindViewer > Navigation > handles cyclic navigation > cyclic-down 1`] │ No files have been changed │ │ │ │ │ -│ (Use Enter to select a message, Esc to close) │ +│ (Use Enter to select a message, Esc to close, Right/Left to expand/collapse) │ │ │ ╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯" `; @@ -133,7 +133,7 @@ exports[`RewindViewer > Navigation > handles cyclic navigation > cyclic-up 1`] = │ No files have been changed │ │ │ │ │ -│ (Use Enter to select a message, Esc to close) │ +│ (Use Enter to select a message, Esc to close, Right/Left to expand/collapse) │ │ │ ╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯" `; @@ -147,7 +147,7 @@ exports[`RewindViewer > Rendering > renders 'a single interaction' 1`] = ` │ No files have been changed │ │ │ │ │ -│ (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 +158,11 @@ exports[`RewindViewer > Rendering > renders 'full text for selected item' 1`] = │ > Rewind │ │ │ │ ● 1 │ -│ 2 │ -│ 3 │ -│ 4 │ -│ 5 │ -│ 6 │ -│ 7 │ +│ 2... │ │ No files have been changed │ │ │ │ │ -│ (Use Enter to select a message, Esc to close) │ +│ (Use Enter to select a message, Esc to close, Right/Left to expand/collapse) │ │ │ ╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯" `; @@ -178,7 +173,7 @@ exports[`RewindViewer > Rendering > renders 'nothing interesting for empty conve │ > Rewind │ │ │ │ │ -│ (Use Enter to select a message, Esc to close) │ +│ (Use Enter to select a message, Esc to close, Right/Left to expand/collapse) │ │ │ ╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯" `; @@ -195,7 +190,7 @@ exports[`RewindViewer > updates content when conversation changes (background up │ No files have been changed │ │ │ │ │ -│ (Use Enter to select a message, Esc to close) │ +│ (Use Enter to select a message, Esc to close, Right/Left to expand/collapse) │ │ │ ╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯" `; @@ -209,7 +204,7 @@ exports[`RewindViewer > updates content when conversation changes (background up │ No files have been changed │ │ │ │ │ -│ (Use Enter to select a message, Esc to close) │ +│ (Use Enter to select a message, Esc to close, Right/Left to expand/collapse) │ │ │ ╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯" `; @@ -220,25 +215,15 @@ exports[`RewindViewer > updates selection and expansion on navigation > after-do │ > Rewind │ │ │ │ Line 1 │ -│ Line 2 │ -│ Line 3 │ -│ Line 4 │ -│ Line 5 │ -│ Line 6 │ -│ Line 7 │ +│ Line 2... │ │ No files have been changed │ │ │ │ ● Line A │ -│ Line B │ -│ Line C │ -│ Line D │ -│ Line E │ -│ Line F │ -│ Line G │ +│ Line B... │ │ No files have been changed │ │ │ │ │ -│ (Use Enter to select a message, Esc to close) │ +│ (Use Enter to select a message, Esc to close, Right/Left to expand/collapse) │ │ │ ╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯" `; @@ -249,25 +234,15 @@ 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 2... │ │ No files have been changed │ │ │ │ Line A │ -│ Line B │ -│ Line C │ -│ Line D │ -│ Line E │ -│ Line F │ -│ Line G │ +│ Line B... │ │ No files have been changed │ │ │ │ │ -│ (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/shared/ExpandablePrompt.tsx b/packages/cli/src/ui/components/shared/ExpandablePrompt.tsx index 28e68b1eca6..52ef5954612 100644 --- a/packages/cli/src/ui/components/shared/ExpandablePrompt.tsx +++ b/packages/cli/src/ui/components/shared/ExpandablePrompt.tsx @@ -17,6 +17,7 @@ export interface ExpandablePromptProps { textColor?: string; isExpanded?: boolean; maxWidth?: number; + maxLines?: number; } const _ExpandablePrompt: React.FC = ({ @@ -26,6 +27,7 @@ const _ExpandablePrompt: React.FC = ({ textColor = theme.text.primary, isExpanded = false, maxWidth = DEFAULT_MAX_WIDTH, + maxLines, }) => { const hasMatch = matchedIndex !== undefined && @@ -35,11 +37,27 @@ const _ExpandablePrompt: React.FC = ({ // Render the plain label if there's no match if (!hasMatch) { - const display = isExpanded - ? label - : label.length > maxWidth - ? label.slice(0, maxWidth) + '...' - : 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} From 46e276402a8a5b694267e4ff389862d869d21673 Mon Sep 17 00:00:00 2001 From: "A.K.M. Adib" Date: Fri, 16 Jan 2026 13:29:14 -0500 Subject: [PATCH 34/42] update test --- packages/cli/src/ui/commands/rewindCommand.test.tsx | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/packages/cli/src/ui/commands/rewindCommand.test.tsx b/packages/cli/src/ui/commands/rewindCommand.test.tsx index 3a407534128..929ec9bc9e5 100644 --- a/packages/cli/src/ui/commands/rewindCommand.test.tsx +++ b/packages/cli/src/ui/commands/rewindCommand.test.tsx @@ -43,6 +43,12 @@ vi.mock('@google/gemini-cli-core', () => ({ coreEvents: { emitFeedback: vi.fn(), }, + LruCache: class { + get() { + return undefined; + } + set() {} + }, })); vi.mock('../components/RewindViewer.js', () => ({ From 14f75b7211b2ac1fb3f34c90e7cca31d934721e0 Mon Sep 17 00:00:00 2001 From: "A.K.M. Adib" Date: Fri, 16 Jan 2026 14:07:57 -0500 Subject: [PATCH 35/42] update test --- .../src/ui/commands/rewindCommand.test.tsx | 47 ++++++++++++------- .../src/ui/components/RewindViewer.test.tsx | 11 +++++ 2 files changed, 41 insertions(+), 17 deletions(-) diff --git a/packages/cli/src/ui/commands/rewindCommand.test.tsx b/packages/cli/src/ui/commands/rewindCommand.test.tsx index 929ec9bc9e5..b66a40d1eca 100644 --- a/packages/cli/src/ui/commands/rewindCommand.test.tsx +++ b/packages/cli/src/ui/commands/rewindCommand.test.tsx @@ -33,23 +33,36 @@ const mockRevertFileChanges = vi.fn(); const mockGetProjectRoot = vi.fn().mockReturnValue('/mock/root'); const mockClearTextToast = vi.fn(); -vi.mock('@google/gemini-cli-core', () => ({ - uiTelemetryService: { - recordRewind: vi.fn(), - }, - debugLogger: { - error: vi.fn(), - }, - coreEvents: { - emitFeedback: vi.fn(), - }, - LruCache: class { - get() { - return undefined; - } - set() {} - }, -})); +vi.mock('@google/gemini-cli-core', async (importOriginal) => { + const actual = + await importOriginal(); + return { + ...actual, + uiTelemetryService: { + ...actual.uiTelemetryService, + recordRewind: vi.fn(), + }, + debugLogger: { + ...actual.debugLogger, + error: vi.fn(), + }, + coreEvents: { + ...actual.coreEvents, + emitFeedback: vi.fn(), + }, + LruCache: class { + get() { + return undefined; + } + set() {} + clear() {} + has() { + return false; + } + }, + DEFAULT_MODEL_CONFIGS: { aliases: {}, overrides: [] }, + }; +}); vi.mock('../components/RewindViewer.js', () => ({ RewindViewer: () => null, diff --git a/packages/cli/src/ui/components/RewindViewer.test.tsx b/packages/cli/src/ui/components/RewindViewer.test.tsx index 649fbb4f4b3..382cb4bf6d2 100644 --- a/packages/cli/src/ui/components/RewindViewer.test.tsx +++ b/packages/cli/src/ui/components/RewindViewer.test.tsx @@ -46,6 +46,17 @@ vi.mock('@google/gemini-cli-core', async (importOriginal) => { return { ...original, partToString: (part: string | JSON) => partToStringRecursive(part), + LruCache: class { + get() { + return undefined; + } + set() {} + clear() {} + has() { + return false; + } + }, + DEFAULT_MODEL_CONFIGS: { aliases: {}, overrides: [] }, }; }); From f4baedcaa436fe2d3f1483de9089f0651729a322 Mon Sep 17 00:00:00 2001 From: "A.K.M. Adib" Date: Fri, 16 Jan 2026 20:06:23 -0500 Subject: [PATCH 36/42] address feedback from Jacob --- .../rewindCommand.integration.test.tsx | 175 ------------------ .../src/ui/commands/rewindCommand.test.tsx | 19 -- .../cli/src/ui/components/Composer.test.tsx | 2 +- .../src/ui/components/InputPrompt.test.tsx | 2 +- .../cli/src/ui/components/InputPrompt.tsx | 4 +- .../cli/src/ui/components/PrepareLabel.tsx | 25 --- .../src/ui/components/RewindViewer.test.tsx | 11 -- .../cli/src/ui/components/RewindViewer.tsx | 6 +- .../cli/src/ui/components/StatusDisplay.tsx | 2 +- .../src/ui/components/SuggestionsDisplay.tsx | 4 +- .../__snapshots__/StatusDisplay.test.tsx.snap | 2 +- ...rompt.test.tsx => ExpandableText.test.tsx} | 28 +-- ...xpandablePrompt.tsx => ExpandableText.tsx} | 10 +- .../ExpandableText.test.tsx.snap | 27 +++ 14 files changed, 56 insertions(+), 261 deletions(-) delete mode 100644 packages/cli/src/ui/commands/rewindCommand.integration.test.tsx delete mode 100644 packages/cli/src/ui/components/PrepareLabel.tsx rename packages/cli/src/ui/components/shared/{ExpandablePrompt.test.tsx => ExpandableText.test.tsx} (87%) rename packages/cli/src/ui/components/shared/{ExpandablePrompt.tsx => ExpandableText.tsx} (93%) create mode 100644 packages/cli/src/ui/components/shared/__snapshots__/ExpandableText.test.tsx.snap diff --git a/packages/cli/src/ui/commands/rewindCommand.integration.test.tsx b/packages/cli/src/ui/commands/rewindCommand.integration.test.tsx deleted file mode 100644 index 9c7db279651..00000000000 --- a/packages/cli/src/ui/commands/rewindCommand.integration.test.tsx +++ /dev/null @@ -1,175 +0,0 @@ -/** - * @license - * Copyright 2025 Google LLC - * SPDX-License-Identifier: Apache-2.0 - */ - -import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest'; -import { rewindCommand } from './rewindCommand.js'; -import { createMockCommandContext } from '../../test-utils/mockCommandContext.js'; -import { waitFor } from '../../test-utils/async.js'; -import { renderWithProviders } from '../../test-utils/render.js'; -import type { CommandContext, OpenCustomDialogActionReturn } from './types.js'; -import { act } from 'react'; - -// 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 mockGetProjectRoot = vi.fn().mockReturnValue('/mock/root'); - -// Mock rewindFileOps -const mockRevertFileChanges = vi.fn(); -vi.mock('../utils/rewindFileOps.js', () => ({ - revertFileChanges: (...args: unknown[]) => mockRevertFileChanges(...args), - calculateTurnStats: vi.fn(), - calculateRewindImpact: vi.fn(), -})); - -// Mock useSessionBrowser -vi.mock('../hooks/useSessionBrowser.js', () => ({ - convertSessionToHistoryFormats: vi.fn().mockReturnValue({ - uiHistory: [], - clientHistory: [], - }), -})); - -vi.mock('@google/gemini-cli-core', async () => { - const actual = await vi.importActual('@google/gemini-cli-core'); - return { - ...actual, - debugLogger: { - error: vi.fn(), - debug: vi.fn(), - log: vi.fn(), - }, - coreEvents: { - emitFeedback: vi.fn(), - }, - }; -}); - -describe('rewindCommand Integration', () => { - let mockContext: CommandContext; - - beforeEach(() => { - vi.clearAllMocks(); - - mockGetConversation.mockReturnValue({ - messages: [ - { type: 'user', content: 'Test Message', id: '1', timestamp: '1' }, - { type: 'gemini', content: 'Response', id: '2', timestamp: '2' }, - ], - sessionId: 'test-session', - }); - - mockRewindTo.mockReturnValue({ - 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; - }); - - afterEach(() => { - vi.restoreAllMocks(); - }); - - it('renders RewindViewer, handles interaction, and shows loading state', async () => { - // 1. Run the command to get the component - const result = (await rewindCommand.action!( - mockContext, - '', - )) as OpenCustomDialogActionReturn; - expect(result).toHaveProperty('type', 'custom_dialog'); - - // 2. Render the component with providers - const { lastFrame, stdin } = renderWithProviders( - result.component as React.ReactElement, - ); - - // 3. Verify RewindViewer is rendered - expect(lastFrame()).toContain('> Rewind'); - expect(lastFrame()).toContain('Test Message'); - - // 4. Select the message (Enter) - act(() => { - stdin.write('\r'); - }); - - // 5. Verify Confirmation Dialog - await waitFor(() => { - expect(lastFrame()).toContain('Confirm Rewind'); - }); - - await new Promise((resolve) => setTimeout(resolve, 200)); - - // 6. Mock rewindTo to delay so we can see loading state - mockRewindTo.mockImplementation(async () => { - await new Promise((resolve) => setTimeout(resolve, 500)); - - return { messages: [] }; - }); - - // 7. Confirm Rewind - - // (Enter on default option 'Rewind conversation and revert code changes') - - // We need to ensure RewindAndRevert is selected or select it. - - // Default selection is usually first. - - await act(async () => { - stdin.write('\r'); - }); - - // 8. Verify Loading State - - // We expect "Rewinding..." to be visible - - await waitFor(() => { - expect(lastFrame()).toContain('Rewinding...'); - }); - - // 9. Wait for completion - - await waitFor(() => { - expect(mockRewindTo).toHaveBeenCalledWith('1'); - }); - - // 10. Verify component removal (it's called in onRewind) - expect(mockRemoveComponent).toHaveBeenCalled(); - }); -}); diff --git a/packages/cli/src/ui/commands/rewindCommand.test.tsx b/packages/cli/src/ui/commands/rewindCommand.test.tsx index b66a40d1eca..db8e3735ca0 100644 --- a/packages/cli/src/ui/commands/rewindCommand.test.tsx +++ b/packages/cli/src/ui/commands/rewindCommand.test.tsx @@ -38,29 +38,10 @@ vi.mock('@google/gemini-cli-core', async (importOriginal) => { await importOriginal(); return { ...actual, - uiTelemetryService: { - ...actual.uiTelemetryService, - recordRewind: vi.fn(), - }, - debugLogger: { - ...actual.debugLogger, - error: vi.fn(), - }, coreEvents: { ...actual.coreEvents, emitFeedback: vi.fn(), }, - LruCache: class { - get() { - return undefined; - } - set() {} - clear() {} - has() { - return false; - } - }, - DEFAULT_MODEL_CONFIGS: { aliases: {}, overrides: [] }, }; }); diff --git a/packages/cli/src/ui/components/Composer.test.tsx b/packages/cli/src/ui/components/Composer.test.tsx index 41002c78c06..96b42dc4868 100644 --- a/packages/cli/src/ui/components/Composer.test.tsx +++ b/packages/cli/src/ui/components/Composer.test.tsx @@ -403,7 +403,7 @@ describe('Composer', () => { const { lastFrame } = renderComposer(uiState); - expect(lastFrame()).toContain('Ctrl + C is the new way to clear text'); + expect(lastFrame()).toContain('Ctrl + C clears all text in the prompt'); }); }); diff --git a/packages/cli/src/ui/components/InputPrompt.test.tsx b/packages/cli/src/ui/components/InputPrompt.test.tsx index 9e617e7bc64..0a801cba871 100644 --- a/packages/cli/src/ui/components/InputPrompt.test.tsx +++ b/packages/cli/src/ui/components/InputPrompt.test.tsx @@ -1904,7 +1904,7 @@ describe('InputPrompt', () => { await act(async () => { stdin.write('\x1B\x1B'); - vi.advanceTimersByTime(2000); + vi.advanceTimersByTime(100); expect(props.onSubmit).toHaveBeenCalledWith('/rewind'); }); diff --git a/packages/cli/src/ui/components/InputPrompt.tsx b/packages/cli/src/ui/components/InputPrompt.tsx index 62e349e541c..63847317ada 100644 --- a/packages/cli/src/ui/components/InputPrompt.tsx +++ b/packages/cli/src/ui/components/InputPrompt.tsx @@ -513,9 +513,7 @@ export const InputPrompt: React.FC = ({ if (onClearTextToastChange) { onClearTextToastChange(true); } - setTimeout(() => { - onSubmit('/rewind'); - }, 1000); + onSubmit('/rewind'); } return; } diff --git a/packages/cli/src/ui/components/PrepareLabel.tsx b/packages/cli/src/ui/components/PrepareLabel.tsx deleted file mode 100644 index 88bc91b53e1..00000000000 --- a/packages/cli/src/ui/components/PrepareLabel.tsx +++ /dev/null @@ -1,25 +0,0 @@ -/** - * @license - * Copyright 2025 Google LLC - * SPDX-License-Identifier: Apache-2.0 - */ - -import type React from 'react'; -import { - ExpandablePrompt, - DEFAULT_MAX_WIDTH, -} from './shared/ExpandablePrompt.js'; - -export const MAX_WIDTH = DEFAULT_MAX_WIDTH; - -export interface PrepareLabelProps { - label: string; - matchedIndex?: number; - userInput: string; - textColor: string; - isExpanded?: boolean; -} - -export const PrepareLabel: React.FC = (props) => ( - -); diff --git a/packages/cli/src/ui/components/RewindViewer.test.tsx b/packages/cli/src/ui/components/RewindViewer.test.tsx index 382cb4bf6d2..649fbb4f4b3 100644 --- a/packages/cli/src/ui/components/RewindViewer.test.tsx +++ b/packages/cli/src/ui/components/RewindViewer.test.tsx @@ -46,17 +46,6 @@ vi.mock('@google/gemini-cli-core', async (importOriginal) => { return { ...original, partToString: (part: string | JSON) => partToStringRecursive(part), - LruCache: class { - get() { - return undefined; - } - set() {} - clear() {} - has() { - return false; - } - }, - DEFAULT_MODEL_CONFIGS: { aliases: {}, overrides: [] }, }; }); diff --git a/packages/cli/src/ui/components/RewindViewer.tsx b/packages/cli/src/ui/components/RewindViewer.tsx index f53644efd1d..67c7d3e3e6c 100644 --- a/packages/cli/src/ui/components/RewindViewer.tsx +++ b/packages/cli/src/ui/components/RewindViewer.tsx @@ -21,7 +21,7 @@ import { RewindConfirmation, RewindOutcome } from './RewindConfirmation.js'; import { stripReferenceContent } from '../utils/formatters.js'; import { keyMatchers, Command } from '../keyMatchers.js'; import { CliSpinner } from './CliSpinner.js'; -import { ExpandablePrompt } from './shared/ExpandablePrompt.js'; +import { ExpandableText } from './shared/ExpandableText.js'; interface RewindViewerProps { conversation: ConversationRecord; @@ -126,7 +126,7 @@ export const RewindViewer: React.FC = ({ width={terminalWidth} flexDirection="row" > - + Rewinding... @@ -207,7 +207,7 @@ export const RewindViewer: React.FC = ({ return ( - = ({ if (uiState.showClearTextToast) { return ( - Ctrl + C is the new way to clear text. + Ctrl + C clears all text in the prompt. ); } diff --git a/packages/cli/src/ui/components/SuggestionsDisplay.tsx b/packages/cli/src/ui/components/SuggestionsDisplay.tsx index 96eb5540767..eb08997e4f5 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 = ( - prioritizes Ctrl+C prompt over everything else (except exports[`StatusDisplay > prioritizes warning over Ctrl+D 1`] = `"Warning"`; -exports[`StatusDisplay > renders Clear Text Toast 1`] = `"Ctrl + C is the new way to clear text."`; +exports[`StatusDisplay > renders Clear Text Toast 1`] = `"Ctrl + C clears all text in the prompt."`; exports[`StatusDisplay > renders ContextSummaryDisplay by default 1`] = `"Mock Context Summary Display (Skills: 2)"`; diff --git a/packages/cli/src/ui/components/shared/ExpandablePrompt.test.tsx b/packages/cli/src/ui/components/shared/ExpandableText.test.tsx similarity index 87% rename from packages/cli/src/ui/components/shared/ExpandablePrompt.test.tsx rename to packages/cli/src/ui/components/shared/ExpandableText.test.tsx index 45ad4fe1c2f..1e46751f573 100644 --- a/packages/cli/src/ui/components/shared/ExpandablePrompt.test.tsx +++ b/packages/cli/src/ui/components/shared/ExpandableText.test.tsx @@ -6,15 +6,15 @@ import { describe, it, expect } from 'vitest'; import { render } from '../../../test-utils/render.js'; -import { ExpandablePrompt, DEFAULT_MAX_WIDTH } from './ExpandablePrompt.js'; +import { ExpandableText, MAX_WIDTH } from './ExpandableText.js'; -describe('ExpandablePrompt', () => { +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(DEFAULT_MAX_WIDTH + 25); + const long = 'x'.repeat(MAX_WIDTH + 25); const { lastFrame, unmount } = render( - { const out = lastFrame(); const f = flat(out); expect(f.endsWith('...')).toBe(true); - expect(f.length).toBe(DEFAULT_MAX_WIDTH + 3); + expect(f.length).toBe(MAX_WIDTH + 3); expect(out).toMatchSnapshot(); unmount(); }); it('shows full long label when expanded and no match', () => { - const long = 'y'.repeat(DEFAULT_MAX_WIDTH + 25); + 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( - { it('truncates match itself when match is very long', () => { const prefix = 'find '; - const core = 'x'.repeat(DEFAULT_MAX_WIDTH + 25); + const core = 'x'.repeat(MAX_WIDTH + 25); const suffix = ' in this text'; const label = prefix + core + suffix; const matchedIndex = prefix.length; const { lastFrame, unmount } = render( - { expect(f.includes('...')).toBe(true); expect(f.startsWith('...')).toBe(false); expect(f.endsWith('...')).toBe(true); - expect(f.length).toBe(DEFAULT_MAX_WIDTH + 2); + expect(f.length).toBe(MAX_WIDTH + 2); expect(out).toMatchSnapshot(); unmount(); }); @@ -133,7 +133,7 @@ describe('ExpandablePrompt', () => { const customWidth = 50; const long = 'z'.repeat(100); const { lastFrame, unmount } = render( - = ({ +const _ExpandableText: React.FC = ({ label, matchedIndex, userInput = '', textColor = theme.text.primary, isExpanded = false, - maxWidth = DEFAULT_MAX_WIDTH, + maxWidth = MAX_WIDTH, maxLines, }) => { const hasMatch = @@ -133,4 +133,4 @@ const _ExpandablePrompt: React.FC = ({ ); }; -export const ExpandablePrompt = React.memo(_ExpandablePrompt); +export const ExpandableText = React.memo(_ExpandableText); 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 00000000000..8716c962ea6 --- /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..." +`; From f2424cb66128236b6dfb7a29df213dd463884ad5 Mon Sep 17 00:00:00 2001 From: "A.K.M. Adib" Date: Tue, 20 Jan 2026 09:39:14 -0500 Subject: [PATCH 37/42] add educational toast to notifications so that it appears at the same time when users open the rewind viewer --- packages/cli/src/ui/components/Composer.test.tsx | 10 ---------- packages/cli/src/ui/components/Notifications.test.tsx | 11 +++++++++++ packages/cli/src/ui/components/Notifications.tsx | 11 +++++++++-- packages/cli/src/ui/components/StatusDisplay.test.tsx | 11 ----------- packages/cli/src/ui/components/StatusDisplay.tsx | 8 -------- .../__snapshots__/StatusDisplay.test.tsx.snap | 2 -- 6 files changed, 20 insertions(+), 33 deletions(-) diff --git a/packages/cli/src/ui/components/Composer.test.tsx b/packages/cli/src/ui/components/Composer.test.tsx index 96b42dc4868..8368109a8e8 100644 --- a/packages/cli/src/ui/components/Composer.test.tsx +++ b/packages/cli/src/ui/components/Composer.test.tsx @@ -395,16 +395,6 @@ describe('Composer', () => { expect(lastFrame()).toContain('Press Esc again to rewind'); }); - - it('shows clearing input educational message when showClearTextToast is true', () => { - const uiState = createMockUIState({ - showClearTextToast: true, - }); - - const { lastFrame } = renderComposer(uiState); - - expect(lastFrame()).toContain('Ctrl + C clears all text in the prompt'); - }); }); describe('Input and Indicators', () => { diff --git a/packages/cli/src/ui/components/Notifications.test.tsx b/packages/cli/src/ui/components/Notifications.test.tsx index 0e04799cbaf..0940788b18e 100644 --- a/packages/cli/src/ui/components/Notifications.test.tsx +++ b/packages/cli/src/ui/components/Notifications.test.tsx @@ -181,4 +181,15 @@ describe('Notifications', () => { expect(lastFrame()).toBe(''); expect(mockFsWriteFile).not.toHaveBeenCalled(); }); + + it('renders clear text toast', () => { + mockUseUIState.mockReturnValue({ + initError: null, + streamingState: 'idle', + updateInfo: null, + showClearTextToast: true, + } as unknown as UIState); + const { lastFrame } = render(); + expect(lastFrame()).toContain('Ctrl + C clears all text in the prompt'); + }); }); diff --git a/packages/cli/src/ui/components/Notifications.tsx b/packages/cli/src/ui/components/Notifications.tsx index 460d03f88b9..49e700367a3 100644 --- a/packages/cli/src/ui/components/Notifications.tsx +++ b/packages/cli/src/ui/components/Notifications.tsx @@ -31,7 +31,8 @@ const screenReaderNudgeFilePath = path.join( export const Notifications = () => { const { startupWarnings } = useAppContext(); - const { initError, streamingState, updateInfo } = useUIState(); + const { initError, streamingState, updateInfo, showClearTextToast } = + useUIState(); const isScreenReaderEnabled = useIsScreenReaderEnabled(); const showStartupWarnings = startupWarnings.length > 0; @@ -82,13 +83,19 @@ export const Notifications = () => { !showStartupWarnings && !showInitError && !updateInfo && - !showScreenReaderNudge + !showScreenReaderNudge && + !showClearTextToast ) { return null; } return ( <> + {showClearTextToast && ( + + Ctrl + C clears all text in the prompt. + + )} {showScreenReaderNudge && ( You are currently in screen reader-friendly view. To switch out, open{' '} diff --git a/packages/cli/src/ui/components/StatusDisplay.test.tsx b/packages/cli/src/ui/components/StatusDisplay.test.tsx index 13d1afbedb5..9d19fb8e8bc 100644 --- a/packages/cli/src/ui/components/StatusDisplay.test.tsx +++ b/packages/cli/src/ui/components/StatusDisplay.test.tsx @@ -159,17 +159,6 @@ describe('StatusDisplay', () => { expect(lastFrame()).toMatchSnapshot(); }); - it('renders Clear Text Toast', () => { - const uiState = createMockUIState({ - showClearTextToast: true, - }); - const { lastFrame } = renderStatusDisplay( - { hideContextSummary: false }, - uiState, - ); - expect(lastFrame()).toMatchSnapshot(); - }); - it('renders Queue Error Message', () => { const uiState = createMockUIState({ queueErrorMessage: 'Queue Error', diff --git a/packages/cli/src/ui/components/StatusDisplay.tsx b/packages/cli/src/ui/components/StatusDisplay.tsx index d5c2b09e833..c9e9e414e56 100644 --- a/packages/cli/src/ui/components/StatusDisplay.tsx +++ b/packages/cli/src/ui/components/StatusDisplay.tsx @@ -48,14 +48,6 @@ export const StatusDisplay: React.FC = ({ return Press Esc again to rewind.; } - if (uiState.showClearTextToast) { - return ( - - Ctrl + C clears all text in the prompt. - - ); - } - if (uiState.queueErrorMessage) { return {uiState.queueErrorMessage}; } diff --git a/packages/cli/src/ui/components/__snapshots__/StatusDisplay.test.tsx.snap b/packages/cli/src/ui/components/__snapshots__/StatusDisplay.test.tsx.snap index e9db532868e..521f642a9af 100644 --- a/packages/cli/src/ui/components/__snapshots__/StatusDisplay.test.tsx.snap +++ b/packages/cli/src/ui/components/__snapshots__/StatusDisplay.test.tsx.snap @@ -6,8 +6,6 @@ exports[`StatusDisplay > prioritizes Ctrl+C prompt over everything else (except exports[`StatusDisplay > prioritizes warning over Ctrl+D 1`] = `"Warning"`; -exports[`StatusDisplay > renders Clear Text Toast 1`] = `"Ctrl + C clears all text in the prompt."`; - exports[`StatusDisplay > renders ContextSummaryDisplay by default 1`] = `"Mock Context Summary Display (Skills: 2)"`; exports[`StatusDisplay > renders Ctrl+D prompt 1`] = `"Press Ctrl+D again to exit."`; From e75590ef353b78ca866ae9d6d833180589fcbc23 Mon Sep 17 00:00:00 2001 From: "A.K.M. Adib" Date: Wed, 21 Jan 2026 14:03:43 -0500 Subject: [PATCH 38/42] address another round of feedback --- packages/cli/src/nonInteractiveCli.test.ts | 6 +- packages/cli/src/test-utils/render.tsx | 1 - packages/cli/src/ui/AppContainer.tsx | 23 ----- .../src/ui/commands/rewindCommand.test.tsx | 21 ++++- .../cli/src/ui/commands/rewindCommand.tsx | 19 +++- packages/cli/src/ui/commands/types.ts | 2 - packages/cli/src/ui/components/Composer.tsx | 1 - .../cli/src/ui/components/InputPrompt.tsx | 28 +++--- .../src/ui/components/Notifications.test.tsx | 11 --- .../cli/src/ui/components/Notifications.tsx | 11 +-- .../src/ui/components/RewindViewer.test.tsx | 12 ++- .../cli/src/ui/components/RewindViewer.tsx | 80 +++++++++++----- .../src/ui/components/StatusDisplay.test.tsx | 1 - .../__snapshots__/RewindViewer.test.tsx.snap | 77 ++++++++++++---- .../cli/src/ui/contexts/UIActionsContext.tsx | 1 - .../cli/src/ui/contexts/UIStateContext.tsx | 1 - .../src/ui/hooks/atCommandProcessor.test.ts | 92 ++++++++++--------- .../cli/src/ui/hooks/atCommandProcessor.ts | 6 +- .../ui/hooks/slashCommandProcessor.test.tsx | 1 - .../cli/src/ui/hooks/slashCommandProcessor.ts | 2 - .../src/ui/noninteractive/nonInteractiveUi.ts | 1 - packages/cli/src/ui/utils/formatters.test.ts | 23 +++-- packages/cli/src/ui/utils/formatters.ts | 10 +- .../cli/src/zed-integration/zedIntegration.ts | 3 +- packages/core/src/index.ts | 1 + .../core/src/tools/read-many-files.test.ts | 19 ++-- packages/core/src/tools/read-many-files.ts | 6 +- packages/core/src/utils/constants.ts | 8 ++ 28 files changed, 266 insertions(+), 201 deletions(-) create mode 100644 packages/core/src/utils/constants.ts diff --git a/packages/cli/src/nonInteractiveCli.test.ts b/packages/cli/src/nonInteractiveCli.test.ts index 7b12f864b34..1cc24d3429a 100644 --- a/packages/cli/src/nonInteractiveCli.test.ts +++ b/packages/cli/src/nonInteractiveCli.test.ts @@ -18,6 +18,8 @@ import { ToolErrorType, GeminiEventType, OutputFormat, + REFERENCE_CONTENT_START, + REFERENCE_CONTENT_END, uiTelemetryService, FatalInputError, CoreEvent, @@ -568,9 +570,9 @@ describe('runNonInteractive', () => { const rawInput = 'Summarize @file.txt'; const processedParts: Part[] = [ { text: 'Summarize @file.txt' }, - { text: '\n--- Content from referenced files ---\n' }, + { text: `\n${REFERENCE_CONTENT_START}\n` }, { text: 'This is the content of the file.' }, - { text: '\n--- End of content ---' }, + { text: `\n${REFERENCE_CONTENT_END}` }, ]; // 3. Setup the mock to return the processed parts diff --git a/packages/cli/src/test-utils/render.tsx b/packages/cli/src/test-utils/render.tsx index 3637ee89cf9..bb4cd5ca8db 100644 --- a/packages/cli/src/test-utils/render.tsx +++ b/packages/cli/src/test-utils/render.tsx @@ -174,7 +174,6 @@ const mockUIActions: UIActions = { setBannerVisible: vi.fn(), setEmbeddedShellFocused: vi.fn(), setAuthContext: vi.fn(), - onClearTextToastChange: vi.fn(), handleRestart: vi.fn(), }; diff --git a/packages/cli/src/ui/AppContainer.tsx b/packages/cli/src/ui/AppContainer.tsx index a9c3b0439f2..5c9b6b7395a 100644 --- a/packages/cli/src/ui/AppContainer.tsx +++ b/packages/cli/src/ui/AppContainer.tsx @@ -659,23 +659,6 @@ Logging in with Google... Restarting Gemini CLI to continue. exitEditorDialog, } = useEditorSettings(settings, setEditorError, historyManager.addItem); - const [showClearTextToast, setShowClearTextToast] = useState(false); - const clearTextToastTimeoutRef = useRef(null); - - const handleClearTextToastChange = useCallback((show: boolean) => { - setShowClearTextToast(show); - if (clearTextToastTimeoutRef.current) { - clearTimeout(clearTextToastTimeoutRef.current); - clearTextToastTimeoutRef.current = null; - } - if (show) { - clearTextToastTimeoutRef.current = setTimeout(() => { - setShowClearTextToast(false); - clearTextToastTimeoutRef.current = null; - }, QUEUE_ERROR_DISPLAY_DURATION_MS); - } - }, []); - const { isSettingsDialogOpen, openSettingsDialog, closeSettingsDialog } = useSettingsCommand(); @@ -707,7 +690,6 @@ Logging in with Google... Restarting Gemini CLI to continue. dispatchExtensionStateUpdate, addConfirmUpdateExtensionRequest, setText: (text: string) => buffer.setText(text), - clearTextToast: () => handleClearTextToastChange(false), }), [ setAuthState, @@ -725,7 +707,6 @@ Logging in with Google... Restarting Gemini CLI to continue. addConfirmUpdateExtensionRequest, toggleDebugProfiler, buffer, - handleClearTextToastChange, ], ); @@ -1604,7 +1585,6 @@ Logging in with Google... Restarting Gemini CLI to continue. ctrlCPressedOnce: ctrlCPressCount >= 1, ctrlDPressedOnce: ctrlDPressCount >= 1, showEscapePrompt, - showClearTextToast, isFocused, elapsedTime, currentLoadingPhrase, @@ -1697,7 +1677,6 @@ Logging in with Google... Restarting Gemini CLI to continue. ctrlCPressCount, ctrlDPressCount, showEscapePrompt, - showClearTextToast, isFocused, elapsedTime, currentLoadingPhrase, @@ -1774,7 +1753,6 @@ Logging in with Google... Restarting Gemini CLI to continue. handleFolderTrustSelect, setConstrainHeight, onEscapePromptChange: handleEscapePromptChange, - onClearTextToastChange: handleClearTextToastChange, refreshStatic, handleFinalSubmit, handleClearScreen, @@ -1816,7 +1794,6 @@ Logging in with Google... Restarting Gemini CLI to continue. handleFolderTrustSelect, setConstrainHeight, handleEscapePromptChange, - handleClearTextToastChange, refreshStatic, handleFinalSubmit, handleClearScreen, diff --git a/packages/cli/src/ui/commands/rewindCommand.test.tsx b/packages/cli/src/ui/commands/rewindCommand.test.tsx index db8e3735ca0..b0236845bc8 100644 --- a/packages/cli/src/ui/commands/rewindCommand.test.tsx +++ b/packages/cli/src/ui/commands/rewindCommand.test.tsx @@ -31,7 +31,6 @@ const mockResetContext = vi.fn(); const mockSetInput = vi.fn(); const mockRevertFileChanges = vi.fn(); const mockGetProjectRoot = vi.fn().mockReturnValue('/mock/root'); -const mockClearTextToast = vi.fn(); vi.mock('@google/gemini-cli-core', async (importOriginal) => { const actual = @@ -80,7 +79,7 @@ describe('rewindCommand', () => { vi.clearAllMocks(); mockGetConversation.mockReturnValue({ - messages: [], + messages: [{ id: 'msg-1', type: 'user', content: 'hello' }], sessionId: 'test-session', }); @@ -112,7 +111,6 @@ describe('rewindCommand', () => { loadHistory: mockLoadHistory, addItem: mockAddItem, setPendingItem: mockSetPendingItem, - clearTextToast: mockClearTextToast, }, }) as unknown as CommandContext; }); @@ -218,7 +216,6 @@ describe('rewindCommand', () => { expect(mockRevertFileChanges).not.toHaveBeenCalled(); expect(mockRewindTo).not.toHaveBeenCalled(); expect(mockRemoveComponent).toHaveBeenCalled(); - expect(mockClearTextToast).toHaveBeenCalled(); }); expect(mockSetInput).not.toHaveBeenCalled(); }); @@ -234,7 +231,6 @@ describe('rewindCommand', () => { onExit(); expect(mockRemoveComponent).toHaveBeenCalled(); - expect(mockClearTextToast).toHaveBeenCalled(); }); it('should handle rewind error correctly', async () => { @@ -337,4 +333,19 @@ describe('rewindCommand', () => { 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 index 53bd0b6d7f8..882644b5d2e 100644 --- a/packages/cli/src/ui/commands/rewindCommand.tsx +++ b/packages/cli/src/ui/commands/rewindCommand.tsx @@ -52,6 +52,17 @@ export const rewindCommand: SlashCommand = { 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: ( @@ -59,14 +70,12 @@ export const rewindCommand: SlashCommand = { conversation={conversation} onExit={() => { context.ui.removeComponent(); - context.ui.clearTextToast(); }} onRewind={async (messageId, newText, outcome) => { try { switch (outcome) { case RewindOutcome.Cancel: context.ui.removeComponent(); - context.ui.clearTextToast(); return; case RewindOutcome.RevertOnly: @@ -92,8 +101,8 @@ export const rewindCommand: SlashCommand = { checkExhaustive(outcome); } - const rewindedConvesation = recordingService.rewindTo(messageId); - if (!rewindedConvesation) { + const rewindedConversation = recordingService.rewindTo(messageId); + if (!rewindedConversation) { const errorMsg = 'Could not fetch conversation file'; debugLogger.error(errorMsg); context.ui.removeComponent(); @@ -103,7 +112,7 @@ export const rewindCommand: SlashCommand = { // Convert to UI and Client formats const { uiHistory, clientHistory } = - convertSessionToHistoryFormats(rewindedConvesation.messages); + convertSessionToHistoryFormats(rewindedConversation.messages); // Reset the client's internal history to match the file client.setHistory(clientHistory as Content[]); diff --git a/packages/cli/src/ui/commands/types.ts b/packages/cli/src/ui/commands/types.ts index 97fe767f98f..613175c1bed 100644 --- a/packages/cli/src/ui/commands/types.ts +++ b/packages/cli/src/ui/commands/types.ts @@ -78,8 +78,6 @@ export interface CommandContext { dispatchExtensionStateUpdate: (action: ExtensionUpdateAction) => void; addConfirmUpdateExtensionRequest: (value: ConfirmationRequest) => void; removeComponent: () => void; - /** Clears the "Ctrl+C to clear text" toast if it is visible. */ - clearTextToast: () => void; }; // Session-specific data session: { diff --git a/packages/cli/src/ui/components/Composer.tsx b/packages/cli/src/ui/components/Composer.tsx index b3de845924e..4fca6e8b0ba 100644 --- a/packages/cli/src/ui/components/Composer.tsx +++ b/packages/cli/src/ui/components/Composer.tsx @@ -133,7 +133,6 @@ export const Composer = () => { setShellModeActive={uiActions.setShellModeActive} approvalMode={showApprovalModeIndicator} onEscapePromptChange={uiActions.onEscapePromptChange} - onClearTextToastChange={uiActions.onClearTextToastChange} focus={true} vimHandleInput={uiActions.vimHandleInput} isEmbeddedShellFocused={uiState.embeddedShellFocused} diff --git a/packages/cli/src/ui/components/InputPrompt.tsx b/packages/cli/src/ui/components/InputPrompt.tsx index 7ddb412a19b..e12e1e4d3f4 100644 --- a/packages/cli/src/ui/components/InputPrompt.tsx +++ b/packages/cli/src/ui/components/InputPrompt.tsx @@ -27,7 +27,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, @@ -82,7 +82,6 @@ export interface InputPromptProps { setShellModeActive: (value: boolean) => void; approvalMode: ApprovalMode; onEscapePromptChange?: (showPrompt: boolean) => void; - onClearTextToastChange?: (showToast: boolean) => void; onSuggestionsVisibilityChange?: (visible: boolean) => void; vimHandleInput?: (key: Key) => boolean; isEmbeddedShellFocused?: boolean; @@ -133,7 +132,6 @@ export const InputPrompt: React.FC = ({ popAllMessages, suggestionsPosition = 'below', setBannerVisible, - onClearTextToastChange, }) => { const { stdout } = useStdout(); const { merged: settings } = useSettings(); @@ -507,18 +505,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/Notifications.test.tsx b/packages/cli/src/ui/components/Notifications.test.tsx index 0940788b18e..0e04799cbaf 100644 --- a/packages/cli/src/ui/components/Notifications.test.tsx +++ b/packages/cli/src/ui/components/Notifications.test.tsx @@ -181,15 +181,4 @@ describe('Notifications', () => { expect(lastFrame()).toBe(''); expect(mockFsWriteFile).not.toHaveBeenCalled(); }); - - it('renders clear text toast', () => { - mockUseUIState.mockReturnValue({ - initError: null, - streamingState: 'idle', - updateInfo: null, - showClearTextToast: true, - } as unknown as UIState); - const { lastFrame } = render(); - expect(lastFrame()).toContain('Ctrl + C clears all text in the prompt'); - }); }); diff --git a/packages/cli/src/ui/components/Notifications.tsx b/packages/cli/src/ui/components/Notifications.tsx index 49e700367a3..460d03f88b9 100644 --- a/packages/cli/src/ui/components/Notifications.tsx +++ b/packages/cli/src/ui/components/Notifications.tsx @@ -31,8 +31,7 @@ const screenReaderNudgeFilePath = path.join( export const Notifications = () => { const { startupWarnings } = useAppContext(); - const { initError, streamingState, updateInfo, showClearTextToast } = - useUIState(); + const { initError, streamingState, updateInfo } = useUIState(); const isScreenReaderEnabled = useIsScreenReaderEnabled(); const showStartupWarnings = startupWarnings.length > 0; @@ -83,19 +82,13 @@ export const Notifications = () => { !showStartupWarnings && !showInitError && !updateInfo && - !showScreenReaderNudge && - !showClearTextToast + !showScreenReaderNudge ) { return null; } return ( <> - {showClearTextToast && ( - - Ctrl + C clears all text in the prompt. - - )} {showScreenReaderNudge && ( You are currently in screen reader-friendly view. To switch out, open{' '} diff --git a/packages/cli/src/ui/components/RewindViewer.test.tsx b/packages/cli/src/ui/components/RewindViewer.test.tsx index 649fbb4f4b3..db7d627e5c5 100644 --- a/packages/cli/src/ui/components/RewindViewer.test.tsx +++ b/packages/cli/src/ui/components/RewindViewer.test.tsx @@ -57,6 +57,11 @@ const createConversation = (messages: MessageRecord[]): ConversationRecord => ({ messages, }); +import { + REFERENCE_CONTENT_START, + REFERENCE_CONTENT_END, +} from '@google/gemini-cli-core'; + describe('RewindViewer', () => { afterEach(() => { vi.restoreAllMocks(); @@ -252,17 +257,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${REFERENCE_CONTENT_START}\nContent from file:\nblah blah\n${REFERENCE_CONTENT_END}`, }, { description: 'strips expanded MCP resource content', prompt: 'read @server3:mcp://demo-resource hello\n' + - '--- Content from referenced files ---\n' + + `${REFERENCE_CONTENT_START}\n` + '\nContent from @server3:mcp://demo-resource:\n' + 'This is the content of the demo resource.\n' + - '--- End of content ---', + `${REFERENCE_CONTENT_END}`, }, ])('$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 67c7d3e3e6c..7fd0bd16dcc 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, useState, useEffect } from 'react'; +import { useMemo, useState } from 'react'; import { Box, Text } from 'ink'; import { useUIState } from '../contexts/UIStateContext.js'; import { @@ -62,27 +62,30 @@ export const RewindViewer: React.FC = ({ [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, + })); - useEffect(() => { - if (items.length > 0 && !highlightedMessageId) { - // Initialize with the first item (most recent) - const firstItem = items[0]; - if (firstItem?.value.id) { - setHighlightedMessageId(firstItem.value.id); - } - } - }, [items, highlightedMessageId]); + // 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]); + + const initialIndex = useMemo(() => Math.max(0, items.length - 2), [items]); useKeypress( (key) => { @@ -92,7 +95,10 @@ export const RewindViewer: React.FC = ({ return; } if (keyMatchers[Command.EXPAND_SUGGESTION](key)) { - if (highlightedMessageId) { + if ( + highlightedMessageId && + highlightedMessageId !== 'current-position' + ) { setExpandedMessageId(highlightedMessageId); } } @@ -134,6 +140,11 @@ export const RewindViewer: React.FC = ({ ); } + if (selectedMessageId === 'current-position') { + onExit(); + return null; + } + const selectedMessage = interactions.find( (m) => m.id === selectedMessageId, ); @@ -179,12 +190,17 @@ export const RewindViewer: React.FC = ({ { const userPrompt = item; if (userPrompt && userPrompt.id) { - selectMessage(userPrompt.id); + if (userPrompt.id === 'current-position') { + onExit(); + } else { + selectMessage(userPrompt.id); + } } }} onHighlight={(item: MessageRecord) => { @@ -197,6 +213,24 @@ export const RewindViewer: React.FC = ({ 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 diff --git a/packages/cli/src/ui/components/StatusDisplay.test.tsx b/packages/cli/src/ui/components/StatusDisplay.test.tsx index ada86cc5bf6..8861b3c62a3 100644 --- a/packages/cli/src/ui/components/StatusDisplay.test.tsx +++ b/packages/cli/src/ui/components/StatusDisplay.test.tsx @@ -36,7 +36,6 @@ const createMockUIState = (overrides: UIStateOverrides = {}): UIState => warningMessage: null, ctrlDPressedOnce: false, showEscapePrompt: false, - showClearTextToast: false, queueErrorMessage: null, activeHooks: [], ideContextState: null, diff --git a/packages/cli/src/ui/components/__snapshots__/RewindViewer.test.tsx.snap b/packages/cli/src/ui/components/__snapshots__/RewindViewer.test.tsx.snap index a20302676e7..0030a8adc87 100644 --- a/packages/cli/src/ui/components/__snapshots__/RewindViewer.test.tsx.snap +++ b/packages/cli/src/ui/components/__snapshots__/RewindViewer.test.tsx.snap @@ -8,6 +8,9 @@ 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, Right/Left to expand/collapse) │ │ │ @@ -22,6 +25,9 @@ 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, Right/Left to expand/collapse) │ │ │ @@ -63,15 +69,18 @@ exports[`RewindViewer > Navigation > handles 'down' navigation > after-down 1`] │ │ │ > Rewind │ │ │ -│ Q3 │ +│ Q1 │ │ No files have been changed │ │ │ -│ ● Q2 │ +│ 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, Right/Left to expand/collapse) │ │ │ @@ -83,15 +92,18 @@ exports[`RewindViewer > Navigation > handles 'up' navigation > after-up 1`] = ` │ │ │ > Rewind │ │ │ -│ Q3 │ +│ Q1 │ │ No files have been changed │ │ │ -│ Q2 │ +│ ● 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, Right/Left to expand/collapse) │ │ │ @@ -103,15 +115,18 @@ 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, Right/Left to expand/collapse) │ │ │ @@ -123,15 +138,18 @@ exports[`RewindViewer > Navigation > handles cyclic navigation > cyclic-up 1`] = │ │ │ > Rewind │ │ │ -│ Q3 │ +│ Q1 │ │ No files have been changed │ │ │ -│ Q2 │ +│ ● 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, Right/Left to expand/collapse) │ │ │ @@ -146,6 +164,9 @@ 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, Right/Left to expand/collapse) │ │ │ @@ -161,6 +182,9 @@ exports[`RewindViewer > Rendering > renders 'full text for selected item' 1`] = │ 2... │ │ No files have been changed │ │ │ +│ Stay at current position │ +│ Cancel rewind and stay here │ +│ │ │ │ │ (Use Enter to select a message, Esc to close, Right/Left to expand/collapse) │ │ │ @@ -172,6 +196,9 @@ 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, Right/Left to expand/collapse) │ │ │ @@ -183,12 +210,15 @@ 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, Right/Left to expand/collapse) │ │ │ @@ -203,6 +233,9 @@ 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, Right/Left to expand/collapse) │ │ │ @@ -214,13 +247,16 @@ exports[`RewindViewer > updates selection and expansion on navigation > after-do │ │ │ > Rewind │ │ │ +│ Line A │ +│ Line B... │ +│ No files have been changed │ +│ │ │ Line 1 │ │ Line 2... │ │ No files have been changed │ │ │ -│ ● Line A │ -│ Line B... │ -│ No files have been changed │ +│ ● Stay at current position │ +│ Cancel rewind and stay here │ │ │ │ │ │ (Use Enter to select a message, Esc to close, Right/Left to expand/collapse) │ @@ -233,13 +269,16 @@ exports[`RewindViewer > updates selection and expansion on navigation > initial- │ │ │ > Rewind │ │ │ +│ Line A │ +│ Line B... │ +│ No files have been changed │ +│ │ │ ● Line 1 │ │ Line 2... │ │ No files have been changed │ │ │ -│ Line A │ -│ Line B... │ -│ No files have been changed │ +│ Stay at current position │ +│ Cancel rewind and stay here │ │ │ │ │ │ (Use Enter to select a message, Esc to close, Right/Left to expand/collapse) │ diff --git a/packages/cli/src/ui/contexts/UIActionsContext.tsx b/packages/cli/src/ui/contexts/UIActionsContext.tsx index 2933d1d63ca..1ba8c7dfe36 100644 --- a/packages/cli/src/ui/contexts/UIActionsContext.tsx +++ b/packages/cli/src/ui/contexts/UIActionsContext.tsx @@ -40,7 +40,6 @@ export interface UIActions { handleFolderTrustSelect: (choice: FolderTrustChoice) => void; setConstrainHeight: (value: boolean) => void; onEscapePromptChange: (show: boolean) => void; - onClearTextToastChange: (show: boolean) => void; refreshStatic: () => void; handleFinalSubmit: (value: string) => void; handleClearScreen: () => void; diff --git a/packages/cli/src/ui/contexts/UIStateContext.tsx b/packages/cli/src/ui/contexts/UIStateContext.tsx index b0ee22a8f2c..feb3157e1ad 100644 --- a/packages/cli/src/ui/contexts/UIStateContext.tsx +++ b/packages/cli/src/ui/contexts/UIStateContext.tsx @@ -100,7 +100,6 @@ export interface UIState { ctrlCPressedOnce: boolean; ctrlDPressedOnce: boolean; showEscapePrompt: boolean; - showClearTextToast: boolean; elapsedTime: number; currentLoadingPhrase: string; historyRemountKey: number; diff --git a/packages/cli/src/ui/hooks/atCommandProcessor.test.ts b/packages/cli/src/ui/hooks/atCommandProcessor.test.ts index 5e86c9b27a1..f519bf13bcf 100644 --- a/packages/cli/src/ui/hooks/atCommandProcessor.test.ts +++ b/packages/cli/src/ui/hooks/atCommandProcessor.test.ts @@ -12,6 +12,8 @@ import { FileDiscoveryService, GlobTool, ReadManyFilesTool, + REFERENCE_CONTENT_START, + REFERENCE_CONTENT_END, StandardFileSystemService, ToolRegistry, COMMON_IGNORE_PATTERNS, @@ -172,10 +174,10 @@ describe('handleAtCommand', () => { expect(result).toEqual({ processedQuery: [ { text: `@${relativePath}` }, - { text: '\n--- Content from referenced files ---' }, + { text: `\n${REFERENCE_CONTENT_START}` }, { text: `\nContent from @${relativePath}:\n` }, { text: fileContent }, - { text: '\n--- End of content ---' }, + { text: `\n${REFERENCE_CONTENT_END}` }, ], }); expect(mockAddItem).toHaveBeenCalledWith( @@ -211,10 +213,10 @@ describe('handleAtCommand', () => { expect(result).toEqual({ processedQuery: [ { text: `@${resolvedGlob}` }, - { text: '\n--- Content from referenced files ---' }, + { text: `\n${REFERENCE_CONTENT_START}` }, { text: `\nContent from @${relativeFilePath}:\n` }, { text: fileContent }, - { text: '\n--- End of content ---' }, + { text: `\n${REFERENCE_CONTENT_END}` }, ], }); expect(mockOnDebugMessage).toHaveBeenCalledWith( @@ -245,10 +247,10 @@ describe('handleAtCommand', () => { expect(result).toEqual({ processedQuery: [ { text: `${textBefore}@${relativePath}${textAfter}` }, - { text: '\n--- Content from referenced files ---' }, + { text: `\n${REFERENCE_CONTENT_START}` }, { text: `\nContent from @${relativePath}:\n` }, { text: fileContent }, - { text: '\n--- End of content ---' }, + { text: `\n${REFERENCE_CONTENT_END}` }, ], }); }); @@ -274,10 +276,10 @@ describe('handleAtCommand', () => { expect(result).toEqual({ processedQuery: [ { text: `@${getRelativePath(filePath)}` }, - { text: '\n--- Content from referenced files ---' }, + { text: `\n${REFERENCE_CONTENT_START}` }, { text: `\nContent from @${getRelativePath(filePath)}:\n` }, { text: fileContent }, - { text: '\n--- End of content ---' }, + { text: `\n${REFERENCE_CONTENT_END}` }, ], }); expect(mockAddItem).toHaveBeenCalledWith( @@ -316,12 +318,12 @@ describe('handleAtCommand', () => { { text: `@${getRelativePath(file1Path)} @${getRelativePath(file2Path)}`, }, - { text: '\n--- Content from referenced files ---' }, + { text: `\n${REFERENCE_CONTENT_START}` }, { text: `\nContent from @${getRelativePath(file1Path)}:\n` }, { text: content1 }, { text: `\nContent from @${getRelativePath(file2Path)}:\n` }, { text: content2 }, - { text: '\n--- End of content ---' }, + { text: `\n${REFERENCE_CONTENT_END}` }, ], }); }); @@ -356,12 +358,12 @@ describe('handleAtCommand', () => { { text: `${text1}@${getRelativePath(file1Path)}${text2}@${getRelativePath(file2Path)}${text3}`, }, - { text: '\n--- Content from referenced files ---' }, + { text: `\n${REFERENCE_CONTENT_START}` }, { text: `\nContent from @${getRelativePath(file1Path)}:\n` }, { text: content1 }, { text: `\nContent from @${getRelativePath(file2Path)}:\n` }, { text: content2 }, - { text: '\n--- End of content ---' }, + { text: `\n${REFERENCE_CONTENT_END}` }, ], }); }); @@ -394,12 +396,12 @@ describe('handleAtCommand', () => { { text: `Look at @${getRelativePath(file1Path)} then @${invalidFile} and also just @ symbol, then @${getRelativePath(file2Path)}`, }, - { text: '\n--- Content from referenced files ---' }, + { text: `\n${REFERENCE_CONTENT_START}` }, { text: `\nContent from @${getRelativePath(file2Path)}:\n` }, { text: content2 }, { text: `\nContent from @${getRelativePath(file1Path)}:\n` }, { text: content1 }, - { text: '\n--- End of content ---' }, + { text: `\n${REFERENCE_CONTENT_END}` }, ], }); expect(mockOnDebugMessage).toHaveBeenCalledWith( @@ -493,10 +495,10 @@ describe('handleAtCommand', () => { expect(result).toEqual({ processedQuery: [ { text: `@${getRelativePath(validFile)}` }, - { text: '\n--- Content from referenced files ---' }, + { text: `\n${REFERENCE_CONTENT_START}` }, { text: `\nContent from @${getRelativePath(validFile)}:\n` }, { text: 'console.log("Hello world");' }, - { text: '\n--- End of content ---' }, + { text: `\n${REFERENCE_CONTENT_END}` }, ], }); }); @@ -525,10 +527,10 @@ describe('handleAtCommand', () => { expect(result).toEqual({ processedQuery: [ { text: `@${getRelativePath(validFile)} @${gitIgnoredFile}` }, - { text: '\n--- Content from referenced files ---' }, + { text: `\n${REFERENCE_CONTENT_START}` }, { text: `\nContent from @${getRelativePath(validFile)}:\n` }, { text: '# Project README' }, - { text: '\n--- End of content ---' }, + { text: `\n${REFERENCE_CONTENT_END}` }, ], }); expect(mockOnDebugMessage).toHaveBeenCalledWith( @@ -649,10 +651,10 @@ describe('handleAtCommand', () => { expect(result).toEqual({ processedQuery: [ { text: `@${getRelativePath(validFile)}` }, - { text: '\n--- Content from referenced files ---' }, + { text: `\n${REFERENCE_CONTENT_START}` }, { text: `\nContent from @${getRelativePath(validFile)}:\n` }, { text: 'console.log("Hello world");' }, - { text: '\n--- End of content ---' }, + { text: `\n${REFERENCE_CONTENT_END}` }, ], }); }); @@ -684,10 +686,10 @@ describe('handleAtCommand', () => { expect(result).toEqual({ processedQuery: [ { text: `@${getRelativePath(validFile)} @${geminiIgnoredFile}` }, - { text: '\n--- Content from referenced files ---' }, + { text: `\n${REFERENCE_CONTENT_START}` }, { text: `\nContent from @${getRelativePath(validFile)}:\n` }, { text: '// Main application entry' }, - { text: '\n--- End of content ---' }, + { text: `\n${REFERENCE_CONTENT_END}` }, ], }); expect(mockOnDebugMessage).toHaveBeenCalledWith( @@ -811,10 +813,10 @@ describe('handleAtCommand', () => { expect(result).toEqual({ processedQuery: [ { text: query }, - { text: '\n--- Content from referenced files ---' }, + { text: `\n${REFERENCE_CONTENT_START}` }, { text: `\nContent from @${getRelativePath(filePath)}:\n` }, { text: fileContent }, - { text: '\n--- End of content ---' }, + { text: `\n${REFERENCE_CONTENT_END}` }, ], }); }, @@ -847,12 +849,12 @@ describe('handleAtCommand', () => { { text: `Compare @${getRelativePath(file1Path)}, @${getRelativePath(file2Path)}; what's different?`, }, - { text: '\n--- Content from referenced files ---' }, + { text: `\n${REFERENCE_CONTENT_START}` }, { text: `\nContent from @${getRelativePath(file1Path)}:\n` }, { text: content1 }, { text: `\nContent from @${getRelativePath(file2Path)}:\n` }, { text: content2 }, - { text: '\n--- End of content ---' }, + { text: `\n${REFERENCE_CONTENT_END}` }, ], }); }); @@ -878,10 +880,10 @@ describe('handleAtCommand', () => { expect(result).toEqual({ processedQuery: [ { text: `Check @${getRelativePath(filePath)}, it has spaces.` }, - { text: '\n--- Content from referenced files ---' }, + { text: `\n${REFERENCE_CONTENT_START}` }, { text: `\nContent from @${getRelativePath(filePath)}:\n` }, { text: fileContent }, - { text: '\n--- End of content ---' }, + { text: `\n${REFERENCE_CONTENT_END}` }, ], }); }); @@ -908,10 +910,10 @@ describe('handleAtCommand', () => { { text: `Analyze @${getRelativePath(filePath)} for type definitions.`, }, - { text: '\n--- Content from referenced files ---' }, + { text: `\n${REFERENCE_CONTENT_START}` }, { text: `\nContent from @${getRelativePath(filePath)}:\n` }, { text: fileContent }, - { text: '\n--- End of content ---' }, + { text: `\n${REFERENCE_CONTENT_END}` }, ], }); }); @@ -938,10 +940,10 @@ describe('handleAtCommand', () => { { text: `Check @${getRelativePath(filePath)}. This file contains settings.`, }, - { text: '\n--- Content from referenced files ---' }, + { text: `\n${REFERENCE_CONTENT_START}` }, { text: `\nContent from @${getRelativePath(filePath)}:\n` }, { text: fileContent }, - { text: '\n--- End of content ---' }, + { text: `\n${REFERENCE_CONTENT_END}` }, ], }); }); @@ -968,10 +970,10 @@ describe('handleAtCommand', () => { { text: `Review @${getRelativePath(filePath)}, then check dependencies.`, }, - { text: '\n--- Content from referenced files ---' }, + { text: `\n${REFERENCE_CONTENT_START}` }, { text: `\nContent from @${getRelativePath(filePath)}:\n` }, { text: fileContent }, - { text: '\n--- End of content ---' }, + { text: `\n${REFERENCE_CONTENT_END}` }, ], }); }); @@ -998,10 +1000,10 @@ describe('handleAtCommand', () => { { text: `Check @${getRelativePath(filePath)} contains version information.`, }, - { text: '\n--- Content from referenced files ---' }, + { text: `\n${REFERENCE_CONTENT_START}` }, { text: `\nContent from @${getRelativePath(filePath)}:\n` }, { text: fileContent }, - { text: '\n--- End of content ---' }, + { text: `\n${REFERENCE_CONTENT_END}` }, ], }); }); @@ -1026,10 +1028,10 @@ describe('handleAtCommand', () => { expect(result).toEqual({ processedQuery: [ { text: `Show me @${getRelativePath(filePath)}.` }, - { text: '\n--- Content from referenced files ---' }, + { text: `\n${REFERENCE_CONTENT_START}` }, { text: `\nContent from @${getRelativePath(filePath)}:\n` }, { text: fileContent }, - { text: '\n--- End of content ---' }, + { text: `\n${REFERENCE_CONTENT_END}` }, ], }); }); @@ -1054,10 +1056,10 @@ describe('handleAtCommand', () => { expect(result).toEqual({ processedQuery: [ { text: `Check @${getRelativePath(filePath)} for content.` }, - { text: '\n--- Content from referenced files ---' }, + { text: `\n${REFERENCE_CONTENT_START}` }, { text: `\nContent from @${getRelativePath(filePath)}:\n` }, { text: fileContent }, - { text: '\n--- End of content ---' }, + { text: `\n${REFERENCE_CONTENT_END}` }, ], }); }); @@ -1082,10 +1084,10 @@ describe('handleAtCommand', () => { expect(result).toEqual({ processedQuery: [ { text: `Check @${getRelativePath(filePath)} please.` }, - { text: '\n--- Content from referenced files ---' }, + { text: `\n${REFERENCE_CONTENT_START}` }, { text: `\nContent from @${getRelativePath(filePath)}:\n` }, { text: fileContent }, - { text: '\n--- End of content ---' }, + { text: `\n${REFERENCE_CONTENT_END}` }, ], }); }); @@ -1113,10 +1115,10 @@ describe('handleAtCommand', () => { expect(result).toEqual({ processedQuery: [ { text: `Check @${relativePath} please.` }, - { text: '\n--- Content from referenced files ---' }, + { text: `\n${REFERENCE_CONTENT_START}` }, { text: `\nContent from @${relativePath}:\n` }, { text: fileContent }, - { text: '\n--- End of content ---' }, + { text: `\n${REFERENCE_CONTENT_END}` }, ], }); @@ -1152,7 +1154,7 @@ describe('handleAtCommand', () => { expect.arrayContaining([ { text: `Check @${path.join(subDirPath, '**')} please.` }, expect.objectContaining({ - text: '\n--- Content from referenced files ---', + text: `\n${REFERENCE_CONTENT_START}`, }), ]), ); diff --git a/packages/cli/src/ui/hooks/atCommandProcessor.ts b/packages/cli/src/ui/hooks/atCommandProcessor.ts index cd4fae3a4bb..708c9509072 100644 --- a/packages/cli/src/ui/hooks/atCommandProcessor.ts +++ b/packages/cli/src/ui/hooks/atCommandProcessor.ts @@ -18,14 +18,16 @@ 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--- Content from referenced files ---'; -const REF_CONTENT_FOOTER = '\n--- End of content ---'; +const REF_CONTENT_HEADER = `\n${REFERENCE_CONTENT_START}`; +const REF_CONTENT_FOOTER = `\n${REFERENCE_CONTENT_END}`; interface HandleAtCommandParams { query: string; diff --git a/packages/cli/src/ui/hooks/slashCommandProcessor.test.tsx b/packages/cli/src/ui/hooks/slashCommandProcessor.test.tsx index bff29573ab9..717da578053 100644 --- a/packages/cli/src/ui/hooks/slashCommandProcessor.test.tsx +++ b/packages/cli/src/ui/hooks/slashCommandProcessor.test.tsx @@ -196,7 +196,6 @@ describe('useSlashCommandProcessor', () => { dispatchExtensionStateUpdate: vi.fn(), addConfirmUpdateExtensionRequest: vi.fn(), setText: vi.fn(), - clearTextToast: 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 91cc53e07b0..c2bb7ebceed 100644 --- a/packages/cli/src/ui/hooks/slashCommandProcessor.ts +++ b/packages/cli/src/ui/hooks/slashCommandProcessor.ts @@ -77,7 +77,6 @@ interface SlashCommandProcessorActions { dispatchExtensionStateUpdate: (action: ExtensionUpdateAction) => void; addConfirmUpdateExtensionRequest: (request: ConfirmationRequest) => void; setText: (text: string) => void; - clearTextToast: () => void; } /** @@ -230,7 +229,6 @@ export const useSlashCommandProcessor = ( addConfirmUpdateExtensionRequest: actions.addConfirmUpdateExtensionRequest, removeComponent: () => setCustomDialog(null), - clearTextToast: actions.clearTextToast, }, session: { stats: session.stats, diff --git a/packages/cli/src/ui/noninteractive/nonInteractiveUi.ts b/packages/cli/src/ui/noninteractive/nonInteractiveUi.ts index 9eea5b8c999..542ed16bdb1 100644 --- a/packages/cli/src/ui/noninteractive/nonInteractiveUi.ts +++ b/packages/cli/src/ui/noninteractive/nonInteractiveUi.ts @@ -28,6 +28,5 @@ export function createNonInteractiveUI(): CommandContext['ui'] { dispatchExtensionStateUpdate: (_action: ExtensionUpdateAction) => {}, addConfirmUpdateExtensionRequest: (_request) => {}, removeComponent: () => {}, - clearTextToast: () => {}, }; } diff --git a/packages/cli/src/ui/utils/formatters.test.ts b/packages/cli/src/ui/utils/formatters.test.ts index 48c0a2c6059..12c9afcad7d 100644 --- a/packages/cli/src/ui/utils/formatters.test.ts +++ b/packages/cli/src/ui/utils/formatters.test.ts @@ -4,13 +4,17 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { describe, it, expect } from 'vitest'; import { formatDuration, formatMemoryUsage, formatTimeAgo, stripReferenceContent, } from './formatters.js'; +import { + REFERENCE_CONTENT_START, + REFERENCE_CONTENT_END, +} from '@google/gemini-cli-core'; describe('formatters', () => { describe('formatMemoryUsage', () => { @@ -129,37 +133,32 @@ describe('formatters', () => { }); it('should strip content between markers', () => { - const text = - 'Prompt @file.txt\n--- Content from referenced files ---\nFile content here\n--- End of content ---'; + const text = `Prompt @file.txt\n${REFERENCE_CONTENT_START}\nFile content here\n${REFERENCE_CONTENT_END}`; expect(stripReferenceContent(text)).toBe('Prompt @file.txt'); }); it('should strip content and keep text after the markers', () => { - const text = - 'Before\n--- Content from referenced files ---\nMiddle\n--- End of content ---\nAfter'; + const text = `Before\n${REFERENCE_CONTENT_START}\nMiddle\n${REFERENCE_CONTENT_END}\nAfter`; expect(stripReferenceContent(text)).toBe('Before\nAfter'); }); it('should handle missing end marker gracefully', () => { - const text = 'Before\n--- Content from referenced files ---\nMiddle'; + const text = `Before\n${REFERENCE_CONTENT_START}\nMiddle`; expect(stripReferenceContent(text)).toBe(text); }); it('should handle end marker before start marker gracefully', () => { - const text = - '--- End of content ---\n--- Content from referenced files ---'; + const text = `${REFERENCE_CONTENT_END}\n${REFERENCE_CONTENT_START}`; expect(stripReferenceContent(text)).toBe(text); }); it('should strip even if markers are on the same line (though unlikely)', () => { - const text = - 'A--- Content from referenced files ---B--- End of content ---C'; + const text = `A${REFERENCE_CONTENT_START}B${REFERENCE_CONTENT_END}C`; expect(stripReferenceContent(text)).toBe('AC'); }); it('should strip multiple blocks correctly and preserve text in between', () => { - const text = - 'Start\n--- Content from referenced files ---\nBlock1\n--- End of content ---\nMiddle\n--- Content from referenced files ---\nBlock2\n--- End of content ---\nEnd'; + const text = `Start\n${REFERENCE_CONTENT_START}\nBlock1\n${REFERENCE_CONTENT_END}\nMiddle\n${REFERENCE_CONTENT_START}\nBlock2\n${REFERENCE_CONTENT_END}\nEnd`; expect(stripReferenceContent(text)).toBe('Start\nMiddle\nEnd'); }); }); diff --git a/packages/cli/src/ui/utils/formatters.ts b/packages/cli/src/ui/utils/formatters.ts index 6552f6c4f7c..419ad8d0e40 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 3cf00302cf1..93a97571a4f 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 775223f4e50..fdd54c51503 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.test.ts b/packages/core/src/tools/read-many-files.test.ts index e092b6d6a5b..42e1083ebde 100644 --- a/packages/core/src/tools/read-many-files.test.ts +++ b/packages/core/src/tools/read-many-files.test.ts @@ -8,6 +8,7 @@ import { vi, describe, it, expect, beforeEach, afterEach } from 'vitest'; import type { Mock } from 'vitest'; import { mockControl } from '../__mocks__/fs/promises.js'; import { ReadManyFilesTool } from './read-many-files.js'; +import { REFERENCE_CONTENT_END } from '../utils/constants.js'; import { FileDiscoveryService } from '../services/fileDiscoveryService.js'; import path from 'node:path'; import fs from 'node:fs'; // Actual fs for setup @@ -234,7 +235,7 @@ describe('ReadManyFilesTool', () => { const expectedPath = path.join(tempRootDir, 'file1.txt'); expect(result.llmContent).toEqual([ `--- ${expectedPath} ---\n\nContent of file1\n\n`, - `\n--- End of content ---`, + `\n${REFERENCE_CONTENT_END}`, ]); expect(result.returnDisplay).toContain( 'Successfully read and concatenated content from **1 file(s)**', @@ -301,7 +302,7 @@ describe('ReadManyFilesTool', () => { const expectedPath = path.join(tempRootDir, 'src/main.ts'); expect(content).toEqual([ `--- ${expectedPath} ---\n\nMain content\n\n`, - `\n--- End of content ---`, + `\n${REFERENCE_CONTENT_END}`, ]); expect( content.find((c) => c.includes('src/main.test.ts')), @@ -333,7 +334,7 @@ describe('ReadManyFilesTool', () => { const expectedPath = path.join(tempRootDir, 'src/app.js'); expect(content).toEqual([ `--- ${expectedPath} ---\n\napp code\n\n`, - `\n--- End of content ---`, + `\n${REFERENCE_CONTENT_END}`, ]); expect( content.find((c) => c.includes('node_modules/some-lib/index.js')), @@ -387,7 +388,7 @@ describe('ReadManyFilesTool', () => { mimeType: 'image/png', }, }, - '\n--- End of content ---', + `\n${REFERENCE_CONTENT_END}`, ]); expect(result.returnDisplay).toContain( 'Successfully read and concatenated content from **1 file(s)**', @@ -411,7 +412,7 @@ describe('ReadManyFilesTool', () => { mimeType: 'image/png', }, }, - '\n--- End of content ---', + `\n${REFERENCE_CONTENT_END}`, ]); }); @@ -448,7 +449,7 @@ describe('ReadManyFilesTool', () => { mimeType: 'application/pdf', }, }, - '\n--- End of content ---', + `\n${REFERENCE_CONTENT_END}`, ]); }); @@ -464,7 +465,7 @@ describe('ReadManyFilesTool', () => { mimeType: 'application/pdf', }, }, - '\n--- End of content ---', + `\n${REFERENCE_CONTENT_END}`, ]); }); @@ -581,7 +582,7 @@ describe('ReadManyFilesTool', () => { Content of receive-detail `, - `\n--- End of content ---`, + `\n${REFERENCE_CONTENT_END}`, ]); expect(result.returnDisplay).toContain( 'Successfully read and concatenated content from **1 file(s)**', @@ -600,7 +601,7 @@ Content of receive-detail Content of file[1] `, - `\n--- End of content ---`, + `\n${REFERENCE_CONTENT_END}`, ]); expect(result.returnDisplay).toContain( 'Successfully read and concatenated content from **1 file(s)**', diff --git a/packages/core/src/tools/read-many-files.ts b/packages/core/src/tools/read-many-files.ts index c1d8c18cd7f..26ddf673a82 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 00000000000..e11cbb67c1f --- /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 ---'; From cd34f27d894675f422bd4b7936809aaf5321fd99 Mon Sep 17 00:00:00 2001 From: "A.K.M. Adib" Date: Wed, 21 Jan 2026 14:59:28 -0500 Subject: [PATCH 39/42] address feedback from running /review-frontend myself --- docs/cli/keyboard-shortcuts.md | 1 + packages/cli/src/config/keyBindings.ts | 4 ++++ packages/cli/src/ui/components/InputPrompt.test.tsx | 3 ++- scripts/generate-keybindings-doc.ts | 1 + 4 files changed, 8 insertions(+), 1 deletion(-) diff --git a/docs/cli/keyboard-shortcuts.md b/docs/cli/keyboard-shortcuts.md index 265fce8319e..5056f6e9f50 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 455c9dba391..b230dc7f004 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', @@ -183,6 +184,7 @@ export const defaultKeyBindings: KeyBindingConfig = { [Command.HISTORY_UP]: [{ key: 'p', ctrl: true, shift: false }], [Command.HISTORY_DOWN]: [{ key: 'n', ctrl: true, shift: false }], [Command.REVERSE_SEARCH]: [{ key: 'r', ctrl: true }], + [Command.REWIND]: [{ key: 'double escape' }], // Note: original logic ONLY checked ctrl=false, ignored meta/shift/paste [Command.SUBMIT_REVERSE_SEARCH]: [{ key: 'return', ctrl: false }], [Command.ACCEPT_SUGGESTION_REVERSE_SEARCH]: [{ key: 'tab' }], @@ -313,6 +315,7 @@ export const commandCategories: readonly CommandCategory[] = [ Command.REVERSE_SEARCH, Command.SUBMIT_REVERSE_SEARCH, Command.ACCEPT_SUGGESTION_REVERSE_SEARCH, + Command.REWIND, ], }, { @@ -409,6 +412,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/ui/components/InputPrompt.test.tsx b/packages/cli/src/ui/components/InputPrompt.test.tsx index 7fa6476a49d..3fc2ecf5fbd 100644 --- a/packages/cli/src/ui/components/InputPrompt.test.tsx +++ b/packages/cli/src/ui/components/InputPrompt.test.tsx @@ -1910,8 +1910,9 @@ describe('InputPrompt', () => { await act(async () => { stdin.write('\x1B\x1B'); - vi.advanceTimersByTime(100); + }); + await waitFor(() => { expect(props.onSubmit).toHaveBeenCalledWith('/rewind'); }); unmount(); diff --git a/scripts/generate-keybindings-doc.ts b/scripts/generate-keybindings-doc.ts index 4c2e6c4618a..e7944785151 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', From 8d5801af8cc35c81cc36c028667a5510c5879673 Mon Sep 17 00:00:00 2001 From: "A.K.M. Adib" Date: Wed, 21 Jan 2026 15:16:52 -0500 Subject: [PATCH 40/42] fix failing tests --- packages/cli/src/ui/components/InputPrompt.test.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/cli/src/ui/components/InputPrompt.test.tsx b/packages/cli/src/ui/components/InputPrompt.test.tsx index 3fc2ecf5fbd..77d983b8173 100644 --- a/packages/cli/src/ui/components/InputPrompt.test.tsx +++ b/packages/cli/src/ui/components/InputPrompt.test.tsx @@ -1910,6 +1910,7 @@ describe('InputPrompt', () => { await act(async () => { stdin.write('\x1B\x1B'); + vi.advanceTimersByTime(100); }); await waitFor(() => { From 03abc9b981c5b761ccc9ff5a39c01351565f32f4 Mon Sep 17 00:00:00 2001 From: "A.K.M. Adib" Date: Thu, 22 Jan 2026 09:23:41 -0500 Subject: [PATCH 41/42] address nits and stick to first message in the history --- packages/cli/src/config/keyBindings.ts | 1 - packages/cli/src/nonInteractiveCli.test.ts | 6 +- .../cli/src/ui/commands/rewindCommand.tsx | 181 +++++++++++------- .../src/ui/components/RewindViewer.test.tsx | 11 +- .../cli/src/ui/components/RewindViewer.tsx | 3 - .../src/ui/hooks/atCommandProcessor.test.ts | 92 +++++---- packages/cli/src/ui/utils/formatters.test.ts | 18 +- .../core/src/tools/read-many-files.test.ts | 19 +- 8 files changed, 175 insertions(+), 156 deletions(-) diff --git a/packages/cli/src/config/keyBindings.ts b/packages/cli/src/config/keyBindings.ts index 3e50f5079bd..2165e622ddc 100644 --- a/packages/cli/src/config/keyBindings.ts +++ b/packages/cli/src/config/keyBindings.ts @@ -190,7 +190,6 @@ export const defaultKeyBindings: KeyBindingConfig = { [Command.HISTORY_DOWN]: [{ key: 'n', shift: false, ctrl: true }], [Command.REVERSE_SEARCH]: [{ key: 'r', ctrl: true }], [Command.REWIND]: [{ key: 'double escape' }], - // Note: original logic ONLY checked ctrl=false, ignored meta/shift/paste [Command.SUBMIT_REVERSE_SEARCH]: [{ key: 'return', ctrl: false }], [Command.ACCEPT_SUGGESTION_REVERSE_SEARCH]: [{ key: 'tab' }], diff --git a/packages/cli/src/nonInteractiveCli.test.ts b/packages/cli/src/nonInteractiveCli.test.ts index 1cc24d3429a..7b12f864b34 100644 --- a/packages/cli/src/nonInteractiveCli.test.ts +++ b/packages/cli/src/nonInteractiveCli.test.ts @@ -18,8 +18,6 @@ import { ToolErrorType, GeminiEventType, OutputFormat, - REFERENCE_CONTENT_START, - REFERENCE_CONTENT_END, uiTelemetryService, FatalInputError, CoreEvent, @@ -570,9 +568,9 @@ describe('runNonInteractive', () => { const rawInput = 'Summarize @file.txt'; const processedParts: Part[] = [ { text: 'Summarize @file.txt' }, - { text: `\n${REFERENCE_CONTENT_START}\n` }, + { text: '\n--- Content from referenced files ---\n' }, { text: 'This is the content of the file.' }, - { text: `\n${REFERENCE_CONTENT_END}` }, + { text: '\n--- End of content ---' }, ]; // 3. Setup the mock to return the processed parts diff --git a/packages/cli/src/ui/commands/rewindCommand.tsx b/packages/cli/src/ui/commands/rewindCommand.tsx index 882644b5d2e..c9b0424842e 100644 --- a/packages/cli/src/ui/commands/rewindCommand.tsx +++ b/packages/cli/src/ui/commands/rewindCommand.tsx @@ -4,7 +4,11 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { CommandKind, type SlashCommand } from './types.js'; +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'; @@ -13,8 +17,76 @@ 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', @@ -72,79 +144,44 @@ export const rewindCommand: SlashCommand = { context.ui.removeComponent(); }} onRewind={async (messageId, newText, outcome) => { - try { - 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); - } - // Proceed to rewind logic - break; - - case RewindOutcome.RewindOnly: - // Proceed to rewind logic - break; - - default: - checkExhaustive(outcome); - } - - const rewindedConversation = recordingService.rewindTo(messageId); - if (!rewindedConversation) { - const errorMsg = 'Could not fetch conversation file'; - debugLogger.error(errorMsg); + switch (outcome) { + case RewindOutcome.Cancel: + context.ui.removeComponent(); + return; + + case RewindOutcome.RevertOnly: + if (conversation) { + await revertFileChanges(conversation, messageId); + } context.ui.removeComponent(); - coreEvents.emitFeedback('error', errorMsg); + coreEvents.emitFeedback('info', 'File changes reverted.'); return; - } - - // Convert to UI and Client formats - const { uiHistory, clientHistory } = - convertSessionToHistoryFormats(rewindedConversation.messages); - - // Reset the client's internal history to match the file - client.setHistory(clientHistory as Content[]); - - // Reset context manager as we are rewinding history - await 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', - ); + + 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/components/RewindViewer.test.tsx b/packages/cli/src/ui/components/RewindViewer.test.tsx index db7d627e5c5..8272fc9c9fa 100644 --- a/packages/cli/src/ui/components/RewindViewer.test.tsx +++ b/packages/cli/src/ui/components/RewindViewer.test.tsx @@ -57,11 +57,6 @@ const createConversation = (messages: MessageRecord[]): ConversationRecord => ({ messages, }); -import { - REFERENCE_CONTENT_START, - REFERENCE_CONTENT_END, -} from '@google/gemini-cli-core'; - describe('RewindViewer', () => { afterEach(() => { vi.restoreAllMocks(); @@ -257,16 +252,16 @@ describe('RewindViewer', () => { it.each([ { description: 'removes reference markers', - prompt: `some command @file\n${REFERENCE_CONTENT_START}\nContent from file:\nblah blah\n${REFERENCE_CONTENT_END}`, + 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' + - `${REFERENCE_CONTENT_START}\n` + + `--- Content from referenced files ---\n` + '\nContent from @server3:mcp://demo-resource:\n' + 'This is the content of the demo resource.\n' + - `${REFERENCE_CONTENT_END}`, + `--- 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 7fd0bd16dcc..956d94ac915 100644 --- a/packages/cli/src/ui/components/RewindViewer.tsx +++ b/packages/cli/src/ui/components/RewindViewer.tsx @@ -85,8 +85,6 @@ export const RewindViewer: React.FC = ({ ]; }, [interactions]); - const initialIndex = useMemo(() => Math.max(0, items.length - 2), [items]); - useKeypress( (key) => { if (!selectedMessageId) { @@ -190,7 +188,6 @@ export const RewindViewer: React.FC = ({ { diff --git a/packages/cli/src/ui/hooks/atCommandProcessor.test.ts b/packages/cli/src/ui/hooks/atCommandProcessor.test.ts index f519bf13bcf..5e86c9b27a1 100644 --- a/packages/cli/src/ui/hooks/atCommandProcessor.test.ts +++ b/packages/cli/src/ui/hooks/atCommandProcessor.test.ts @@ -12,8 +12,6 @@ import { FileDiscoveryService, GlobTool, ReadManyFilesTool, - REFERENCE_CONTENT_START, - REFERENCE_CONTENT_END, StandardFileSystemService, ToolRegistry, COMMON_IGNORE_PATTERNS, @@ -174,10 +172,10 @@ describe('handleAtCommand', () => { expect(result).toEqual({ processedQuery: [ { text: `@${relativePath}` }, - { text: `\n${REFERENCE_CONTENT_START}` }, + { text: '\n--- Content from referenced files ---' }, { text: `\nContent from @${relativePath}:\n` }, { text: fileContent }, - { text: `\n${REFERENCE_CONTENT_END}` }, + { text: '\n--- End of content ---' }, ], }); expect(mockAddItem).toHaveBeenCalledWith( @@ -213,10 +211,10 @@ describe('handleAtCommand', () => { expect(result).toEqual({ processedQuery: [ { text: `@${resolvedGlob}` }, - { text: `\n${REFERENCE_CONTENT_START}` }, + { text: '\n--- Content from referenced files ---' }, { text: `\nContent from @${relativeFilePath}:\n` }, { text: fileContent }, - { text: `\n${REFERENCE_CONTENT_END}` }, + { text: '\n--- End of content ---' }, ], }); expect(mockOnDebugMessage).toHaveBeenCalledWith( @@ -247,10 +245,10 @@ describe('handleAtCommand', () => { expect(result).toEqual({ processedQuery: [ { text: `${textBefore}@${relativePath}${textAfter}` }, - { text: `\n${REFERENCE_CONTENT_START}` }, + { text: '\n--- Content from referenced files ---' }, { text: `\nContent from @${relativePath}:\n` }, { text: fileContent }, - { text: `\n${REFERENCE_CONTENT_END}` }, + { text: '\n--- End of content ---' }, ], }); }); @@ -276,10 +274,10 @@ describe('handleAtCommand', () => { expect(result).toEqual({ processedQuery: [ { text: `@${getRelativePath(filePath)}` }, - { text: `\n${REFERENCE_CONTENT_START}` }, + { text: '\n--- Content from referenced files ---' }, { text: `\nContent from @${getRelativePath(filePath)}:\n` }, { text: fileContent }, - { text: `\n${REFERENCE_CONTENT_END}` }, + { text: '\n--- End of content ---' }, ], }); expect(mockAddItem).toHaveBeenCalledWith( @@ -318,12 +316,12 @@ describe('handleAtCommand', () => { { text: `@${getRelativePath(file1Path)} @${getRelativePath(file2Path)}`, }, - { text: `\n${REFERENCE_CONTENT_START}` }, + { text: '\n--- Content from referenced files ---' }, { text: `\nContent from @${getRelativePath(file1Path)}:\n` }, { text: content1 }, { text: `\nContent from @${getRelativePath(file2Path)}:\n` }, { text: content2 }, - { text: `\n${REFERENCE_CONTENT_END}` }, + { text: '\n--- End of content ---' }, ], }); }); @@ -358,12 +356,12 @@ describe('handleAtCommand', () => { { text: `${text1}@${getRelativePath(file1Path)}${text2}@${getRelativePath(file2Path)}${text3}`, }, - { text: `\n${REFERENCE_CONTENT_START}` }, + { text: '\n--- Content from referenced files ---' }, { text: `\nContent from @${getRelativePath(file1Path)}:\n` }, { text: content1 }, { text: `\nContent from @${getRelativePath(file2Path)}:\n` }, { text: content2 }, - { text: `\n${REFERENCE_CONTENT_END}` }, + { text: '\n--- End of content ---' }, ], }); }); @@ -396,12 +394,12 @@ describe('handleAtCommand', () => { { text: `Look at @${getRelativePath(file1Path)} then @${invalidFile} and also just @ symbol, then @${getRelativePath(file2Path)}`, }, - { text: `\n${REFERENCE_CONTENT_START}` }, + { text: '\n--- Content from referenced files ---' }, { text: `\nContent from @${getRelativePath(file2Path)}:\n` }, { text: content2 }, { text: `\nContent from @${getRelativePath(file1Path)}:\n` }, { text: content1 }, - { text: `\n${REFERENCE_CONTENT_END}` }, + { text: '\n--- End of content ---' }, ], }); expect(mockOnDebugMessage).toHaveBeenCalledWith( @@ -495,10 +493,10 @@ describe('handleAtCommand', () => { expect(result).toEqual({ processedQuery: [ { text: `@${getRelativePath(validFile)}` }, - { text: `\n${REFERENCE_CONTENT_START}` }, + { text: '\n--- Content from referenced files ---' }, { text: `\nContent from @${getRelativePath(validFile)}:\n` }, { text: 'console.log("Hello world");' }, - { text: `\n${REFERENCE_CONTENT_END}` }, + { text: '\n--- End of content ---' }, ], }); }); @@ -527,10 +525,10 @@ describe('handleAtCommand', () => { expect(result).toEqual({ processedQuery: [ { text: `@${getRelativePath(validFile)} @${gitIgnoredFile}` }, - { text: `\n${REFERENCE_CONTENT_START}` }, + { text: '\n--- Content from referenced files ---' }, { text: `\nContent from @${getRelativePath(validFile)}:\n` }, { text: '# Project README' }, - { text: `\n${REFERENCE_CONTENT_END}` }, + { text: '\n--- End of content ---' }, ], }); expect(mockOnDebugMessage).toHaveBeenCalledWith( @@ -651,10 +649,10 @@ describe('handleAtCommand', () => { expect(result).toEqual({ processedQuery: [ { text: `@${getRelativePath(validFile)}` }, - { text: `\n${REFERENCE_CONTENT_START}` }, + { text: '\n--- Content from referenced files ---' }, { text: `\nContent from @${getRelativePath(validFile)}:\n` }, { text: 'console.log("Hello world");' }, - { text: `\n${REFERENCE_CONTENT_END}` }, + { text: '\n--- End of content ---' }, ], }); }); @@ -686,10 +684,10 @@ describe('handleAtCommand', () => { expect(result).toEqual({ processedQuery: [ { text: `@${getRelativePath(validFile)} @${geminiIgnoredFile}` }, - { text: `\n${REFERENCE_CONTENT_START}` }, + { text: '\n--- Content from referenced files ---' }, { text: `\nContent from @${getRelativePath(validFile)}:\n` }, { text: '// Main application entry' }, - { text: `\n${REFERENCE_CONTENT_END}` }, + { text: '\n--- End of content ---' }, ], }); expect(mockOnDebugMessage).toHaveBeenCalledWith( @@ -813,10 +811,10 @@ describe('handleAtCommand', () => { expect(result).toEqual({ processedQuery: [ { text: query }, - { text: `\n${REFERENCE_CONTENT_START}` }, + { text: '\n--- Content from referenced files ---' }, { text: `\nContent from @${getRelativePath(filePath)}:\n` }, { text: fileContent }, - { text: `\n${REFERENCE_CONTENT_END}` }, + { text: '\n--- End of content ---' }, ], }); }, @@ -849,12 +847,12 @@ describe('handleAtCommand', () => { { text: `Compare @${getRelativePath(file1Path)}, @${getRelativePath(file2Path)}; what's different?`, }, - { text: `\n${REFERENCE_CONTENT_START}` }, + { text: '\n--- Content from referenced files ---' }, { text: `\nContent from @${getRelativePath(file1Path)}:\n` }, { text: content1 }, { text: `\nContent from @${getRelativePath(file2Path)}:\n` }, { text: content2 }, - { text: `\n${REFERENCE_CONTENT_END}` }, + { text: '\n--- End of content ---' }, ], }); }); @@ -880,10 +878,10 @@ describe('handleAtCommand', () => { expect(result).toEqual({ processedQuery: [ { text: `Check @${getRelativePath(filePath)}, it has spaces.` }, - { text: `\n${REFERENCE_CONTENT_START}` }, + { text: '\n--- Content from referenced files ---' }, { text: `\nContent from @${getRelativePath(filePath)}:\n` }, { text: fileContent }, - { text: `\n${REFERENCE_CONTENT_END}` }, + { text: '\n--- End of content ---' }, ], }); }); @@ -910,10 +908,10 @@ describe('handleAtCommand', () => { { text: `Analyze @${getRelativePath(filePath)} for type definitions.`, }, - { text: `\n${REFERENCE_CONTENT_START}` }, + { text: '\n--- Content from referenced files ---' }, { text: `\nContent from @${getRelativePath(filePath)}:\n` }, { text: fileContent }, - { text: `\n${REFERENCE_CONTENT_END}` }, + { text: '\n--- End of content ---' }, ], }); }); @@ -940,10 +938,10 @@ describe('handleAtCommand', () => { { text: `Check @${getRelativePath(filePath)}. This file contains settings.`, }, - { text: `\n${REFERENCE_CONTENT_START}` }, + { text: '\n--- Content from referenced files ---' }, { text: `\nContent from @${getRelativePath(filePath)}:\n` }, { text: fileContent }, - { text: `\n${REFERENCE_CONTENT_END}` }, + { text: '\n--- End of content ---' }, ], }); }); @@ -970,10 +968,10 @@ describe('handleAtCommand', () => { { text: `Review @${getRelativePath(filePath)}, then check dependencies.`, }, - { text: `\n${REFERENCE_CONTENT_START}` }, + { text: '\n--- Content from referenced files ---' }, { text: `\nContent from @${getRelativePath(filePath)}:\n` }, { text: fileContent }, - { text: `\n${REFERENCE_CONTENT_END}` }, + { text: '\n--- End of content ---' }, ], }); }); @@ -1000,10 +998,10 @@ describe('handleAtCommand', () => { { text: `Check @${getRelativePath(filePath)} contains version information.`, }, - { text: `\n${REFERENCE_CONTENT_START}` }, + { text: '\n--- Content from referenced files ---' }, { text: `\nContent from @${getRelativePath(filePath)}:\n` }, { text: fileContent }, - { text: `\n${REFERENCE_CONTENT_END}` }, + { text: '\n--- End of content ---' }, ], }); }); @@ -1028,10 +1026,10 @@ describe('handleAtCommand', () => { expect(result).toEqual({ processedQuery: [ { text: `Show me @${getRelativePath(filePath)}.` }, - { text: `\n${REFERENCE_CONTENT_START}` }, + { text: '\n--- Content from referenced files ---' }, { text: `\nContent from @${getRelativePath(filePath)}:\n` }, { text: fileContent }, - { text: `\n${REFERENCE_CONTENT_END}` }, + { text: '\n--- End of content ---' }, ], }); }); @@ -1056,10 +1054,10 @@ describe('handleAtCommand', () => { expect(result).toEqual({ processedQuery: [ { text: `Check @${getRelativePath(filePath)} for content.` }, - { text: `\n${REFERENCE_CONTENT_START}` }, + { text: '\n--- Content from referenced files ---' }, { text: `\nContent from @${getRelativePath(filePath)}:\n` }, { text: fileContent }, - { text: `\n${REFERENCE_CONTENT_END}` }, + { text: '\n--- End of content ---' }, ], }); }); @@ -1084,10 +1082,10 @@ describe('handleAtCommand', () => { expect(result).toEqual({ processedQuery: [ { text: `Check @${getRelativePath(filePath)} please.` }, - { text: `\n${REFERENCE_CONTENT_START}` }, + { text: '\n--- Content from referenced files ---' }, { text: `\nContent from @${getRelativePath(filePath)}:\n` }, { text: fileContent }, - { text: `\n${REFERENCE_CONTENT_END}` }, + { text: '\n--- End of content ---' }, ], }); }); @@ -1115,10 +1113,10 @@ describe('handleAtCommand', () => { expect(result).toEqual({ processedQuery: [ { text: `Check @${relativePath} please.` }, - { text: `\n${REFERENCE_CONTENT_START}` }, + { text: '\n--- Content from referenced files ---' }, { text: `\nContent from @${relativePath}:\n` }, { text: fileContent }, - { text: `\n${REFERENCE_CONTENT_END}` }, + { text: '\n--- End of content ---' }, ], }); @@ -1154,7 +1152,7 @@ describe('handleAtCommand', () => { expect.arrayContaining([ { text: `Check @${path.join(subDirPath, '**')} please.` }, expect.objectContaining({ - text: `\n${REFERENCE_CONTENT_START}`, + text: '\n--- Content from referenced files ---', }), ]), ); diff --git a/packages/cli/src/ui/utils/formatters.test.ts b/packages/cli/src/ui/utils/formatters.test.ts index 12c9afcad7d..dff63e90245 100644 --- a/packages/cli/src/ui/utils/formatters.test.ts +++ b/packages/cli/src/ui/utils/formatters.test.ts @@ -4,17 +4,13 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { describe, it, expect } from 'vitest'; +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; import { formatDuration, formatMemoryUsage, formatTimeAgo, stripReferenceContent, } from './formatters.js'; -import { - REFERENCE_CONTENT_START, - REFERENCE_CONTENT_END, -} from '@google/gemini-cli-core'; describe('formatters', () => { describe('formatMemoryUsage', () => { @@ -133,32 +129,32 @@ describe('formatters', () => { }); it('should strip content between markers', () => { - const text = `Prompt @file.txt\n${REFERENCE_CONTENT_START}\nFile content here\n${REFERENCE_CONTENT_END}`; + const text = `Prompt @file.txt\n--- Content from referenced files ---\nFile content here\n--- End of content ---`; expect(stripReferenceContent(text)).toBe('Prompt @file.txt'); }); it('should strip content and keep text after the markers', () => { - const text = `Before\n${REFERENCE_CONTENT_START}\nMiddle\n${REFERENCE_CONTENT_END}\nAfter`; + const text = `Before\n--- Content from referenced files ---\nMiddle\n--- End of content ---\nAfter`; expect(stripReferenceContent(text)).toBe('Before\nAfter'); }); it('should handle missing end marker gracefully', () => { - const text = `Before\n${REFERENCE_CONTENT_START}\nMiddle`; + const text = `Before\n--- Content from referenced files ---\nMiddle`; expect(stripReferenceContent(text)).toBe(text); }); it('should handle end marker before start marker gracefully', () => { - const text = `${REFERENCE_CONTENT_END}\n${REFERENCE_CONTENT_START}`; + const text = `--- End of content ---\n--- Content from referenced files ---`; expect(stripReferenceContent(text)).toBe(text); }); it('should strip even if markers are on the same line (though unlikely)', () => { - const text = `A${REFERENCE_CONTENT_START}B${REFERENCE_CONTENT_END}C`; + const text = `A--- Content from referenced files ---B--- End of content ---C`; expect(stripReferenceContent(text)).toBe('AC'); }); it('should strip multiple blocks correctly and preserve text in between', () => { - const text = `Start\n${REFERENCE_CONTENT_START}\nBlock1\n${REFERENCE_CONTENT_END}\nMiddle\n${REFERENCE_CONTENT_START}\nBlock2\n${REFERENCE_CONTENT_END}\nEnd`; + const text = `Start\n--- Content from referenced files ---\nBlock1\n--- End of content ---\nMiddle\n--- Content from referenced files ---\nBlock2\n--- End of content ---\nEnd`; expect(stripReferenceContent(text)).toBe('Start\nMiddle\nEnd'); }); }); diff --git a/packages/core/src/tools/read-many-files.test.ts b/packages/core/src/tools/read-many-files.test.ts index 42e1083ebde..e092b6d6a5b 100644 --- a/packages/core/src/tools/read-many-files.test.ts +++ b/packages/core/src/tools/read-many-files.test.ts @@ -8,7 +8,6 @@ import { vi, describe, it, expect, beforeEach, afterEach } from 'vitest'; import type { Mock } from 'vitest'; import { mockControl } from '../__mocks__/fs/promises.js'; import { ReadManyFilesTool } from './read-many-files.js'; -import { REFERENCE_CONTENT_END } from '../utils/constants.js'; import { FileDiscoveryService } from '../services/fileDiscoveryService.js'; import path from 'node:path'; import fs from 'node:fs'; // Actual fs for setup @@ -235,7 +234,7 @@ describe('ReadManyFilesTool', () => { const expectedPath = path.join(tempRootDir, 'file1.txt'); expect(result.llmContent).toEqual([ `--- ${expectedPath} ---\n\nContent of file1\n\n`, - `\n${REFERENCE_CONTENT_END}`, + `\n--- End of content ---`, ]); expect(result.returnDisplay).toContain( 'Successfully read and concatenated content from **1 file(s)**', @@ -302,7 +301,7 @@ describe('ReadManyFilesTool', () => { const expectedPath = path.join(tempRootDir, 'src/main.ts'); expect(content).toEqual([ `--- ${expectedPath} ---\n\nMain content\n\n`, - `\n${REFERENCE_CONTENT_END}`, + `\n--- End of content ---`, ]); expect( content.find((c) => c.includes('src/main.test.ts')), @@ -334,7 +333,7 @@ describe('ReadManyFilesTool', () => { const expectedPath = path.join(tempRootDir, 'src/app.js'); expect(content).toEqual([ `--- ${expectedPath} ---\n\napp code\n\n`, - `\n${REFERENCE_CONTENT_END}`, + `\n--- End of content ---`, ]); expect( content.find((c) => c.includes('node_modules/some-lib/index.js')), @@ -388,7 +387,7 @@ describe('ReadManyFilesTool', () => { mimeType: 'image/png', }, }, - `\n${REFERENCE_CONTENT_END}`, + '\n--- End of content ---', ]); expect(result.returnDisplay).toContain( 'Successfully read and concatenated content from **1 file(s)**', @@ -412,7 +411,7 @@ describe('ReadManyFilesTool', () => { mimeType: 'image/png', }, }, - `\n${REFERENCE_CONTENT_END}`, + '\n--- End of content ---', ]); }); @@ -449,7 +448,7 @@ describe('ReadManyFilesTool', () => { mimeType: 'application/pdf', }, }, - `\n${REFERENCE_CONTENT_END}`, + '\n--- End of content ---', ]); }); @@ -465,7 +464,7 @@ describe('ReadManyFilesTool', () => { mimeType: 'application/pdf', }, }, - `\n${REFERENCE_CONTENT_END}`, + '\n--- End of content ---', ]); }); @@ -582,7 +581,7 @@ describe('ReadManyFilesTool', () => { Content of receive-detail `, - `\n${REFERENCE_CONTENT_END}`, + `\n--- End of content ---`, ]); expect(result.returnDisplay).toContain( 'Successfully read and concatenated content from **1 file(s)**', @@ -601,7 +600,7 @@ Content of receive-detail Content of file[1] `, - `\n${REFERENCE_CONTENT_END}`, + `\n--- End of content ---`, ]); expect(result.returnDisplay).toContain( 'Successfully read and concatenated content from **1 file(s)**', From 72afc71015cefe04ebcee21b71cab864b639aead Mon Sep 17 00:00:00 2001 From: "A.K.M. Adib" Date: Thu, 22 Jan 2026 09:45:36 -0500 Subject: [PATCH 42/42] restore formatter test and update RewindViewer snapshot --- .../__snapshots__/RewindViewer.test.tsx.snap | 28 +++++++++---------- packages/cli/src/ui/utils/formatters.test.ts | 19 ++++++++----- 2 files changed, 26 insertions(+), 21 deletions(-) diff --git a/packages/cli/src/ui/components/__snapshots__/RewindViewer.test.tsx.snap b/packages/cli/src/ui/components/__snapshots__/RewindViewer.test.tsx.snap index 0030a8adc87..64bb27dba35 100644 --- a/packages/cli/src/ui/components/__snapshots__/RewindViewer.test.tsx.snap +++ b/packages/cli/src/ui/components/__snapshots__/RewindViewer.test.tsx.snap @@ -72,13 +72,13 @@ exports[`RewindViewer > Navigation > handles 'down' navigation > after-down 1`] │ Q1 │ │ No files have been changed │ │ │ -│ Q2 │ +│ ● Q2 │ │ No files have been changed │ │ │ │ Q3 │ │ No files have been changed │ │ │ -│ ● Stay at current position │ +│ Stay at current position │ │ Cancel rewind and stay here │ │ │ │ │ @@ -95,13 +95,13 @@ exports[`RewindViewer > Navigation > handles 'up' navigation > after-up 1`] = ` │ Q1 │ │ No files have been changed │ │ │ -│ ● Q2 │ +│ Q2 │ │ No files have been changed │ │ │ │ Q3 │ │ No files have been changed │ │ │ -│ Stay at current position │ +│ ● Stay at current position │ │ Cancel rewind and stay here │ │ │ │ │ @@ -115,13 +115,13 @@ exports[`RewindViewer > Navigation > handles cyclic navigation > cyclic-down 1`] │ │ │ > Rewind │ │ │ -│ Q1 │ +│ ● Q1 │ │ No files have been changed │ │ │ │ Q2 │ │ No files have been changed │ │ │ -│ ● Q3 │ +│ Q3 │ │ No files have been changed │ │ │ │ Stay at current position │ @@ -141,13 +141,13 @@ exports[`RewindViewer > Navigation > handles cyclic navigation > cyclic-up 1`] = │ Q1 │ │ No files have been changed │ │ │ -│ ● Q2 │ +│ Q2 │ │ No files have been changed │ │ │ │ Q3 │ │ No files have been changed │ │ │ -│ Stay at current position │ +│ ● Stay at current position │ │ Cancel rewind and stay here │ │ │ │ │ @@ -210,10 +210,10 @@ exports[`RewindViewer > updates content when conversation changes (background up │ │ │ > Rewind │ │ │ -│ Message 1 │ +│ ● Message 1 │ │ No files have been changed │ │ │ -│ ● Message 2 │ +│ Message 2 │ │ No files have been changed │ │ │ │ Stay at current position │ @@ -251,11 +251,11 @@ exports[`RewindViewer > updates selection and expansion on navigation > after-do │ Line B... │ │ No files have been changed │ │ │ -│ Line 1 │ +│ ● Line 1 │ │ Line 2... │ │ No files have been changed │ │ │ -│ ● Stay at current position │ +│ Stay at current position │ │ Cancel rewind and stay here │ │ │ │ │ @@ -269,11 +269,11 @@ exports[`RewindViewer > updates selection and expansion on navigation > initial- │ │ │ > Rewind │ │ │ -│ Line A │ +│ ● Line A │ │ Line B... │ │ No files have been changed │ │ │ -│ ● Line 1 │ +│ Line 1 │ │ Line 2... │ │ No files have been changed │ │ │ diff --git a/packages/cli/src/ui/utils/formatters.test.ts b/packages/cli/src/ui/utils/formatters.test.ts index dff63e90245..48c0a2c6059 100644 --- a/packages/cli/src/ui/utils/formatters.test.ts +++ b/packages/cli/src/ui/utils/formatters.test.ts @@ -4,7 +4,7 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import { formatDuration, formatMemoryUsage, @@ -129,32 +129,37 @@ describe('formatters', () => { }); it('should strip content between markers', () => { - const text = `Prompt @file.txt\n--- Content from referenced files ---\nFile content here\n--- End of content ---`; + const text = + 'Prompt @file.txt\n--- Content from referenced files ---\nFile content here\n--- End of content ---'; expect(stripReferenceContent(text)).toBe('Prompt @file.txt'); }); it('should strip content and keep text after the markers', () => { - const text = `Before\n--- Content from referenced files ---\nMiddle\n--- End of content ---\nAfter`; + const text = + 'Before\n--- Content from referenced files ---\nMiddle\n--- End of content ---\nAfter'; expect(stripReferenceContent(text)).toBe('Before\nAfter'); }); it('should handle missing end marker gracefully', () => { - const text = `Before\n--- Content from referenced files ---\nMiddle`; + const text = 'Before\n--- Content from referenced files ---\nMiddle'; expect(stripReferenceContent(text)).toBe(text); }); it('should handle end marker before start marker gracefully', () => { - const text = `--- End of content ---\n--- Content from referenced files ---`; + const text = + '--- End of content ---\n--- Content from referenced files ---'; expect(stripReferenceContent(text)).toBe(text); }); it('should strip even if markers are on the same line (though unlikely)', () => { - const text = `A--- Content from referenced files ---B--- End of content ---C`; + const text = + 'A--- Content from referenced files ---B--- End of content ---C'; expect(stripReferenceContent(text)).toBe('AC'); }); it('should strip multiple blocks correctly and preserve text in between', () => { - const text = `Start\n--- Content from referenced files ---\nBlock1\n--- End of content ---\nMiddle\n--- Content from referenced files ---\nBlock2\n--- End of content ---\nEnd`; + const text = + 'Start\n--- Content from referenced files ---\nBlock1\n--- End of content ---\nMiddle\n--- Content from referenced files ---\nBlock2\n--- End of content ---\nEnd'; expect(stripReferenceContent(text)).toBe('Start\nMiddle\nEnd'); }); });