summaryrefslogtreecommitdiff
path: root/packages/cli/src/ui/commands
diff options
context:
space:
mode:
Diffstat (limited to 'packages/cli/src/ui/commands')
-rw-r--r--packages/cli/src/ui/commands/copyCommand.test.ts296
-rw-r--r--packages/cli/src/ui/commands/copyCommand.ts62
2 files changed, 358 insertions, 0 deletions
diff --git a/packages/cli/src/ui/commands/copyCommand.test.ts b/packages/cli/src/ui/commands/copyCommand.test.ts
new file mode 100644
index 00000000..b163b43f
--- /dev/null
+++ b/packages/cli/src/ui/commands/copyCommand.test.ts
@@ -0,0 +1,296 @@
+/**
+ * @license
+ * Copyright 2025 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import { vi, describe, it, expect, beforeEach, Mock } from 'vitest';
+import { copyCommand } from './copyCommand.js';
+import { type CommandContext } from './types.js';
+import { createMockCommandContext } from '../../test-utils/mockCommandContext.js';
+import { copyToClipboard } from '../utils/commandUtils.js';
+
+vi.mock('../utils/commandUtils.js', () => ({
+ copyToClipboard: vi.fn(),
+}));
+
+describe('copyCommand', () => {
+ let mockContext: CommandContext;
+ let mockCopyToClipboard: Mock;
+ let mockGetChat: Mock;
+ let mockGetHistory: Mock;
+
+ beforeEach(() => {
+ vi.clearAllMocks();
+
+ mockCopyToClipboard = vi.mocked(copyToClipboard);
+ mockGetChat = vi.fn();
+ mockGetHistory = vi.fn();
+
+ mockContext = createMockCommandContext({
+ services: {
+ config: {
+ getGeminiClient: () => ({
+ getChat: mockGetChat,
+ }),
+ },
+ },
+ });
+
+ mockGetChat.mockReturnValue({
+ getHistory: mockGetHistory,
+ });
+ });
+
+ it('should return info message when no history is available', async () => {
+ if (!copyCommand.action) throw new Error('Command has no action');
+
+ mockGetChat.mockReturnValue(undefined);
+
+ const result = await copyCommand.action(mockContext, '');
+
+ expect(result).toEqual({
+ type: 'message',
+ messageType: 'info',
+ content: 'No output in history',
+ });
+
+ expect(mockCopyToClipboard).not.toHaveBeenCalled();
+ });
+
+ it('should return info message when history is empty', async () => {
+ if (!copyCommand.action) throw new Error('Command has no action');
+
+ mockGetHistory.mockReturnValue([]);
+
+ const result = await copyCommand.action(mockContext, '');
+
+ expect(result).toEqual({
+ type: 'message',
+ messageType: 'info',
+ content: 'No output in history',
+ });
+
+ expect(mockCopyToClipboard).not.toHaveBeenCalled();
+ });
+
+ it('should return info message when no AI messages are found in history', async () => {
+ if (!copyCommand.action) throw new Error('Command has no action');
+
+ const historyWithUserOnly = [
+ {
+ role: 'user',
+ parts: [{ text: 'Hello' }],
+ },
+ ];
+
+ mockGetHistory.mockReturnValue(historyWithUserOnly);
+
+ const result = await copyCommand.action(mockContext, '');
+
+ expect(result).toEqual({
+ type: 'message',
+ messageType: 'info',
+ content: 'No output in history',
+ });
+
+ expect(mockCopyToClipboard).not.toHaveBeenCalled();
+ });
+
+ it('should copy last AI message to clipboard successfully', async () => {
+ if (!copyCommand.action) throw new Error('Command has no action');
+
+ const historyWithAiMessage = [
+ {
+ role: 'user',
+ parts: [{ text: 'Hello' }],
+ },
+ {
+ role: 'model',
+ parts: [{ text: 'Hi there! How can I help you?' }],
+ },
+ ];
+
+ mockGetHistory.mockReturnValue(historyWithAiMessage);
+ mockCopyToClipboard.mockResolvedValue(undefined);
+
+ const result = await copyCommand.action(mockContext, '');
+
+ expect(result).toEqual({
+ type: 'message',
+ messageType: 'info',
+ content: 'Last output copied to the clipboard',
+ });
+
+ expect(mockCopyToClipboard).toHaveBeenCalledWith(
+ 'Hi there! How can I help you?',
+ );
+ });
+
+ it('should handle multiple text parts in AI message', async () => {
+ if (!copyCommand.action) throw new Error('Command has no action');
+
+ const historyWithMultipleParts = [
+ {
+ role: 'model',
+ parts: [{ text: 'Part 1: ' }, { text: 'Part 2: ' }, { text: 'Part 3' }],
+ },
+ ];
+
+ mockGetHistory.mockReturnValue(historyWithMultipleParts);
+ mockCopyToClipboard.mockResolvedValue(undefined);
+
+ const result = await copyCommand.action(mockContext, '');
+
+ expect(mockCopyToClipboard).toHaveBeenCalledWith('Part 1: Part 2: Part 3');
+ expect(result).toEqual({
+ type: 'message',
+ messageType: 'info',
+ content: 'Last output copied to the clipboard',
+ });
+ });
+
+ it('should filter out non-text parts', async () => {
+ if (!copyCommand.action) throw new Error('Command has no action');
+
+ const historyWithMixedParts = [
+ {
+ role: 'model',
+ parts: [
+ { text: 'Text part' },
+ { image: 'base64data' }, // Non-text part
+ { text: ' more text' },
+ ],
+ },
+ ];
+
+ mockGetHistory.mockReturnValue(historyWithMixedParts);
+ mockCopyToClipboard.mockResolvedValue(undefined);
+
+ const result = await copyCommand.action(mockContext, '');
+
+ expect(mockCopyToClipboard).toHaveBeenCalledWith('Text part more text');
+ expect(result).toEqual({
+ type: 'message',
+ messageType: 'info',
+ content: 'Last output copied to the clipboard',
+ });
+ });
+
+ it('should get the last AI message when multiple AI messages exist', async () => {
+ if (!copyCommand.action) throw new Error('Command has no action');
+
+ const historyWithMultipleAiMessages = [
+ {
+ role: 'model',
+ parts: [{ text: 'First AI response' }],
+ },
+ {
+ role: 'user',
+ parts: [{ text: 'User message' }],
+ },
+ {
+ role: 'model',
+ parts: [{ text: 'Second AI response' }],
+ },
+ ];
+
+ mockGetHistory.mockReturnValue(historyWithMultipleAiMessages);
+ mockCopyToClipboard.mockResolvedValue(undefined);
+
+ const result = await copyCommand.action(mockContext, '');
+
+ expect(mockCopyToClipboard).toHaveBeenCalledWith('Second AI response');
+ expect(result).toEqual({
+ type: 'message',
+ messageType: 'info',
+ content: 'Last output copied to the clipboard',
+ });
+ });
+
+ it('should handle clipboard copy error', async () => {
+ if (!copyCommand.action) throw new Error('Command has no action');
+
+ const historyWithAiMessage = [
+ {
+ role: 'model',
+ parts: [{ text: 'AI response' }],
+ },
+ ];
+
+ mockGetHistory.mockReturnValue(historyWithAiMessage);
+ const clipboardError = new Error('Clipboard access denied');
+ mockCopyToClipboard.mockRejectedValue(clipboardError);
+
+ const result = await copyCommand.action(mockContext, '');
+
+ expect(result).toEqual({
+ type: 'message',
+ messageType: 'error',
+ content: 'Failed to copy to the clipboard.',
+ });
+ });
+
+ it('should handle non-Error clipboard errors', async () => {
+ if (!copyCommand.action) throw new Error('Command has no action');
+
+ const historyWithAiMessage = [
+ {
+ role: 'model',
+ parts: [{ text: 'AI response' }],
+ },
+ ];
+
+ mockGetHistory.mockReturnValue(historyWithAiMessage);
+ mockCopyToClipboard.mockRejectedValue('String error');
+
+ const result = await copyCommand.action(mockContext, '');
+
+ expect(result).toEqual({
+ type: 'message',
+ messageType: 'error',
+ content: 'Failed to copy to the clipboard.',
+ });
+ });
+
+ it('should return info message when no text parts found in AI message', async () => {
+ if (!copyCommand.action) throw new Error('Command has no action');
+
+ const historyWithEmptyParts = [
+ {
+ role: 'model',
+ parts: [{ image: 'base64data' }], // No text parts
+ },
+ ];
+
+ mockGetHistory.mockReturnValue(historyWithEmptyParts);
+
+ const result = await copyCommand.action(mockContext, '');
+
+ expect(result).toEqual({
+ type: 'message',
+ messageType: 'info',
+ content: 'Last AI output contains no text to copy.',
+ });
+
+ expect(mockCopyToClipboard).not.toHaveBeenCalled();
+ });
+
+ it('should handle unavailable config service', async () => {
+ if (!copyCommand.action) throw new Error('Command has no action');
+
+ const nullConfigContext = createMockCommandContext({
+ services: { config: null },
+ });
+
+ const result = await copyCommand.action(nullConfigContext, '');
+
+ expect(result).toEqual({
+ type: 'message',
+ messageType: 'info',
+ content: 'No output in history',
+ });
+
+ expect(mockCopyToClipboard).not.toHaveBeenCalled();
+ });
+});
diff --git a/packages/cli/src/ui/commands/copyCommand.ts b/packages/cli/src/ui/commands/copyCommand.ts
new file mode 100644
index 00000000..5714b5ab
--- /dev/null
+++ b/packages/cli/src/ui/commands/copyCommand.ts
@@ -0,0 +1,62 @@
+/**
+ * @license
+ * Copyright 2025 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import { copyToClipboard } from '../utils/commandUtils.js';
+import { SlashCommand, SlashCommandActionReturn } from './types.js';
+
+export const copyCommand: SlashCommand = {
+ name: 'copy',
+ description: 'Copy the last result or code snippet to clipboard',
+ action: async (context, _args): Promise<SlashCommandActionReturn | void> => {
+ const chat = await context.services.config?.getGeminiClient()?.getChat();
+ const history = chat?.getHistory();
+
+ // Get the last message from the AI (model role)
+ const lastAiMessage = history
+ ? history.filter((item) => item.role === 'model').pop()
+ : undefined;
+
+ if (!lastAiMessage) {
+ return {
+ type: 'message',
+ messageType: 'info',
+ content: 'No output in history',
+ };
+ }
+ // Extract text from the parts
+ const lastAiOutput = lastAiMessage.parts
+ ?.filter((part) => part.text)
+ .map((part) => part.text)
+ .join('');
+
+ if (lastAiOutput) {
+ try {
+ await copyToClipboard(lastAiOutput);
+
+ return {
+ type: 'message',
+ messageType: 'info',
+ content: 'Last output copied to the clipboard',
+ };
+ } catch (error) {
+ const message = error instanceof Error ? error.message : String(error);
+ console.debug(message);
+
+ return {
+ type: 'message',
+ messageType: 'error',
+ content: 'Failed to copy to the clipboard.',
+ };
+ }
+ } else {
+ return {
+ type: 'message',
+ messageType: 'info',
+ content: 'Last AI output contains no text to copy.',
+ };
+ }
+ },
+};