summaryrefslogtreecommitdiff
path: root/packages/cli/src
diff options
context:
space:
mode:
Diffstat (limited to 'packages/cli/src')
-rw-r--r--packages/cli/src/services/CommandService.test.ts8
-rw-r--r--packages/cli/src/services/CommandService.ts2
-rw-r--r--packages/cli/src/ui/commands/copyCommand.test.ts296
-rw-r--r--packages/cli/src/ui/commands/copyCommand.ts62
-rw-r--r--packages/cli/src/ui/utils/commandUtils.test.ts345
-rw-r--r--packages/cli/src/ui/utils/commandUtils.ts56
6 files changed, 768 insertions, 1 deletions
diff --git a/packages/cli/src/services/CommandService.test.ts b/packages/cli/src/services/CommandService.test.ts
index d03bf988..de4ff2ea 100644
--- a/packages/cli/src/services/CommandService.test.ts
+++ b/packages/cli/src/services/CommandService.test.ts
@@ -11,6 +11,7 @@ import { type SlashCommand } from '../ui/commands/types.js';
import { memoryCommand } from '../ui/commands/memoryCommand.js';
import { helpCommand } from '../ui/commands/helpCommand.js';
import { clearCommand } from '../ui/commands/clearCommand.js';
+import { copyCommand } from '../ui/commands/copyCommand.js';
import { corgiCommand } from '../ui/commands/corgiCommand.js';
import { docsCommand } from '../ui/commands/docsCommand.js';
import { chatCommand } from '../ui/commands/chatCommand.js';
@@ -51,6 +52,9 @@ vi.mock('../ui/commands/authCommand.js', () => ({
vi.mock('../ui/commands/themeCommand.js', () => ({
themeCommand: { name: 'theme', description: 'Mock Theme' },
}));
+vi.mock('../ui/commands/copyCommand.js', () => ({
+ copyCommand: { name: 'copy', description: 'Mock Copy' },
+}));
vi.mock('../ui/commands/privacyCommand.js', () => ({
privacyCommand: { name: 'privacy', description: 'Mock Privacy' },
}));
@@ -89,7 +93,7 @@ vi.mock('../ui/commands/restoreCommand.js', () => ({
}));
describe('CommandService', () => {
- const subCommandLen = 18;
+ const subCommandLen = 19;
let mockConfig: Mocked<Config>;
beforeEach(() => {
@@ -132,6 +136,7 @@ describe('CommandService', () => {
expect(commandNames).toContain('memory');
expect(commandNames).toContain('help');
expect(commandNames).toContain('clear');
+ expect(commandNames).toContain('copy');
expect(commandNames).toContain('compress');
expect(commandNames).toContain('corgi');
expect(commandNames).toContain('docs');
@@ -205,6 +210,7 @@ describe('CommandService', () => {
bugCommand,
chatCommand,
clearCommand,
+ copyCommand,
compressCommand,
corgiCommand,
docsCommand,
diff --git a/packages/cli/src/services/CommandService.ts b/packages/cli/src/services/CommandService.ts
index def8cfcc..99eccbf2 100644
--- a/packages/cli/src/services/CommandService.ts
+++ b/packages/cli/src/services/CommandService.ts
@@ -9,6 +9,7 @@ import { SlashCommand } from '../ui/commands/types.js';
import { memoryCommand } from '../ui/commands/memoryCommand.js';
import { helpCommand } from '../ui/commands/helpCommand.js';
import { clearCommand } from '../ui/commands/clearCommand.js';
+import { copyCommand } from '../ui/commands/copyCommand.js';
import { corgiCommand } from '../ui/commands/corgiCommand.js';
import { docsCommand } from '../ui/commands/docsCommand.js';
import { mcpCommand } from '../ui/commands/mcpCommand.js';
@@ -36,6 +37,7 @@ const loadBuiltInCommands = async (
bugCommand,
chatCommand,
clearCommand,
+ copyCommand,
compressCommand,
corgiCommand,
docsCommand,
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.',
+ };
+ }
+ },
+};
diff --git a/packages/cli/src/ui/utils/commandUtils.test.ts b/packages/cli/src/ui/utils/commandUtils.test.ts
new file mode 100644
index 00000000..158a5f7a
--- /dev/null
+++ b/packages/cli/src/ui/utils/commandUtils.test.ts
@@ -0,0 +1,345 @@
+/**
+ * @license
+ * Copyright 2025 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import { vi, describe, it, expect, beforeEach, Mock } from 'vitest';
+import { spawn } from 'child_process';
+import { EventEmitter } from 'events';
+import {
+ isAtCommand,
+ isSlashCommand,
+ copyToClipboard,
+} from './commandUtils.js';
+
+// Mock child_process
+vi.mock('child_process');
+
+// Mock process.platform for platform-specific tests
+const mockProcess = vi.hoisted(() => ({
+ platform: 'darwin',
+}));
+
+vi.stubGlobal('process', {
+ ...process,
+ get platform() {
+ return mockProcess.platform;
+ },
+});
+
+interface MockChildProcess extends EventEmitter {
+ stdin: EventEmitter & {
+ write: Mock;
+ end: Mock;
+ };
+ stderr: EventEmitter;
+}
+
+describe('commandUtils', () => {
+ let mockSpawn: Mock;
+ let mockChild: MockChildProcess;
+
+ beforeEach(async () => {
+ vi.clearAllMocks();
+ // Dynamically import and set up spawn mock
+ const { spawn } = await import('child_process');
+ mockSpawn = spawn as Mock;
+
+ // Create mock child process with stdout/stderr emitters
+ mockChild = Object.assign(new EventEmitter(), {
+ stdin: Object.assign(new EventEmitter(), {
+ write: vi.fn(),
+ end: vi.fn(),
+ }),
+ stderr: new EventEmitter(),
+ }) as MockChildProcess;
+
+ mockSpawn.mockReturnValue(mockChild as unknown as ReturnType<typeof spawn>);
+ });
+
+ describe('isAtCommand', () => {
+ it('should return true when query starts with @', () => {
+ expect(isAtCommand('@file')).toBe(true);
+ expect(isAtCommand('@path/to/file')).toBe(true);
+ expect(isAtCommand('@')).toBe(true);
+ });
+
+ it('should return true when query contains @ preceded by whitespace', () => {
+ expect(isAtCommand('hello @file')).toBe(true);
+ expect(isAtCommand('some text @path/to/file')).toBe(true);
+ expect(isAtCommand(' @file')).toBe(true);
+ });
+
+ it('should return false when query does not start with @ and has no spaced @', () => {
+ expect(isAtCommand('file')).toBe(false);
+ expect(isAtCommand('hello')).toBe(false);
+ expect(isAtCommand('')).toBe(false);
+ expect(isAtCommand('[email protected]')).toBe(false);
+ expect(isAtCommand('user@host')).toBe(false);
+ });
+
+ it('should return false when @ is not preceded by whitespace', () => {
+ expect(isAtCommand('hello@file')).toBe(false);
+ expect(isAtCommand('text@path')).toBe(false);
+ });
+ });
+
+ describe('isSlashCommand', () => {
+ it('should return true when query starts with /', () => {
+ expect(isSlashCommand('/help')).toBe(true);
+ expect(isSlashCommand('/memory show')).toBe(true);
+ expect(isSlashCommand('/clear')).toBe(true);
+ expect(isSlashCommand('/')).toBe(true);
+ });
+
+ it('should return false when query does not start with /', () => {
+ expect(isSlashCommand('help')).toBe(false);
+ expect(isSlashCommand('memory show')).toBe(false);
+ expect(isSlashCommand('')).toBe(false);
+ expect(isSlashCommand('path/to/file')).toBe(false);
+ expect(isSlashCommand(' /help')).toBe(false);
+ });
+ });
+
+ describe('copyToClipboard', () => {
+ describe('on macOS (darwin)', () => {
+ beforeEach(() => {
+ mockProcess.platform = 'darwin';
+ });
+
+ it('should successfully copy text to clipboard using pbcopy', async () => {
+ const testText = 'Hello, world!';
+
+ // Simulate successful execution
+ setTimeout(() => {
+ mockChild.emit('close', 0);
+ }, 0);
+
+ await copyToClipboard(testText);
+
+ expect(mockSpawn).toHaveBeenCalledWith('pbcopy', []);
+ expect(mockChild.stdin.write).toHaveBeenCalledWith(testText);
+ expect(mockChild.stdin.end).toHaveBeenCalled();
+ });
+
+ it('should handle pbcopy command failure', async () => {
+ const testText = 'Hello, world!';
+
+ // Simulate command failure
+ setTimeout(() => {
+ mockChild.stderr.emit('data', 'Command not found');
+ mockChild.emit('close', 1);
+ }, 0);
+
+ await expect(copyToClipboard(testText)).rejects.toThrow(
+ "'pbcopy' exited with code 1: Command not found",
+ );
+ });
+
+ it('should handle spawn error', async () => {
+ const testText = 'Hello, world!';
+
+ setTimeout(() => {
+ mockChild.emit('error', new Error('spawn error'));
+ }, 0);
+
+ await expect(copyToClipboard(testText)).rejects.toThrow('spawn error');
+ });
+
+ it('should handle stdin write error', async () => {
+ const testText = 'Hello, world!';
+
+ setTimeout(() => {
+ mockChild.stdin.emit('error', new Error('stdin error'));
+ }, 0);
+
+ await expect(copyToClipboard(testText)).rejects.toThrow('stdin error');
+ });
+ });
+
+ describe('on Windows (win32)', () => {
+ beforeEach(() => {
+ mockProcess.platform = 'win32';
+ });
+
+ it('should successfully copy text to clipboard using clip', async () => {
+ const testText = 'Hello, world!';
+
+ setTimeout(() => {
+ mockChild.emit('close', 0);
+ }, 0);
+
+ await copyToClipboard(testText);
+
+ expect(mockSpawn).toHaveBeenCalledWith('clip', []);
+ expect(mockChild.stdin.write).toHaveBeenCalledWith(testText);
+ expect(mockChild.stdin.end).toHaveBeenCalled();
+ });
+ });
+
+ describe('on Linux', () => {
+ beforeEach(() => {
+ mockProcess.platform = 'linux';
+ });
+
+ it('should successfully copy text to clipboard using xclip', async () => {
+ const testText = 'Hello, world!';
+
+ setTimeout(() => {
+ mockChild.emit('close', 0);
+ }, 0);
+
+ await copyToClipboard(testText);
+
+ expect(mockSpawn).toHaveBeenCalledWith('xclip', [
+ '-selection',
+ 'clipboard',
+ ]);
+ expect(mockChild.stdin.write).toHaveBeenCalledWith(testText);
+ expect(mockChild.stdin.end).toHaveBeenCalled();
+ });
+
+ it('should fallback to xsel when xclip fails', async () => {
+ const testText = 'Hello, world!';
+ let callCount = 0;
+
+ mockSpawn.mockImplementation(() => {
+ const child = Object.assign(new EventEmitter(), {
+ stdin: Object.assign(new EventEmitter(), {
+ write: vi.fn(),
+ end: vi.fn(),
+ }),
+ stderr: new EventEmitter(),
+ }) as MockChildProcess;
+
+ setTimeout(() => {
+ if (callCount === 0) {
+ // First call (xclip) fails
+ child.stderr.emit('data', 'xclip not found');
+ child.emit('close', 1);
+ callCount++;
+ } else {
+ // Second call (xsel) succeeds
+ child.emit('close', 0);
+ }
+ }, 0);
+
+ return child as unknown as ReturnType<typeof spawn>;
+ });
+
+ await copyToClipboard(testText);
+
+ expect(mockSpawn).toHaveBeenCalledTimes(2);
+ expect(mockSpawn).toHaveBeenNthCalledWith(1, 'xclip', [
+ '-selection',
+ 'clipboard',
+ ]);
+ expect(mockSpawn).toHaveBeenNthCalledWith(2, 'xsel', [
+ '--clipboard',
+ '--input',
+ ]);
+ });
+
+ it('should throw error when both xclip and xsel fail', async () => {
+ const testText = 'Hello, world!';
+ let callCount = 0;
+
+ mockSpawn.mockImplementation(() => {
+ const child = Object.assign(new EventEmitter(), {
+ stdin: Object.assign(new EventEmitter(), {
+ write: vi.fn(),
+ end: vi.fn(),
+ }),
+ stderr: new EventEmitter(),
+ });
+
+ setTimeout(() => {
+ if (callCount === 0) {
+ // First call (xclip) fails
+ child.stderr.emit('data', 'xclip command not found');
+ child.emit('close', 1);
+ callCount++;
+ } else {
+ // Second call (xsel) fails
+ child.stderr.emit('data', 'xsel command not found');
+ child.emit('close', 1);
+ }
+ }, 0);
+
+ return child as unknown as ReturnType<typeof spawn>;
+ });
+
+ await expect(copyToClipboard(testText)).rejects.toThrow(
+ /All copy commands failed/,
+ );
+
+ expect(mockSpawn).toHaveBeenCalledTimes(2);
+ });
+ });
+
+ describe('on unsupported platform', () => {
+ beforeEach(() => {
+ mockProcess.platform = 'unsupported';
+ });
+
+ it('should throw error for unsupported platform', async () => {
+ await expect(copyToClipboard('test')).rejects.toThrow(
+ 'Unsupported platform: unsupported',
+ );
+ });
+ });
+
+ describe('error handling', () => {
+ beforeEach(() => {
+ mockProcess.platform = 'darwin';
+ });
+
+ it('should handle command exit without stderr', async () => {
+ const testText = 'Hello, world!';
+
+ setTimeout(() => {
+ mockChild.emit('close', 1);
+ }, 0);
+
+ await expect(copyToClipboard(testText)).rejects.toThrow(
+ "'pbcopy' exited with code 1",
+ );
+ });
+
+ it('should handle empty text', async () => {
+ setTimeout(() => {
+ mockChild.emit('close', 0);
+ }, 0);
+
+ await copyToClipboard('');
+
+ expect(mockChild.stdin.write).toHaveBeenCalledWith('');
+ });
+
+ it('should handle multiline text', async () => {
+ const multilineText = 'Line 1\nLine 2\nLine 3';
+
+ setTimeout(() => {
+ mockChild.emit('close', 0);
+ }, 0);
+
+ await copyToClipboard(multilineText);
+
+ expect(mockChild.stdin.write).toHaveBeenCalledWith(multilineText);
+ });
+
+ it('should handle special characters', async () => {
+ const specialText = 'Special chars: !@#$%^&*()_+-=[]{}|;:,.<>?';
+
+ setTimeout(() => {
+ mockChild.emit('close', 0);
+ }, 0);
+
+ await copyToClipboard(specialText);
+
+ expect(mockChild.stdin.write).toHaveBeenCalledWith(specialText);
+ });
+ });
+ });
+});
diff --git a/packages/cli/src/ui/utils/commandUtils.ts b/packages/cli/src/ui/utils/commandUtils.ts
index aadd035e..4280388f 100644
--- a/packages/cli/src/ui/utils/commandUtils.ts
+++ b/packages/cli/src/ui/utils/commandUtils.ts
@@ -4,6 +4,8 @@
* SPDX-License-Identifier: Apache-2.0
*/
+import { spawn } from 'child_process';
+
/**
* Checks if a query string potentially represents an '@' command.
* It triggers if the query starts with '@' or contains '@' preceded by whitespace
@@ -24,3 +26,57 @@ export const isAtCommand = (query: string): boolean =>
* @returns True if the query looks like an '/' command, false otherwise.
*/
export const isSlashCommand = (query: string): boolean => query.startsWith('/');
+
+//Copies a string snippet to the clipboard for different platforms
+export const copyToClipboard = async (text: string): Promise<void> => {
+ const run = (cmd: string, args: string[]) =>
+ new Promise<void>((resolve, reject) => {
+ const child = spawn(cmd, args);
+ let stderr = '';
+ child.stderr.on('data', (chunk) => (stderr += chunk.toString()));
+ child.on('error', reject);
+ child.on('close', (code) => {
+ if (code === 0) return resolve();
+ const errorMsg = stderr.trim();
+ reject(
+ new Error(
+ `'${cmd}' exited with code ${code}${errorMsg ? `: ${errorMsg}` : ''}`,
+ ),
+ );
+ });
+ child.stdin.on('error', reject);
+ child.stdin.write(text);
+ child.stdin.end();
+ });
+
+ switch (process.platform) {
+ case 'win32':
+ return run('clip', []);
+ case 'darwin':
+ return run('pbcopy', []);
+ case 'linux':
+ try {
+ await run('xclip', ['-selection', 'clipboard']);
+ } catch (primaryError) {
+ try {
+ // If xclip fails for any reason, try xsel as a fallback.
+ await run('xsel', ['--clipboard', '--input']);
+ } catch (fallbackError) {
+ const primaryMsg =
+ primaryError instanceof Error
+ ? primaryError.message
+ : String(primaryError);
+ const fallbackMsg =
+ fallbackError instanceof Error
+ ? fallbackError.message
+ : String(fallbackError);
+ throw new Error(
+ `All copy commands failed. xclip: "${primaryMsg}", xsel: "${fallbackMsg}". Please ensure xclip or xsel is installed and configured.`,
+ );
+ }
+ }
+ return;
+ default:
+ throw new Error(`Unsupported platform: ${process.platform}`);
+ }
+};