diff --git a/docs/cli/commands.md b/docs/cli/commands.md index 6db137c7..b28b2dd3 100644 --- a/docs/cli/commands.md +++ b/docs/cli/commands.md @@ -40,6 +40,9 @@ Slash commands provide meta-level control over the CLI itself. - **`/copy`** - **Description:** Copies the last output produced by Gemini CLI to your clipboard, for easy sharing or reuse. + - **Note:** This command requires platform-specific clipboard tools to be installed. + - On Linux, it requires `xclip` or `xsel`. You can typically install them using your system's package manager. + - On macOS, it requires `pbcopy`, and on Windows, it requires `clip`. These tools are typically pre-installed on their respective systems. - **`/directory`** (or **`/dir`**) - **Description:** Manage workspace directories for multi-directory support. diff --git a/packages/cli/src/ui/commands/copyCommand.test.ts b/packages/cli/src/ui/commands/copyCommand.test.ts index b163b43f..da356965 100644 --- a/packages/cli/src/ui/commands/copyCommand.test.ts +++ b/packages/cli/src/ui/commands/copyCommand.test.ts @@ -227,7 +227,7 @@ describe('copyCommand', () => { expect(result).toEqual({ type: 'message', messageType: 'error', - content: 'Failed to copy to the clipboard.', + content: `Failed to copy to the clipboard. ${clipboardError.message}`, }); }); @@ -242,14 +242,15 @@ describe('copyCommand', () => { ]; mockGetHistory.mockReturnValue(historyWithAiMessage); - mockCopyToClipboard.mockRejectedValue('String error'); + const rejectedValue = 'String error'; + mockCopyToClipboard.mockRejectedValue(rejectedValue); const result = await copyCommand.action(mockContext, ''); expect(result).toEqual({ type: 'message', messageType: 'error', - content: 'Failed to copy to the clipboard.', + content: `Failed to copy to the clipboard. ${rejectedValue}`, }); }); diff --git a/packages/cli/src/ui/commands/copyCommand.ts b/packages/cli/src/ui/commands/copyCommand.ts index bd330faa..9acbd7e0 100644 --- a/packages/cli/src/ui/commands/copyCommand.ts +++ b/packages/cli/src/ui/commands/copyCommand.ts @@ -53,7 +53,7 @@ export const copyCommand: SlashCommand = { return { type: 'message', messageType: 'error', - content: 'Failed to copy to the clipboard.', + content: `Failed to copy to the clipboard. ${message}`, }; } } else { diff --git a/packages/cli/src/ui/utils/commandUtils.test.ts b/packages/cli/src/ui/utils/commandUtils.test.ts index c1920599..3b30af83 100644 --- a/packages/cli/src/ui/utils/commandUtils.test.ts +++ b/packages/cli/src/ui/utils/commandUtils.test.ts @@ -224,7 +224,9 @@ describe('commandUtils', () => { setTimeout(() => { if (callCount === 0) { // First call (xclip) fails - child.stderr.emit('data', 'xclip not found'); + const error = new Error('spawn xclip ENOENT'); + (error as NodeJS.ErrnoException).code = 'ENOENT'; + child.emit('error', error); child.emit('close', 1); callCount++; } else { @@ -253,9 +255,12 @@ describe('commandUtils', () => { ); }); - it('should throw error when both xclip and xsel fail', async () => { + it('should throw error when both xclip and xsel are not found', async () => { const testText = 'Hello, world!'; let callCount = 0; + const linuxOptions: SpawnOptions = { + stdio: ['pipe', 'inherit', 'pipe'], + }; mockSpawn.mockImplementation(() => { const child = Object.assign(new EventEmitter(), { @@ -264,29 +269,99 @@ describe('commandUtils', () => { end: vi.fn(), }), stderr: new EventEmitter(), - }); + }) as MockChildProcess; setTimeout(() => { if (callCount === 0) { - // First call (xclip) fails - child.stderr.emit('data', 'xclip command not found'); + // First call (xclip) fails with ENOENT + const error = new Error('spawn xclip ENOENT'); + (error as NodeJS.ErrnoException).code = 'ENOENT'; + child.emit('error', error); child.emit('close', 1); callCount++; } else { - // Second call (xsel) fails - child.stderr.emit('data', 'xsel command not found'); + // Second call (xsel) fails with ENOENT + const error = new Error('spawn xsel ENOENT'); + (error as NodeJS.ErrnoException).code = 'ENOENT'; + child.emit('error', error); child.emit('close', 1); } }, 0); return child as unknown as ReturnType; }); - await expect(copyToClipboard(testText)).rejects.toThrow( - /All copy commands failed/, + 'Please ensure xclip or xsel is installed and configured.', ); expect(mockSpawn).toHaveBeenCalledTimes(2); + expect(mockSpawn).toHaveBeenNthCalledWith( + 1, + 'xclip', + ['-selection', 'clipboard'], + linuxOptions, + ); + expect(mockSpawn).toHaveBeenNthCalledWith( + 2, + 'xsel', + ['--clipboard', '--input'], + linuxOptions, + ); + }); + + it('should emit error when xclip or xsel fail with stderr output (command installed)', async () => { + const testText = 'Hello, world!'; + let callCount = 0; + const linuxOptions: SpawnOptions = { + stdio: ['pipe', 'inherit', 'pipe'], + }; + const errorMsg = "Error: Can't open display:"; + const exitCode = 1; + + 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(() => { + // e.g., cannot connect to X server + if (callCount === 0) { + child.stderr.emit('data', errorMsg); + child.emit('close', exitCode); + callCount++; + } else { + child.stderr.emit('data', errorMsg); + child.emit('close', exitCode); + } + }, 0); + + return child as unknown as ReturnType; + }); + + const xclipErrorMsg = `'xclip' exited with code ${exitCode}${errorMsg ? `: ${errorMsg}` : ''}`; + const xselErrorMsg = `'xsel' exited with code ${exitCode}${errorMsg ? `: ${errorMsg}` : ''}`; + + await expect(copyToClipboard(testText)).rejects.toThrow( + `All copy commands failed. "${xclipErrorMsg}", "${xselErrorMsg}". `, + ); + + expect(mockSpawn).toHaveBeenCalledTimes(2); + expect(mockSpawn).toHaveBeenNthCalledWith( + 1, + 'xclip', + ['-selection', 'clipboard'], + linuxOptions, + ); + expect(mockSpawn).toHaveBeenNthCalledWith( + 2, + 'xsel', + ['--clipboard', '--input'], + linuxOptions, + ); }); }); diff --git a/packages/cli/src/ui/utils/commandUtils.ts b/packages/cli/src/ui/utils/commandUtils.ts index 089ec339..99158705 100644 --- a/packages/cli/src/ui/utils/commandUtils.ts +++ b/packages/cli/src/ui/utils/commandUtils.ts @@ -74,16 +74,35 @@ export const copyToClipboard = async (text: string): Promise => { // If xclip fails for any reason, try xsel as a fallback. await run('xsel', ['--clipboard', '--input'], linuxOptions); } catch (fallbackError) { - const primaryMsg = + const xclipNotFound = + primaryError instanceof Error && + (primaryError as NodeJS.ErrnoException).code === 'ENOENT'; + const xselNotFound = + fallbackError instanceof Error && + (fallbackError as NodeJS.ErrnoException).code === 'ENOENT'; + if (xclipNotFound && xselNotFound) { + throw new Error( + 'Please ensure xclip or xsel is installed and configured.', + ); + } + + let primaryMsg = primaryError instanceof Error ? primaryError.message : String(primaryError); - const fallbackMsg = + if (xclipNotFound) { + primaryMsg = `xclip not found`; + } + let fallbackMsg = fallbackError instanceof Error ? fallbackError.message : String(fallbackError); + if (xselNotFound) { + fallbackMsg = `xsel not found`; + } + throw new Error( - `All copy commands failed. xclip: "${primaryMsg}", xsel: "${fallbackMsg}". Please ensure xclip or xsel is installed and configured.`, + `All copy commands failed. "${primaryMsg}", "${fallbackMsg}". `, ); } }