Right click to paste in Alternate Buffer mode (#13234)

This commit is contained in:
Tommaso Sciortino
2025-11-17 15:48:33 -08:00
committed by GitHub
parent 1d1bdc57ce
commit 8877c85278
10 changed files with 172 additions and 523 deletions
+4 -4
View File
@@ -54,7 +54,7 @@ export enum Command {
// External tools
OPEN_EXTERNAL_EDITOR = 'openExternalEditor',
PASTE_CLIPBOARD_IMAGE = 'pasteClipboardImage',
PASTE_CLIPBOARD = 'pasteClipboard',
// App level bindings
SHOW_ERROR_DETAILS = 'showErrorDetails',
@@ -192,7 +192,7 @@ export const defaultKeyBindings: KeyBindingConfig = {
{ key: 'x', ctrl: true },
{ sequence: '\x18', ctrl: true },
],
[Command.PASTE_CLIPBOARD_IMAGE]: [{ key: 'v', ctrl: true }],
[Command.PASTE_CLIPBOARD]: [{ key: 'v', ctrl: true }],
// App level bindings
[Command.SHOW_ERROR_DETAILS]: [{ key: 'f12' }],
@@ -292,7 +292,7 @@ export const commandCategories: readonly CommandCategory[] = [
},
{
title: 'External Tools',
commands: [Command.OPEN_EXTERNAL_EDITOR, Command.PASTE_CLIPBOARD_IMAGE],
commands: [Command.OPEN_EXTERNAL_EDITOR, Command.PASTE_CLIPBOARD],
},
{
title: 'App Controls',
@@ -344,7 +344,7 @@ export const commandDescriptions: Readonly<Record<Command, string>> = {
[Command.NEWLINE]: 'Insert a newline without submitting.',
[Command.OPEN_EXTERNAL_EDITOR]:
'Open the current prompt in an external editor.',
[Command.PASTE_CLIPBOARD_IMAGE]: 'Paste an image from the clipboard.',
[Command.PASTE_CLIPBOARD]: 'Paste from the clipboard.',
[Command.SHOW_ERROR_DETAILS]: 'Toggle detailed error information.',
[Command.SHOW_FULL_TODOS]: 'Toggle the full TODO list.',
[Command.TOGGLE_IDE_CONTEXT_DETAIL]: 'Toggle IDE context details.',
@@ -24,6 +24,7 @@ import type { UseInputHistoryReturn } from '../hooks/useInputHistory.js';
import { useInputHistory } from '../hooks/useInputHistory.js';
import type { UseReverseSearchCompletionReturn } from '../hooks/useReverseSearchCompletion.js';
import { useReverseSearchCompletion } from '../hooks/useReverseSearchCompletion.js';
import clipboardy from 'clipboardy';
import * as clipboardUtils from '../utils/clipboardUtils.js';
import { useKittyKeyboardProtocol } from '../hooks/useKittyKeyboardProtocol.js';
import { createMockCommandContext } from '../../test-utils/mockCommandContext.js';
@@ -35,6 +36,7 @@ vi.mock('../hooks/useShellHistory.js');
vi.mock('../hooks/useCommandCompletion.js');
vi.mock('../hooks/useInputHistory.js');
vi.mock('../hooks/useReverseSearchCompletion.js');
vi.mock('clipboardy');
vi.mock('../utils/clipboardUtils.js');
vi.mock('../hooks/useKittyKeyboardProtocol.js');
@@ -146,6 +148,7 @@ describe('InputPrompt', () => {
deleteWordLeft: vi.fn(),
deleteWordRight: vi.fn(),
visualToLogicalMap: [[0, 0]],
getOffset: vi.fn().mockReturnValue(0),
} as unknown as TextBuffer;
mockShellHistory = {
@@ -505,6 +508,7 @@ describe('InputPrompt', () => {
// Set initial text and cursor position
mockBuffer.text = 'Hello world';
mockBuffer.cursor = [0, 5]; // Cursor after "Hello"
vi.mocked(mockBuffer.getOffset).mockReturnValue(5);
mockBuffer.lines = ['Hello world'];
mockBuffer.replaceRangeByOffset = vi.fn();
@@ -559,6 +563,32 @@ describe('InputPrompt', () => {
});
});
describe('clipboard text paste', () => {
it('should insert text from clipboard on Ctrl+V', async () => {
vi.mocked(clipboardUtils.clipboardHasImage).mockResolvedValue(false);
vi.mocked(clipboardy.read).mockResolvedValue('pasted text');
vi.mocked(mockBuffer.replaceRangeByOffset).mockClear();
const { stdin, unmount } = renderWithProviders(
<InputPrompt {...props} />,
);
await act(async () => {
stdin.write('\x16'); // Ctrl+V
});
await waitFor(() => {
expect(clipboardy.read).toHaveBeenCalled();
expect(mockBuffer.replaceRangeByOffset).toHaveBeenCalledWith(
expect.any(Number),
expect.any(Number),
'pasted text',
);
});
unmount();
});
});
it.each([
{
name: 'should complete a partial parent command',
+15 -14
View File
@@ -5,6 +5,7 @@
*/
import type React from 'react';
import clipboardy from 'clipboardy';
import { useCallback, useEffect, useState, useRef } from 'react';
import { Box, Text, getBoundingBox, type DOMElement } from 'ink';
import { SuggestionsDisplay, MAX_WIDTH } from './SuggestionsDisplay.js';
@@ -315,7 +316,7 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
}, [buffer, popAllMessages, inputHistory]);
// Handle clipboard image pasting with Ctrl+V
const handleClipboardImage = useCallback(async () => {
const handleClipboardPaste = useCallback(async () => {
try {
if (await clipboardHasImage()) {
const imagePath = await saveClipboardImage(config.getTargetDir());
@@ -331,14 +332,7 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
// Insert @path reference at cursor position
const insertText = `@${relativePath}`;
const currentText = buffer.text;
const [row, col] = buffer.cursor;
// Calculate offset from row/col
let offset = 0;
for (let i = 0; i < row; i++) {
offset += buffer.lines[i].length + 1; // +1 for newline
}
offset += col;
const offset = buffer.getOffset();
// Add spaces around the path if needed
let textToInsert = insertText;
@@ -355,8 +349,13 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
// Insert at cursor position
buffer.replaceRangeByOffset(offset, offset, textToInsert);
return;
}
}
const textToInsert = await clipboardy.read();
const offset = buffer.getOffset();
buffer.replaceRangeByOffset(offset, offset, textToInsert);
} catch (error) {
console.error('Error handling clipboard image:', error);
}
@@ -381,10 +380,12 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
buffer.moveToVisualPosition(visualRow, relX);
return true;
}
} else if (event.name === 'right-release') {
handleClipboardPaste();
}
return false;
},
[buffer],
[buffer, handleClipboardPaste],
);
useMouse(handleMouse, { isActive: focus && !isEmbeddedShellFocused });
@@ -772,9 +773,9 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
return;
}
// Ctrl+V for clipboard image paste
if (keyMatchers[Command.PASTE_CLIPBOARD_IMAGE](key)) {
handleClipboardImage();
// Ctrl+V for clipboard paste
if (keyMatchers[Command.PASTE_CLIPBOARD](key)) {
handleClipboardPaste();
return;
}
@@ -805,7 +806,7 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
handleSubmit,
shellHistory,
reverseSearchCompletion,
handleClipboardImage,
handleClipboardPaste,
resetCompletionState,
showEscapePrompt,
resetEscapeState,
@@ -2040,6 +2040,11 @@ export function useTextBuffer({
[visualLayout],
);
const getOffset = useCallback(
(): number => logicalPosToOffset(lines, cursorRow, cursorCol),
[lines, cursorRow, cursorCol],
);
const returnValue: TextBuffer = useMemo(
() => ({
lines,
@@ -2065,6 +2070,7 @@ export function useTextBuffer({
replaceRange,
replaceRangeByOffset,
moveToOffset,
getOffset,
moveToVisualPosition,
deleteWordLeft,
deleteWordRight,
@@ -2129,6 +2135,7 @@ export function useTextBuffer({
replaceRange,
replaceRangeByOffset,
moveToOffset,
getOffset,
moveToVisualPosition,
deleteWordLeft,
deleteWordRight,
@@ -2283,6 +2290,7 @@ export interface TextBuffer {
endOffset: number,
replacementText: string,
) => void;
getOffset: () => number;
moveToOffset(offset: number): void;
moveToVisualPosition(visualRow: number, visualCol: number): void;
+2 -2
View File
@@ -60,7 +60,7 @@ describe('keyMatchers', () => {
key.name === 'return' && (key.ctrl || key.meta || key.paste),
[Command.OPEN_EXTERNAL_EDITOR]: (key: Key) =>
key.ctrl && (key.name === 'x' || key.sequence === '\x18'),
[Command.PASTE_CLIPBOARD_IMAGE]: (key: Key) => key.ctrl && key.name === 'v',
[Command.PASTE_CLIPBOARD]: (key: Key) => key.ctrl && key.name === 'v',
[Command.SHOW_ERROR_DETAILS]: (key: Key) => key.name === 'f12',
[Command.SHOW_FULL_TODOS]: (key: Key) => key.ctrl && key.name === 't',
[Command.TOGGLE_IDE_CONTEXT_DETAIL]: (key: Key) =>
@@ -268,7 +268,7 @@ describe('keyMatchers', () => {
negative: [createKey('x'), createKey('c', { ctrl: true })],
},
{
command: Command.PASTE_CLIPBOARD_IMAGE,
command: Command.PASTE_CLIPBOARD,
positive: [createKey('v', { ctrl: true })],
negative: [createKey('v'), createKey('c', { ctrl: true })],
},
+24 -383
View File
@@ -6,8 +6,8 @@
import type { Mock } from 'vitest';
import { vi, describe, it, expect, beforeEach } from 'vitest';
import type { spawn, SpawnOptions } from 'node:child_process';
import { EventEmitter } from 'node:events';
import clipboardy from 'clipboardy';
import {
isAtCommand,
isSlashCommand,
@@ -15,6 +15,13 @@ import {
getUrlOpenCommand,
} from './commandUtils.js';
// Mock clipboardy
vi.mock('clipboardy', () => ({
default: {
write: vi.fn(),
},
}));
// Mock child_process
vi.mock('child_process');
@@ -44,6 +51,7 @@ interface MockChildProcess extends EventEmitter {
describe('commandUtils', () => {
let mockSpawn: Mock;
let mockChild: MockChildProcess;
let mockClipboardyWrite: Mock;
beforeEach(async () => {
vi.clearAllMocks();
@@ -67,6 +75,9 @@ describe('commandUtils', () => {
}) as MockChildProcess;
mockSpawn.mockReturnValue(mockChild as unknown as ReturnType<typeof spawn>);
// Setup clipboardy mock
mockClipboardyWrite = clipboardy.write as Mock;
});
describe('isAtCommand', () => {
@@ -128,393 +139,23 @@ describe('commandUtils', () => {
});
describe('copyToClipboard', () => {
describe('on macOS (darwin)', () => {
beforeEach(() => {
mockProcess.platform = 'darwin';
});
it('should successfully copy text to clipboard using clipboardy', async () => {
const testText = 'Hello, world!';
mockClipboardyWrite.mockResolvedValue(undefined);
it('should successfully copy text to clipboard using pbcopy', async () => {
const testText = 'Hello, world!';
await copyToClipboard(testText);
// 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');
});
expect(mockClipboardyWrite).toHaveBeenCalledWith(testText);
});
describe('on Windows (win32)', () => {
beforeEach(() => {
mockProcess.platform = 'win32';
});
it('should propagate errors from clipboardy', async () => {
const testText = 'Hello, world!';
const error = new Error('Clipboard error');
mockClipboardyWrite.mockRejectedValue(error);
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!';
const linuxOptions: SpawnOptions = {
stdio: ['pipe', 'inherit', 'pipe'],
};
setTimeout(() => {
mockChild.emit('close', 0);
}, 0);
await copyToClipboard(testText);
expect(mockSpawn).toHaveBeenCalledWith(
'xclip',
['-selection', 'clipboard'],
linuxOptions,
);
expect(mockChild.stdin.write).toHaveBeenCalledWith(testText);
expect(mockChild.stdin.end).toHaveBeenCalled();
});
it('should successfully copy on Linux when receiving an "exit" event', async () => {
const testText = 'Hello, linux!';
const linuxOptions: SpawnOptions = {
stdio: ['pipe', 'inherit', 'pipe'],
};
// Simulate successful execution via 'exit' event
setTimeout(() => {
mockChild.emit('exit', 0);
}, 0);
await copyToClipboard(testText);
expect(mockSpawn).toHaveBeenCalledWith(
'xclip',
['-selection', 'clipboard'],
linuxOptions,
);
expect(mockChild.stdin.write).toHaveBeenCalledWith(testText);
expect(mockChild.stdin.end).toHaveBeenCalled();
});
it('should handle command failure on Linux via "exit" event', async () => {
const testText = 'Hello, linux!';
let callCount = 0;
mockSpawn.mockImplementation(() => {
const child = Object.assign(new EventEmitter(), {
stdin: Object.assign(new EventEmitter(), {
write: vi.fn(),
end: vi.fn(),
destroy: vi.fn(),
}),
stdout: Object.assign(new EventEmitter(), {
destroy: vi.fn(),
}),
stderr: Object.assign(new EventEmitter(), {
destroy: vi.fn(),
}),
});
setTimeout(() => {
if (callCount === 0) {
// First call (xclip) fails with 'exit'
child.stderr.emit('data', 'xclip failed');
child.emit('exit', 127);
} else {
// Second call (xsel) also fails with 'exit'
child.stderr.emit('data', 'xsel failed');
child.emit('exit', 127);
}
callCount++;
}, 0);
return child as unknown as ReturnType<typeof spawn>;
});
await expect(copyToClipboard(testText)).rejects.toThrow(
'All copy commands failed. "\'xclip\' exited with code 127: xclip failed", "\'xsel\' exited with code 127: xsel failed".',
);
expect(mockSpawn).toHaveBeenCalledTimes(2);
});
it('should fall back to xsel when xclip fails', async () => {
const testText = 'Hello, world!';
let callCount = 0;
const linuxOptions: SpawnOptions = {
stdio: ['pipe', 'inherit', 'pipe'],
};
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
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) 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'],
linuxOptions,
);
expect(mockSpawn).toHaveBeenNthCalledWith(
2,
'xsel',
['--clipboard', '--input'],
linuxOptions,
);
});
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(), {
stdin: Object.assign(new EventEmitter(), {
write: vi.fn(),
end: vi.fn(),
}),
stderr: new EventEmitter(),
}) as MockChildProcess;
setTimeout(() => {
if (callCount === 0) {
// 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 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<typeof spawn>;
});
await expect(copyToClipboard(testText)).rejects.toThrow(
'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<typeof spawn>;
});
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,
);
});
});
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);
});
await expect(copyToClipboard(testText)).rejects.toThrow(
'Clipboard error',
);
});
});
+3 -104
View File
@@ -5,8 +5,7 @@
*/
import { debugLogger } from '@google/gemini-cli-core';
import type { SpawnOptions } from 'node:child_process';
import { spawn } from 'node:child_process';
import clipboardy from 'clipboardy';
/**
* Checks if a query string potentially represents an '@' command.
@@ -45,109 +44,9 @@ export const isSlashCommand = (query: string): boolean => {
return true;
};
// Copies a string snippet to the clipboard for different platforms
// Copies a string snippet to the clipboard
export const copyToClipboard = async (text: string): Promise<void> => {
const run = (cmd: string, args: string[], options?: SpawnOptions) =>
new Promise<void>((resolve, reject) => {
const child = options ? spawn(cmd, args, options) : spawn(cmd, args);
let stderr = '';
if (child.stderr) {
child.stderr.on('data', (chunk) => (stderr += chunk.toString()));
}
const copyResult = (code: number | null) => {
if (code === 0) return resolve();
const errorMsg = stderr.trim();
reject(
new Error(
`'${cmd}' exited with code ${code}${errorMsg ? `: ${errorMsg}` : ''}`,
),
);
};
// The 'exit' event workaround is only needed for the specific stdio
// configuration used on Linux.
if (process.platform === 'linux') {
child.on('exit', (code) => {
child.stdin?.destroy();
child.stdout?.destroy();
child.stderr?.destroy();
copyResult(code);
});
}
child.on('error', reject);
// For win32/darwin, 'close' is the safest event, guaranteeing all I/O is flushed.
// For Linux, this acts as a fallback. This is safe because the promise
// can only be settled once.
child.on('close', (code) => {
copyResult(code);
});
if (child.stdin) {
child.stdin.on('error', reject);
child.stdin.write(text);
child.stdin.end();
} else {
reject(new Error('Child process has no stdin stream to write to.'));
}
});
// Configure stdio for Linux clipboard commands.
// - stdin: 'pipe' to write the text that needs to be copied.
// - stdout: 'inherit' since we don't need to capture the command's output on success.
// - stderr: 'pipe' to capture error messages (e.g., "command not found") for better error handling.
const linuxOptions: SpawnOptions = { stdio: ['pipe', 'inherit', 'pipe'] };
switch (process.platform) {
case 'win32':
return run('clip', []);
case 'darwin':
return run('pbcopy', []);
case 'linux':
try {
await run('xclip', ['-selection', 'clipboard'], linuxOptions);
} catch (primaryError) {
try {
// If xclip fails for any reason, try xsel as a fallback.
await run('xsel', ['--clipboard', '--input'], linuxOptions);
} catch (fallbackError) {
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);
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. "${primaryMsg}", "${fallbackMsg}". `,
);
}
}
return;
default:
throw new Error(`Unsupported platform: ${process.platform}`);
}
await clipboardy.write(text);
};
export const getUrlOpenCommand = (): string => {