From 679a587c9de42544cec8085cf7de2d9f10bcc9d3 Mon Sep 17 00:00:00 2001 From: Simon Klee Date: Mon, 3 Aug 2026 08:15:58 +0200 Subject: [PATCH] feat(tui): use OpenTUI clipboard service --- bun.lock | 89 ++++----- package.json | 12 +- packages/plugin/package.json | 6 +- packages/tui/package.json | 1 - packages/tui/src/app.tsx | 22 ++- packages/tui/src/clipboard.ts | 176 ++++++------------ packages/tui/src/component/dialog-debug.tsx | 2 +- .../tui/src/component/dialog-integration.tsx | 2 +- packages/tui/src/component/dialog-mcp.tsx | 1 - .../tui/src/component/error-component.tsx | 33 +++- packages/tui/src/component/prompt/index.tsx | 13 +- packages/tui/src/context/clipboard.tsx | 17 +- .../tui/src/routes/session/dialog-message.tsx | 8 +- packages/tui/src/routes/session/form.tsx | 4 +- packages/tui/src/routes/session/index.tsx | 6 +- packages/tui/src/ui/dialog.tsx | 2 +- packages/tui/src/util/selection.ts | 2 +- packages/tui/test/cli/tui/form.test.tsx | 3 + packages/tui/test/clipboard.test.ts | 132 +++++++++++-- packages/tui/test/fixture/tui-environment.tsx | 13 +- packages/tui/test/prompt/clipboard.test.ts | 97 ++++++++++ 21 files changed, 404 insertions(+), 237 deletions(-) create mode 100644 packages/tui/test/prompt/clipboard.test.ts diff --git a/bun.lock b/bun.lock index 215c55c66c7..649d5d21cc9 100644 --- a/bun.lock +++ b/bun.lock @@ -88,6 +88,7 @@ "@tanstack/solid-query": "5.91.4", "@tanstack/solid-virtual": "catalog:", "@thisbeyond/solid-dnd": "0.7.5", + "diff": "catalog:", "effect": "catalog:", "fuzzysort": "catalog:", "ghostty-web": "github:anomalyco/ghostty-web#83c0a07b8628b748aed073b232cb4b52a6ca11c1", @@ -617,9 +618,9 @@ }, "peerDependencies": { "@opencode-ai/theme": "workspace:*", - "@opentui/core": ">=0.4.5", - "@opentui/keymap": ">=0.4.5", - "@opentui/solid": ">=0.4.5", + "@opentui/core": "0.0.0-20260803-1395a054", + "@opentui/keymap": "0.0.0-20260803-1395a054", + "@opentui/solid": "0.0.0-20260803-1395a054", "solid-js": ">=1.9.0", }, "optionalPeers": [ @@ -910,8 +911,6 @@ "@opentui/keymap": "catalog:", "@opentui/solid": "catalog:", "@solid-primitives/event-bus": "1.1.2", - "clipboardy": "4.0.0", - "diff": "catalog:", "effect": "catalog:", "fuzzysort": "catalog:", "get-east-asian-width": "catalog:", @@ -1097,9 +1096,9 @@ "@ai-sdk/google@3.0.73": "patches/@ai-sdk%2Fgoogle@3.0.73.patch", }, "overrides": { - "@opentui/core": "catalog:", - "@opentui/keymap": "catalog:", - "@opentui/solid": "catalog:", + "@opentui/core": "0.0.0-20260803-1395a054", + "@opentui/keymap": "0.0.0-20260803-1395a054", + "@opentui/solid": "0.0.0-20260803-1395a054", "@types/bun": "catalog:", "@types/node": "catalog:", "effect": "catalog:", @@ -1117,9 +1116,9 @@ "@npmcli/arborist": "9.4.0", "@octokit/rest": "22.0.0", "@openauthjs/openauth": "0.0.0-20250322224806", - "@opentui/core": "0.4.5", - "@opentui/keymap": "0.4.5", - "@opentui/solid": "0.4.5", + "@opentui/core": "0.0.0-20260803-1395a054", + "@opentui/keymap": "0.0.0-20260803-1395a054", + "@opentui/solid": "0.0.0-20260803-1395a054", "@pierre/diffs": "1.2.10", "@playwright/test": "1.59.1", "@sentry/solid": "10.36.0", @@ -2158,27 +2157,27 @@ "@opentelemetry/semantic-conventions": ["@opentelemetry/semantic-conventions@1.43.0", "", {}, "sha512-eSYWTm620tTk45EKSedaUL8MFYI8hW164hIXsgIHyxu3VobUB3fFCu5t0hQby6OoWRPsG1KkKUG2M5UadiLiVg=="], - "@opentui/core": ["@opentui/core@0.4.5", "", { "dependencies": { "bun-ffi-structs": "0.2.4", "diff": "9.0.0", "marked": "17.0.1", "string-width": "7.2.0", "strip-ansi": "7.1.2" }, "optionalDependencies": { "@opentui/core-darwin-arm64": "0.4.5", "@opentui/core-darwin-x64": "0.4.5", "@opentui/core-linux-arm64": "0.4.5", "@opentui/core-linux-arm64-musl": "0.4.5", "@opentui/core-linux-x64": "0.4.5", "@opentui/core-linux-x64-musl": "0.4.5", "@opentui/core-win32-arm64": "0.4.5", "@opentui/core-win32-x64": "0.4.5" }, "peerDependencies": { "web-tree-sitter": "0.25.10" } }, "sha512-JsgRTPkA6e+Vxmumxai6SElOSlRQkbzNKHlCfemlArRiLhfC1IZ9RXJo2QH4xSu+uBOWAM90uss73/pPlkdEig=="], + "@opentui/core": ["@opentui/core@0.0.0-20260803-1395a054", "", { "dependencies": { "bun-ffi-structs": "0.3.1", "diff": "9.0.0", "marked": "17.0.1", "string-width": "7.2.0", "strip-ansi": "7.1.2" }, "optionalDependencies": { "@opentui/core-darwin-arm64": "0.0.0-20260803-1395a054", "@opentui/core-darwin-x64": "0.0.0-20260803-1395a054", "@opentui/core-linux-arm64": "0.0.0-20260803-1395a054", "@opentui/core-linux-arm64-musl": "0.0.0-20260803-1395a054", "@opentui/core-linux-x64": "0.0.0-20260803-1395a054", "@opentui/core-linux-x64-musl": "0.0.0-20260803-1395a054", "@opentui/core-win32-arm64": "0.0.0-20260803-1395a054", "@opentui/core-win32-x64": "0.0.0-20260803-1395a054" }, "peerDependencies": { "web-tree-sitter": "0.25.10" } }, "sha512-FSx082sVlyiwbt7sSpFE3CxQWY240cGuqwFfiFg6UHniLqZcbFkLerwFfOOJgVzwE9Ss1+3QK+xUZ2faxdq3gw=="], - "@opentui/core-darwin-arm64": ["@opentui/core-darwin-arm64@0.4.5", "", { "os": "darwin", "cpu": "arm64" }, "sha512-8KUG0oRidnR+oW1RSZJ72/PhZLl+qRRMk5U/mieF4c0SJ5V3tYACpBZAKzQfHNd1f7QzD8FHZct1lPpQgtmkWg=="], + "@opentui/core-darwin-arm64": ["@opentui/core-darwin-arm64@0.0.0-20260803-1395a054", "", { "os": "darwin", "cpu": "arm64" }, "sha512-gH9K1DBtdj2LV9Zs80/5kXtJWNorPXKMUoushvo3gf0oRhcukZRtyVssbp/R+1GEJENwBv48UkXB3F/lm8Ijlw=="], - "@opentui/core-darwin-x64": ["@opentui/core-darwin-x64@0.4.5", "", { "os": "darwin", "cpu": "x64" }, "sha512-R2bocsg55gwjOqCp/MWFgFYzRmsduKegB6nzgFAPCvAD/L5Jf30xpWJWFlSg3x8vxe1L9WJ84dfqa4M7mZZ3wA=="], + "@opentui/core-darwin-x64": ["@opentui/core-darwin-x64@0.0.0-20260803-1395a054", "", { "os": "darwin", "cpu": "x64" }, "sha512-5iNqfkyxhvh01EkY/D7PAt7kElWo9eisITfxAA8pc2/pfwIPRl81VeRCDEP7uKMwUx2E+HwZP1x+bs4PG4GLJw=="], - "@opentui/core-linux-arm64": ["@opentui/core-linux-arm64@0.4.5", "", { "os": "linux", "cpu": "arm64" }, "sha512-R4MZ25a4CzOAGVjW9aj1hUfzQGVfCJwrwBDbNs2SXaIvzcZqkxCVtU4FoQ5LsaD0j/BdNQVg2CIfFkFsm1fDuQ=="], + "@opentui/core-linux-arm64": ["@opentui/core-linux-arm64@0.0.0-20260803-1395a054", "", { "os": "linux", "cpu": "arm64" }, "sha512-san/6drDO6FNISL6GL3cDxMctls0s8pcyBvDMY1TrAHBT4ZdMXnxS83KGfA4DmXKUpRZcchf788nvJ48e8Zclw=="], - "@opentui/core-linux-arm64-musl": ["@opentui/core-linux-arm64-musl@0.4.5", "", { "os": "linux", "cpu": "arm64" }, "sha512-ieqdyKI6EIYPalYAETB2wsdP83hr5Ifi+dFnBFUmdEEFHsoKwBmn2S7bsTOYlX7Bg03F4/YPIg+IvRpeC+cUJw=="], + "@opentui/core-linux-arm64-musl": ["@opentui/core-linux-arm64-musl@0.0.0-20260803-1395a054", "", { "os": "linux", "cpu": "arm64" }, "sha512-TpOWMpBmf1BdqG1GDyj1TCiJmYenq0r0BsBDlkixBpmnTXeGT4ywJAGUYOUEJB+qu4vdijJ8kuO5ataKz3giGw=="], - "@opentui/core-linux-x64": ["@opentui/core-linux-x64@0.4.5", "", { "os": "linux", "cpu": "x64" }, "sha512-SNyuQoxMKI1vuJhgxSSW96adWM6LqFl2SoS3GM4tGeneGOanVVG2Y06PvlytXvF4cKik97t0rqkVMRetmOs93w=="], + "@opentui/core-linux-x64": ["@opentui/core-linux-x64@0.0.0-20260803-1395a054", "", { "os": "linux", "cpu": "x64" }, "sha512-8Y8oPI1LZmVezIUu0lGwPG96FImBtLRfE/+9vMsx0njTENTVJBdALD9+L4SauE8+PRiGblKR4IUMVTg5kn3Mgw=="], - "@opentui/core-linux-x64-musl": ["@opentui/core-linux-x64-musl@0.4.5", "", { "os": "linux", "cpu": "x64" }, "sha512-mKVKcIcPiSVVZZsdPSBoWwoa2/TCeQAaMDeHF7PFw2kt5bTXZPP7xxWfRQLCNIcA1eaGl59UuwUWHDR2Ve548Q=="], + "@opentui/core-linux-x64-musl": ["@opentui/core-linux-x64-musl@0.0.0-20260803-1395a054", "", { "os": "linux", "cpu": "x64" }, "sha512-UythgF6YIrsI/kRV2i21NyAJ0sTt07d1K/+uZbfAq2vMgtUjcpIYe2qKqNNel826ak40J6FB1cccFyhl//moVw=="], - "@opentui/core-win32-arm64": ["@opentui/core-win32-arm64@0.4.5", "", { "os": "win32", "cpu": "arm64" }, "sha512-GHTTsqeR45q2Iek9Rb7ty+x/hAKn2jZ1ujlCgPR8LBKyF7h0E1dNFryoZ7ehMc3kJndP1sKn836IemKFqxuDdQ=="], + "@opentui/core-win32-arm64": ["@opentui/core-win32-arm64@0.0.0-20260803-1395a054", "", { "os": "win32", "cpu": "arm64" }, "sha512-hFg41FMidMn4MqCahxwvbpi3aLF3rk6LthSS08XRzrGLFbTtCmB3uxkCsUQHVSWIzGu28Qq845YzYDu3oBoe9A=="], - "@opentui/core-win32-x64": ["@opentui/core-win32-x64@0.4.5", "", { "os": "win32", "cpu": "x64" }, "sha512-Y8T/yXCDGagRGiQrtmuB6AhRcPucKFs/Dre3v8kJwNYqDccI4FzUPKclZ7djfmRZNjl7JUqPhZZP/PwDpQocMg=="], + "@opentui/core-win32-x64": ["@opentui/core-win32-x64@0.0.0-20260803-1395a054", "", { "os": "win32", "cpu": "x64" }, "sha512-ZxJW1X2cIohwPsVXzbG7/xX+KaRMcK46hKNMocx5XAuW6BMKL1Cj3X6BdPByAC1pDx0UQJtvBqNZqTdubFkfaA=="], - "@opentui/keymap": ["@opentui/keymap@0.4.5", "", { "dependencies": { "@opentui/core": "0.4.5" }, "peerDependencies": { "@opentui/react": "0.4.5", "@opentui/solid": "0.4.5", "react": ">=19.2.0", "solid-js": "1.9.12" }, "optionalPeers": ["@opentui/react", "@opentui/solid", "react", "solid-js"] }, "sha512-S1wzKHhF70zT6bH+VBFY+lSeTImLcIFW28JNQiME8MoPcy6KGPs7rKFSHrb/U7P8rsTJeRfW5A4d1Cy6PKodDg=="], + "@opentui/keymap": ["@opentui/keymap@0.0.0-20260803-1395a054", "", { "dependencies": { "@opentui/core": "0.0.0-20260803-1395a054" }, "peerDependencies": { "@opentui/react": "0.0.0-20260803-1395a054", "@opentui/solid": "0.0.0-20260803-1395a054", "react": ">=19.2.0", "solid-js": "1.9.12" }, "optionalPeers": ["@opentui/react", "@opentui/solid", "react", "solid-js"] }, "sha512-FW480MzEwRYLA3y7XfMp2+xhrAa3uiLhpQT1uJT2k8M82eAJYAjonId1DpWhw0KLXONqeg27UMCPpAy+npvKEA=="], - "@opentui/solid": ["@opentui/solid@0.4.5", "", { "dependencies": { "@babel/core": "7.28.0", "@babel/preset-typescript": "7.27.1", "@opentui/core": "0.4.5", "babel-plugin-module-resolver": "5.0.2", "babel-preset-solid": "1.9.12", "entities": "7.0.1", "s-js": "^0.4.9" }, "peerDependencies": { "solid-js": "1.9.12" } }, "sha512-B0RSkXnrtPVfEJOX+Hj+axjLJ3lzbG1BZw5I7Pvb9OPp48Vzg2cW2a3cSa86/q48ndLt647i/XwFPIw/jqnI5g=="], + "@opentui/solid": ["@opentui/solid@0.0.0-20260803-1395a054", "", { "dependencies": { "@babel/core": "7.28.0", "@babel/preset-typescript": "7.27.1", "@opentui/core": "0.0.0-20260803-1395a054", "babel-plugin-module-resolver": "5.0.2", "babel-preset-solid": "1.9.12", "entities": "7.0.1", "s-js": "^0.4.9" }, "peerDependencies": { "solid-js": "1.9.12" } }, "sha512-K4w7+6nyJg4lrs8psdeHDcTyYNTcLgQdSATBf5R76vX7DIK1gQx+WUHsRNDm+Q4XeIjkngGTK/GsFLviUA9MYw=="], "@orama/orama": ["@orama/orama@3.1.18", "", {}, "sha512-a61ljmRVVyG5MC/698C8/FfFDw5a8LOIvyOLW5fztgUXqUpc1jOfQzOitSCbge657OgXXThmY3Tk8fpiDb4UcA=="], @@ -3526,7 +3525,7 @@ "builder-util-runtime": ["builder-util-runtime@9.7.0", "", { "dependencies": { "debug": "^4.3.4", "sax": "^1.2.4" } }, "sha512-g/kR520giAFYkSXTzcmF3kqQq7wi8F6N6SzeDgZrqTBN+VHdmgWOyTdD1yD7AATDId/yXLvuP34CxW46/BwCdw=="], - "bun-ffi-structs": ["bun-ffi-structs@0.2.4", "", { "peerDependencies": { "typescript": "^5" } }, "sha512-AJzsqoVFs1KBbJbWHIYrVZLDC3NhTqqh25awRXqzoLzmBAKr5oqk6+CwuYHAekKx+VBCYVohBoKuRq40dV+TYg=="], + "bun-ffi-structs": ["bun-ffi-structs@0.3.1", "", { "peerDependencies": { "typescript": "^5" } }, "sha512-3gM7PpVWLyrwxWjcilSiGuhWanhZivvo6l0u573NziPH6f/gwk6McbaYgn7oJWov6pKGRTDbrg94W5DcJsKTtQ=="], "bun-pty": ["bun-pty@0.4.8", "", {}, "sha512-rO70Mrbr13+jxHHHu2YBkk2pNqrJE5cJn29WE++PUr+GFA0hq/VgtQPZANJ8dJo6d7XImvBk37Innt8GM7O28w=="], @@ -3606,8 +3605,6 @@ "cli-truncate": ["cli-truncate@4.0.0", "", { "dependencies": { "slice-ansi": "^5.0.0", "string-width": "^7.0.0" } }, "sha512-nPdaFdQ0h/GEigbPClz11D0v/ZJEwxmeVZGeMo3Z5StPtUTkA9o1lD6QwoirYiSDzbcwn2XcjwmCp68W1IS4TA=="], - "clipboardy": ["clipboardy@4.0.0", "", { "dependencies": { "execa": "^8.0.1", "is-wsl": "^3.1.0", "is64bit": "^2.0.0" } }, "sha512-5mOlNS0mhX0707P2I0aZ2V/cmHUEO/fL7VFLqszkhUsxt7RwnmrInf/eEQKlf5GzvYeHIjT+Ov1HRfNmymlG0w=="], - "cliui": ["cliui@8.0.1", "", { "dependencies": { "string-width": "^4.2.0", "strip-ansi": "^6.0.1", "wrap-ansi": "^7.0.0" } }, "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ=="], "clone": ["clone@2.1.2", "", {}, "sha512-3Pe/CF1Nn94hyhIYpjtiLhdCoEoz0DqQ+988E9gmeEdQZlojxnOb74wctFyuwWQHzqyf9X7C7MG8juUpqBJT8w=="], @@ -4024,7 +4021,7 @@ "eventsource-parser": ["eventsource-parser@3.1.0", "", {}, "sha512-kJezFj9YFAMLeORyi7aCLxLbD5/qWMQnoMVlVPyHIll7lgRJCc3JVln9Vgl9nwQi0YkMnhdGTMNn7CkRRAptMg=="], - "execa": ["execa@8.0.1", "", { "dependencies": { "cross-spawn": "^7.0.3", "get-stream": "^8.0.1", "human-signals": "^5.0.0", "is-stream": "^3.0.0", "merge-stream": "^2.0.0", "npm-run-path": "^5.1.0", "onetime": "^6.0.0", "signal-exit": "^4.1.0", "strip-final-newline": "^3.0.0" } }, "sha512-VyhnebXciFV2DESc+p6B+y0LjSm0krU4OgJN44qFAhBY0TJ+1V61tYD2+wHusZ6F9n5K+vl8k0sTy7PEfV4qpg=="], + "execa": ["execa@5.1.1", "", { "dependencies": { "cross-spawn": "^7.0.3", "get-stream": "^6.0.0", "human-signals": "^2.1.0", "is-stream": "^2.0.0", "merge-stream": "^2.0.0", "npm-run-path": "^4.0.1", "onetime": "^5.1.2", "signal-exit": "^3.0.3", "strip-final-newline": "^2.0.0" } }, "sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg=="], "exit-hook": ["exit-hook@2.2.1", "", {}, "sha512-eNTPlAD67BmP31LDINZ3U7HSF8l57TxOY2PmBJ1shpCvpnxBF93mWCE8YHBnXs8qiUZJc9WDcWIeC3a2HIAMfw=="], @@ -4306,7 +4303,7 @@ "https-proxy-agent": ["https-proxy-agent@7.0.6", "", { "dependencies": { "agent-base": "^7.1.2", "debug": "4" } }, "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw=="], - "human-signals": ["human-signals@5.0.0", "", {}, "sha512-AXcZb6vzzrFAUE61HnN4mpLqd/cSIwNQjtNWR0euPm6y0iqx3G4gOXaIDdtdDwZmhwe82LA6+zinmW4UBWVePQ=="], + "human-signals": ["human-signals@2.1.0", "", {}, "sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw=="], "humanize-ms": ["humanize-ms@1.2.1", "", { "dependencies": { "ms": "^2.0.0" } }, "sha512-Fl70vYtsAFb/C06PTS9dZBo7ihau+Tu/DNCk/OyHhea07S+aeMWpFFkUaXRa8fI+ScZbEI8dfSxwY7gxZ9SAVQ=="], @@ -4450,8 +4447,6 @@ "is-wsl": ["is-wsl@3.1.1", "", { "dependencies": { "is-inside-container": "^1.0.0" } }, "sha512-e6rvdUCiQCAuumZslxRJWR/Doq4VpPR82kqclvcS0efgt430SlGIk05vdCN58+VrzgtIcfNODjozVielycD4Sw=="], - "is64bit": ["is64bit@2.0.0", "", { "dependencies": { "system-architecture": "^0.1.0" } }, "sha512-jv+8jaWCl0g2lSBkNSVXdzfBA0npK1HGC2KtWM9FumFRoGS94g3NbCCLVnCYHLjp4GrW2KZeeSTMo5ddtznmGw=="], - "isarray": ["isarray@2.0.5", "", {}, "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw=="], "isbinaryfile": ["isbinaryfile@5.0.7", "", {}, "sha512-gnWD14Jh3FzS3CPhF0AxNOJ8CxqeblPTADzI38r0wt8ZyQl5edpy75myt08EG2oKvpyiqSqsx+Wkz9vtkbTqYQ=="], @@ -4790,7 +4785,7 @@ "mime-types": ["mime-types@3.0.2", "", { "dependencies": { "mime-db": "^1.54.0" } }, "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A=="], - "mimic-fn": ["mimic-fn@4.0.0", "", {}, "sha512-vqiC06CuhBTUdZH+RYl8sFrL096vA45Ok5ISO6sE/Mr1jRbGH4Csnhi8f3wKVl7x8mO4Au7Ir9D3Oyv1VYMFJw=="], + "mimic-fn": ["mimic-fn@2.1.0", "", {}, "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg=="], "mimic-function": ["mimic-function@5.0.1", "", {}, "sha512-VP79XUPxV2CigYP3jWwAUFSku2aKqBH7uTAapFWCBqutsbmDo96KY5o8uh6U+/YSIn5OxJnXp73beVkpqMIGhA=="], @@ -4910,7 +4905,7 @@ "npm-registry-fetch": ["npm-registry-fetch@19.1.1", "", { "dependencies": { "@npmcli/redact": "^4.0.0", "jsonparse": "^1.3.1", "make-fetch-happen": "^15.0.0", "minipass": "^7.0.2", "minipass-fetch": "^5.0.0", "minizlib": "^3.0.1", "npm-package-arg": "^13.0.0", "proc-log": "^6.0.0" } }, "sha512-TakBap6OM1w0H73VZVDf44iFXsOS3h+L4wVMXmbWOQroZgFhMch0juN6XSzBNlD965yIKvWg2dfu7NSiaYLxtw=="], - "npm-run-path": ["npm-run-path@5.3.0", "", { "dependencies": { "path-key": "^4.0.0" } }, "sha512-ppwTtiJZq0O/ai0z7yfudtBpWIoxM8yE6nHi1X47eFR2EWORqfbu6CnPlNsjeN683eT0qG6H/Pyf9fCcvjnnnQ=="], + "npm-run-path": ["npm-run-path@4.0.1", "", { "dependencies": { "path-key": "^3.0.0" } }, "sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw=="], "nth-check": ["nth-check@2.1.1", "", { "dependencies": { "boolbase": "^1.0.0" } }, "sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w=="], @@ -4936,7 +4931,7 @@ "once": ["once@1.4.0", "", { "dependencies": { "wrappy": "1" } }, "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w=="], - "onetime": ["onetime@6.0.0", "", { "dependencies": { "mimic-fn": "^4.0.0" } }, "sha512-1FlR+gjXK7X+AsAHso35MnyN5KqGwJRi/31ft6x0M194ht7S+rWAvd7PHss9xSKMzE0asv1pyIHaJYq+BbacAQ=="], + "onetime": ["onetime@5.1.2", "", { "dependencies": { "mimic-fn": "^2.1.0" } }, "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg=="], "oniguruma-parser": ["oniguruma-parser@0.12.2", "", {}, "sha512-6HVa5oIrgMC6aA6WF6XyyqbhRPJrKR02L20+2+zpDtO5QAzGHAUGw5TKQvwi5vctNnRHkJYmjAhRVQF2EKdTQw=="], @@ -5536,7 +5531,7 @@ "strip-bom-string": ["strip-bom-string@1.0.0", "", {}, "sha512-uCC2VHvQRYu+lMh4My/sFNmF2klFymLX1wHJeXnbEJERpV/ZsVuonzerjfrGpIGF7LBVa1O7i9kjiWvJiFck8g=="], - "strip-final-newline": ["strip-final-newline@3.0.0", "", {}, "sha512-dOESqjYr96iWYylGObzd39EuNTa5VJxyvVAEm5Jnh7KGo75V43Hk1odPQkNDyXNmUR6k+gEiDVXnjB8HJ3crXw=="], + "strip-final-newline": ["strip-final-newline@2.0.0", "", {}, "sha512-BrpvfNAE3dcvq7ll3xVumzjKjZQ5tI1sEUIKr3Uoks0XUl45St3FlatVqef9prk4jRDzhW6WZg+3bk93y6pLjA=="], "strip-indent": ["strip-indent@3.0.0", "", { "dependencies": { "min-indent": "^1.0.0" } }, "sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ=="], @@ -5568,8 +5563,6 @@ "svgo": ["svgo@4.0.2", "", { "dependencies": { "commander": "^11.1.0", "css-select": "^5.1.0", "css-tree": "^3.0.1", "css-what": "^6.1.0", "csso": "^5.0.5", "picocolors": "^1.1.1", "sax": "^1.5.0" }, "bin": "./bin/svgo.js" }, "sha512-ekx94z1rRc5LDi6oSUaeRnYhd0UOJxdtQCL2rF8xpWxD3TPAsISWOrxezqGovqS38GRZOdpDfvQe3ts6F7nsng=="], - "system-architecture": ["system-architecture@0.1.0", "", {}, "sha512-ulAk51I9UVUyJgxlv9M6lFot2WP3e7t8Kz9+IS6D4rVba1tR9kON+Ey69f+1R4Q8cd45Lod6a4IcJIxnzGc/zA=="], - "tagged-tag": ["tagged-tag@1.0.0", "", {}, "sha512-yEFYrVhod+hdNyx7g5Bnkkb0G6si8HJurOoOEgC8B/O0uXLHlaey/65KRv6cuWBNhBgHKAROVpc7QyYqE5gFng=="], "tailwindcss": ["tailwindcss@4.1.11", "", {}, "sha512-2E9TBm6MDD/xKYe+dvJZAmg3yxIEDNRc0jwlNyDg/4Fil2QcSLjFKGVff0lAf1jjeaArlG/M75Ey/EYr/OJtBA=="], @@ -6510,8 +6503,6 @@ "@vercel/cli-config/zod": ["zod@4.1.11", "", {}, "sha512-WPsqwxITS2tzx1bzhIKsEs19ABD5vmCVa4xBo2tq/SrV4RNZtfws1EnCWQXM6yh8bD08a1idvkB5MZSBiZsjwg=="], - "@vercel/cli-exec/execa": ["execa@5.1.1", "", { "dependencies": { "cross-spawn": "^7.0.3", "get-stream": "^6.0.0", "human-signals": "^2.1.0", "is-stream": "^2.0.0", "merge-stream": "^2.0.0", "npm-run-path": "^4.0.1", "onetime": "^5.1.2", "signal-exit": "^3.0.3", "strip-final-newline": "^2.0.0" } }, "sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg=="], - "@vercel/functions/@vercel/oidc": ["@vercel/oidc@3.8.1", "", { "dependencies": { "@vercel/cli-config": "0.2.1", "@vercel/cli-exec": "1.0.0", "jose": "^5.9.6" } }, "sha512-ufdalm2MWOYksyj8KVpWjoOFPJO6zoYpuyvIggIQ2bB0CFCjTCiTkGXHqAKwG77GVRjOaN3/8S5ITlZpXWmqOw=="], "@vercel/nft/acorn": ["acorn@8.17.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-xRQbDb9BnwDafYNn6Vwl839DYVjqXYb1XVGtWAZ1kcDc6iwAL4hg3B1dZlRiuENFeO2H53gFG3in621AdERVAg=="], @@ -6694,9 +6685,11 @@ "estree-util-to-js/source-map": ["source-map@0.7.6", "", {}, "sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ=="], - "execa/get-stream": ["get-stream@8.0.1", "", {}, "sha512-VaUJspBffn/LMCJVoMvSAdmscJyS1auj5Zulnn5UoYcY531UWmdwhRWkcGKnGU93m5HSXP9LP2usOryrBtQowA=="], + "execa/get-stream": ["get-stream@6.0.1", "", {}, "sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg=="], - "execa/is-stream": ["is-stream@3.0.0", "", {}, "sha512-LnQR4bZ9IADDRSkvpqMGvt/tEJWclzklNgSw48V5EAaAeDd6qGvN8ei6k5p0tvxSR171VmGyHuTiAOfxAbr8kA=="], + "execa/is-stream": ["is-stream@2.0.1", "", {}, "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg=="], + + "execa/signal-exit": ["signal-exit@3.0.7", "", {}, "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ=="], "express/cookie": ["cookie@0.7.2", "", {}, "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w=="], @@ -6770,8 +6763,6 @@ "node-gyp/undici": ["undici@6.28.0", "", {}, "sha512-LIY910g9TI13YS95lrMFrs8Rm/u/irgHeTWoKCoteeJ04CUJ92eEfj0rVn+7VKMPBpUPiUoBKfhNyLI23EE/KA=="], - "npm-run-path/path-key": ["path-key@4.0.0", "", {}, "sha512-haREypq7xkM7ErfgIyA0z+Bj4AGKlMSdlQE2jvJo6huWD1EdkKYV+G/T4nq0YEF2vgTT8kqMFKo1uHn950r4SQ=="], - "openid-client/jose": ["jose@4.15.9", "", {}, "sha512-1vUQX+IdDMVPj4k8kOxgUqlcK518yluMuGZwqlr44FS1ppZB/5GWh4rZG89erpOBOJjU/OBsnCVFfapsRz6nEA=="], "openid-client/lru-cache": ["lru-cache@6.0.0", "", { "dependencies": { "yallist": "^4.0.0" } }, "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA=="], @@ -7546,20 +7537,6 @@ "@tailwindcss/oxide-wasm32-wasi/@napi-rs/wasm-runtime/@tybys/wasm-util": ["@tybys/wasm-util@0.10.3", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg=="], - "@vercel/cli-exec/execa/get-stream": ["get-stream@6.0.1", "", {}, "sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg=="], - - "@vercel/cli-exec/execa/human-signals": ["human-signals@2.1.0", "", {}, "sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw=="], - - "@vercel/cli-exec/execa/is-stream": ["is-stream@2.0.1", "", {}, "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg=="], - - "@vercel/cli-exec/execa/npm-run-path": ["npm-run-path@4.0.1", "", { "dependencies": { "path-key": "^3.0.0" } }, "sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw=="], - - "@vercel/cli-exec/execa/onetime": ["onetime@5.1.2", "", { "dependencies": { "mimic-fn": "^2.1.0" } }, "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg=="], - - "@vercel/cli-exec/execa/signal-exit": ["signal-exit@3.0.7", "", {}, "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ=="], - - "@vercel/cli-exec/execa/strip-final-newline": ["strip-final-newline@2.0.0", "", {}, "sha512-BrpvfNAE3dcvq7ll3xVumzjKjZQ5tI1sEUIKr3Uoks0XUl45St3FlatVqef9prk4jRDzhW6WZg+3bk93y6pLjA=="], - "@vercel/functions/@vercel/oidc/jose": ["jose@5.9.6", "", {}, "sha512-AMlnetc9+CV9asI19zHmrgS/WYsWUwCn2R7RzlbJWD7F9eWYUTGyBmU9o6PxngtLGOiDGPRu+Uc4fhKzbpteZQ=="], "@vercel/routing-utils/ajv/json-schema-traverse": ["json-schema-traverse@0.4.1", "", {}, "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg=="], @@ -8614,8 +8591,6 @@ "@solidjs/start/shiki/@shikijs/engine-javascript/oniguruma-to-es": ["oniguruma-to-es@2.3.0", "", { "dependencies": { "emoji-regex-xs": "^1.0.0", "regex": "^5.1.1", "regex-recursion": "^5.1.1" } }, "sha512-bwALDxriqfKGfUufKGGepCzu9x7nJQuoRoAFp4AnwehhC2crqrDIAP/uN2qdlsAvSMpeRC3+Yzhqc7hLmle5+g=="], - "@vercel/cli-exec/execa/onetime/mimic-fn": ["mimic-fn@2.1.0", "", {}, "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg=="], - "ansi-align/string-width/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], "app-builder-lib/@electron/get/fs-extra/universalify": ["universalify@0.1.2", "", {}, "sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg=="], diff --git a/package.json b/package.json index 857db661052..558f8ad0fd0 100644 --- a/package.json +++ b/package.json @@ -46,9 +46,9 @@ "@octokit/rest": "22.0.0", "@hono/standard-validator": "0.2.0", "@hono/zod-validator": "0.4.2", - "@opentui/core": "0.4.5", - "@opentui/keymap": "0.4.5", - "@opentui/solid": "0.4.5", + "@opentui/core": "0.0.0-20260803-1395a054", + "@opentui/keymap": "0.0.0-20260803-1395a054", + "@opentui/solid": "0.0.0-20260803-1395a054", "@tanstack/solid-virtual": "3.13.32", "@shikijs/stream": "4.2.0", "@standard-schema/spec": "1.1.0", @@ -149,9 +149,9 @@ "electron" ], "overrides": { - "@opentui/core": "catalog:", - "@opentui/keymap": "catalog:", - "@opentui/solid": "catalog:", + "@opentui/core": "0.0.0-20260803-1395a054", + "@opentui/keymap": "0.0.0-20260803-1395a054", + "@opentui/solid": "0.0.0-20260803-1395a054", "@types/bun": "catalog:", "@types/node": "catalog:", "effect": "catalog:" diff --git a/packages/plugin/package.json b/packages/plugin/package.json index 21b61cd4181..7e4bd41c20c 100644 --- a/packages/plugin/package.json +++ b/packages/plugin/package.json @@ -31,9 +31,9 @@ }, "peerDependencies": { "@opencode-ai/theme": "workspace:*", - "@opentui/core": ">=0.4.5", - "@opentui/keymap": ">=0.4.5", - "@opentui/solid": ">=0.4.5", + "@opentui/core": "0.0.0-20260803-1395a054", + "@opentui/keymap": "0.0.0-20260803-1395a054", + "@opentui/solid": "0.0.0-20260803-1395a054", "solid-js": ">=1.9.0" }, "peerDependenciesMeta": { diff --git a/packages/tui/package.json b/packages/tui/package.json index 215bd69a89b..8214882575e 100644 --- a/packages/tui/package.json +++ b/packages/tui/package.json @@ -92,7 +92,6 @@ "@opentui/keymap": "catalog:", "@opentui/solid": "catalog:", "@solid-primitives/event-bus": "1.1.2", - "clipboardy": "4.0.0", "effect": "catalog:", "fuzzysort": "catalog:", "get-east-asian-width": "catalog:", diff --git a/packages/tui/src/app.tsx b/packages/tui/src/app.tsx index d692b4e50e8..f48ec13b753 100644 --- a/packages/tui/src/app.tsx +++ b/packages/tui/src/app.tsx @@ -95,6 +95,7 @@ import { destroyRenderer } from "./util/renderer" import { cliErrorMessage, errorFormat } from "./util/error" import { AttentionProvider } from "./context/attention" import { StorageProvider } from "./context/storage" +import { createTuiClipboard } from "./clipboard" registerOpencodeSpinner() @@ -215,9 +216,7 @@ export const run = Effect.fn("Tui.run")(function* (input: TuiInput) { Effect.catch(() => Effect.tryPromise(() => api.location.get())), ) const directory = location.directory - const pluginDirectories = yield* Effect.promise(() => - tuiPluginDirectories(process.cwd(), global.config), - ) + const pluginDirectories = yield* Effect.promise(() => tuiPluginDirectories(process.cwd(), global.config)) const handoff = input.terminalHandoff ? yield* Effect.promise(input.terminalHandoff) : undefined const managed = input.server.service const service = managed @@ -265,6 +264,13 @@ export const run = Effect.fn("Tui.run")(function* (input: TuiInput) { (renderer) => Effect.sync(() => destroyRenderer(renderer)), ) }) + const clipboard = yield* Effect.acquireRelease( + Effect.sync(() => createTuiClipboard(renderer)), + (clipboard) => + Effect.tryPromise(() => clipboard.dispose()).pipe( + Effect.catch((error) => Effect.sync(() => log("error", "Failed to dispose TUI clipboard", { error }))), + ), + ) win32DisableProcessedInput() const finalizers = new Set<() => Promise>() yield* Effect.addFinalizer(() => @@ -301,7 +307,11 @@ export const run = Effect.fn("Tui.run")(function* (input: TuiInput) { (exit.epilogue = value)}> } + fallback={(error, reset) => ( + + + + )} > - + toast.show({ message: "Copied to clipboard", variant: "info" })) .catch(toast.error) diff --git a/packages/tui/src/clipboard.ts b/packages/tui/src/clipboard.ts index 08f86f9f7a9..cd7a6ce1a17 100644 --- a/packages/tui/src/clipboard.ts +++ b/packages/tui/src/clipboard.ts @@ -1,124 +1,66 @@ -import { execFile, spawn } from "node:child_process" -import { readFile, rm } from "node:fs/promises" -import { platform, release, tmpdir } from "node:os" -import path from "node:path" -import { promisify } from "node:util" +import { + createClipboard, + createHostClipboard, + createRendererClipboardAdapter, + decodePasteBytes, + type ClipboardService as CoreClipboardService, + type RendererClipboardBoundary, +} from "@opentui/core" +import type { ClipboardContent, ClipboardService } from "./context/clipboard" -const exec = promisify(execFile) +export type OwnedClipboardService = Required & Readonly<{ dispose(): Promise }> -function command(command: string, args: string[] = [], input?: string) { - return new Promise((resolve, reject) => { - const child = spawn(command, args, { stdio: [input === undefined ? "ignore" : "pipe", "pipe", "ignore"] }) - const output: Buffer[] = [] - child.on("error", reject) - child.stdout?.on("data", (chunk: Buffer) => output.push(chunk)) - child.on("close", (code) => { - if (code === 0) return resolve(Buffer.concat(output)) - reject(new Error(`${command} exited with code ${code}`)) - }) - if (input !== undefined) child.stdin?.end(input) - }) +export function createTuiClipboard(renderer: RendererClipboardBoundary): OwnedClipboardService { + return createClipboardAdapter( + createClipboard({ + host: createHostClipboard(), + terminal: createRendererClipboardAdapter(renderer), + }), + ) } -function writeOsc52(text: string) { - if (!process.stdout.isTTY) return - const sequence = `\x1b]52;c;${Buffer.from(text).toString("base64")}\x07` - process.stdout.write(process.env.TMUX || process.env.STY ? `\x1bPtmux;\x1b${sequence}\x1b\\` : sequence) -} - -export async function read() { - if (platform() === "darwin") { - const file = path.join(tmpdir(), "opencode-clipboard.png") - try { - await exec("osascript", [ - "-e", - 'set imageData to the clipboard as "PNGf"', - "-e", - `set fileRef to open for access POSIX file "${file}" with write permission`, - "-e", - "set eof fileRef to 0", - "-e", - "write imageData to fileRef", - "-e", - "close access fileRef", - ]) - return { data: (await readFile(file)).toString("base64"), mime: "image/png" } - } catch { - // Fall through to text clipboard. - } finally { - await rm(file, { force: true }).catch(() => {}) - } - } - - if (platform() === "win32" || release().includes("WSL")) { - const script = - "Add-Type -AssemblyName System.Windows.Forms; $img = [System.Windows.Forms.Clipboard]::GetImage(); if ($img) { $ms = New-Object System.IO.MemoryStream; $img.Save($ms, [System.Drawing.Imaging.ImageFormat]::Png); [System.Convert]::ToBase64String($ms.ToArray()) }" - const image = await command("powershell.exe", ["-NonInteractive", "-NoProfile", "-command", script]).catch(() => - Buffer.alloc(0), - ) - if (image.length) return { data: image.toString().trim(), mime: "image/png" } - } - - if (platform() === "linux") { - const wayland = await command("wl-paste", ["-t", "image/png"]).catch(() => Buffer.alloc(0)) - if (wayland.length) return { data: wayland.toString("base64"), mime: "image/png" } - const x11 = await command("xclip", ["-selection", "clipboard", "-t", "image/png", "-o"]).catch(() => - Buffer.alloc(0), - ) - if (x11.length) return { data: x11.toString("base64"), mime: "image/png" } - } - - const { default: clipboardy } = await import("clipboardy") - const text = await clipboardy.read().catch(() => undefined) - if (text) return { data: text, mime: "text/plain" } -} - -export function copyCommand( - os: NodeJS.Platform, - wayland: boolean, - has: (name: string) => boolean, -): string[] | undefined { - if (os === "darwin" && has("osascript")) return ["osascript"] - if (os === "linux" && wayland && has("wl-copy")) return ["wl-copy"] - if (os === "linux" && has("xclip")) return ["xclip", "-selection", "clipboard"] - if (os === "linux" && has("xsel")) return ["xsel", "--clipboard", "--input"] - if (os === "win32" && has("powershell.exe")) { - return [ - "powershell.exe", - "-NonInteractive", - "-NoProfile", - "-Command", - "[Console]::InputEncoding = [System.Text.Encoding]::UTF8; Set-Clipboard -Value ([Console]::In.ReadToEnd())", - ] - } -} - -let copyMethod: Promise<(text: string) => Promise> | undefined - -function getCopyMethod() { - return (copyMethod ??= (async () => { - const { which } = await import("@opencode-ai/core/util/which") - const native = copyCommand(platform(), Boolean(process.env.WAYLAND_DISPLAY), (name) => Boolean(which(name))) - if (native?.[0] === "osascript") { - return async (text: string) => { - const escaped = text.replace(/\\/g, "\\\\").replace(/"/g, '\\"') - await command("osascript", ["-e", `set the clipboard to "${escaped}"`]).catch(() => undefined) +export function createClipboardAdapter(clipboard: CoreClipboardService): OwnedClipboardService { + return { + async read(): Promise { + const result = await clipboard.read({ + preferredTypes: ["image/png", "text/plain"], + selection: "clipboard", + }) + if (result.status !== "read") { + if (result.status === "failed") throw result.error + if (result.status === "timed-out") throw new Error("Clipboard read timed out") + if (result.status === "limit-exceeded") { + throw new RangeError("Clipboard content exceeded configured read or image conversion limits") + } + return undefined } - } - if (native) { - return async (text: string) => { - await command(native[0], native.slice(1), text).catch(() => undefined) - } - } - return async (text: string) => { - const { default: clipboardy } = await import("clipboardy") - await clipboardy.write(text).catch(() => undefined) - } - })()) -} -export async function write(text: string) { - writeOsc52(text) - const method = await getCopyMethod() - await method(text) + if (result.representation.mimeType === "image/png") { + return { + data: Buffer.from(result.representation.bytes).toString("base64"), + mime: result.representation.mimeType, + } + } + if (result.representation.mimeType === "text/plain") { + if (result.representation.bytes.length === 0) return undefined + return { + data: decodePasteBytes(result.representation.bytes), + mime: result.representation.mimeType, + } + } + throw new Error(`Unexpected clipboard MIME type: ${result.representation.mimeType}`) + }, + async write(text) { + const result = await clipboard.writeText(text, { + destination: "all-available", + selection: "clipboard", + }) + if (result.host.status === "written" || result.terminal.status === "attempted") return + if (result.host.status === "failed") throw result.host.error + throw new Error(`Clipboard write failed (host: ${result.host.status}, terminal: ${result.terminal.status})`) + }, + dispose() { + return clipboard.dispose() + }, + } } diff --git a/packages/tui/src/component/dialog-debug.tsx b/packages/tui/src/component/dialog-debug.tsx index fcf2673187f..4419578a5ee 100644 --- a/packages/tui/src/component/dialog-debug.tsx +++ b/packages/tui/src/component/dialog-debug.tsx @@ -39,7 +39,7 @@ export function DialogDebug() { .map((entry) => `${entry.label}: ${entry.value}`) .join("\n") void clipboard - .write?.(text) + .write(text) .then(() => { setCopied(true) toast.show({ message: "Debug info copied to clipboard", variant: "info" }) diff --git a/packages/tui/src/component/dialog-integration.tsx b/packages/tui/src/component/dialog-integration.tsx index 6511a14036b..f35957716e4 100644 --- a/packages/tui/src/component/dialog-integration.tsx +++ b/packages/tui/src/component/dialog-integration.tsx @@ -452,7 +452,7 @@ function OAuthAuto(props: { run: () => { const value = props.attempt.instructions.match(/[A-Z0-9]{4}-[A-Z0-9]{4,5}/)?.[0] ?? props.attempt.url clipboard - .write?.(value) + .write(value) .then(() => toast.show({ message: "Copied to clipboard", variant: "info" })) .catch(toast.error) }, diff --git a/packages/tui/src/component/dialog-mcp.tsx b/packages/tui/src/component/dialog-mcp.tsx index 3d3a0ab22e6..39868c56322 100644 --- a/packages/tui/src/component/dialog-mcp.tsx +++ b/packages/tui/src/component/dialog-mcp.tsx @@ -146,7 +146,6 @@ function DialogMcpError(props: { server: McpServer; onBack: () => void }) { onMount(() => dialog.setSize("large")) const copy = () => { - if (!clipboard.write) return void clipboard .write(error()) .then(() => setCopied(true)) diff --git a/packages/tui/src/component/error-component.tsx b/packages/tui/src/component/error-component.tsx index d500e36c0db..0949e1f9047 100644 --- a/packages/tui/src/component/error-component.tsx +++ b/packages/tui/src/component/error-component.tsx @@ -12,7 +12,7 @@ export function ErrorComponent(props: { error: Error; reset: () => void; mode?: const exit = useExit() const clipboard = useClipboard() const app = useTuiApp() - const [copied, setCopied] = createSignal(false) + const [copyState, setCopyState] = createSignal<"idle" | "copied" | "failed">("idle") // Safe fallback palette per mode (mirrors theme/assets/opencode.json) since the // theme context may be the thing that crashed. @@ -46,11 +46,19 @@ export function ErrorComponent(props: { error: Error; reset: () => void; mode?: const issueURL = buildIssueURL(message, stack, app.version) const copyReport = () => { - void clipboard.write?.(issueURL.toString()).then(() => setCopied(true)) + void clipboard + .write(issueURL.toString()) + .then(() => setCopyState("copied")) + .catch(() => setCopyState("failed")) } const actions = [ - { key: "c", label: () => (copied() ? "✓ Copied" : "Copy report"), copy: true, onUse: copyReport }, + { + key: "c", + label: () => ({ idle: "Copy report", copied: "✓ Copied", failed: "Copy failed" })[copyState()], + copy: true, + onUse: copyReport, + }, { key: "r", label: () => "Restart", onUse: props.reset }, { key: "q", label: () => "Quit", onUse: () => exit() }, ] @@ -135,13 +143,20 @@ export function ErrorComponent(props: { error: Error; reset: () => void; mode?: {(action, index) => { const isSelected = () => selected() === index() - const isCopied = () => action.copy && copied() + const copyColor = () => + action.copy + ? copyState() === "copied" + ? colors.success + : copyState() === "failed" + ? colors.error + : undefined + : undefined return ( setSelected(index())} onMouseUp={() => action.onUse()} - backgroundColor={isCopied() ? colors.success : isSelected() ? colors.primary : colors.element} + backgroundColor={copyColor() ?? (isSelected() ? colors.primary : colors.element)} minWidth={15} alignItems="center" paddingLeft={2} @@ -149,7 +164,7 @@ export function ErrorComponent(props: { error: Error; reset: () => void; mode?: > {action.label()} @@ -189,9 +204,11 @@ export function ErrorComponent(props: { error: Error; reset: () => void; mode?: - {copied() + {copyState() === "copied" ? "Report copied — paste it into a new GitHub issue." - : "Copy the report and open a GitHub issue to help us fix this."} + : copyState() === "failed" + ? "Clipboard write failed. Try again or report the crash manually." + : "Copy the report and open a GitHub issue to help us fix this."} OpenCode {app.version} diff --git a/packages/tui/src/component/prompt/index.tsx b/packages/tui/src/component/prompt/index.tsx index 34df09fcc0f..2b2c34cbb1d 100644 --- a/packages/tui/src/component/prompt/index.tsx +++ b/packages/tui/src/component/prompt/index.tsx @@ -379,7 +379,10 @@ export function Prompt(props: PromptProps) { run: async (_input: string | undefined, event?: KeyEvent) => { event?.preventDefault() event?.stopPropagation() - const content = await clipboard.read?.() + const content = await clipboard.read().catch((error) => { + toast.error(error) + return undefined + }) if (content?.mime.startsWith("image/")) { await pasteAttachment({ filename: "clipboard", @@ -1320,10 +1323,7 @@ export function Prompt(props: PromptProps) { return `Ask anything... "${list()[store.placeholder % list().length]}"` })() if (!value) return undefined - const width = - dimensions().width < 44 - ? dimensions().width - 5 - : Math.min(75, dimensions().width - 4) - 5 + const width = dimensions().width < 44 ? dimensions().width - 5 : Math.min(75, dimensions().width - 4) - 5 return Locale.takeWidth(value, Math.max(1, width)).trimEnd() }) const locationLabel = createMemo(() => { @@ -1419,11 +1419,10 @@ export function Prompt(props: PromptProps) { // Windows ConPTY/Terminal often sends CR-only newlines in bracketed paste // Replace CRLF first, then any remaining CR const normalizedText = decodePasteBytes(event.bytes).replace(/\r\n/g, "\n").replace(/\r/g, "\n") - const pastedContent = normalizedText.trim() // Windows Terminal <1.25 can surface image-only clipboard as an // empty bracketed paste. Windows Terminal 1.25+ does not. - if (!pastedContent) { + if (event.bytes.byteLength === 0) { keymap.dispatch("prompt.paste") return } diff --git a/packages/tui/src/context/clipboard.tsx b/packages/tui/src/context/clipboard.tsx index 6e0ac5370e1..ed2c51c78c5 100644 --- a/packages/tui/src/context/clipboard.tsx +++ b/packages/tui/src/context/clipboard.tsx @@ -1,18 +1,19 @@ import { createContext, type JSX, useContext } from "solid-js" -import { read, write } from "../clipboard" export type ClipboardContent = Readonly<{ data: string; mime: string }> export type ClipboardService = Readonly<{ - read?(): Promise - write?(text: string): Promise + read(): Promise + write(text: string): Promise }> -const clipboard = { read, write } -const ClipboardContext = createContext(clipboard) -export function ClipboardProvider(props: { value?: ClipboardService; children: JSX.Element }) { - return {props.children} +const ClipboardContext = createContext() + +export function ClipboardProvider(props: { value: ClipboardService; children: JSX.Element }) { + return {props.children} } export function useClipboard() { - return useContext(ClipboardContext) + const value = useContext(ClipboardContext) + if (!value) throw new Error("useClipboard must be used within a ClipboardProvider") + return value } diff --git a/packages/tui/src/routes/session/dialog-message.tsx b/packages/tui/src/routes/session/dialog-message.tsx index 74e3aaab9b9..117491cc7d8 100644 --- a/packages/tui/src/routes/session/dialog-message.tsx +++ b/packages/tui/src/routes/session/dialog-message.tsx @@ -66,8 +66,12 @@ export function DialogMessage(props: { : "text" in value ? value.text : "" - await clipboard.write?.(text) - dialog.clear() + try { + await clipboard.write(text) + dialog.clear() + } catch (error) { + toast.error(error) + } }, }, { diff --git a/packages/tui/src/routes/session/form.tsx b/packages/tui/src/routes/session/form.tsx index 8ea3c6f8c91..d785360c624 100644 --- a/packages/tui/src/routes/session/form.tsx +++ b/packages/tui/src/routes/session/form.tsx @@ -364,7 +364,7 @@ export function FormPrompt(props: { form: FormWithLocation }) { function copyExternal() { const current = externalField() - if (!current || !clipboard.write) return + if (!current) return void clipboard .write(current.url) .then(() => { @@ -992,7 +992,7 @@ export function FormPrompt(props: { form: FormWithLocation }) { > enter {actionLabel()} - + c copy diff --git a/packages/tui/src/routes/session/index.tsx b/packages/tui/src/routes/session/index.tsx index 67e0f47e9b2..e4b59b396c8 100644 --- a/packages/tui/src/routes/session/index.tsx +++ b/packages/tui/src/routes/session/index.tsx @@ -779,7 +779,7 @@ export function Session() { } clipboard - .write?.(text) + .write(text) .then(() => toast.show({ message: "Message copied to clipboard!", variant: "success" })) .catch(() => toast.show({ message: "Failed to copy to clipboard", variant: "error" })) dialog.clear() @@ -797,7 +797,7 @@ export function Session() { const sessionData = session() if (!sessionData) return const transcript = formatSessionTranscript(sessionData, messages(), showThinking()) - await clipboard.write?.(transcript) + await clipboard.write(transcript) toast.show({ message: "Session transcript copied to clipboard!", variant: "success" }) } catch { toast.show({ message: "Failed to copy session transcript", variant: "error" }) @@ -840,7 +840,7 @@ export function Session() { })() if (options.action === "copy") { - await clipboard.write?.(content) + await clipboard.write(content) dialog.clear() toast.show({ message: "Copied to clipboard", variant: "success" }) return diff --git a/packages/tui/src/ui/dialog.tsx b/packages/tui/src/ui/dialog.tsx index 6a89f79a17e..cf2f4b91e3a 100644 --- a/packages/tui/src/ui/dialog.tsx +++ b/packages/tui/src/ui/dialog.tsx @@ -204,7 +204,7 @@ export function DialogProvider(props: ParentProps) { function copySelection() { const text = renderer.getSelection()?.getSelectedText() - if (!text || !clipboard.write) return false + if (!text) return false void clipboard.write(text).then( () => toast.show({ message: "Copied to clipboard", variant: "info" }), (error) => toast.error(error), diff --git a/packages/tui/src/util/selection.ts b/packages/tui/src/util/selection.ts index d9158ba4076..243d9b88feb 100644 --- a/packages/tui/src/util/selection.ts +++ b/packages/tui/src/util/selection.ts @@ -35,7 +35,7 @@ export function copy(renderer: Renderer, toast: Toast, clipboard: ClipboardServi focus?.getClipboardText && selection.selectedRenderables.includes(focus) ? focus.getClipboardText(text) : text clipboard - ?.write?.(clipboardText) + .write(clipboardText) .then(() => toast.show({ message: "Copied to clipboard", variant: "info" })) .catch(toast.error) diff --git a/packages/tui/test/cli/tui/form.test.tsx b/packages/tui/test/cli/tui/form.test.tsx index 9aa82f7a896..116ec386fda 100644 --- a/packages/tui/test/cli/tui/form.test.tsx +++ b/packages/tui/test/cli/tui/form.test.tsx @@ -60,6 +60,9 @@ async function mountForm(root: string, width = 80) { > { - expect(copyCommand("linux", true, (name) => name === "wl-copy")).toEqual(["wl-copy"]) +function coreClipboard(options: { + read?: ClipboardReadResult + write?: ClipboardWriteResult + onRead?: (input: ClipboardReadOptions) => void + onWrite?: (text: string, input: ClipboardWriteOptions) => void +}): CoreClipboardService { + return { + async read(input) { + options.onRead?.(input) + return options.read ?? { status: "empty" } + }, + async writeText(text, input) { + options.onWrite?.(text, input) + return ( + options.write ?? { + host: { status: "written" }, + terminal: { status: "not-attempted", capability: "unknown" }, + } + ) + }, + async clear() { + return { + host: { status: "cleared" }, + terminal: { status: "not-attempted", capability: "unknown" }, + } + }, + async dispose() {}, + } +} + +test("adapts OpenTUI image and text reads", async () => { + const requests: ClipboardReadOptions[] = [] + const image = createClipboardAdapter( + coreClipboard({ + read: { + status: "read", + representation: { mimeType: "image/png", bytes: new Uint8Array([0, 1, 2, 255]) }, + }, + onRead: (input) => requests.push(input), + }), + ) + const text = "line 1\r\n\t世界" + const plain = createClipboardAdapter( + coreClipboard({ + read: { + status: "read", + representation: { mimeType: "text/plain", bytes: new TextEncoder().encode(text) }, + }, + }), + ) + + expect(await image.read()).toEqual({ data: "AAEC/w==", mime: "image/png" }) + expect(await plain.read()).toEqual({ data: text, mime: "text/plain" }) + expect(requests).toEqual([{ preferredTypes: ["image/png", "text/plain"], selection: "clipboard" }]) }) -test("uses osascript on macOS", () => { - expect(copyCommand("darwin", false, (name) => name === "osascript")).toEqual(["osascript"]) +test("uses all available routes but skips the process host remotely", async () => { + const writes = { host: 0, terminal: 0 } + const host: HostClipboardService = { + maxWriteBytes: 8 * 1024 * 1024, + async read() { + return { status: "empty" } + }, + async writeText() { + writes.host++ + return { status: "written" } + }, + async clear() { + return { status: "cleared" } + }, + async dispose() {}, + } + const clipboard = createClipboardAdapter( + createClipboard({ + host, + terminal: { + remote: true, + writeText() { + writes.terminal++ + return { status: "attempted", capability: "supported" } + }, + clear() { + return { status: "attempted", capability: "supported" } + }, + }, + }), + ) + + expect(await clipboard.write("hello")).toBeUndefined() + expect(writes).toEqual({ host: 0, terminal: 1 }) }) -test("falls back through X11 clipboard commands", () => { - expect(copyCommand("linux", true, (name) => name === "xclip")).toEqual(["xclip", "-selection", "clipboard"]) - expect(copyCommand("linux", false, (name) => name === "xsel")).toEqual(["xsel", "--clipboard", "--input"]) -}) +test("rejects only when no clipboard route accepted the write", async () => { + const writes: [string, ClipboardWriteOptions][] = [] + const failure = new Error("native clipboard failed") + const fallback = createClipboardAdapter( + coreClipboard({ + write: { + host: { status: "written" }, + terminal: { status: "local-failure", capability: "supported" }, + }, + }), + ) + const clipboard = createClipboardAdapter( + coreClipboard({ + write: { + host: { status: "failed", error: failure }, + terminal: { status: "local-failure", capability: "supported" }, + }, + onWrite: (text, input) => writes.push([text, input]), + }), + ) -test("returns undefined when native clipboard is unavailable", () => { - expect(copyCommand("linux", false, () => false)).toBeUndefined() + expect(await fallback.write("hello")).toBeUndefined() + expect(await clipboard.write("hello").then(undefined, (error) => error)).toBe(failure) + expect(writes).toEqual([["hello", { destination: "all-available", selection: "clipboard" }]]) }) diff --git a/packages/tui/test/fixture/tui-environment.tsx b/packages/tui/test/fixture/tui-environment.tsx index e2ef9ba7745..5a255721742 100644 --- a/packages/tui/test/fixture/tui-environment.tsx +++ b/packages/tui/test/fixture/tui-environment.tsx @@ -7,6 +7,14 @@ import { } from "../../src/context/runtime" import type { ParentProps } from "solid-js" import { LogProvider, type LogSink } from "../../src/context/log" +import { ClipboardProvider, type ClipboardService } from "../../src/context/clipboard" + +const clipboard: ClipboardService = { + async read() { + return undefined + }, + async write() {}, +} export function TestTuiContexts( props: ParentProps<{ @@ -14,6 +22,7 @@ export function TestTuiContexts( directory?: string paths?: Partial log?: LogSink + clipboard?: ClipboardService }>, ) { return ( @@ -28,7 +37,9 @@ export function TestTuiContexts( }} > - {props.children} + + {props.children} + diff --git a/packages/tui/test/prompt/clipboard.test.ts b/packages/tui/test/prompt/clipboard.test.ts new file mode 100644 index 00000000000..47ed0630be0 --- /dev/null +++ b/packages/tui/test/prompt/clipboard.test.ts @@ -0,0 +1,97 @@ +import { afterAll, expect, mock, test } from "bun:test" +import { TextareaRenderable, type HostClipboardService } from "@opentui/core" +import { createTestRenderer } from "@opentui/core/testing" +import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" +import { Global } from "@opencode-ai/util/global" +import { Effect, FileSystem } from "effect" +import { createComponent } from "solid-js" +import { Prompt } from "../../src/component/prompt" +import { createEventStream, createFetch } from "../fixture/tui-client" + +const openTui = { ...(await import("@opentui/core")) } +let activeSetup: Awaited> | undefined +let reads = 0 + +await mock.module("@opentui/core", () => ({ + ...openTui, + createCliRenderer: async () => { + if (!activeSetup) throw new Error("Prompt renderer is not mounted") + return activeSetup.renderer + }, + createHostClipboard: () => + ({ + maxWriteBytes: 8 * 1024 * 1024, + async read() { + reads++ + return { status: "empty" } + }, + async writeText() { + return { status: "written" } + }, + async clear() { + return { status: "cleared" } + }, + async dispose() {}, + }) satisfies HostClipboardService, +})) +await mock.module("../../src/routes/home", () => ({ + Home: () => createComponent(Prompt, { showPlaceholder: false }), +})) +const { run } = await import("../../src/app") + +afterAll(() => mock.restore()) + +test("only zero-byte terminal pastes read the host clipboard", async () => { + const setup = await createTestRenderer({ width: 80, height: 24, useThread: false }) + activeSetup = setup + reads = 0 + const mounted = Promise.withResolvers() + const setTitle = setup.renderer.setTerminalTitle.bind(setup.renderer) + setup.renderer.setTerminalTitle = (title) => { + if (title === "OpenCode") mounted.resolve() + setTitle(title) + } + const events = createEventStream() + const calls = createFetch(undefined, events) + const preloaded = Promise.withResolvers() + const server = Bun.serve({ + port: 0, + fetch: async (request) => { + const response = await calls.fetch(request) + if (new URL(request.url).pathname === "/api/session/active") preloaded.resolve() + return response + }, + }) + const task = Effect.runPromise( + run({ + app: { name: "test", version: "test", channel: "test" }, + server: { endpoint: { url: server.url.toString() } }, + config: { get: async () => ({ prompt: { paste: "full" as const } }), update: async () => ({}) }, + packages: { resolve: async () => undefined }, + args: {}, + log: () => {}, + }).pipe(Effect.provide(AppNodeBuilder.build(Global.node)), Effect.provide(FileSystem.layerNoop({}))), + ) + + try { + await mounted.promise + await setup.waitFor(() => setup.renderer.currentFocusedEditor instanceof TextareaRenderable) + await preloaded.promise + await Bun.sleep(0) + const input = setup.renderer.currentFocusedEditor + if (!(input instanceof TextareaRenderable)) throw new Error("Prompt textarea is not focused") + + await setup.mockInput.pasteBracketedText(" \t\n") + await setup.waitFor(() => input.plainText === " \t\n") + expect(reads).toBe(0) + + setup.renderer.keyInput.processPaste(new Uint8Array()) + await setup.waitFor(() => reads === 1) + expect(input.plainText).toBe(" \t\n") + } finally { + setup.renderer.destroy() + await task + await server.stop() + activeSetup = undefined + } +})