diff options
Diffstat (limited to 'packages/cli/src/ui/commands')
| -rw-r--r-- | packages/cli/src/ui/commands/compressCommand.test.ts | 129 | ||||
| -rw-r--r-- | packages/cli/src/ui/commands/compressCommand.ts | 77 | ||||
| -rw-r--r-- | packages/cli/src/ui/commands/types.ts | 14 |
3 files changed, 215 insertions, 5 deletions
diff --git a/packages/cli/src/ui/commands/compressCommand.test.ts b/packages/cli/src/ui/commands/compressCommand.test.ts new file mode 100644 index 00000000..95a8bda3 --- /dev/null +++ b/packages/cli/src/ui/commands/compressCommand.test.ts @@ -0,0 +1,129 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { GeminiClient } from '@google/gemini-cli-core'; +import { vi, describe, it, expect, beforeEach } from 'vitest'; +import { compressCommand } from './compressCommand.js'; +import { createMockCommandContext } from '../../test-utils/mockCommandContext.js'; +import { MessageType } from '../types.js'; + +describe('compressCommand', () => { + let context: ReturnType<typeof createMockCommandContext>; + let mockTryCompressChat: ReturnType<typeof vi.fn>; + + beforeEach(() => { + mockTryCompressChat = vi.fn(); + context = createMockCommandContext({ + services: { + config: { + getGeminiClient: () => + ({ + tryCompressChat: mockTryCompressChat, + }) as unknown as GeminiClient, + }, + }, + }); + }); + + it('should do nothing if a compression is already pending', async () => { + context.ui.pendingItem = { + type: MessageType.COMPRESSION, + compression: { + isPending: true, + originalTokenCount: null, + newTokenCount: null, + }, + }; + await compressCommand.action!(context, ''); + expect(context.ui.addItem).toHaveBeenCalledWith( + expect.objectContaining({ + type: MessageType.ERROR, + text: 'Already compressing, wait for previous request to complete', + }), + expect.any(Number), + ); + expect(context.ui.setPendingItem).not.toHaveBeenCalled(); + expect(mockTryCompressChat).not.toHaveBeenCalled(); + }); + + it('should set pending item, call tryCompressChat, and add result on success', async () => { + const compressedResult = { + originalTokenCount: 200, + newTokenCount: 100, + }; + mockTryCompressChat.mockResolvedValue(compressedResult); + + await compressCommand.action!(context, ''); + + expect(context.ui.setPendingItem).toHaveBeenNthCalledWith( + 1, + expect.objectContaining({ + type: MessageType.COMPRESSION, + compression: { + isPending: true, + originalTokenCount: null, + newTokenCount: null, + }, + }), + ); + + expect(mockTryCompressChat).toHaveBeenCalledWith( + expect.stringMatching(/^compress-\d+$/), + true, + ); + + expect(context.ui.addItem).toHaveBeenCalledWith( + expect.objectContaining({ + type: MessageType.COMPRESSION, + compression: { + isPending: false, + originalTokenCount: 200, + newTokenCount: 100, + }, + }), + expect.any(Number), + ); + + expect(context.ui.setPendingItem).toHaveBeenNthCalledWith(2, null); + }); + + it('should add an error message if tryCompressChat returns falsy', async () => { + mockTryCompressChat.mockResolvedValue(null); + + await compressCommand.action!(context, ''); + + expect(context.ui.addItem).toHaveBeenCalledWith( + expect.objectContaining({ + type: MessageType.ERROR, + text: 'Failed to compress chat history.', + }), + expect.any(Number), + ); + expect(context.ui.setPendingItem).toHaveBeenCalledWith(null); + }); + + it('should add an error message if tryCompressChat throws', async () => { + const error = new Error('Compression failed'); + mockTryCompressChat.mockRejectedValue(error); + + await compressCommand.action!(context, ''); + + expect(context.ui.addItem).toHaveBeenCalledWith( + expect.objectContaining({ + type: MessageType.ERROR, + text: `Failed to compress chat history: ${error.message}`, + }), + expect.any(Number), + ); + expect(context.ui.setPendingItem).toHaveBeenCalledWith(null); + }); + + it('should clear the pending item in a finally block', async () => { + mockTryCompressChat.mockRejectedValue(new Error('some error')); + await compressCommand.action!(context, ''); + expect(context.ui.setPendingItem).toHaveBeenCalledWith(null); + }); +}); diff --git a/packages/cli/src/ui/commands/compressCommand.ts b/packages/cli/src/ui/commands/compressCommand.ts new file mode 100644 index 00000000..c3dfdf37 --- /dev/null +++ b/packages/cli/src/ui/commands/compressCommand.ts @@ -0,0 +1,77 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { HistoryItemCompression, MessageType } from '../types.js'; +import { SlashCommand } from './types.js'; + +export const compressCommand: SlashCommand = { + name: 'compress', + altName: 'summarize', + description: 'Compresses the context by replacing it with a summary.', + action: async (context) => { + const { ui } = context; + if (ui.pendingItem) { + ui.addItem( + { + type: MessageType.ERROR, + text: 'Already compressing, wait for previous request to complete', + }, + Date.now(), + ); + return; + } + + const pendingMessage: HistoryItemCompression = { + type: MessageType.COMPRESSION, + compression: { + isPending: true, + originalTokenCount: null, + newTokenCount: null, + }, + }; + + try { + ui.setPendingItem(pendingMessage); + const promptId = `compress-${Date.now()}`; + const compressed = await context.services.config + ?.getGeminiClient() + ?.tryCompressChat(promptId, true); + if (compressed) { + ui.addItem( + { + type: MessageType.COMPRESSION, + compression: { + isPending: false, + originalTokenCount: compressed.originalTokenCount, + newTokenCount: compressed.newTokenCount, + }, + } as HistoryItemCompression, + Date.now(), + ); + } else { + ui.addItem( + { + type: MessageType.ERROR, + text: 'Failed to compress chat history.', + }, + Date.now(), + ); + } + } catch (e) { + ui.addItem( + { + type: MessageType.ERROR, + text: `Failed to compress chat history: ${ + e instanceof Error ? e.message : String(e) + }`, + }, + Date.now(), + ); + } finally { + ui.setPendingItem(null); + } + }, +}; diff --git a/packages/cli/src/ui/commands/types.ts b/packages/cli/src/ui/commands/types.ts index 27db2be2..85a85abe 100644 --- a/packages/cli/src/ui/commands/types.ts +++ b/packages/cli/src/ui/commands/types.ts @@ -23,11 +23,6 @@ export interface CommandContext { }; // UI state and history management ui: { - // TODO - As more commands are add some additions may be needed or reworked using this new context. - // Ex. - // history: HistoryItem[]; - // pendingHistoryItems: HistoryItemWithoutId[]; - /** Adds a new item to the history display. */ addItem: UseHistoryManagerReturn['addItem']; /** Clears all history items and the console screen. */ @@ -36,6 +31,15 @@ export interface CommandContext { * Sets the transient debug message displayed in the application footer in debug mode. */ setDebugMessage: (message: string) => void; + /** The currently pending history item, if any. */ + pendingItem: HistoryItemWithoutId | null; + /** + * Sets a pending item in the history, which is useful for indicating + * that a long-running operation is in progress. + * + * @param item The history item to display as pending, or `null` to clear. + */ + setPendingItem: (item: HistoryItemWithoutId | null) => void; }; // Session-specific data session: { |
