mirror of
https://github.com/QwenLM/qwen-code.git
synced 2025-12-20 08:47:44 +00:00
feat: Added /copy command for copying output to clipboard with new Command Service approach (#3706)
Co-authored-by: Abhi <43648792+abhipatel12@users.noreply.github.com> Co-authored-by: N. Taylor Mullen <ntaylormullen@google.com>
This commit is contained in:
296
packages/cli/src/ui/commands/copyCommand.test.ts
Normal file
296
packages/cli/src/ui/commands/copyCommand.test.ts
Normal file
@@ -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();
|
||||
});
|
||||
});
|
||||
62
packages/cli/src/ui/commands/copyCommand.ts
Normal file
62
packages/cli/src/ui/commands/copyCommand.ts
Normal file
@@ -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.',
|
||||
};
|
||||
}
|
||||
},
|
||||
};
|
||||
Reference in New Issue
Block a user