From 8877c85278a0ece6f7bddddf203b877760841216 Mon Sep 17 00:00:00 2001 From: Tommaso Sciortino Date: Mon, 17 Nov 2025 15:48:33 -0800 Subject: [PATCH] Right click to paste in Alternate Buffer mode (#13234) --- docs/cli/keyboard-shortcuts.md | 2 +- package-lock.json | 99 ++++- packages/cli/package.json | 1 + packages/cli/src/config/keyBindings.ts | 8 +- .../src/ui/components/InputPrompt.test.tsx | 30 ++ .../cli/src/ui/components/InputPrompt.tsx | 29 +- .../src/ui/components/shared/text-buffer.ts | 8 + packages/cli/src/ui/keyMatchers.test.ts | 4 +- .../cli/src/ui/utils/commandUtils.test.ts | 407 ++---------------- packages/cli/src/ui/utils/commandUtils.ts | 107 +---- 10 files changed, 172 insertions(+), 523 deletions(-) diff --git a/docs/cli/keyboard-shortcuts.md b/docs/cli/keyboard-shortcuts.md index f44a1c7d14..a3d021cbd8 100644 --- a/docs/cli/keyboard-shortcuts.md +++ b/docs/cli/keyboard-shortcuts.md @@ -87,7 +87,7 @@ available combinations. | Action | Keys | | ---------------------------------------------- | ---------- | | Open the current prompt in an external editor. | `Ctrl + X` | -| Paste an image from the clipboard. | `Ctrl + V` | +| Paste from the clipboard. | `Ctrl + V` | #### App Controls diff --git a/package-lock.json b/package-lock.json index d7953ee7b6..fe013b19d3 100644 --- a/package-lock.json +++ b/package-lock.json @@ -2301,6 +2301,7 @@ "integrity": "sha512-t54CUOsFMappY1Jbzb7fetWeO0n6K0k/4+/ZpkS+3Joz8I4VcvY9OiEBFRYISqaI2fq5sCiPtAjRDOzVYG8m+Q==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@octokit/auth-token": "^6.0.0", "@octokit/graphql": "^9.0.2", @@ -2481,6 +2482,7 @@ "resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.0.tgz", "integrity": "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg==", "license": "Apache-2.0", + "peer": true, "engines": { "node": ">=8.0.0" } @@ -2514,6 +2516,7 @@ "resolved": "https://registry.npmjs.org/@opentelemetry/core/-/core-2.0.1.tgz", "integrity": "sha512-MaZk9SJIDgo1peKevlbhP6+IwIiNPNmswNL4AF0WaQJLbHXjr9SrZMgS12+iqr9ToV4ZVosCcc0f8Rg67LXjxw==", "license": "Apache-2.0", + "peer": true, "dependencies": { "@opentelemetry/semantic-conventions": "^1.29.0" }, @@ -2882,6 +2885,7 @@ "resolved": "https://registry.npmjs.org/@opentelemetry/resources/-/resources-2.0.1.tgz", "integrity": "sha512-dZOB3R6zvBwDKnHDTB4X1xtMArB/d324VsbiPkX/Yu0Q8T2xceRthoIVFhJdvgVM2QhGVUyX9tzwiNxGtoBJUw==", "license": "Apache-2.0", + "peer": true, "dependencies": { "@opentelemetry/core": "2.0.1", "@opentelemetry/semantic-conventions": "^1.29.0" @@ -2915,6 +2919,7 @@ "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-metrics/-/sdk-metrics-2.0.1.tgz", "integrity": "sha512-wf8OaJoSnujMAHWR3g+/hGvNcsC16rf9s1So4JlMiFaFHiE4HpIA3oUh+uWZQ7CNuK8gVW/pQSkgoa5HkkOl0g==", "license": "Apache-2.0", + "peer": true, "dependencies": { "@opentelemetry/core": "2.0.1", "@opentelemetry/resources": "2.0.1" @@ -2967,6 +2972,7 @@ "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-trace-base/-/sdk-trace-base-2.0.1.tgz", "integrity": "sha512-xYLlvk/xdScGx1aEqvxLwf6sXQLXCjk3/1SQT9X9AoN5rXRhkdvIFShuNNmtTEPRBqcsMbS4p/gJLNI2wXaDuQ==", "license": "Apache-2.0", + "peer": true, "dependencies": { "@opentelemetry/core": "2.0.1", "@opentelemetry/resources": "2.0.1", @@ -4148,6 +4154,7 @@ "integrity": "sha512-6mDvHUFSjyT2B2yeNx2nUgMxh9LtOWvkhIU3uePn2I2oyNymUAX1NIsdgviM4CH+JSrp2D2hsMvJOkxY+0wNRA==", "devOptional": true, "license": "MIT", + "peer": true, "dependencies": { "csstype": "^3.0.2" } @@ -4435,6 +4442,7 @@ "integrity": "sha512-6sMvZePQrnZH2/cJkwRpkT7DxoAWh+g6+GFRK6bV3YQo7ogi3SX5rgF6099r5Q53Ma5qeT7LGmOmuIutF4t3lA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.35.0", "@typescript-eslint/types": "8.35.0", @@ -5202,6 +5210,7 @@ "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "license": "MIT", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -5556,8 +5565,7 @@ "version": "1.1.1", "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz", "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/array-includes": { "version": "3.1.9", @@ -6510,6 +6518,24 @@ "node": ">= 12" } }, + "node_modules/clipboardy": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/clipboardy/-/clipboardy-5.0.0.tgz", + "integrity": "sha512-MQfKHaD09eP80Pev4qBxZLbxJK/ONnqfSYAPlCmPh+7BDboYtO/3BmB6HGzxDIT0SlTRc2tzS8lQqfcdLtZ0Kg==", + "license": "MIT", + "dependencies": { + "execa": "^9.6.0", + "is-wayland": "^0.1.0", + "is-wsl": "^3.1.0", + "is64bit": "^2.0.0" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/cliui": { "version": "8.0.1", "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", @@ -6803,7 +6829,6 @@ "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz", "integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==", "license": "MIT", - "peer": true, "dependencies": { "safe-buffer": "5.2.1" }, @@ -7810,6 +7835,7 @@ "integrity": "sha512-GsGizj2Y1rCWDu6XoEekL3RLilp0voSePurjZIkxL3wlm5o5EC9VpgaP7lrCvjnkuLvzFBQWB3vWB3K5KQTveQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.2.0", "@eslint-community/regexpp": "^4.12.1", @@ -8399,7 +8425,6 @@ "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.1.tgz", "integrity": "sha512-6DnInpx7SJ2AK3+CTUE/ZM0vWTUboZCegxhC2xiIydHR9jNuTAASBrfEpHhiGOZw/nX51bHt6YQl8jsGo4y/0w==", "license": "MIT", - "peer": true, "engines": { "node": ">= 0.6" } @@ -8409,7 +8434,6 @@ "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", "license": "MIT", - "peer": true, "dependencies": { "ms": "2.0.0" } @@ -8419,7 +8443,6 @@ "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.1.tgz", "integrity": "sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==", "license": "MIT", - "peer": true, "engines": { "node": ">= 0.8" } @@ -8649,7 +8672,6 @@ "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.1.tgz", "integrity": "sha512-6BN9trH7bp3qvnrRyzsBz+g3lZxTNZTbVO2EV1CS0WIcDbawYVdYvGflME/9QP0h0pYlCDBCTjYa9nZzMDpyxQ==", "license": "MIT", - "peer": true, "dependencies": { "debug": "2.6.9", "encodeurl": "~2.0.0", @@ -8668,7 +8690,6 @@ "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", "license": "MIT", - "peer": true, "dependencies": { "ms": "2.0.0" } @@ -8677,15 +8698,13 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/finalhandler/node_modules/statuses": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.1.tgz", "integrity": "sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==", "license": "MIT", - "peer": true, "engines": { "node": ">= 0.8" } @@ -9890,6 +9909,7 @@ "resolved": "https://registry.npmjs.org/@jrichman/ink/-/ink-6.4.3.tgz", "integrity": "sha512-2qm05tjtdia+d1gD7LQjPJyCPJluKDuR5B+FI3ZZXshFoU1igZBFvXs2++x9OT6d9755q+gkRPOdtH8jzx5MiQ==", "license": "MIT", + "peer": true, "dependencies": { "@alcalzone/ansi-tokenize": "^0.2.1", "ansi-escapes": "^7.0.0", @@ -10535,6 +10555,18 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/is-wayland": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/is-wayland/-/is-wayland-0.1.0.tgz", + "integrity": "sha512-QkbMsWkIfkrzOPxenwye0h56iAXirZYHG9eHVPb22fO9y+wPbaX/CHacOWBa/I++4ohTcByimhM1/nyCsH8KNA==", + "license": "MIT", + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/is-weakmap": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/is-weakmap/-/is-weakmap-2.0.2.tgz", @@ -10596,6 +10628,21 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/is64bit": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/is64bit/-/is64bit-2.0.0.tgz", + "integrity": "sha512-jv+8jaWCl0g2lSBkNSVXdzfBA0npK1HGC2KtWM9FumFRoGS94g3NbCCLVnCYHLjp4GrW2KZeeSTMo5ddtznmGw==", + "license": "MIT", + "dependencies": { + "system-architecture": "^0.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/isarray": { "version": "2.0.5", "resolved": "https://registry.npmjs.org/isarray/-/isarray-2.0.5.tgz", @@ -12941,8 +12988,7 @@ "version": "0.1.12", "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.12.tgz", "integrity": "sha512-RA1GjUVMnvYFxuqovrEqZoxxW5NUZqbwKtYz/Tt7nXerk0LbLblQmrsgdeOxV5SFHf0UDggjS/bSeOZwt1pmEQ==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/path-type": { "version": "3.0.0", @@ -13476,6 +13522,7 @@ "resolved": "https://registry.npmjs.org/react/-/react-19.2.0.tgz", "integrity": "sha512-tmbWg6W31tQLeB5cdIBOicJDJRR2KzXsV7uSK9iNfLWQ5bIZfxuPEHp7M8wiHyHnn0DD1i7w3Zmin0FtkrwoCQ==", "license": "MIT", + "peer": true, "engines": { "node": ">=0.10.0" } @@ -13486,6 +13533,7 @@ "integrity": "sha512-ePrwPfxAnB+7hgnEr8vpKxL9cmnp7F322t8oqcPshbIQQhDKgFDW4tjhF2wjVbdXF9O/nyuy3sQWd9JGpiLPvA==", "devOptional": true, "license": "MIT", + "peer": true, "dependencies": { "shell-quote": "^1.6.1", "ws": "^7" @@ -15172,6 +15220,18 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/system-architecture": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/system-architecture/-/system-architecture-0.1.0.tgz", + "integrity": "sha512-ulAk51I9UVUyJgxlv9M6lFot2WP3e7t8Kz9+IS6D4rVba1tR9kON+Ey69f+1R4Q8cd45Lod6a4IcJIxnzGc/zA==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/table": { "version": "6.9.0", "resolved": "https://registry.npmjs.org/table/-/table-6.9.0.tgz", @@ -15546,6 +15606,7 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -15710,7 +15771,8 @@ "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", "dev": true, - "license": "0BSD" + "license": "0BSD", + "peer": true }, "node_modules/tsx": { "version": "4.20.3", @@ -15718,6 +15780,7 @@ "integrity": "sha512-qjbnuR9Tr+FJOMBqJCW5ehvIo/buZq7vH7qD7JziU98h6l3qGy0a/yPFjwO+y0/T7GFpNgNAvEcPPVfyT8rrPQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "esbuild": "~0.25.0", "get-tsconfig": "^4.7.5" @@ -15902,6 +15965,7 @@ "integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==", "dev": true, "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -16063,7 +16127,6 @@ "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz", "integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==", "license": "MIT", - "peer": true, "engines": { "node": ">= 0.4.0" } @@ -16119,6 +16182,7 @@ "integrity": "sha512-ZWyE8YXEXqJrrSLvYgrRP7p62OziLW7xI5HYGWFzOvupfAlrLvURSzv/FyGyy0eidogEM3ujU+kUG1zuHgb6Ug==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.5.0", @@ -16235,6 +16299,7 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -16248,6 +16313,7 @@ "integrity": "sha512-LUCP5ev3GURDysTWiP47wRRUpLKMOfPh+yKTx3kVIEiu5KOMeqzpnYNsKyOoVrULivR8tLcks4+lga33Whn90A==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@types/chai": "^5.2.2", "@vitest/expect": "3.2.4", @@ -16945,6 +17011,7 @@ "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", "license": "MIT", + "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } @@ -17258,6 +17325,7 @@ "@modelcontextprotocol/sdk": "^1.15.1", "@types/update-notifier": "^6.0.8", "ansi-regex": "^6.2.2", + "clipboardy": "^5.0.0", "command-exists": "^1.2.9", "comment-json": "^4.2.5", "diff": "^7.0.0", @@ -17485,6 +17553,7 @@ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, diff --git a/packages/cli/package.json b/packages/cli/package.json index f9c9076f99..f3d4e83968 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -34,6 +34,7 @@ "@modelcontextprotocol/sdk": "^1.15.1", "@types/update-notifier": "^6.0.8", "ansi-regex": "^6.2.2", + "clipboardy": "^5.0.0", "command-exists": "^1.2.9", "comment-json": "^4.2.5", "diff": "^7.0.0", diff --git a/packages/cli/src/config/keyBindings.ts b/packages/cli/src/config/keyBindings.ts index 42b1ea334e..497b359f2e 100644 --- a/packages/cli/src/config/keyBindings.ts +++ b/packages/cli/src/config/keyBindings.ts @@ -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> = { [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.', diff --git a/packages/cli/src/ui/components/InputPrompt.test.tsx b/packages/cli/src/ui/components/InputPrompt.test.tsx index 12df6a6b28..c7c1a35268 100644 --- a/packages/cli/src/ui/components/InputPrompt.test.tsx +++ b/packages/cli/src/ui/components/InputPrompt.test.tsx @@ -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( + , + ); + + 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', diff --git a/packages/cli/src/ui/components/InputPrompt.tsx b/packages/cli/src/ui/components/InputPrompt.tsx index 5e908614db..629b50a7a9 100644 --- a/packages/cli/src/ui/components/InputPrompt.tsx +++ b/packages/cli/src/ui/components/InputPrompt.tsx @@ -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 = ({ }, [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 = ({ // 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 = ({ // 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 = ({ 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 = ({ 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 = ({ handleSubmit, shellHistory, reverseSearchCompletion, - handleClipboardImage, + handleClipboardPaste, resetCompletionState, showEscapePrompt, resetEscapeState, diff --git a/packages/cli/src/ui/components/shared/text-buffer.ts b/packages/cli/src/ui/components/shared/text-buffer.ts index 70f30abc03..d711a57477 100644 --- a/packages/cli/src/ui/components/shared/text-buffer.ts +++ b/packages/cli/src/ui/components/shared/text-buffer.ts @@ -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; diff --git a/packages/cli/src/ui/keyMatchers.test.ts b/packages/cli/src/ui/keyMatchers.test.ts index caf1216579..0982e84b2a 100644 --- a/packages/cli/src/ui/keyMatchers.test.ts +++ b/packages/cli/src/ui/keyMatchers.test.ts @@ -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 })], }, diff --git a/packages/cli/src/ui/utils/commandUtils.test.ts b/packages/cli/src/ui/utils/commandUtils.test.ts index 660ec60ddb..722bcef18d 100644 --- a/packages/cli/src/ui/utils/commandUtils.test.ts +++ b/packages/cli/src/ui/utils/commandUtils.test.ts @@ -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); + + // 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; - }); - - 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; - }); - - 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; - }); - 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; - }); - - 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', + ); }); }); diff --git a/packages/cli/src/ui/utils/commandUtils.ts b/packages/cli/src/ui/utils/commandUtils.ts index 3b3a0437cd..3169db8ade 100644 --- a/packages/cli/src/ui/utils/commandUtils.ts +++ b/packages/cli/src/ui/utils/commandUtils.ts @@ -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 => { - const run = (cmd: string, args: string[], options?: SpawnOptions) => - new Promise((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 => {