mirror of
https://github.com/anomalyco/opencode.git
synced 2026-08-07 09:39:46 -04:00
Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| e3750128ed | |||
| 029547a615 | |||
| 51e5136f76 | |||
| 68a80a2665 | |||
| e6c9b6bef7 |
@@ -597,9 +597,9 @@
|
|||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"@opencode-ai/theme": "workspace:*",
|
"@opencode-ai/theme": "workspace:*",
|
||||||
"@opentui/core": ">=0.4.5",
|
"@opentui/core": "0.0.0-20260807-6b4336f3",
|
||||||
"@opentui/keymap": ">=0.4.5",
|
"@opentui/keymap": "0.0.0-20260807-6b4336f3",
|
||||||
"@opentui/solid": ">=0.4.5",
|
"@opentui/solid": "0.0.0-20260807-6b4336f3",
|
||||||
"solid-js": ">=1.9.0",
|
"solid-js": ">=1.9.0",
|
||||||
},
|
},
|
||||||
"optionalPeers": [
|
"optionalPeers": [
|
||||||
@@ -890,7 +890,6 @@
|
|||||||
"@opentui/keymap": "catalog:",
|
"@opentui/keymap": "catalog:",
|
||||||
"@opentui/solid": "catalog:",
|
"@opentui/solid": "catalog:",
|
||||||
"@solid-primitives/event-bus": "1.1.2",
|
"@solid-primitives/event-bus": "1.1.2",
|
||||||
"clipboardy": "4.0.0",
|
|
||||||
"effect": "catalog:",
|
"effect": "catalog:",
|
||||||
"fuzzysort": "catalog:",
|
"fuzzysort": "catalog:",
|
||||||
"get-east-asian-width": "catalog:",
|
"get-east-asian-width": "catalog:",
|
||||||
@@ -1074,9 +1073,9 @@
|
|||||||
"@silvia-odwyer/photon-node@0.3.4": "patches/@silvia-odwyer%2Fphoton-node@0.3.4.patch",
|
"@silvia-odwyer/photon-node@0.3.4": "patches/@silvia-odwyer%2Fphoton-node@0.3.4.patch",
|
||||||
},
|
},
|
||||||
"overrides": {
|
"overrides": {
|
||||||
"@opentui/core": "catalog:",
|
"@opentui/core": "0.0.0-20260807-6b4336f3",
|
||||||
"@opentui/keymap": "catalog:",
|
"@opentui/keymap": "0.0.0-20260807-6b4336f3",
|
||||||
"@opentui/solid": "catalog:",
|
"@opentui/solid": "0.0.0-20260807-6b4336f3",
|
||||||
"@types/bun": "catalog:",
|
"@types/bun": "catalog:",
|
||||||
"@types/node": "catalog:",
|
"@types/node": "catalog:",
|
||||||
"effect": "catalog:",
|
"effect": "catalog:",
|
||||||
@@ -1094,9 +1093,9 @@
|
|||||||
"@npmcli/arborist": "9.4.0",
|
"@npmcli/arborist": "9.4.0",
|
||||||
"@octokit/rest": "22.0.0",
|
"@octokit/rest": "22.0.0",
|
||||||
"@openauthjs/openauth": "0.0.0-20250322224806",
|
"@openauthjs/openauth": "0.0.0-20250322224806",
|
||||||
"@opentui/core": "0.4.5",
|
"@opentui/core": "0.0.0-20260807-6b4336f3",
|
||||||
"@opentui/keymap": "0.4.5",
|
"@opentui/keymap": "0.0.0-20260807-6b4336f3",
|
||||||
"@opentui/solid": "0.4.5",
|
"@opentui/solid": "0.0.0-20260807-6b4336f3",
|
||||||
"@pierre/diffs": "1.2.10",
|
"@pierre/diffs": "1.2.10",
|
||||||
"@playwright/test": "1.59.1",
|
"@playwright/test": "1.59.1",
|
||||||
"@sentry/solid": "10.36.0",
|
"@sentry/solid": "10.36.0",
|
||||||
@@ -2133,27 +2132,27 @@
|
|||||||
|
|
||||||
"@opentelemetry/semantic-conventions": ["@opentelemetry/semantic-conventions@1.43.0", "", {}, "sha512-eSYWTm620tTk45EKSedaUL8MFYI8hW164hIXsgIHyxu3VobUB3fFCu5t0hQby6OoWRPsG1KkKUG2M5UadiLiVg=="],
|
"@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-20260807-6b4336f3", "", { "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-20260807-6b4336f3", "@opentui/core-darwin-x64": "0.0.0-20260807-6b4336f3", "@opentui/core-linux-arm64": "0.0.0-20260807-6b4336f3", "@opentui/core-linux-arm64-musl": "0.0.0-20260807-6b4336f3", "@opentui/core-linux-x64": "0.0.0-20260807-6b4336f3", "@opentui/core-linux-x64-musl": "0.0.0-20260807-6b4336f3", "@opentui/core-win32-arm64": "0.0.0-20260807-6b4336f3", "@opentui/core-win32-x64": "0.0.0-20260807-6b4336f3" }, "peerDependencies": { "web-tree-sitter": "0.25.10" } }, "sha512-ExOJ/jlJwDEh/S+IadNdG4ueTSG1rMu5r7Vyr6PXHqMNd1oY4T94aabRCXZJ+weL3flO07Iw8OW92EvvC4Nf+A=="],
|
||||||
|
|
||||||
"@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-20260807-6b4336f3", "", { "os": "darwin", "cpu": "arm64" }, "sha512-cL9wjMoQdXbIVi60Xc0ktkbsHEvG9wePXcmS55LXIAoPQE2xbbGOOBmRQL8nrBzs5kdOAehzaYaIrbT5H290KA=="],
|
||||||
|
|
||||||
"@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-20260807-6b4336f3", "", { "os": "darwin", "cpu": "x64" }, "sha512-Vtdr6QCBhIc+gZYpcBhPBbUNV6j3IcfBdf3auKEAYUGEYjyD0aHlKsur8nKndeEDoEOuSeWMxAs97on2ejpetw=="],
|
||||||
|
|
||||||
"@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-20260807-6b4336f3", "", { "os": "linux", "cpu": "arm64" }, "sha512-zN82yIVO7pEse3ZdOmIJAuFtCMbAURkMausD769NdJiJP0OAVs+21T0TMPRNB9fLgaIWDy1Ojk0mn2xPnF3QWg=="],
|
||||||
|
|
||||||
"@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-20260807-6b4336f3", "", { "os": "linux", "cpu": "arm64" }, "sha512-0xjJU9q/v5YUTzEE2pxdNFCYVlNzvNx45zSTOIPGrodXo1ecuwBh+0IyCa0EnCIciK7y2wHBd5AatUv0WXEbsw=="],
|
||||||
|
|
||||||
"@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-20260807-6b4336f3", "", { "os": "linux", "cpu": "x64" }, "sha512-RuUEbnNfb8gzwVODCF5mUii0FW2r6P0rorSQbI/4+CwJgq9kTr5Nml4/J2wvxvxFiROWFgciohyFfiIgR7bhDA=="],
|
||||||
|
|
||||||
"@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-20260807-6b4336f3", "", { "os": "linux", "cpu": "x64" }, "sha512-2DvIF+3CArHx7cY/KI3lmMdqKjPoxAuP/Haj+Fm+uCKRT4mQGGfkfRnparbFPs2dNxl4zmrT8/DolDZhpvcX8g=="],
|
||||||
|
|
||||||
"@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-20260807-6b4336f3", "", { "os": "win32", "cpu": "arm64" }, "sha512-JYj7hDjx6qvpuL8C9bvhlvkD01sj3wSzCbuTMb1hq9lq1nMbtaekUWklLr6tEyxjDmu5+XkxCoxbUSbO7BvT6A=="],
|
||||||
|
|
||||||
"@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-20260807-6b4336f3", "", { "os": "win32", "cpu": "x64" }, "sha512-++aXqPN0LlWUvNpzqUztGa3iI6EFrGxLC/EZxhi6NzBCRZN8nyfT8lzEyr6A5cMtjtv0/c0otSOU9fy1wgJkAQ=="],
|
||||||
|
|
||||||
"@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-20260807-6b4336f3", "", { "dependencies": { "@opentui/core": "0.0.0-20260807-6b4336f3" }, "peerDependencies": { "@opentui/react": "0.0.0-20260807-6b4336f3", "@opentui/solid": "0.0.0-20260807-6b4336f3", "react": ">=19.2.0", "solid-js": "1.9.12" }, "optionalPeers": ["@opentui/react", "@opentui/solid", "react", "solid-js"] }, "sha512-WE24FrJqv/cZSuNRB0MAIZrTZRNBFIeTahscRYD8C0PoQ9eMJAcVT0RDy85gMONfCOW9G1O2g1WQlWU1nz/cWg=="],
|
||||||
|
|
||||||
"@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-20260807-6b4336f3", "", { "dependencies": { "@babel/core": "7.28.0", "@babel/preset-typescript": "7.27.1", "@opentui/core": "0.0.0-20260807-6b4336f3", "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-Vn8SG0Zh2uWICXlz5ms0CSiRZrVO7zbQZOfj/NFE4h1fRm0n3oqXJBtmSbX/KWCzq9l181hP0PtbLomqgmONiQ=="],
|
||||||
|
|
||||||
"@orama/orama": ["@orama/orama@3.1.18", "", {}, "sha512-a61ljmRVVyG5MC/698C8/FfFDw5a8LOIvyOLW5fztgUXqUpc1jOfQzOitSCbge657OgXXThmY3Tk8fpiDb4UcA=="],
|
"@orama/orama": ["@orama/orama@3.1.18", "", {}, "sha512-a61ljmRVVyG5MC/698C8/FfFDw5a8LOIvyOLW5fztgUXqUpc1jOfQzOitSCbge657OgXXThmY3Tk8fpiDb4UcA=="],
|
||||||
|
|
||||||
@@ -3501,7 +3500,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=="],
|
"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=="],
|
"bun-pty": ["bun-pty@0.4.8", "", {}, "sha512-rO70Mrbr13+jxHHHu2YBkk2pNqrJE5cJn29WE++PUr+GFA0hq/VgtQPZANJ8dJo6d7XImvBk37Innt8GM7O28w=="],
|
||||||
|
|
||||||
@@ -3581,8 +3580,6 @@
|
|||||||
|
|
||||||
"cli-truncate": ["cli-truncate@4.0.0", "", { "dependencies": { "slice-ansi": "^5.0.0", "string-width": "^7.0.0" } }, "sha512-nPdaFdQ0h/GEigbPClz11D0v/ZJEwxmeVZGeMo3Z5StPtUTkA9o1lD6QwoirYiSDzbcwn2XcjwmCp68W1IS4TA=="],
|
"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=="],
|
"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=="],
|
"clone": ["clone@2.1.2", "", {}, "sha512-3Pe/CF1Nn94hyhIYpjtiLhdCoEoz0DqQ+988E9gmeEdQZlojxnOb74wctFyuwWQHzqyf9X7C7MG8juUpqBJT8w=="],
|
||||||
@@ -3999,7 +3996,7 @@
|
|||||||
|
|
||||||
"eventsource-parser": ["eventsource-parser@3.1.0", "", {}, "sha512-kJezFj9YFAMLeORyi7aCLxLbD5/qWMQnoMVlVPyHIll7lgRJCc3JVln9Vgl9nwQi0YkMnhdGTMNn7CkRRAptMg=="],
|
"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=="],
|
"exit-hook": ["exit-hook@2.2.1", "", {}, "sha512-eNTPlAD67BmP31LDINZ3U7HSF8l57TxOY2PmBJ1shpCvpnxBF93mWCE8YHBnXs8qiUZJc9WDcWIeC3a2HIAMfw=="],
|
||||||
|
|
||||||
@@ -4281,7 +4278,7 @@
|
|||||||
|
|
||||||
"https-proxy-agent": ["https-proxy-agent@7.0.6", "", { "dependencies": { "agent-base": "^7.1.2", "debug": "4" } }, "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw=="],
|
"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=="],
|
"humanize-ms": ["humanize-ms@1.2.1", "", { "dependencies": { "ms": "^2.0.0" } }, "sha512-Fl70vYtsAFb/C06PTS9dZBo7ihau+Tu/DNCk/OyHhea07S+aeMWpFFkUaXRa8fI+ScZbEI8dfSxwY7gxZ9SAVQ=="],
|
||||||
|
|
||||||
@@ -4425,8 +4422,6 @@
|
|||||||
|
|
||||||
"is-wsl": ["is-wsl@3.1.1", "", { "dependencies": { "is-inside-container": "^1.0.0" } }, "sha512-e6rvdUCiQCAuumZslxRJWR/Doq4VpPR82kqclvcS0efgt430SlGIk05vdCN58+VrzgtIcfNODjozVielycD4Sw=="],
|
"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=="],
|
"isarray": ["isarray@2.0.5", "", {}, "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw=="],
|
||||||
|
|
||||||
"isbinaryfile": ["isbinaryfile@5.0.7", "", {}, "sha512-gnWD14Jh3FzS3CPhF0AxNOJ8CxqeblPTADzI38r0wt8ZyQl5edpy75myt08EG2oKvpyiqSqsx+Wkz9vtkbTqYQ=="],
|
"isbinaryfile": ["isbinaryfile@5.0.7", "", {}, "sha512-gnWD14Jh3FzS3CPhF0AxNOJ8CxqeblPTADzI38r0wt8ZyQl5edpy75myt08EG2oKvpyiqSqsx+Wkz9vtkbTqYQ=="],
|
||||||
@@ -4765,7 +4760,7 @@
|
|||||||
|
|
||||||
"mime-types": ["mime-types@3.0.2", "", { "dependencies": { "mime-db": "^1.54.0" } }, "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A=="],
|
"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=="],
|
"mimic-function": ["mimic-function@5.0.1", "", {}, "sha512-VP79XUPxV2CigYP3jWwAUFSku2aKqBH7uTAapFWCBqutsbmDo96KY5o8uh6U+/YSIn5OxJnXp73beVkpqMIGhA=="],
|
||||||
|
|
||||||
@@ -4885,7 +4880,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-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=="],
|
"nth-check": ["nth-check@2.1.1", "", { "dependencies": { "boolbase": "^1.0.0" } }, "sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w=="],
|
||||||
|
|
||||||
@@ -4911,7 +4906,7 @@
|
|||||||
|
|
||||||
"once": ["once@1.4.0", "", { "dependencies": { "wrappy": "1" } }, "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w=="],
|
"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=="],
|
"oniguruma-parser": ["oniguruma-parser@0.12.2", "", {}, "sha512-6HVa5oIrgMC6aA6WF6XyyqbhRPJrKR02L20+2+zpDtO5QAzGHAUGw5TKQvwi5vctNnRHkJYmjAhRVQF2EKdTQw=="],
|
||||||
|
|
||||||
@@ -5511,7 +5506,7 @@
|
|||||||
|
|
||||||
"strip-bom-string": ["strip-bom-string@1.0.0", "", {}, "sha512-uCC2VHvQRYu+lMh4My/sFNmF2klFymLX1wHJeXnbEJERpV/ZsVuonzerjfrGpIGF7LBVa1O7i9kjiWvJiFck8g=="],
|
"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=="],
|
"strip-indent": ["strip-indent@3.0.0", "", { "dependencies": { "min-indent": "^1.0.0" } }, "sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ=="],
|
||||||
|
|
||||||
@@ -5543,8 +5538,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=="],
|
"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=="],
|
"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=="],
|
"tailwindcss": ["tailwindcss@4.1.11", "", {}, "sha512-2E9TBm6MDD/xKYe+dvJZAmg3yxIEDNRc0jwlNyDg/4Fil2QcSLjFKGVff0lAf1jjeaArlG/M75Ey/EYr/OJtBA=="],
|
||||||
@@ -6483,8 +6476,6 @@
|
|||||||
|
|
||||||
"@vercel/cli-config/zod": ["zod@4.1.11", "", {}, "sha512-WPsqwxITS2tzx1bzhIKsEs19ABD5vmCVa4xBo2tq/SrV4RNZtfws1EnCWQXM6yh8bD08a1idvkB5MZSBiZsjwg=="],
|
"@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/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=="],
|
"@vercel/nft/acorn": ["acorn@8.17.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-xRQbDb9BnwDafYNn6Vwl839DYVjqXYb1XVGtWAZ1kcDc6iwAL4hg3B1dZlRiuENFeO2H53gFG3in621AdERVAg=="],
|
||||||
@@ -6669,9 +6660,11 @@
|
|||||||
|
|
||||||
"estree-util-to-js/source-map": ["source-map@0.7.6", "", {}, "sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ=="],
|
"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=="],
|
"express/cookie": ["cookie@0.7.2", "", {}, "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w=="],
|
||||||
|
|
||||||
@@ -6743,8 +6736,6 @@
|
|||||||
|
|
||||||
"node-gyp/undici": ["undici@6.28.0", "", {}, "sha512-LIY910g9TI13YS95lrMFrs8Rm/u/irgHeTWoKCoteeJ04CUJ92eEfj0rVn+7VKMPBpUPiUoBKfhNyLI23EE/KA=="],
|
"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/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=="],
|
"openid-client/lru-cache": ["lru-cache@6.0.0", "", { "dependencies": { "yallist": "^4.0.0" } }, "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA=="],
|
||||||
@@ -7515,20 +7506,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=="],
|
"@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/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=="],
|
"@vercel/routing-utils/ajv/json-schema-traverse": ["json-schema-traverse@0.4.1", "", {}, "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg=="],
|
||||||
@@ -8585,8 +8562,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=="],
|
"@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=="],
|
"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=="],
|
"app-builder-lib/@electron/get/fs-extra/universalify": ["universalify@0.1.2", "", {}, "sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg=="],
|
||||||
|
|||||||
+6
-6
@@ -46,9 +46,9 @@
|
|||||||
"@octokit/rest": "22.0.0",
|
"@octokit/rest": "22.0.0",
|
||||||
"@hono/standard-validator": "0.2.0",
|
"@hono/standard-validator": "0.2.0",
|
||||||
"@hono/zod-validator": "0.4.2",
|
"@hono/zod-validator": "0.4.2",
|
||||||
"@opentui/core": "0.4.5",
|
"@opentui/core": "0.0.0-20260807-6b4336f3",
|
||||||
"@opentui/keymap": "0.4.5",
|
"@opentui/keymap": "0.0.0-20260807-6b4336f3",
|
||||||
"@opentui/solid": "0.4.5",
|
"@opentui/solid": "0.0.0-20260807-6b4336f3",
|
||||||
"@tanstack/solid-virtual": "3.13.32",
|
"@tanstack/solid-virtual": "3.13.32",
|
||||||
"@shikijs/stream": "4.2.0",
|
"@shikijs/stream": "4.2.0",
|
||||||
"@standard-schema/spec": "1.1.0",
|
"@standard-schema/spec": "1.1.0",
|
||||||
@@ -149,9 +149,9 @@
|
|||||||
"electron"
|
"electron"
|
||||||
],
|
],
|
||||||
"overrides": {
|
"overrides": {
|
||||||
"@opentui/core": "catalog:",
|
"@opentui/core": "0.0.0-20260807-6b4336f3",
|
||||||
"@opentui/keymap": "catalog:",
|
"@opentui/keymap": "0.0.0-20260807-6b4336f3",
|
||||||
"@opentui/solid": "catalog:",
|
"@opentui/solid": "0.0.0-20260807-6b4336f3",
|
||||||
"@types/bun": "catalog:",
|
"@types/bun": "catalog:",
|
||||||
"@types/node": "catalog:",
|
"@types/node": "catalog:",
|
||||||
"effect": "catalog:"
|
"effect": "catalog:"
|
||||||
|
|||||||
@@ -6,17 +6,21 @@ import { Icon } from "@opencode-ai/ui/icon"
|
|||||||
import { IconButton } from "@opencode-ai/ui/icon-button"
|
import { IconButton } from "@opencode-ai/ui/icon-button"
|
||||||
import { List } from "@opencode-ai/ui/list"
|
import { List } from "@opencode-ai/ui/list"
|
||||||
import { TextField } from "@opencode-ai/ui/text-field"
|
import { TextField } from "@opencode-ai/ui/text-field"
|
||||||
import { Show } from "solid-js"
|
import { useMutation } from "@tanstack/solid-query"
|
||||||
|
import { showToast } from "@/utils/toast"
|
||||||
|
import { useNavigate } from "@solidjs/router"
|
||||||
|
import { createEffect, createMemo, createResource, Show } from "solid-js"
|
||||||
|
import { createStore } from "solid-js/store"
|
||||||
import { ServerHealthIndicator, ServerRow } from "@/components/server/server-row"
|
import { ServerHealthIndicator, ServerRow } from "@/components/server/server-row"
|
||||||
|
import { useGlobal } from "@/context/global"
|
||||||
import { useLanguage } from "@/context/language"
|
import { useLanguage } from "@/context/language"
|
||||||
import { ServerConnection } from "@/context/server"
|
import { usePlatform } from "@/context/platform"
|
||||||
|
import { normalizeServerUrl, ServerConnection, useServer } from "@/context/server"
|
||||||
|
import { type ServerHealth, useCheckServerHealth } from "@/utils/server-health"
|
||||||
import { useSettings } from "@/context/settings"
|
import { useSettings } from "@/context/settings"
|
||||||
import {
|
import { useTabs } from "@/context/tabs"
|
||||||
type ServerDomainController,
|
|
||||||
type ServerFormController,
|
const DEFAULT_USERNAME = "opencode"
|
||||||
useServerDomainController,
|
|
||||||
useServerFormController,
|
|
||||||
} from "@/components/server/server-management-controller"
|
|
||||||
|
|
||||||
interface ServerFormProps {
|
interface ServerFormProps {
|
||||||
value: string
|
value: string
|
||||||
@@ -35,6 +39,76 @@ interface ServerFormProps {
|
|||||||
onBack: () => void
|
onBack: () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function showRequestError(language: ReturnType<typeof useLanguage>, err: unknown) {
|
||||||
|
showToast({
|
||||||
|
variant: "error",
|
||||||
|
title: language.t("common.requestFailed"),
|
||||||
|
description: err instanceof Error ? err.message : String(err),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function useDefaultServer() {
|
||||||
|
const language = useLanguage()
|
||||||
|
const platform = usePlatform()
|
||||||
|
const [defaultKey, defaultUrlActions] = createResource(
|
||||||
|
async () => {
|
||||||
|
try {
|
||||||
|
const key = await platform.getDefaultServer?.()
|
||||||
|
if (!key) return null
|
||||||
|
return key
|
||||||
|
} catch (err) {
|
||||||
|
showRequestError(language, err)
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ initialValue: null },
|
||||||
|
)
|
||||||
|
|
||||||
|
const canDefault = createMemo(() => !!platform.getDefaultServer && !!platform.setDefaultServer)
|
||||||
|
const setDefault = async (key: ServerConnection.Key | null) => {
|
||||||
|
try {
|
||||||
|
await platform.setDefaultServer?.(key)
|
||||||
|
defaultUrlActions.mutate(key)
|
||||||
|
} catch (err) {
|
||||||
|
showRequestError(language, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { defaultKey: () => defaultKey.latest, canDefault, setDefault }
|
||||||
|
}
|
||||||
|
|
||||||
|
function useServerPreview() {
|
||||||
|
const checkServerHealth = useCheckServerHealth()
|
||||||
|
|
||||||
|
const looksComplete = (value: string) => {
|
||||||
|
const normalized = normalizeServerUrl(value)
|
||||||
|
if (!normalized) return false
|
||||||
|
const host = normalized.replace(/^https?:\/\//, "").split("/")[0]
|
||||||
|
if (!host) return false
|
||||||
|
if (host.includes("localhost") || host.startsWith("127.0.0.1")) return true
|
||||||
|
return host.includes(".") || host.includes(":")
|
||||||
|
}
|
||||||
|
|
||||||
|
const previewStatus = async (
|
||||||
|
value: string,
|
||||||
|
username: string,
|
||||||
|
password: string,
|
||||||
|
setStatus: (value: boolean | undefined) => void,
|
||||||
|
) => {
|
||||||
|
setStatus(undefined)
|
||||||
|
if (!looksComplete(value)) return
|
||||||
|
const normalized = normalizeServerUrl(value)
|
||||||
|
if (!normalized) return
|
||||||
|
const http: ServerConnection.HttpBase = { url: normalized }
|
||||||
|
if (username) http.username = username
|
||||||
|
if (password) http.password = password
|
||||||
|
const result = await checkServerHealth(http)
|
||||||
|
setStatus(result.healthy)
|
||||||
|
}
|
||||||
|
|
||||||
|
return { previewStatus }
|
||||||
|
}
|
||||||
|
|
||||||
function ServerForm(props: ServerFormProps) {
|
function ServerForm(props: ServerFormProps) {
|
||||||
const language = useLanguage()
|
const language = useLanguage()
|
||||||
const keyDown = (event: KeyboardEvent) => {
|
const keyDown = (event: KeyboardEvent) => {
|
||||||
@@ -102,40 +176,385 @@ function ServerForm(props: ServerFormProps) {
|
|||||||
|
|
||||||
export function DialogSelectServer() {
|
export function DialogSelectServer() {
|
||||||
const dialog = useDialog()
|
const dialog = useDialog()
|
||||||
const language = useLanguage()
|
const controller = useServerManagementController({ onSelect: dialog.close })
|
||||||
const domain = useServerDomainController({ onSelect: () => dialog.close() })
|
|
||||||
const form = useServerFormController({ onSelect: () => dialog.close() })
|
|
||||||
const title = () => {
|
|
||||||
if (!form.state.open()) return language.t("dialog.server.title")
|
|
||||||
return (
|
|
||||||
<div class="flex items-center gap-2 -ml-2">
|
|
||||||
<IconButton icon="arrow-left" variant="ghost" onClick={form.reset} aria-label={language.t("common.goBack")} />
|
|
||||||
<span>
|
|
||||||
{form.state.adding() ? language.t("dialog.server.add.title") : language.t("dialog.server.edit.title")}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog title={title()}>
|
<Dialog title={controller.formTitle()}>
|
||||||
<div class="flex flex-1 min-h-0 flex-col px-5">
|
<div class="flex flex-1 min-h-0 flex-col px-5">
|
||||||
<Show
|
<Show when={controller.isFormMode()} fallback={<ServerConnectionList controller={controller} />}>
|
||||||
when={form.state.open()}
|
<ServerConnectionForm controller={controller} />
|
||||||
fallback={<ServerConnectionList domain={domain} onAdd={form.start.add} onEdit={form.start.edit} />}
|
|
||||||
>
|
|
||||||
<ServerConnectionForm form={form} />
|
|
||||||
</Show>
|
</Show>
|
||||||
</div>
|
</div>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ServerConnectionList(props: {
|
export function useServerManagementController(options: { onSelect?: () => void; navigateOnAdd?: boolean } = {}) {
|
||||||
domain: ServerDomainController
|
const navigate = useNavigate()
|
||||||
onAdd: () => void
|
const server = useServer()
|
||||||
onEdit: (server: ServerConnection.Http) => void
|
const tabs = useTabs()
|
||||||
}) {
|
const global = useGlobal()
|
||||||
|
const platform = usePlatform()
|
||||||
|
const language = useLanguage()
|
||||||
|
const { defaultKey, canDefault, setDefault } = useDefaultServer()
|
||||||
|
const { previewStatus } = useServerPreview()
|
||||||
|
const checkServerHealth = useCheckServerHealth()
|
||||||
|
const [store, setStore] = createStore({
|
||||||
|
addServer: {
|
||||||
|
url: "",
|
||||||
|
name: "",
|
||||||
|
username: DEFAULT_USERNAME,
|
||||||
|
password: "",
|
||||||
|
error: "",
|
||||||
|
showForm: false,
|
||||||
|
status: undefined as boolean | undefined,
|
||||||
|
},
|
||||||
|
editServer: {
|
||||||
|
id: undefined as string | undefined,
|
||||||
|
value: "",
|
||||||
|
name: "",
|
||||||
|
username: "",
|
||||||
|
password: "",
|
||||||
|
error: "",
|
||||||
|
status: undefined as boolean | undefined,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const resetAdd = () => {
|
||||||
|
setStore("addServer", {
|
||||||
|
url: "",
|
||||||
|
name: "",
|
||||||
|
username: DEFAULT_USERNAME,
|
||||||
|
password: "",
|
||||||
|
error: "",
|
||||||
|
showForm: false,
|
||||||
|
status: undefined,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
const resetEdit = () => {
|
||||||
|
setStore("editServer", {
|
||||||
|
id: undefined,
|
||||||
|
value: "",
|
||||||
|
name: "",
|
||||||
|
username: "",
|
||||||
|
password: "",
|
||||||
|
error: "",
|
||||||
|
status: undefined,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const addMutation = useMutation(() => ({
|
||||||
|
mutationFn: async (value: string) => {
|
||||||
|
const normalized = normalizeServerUrl(value)
|
||||||
|
if (!normalized) {
|
||||||
|
resetAdd()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const conn: ServerConnection.Http = {
|
||||||
|
type: "http",
|
||||||
|
http: { url: normalized },
|
||||||
|
}
|
||||||
|
if (store.addServer.name.trim()) conn.displayName = store.addServer.name.trim()
|
||||||
|
if (store.addServer.password) conn.http.password = store.addServer.password
|
||||||
|
if (store.addServer.password && store.addServer.username) conn.http.username = store.addServer.username
|
||||||
|
const result = await checkServerHealth(conn.http)
|
||||||
|
if (!result.healthy) {
|
||||||
|
setStore("addServer", { error: language.t("dialog.server.add.error") })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
resetAdd()
|
||||||
|
if (options.navigateOnAdd === false) {
|
||||||
|
server.add(conn)
|
||||||
|
options.onSelect?.()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
await select(conn, true)
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
|
||||||
|
const editMutation = useMutation(() => ({
|
||||||
|
mutationFn: async (input: { original: ServerConnection.Any; value: string }) => {
|
||||||
|
if (input.original.type !== "http") return
|
||||||
|
const normalized = normalizeServerUrl(input.value)
|
||||||
|
if (!normalized) {
|
||||||
|
resetEdit()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const name = store.editServer.name.trim() || undefined
|
||||||
|
const username = store.editServer.username || undefined
|
||||||
|
const password = store.editServer.password || undefined
|
||||||
|
const existingName = input.original.displayName
|
||||||
|
if (
|
||||||
|
normalized === input.original.http.url &&
|
||||||
|
name === existingName &&
|
||||||
|
username === input.original.http.username &&
|
||||||
|
password === input.original.http.password
|
||||||
|
) {
|
||||||
|
resetEdit()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const conn: ServerConnection.Http = {
|
||||||
|
type: "http",
|
||||||
|
displayName: name,
|
||||||
|
http: { url: normalized, username, password },
|
||||||
|
}
|
||||||
|
const result = await checkServerHealth(conn.http)
|
||||||
|
if (!result.healthy) {
|
||||||
|
setStore("editServer", { error: language.t("dialog.server.add.error") })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (normalized === input.original.http.url) {
|
||||||
|
server.add(conn)
|
||||||
|
} else {
|
||||||
|
replaceServer(input.original, conn)
|
||||||
|
}
|
||||||
|
|
||||||
|
resetEdit()
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
|
||||||
|
const replaceServer = (original: ServerConnection.Http, next: ServerConnection.Http) => {
|
||||||
|
const originalKey = ServerConnection.key(original)
|
||||||
|
const active = server.key
|
||||||
|
tabs.removeServer(originalKey)
|
||||||
|
const newConn = server.add(next)
|
||||||
|
if (!newConn) return
|
||||||
|
const nextActive = active === originalKey ? ServerConnection.key(newConn) : active
|
||||||
|
if (nextActive) server.setActive(nextActive)
|
||||||
|
server.remove(originalKey)
|
||||||
|
}
|
||||||
|
|
||||||
|
const items = createMemo(() => {
|
||||||
|
const current = server.current
|
||||||
|
const list = server.list
|
||||||
|
if (!current) return list
|
||||||
|
if (!list.includes(current)) return [current, ...list]
|
||||||
|
return [current, ...list.filter((x) => x !== current)]
|
||||||
|
})
|
||||||
|
|
||||||
|
const settings = useSettings()
|
||||||
|
const current = createMemo<ServerConnection.Any | undefined>(() =>
|
||||||
|
settings.general.newLayoutDesigns()
|
||||||
|
? undefined
|
||||||
|
: (items().find((x) => ServerConnection.key(x) === server.key) ?? items()[0]),
|
||||||
|
)
|
||||||
|
|
||||||
|
const sortedItems = createMemo(() => {
|
||||||
|
const raw = items()
|
||||||
|
const list = raw
|
||||||
|
if (!list.length) return list
|
||||||
|
const active = current()
|
||||||
|
const order = new Map(list.map((url, index) => [url, index] as const))
|
||||||
|
const rank = (value?: ServerHealth) => {
|
||||||
|
if (value?.healthy === true) return 0
|
||||||
|
if (value?.healthy === false) return 2
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
return list.slice().sort((a, b) => {
|
||||||
|
if (a === active) return -1
|
||||||
|
if (b === active) return 1
|
||||||
|
const diff =
|
||||||
|
rank(global.servers.health[ServerConnection.key(a)]) - rank(global.servers.health[ServerConnection.key(b)])
|
||||||
|
if (diff !== 0) return diff
|
||||||
|
return (order.get(a) ?? 0) - (order.get(b) ?? 0)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
async function select(conn: ServerConnection.Any, persist?: boolean) {
|
||||||
|
if (!persist && global.servers.health[ServerConnection.key(conn)]?.healthy === false) return
|
||||||
|
options.onSelect?.()
|
||||||
|
if (persist && conn.type === "http") {
|
||||||
|
server.add(conn)
|
||||||
|
navigate("/")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
navigate("/")
|
||||||
|
queueMicrotask(() => server.setActive(ServerConnection.key(conn)))
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleAddChange = (value: string) => {
|
||||||
|
if (addMutation.isPending) return
|
||||||
|
setStore("addServer", { url: value, error: "" })
|
||||||
|
void previewStatus(value, store.addServer.username, store.addServer.password, (next) =>
|
||||||
|
setStore("addServer", { status: next }),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleAddNameChange = (value: string) => {
|
||||||
|
if (addMutation.isPending) return
|
||||||
|
setStore("addServer", { name: value, error: "" })
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleAddUsernameChange = (value: string) => {
|
||||||
|
if (addMutation.isPending) return
|
||||||
|
setStore("addServer", { username: value, error: "" })
|
||||||
|
void previewStatus(store.addServer.url, value, store.addServer.password, (next) =>
|
||||||
|
setStore("addServer", { status: next }),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleAddPasswordChange = (value: string) => {
|
||||||
|
if (addMutation.isPending) return
|
||||||
|
setStore("addServer", { password: value, error: "" })
|
||||||
|
void previewStatus(store.addServer.url, store.addServer.username, value, (next) =>
|
||||||
|
setStore("addServer", { status: next }),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleEditChange = (value: string) => {
|
||||||
|
if (editMutation.isPending) return
|
||||||
|
setStore("editServer", { value, error: "" })
|
||||||
|
void previewStatus(value, store.editServer.username, store.editServer.password, (next) =>
|
||||||
|
setStore("editServer", { status: next }),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleEditNameChange = (value: string) => {
|
||||||
|
if (editMutation.isPending) return
|
||||||
|
setStore("editServer", { name: value, error: "" })
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleEditUsernameChange = (value: string) => {
|
||||||
|
if (editMutation.isPending) return
|
||||||
|
setStore("editServer", { username: value, error: "" })
|
||||||
|
void previewStatus(store.editServer.value, value, store.editServer.password, (next) =>
|
||||||
|
setStore("editServer", { status: next }),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleEditPasswordChange = (value: string) => {
|
||||||
|
if (editMutation.isPending) return
|
||||||
|
setStore("editServer", { password: value, error: "" })
|
||||||
|
void previewStatus(store.editServer.value, store.editServer.username, value, (next) =>
|
||||||
|
setStore("editServer", { status: next }),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const mode = createMemo<"list" | "add" | "edit">(() => {
|
||||||
|
if (store.editServer.id) return "edit"
|
||||||
|
if (store.addServer.showForm) return "add"
|
||||||
|
return "list"
|
||||||
|
})
|
||||||
|
|
||||||
|
const editing = createMemo(() => {
|
||||||
|
if (!store.editServer.id) return
|
||||||
|
return items().find((x) => x.type === "http" && x.http.url === store.editServer.id)
|
||||||
|
})
|
||||||
|
|
||||||
|
const resetForm = () => {
|
||||||
|
resetAdd()
|
||||||
|
resetEdit()
|
||||||
|
}
|
||||||
|
|
||||||
|
const startAdd = () => {
|
||||||
|
resetEdit()
|
||||||
|
setStore("addServer", {
|
||||||
|
showForm: true,
|
||||||
|
url: "",
|
||||||
|
name: "",
|
||||||
|
username: DEFAULT_USERNAME,
|
||||||
|
password: "",
|
||||||
|
error: "",
|
||||||
|
status: undefined,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const startEdit = (conn: ServerConnection.Http) => {
|
||||||
|
resetAdd()
|
||||||
|
setStore("editServer", {
|
||||||
|
id: conn.http.url,
|
||||||
|
value: conn.http.url,
|
||||||
|
name: conn.displayName ?? "",
|
||||||
|
username: conn.http.username ?? "",
|
||||||
|
password: conn.http.password ?? "",
|
||||||
|
error: "",
|
||||||
|
status: global.servers.health[ServerConnection.key(conn)]?.healthy,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const submitForm = () => {
|
||||||
|
if (mode() === "add") {
|
||||||
|
if (addMutation.isPending) return
|
||||||
|
setStore("addServer", { error: "" })
|
||||||
|
addMutation.mutate(store.addServer.url)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const original = editing()
|
||||||
|
if (!original) return
|
||||||
|
if (editMutation.isPending) return
|
||||||
|
setStore("editServer", { error: "" })
|
||||||
|
editMutation.mutate({ original, value: store.editServer.value })
|
||||||
|
}
|
||||||
|
|
||||||
|
const isFormMode = createMemo(() => mode() !== "list")
|
||||||
|
const isAddMode = createMemo(() => mode() === "add")
|
||||||
|
const formBusy = createMemo(() => (isAddMode() ? addMutation.isPending : editMutation.isPending))
|
||||||
|
|
||||||
|
const formTitle = createMemo(() => {
|
||||||
|
if (!isFormMode()) return language.t("dialog.server.title")
|
||||||
|
return (
|
||||||
|
<div class="flex items-center gap-2 -ml-2">
|
||||||
|
<IconButton icon="arrow-left" variant="ghost" onClick={resetForm} aria-label={language.t("common.goBack")} />
|
||||||
|
<span>{isAddMode() ? language.t("dialog.server.add.title") : language.t("dialog.server.edit.title")}</span>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
createEffect(() => {
|
||||||
|
if (!store.editServer.id) return
|
||||||
|
if (editing()) return
|
||||||
|
resetEdit()
|
||||||
|
})
|
||||||
|
|
||||||
|
async function handleRemove(key: ServerConnection.Key) {
|
||||||
|
try {
|
||||||
|
if (key.startsWith("wsl:")) await platform.wslServers?.removeServer(key)
|
||||||
|
tabs.removeServer(key)
|
||||||
|
server.remove(key)
|
||||||
|
if ((await platform.getDefaultServer?.()) === key) {
|
||||||
|
await setDefault(null)
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
showRequestError(language, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
defaultKey,
|
||||||
|
canDefault,
|
||||||
|
current,
|
||||||
|
sortedItems,
|
||||||
|
status: () => global.servers.health,
|
||||||
|
isFormMode,
|
||||||
|
isAddMode,
|
||||||
|
formTitle,
|
||||||
|
formBusy,
|
||||||
|
formValue: () => (isAddMode() ? store.addServer.url : store.editServer.value),
|
||||||
|
formName: () => (isAddMode() ? store.addServer.name : store.editServer.name),
|
||||||
|
formUsername: () => (isAddMode() ? store.addServer.username : store.editServer.username),
|
||||||
|
formPassword: () => (isAddMode() ? store.addServer.password : store.editServer.password),
|
||||||
|
formError: () => (isAddMode() ? store.addServer.error : store.editServer.error),
|
||||||
|
formStatus: () => (isAddMode() ? store.addServer.status : store.editServer.status),
|
||||||
|
select,
|
||||||
|
setDefault,
|
||||||
|
startAdd,
|
||||||
|
startEdit,
|
||||||
|
resetForm,
|
||||||
|
submitForm,
|
||||||
|
canRemove: server.canRemove,
|
||||||
|
handleRemove,
|
||||||
|
handleFormChange: () => (isAddMode() ? handleAddChange : handleEditChange),
|
||||||
|
handleFormNameChange: () => (isAddMode() ? handleAddNameChange : handleEditNameChange),
|
||||||
|
handleFormUsernameChange: () => (isAddMode() ? handleAddUsernameChange : handleEditUsernameChange),
|
||||||
|
handleFormPasswordChange: () => (isAddMode() ? handleAddPasswordChange : handleEditPasswordChange),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ServerConnectionList(props: { controller: ReturnType<typeof useServerManagementController> }) {
|
||||||
const language = useLanguage()
|
const language = useLanguage()
|
||||||
const settings = useSettings()
|
const settings = useSettings()
|
||||||
|
|
||||||
@@ -149,10 +568,10 @@ export function ServerConnectionList(props: {
|
|||||||
}}
|
}}
|
||||||
noInitialSelection
|
noInitialSelection
|
||||||
emptyMessage={language.t("dialog.server.empty")}
|
emptyMessage={language.t("dialog.server.empty")}
|
||||||
items={props.domain.collection.items}
|
items={props.controller.sortedItems}
|
||||||
key={(x) => x.http.url}
|
key={(x) => x.http.url}
|
||||||
onSelect={(x) => {
|
onSelect={(x) => {
|
||||||
if (x && !settings.general.newLayoutDesigns()) void props.domain.selection.select(x)
|
if (x && !settings.general.newLayoutDesigns()) void props.controller.select(x)
|
||||||
}}
|
}}
|
||||||
divider={true}
|
divider={true}
|
||||||
>
|
>
|
||||||
@@ -161,15 +580,15 @@ export function ServerConnectionList(props: {
|
|||||||
return (
|
return (
|
||||||
<div class="flex items-center gap-3 min-w-0 flex-1 w-full group/item">
|
<div class="flex items-center gap-3 min-w-0 flex-1 w-full group/item">
|
||||||
<div class="flex flex-col h-full items-center w-5">
|
<div class="flex flex-col h-full items-center w-5">
|
||||||
<ServerHealthIndicator health={props.domain.collection.health()[key]} />
|
<ServerHealthIndicator health={props.controller.status()[key]} />
|
||||||
</div>
|
</div>
|
||||||
<ServerRow
|
<ServerRow
|
||||||
conn={i}
|
conn={i}
|
||||||
dimmed={props.domain.collection.health()[key]?.healthy === false}
|
dimmed={props.controller.status()[key]?.healthy === false}
|
||||||
status={props.domain.collection.health()[key]}
|
status={props.controller.status()[key]}
|
||||||
class="flex items-center gap-3 min-w-0 flex-1"
|
class="flex items-center gap-3 min-w-0 flex-1"
|
||||||
badge={
|
badge={
|
||||||
<Show when={props.domain.defaults.key() === ServerConnection.key(i)}>
|
<Show when={props.controller.defaultKey() === ServerConnection.key(i)}>
|
||||||
<span class="text-text-base bg-surface-base text-14-regular px-1.5 rounded-xs">
|
<span class="text-text-base bg-surface-base text-14-regular px-1.5 rounded-xs">
|
||||||
{language.t("dialog.server.status.default")}
|
{language.t("dialog.server.status.default")}
|
||||||
</span>
|
</span>
|
||||||
@@ -178,12 +597,7 @@ export function ServerConnectionList(props: {
|
|||||||
showCredentials
|
showCredentials
|
||||||
/>
|
/>
|
||||||
<div class="flex items-center justify-center gap-4 pl-4">
|
<div class="flex items-center justify-center gap-4 pl-4">
|
||||||
<Show
|
<Show when={props.controller.current() && ServerConnection.key(props.controller.current()!) === key}>
|
||||||
when={
|
|
||||||
props.domain.collection.current() &&
|
|
||||||
ServerConnection.key(props.domain.collection.current()!) === key
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<Icon name="check" class="h-6" />
|
<Icon name="check" class="h-6" />
|
||||||
</Show>
|
</Show>
|
||||||
|
|
||||||
@@ -202,27 +616,27 @@ export function ServerConnectionList(props: {
|
|||||||
<DropdownMenu.Item
|
<DropdownMenu.Item
|
||||||
onSelect={() => {
|
onSelect={() => {
|
||||||
if (i.type !== "http") return
|
if (i.type !== "http") return
|
||||||
props.onEdit(i)
|
props.controller.startEdit(i)
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<DropdownMenu.ItemLabel>{language.t("dialog.server.menu.edit")}</DropdownMenu.ItemLabel>
|
<DropdownMenu.ItemLabel>{language.t("dialog.server.menu.edit")}</DropdownMenu.ItemLabel>
|
||||||
</DropdownMenu.Item>
|
</DropdownMenu.Item>
|
||||||
<Show when={props.domain.defaults.available() && props.domain.defaults.key() !== key}>
|
<Show when={props.controller.canDefault() && props.controller.defaultKey() !== key}>
|
||||||
<DropdownMenu.Item onSelect={() => props.domain.defaults.set(key)}>
|
<DropdownMenu.Item onSelect={() => props.controller.setDefault(key)}>
|
||||||
<DropdownMenu.ItemLabel>{language.t("dialog.server.menu.default")}</DropdownMenu.ItemLabel>
|
<DropdownMenu.ItemLabel>{language.t("dialog.server.menu.default")}</DropdownMenu.ItemLabel>
|
||||||
</DropdownMenu.Item>
|
</DropdownMenu.Item>
|
||||||
</Show>
|
</Show>
|
||||||
<Show when={props.domain.defaults.available() && props.domain.defaults.key() === key}>
|
<Show when={props.controller.canDefault() && props.controller.defaultKey() === key}>
|
||||||
<DropdownMenu.Item onSelect={() => props.domain.defaults.set(null)}>
|
<DropdownMenu.Item onSelect={() => props.controller.setDefault(null)}>
|
||||||
<DropdownMenu.ItemLabel>
|
<DropdownMenu.ItemLabel>
|
||||||
{language.t("dialog.server.menu.defaultRemove")}
|
{language.t("dialog.server.menu.defaultRemove")}
|
||||||
</DropdownMenu.ItemLabel>
|
</DropdownMenu.ItemLabel>
|
||||||
</DropdownMenu.Item>
|
</DropdownMenu.Item>
|
||||||
</Show>
|
</Show>
|
||||||
<Show when={props.domain.connection.canRemove(key)}>
|
<Show when={props.controller.canRemove(key)}>
|
||||||
<DropdownMenu.Separator />
|
<DropdownMenu.Separator />
|
||||||
<DropdownMenu.Item
|
<DropdownMenu.Item
|
||||||
onSelect={() => props.domain.connection.remove(key)}
|
onSelect={() => props.controller.handleRemove(ServerConnection.key(i))}
|
||||||
class="text-text-on-critical-base hover:bg-surface-critical-weak"
|
class="text-text-on-critical-base hover:bg-surface-critical-weak"
|
||||||
>
|
>
|
||||||
<DropdownMenu.ItemLabel>{language.t("dialog.server.menu.delete")}</DropdownMenu.ItemLabel>
|
<DropdownMenu.ItemLabel>{language.t("dialog.server.menu.delete")}</DropdownMenu.ItemLabel>
|
||||||
@@ -243,7 +657,7 @@ export function ServerConnectionList(props: {
|
|||||||
variant="secondary"
|
variant="secondary"
|
||||||
icon="plus-small"
|
icon="plus-small"
|
||||||
size="large"
|
size="large"
|
||||||
onClick={props.onAdd}
|
onClick={props.controller.startAdd}
|
||||||
class="py-1.5 pl-1.5 pr-3 flex items-center gap-1.5"
|
class="py-1.5 pl-1.5 pr-3 flex items-center gap-1.5"
|
||||||
>
|
>
|
||||||
{language.t("dialog.server.add.button")}
|
{language.t("dialog.server.add.button")}
|
||||||
@@ -253,38 +667,38 @@ export function ServerConnectionList(props: {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ServerConnectionForm(props: { form: ServerFormController }) {
|
export function ServerConnectionForm(props: { controller: ReturnType<typeof useServerManagementController> }) {
|
||||||
const language = useLanguage()
|
const language = useLanguage()
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div class="flex flex-1 min-h-0 flex-col gap-4">
|
<div class="flex flex-1 min-h-0 flex-col gap-4">
|
||||||
<ServerForm
|
<ServerForm
|
||||||
value={props.form.state.value()}
|
value={props.controller.formValue()}
|
||||||
name={props.form.state.name()}
|
name={props.controller.formName()}
|
||||||
username={props.form.state.username()}
|
username={props.controller.formUsername()}
|
||||||
password={props.form.state.password()}
|
password={props.controller.formPassword()}
|
||||||
placeholder={language.t("dialog.server.add.placeholder")}
|
placeholder={language.t("dialog.server.add.placeholder")}
|
||||||
busy={props.form.state.busy()}
|
busy={props.controller.formBusy()}
|
||||||
error={props.form.state.error()}
|
error={props.controller.formError()}
|
||||||
status={props.form.state.status()}
|
status={props.controller.formStatus()}
|
||||||
onChange={props.form.change.value}
|
onChange={props.controller.handleFormChange()}
|
||||||
onNameChange={props.form.change.name}
|
onNameChange={props.controller.handleFormNameChange()}
|
||||||
onUsernameChange={props.form.change.username}
|
onUsernameChange={props.controller.handleFormUsernameChange()}
|
||||||
onPasswordChange={props.form.change.password}
|
onPasswordChange={props.controller.handleFormPasswordChange()}
|
||||||
onSubmit={props.form.submit}
|
onSubmit={props.controller.submitForm}
|
||||||
onBack={props.form.reset}
|
onBack={props.controller.resetForm}
|
||||||
/>
|
/>
|
||||||
<div class="shrink-0 pb-5">
|
<div class="shrink-0 pb-5">
|
||||||
<Button
|
<Button
|
||||||
variant="primary"
|
variant="primary"
|
||||||
size="large"
|
size="large"
|
||||||
onClick={props.form.submit}
|
onClick={props.controller.submitForm}
|
||||||
disabled={props.form.state.busy()}
|
disabled={props.controller.formBusy()}
|
||||||
class="px-3 py-1.5"
|
class="px-3 py-1.5"
|
||||||
>
|
>
|
||||||
{props.form.state.busy()
|
{props.controller.formBusy()
|
||||||
? language.t("dialog.server.add.checking")
|
? language.t("dialog.server.add.checking")
|
||||||
: props.form.state.adding()
|
: props.controller.isAddMode()
|
||||||
? language.t("dialog.server.add.button")
|
? language.t("dialog.server.add.button")
|
||||||
: language.t("common.save")}
|
: language.t("common.save")}
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -1,323 +0,0 @@
|
|||||||
import { useNavigate } from "@solidjs/router"
|
|
||||||
import { useMutation } from "@tanstack/solid-query"
|
|
||||||
import { createEffect, createMemo, createResource, onCleanup } from "solid-js"
|
|
||||||
import { createStore } from "solid-js/store"
|
|
||||||
import { useGlobal } from "@/context/global"
|
|
||||||
import { useLanguage } from "@/context/language"
|
|
||||||
import { usePlatform } from "@/context/platform"
|
|
||||||
import { normalizeServerUrl, ServerConnection, useServer } from "@/context/server"
|
|
||||||
import { useSettings } from "@/context/settings"
|
|
||||||
import { useTabs } from "@/context/tabs"
|
|
||||||
import { type ServerHealth, useCheckServerHealth } from "@/utils/server-health"
|
|
||||||
import { showToast } from "@/utils/toast"
|
|
||||||
import { createServerHealthPreview, replaceServerConnection, type ServerFormValues } from "./server-management"
|
|
||||||
|
|
||||||
const DEFAULT_USERNAME = "opencode"
|
|
||||||
|
|
||||||
type FormMode = "list" | "add" | "edit"
|
|
||||||
|
|
||||||
function showRequestError(language: ReturnType<typeof useLanguage>, err: unknown) {
|
|
||||||
showToast({
|
|
||||||
variant: "error",
|
|
||||||
title: language.t("common.requestFailed"),
|
|
||||||
description: err instanceof Error ? err.message : String(err),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
function useDefaultServer() {
|
|
||||||
const language = useLanguage()
|
|
||||||
const platform = usePlatform()
|
|
||||||
const [defaultKey, defaultKeyActions] = createResource(
|
|
||||||
async () => {
|
|
||||||
try {
|
|
||||||
return (await platform.getDefaultServer?.()) ?? null
|
|
||||||
} catch (err) {
|
|
||||||
showRequestError(language, err)
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{ initialValue: null },
|
|
||||||
)
|
|
||||||
|
|
||||||
const set = async (key: ServerConnection.Key | null) => {
|
|
||||||
try {
|
|
||||||
await platform.setDefaultServer?.(key)
|
|
||||||
defaultKeyActions.mutate(key)
|
|
||||||
} catch (err) {
|
|
||||||
showRequestError(language, err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
key: () => defaultKey.latest,
|
|
||||||
available: createMemo(() => !!platform.getDefaultServer && !!platform.setDefaultServer),
|
|
||||||
set,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function useServerMutations() {
|
|
||||||
const server = useServer()
|
|
||||||
const tabs = useTabs()
|
|
||||||
|
|
||||||
return {
|
|
||||||
add: (connection: ServerConnection.Http) => server.add(connection),
|
|
||||||
replace: (originalKey: ServerConnection.Key, next: ServerConnection.Http) =>
|
|
||||||
replaceServerConnection(originalKey, next, {
|
|
||||||
active: () => server.key,
|
|
||||||
removeTabs: (key) => tabs.removeServer(key),
|
|
||||||
add: (connection) => server.add(connection),
|
|
||||||
setActive: (key) => server.setActive(key),
|
|
||||||
remove: (key) => server.remove(key),
|
|
||||||
}),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useServerActionsController() {
|
|
||||||
const server = useServer()
|
|
||||||
const tabs = useTabs()
|
|
||||||
const platform = usePlatform()
|
|
||||||
const language = useLanguage()
|
|
||||||
const defaults = useDefaultServer()
|
|
||||||
|
|
||||||
const remove = async (key: ServerConnection.Key) => {
|
|
||||||
try {
|
|
||||||
if (key.startsWith("wsl:")) await platform.wslServers?.removeServer(key)
|
|
||||||
tabs.removeServer(key)
|
|
||||||
server.remove(key)
|
|
||||||
if ((await platform.getDefaultServer?.()) === key) await defaults.set(null)
|
|
||||||
} catch (err) {
|
|
||||||
showRequestError(language, err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return { defaults, connection: { canRemove: server.canRemove, remove } }
|
|
||||||
}
|
|
||||||
|
|
||||||
export type ServerActionsController = ReturnType<typeof useServerActionsController>
|
|
||||||
|
|
||||||
export function useServerCollectionController() {
|
|
||||||
const server = useServer()
|
|
||||||
const global = useGlobal()
|
|
||||||
const settings = useSettings()
|
|
||||||
const actions = useServerActionsController()
|
|
||||||
|
|
||||||
const items = createMemo(() => {
|
|
||||||
const current = server.current
|
|
||||||
const list = server.list
|
|
||||||
if (!current) return list
|
|
||||||
if (!list.includes(current)) return [current, ...list]
|
|
||||||
return [current, ...list.filter((item) => item !== current)]
|
|
||||||
})
|
|
||||||
const current = createMemo<ServerConnection.Any | undefined>(() =>
|
|
||||||
settings.general.newLayoutDesigns()
|
|
||||||
? undefined
|
|
||||||
: (items().find((item) => ServerConnection.key(item) === server.key) ?? items()[0]),
|
|
||||||
)
|
|
||||||
const sorted = createMemo(() => {
|
|
||||||
const raw = items()
|
|
||||||
const list = raw
|
|
||||||
if (!list.length) return list
|
|
||||||
const active = current()
|
|
||||||
const order = new Map(list.map((item, index) => [item, index] as const))
|
|
||||||
const rank = (value?: ServerHealth) => {
|
|
||||||
if (value?.healthy === true) return 0
|
|
||||||
if (value?.healthy === false) return 2
|
|
||||||
return 1
|
|
||||||
}
|
|
||||||
return list.slice().sort((a, b) => {
|
|
||||||
if (a === active) return -1
|
|
||||||
if (b === active) return 1
|
|
||||||
const diff =
|
|
||||||
rank(global.servers.health[ServerConnection.key(a)]) - rank(global.servers.health[ServerConnection.key(b)])
|
|
||||||
if (diff !== 0) return diff
|
|
||||||
return (order.get(a) ?? 0) - (order.get(b) ?? 0)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
return {
|
|
||||||
collection: {
|
|
||||||
items: sorted,
|
|
||||||
current,
|
|
||||||
health: () => global.servers.health,
|
|
||||||
},
|
|
||||||
...actions,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export type ServerCollectionController = ReturnType<typeof useServerCollectionController>
|
|
||||||
|
|
||||||
export function useServerDomainController(options: { onSelect?: () => void } = {}) {
|
|
||||||
const navigate = useNavigate()
|
|
||||||
const server = useServer()
|
|
||||||
const global = useGlobal()
|
|
||||||
const collection = useServerCollectionController()
|
|
||||||
|
|
||||||
const select = async (connection: ServerConnection.Any) => {
|
|
||||||
if (global.servers.health[ServerConnection.key(connection)]?.healthy === false) return
|
|
||||||
options.onSelect?.()
|
|
||||||
navigate("/")
|
|
||||||
queueMicrotask(() => server.setActive(ServerConnection.key(connection)))
|
|
||||||
}
|
|
||||||
|
|
||||||
return { ...collection, selection: { select } }
|
|
||||||
}
|
|
||||||
|
|
||||||
export type ServerDomainController = ReturnType<typeof useServerDomainController>
|
|
||||||
|
|
||||||
export function useServerFormController(options: { onSelect?: () => void; navigateOnAdd?: boolean } = {}) {
|
|
||||||
const navigate = useNavigate()
|
|
||||||
const server = useServer()
|
|
||||||
const global = useGlobal()
|
|
||||||
const language = useLanguage()
|
|
||||||
const mutations = useServerMutations()
|
|
||||||
const checkServerHealth = useCheckServerHealth()
|
|
||||||
const healthPreview = createServerHealthPreview(checkServerHealth)
|
|
||||||
const [store, setStore] = createStore({
|
|
||||||
mode: "list" as FormMode,
|
|
||||||
originalUrl: undefined as string | undefined,
|
|
||||||
values: { url: "", name: "", username: DEFAULT_USERNAME, password: "" },
|
|
||||||
error: "",
|
|
||||||
status: undefined as boolean | undefined,
|
|
||||||
})
|
|
||||||
|
|
||||||
onCleanup(healthPreview.cancel)
|
|
||||||
|
|
||||||
const reset = () => {
|
|
||||||
healthPreview.cancel()
|
|
||||||
setStore({
|
|
||||||
mode: "list",
|
|
||||||
originalUrl: undefined,
|
|
||||||
values: { url: "", name: "", username: DEFAULT_USERNAME, password: "" },
|
|
||||||
error: "",
|
|
||||||
status: undefined,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
const allServers = () => {
|
|
||||||
if (!server.current || server.list.includes(server.current)) return server.list
|
|
||||||
return [server.current, ...server.list]
|
|
||||||
}
|
|
||||||
const editing = createMemo(() =>
|
|
||||||
allServers().find((item) => item.type === "http" && item.http.url === store.originalUrl),
|
|
||||||
)
|
|
||||||
|
|
||||||
const request = useMutation(() => ({
|
|
||||||
mutationFn: async () => {
|
|
||||||
const normalized = normalizeServerUrl(store.values.url)
|
|
||||||
if (!normalized) {
|
|
||||||
reset()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const original = store.mode === "edit" ? editing() : undefined
|
|
||||||
if (store.mode === "edit" && !original) return
|
|
||||||
const name = store.values.name.trim() || undefined
|
|
||||||
const username = store.values.username || undefined
|
|
||||||
const password = store.values.password || undefined
|
|
||||||
if (
|
|
||||||
original?.type === "http" &&
|
|
||||||
normalized === original.http.url &&
|
|
||||||
name === original.displayName &&
|
|
||||||
username === original.http.username &&
|
|
||||||
password === original.http.password
|
|
||||||
) {
|
|
||||||
reset()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const connection: ServerConnection.Http = {
|
|
||||||
type: "http",
|
|
||||||
displayName: name,
|
|
||||||
http: {
|
|
||||||
url: normalized,
|
|
||||||
username: store.mode === "add" && !password ? undefined : username,
|
|
||||||
password,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
const result = await checkServerHealth(connection.http)
|
|
||||||
if (!result.healthy) {
|
|
||||||
setStore("error", language.t("dialog.server.add.error"))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (original?.type === "http") {
|
|
||||||
if (normalized === original.http.url) mutations.add(connection)
|
|
||||||
if (normalized !== original.http.url) mutations.replace(ServerConnection.key(original), connection)
|
|
||||||
reset()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
reset()
|
|
||||||
if (options.navigateOnAdd === false) {
|
|
||||||
mutations.add(connection)
|
|
||||||
options.onSelect?.()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
mutations.add(connection)
|
|
||||||
options.onSelect?.()
|
|
||||||
navigate("/")
|
|
||||||
},
|
|
||||||
}))
|
|
||||||
|
|
||||||
const preview = () => void healthPreview.preview(store.values, (status) => setStore("status", status))
|
|
||||||
const change = (field: keyof ServerFormValues, value: string) => {
|
|
||||||
if (request.isPending) return
|
|
||||||
setStore("values", field, value)
|
|
||||||
setStore("error", "")
|
|
||||||
if (field !== "name") preview()
|
|
||||||
}
|
|
||||||
const startAdd = () => {
|
|
||||||
reset()
|
|
||||||
setStore("mode", "add")
|
|
||||||
}
|
|
||||||
const startEdit = (connection: ServerConnection.Http) => {
|
|
||||||
reset()
|
|
||||||
setStore({
|
|
||||||
mode: "edit",
|
|
||||||
originalUrl: connection.http.url,
|
|
||||||
values: {
|
|
||||||
url: connection.http.url,
|
|
||||||
name: connection.displayName ?? "",
|
|
||||||
username: connection.http.username ?? "",
|
|
||||||
password: connection.http.password ?? "",
|
|
||||||
},
|
|
||||||
error: "",
|
|
||||||
status: global.servers.health[ServerConnection.key(connection)]?.healthy,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
const submit = () => {
|
|
||||||
if (store.mode === "list" || request.isPending) return
|
|
||||||
setStore("error", "")
|
|
||||||
request.mutate()
|
|
||||||
}
|
|
||||||
|
|
||||||
createEffect(() => {
|
|
||||||
if (store.mode !== "edit") return
|
|
||||||
if (editing()) return
|
|
||||||
reset()
|
|
||||||
})
|
|
||||||
|
|
||||||
return {
|
|
||||||
state: {
|
|
||||||
mode: () => store.mode,
|
|
||||||
open: () => store.mode !== "list",
|
|
||||||
adding: () => store.mode === "add",
|
|
||||||
busy: () => request.isPending,
|
|
||||||
value: () => store.values.url,
|
|
||||||
name: () => store.values.name,
|
|
||||||
username: () => store.values.username,
|
|
||||||
password: () => store.values.password,
|
|
||||||
error: () => store.error,
|
|
||||||
status: () => store.status,
|
|
||||||
},
|
|
||||||
change: {
|
|
||||||
value: (value: string) => change("url", value),
|
|
||||||
name: (value: string) => change("name", value),
|
|
||||||
username: (value: string) => change("username", value),
|
|
||||||
password: (value: string) => change("password", value),
|
|
||||||
},
|
|
||||||
start: { add: startAdd, edit: startEdit },
|
|
||||||
reset,
|
|
||||||
submit,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export type ServerFormController = ReturnType<typeof useServerFormController>
|
|
||||||
@@ -1,99 +0,0 @@
|
|||||||
import { describe, expect, test } from "bun:test"
|
|
||||||
import { ServerConnection } from "@/context/server"
|
|
||||||
import { createServerHealthPreview, replaceServerConnection, type ServerFormValues } from "./server-management"
|
|
||||||
|
|
||||||
function deferred<T>() {
|
|
||||||
let resolve!: (value: T) => void
|
|
||||||
const promise = new Promise<T>((done) => {
|
|
||||||
resolve = done
|
|
||||||
})
|
|
||||||
return { promise, resolve }
|
|
||||||
}
|
|
||||||
|
|
||||||
const values = (url: string): ServerFormValues => ({ url, name: "", username: "opencode", password: "" })
|
|
||||||
|
|
||||||
describe("createServerHealthPreview", () => {
|
|
||||||
test("ignores an older response that resolves after the latest response", async () => {
|
|
||||||
const first = deferred<{ healthy: boolean }>()
|
|
||||||
const second = deferred<{ healthy: boolean }>()
|
|
||||||
const requests = [first, second]
|
|
||||||
const status: Array<boolean | undefined> = []
|
|
||||||
const preview = createServerHealthPreview(() => requests.shift()!.promise)
|
|
||||||
|
|
||||||
const older = preview.preview(values("old.example.com"), (value) => status.push(value))
|
|
||||||
const latest = preview.preview(values("new.example.com"), (value) => status.push(value))
|
|
||||||
second.resolve({ healthy: true })
|
|
||||||
await latest
|
|
||||||
first.resolve({ healthy: false })
|
|
||||||
await older
|
|
||||||
|
|
||||||
expect(status).toEqual([undefined, undefined, true])
|
|
||||||
})
|
|
||||||
|
|
||||||
test("an incomplete value invalidates an in-flight response", async () => {
|
|
||||||
const request = deferred<{ healthy: boolean }>()
|
|
||||||
const status: Array<boolean | undefined> = []
|
|
||||||
const preview = createServerHealthPreview(() => request.promise)
|
|
||||||
|
|
||||||
const pending = preview.preview(values("server.example.com"), (value) => status.push(value))
|
|
||||||
await preview.preview(values("server"), (value) => status.push(value))
|
|
||||||
request.resolve({ healthy: true })
|
|
||||||
await pending
|
|
||||||
|
|
||||||
expect(status).toEqual([undefined, undefined])
|
|
||||||
})
|
|
||||||
|
|
||||||
test("cancellation prevents an in-flight response from updating status", async () => {
|
|
||||||
const request = deferred<{ healthy: boolean }>()
|
|
||||||
const status: Array<boolean | undefined> = []
|
|
||||||
const preview = createServerHealthPreview(() => request.promise)
|
|
||||||
|
|
||||||
const pending = preview.preview(values("server.example.com"), (value) => status.push(value))
|
|
||||||
preview.cancel()
|
|
||||||
request.resolve({ healthy: true })
|
|
||||||
await pending
|
|
||||||
|
|
||||||
expect(status).toEqual([undefined])
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe("replaceServerConnection", () => {
|
|
||||||
const original: ServerConnection.Http = { type: "http", http: { url: "https://old.example.com" } }
|
|
||||||
const next: ServerConnection.Http = { type: "http", http: { url: "https://new.example.com" } }
|
|
||||||
|
|
||||||
test("moves active selection after adding the replacement and removes the original", () => {
|
|
||||||
const calls: string[] = []
|
|
||||||
|
|
||||||
replaceServerConnection(ServerConnection.key(original), next, {
|
|
||||||
active: () => ServerConnection.key(original),
|
|
||||||
removeTabs: (key) => calls.push(`tabs:${key}`),
|
|
||||||
add: (server) => {
|
|
||||||
calls.push(`add:${ServerConnection.key(server)}`)
|
|
||||||
return server
|
|
||||||
},
|
|
||||||
setActive: (key) => calls.push(`active:${key}`),
|
|
||||||
remove: (key) => calls.push(`remove:${key}`),
|
|
||||||
})
|
|
||||||
|
|
||||||
expect(calls).toEqual([
|
|
||||||
"tabs:https://old.example.com",
|
|
||||||
"add:https://new.example.com",
|
|
||||||
"active:https://new.example.com",
|
|
||||||
"remove:https://old.example.com",
|
|
||||||
])
|
|
||||||
})
|
|
||||||
|
|
||||||
test("keeps the original when the replacement cannot be added", () => {
|
|
||||||
const removed: ServerConnection.Key[] = []
|
|
||||||
|
|
||||||
replaceServerConnection(ServerConnection.key(original), next, {
|
|
||||||
active: () => ServerConnection.key(original),
|
|
||||||
removeTabs: () => {},
|
|
||||||
add: () => undefined,
|
|
||||||
setActive: () => {},
|
|
||||||
remove: (key) => removed.push(key),
|
|
||||||
})
|
|
||||||
|
|
||||||
expect(removed).toEqual([])
|
|
||||||
})
|
|
||||||
})
|
|
||||||
@@ -1,59 +0,0 @@
|
|||||||
import { normalizeServerUrl, ServerConnection } from "@/context/server"
|
|
||||||
import type { ServerHealth } from "@/utils/server-health"
|
|
||||||
|
|
||||||
export type ServerFormValues = {
|
|
||||||
url: string
|
|
||||||
name: string
|
|
||||||
username: string
|
|
||||||
password: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export function createServerHealthPreview(
|
|
||||||
check: (server: ServerConnection.HttpBase) => Promise<Pick<ServerHealth, "healthy">>,
|
|
||||||
) {
|
|
||||||
let generation = 0
|
|
||||||
|
|
||||||
const cancel = () => {
|
|
||||||
generation += 1
|
|
||||||
}
|
|
||||||
|
|
||||||
const preview = async (values: ServerFormValues, setStatus: (value: boolean | undefined) => void) => {
|
|
||||||
const current = ++generation
|
|
||||||
setStatus(undefined)
|
|
||||||
const normalized = normalizeServerUrl(values.url)
|
|
||||||
if (!normalized) return
|
|
||||||
const host = normalized.replace(/^https?:\/\//, "").split("/")[0]
|
|
||||||
if (!host) return
|
|
||||||
if (!host.includes("localhost") && !host.startsWith("127.0.0.1") && !host.includes(".") && !host.includes(":"))
|
|
||||||
return
|
|
||||||
|
|
||||||
const http: ServerConnection.HttpBase = { url: normalized }
|
|
||||||
if (values.username) http.username = values.username
|
|
||||||
if (values.password) http.password = values.password
|
|
||||||
const result = await check(http)
|
|
||||||
if (current !== generation) return
|
|
||||||
setStatus(result.healthy)
|
|
||||||
}
|
|
||||||
|
|
||||||
return { cancel, preview }
|
|
||||||
}
|
|
||||||
|
|
||||||
export function replaceServerConnection(
|
|
||||||
originalKey: ServerConnection.Key,
|
|
||||||
next: ServerConnection.Http,
|
|
||||||
operations: {
|
|
||||||
active: () => ServerConnection.Key | undefined
|
|
||||||
removeTabs: (key: ServerConnection.Key) => void
|
|
||||||
add: (server: ServerConnection.Http) => ServerConnection.Any | undefined
|
|
||||||
setActive: (key: ServerConnection.Key) => void
|
|
||||||
remove: (key: ServerConnection.Key) => void
|
|
||||||
},
|
|
||||||
) {
|
|
||||||
const active = operations.active()
|
|
||||||
operations.removeTabs(originalKey)
|
|
||||||
const added = operations.add(next)
|
|
||||||
if (!added) return
|
|
||||||
const nextActive = active === originalKey ? ServerConnection.key(added) : active
|
|
||||||
if (nextActive) operations.setActive(nextActive)
|
|
||||||
operations.remove(originalKey)
|
|
||||||
}
|
|
||||||
@@ -2,13 +2,13 @@ import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon"
|
|||||||
import { IconButtonV2 } from "@opencode-ai/ui/v2/icon-button-v2"
|
import { IconButtonV2 } from "@opencode-ai/ui/v2/icon-button-v2"
|
||||||
import { MenuV2 } from "@opencode-ai/ui/v2/menu-v2"
|
import { MenuV2 } from "@opencode-ai/ui/v2/menu-v2"
|
||||||
import { type Component, Show } from "solid-js"
|
import { type Component, Show } from "solid-js"
|
||||||
import type { ServerActionsController } from "@/components/server/server-management-controller"
|
import { useServerManagementController } from "@/components/dialog-select-server"
|
||||||
import { useLanguage } from "@/context/language"
|
import { useLanguage } from "@/context/language"
|
||||||
import { ServerConnection } from "@/context/server"
|
import { ServerConnection } from "@/context/server"
|
||||||
|
|
||||||
export const ServerRowMenu: Component<{
|
export const ServerRowMenu: Component<{
|
||||||
server: ServerConnection.Any
|
server: ServerConnection.Any
|
||||||
domain: ServerActionsController
|
controller: ReturnType<typeof useServerManagementController>
|
||||||
onEdit: (server: ServerConnection.Http) => void
|
onEdit: (server: ServerConnection.Http) => void
|
||||||
open?: boolean
|
open?: boolean
|
||||||
onOpenChange?: (open: boolean) => void
|
onOpenChange?: (open: boolean) => void
|
||||||
@@ -19,13 +19,13 @@ export const ServerRowMenu: Component<{
|
|||||||
<ServerRowMenuView
|
<ServerRowMenuView
|
||||||
server={props.server}
|
server={props.server}
|
||||||
labels={serverMenuLabels(language)}
|
labels={serverMenuLabels(language)}
|
||||||
canDefault={props.domain.defaults.available()}
|
canDefault={props.controller.canDefault()}
|
||||||
isDefault={props.domain.defaults.key() === key}
|
isDefault={props.controller.defaultKey() === key}
|
||||||
canRemove={props.domain.connection.canRemove(key)}
|
canRemove={props.controller.canRemove(key)}
|
||||||
onEdit={props.onEdit}
|
onEdit={props.onEdit}
|
||||||
onSetDefault={() => props.domain.defaults.set(key)}
|
onSetDefault={() => props.controller.setDefault(key)}
|
||||||
onRemoveDefault={() => props.domain.defaults.set(null)}
|
onRemoveDefault={() => props.controller.setDefault(null)}
|
||||||
onRemove={() => props.domain.connection.remove(key)}
|
onRemove={() => props.controller.handleRemove(key)}
|
||||||
open={props.open}
|
open={props.open}
|
||||||
onOpenChange={props.onOpenChange}
|
onOpenChange={props.onOpenChange}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import { useDialog } from "@opencode-ai/ui/context/dialog"
|
|||||||
import { type Component, Show, createEffect, createSignal, onCleanup, onMount } from "solid-js"
|
import { type Component, Show, createEffect, createSignal, onCleanup, onMount } from "solid-js"
|
||||||
import { useLanguage } from "@/context/language"
|
import { useLanguage } from "@/context/language"
|
||||||
import { type ServerConnection } from "@/context/server"
|
import { type ServerConnection } from "@/context/server"
|
||||||
import { useServerFormController } from "../server/server-management-controller"
|
import { useServerManagementController } from "../dialog-select-server"
|
||||||
import "./settings-v2.css"
|
import "./settings-v2.css"
|
||||||
|
|
||||||
export const DialogServerV2: Component<{
|
export const DialogServerV2: Component<{
|
||||||
@@ -15,39 +15,39 @@ export const DialogServerV2: Component<{
|
|||||||
}> = (props) => {
|
}> = (props) => {
|
||||||
const dialog = useDialog()
|
const dialog = useDialog()
|
||||||
const language = useLanguage()
|
const language = useLanguage()
|
||||||
const form = useServerFormController({
|
const controller = useServerManagementController({
|
||||||
onSelect: () => dialog.close(),
|
onSelect: () => dialog.close(),
|
||||||
navigateOnAdd: false,
|
navigateOnAdd: false,
|
||||||
})
|
})
|
||||||
const [opened, setOpened] = createSignal(false)
|
const [opened, setOpened] = createSignal(false)
|
||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
if (props.mode === "add") form.start.add()
|
if (props.mode === "add") controller.startAdd()
|
||||||
if (props.mode === "edit" && props.server) form.start.edit(props.server)
|
if (props.mode === "edit" && props.server) controller.startEdit(props.server)
|
||||||
setOpened(true)
|
setOpened(true)
|
||||||
})
|
})
|
||||||
|
|
||||||
onCleanup(() => {
|
onCleanup(() => {
|
||||||
form.reset()
|
controller.resetForm()
|
||||||
})
|
})
|
||||||
|
|
||||||
createEffect(() => {
|
createEffect(() => {
|
||||||
if (!opened()) return
|
if (!opened()) return
|
||||||
if (form.state.open()) return
|
if (controller.isFormMode()) return
|
||||||
dialog.close()
|
dialog.close()
|
||||||
})
|
})
|
||||||
|
|
||||||
const keyDown = (event: KeyboardEvent) => {
|
const keyDown = (event: KeyboardEvent) => {
|
||||||
if (event.key !== "Enter" || event.isComposing) return
|
if (event.key !== "Enter" || event.isComposing) return
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
form.submit()
|
controller.submitForm()
|
||||||
}
|
}
|
||||||
|
|
||||||
const title = () =>
|
const title = () =>
|
||||||
props.mode === "add" ? language.t("dialog.server.add.title") : language.t("dialog.server.edit.title")
|
props.mode === "add" ? language.t("dialog.server.add.title") : language.t("dialog.server.edit.title")
|
||||||
|
|
||||||
const submitLabel = () => {
|
const submitLabel = () => {
|
||||||
if (form.state.busy()) return language.t("dialog.server.add.checking")
|
if (controller.formBusy()) return language.t("dialog.server.add.checking")
|
||||||
if (props.mode === "add") return language.t("dialog.server.add.button")
|
if (props.mode === "add") return language.t("dialog.server.add.button")
|
||||||
return language.t("common.save")
|
return language.t("common.save")
|
||||||
}
|
}
|
||||||
@@ -66,16 +66,16 @@ export const DialogServerV2: Component<{
|
|||||||
type="text"
|
type="text"
|
||||||
appearance="large"
|
appearance="large"
|
||||||
class="!w-full self-stretch"
|
class="!w-full self-stretch"
|
||||||
value={form.state.value()}
|
value={controller.formValue()}
|
||||||
placeholder={language.t("dialog.server.add.placeholder")}
|
placeholder={language.t("dialog.server.add.placeholder")}
|
||||||
invalid={!!form.state.error()}
|
invalid={!!controller.formError()}
|
||||||
disabled={form.state.busy()}
|
disabled={controller.formBusy()}
|
||||||
autofocus
|
autofocus
|
||||||
onInput={(event) => form.change.value(event.currentTarget.value)}
|
onInput={(event) => controller.handleFormChange()(event.currentTarget.value)}
|
||||||
onKeyDown={keyDown}
|
onKeyDown={keyDown}
|
||||||
/>
|
/>
|
||||||
<Show when={form.state.error()}>
|
<Show when={controller.formError()}>
|
||||||
<span class="settings-v2-server-dialog-error">{form.state.error()}</span>
|
<span class="settings-v2-server-dialog-error">{controller.formError()}</span>
|
||||||
</Show>
|
</Show>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex w-full min-w-0 flex-col gap-2">
|
<div class="flex w-full min-w-0 flex-col gap-2">
|
||||||
@@ -84,10 +84,10 @@ export const DialogServerV2: Component<{
|
|||||||
type="text"
|
type="text"
|
||||||
appearance="large"
|
appearance="large"
|
||||||
class="!w-full self-stretch"
|
class="!w-full self-stretch"
|
||||||
value={form.state.name()}
|
value={controller.formName()}
|
||||||
placeholder={language.t("dialog.server.add.namePlaceholder")}
|
placeholder={language.t("dialog.server.add.namePlaceholder")}
|
||||||
disabled={form.state.busy()}
|
disabled={controller.formBusy()}
|
||||||
onInput={(event) => form.change.name(event.currentTarget.value)}
|
onInput={(event) => controller.handleFormNameChange()(event.currentTarget.value)}
|
||||||
onKeyDown={keyDown}
|
onKeyDown={keyDown}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -98,10 +98,10 @@ export const DialogServerV2: Component<{
|
|||||||
type="text"
|
type="text"
|
||||||
appearance="large"
|
appearance="large"
|
||||||
class="!w-full self-stretch"
|
class="!w-full self-stretch"
|
||||||
value={form.state.username()}
|
value={controller.formUsername()}
|
||||||
placeholder={language.t("dialog.server.add.usernamePlaceholder")}
|
placeholder={language.t("dialog.server.add.usernamePlaceholder")}
|
||||||
disabled={form.state.busy()}
|
disabled={controller.formBusy()}
|
||||||
onInput={(event) => form.change.username(event.currentTarget.value)}
|
onInput={(event) => controller.handleFormUsernameChange()(event.currentTarget.value)}
|
||||||
onKeyDown={keyDown}
|
onKeyDown={keyDown}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -111,10 +111,10 @@ export const DialogServerV2: Component<{
|
|||||||
type="password"
|
type="password"
|
||||||
appearance="large"
|
appearance="large"
|
||||||
class="!w-full self-stretch"
|
class="!w-full self-stretch"
|
||||||
value={form.state.password()}
|
value={controller.formPassword()}
|
||||||
placeholder={language.t("dialog.server.add.passwordPlaceholder")}
|
placeholder={language.t("dialog.server.add.passwordPlaceholder")}
|
||||||
disabled={form.state.busy()}
|
disabled={controller.formBusy()}
|
||||||
onInput={(event) => form.change.password(event.currentTarget.value)}
|
onInput={(event) => controller.handleFormPasswordChange()(event.currentTarget.value)}
|
||||||
onKeyDown={keyDown}
|
onKeyDown={keyDown}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -122,10 +122,10 @@ export const DialogServerV2: Component<{
|
|||||||
</div>
|
</div>
|
||||||
</DialogBody>
|
</DialogBody>
|
||||||
<DialogFooter>
|
<DialogFooter>
|
||||||
<ButtonV2 variant="neutral" disabled={form.state.busy()} onClick={() => dialog.close()}>
|
<ButtonV2 variant="neutral" disabled={controller.formBusy()} onClick={() => dialog.close()}>
|
||||||
{language.t("common.cancel")}
|
{language.t("common.cancel")}
|
||||||
</ButtonV2>
|
</ButtonV2>
|
||||||
<ButtonV2 variant="contrast" disabled={form.state.busy()} onClick={form.submit}>
|
<ButtonV2 variant="contrast" disabled={controller.formBusy()} onClick={controller.submitForm}>
|
||||||
{submitLabel()}
|
{submitLabel()}
|
||||||
</ButtonV2>
|
</ButtonV2>
|
||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import { ServerRowMenu } from "@/components/server/server-row-menu"
|
|||||||
import { ServerHealthIndicator } from "@/components/server/server-row"
|
import { ServerHealthIndicator } from "@/components/server/server-row"
|
||||||
import { useLanguage } from "@/context/language"
|
import { useLanguage } from "@/context/language"
|
||||||
import { ServerConnection, serverName } from "@/context/server"
|
import { ServerConnection, serverName } from "@/context/server"
|
||||||
import { useServerCollectionController } from "../server/server-management-controller"
|
import { useServerManagementController } from "../dialog-select-server"
|
||||||
import { DialogServerV2 } from "./dialog-server-v2"
|
import { DialogServerV2 } from "./dialog-server-v2"
|
||||||
import { SettingsListV2 } from "./parts/list"
|
import { SettingsListV2 } from "./parts/list"
|
||||||
import { AddServerMenu, isWslServer, useFilteredWslServers, WslServerSettings } from "@/wsl/settings"
|
import { AddServerMenu, isWslServer, useFilteredWslServers, WslServerSettings } from "@/wsl/settings"
|
||||||
@@ -19,16 +19,16 @@ import "./settings-v2.css"
|
|||||||
export const SettingsServersV2: Component = () => {
|
export const SettingsServersV2: Component = () => {
|
||||||
const dialog = useDialog()
|
const dialog = useDialog()
|
||||||
const language = useLanguage()
|
const language = useLanguage()
|
||||||
const domain = useServerCollectionController()
|
const controller = useServerManagementController()
|
||||||
const [store, setStore] = createStore({ filter: "" })
|
const [store, setStore] = createStore({ filter: "" })
|
||||||
const wslServers = useFilteredWslServers(() => store.filter)
|
const wslServers = useFilteredWslServers(() => store.filter)
|
||||||
|
|
||||||
const showSearch = createMemo(
|
const showSearch = createMemo(
|
||||||
() => domain.collection.items().filter((item) => !isWslServer(item)).length + wslServers().length > 1,
|
() => controller.sortedItems().filter((item) => !isWslServer(item)).length + wslServers().length > 1,
|
||||||
)
|
)
|
||||||
|
|
||||||
const filtered = createMemo(() => {
|
const filtered = createMemo(() => {
|
||||||
const items = domain.collection.items().filter((item) => !isWslServer(item))
|
const items = controller.sortedItems().filter((item) => !isWslServer(item))
|
||||||
const query = store.filter.trim()
|
const query = store.filter.trim()
|
||||||
if (!query) return items
|
if (!query) return items
|
||||||
return fuzzysort
|
return fuzzysort
|
||||||
@@ -39,11 +39,11 @@ export const SettingsServersV2: Component = () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
const openAdd = () => {
|
const openAdd = () => {
|
||||||
void dialog.push(() => <DialogServerV2 mode="add" />)
|
dialog.push(() => <DialogServerV2 mode="add" />)
|
||||||
}
|
}
|
||||||
|
|
||||||
const openEdit = (server: ServerConnection.Http) => {
|
const openEdit = (server: ServerConnection.Http) => {
|
||||||
void dialog.push(() => <DialogServerV2 mode="edit" server={server} />)
|
dialog.push(() => <DialogServerV2 mode="edit" server={server} />)
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -97,12 +97,12 @@ export const SettingsServersV2: Component = () => {
|
|||||||
}
|
}
|
||||||
>
|
>
|
||||||
<SettingsListV2>
|
<SettingsListV2>
|
||||||
<WslServerSettings domain={domain} servers={wslServers} />
|
<WslServerSettings controller={controller} servers={wslServers} />
|
||||||
<For each={filtered()}>
|
<For each={filtered()}>
|
||||||
{(item) => {
|
{(item) => {
|
||||||
const key = ServerConnection.key(item)
|
const key = ServerConnection.key(item)
|
||||||
const health = () => domain.collection.health()[key]
|
const health = () => controller.status()[key]
|
||||||
const isDefault = () => domain.defaults.key() === key
|
const isDefault = () => controller.defaultKey() === key
|
||||||
return (
|
return (
|
||||||
<div class="settings-v2-servers-row">
|
<div class="settings-v2-servers-row">
|
||||||
<div class="settings-v2-servers-lead">
|
<div class="settings-v2-servers-lead">
|
||||||
@@ -122,10 +122,10 @@ export const SettingsServersV2: Component = () => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="settings-v2-servers-actions">
|
<div class="settings-v2-servers-actions">
|
||||||
<Show when={domain.defaults.available() && isDefault()}>
|
<Show when={controller.canDefault() && isDefault()}>
|
||||||
<Tag>{language.t("dialog.server.status.default")}</Tag>
|
<Tag>{language.t("dialog.server.status.default")}</Tag>
|
||||||
</Show>
|
</Show>
|
||||||
<ServerRowMenu server={item} domain={domain} onEdit={openEdit} />
|
<ServerRowMenu server={item} controller={controller} onEdit={openEdit} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useDirectoryPicker } from "@/components/directory-picker"
|
import { useDirectoryPicker } from "@/components/directory-picker"
|
||||||
import { useServerActionsController } from "@/components/server/server-management-controller"
|
import { useServerManagementController } from "@/components/dialog-select-server"
|
||||||
import { useSettingsCommand } from "@/components/settings-dialog"
|
import { useSettingsCommand } from "@/components/settings-dialog"
|
||||||
import { DialogServerV2 } from "@/components/settings-v2/dialog-server-v2"
|
import { DialogServerV2 } from "@/components/settings-v2/dialog-server-v2"
|
||||||
import { type LocalProject } from "@/context/layout"
|
import { type LocalProject } from "@/context/layout"
|
||||||
@@ -22,7 +22,7 @@ export function createHomeProjectsController(home: HomeController) {
|
|||||||
const language = useLanguage()
|
const language = useLanguage()
|
||||||
const notification = useNotification()
|
const notification = useNotification()
|
||||||
const openSettings = useSettingsCommand()
|
const openSettings = useSettingsCommand()
|
||||||
const serverManagement = useServerActionsController()
|
const serverManagement = useServerManagementController({ navigateOnAdd: false })
|
||||||
const [_state, setState, _, ready] = persisted(
|
const [_state, setState, _, ready] = persisted(
|
||||||
Persist.global("home.servers", ["home.servers.v1"]),
|
Persist.global("home.servers", ["home.servers.v1"]),
|
||||||
createStore({ collapsed: {} as Record<string, boolean> }),
|
createStore({ collapsed: {} as Record<string, boolean> }),
|
||||||
@@ -56,12 +56,12 @@ export function createHomeProjectsController(home: HomeController) {
|
|||||||
const key = ServerConnection.key(conn)
|
const key = ServerConnection.key(conn)
|
||||||
setState("collapsed", key, !state().collapsed[key])
|
setState("collapsed", key, !state().collapsed[key])
|
||||||
},
|
},
|
||||||
canDefault: serverManagement.defaults.available,
|
canDefault: serverManagement.canDefault,
|
||||||
defaultKey: serverManagement.defaults.key,
|
defaultKey: serverManagement.defaultKey,
|
||||||
setDefault: (conn: ServerConnection.Any | undefined) =>
|
setDefault: (conn: ServerConnection.Any | undefined) =>
|
||||||
serverManagement.defaults.set(conn ? ServerConnection.key(conn) : null),
|
serverManagement.setDefault(conn ? ServerConnection.key(conn) : null),
|
||||||
canRemove: (conn: ServerConnection.Any) => serverManagement.connection.canRemove(ServerConnection.key(conn)),
|
canRemove: (conn: ServerConnection.Any) => serverManagement.canRemove(ServerConnection.key(conn)),
|
||||||
remove: (conn: ServerConnection.Any) => serverManagement.connection.remove(ServerConnection.key(conn)),
|
remove: (conn: ServerConnection.Any) => serverManagement.handleRemove(ServerConnection.key(conn)),
|
||||||
edit: (conn: ServerConnection.Http) => dialog.show(() => <DialogServerV2 mode="edit" server={conn} />),
|
edit: (conn: ServerConnection.Http) => dialog.show(() => <DialogServerV2 mode="edit" server={conn} />),
|
||||||
focus: home.selection.focusServer,
|
focus: home.selection.focusServer,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import { MenuV2 } from "@opencode-ai/ui/v2/menu-v2"
|
|||||||
import { useMutation } from "@tanstack/solid-query"
|
import { useMutation } from "@tanstack/solid-query"
|
||||||
import fuzzysort from "fuzzysort"
|
import fuzzysort from "fuzzysort"
|
||||||
import { type Accessor, For, Show, createMemo } from "solid-js"
|
import { type Accessor, For, Show, createMemo } from "solid-js"
|
||||||
import type { ServerCollectionController } from "@/components/server/server-management-controller"
|
import type { useServerManagementController } from "@/components/dialog-select-server"
|
||||||
import { ServerHealthIndicator } from "@/components/server/server-row"
|
import { ServerHealthIndicator } from "@/components/server/server-row"
|
||||||
import { useLanguage } from "@/context/language"
|
import { useLanguage } from "@/context/language"
|
||||||
import { usePlatform } from "@/context/platform"
|
import { usePlatform } from "@/context/platform"
|
||||||
@@ -17,6 +17,8 @@ import { DialogAddWslServer } from "./dialog-add-server"
|
|||||||
import { useWslServers } from "./context"
|
import { useWslServers } from "./context"
|
||||||
import { wslOpencodeAction, wslRuntimeRetryable } from "./settings-model"
|
import { wslOpencodeAction, wslRuntimeRetryable } from "./settings-model"
|
||||||
|
|
||||||
|
type Controller = ReturnType<typeof useServerManagementController>
|
||||||
|
|
||||||
export function isWslServer(server: ServerConnection.Any) {
|
export function isWslServer(server: ServerConnection.Any) {
|
||||||
return server.type === "sidecar" && server.variant === "wsl"
|
return server.type === "sidecar" && server.variant === "wsl"
|
||||||
}
|
}
|
||||||
@@ -26,7 +28,7 @@ export function AddServerMenu(props: { onAddServer: () => void }) {
|
|||||||
const dialog = useDialog()
|
const dialog = useDialog()
|
||||||
const language = useLanguage()
|
const language = useLanguage()
|
||||||
const openAddWsl = () => {
|
const openAddWsl = () => {
|
||||||
void dialog.push(() => <DialogAddWslServer />)
|
dialog.push(() => <DialogAddWslServer />)
|
||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
<Show
|
<Show
|
||||||
@@ -65,7 +67,7 @@ export function useFilteredWslServers(filter: Accessor<string>) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function WslServerSettings(props: {
|
export function WslServerSettings(props: {
|
||||||
domain: Pick<ServerCollectionController, "collection" | "defaults" | "connection">
|
controller: Controller
|
||||||
servers: ReturnType<typeof useFilteredWslServers>
|
servers: ReturnType<typeof useFilteredWslServers>
|
||||||
}) {
|
}) {
|
||||||
const platform = usePlatform()
|
const platform = usePlatform()
|
||||||
@@ -84,7 +86,7 @@ export function WslServerSettings(props: {
|
|||||||
}))
|
}))
|
||||||
|
|
||||||
const remove = (key: ServerConnection.Key) => {
|
const remove = (key: ServerConnection.Key) => {
|
||||||
request.mutate(() => props.domain.connection.remove(key))
|
request.mutate(() => props.controller.handleRemove(key))
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -98,7 +100,7 @@ export function WslServerSettings(props: {
|
|||||||
return (
|
return (
|
||||||
<div class="settings-v2-servers-row">
|
<div class="settings-v2-servers-row">
|
||||||
<div class="settings-v2-servers-lead">
|
<div class="settings-v2-servers-lead">
|
||||||
<ServerHealthIndicator health={props.domain.collection.health()[key]} />
|
<ServerHealthIndicator health={props.controller.status()[key]} />
|
||||||
<div class="settings-v2-servers-copy">
|
<div class="settings-v2-servers-copy">
|
||||||
<span class="flex min-w-0 items-center gap-1">
|
<span class="flex min-w-0 items-center gap-1">
|
||||||
<span class="settings-v2-servers-name">{item.config.distro}</span>
|
<span class="settings-v2-servers-name">{item.config.distro}</span>
|
||||||
@@ -112,7 +114,7 @@ export function WslServerSettings(props: {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="settings-v2-servers-actions">
|
<div class="settings-v2-servers-actions">
|
||||||
<Show when={props.domain.defaults.available() && props.domain.defaults.key() === key}>
|
<Show when={props.controller.canDefault() && props.controller.defaultKey() === key}>
|
||||||
<Tag>{language.t("dialog.server.status.default")}</Tag>
|
<Tag>{language.t("dialog.server.status.default")}</Tag>
|
||||||
</Show>
|
</Show>
|
||||||
<Show when={opencodeAction()}>
|
<Show when={opencodeAction()}>
|
||||||
@@ -143,13 +145,13 @@ export function WslServerSettings(props: {
|
|||||||
{language.t("wsl.server.retryStart")}
|
{language.t("wsl.server.retryStart")}
|
||||||
</MenuV2.Item>
|
</MenuV2.Item>
|
||||||
</Show>
|
</Show>
|
||||||
<Show when={props.domain.defaults.available() && props.domain.defaults.key() !== key}>
|
<Show when={props.controller.canDefault() && props.controller.defaultKey() !== key}>
|
||||||
<MenuV2.Item onSelect={() => props.domain.defaults.set(key)}>
|
<MenuV2.Item onSelect={() => props.controller.setDefault(key)}>
|
||||||
{language.t("dialog.server.menu.default")}
|
{language.t("dialog.server.menu.default")}
|
||||||
</MenuV2.Item>
|
</MenuV2.Item>
|
||||||
</Show>
|
</Show>
|
||||||
<Show when={props.domain.defaults.available() && props.domain.defaults.key() === key}>
|
<Show when={props.controller.canDefault() && props.controller.defaultKey() === key}>
|
||||||
<MenuV2.Item onSelect={() => props.domain.defaults.set(null)}>
|
<MenuV2.Item onSelect={() => props.controller.setDefault(null)}>
|
||||||
{language.t("dialog.server.menu.defaultRemove")}
|
{language.t("dialog.server.menu.defaultRemove")}
|
||||||
</MenuV2.Item>
|
</MenuV2.Item>
|
||||||
</Show>
|
</Show>
|
||||||
|
|||||||
@@ -0,0 +1,84 @@
|
|||||||
|
export * as WebSearchFirecrawl from "./firecrawl"
|
||||||
|
|
||||||
|
import { define } from "@opencode-ai/plugin/effect/plugin"
|
||||||
|
import { Effect, Option, Schema, Scope } from "effect"
|
||||||
|
import { HttpClient } from "effect/unstable/http"
|
||||||
|
import { App } from "../../app"
|
||||||
|
import { WebSearchMcp } from "./mcp"
|
||||||
|
|
||||||
|
export const endpoint = "https://mcp.firecrawl.dev/v2/mcp"
|
||||||
|
|
||||||
|
const McpInput = Schema.Struct({
|
||||||
|
query: Schema.String,
|
||||||
|
limit: Schema.Number.pipe(Schema.optional),
|
||||||
|
})
|
||||||
|
|
||||||
|
const McpOutput = Schema.Struct({
|
||||||
|
content: Schema.Array(Schema.Struct({ type: Schema.Literal("text"), text: Schema.String })),
|
||||||
|
})
|
||||||
|
|
||||||
|
const SearchResponse = Schema.fromJsonString(
|
||||||
|
Schema.Struct({
|
||||||
|
success: Schema.Boolean,
|
||||||
|
data: Schema.Struct({
|
||||||
|
web: Schema.Array(
|
||||||
|
Schema.Struct({
|
||||||
|
url: Schema.String,
|
||||||
|
title: Schema.NullOr(Schema.String).pipe(Schema.optional),
|
||||||
|
description: Schema.NullOr(Schema.String).pipe(Schema.optional),
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
const decodeSearchResponse = Schema.decodeUnknownOption(SearchResponse)
|
||||||
|
|
||||||
|
export const Plugin = define<HttpClient.HttpClient | Scope.Scope>({
|
||||||
|
id: "opencode.websearch.firecrawl",
|
||||||
|
effect: Effect.fn("WebSearchFirecrawl.Plugin")(function* (ctx) {
|
||||||
|
const http = yield* HttpClient.HttpClient
|
||||||
|
yield* ctx.integration.transform((draft) => {
|
||||||
|
draft.update("firecrawl", (integration) => (integration.name = "Firecrawl"))
|
||||||
|
draft.method.update({
|
||||||
|
integrationID: "firecrawl",
|
||||||
|
method: { type: "key", label: "API key (optional)" },
|
||||||
|
})
|
||||||
|
draft.method.update({
|
||||||
|
integrationID: "firecrawl",
|
||||||
|
method: { type: "env", names: ["FIRECRAWL_API_KEY"] },
|
||||||
|
})
|
||||||
|
})
|
||||||
|
yield* ctx.websearch.transform((draft) => {
|
||||||
|
draft.add({
|
||||||
|
id: "firecrawl",
|
||||||
|
name: "Firecrawl",
|
||||||
|
execute: (input) =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const connection = yield* ctx.integration.connection.active("firecrawl")
|
||||||
|
const credential = connection ? yield* ctx.integration.connection.resolve(connection) : undefined
|
||||||
|
const result = yield* WebSearchMcp.call(
|
||||||
|
http,
|
||||||
|
endpoint,
|
||||||
|
"firecrawl_search",
|
||||||
|
{ input: McpInput, output: McpOutput },
|
||||||
|
{ query: input.query, limit: 8 },
|
||||||
|
{
|
||||||
|
"User-Agent": App.useragent(ctx.app),
|
||||||
|
...(credential?.type === "key" ? { Authorization: `Bearer ${credential.key}` } : {}),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
const content = result?.content.find((item) => item.text)
|
||||||
|
const response = content ? Option.getOrUndefined(decodeSearchResponse(content.text)) : undefined
|
||||||
|
return (
|
||||||
|
response?.data.web.map((item) => ({
|
||||||
|
url: item.url,
|
||||||
|
...(item.title ? { title: item.title } : {}),
|
||||||
|
...(item.description ? { content: item.description } : {}),
|
||||||
|
time: {},
|
||||||
|
})) ?? []
|
||||||
|
)
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}),
|
||||||
|
})
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import { WebSearchExa } from "./exa"
|
import { WebSearchExa } from "./exa"
|
||||||
|
import { WebSearchFirecrawl } from "./firecrawl"
|
||||||
import { WebSearchParallel } from "./parallel"
|
import { WebSearchParallel } from "./parallel"
|
||||||
|
|
||||||
export const WebSearchPlugins = [WebSearchExa.Plugin, WebSearchParallel.Plugin] as const
|
export const WebSearchPlugins = [WebSearchExa.Plugin, WebSearchFirecrawl.Plugin, WebSearchParallel.Plugin] as const
|
||||||
|
|||||||
@@ -31,9 +31,9 @@
|
|||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"@opencode-ai/theme": "workspace:*",
|
"@opencode-ai/theme": "workspace:*",
|
||||||
"@opentui/core": ">=0.4.5",
|
"@opentui/core": "0.0.0-20260807-6b4336f3",
|
||||||
"@opentui/keymap": ">=0.4.5",
|
"@opentui/keymap": "0.0.0-20260807-6b4336f3",
|
||||||
"@opentui/solid": ">=0.4.5",
|
"@opentui/solid": "0.0.0-20260807-6b4336f3",
|
||||||
"solid-js": ">=1.9.0"
|
"solid-js": ">=1.9.0"
|
||||||
},
|
},
|
||||||
"peerDependenciesMeta": {
|
"peerDependenciesMeta": {
|
||||||
|
|||||||
@@ -462,6 +462,7 @@ function newLayout() {
|
|||||||
function webSearchProviderLabel(provider: unknown) {
|
function webSearchProviderLabel(provider: unknown) {
|
||||||
if (provider === "parallel") return "Parallel Web Search"
|
if (provider === "parallel") return "Parallel Web Search"
|
||||||
if (provider === "exa") return "Exa Web Search"
|
if (provider === "exa") return "Exa Web Search"
|
||||||
|
if (provider === "firecrawl") return "Firecrawl Web Search"
|
||||||
return "Web Search"
|
return "Web Search"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -92,7 +92,6 @@
|
|||||||
"@opentui/keymap": "catalog:",
|
"@opentui/keymap": "catalog:",
|
||||||
"@opentui/solid": "catalog:",
|
"@opentui/solid": "catalog:",
|
||||||
"@solid-primitives/event-bus": "1.1.2",
|
"@solid-primitives/event-bus": "1.1.2",
|
||||||
"clipboardy": "4.0.0",
|
|
||||||
"effect": "catalog:",
|
"effect": "catalog:",
|
||||||
"fuzzysort": "catalog:",
|
"fuzzysort": "catalog:",
|
||||||
"get-east-asian-width": "catalog:",
|
"get-east-asian-width": "catalog:",
|
||||||
|
|||||||
@@ -97,6 +97,7 @@ import { destroyRenderer } from "./util/renderer"
|
|||||||
import { cliErrorMessage, errorFormat } from "./util/error"
|
import { cliErrorMessage, errorFormat } from "./util/error"
|
||||||
import { AttentionProvider } from "./context/attention"
|
import { AttentionProvider } from "./context/attention"
|
||||||
import { StorageProvider } from "./context/storage"
|
import { StorageProvider } from "./context/storage"
|
||||||
|
import { createTuiClipboard } from "./clipboard"
|
||||||
|
|
||||||
registerOpencodeSpinner()
|
registerOpencodeSpinner()
|
||||||
|
|
||||||
@@ -250,6 +251,13 @@ export const run = Effect.fn("Tui.run")(function* (input: TuiInput) {
|
|||||||
(renderer) => Effect.sync(() => destroyRenderer(renderer)),
|
(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()
|
win32DisableProcessedInput()
|
||||||
const finalizers = new Set<() => Promise<void>>()
|
const finalizers = new Set<() => Promise<void>>()
|
||||||
yield* Effect.addFinalizer(() =>
|
yield* Effect.addFinalizer(() =>
|
||||||
@@ -286,7 +294,11 @@ export const run = Effect.fn("Tui.run")(function* (input: TuiInput) {
|
|||||||
<EpilogueProvider set={(value) => (exit.epilogue = value)}>
|
<EpilogueProvider set={(value) => (exit.epilogue = value)}>
|
||||||
<TuiAppProvider value={input.app}>
|
<TuiAppProvider value={input.app}>
|
||||||
<ErrorBoundary
|
<ErrorBoundary
|
||||||
fallback={(error, reset) => <ErrorComponent error={error} reset={reset} mode={mode} />}
|
fallback={(error, reset) => (
|
||||||
|
<ClipboardProvider value={clipboard}>
|
||||||
|
<ErrorComponent error={error} reset={reset} mode={mode} />
|
||||||
|
</ClipboardProvider>
|
||||||
|
)}
|
||||||
>
|
>
|
||||||
<TuiPathsProvider
|
<TuiPathsProvider
|
||||||
value={{
|
value={{
|
||||||
@@ -335,7 +347,7 @@ export const run = Effect.fn("Tui.run")(function* (input: TuiInput) {
|
|||||||
skipInitialLoading: Boolean(process.env.OPENCODE_FAST_BOOT),
|
skipInitialLoading: Boolean(process.env.OPENCODE_FAST_BOOT),
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<ClipboardProvider>
|
<ClipboardProvider value={clipboard}>
|
||||||
<ArgsProvider {...input.args}>
|
<ArgsProvider {...input.args}>
|
||||||
<ConfigProvider
|
<ConfigProvider
|
||||||
config={config}
|
config={config}
|
||||||
@@ -503,7 +515,7 @@ function App(props: { pair?: DialogPairCredentials }) {
|
|||||||
if (!text || text.length === 0) return
|
if (!text || text.length === 0) return
|
||||||
|
|
||||||
await clipboard
|
await clipboard
|
||||||
.write?.(text)
|
.write(text)
|
||||||
.then(() => toast.show({ message: "Copied to clipboard", variant: "info" }))
|
.then(() => toast.show({ message: "Copied to clipboard", variant: "info" }))
|
||||||
.catch(toast.error)
|
.catch(toast.error)
|
||||||
|
|
||||||
|
|||||||
+59
-117
@@ -1,124 +1,66 @@
|
|||||||
import { execFile, spawn } from "node:child_process"
|
import {
|
||||||
import { readFile, rm } from "node:fs/promises"
|
createClipboard,
|
||||||
import { platform, release, tmpdir } from "node:os"
|
createHostClipboard,
|
||||||
import path from "node:path"
|
createRendererClipboardAdapter,
|
||||||
import { promisify } from "node:util"
|
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<ClipboardService> & Readonly<{ dispose(): Promise<void> }>
|
||||||
|
|
||||||
function command(command: string, args: string[] = [], input?: string) {
|
export function createTuiClipboard(renderer: RendererClipboardBoundary): OwnedClipboardService {
|
||||||
return new Promise<Buffer>((resolve, reject) => {
|
return createClipboardAdapter(
|
||||||
const child = spawn(command, args, { stdio: [input === undefined ? "ignore" : "pipe", "pipe", "ignore"] })
|
createClipboard({
|
||||||
const output: Buffer[] = []
|
host: createHostClipboard(),
|
||||||
child.on("error", reject)
|
terminal: createRendererClipboardAdapter(renderer),
|
||||||
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)
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function writeOsc52(text: string) {
|
export function createClipboardAdapter(clipboard: CoreClipboardService): OwnedClipboardService {
|
||||||
if (!process.stdout.isTTY) return
|
return {
|
||||||
const sequence = `\x1b]52;c;${Buffer.from(text).toString("base64")}\x07`
|
async read(): Promise<ClipboardContent | undefined> {
|
||||||
process.stdout.write(process.env.TMUX || process.env.STY ? `\x1bPtmux;\x1b${sequence}\x1b\\` : sequence)
|
const result = await clipboard.read({
|
||||||
}
|
preferredTypes: ["image/png", "text/plain"],
|
||||||
|
selection: "clipboard",
|
||||||
export async function read() {
|
})
|
||||||
if (platform() === "darwin") {
|
if (result.status !== "read") {
|
||||||
const file = path.join(tmpdir(), "opencode-clipboard.png")
|
if (result.status === "failed") throw result.error
|
||||||
try {
|
if (result.status === "timed-out") throw new Error("Clipboard read timed out")
|
||||||
await exec("osascript", [
|
if (result.status === "limit-exceeded") {
|
||||||
"-e",
|
throw new RangeError("Clipboard content exceeded configured read or image conversion limits")
|
||||||
'set imageData to the clipboard as "PNGf"',
|
}
|
||||||
"-e",
|
return undefined
|
||||||
`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<void>> | 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)
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
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) {
|
if (result.representation.mimeType === "image/png") {
|
||||||
writeOsc52(text)
|
return {
|
||||||
const method = await getCopyMethod()
|
data: Buffer.from(result.representation.bytes).toString("base64"),
|
||||||
await method(text)
|
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()
|
||||||
|
},
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -84,6 +84,15 @@ export const settings: Setting[] = [
|
|||||||
values: ["none", "auto"],
|
values: ["none", "auto"],
|
||||||
keywords: ["transcript", "messages"],
|
keywords: ["transcript", "messages"],
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
title: "Transcript images",
|
||||||
|
category: "Session",
|
||||||
|
path: ["session", "image_preview"],
|
||||||
|
default: false,
|
||||||
|
values: [false, true],
|
||||||
|
labels: ["off", "on"],
|
||||||
|
keywords: ["attachments", "images", "tool output"],
|
||||||
|
},
|
||||||
{
|
{
|
||||||
title: "Enabled",
|
title: "Enabled",
|
||||||
category: "Tabs",
|
category: "Tabs",
|
||||||
@@ -188,6 +197,15 @@ export const settings: Setting[] = [
|
|||||||
values: ["compact", "full"],
|
values: ["compact", "full"],
|
||||||
keywords: ["paste summary", "clipboard", "pasted content"],
|
keywords: ["paste summary", "clipboard", "pasted content"],
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
title: "Image previews",
|
||||||
|
category: "Input",
|
||||||
|
path: ["prompt", "image_preview"],
|
||||||
|
default: false,
|
||||||
|
values: [false, true],
|
||||||
|
labels: ["off", "on"],
|
||||||
|
keywords: ["attachments", "clipboard", "images", "prompt"],
|
||||||
|
},
|
||||||
{
|
{
|
||||||
title: "Leader timeout",
|
title: "Leader timeout",
|
||||||
category: "Input",
|
category: "Input",
|
||||||
|
|||||||
@@ -39,7 +39,7 @@ export function DialogDebug() {
|
|||||||
.map((entry) => `${entry.label}: ${entry.value}`)
|
.map((entry) => `${entry.label}: ${entry.value}`)
|
||||||
.join("\n")
|
.join("\n")
|
||||||
void clipboard
|
void clipboard
|
||||||
.write?.(text)
|
.write(text)
|
||||||
.then(() => {
|
.then(() => {
|
||||||
setCopied(true)
|
setCopied(true)
|
||||||
toast.show({ message: "Debug info copied to clipboard", variant: "info" })
|
toast.show({ message: "Debug info copied to clipboard", variant: "info" })
|
||||||
|
|||||||
@@ -0,0 +1,71 @@
|
|||||||
|
import { TextAttributes } from "@opentui/core"
|
||||||
|
import { useTerminalDimensions } from "@opentui/solid"
|
||||||
|
import { createMemo, createSignal } from "solid-js"
|
||||||
|
import { Keymap } from "../context/keymap"
|
||||||
|
import { useTheme } from "../context/theme"
|
||||||
|
import { useDialog } from "../ui/dialog"
|
||||||
|
|
||||||
|
type ImagePreviewItem = Readonly<{
|
||||||
|
uri: string
|
||||||
|
mention?: Readonly<{ text: string }>
|
||||||
|
}>
|
||||||
|
|
||||||
|
export function DialogImagePreview(props: { images: readonly ImagePreviewItem[]; initial: number }) {
|
||||||
|
const dialog = useDialog()
|
||||||
|
const dimensions = useTerminalDimensions()
|
||||||
|
const theme = useTheme("elevated")
|
||||||
|
const [index, setIndex] = createSignal(Math.max(0, Math.min(props.images.length - 1, props.initial)))
|
||||||
|
const [failed, setFailed] = createSignal(false)
|
||||||
|
const current = createMemo(() => props.images[index()])
|
||||||
|
const imageHeight = createMemo(() => Math.max(3, dimensions().height - 8))
|
||||||
|
|
||||||
|
dialog.setSize("xlarge")
|
||||||
|
dialog.setCentered(true)
|
||||||
|
|
||||||
|
function move(direction: number) {
|
||||||
|
if (props.images.length < 2) return
|
||||||
|
setFailed(false)
|
||||||
|
setIndex((value) => (value + direction + props.images.length) % props.images.length)
|
||||||
|
}
|
||||||
|
|
||||||
|
Keymap.createLayer(() => ({
|
||||||
|
mode: "modal",
|
||||||
|
commands: [
|
||||||
|
{ bind: "left", title: "Previous image", group: "Dialog", run: () => move(-1) },
|
||||||
|
{ bind: "right", title: "Next image", group: "Dialog", run: () => move(1) },
|
||||||
|
],
|
||||||
|
}))
|
||||||
|
|
||||||
|
return (
|
||||||
|
<box id="prompt-image-viewer" paddingLeft={2} paddingRight={2} paddingBottom={1} gap={1}>
|
||||||
|
<box flexDirection="row" justifyContent="space-between">
|
||||||
|
<text attributes={TextAttributes.BOLD} fg={theme.text.default}>
|
||||||
|
Image {index() + 1} of {props.images.length}
|
||||||
|
</text>
|
||||||
|
<text fg={theme.text.subdued} onMouseUp={() => dialog.clear()}>
|
||||||
|
esc
|
||||||
|
</text>
|
||||||
|
</box>
|
||||||
|
<image
|
||||||
|
id="prompt-image-viewer-image"
|
||||||
|
source={current().uri}
|
||||||
|
fit="fit"
|
||||||
|
protocol="auto"
|
||||||
|
width="100%"
|
||||||
|
height={imageHeight()}
|
||||||
|
onError={() => setFailed(true)}
|
||||||
|
/>
|
||||||
|
<box flexDirection="row" justifyContent="space-between">
|
||||||
|
<text fg={theme.text.subdued} onMouseUp={() => move(-1)}>
|
||||||
|
{props.images.length > 1 ? "← previous" : ""}
|
||||||
|
</text>
|
||||||
|
<text fg={failed() ? theme.text.feedback.error.default : theme.text.subdued} wrapMode="none" truncate>
|
||||||
|
{failed() ? "No preview" : (current().mention?.text ?? `Image ${index() + 1}`)}
|
||||||
|
</text>
|
||||||
|
<text fg={theme.text.subdued} onMouseUp={() => move(1)}>
|
||||||
|
{props.images.length > 1 ? "next →" : ""}
|
||||||
|
</text>
|
||||||
|
</box>
|
||||||
|
</box>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -466,7 +466,7 @@ function OAuthAuto(props: {
|
|||||||
run: () => {
|
run: () => {
|
||||||
const value = props.attempt.instructions.match(/[A-Z0-9]{4}-[A-Z0-9]{4,5}/)?.[0] ?? props.attempt.url
|
const value = props.attempt.instructions.match(/[A-Z0-9]{4}-[A-Z0-9]{4,5}/)?.[0] ?? props.attempt.url
|
||||||
clipboard
|
clipboard
|
||||||
.write?.(value)
|
.write(value)
|
||||||
.then(() => toast.show({ message: "Copied to clipboard", variant: "info" }))
|
.then(() => toast.show({ message: "Copied to clipboard", variant: "info" }))
|
||||||
.catch(toast.error)
|
.catch(toast.error)
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -172,7 +172,6 @@ function DialogMcpError(props: { server: McpServer; onBack: () => void }) {
|
|||||||
onMount(() => dialog.setSize("large"))
|
onMount(() => dialog.setSize("large"))
|
||||||
|
|
||||||
const copy = () => {
|
const copy = () => {
|
||||||
if (!clipboard.write) return
|
|
||||||
void clipboard
|
void clipboard
|
||||||
.write(error())
|
.write(error())
|
||||||
.then(() => setCopied(true))
|
.then(() => setCopied(true))
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ export function ErrorComponent(props: { error: Error; reset: () => void; mode?:
|
|||||||
const exit = useExit()
|
const exit = useExit()
|
||||||
const clipboard = useClipboard()
|
const clipboard = useClipboard()
|
||||||
const app = useTuiApp()
|
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
|
// Safe fallback palette per mode (mirrors theme/assets/opencode.json) since the
|
||||||
// theme context may be the thing that crashed.
|
// 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 issueURL = buildIssueURL(message, stack, app.version)
|
||||||
|
|
||||||
const copyReport = () => {
|
const copyReport = () => {
|
||||||
void clipboard.write?.(issueURL.toString()).then(() => setCopied(true))
|
void clipboard
|
||||||
|
.write(issueURL.toString())
|
||||||
|
.then(() => setCopyState("copied"))
|
||||||
|
.catch(() => setCopyState("failed"))
|
||||||
}
|
}
|
||||||
|
|
||||||
const actions = [
|
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: "r", label: () => "Restart", onUse: props.reset },
|
||||||
{ key: "q", label: () => "Quit", onUse: () => exit() },
|
{ key: "q", label: () => "Quit", onUse: () => exit() },
|
||||||
]
|
]
|
||||||
@@ -135,13 +143,20 @@ export function ErrorComponent(props: { error: Error; reset: () => void; mode?:
|
|||||||
<For each={actions}>
|
<For each={actions}>
|
||||||
{(action, index) => {
|
{(action, index) => {
|
||||||
const isSelected = () => selected() === 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 (
|
return (
|
||||||
<box flexDirection="column" alignItems="center" flexShrink={0}>
|
<box flexDirection="column" alignItems="center" flexShrink={0}>
|
||||||
<box
|
<box
|
||||||
onMouseDown={() => setSelected(index())}
|
onMouseDown={() => setSelected(index())}
|
||||||
onMouseUp={() => action.onUse()}
|
onMouseUp={() => action.onUse()}
|
||||||
backgroundColor={isCopied() ? colors.success : isSelected() ? colors.primary : colors.element}
|
backgroundColor={copyColor() ?? (isSelected() ? colors.primary : colors.element)}
|
||||||
minWidth={15}
|
minWidth={15}
|
||||||
alignItems="center"
|
alignItems="center"
|
||||||
paddingLeft={2}
|
paddingLeft={2}
|
||||||
@@ -149,7 +164,7 @@ export function ErrorComponent(props: { error: Error; reset: () => void; mode?:
|
|||||||
>
|
>
|
||||||
<text
|
<text
|
||||||
attributes={TextAttributes.BOLD}
|
attributes={TextAttributes.BOLD}
|
||||||
fg={isCopied() || isSelected() ? colors.onPrimary : colors.text}
|
fg={copyColor() || isSelected() ? colors.onPrimary : colors.text}
|
||||||
>
|
>
|
||||||
{action.label()}
|
{action.label()}
|
||||||
</text>
|
</text>
|
||||||
@@ -189,9 +204,11 @@ export function ErrorComponent(props: { error: Error; reset: () => void; mode?:
|
|||||||
<Show when={showFooter()}>
|
<Show when={showFooter()}>
|
||||||
<box flexDirection="column" alignItems="center" flexShrink={0}>
|
<box flexDirection="column" alignItems="center" flexShrink={0}>
|
||||||
<text fg={colors.muted}>
|
<text fg={colors.muted}>
|
||||||
{copied()
|
{copyState() === "copied"
|
||||||
? "Report copied — paste it into a new GitHub issue."
|
? "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."}
|
||||||
</text>
|
</text>
|
||||||
<text fg={colors.muted}>OpenCode {app.version}</text>
|
<text fg={colors.muted}>OpenCode {app.version}</text>
|
||||||
</box>
|
</box>
|
||||||
|
|||||||
@@ -7,9 +7,8 @@ import {
|
|||||||
decodePasteBytes,
|
decodePasteBytes,
|
||||||
type KeyEvent,
|
type KeyEvent,
|
||||||
} from "@opentui/core"
|
} from "@opentui/core"
|
||||||
import { createEffect, createMemo, onMount, createSignal, onCleanup, on, Show, Switch, Match } from "solid-js"
|
import { createEffect, createMemo, onMount, createSignal, onCleanup, on, Show, Switch, Match, For } from "solid-js"
|
||||||
import path from "path"
|
import path from "path"
|
||||||
import { fileURLToPath } from "url"
|
|
||||||
import { useLocal } from "../../context/local"
|
import { useLocal } from "../../context/local"
|
||||||
import { useTheme, useThemes } from "../../context/theme"
|
import { useTheme, useThemes } from "../../context/theme"
|
||||||
import { tint } from "../../theme/color"
|
import { tint } from "../../theme/color"
|
||||||
@@ -47,12 +46,19 @@ import { DialogSkill } from "../dialog-skill"
|
|||||||
import { useArgs } from "../../context/args"
|
import { useArgs } from "../../context/args"
|
||||||
import { useConfig } from "../../config"
|
import { useConfig } from "../../config"
|
||||||
import { usePromptMove } from "./move"
|
import { usePromptMove } from "./move"
|
||||||
import { readLocalAttachment } from "./local-attachment"
|
import {
|
||||||
|
normalizePastedFilepath,
|
||||||
|
parsePastedFilepaths,
|
||||||
|
readLocalAttachment,
|
||||||
|
MAX_LOCAL_ATTACHMENT_BYTES,
|
||||||
|
type LocalAttachment,
|
||||||
|
} from "./local-attachment"
|
||||||
import { useData } from "../../context/data"
|
import { useData } from "../../context/data"
|
||||||
import { useLocation } from "../../context/location"
|
import { useLocation } from "../../context/location"
|
||||||
import { Keymap, type KeymapCommand } from "../../context/keymap"
|
import { Keymap, type KeymapCommand } from "../../context/keymap"
|
||||||
import { abbreviateHome } from "../../runtime"
|
import { abbreviateHome } from "../../runtime"
|
||||||
import { PluginSlot } from "../../plugin/render"
|
import { PluginSlot } from "../../plugin/render"
|
||||||
|
import { DialogImagePreview } from "../dialog-image-preview"
|
||||||
|
|
||||||
export type PromptProps = {
|
export type PromptProps = {
|
||||||
sessionID?: string
|
sessionID?: string
|
||||||
@@ -69,17 +75,6 @@ export type PromptProps = {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function pastedFilepath(value: string, platform: string) {
|
|
||||||
const raw = value.replace(/^['"]+|['"]+$/g, "")
|
|
||||||
if (raw.startsWith("file://")) {
|
|
||||||
try {
|
|
||||||
return fileURLToPath(raw)
|
|
||||||
} catch {}
|
|
||||||
}
|
|
||||||
if (platform === "win32") return raw
|
|
||||||
return raw.replace(/\\(.)/g, "$1")
|
|
||||||
}
|
|
||||||
|
|
||||||
export type PromptRef = {
|
export type PromptRef = {
|
||||||
focused: boolean
|
focused: boolean
|
||||||
current: PromptInfo
|
current: PromptInfo
|
||||||
@@ -307,6 +302,41 @@ export function Prompt(props: PromptProps) {
|
|||||||
extmarkToPart: new Map(),
|
extmarkToPart: new Map(),
|
||||||
interrupt: 0,
|
interrupt: 0,
|
||||||
})
|
})
|
||||||
|
let disposed = false
|
||||||
|
let pasteQueue = Promise.resolve()
|
||||||
|
|
||||||
|
function enqueuePaste(run: (changed: () => boolean) => Promise<void>) {
|
||||||
|
pasteQueue = pasteQueue
|
||||||
|
.then(async () => {
|
||||||
|
if (disposed || input.isDestroyed) return
|
||||||
|
const before = { sessionID: props.sessionID, mode: store.mode, text: input.plainText }
|
||||||
|
await run(
|
||||||
|
() =>
|
||||||
|
disposed ||
|
||||||
|
input.isDestroyed ||
|
||||||
|
props.sessionID !== before.sessionID ||
|
||||||
|
store.mode !== before.mode ||
|
||||||
|
input.plainText !== before.text,
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
if (!disposed) toast.error(error)
|
||||||
|
})
|
||||||
|
return pasteQueue
|
||||||
|
}
|
||||||
|
|
||||||
|
const imageAttachments = createMemo(() =>
|
||||||
|
(store.prompt.files ?? []).filter((file) => typeof file.uri === "string" && file.uri.startsWith("data:image/")),
|
||||||
|
)
|
||||||
|
const imagePreviewHeight = createMemo(() => Math.max(4, Math.min(8, Math.floor(dimensions().height / 4))))
|
||||||
|
const imagePreviewWidth = createMemo(() => imagePreviewHeight() * 2)
|
||||||
|
const visibleImageAttachments = createMemo(() => imageAttachments().slice(0, 3))
|
||||||
|
|
||||||
|
function openImagePreview(initial: number) {
|
||||||
|
const images = imageAttachments()
|
||||||
|
if (images.length === 0) return
|
||||||
|
dialog.replace(() => <DialogImagePreview images={images} initial={initial} />)
|
||||||
|
}
|
||||||
|
|
||||||
createEffect(
|
createEffect(
|
||||||
on(
|
on(
|
||||||
@@ -372,22 +402,32 @@ export function Prompt(props: PromptProps) {
|
|||||||
name: "prompt.paste",
|
name: "prompt.paste",
|
||||||
category: "Prompt",
|
category: "Prompt",
|
||||||
palette: undefined,
|
palette: undefined,
|
||||||
run: async (_input: string | undefined, event?: KeyEvent) => {
|
run: (_input: string | undefined, event?: KeyEvent) => {
|
||||||
event?.preventDefault()
|
event?.preventDefault()
|
||||||
event?.stopPropagation()
|
event?.stopPropagation()
|
||||||
const content = await clipboard.read?.()
|
return enqueuePaste(async (changed) => {
|
||||||
if (content?.mime.startsWith("image/")) {
|
const content = await clipboard.read()
|
||||||
await pasteAttachment({
|
if (changed()) return
|
||||||
filename: "clipboard",
|
if (content?.mime.startsWith("image/")) {
|
||||||
uri: `data:${content.mime};base64,${content.data}`,
|
pasteAttachment({
|
||||||
})
|
filename: "clipboard",
|
||||||
return
|
uri: `data:${content.mime};base64,${content.data}`,
|
||||||
}
|
})
|
||||||
if (content?.mime === "text/plain") {
|
return
|
||||||
await pasteInputText(content.data)
|
}
|
||||||
}
|
if (content?.mime === "text/plain") {
|
||||||
|
await pasteInputText(content.data, changed)
|
||||||
|
}
|
||||||
|
})
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
title: "View image attachments",
|
||||||
|
name: "prompt.images.view",
|
||||||
|
category: "Prompt",
|
||||||
|
enabled: imageAttachments().length > 0,
|
||||||
|
run: () => openImagePreview(0),
|
||||||
|
},
|
||||||
{
|
{
|
||||||
title: "Interrupt session",
|
title: "Interrupt session",
|
||||||
name: "session.interrupt",
|
name: "session.interrupt",
|
||||||
@@ -520,6 +560,7 @@ export function Prompt(props: PromptProps) {
|
|||||||
"prompt.submit",
|
"prompt.submit",
|
||||||
"prompt.editor",
|
"prompt.editor",
|
||||||
"prompt.editor_context.clear",
|
"prompt.editor_context.clear",
|
||||||
|
"prompt.images.view",
|
||||||
"prompt.stash",
|
"prompt.stash",
|
||||||
"prompt.stash.pop",
|
"prompt.stash.pop",
|
||||||
"prompt.stash.list",
|
"prompt.stash.list",
|
||||||
@@ -573,6 +614,7 @@ export function Prompt(props: PromptProps) {
|
|||||||
})
|
})
|
||||||
|
|
||||||
onCleanup(() => {
|
onCleanup(() => {
|
||||||
|
disposed = true
|
||||||
if (store.prompt.text) {
|
if (store.prompt.text) {
|
||||||
stashed = { prompt: unwrap(store.prompt), cursor: input.cursorOffset }
|
stashed = { prompt: unwrap(store.prompt), cursor: input.cursorOffset }
|
||||||
}
|
}
|
||||||
@@ -1180,27 +1222,39 @@ export function Prompt(props: PromptProps) {
|
|||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
async function pasteInputText(text: string) {
|
async function pasteInputText(text: string, changed: () => boolean) {
|
||||||
const normalizedText = text.replace(/\r\n/g, "\n").replace(/\r/g, "\n")
|
const normalizedText = text.replace(/\r\n/g, "\n").replace(/\r/g, "\n")
|
||||||
const pastedContent = normalizedText.trim()
|
const pastedContent = normalizedText.trim()
|
||||||
const filepath = pastedFilepath(pastedContent, terminalEnvironment.platform)
|
const filepath = normalizePastedFilepath(pastedContent, terminalEnvironment.platform)
|
||||||
const isUrl = /^(https?):\/\//.test(filepath)
|
const isUrl = /^(https?):\/\//.test(filepath)
|
||||||
if (!isUrl) {
|
if (!isUrl) {
|
||||||
const attachment = await readLocalAttachment(filepath)
|
const attachment = await readLocalAttachment(filepath)
|
||||||
const filename = path.basename(filepath)
|
if (attachment) {
|
||||||
if (attachment?.type === "text") {
|
if (changed()) return
|
||||||
pasteText(attachment.content, `[SVG: ${filename ?? "image"}]`)
|
pasteLocalAttachment(filepath, attachment)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (attachment?.type === "binary") {
|
|
||||||
await pasteAttachment({
|
const filepaths = parsePastedFilepaths(pastedContent, terminalEnvironment.platform)
|
||||||
filename,
|
if (filepaths.length > 1) {
|
||||||
uri: `data:${attachment.mime};base64,${Buffer.from(attachment.content).toString("base64")}`,
|
let remaining = MAX_LOCAL_ATTACHMENT_BYTES
|
||||||
})
|
const attachments: Array<{ filepath: string; attachment: LocalAttachment }> = []
|
||||||
return
|
for (const candidate of filepaths) {
|
||||||
|
const next = await readLocalAttachment(candidate, remaining)
|
||||||
|
if (!next) break
|
||||||
|
remaining -= typeof next.content === "string" ? Buffer.byteLength(next.content) : next.content.byteLength
|
||||||
|
attachments.push({ filepath: candidate, attachment: next })
|
||||||
|
}
|
||||||
|
if (attachments.length === filepaths.length) {
|
||||||
|
if (changed()) return
|
||||||
|
for (const item of attachments) pasteLocalAttachment(item.filepath, item.attachment)
|
||||||
|
return
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (changed()) return
|
||||||
|
|
||||||
const lineCount = (pastedContent.match(/\n/g)?.length ?? 0) + 1
|
const lineCount = (pastedContent.match(/\n/g)?.length ?? 0) + 1
|
||||||
if ((lineCount >= 3 || pastedContent.length > 150) && config.prompt?.paste !== "full") {
|
if ((lineCount >= 3 || pastedContent.length > 150) && config.prompt?.paste !== "full") {
|
||||||
const extmark = input.extmarks.getAllForTypeId(promptPartTypeId).find((extmark) => {
|
const extmark = input.extmarks.getAllForTypeId(promptPartTypeId).find((extmark) => {
|
||||||
@@ -1225,12 +1279,27 @@ export function Prompt(props: PromptProps) {
|
|||||||
}, 0)
|
}, 0)
|
||||||
}
|
}
|
||||||
|
|
||||||
async function pasteAttachment(file: { filename?: string; uri: string }) {
|
function pasteLocalAttachment(filepath: string, attachment: LocalAttachment) {
|
||||||
|
const filename = path.basename(filepath)
|
||||||
|
if (attachment.type === "text") {
|
||||||
|
pasteText(attachment.content, `[SVG: ${filename || "image"}]`)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
pasteAttachment({
|
||||||
|
filename,
|
||||||
|
uri: `data:${attachment.mime};base64,${Buffer.from(attachment.content).toString("base64")}`,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function pasteAttachment(file: { filename?: string; uri: string }) {
|
||||||
const currentOffset = input.cursorOffset
|
const currentOffset = input.cursorOffset
|
||||||
const extmarkStart = currentOffset
|
const extmarkStart = currentOffset
|
||||||
const pdf = file.uri.startsWith("data:application/pdf;")
|
const pdf = file.uri.startsWith("data:application/pdf;")
|
||||||
const prefix = pdf ? "data:application/pdf;" : "data:image/"
|
const count = pdf
|
||||||
const count = store.prompt.files?.filter((attachment) => attachment.uri.startsWith(prefix)).length ?? 0
|
? (store.prompt.files?.filter(
|
||||||
|
(attachment) => typeof attachment.uri === "string" && attachment.uri.startsWith("data:application/pdf;"),
|
||||||
|
).length ?? 0)
|
||||||
|
: imageAttachments().length
|
||||||
const virtualText = pdf ? `[PDF ${count + 1}]` : `[Image ${count + 1}]`
|
const virtualText = pdf ? `[PDF ${count + 1}]` : `[Image ${count + 1}]`
|
||||||
const extmarkEnd = extmarkStart + virtualText.length
|
const extmarkEnd = extmarkStart + virtualText.length
|
||||||
const textToInsert = virtualText + " "
|
const textToInsert = virtualText + " "
|
||||||
@@ -1262,7 +1331,6 @@ export function Prompt(props: PromptProps) {
|
|||||||
draft.extmarkToPart.set(extmarkId, { type: "file", index })
|
draft.extmarkToPart.set(extmarkId, { type: "file", index })
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
return
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function clearPrompt() {
|
function clearPrompt() {
|
||||||
@@ -1386,6 +1454,74 @@ export function Prompt(props: PromptProps) {
|
|||||||
flexGrow={1}
|
flexGrow={1}
|
||||||
width="100%"
|
width="100%"
|
||||||
>
|
>
|
||||||
|
<Show when={config.prompt?.image_preview && visibleImageAttachments().length > 0}>
|
||||||
|
<box
|
||||||
|
width="100%"
|
||||||
|
height={imagePreviewHeight() + 1}
|
||||||
|
flexDirection="row"
|
||||||
|
flexShrink={0}
|
||||||
|
justifyContent="flex-start"
|
||||||
|
gap={1}
|
||||||
|
paddingBottom={1}
|
||||||
|
>
|
||||||
|
<For each={visibleImageAttachments()}>
|
||||||
|
{(file, index) => {
|
||||||
|
const [failed, setFailed] = createSignal(false)
|
||||||
|
return (
|
||||||
|
<box
|
||||||
|
width={imagePreviewWidth()}
|
||||||
|
height={imagePreviewHeight()}
|
||||||
|
flexBasis={imagePreviewWidth()}
|
||||||
|
flexShrink={1}
|
||||||
|
onMouseUp={(event: MouseEvent) => {
|
||||||
|
if (event.button !== 0) return
|
||||||
|
event.stopPropagation()
|
||||||
|
openImagePreview(index())
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Show
|
||||||
|
when={!failed()}
|
||||||
|
fallback={
|
||||||
|
<box width="100%" height="100%" alignItems="center" justifyContent="center">
|
||||||
|
<text fg={theme.text.subdued}>No preview</text>
|
||||||
|
</box>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<image
|
||||||
|
id={`prompt-image-preview-${index()}`}
|
||||||
|
source={file.uri}
|
||||||
|
fit="cover"
|
||||||
|
protocol="auto"
|
||||||
|
width="100%"
|
||||||
|
height="100%"
|
||||||
|
onError={() => setFailed(true)}
|
||||||
|
/>
|
||||||
|
</Show>
|
||||||
|
</box>
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
</For>
|
||||||
|
<Show when={imageAttachments().length > visibleImageAttachments().length}>
|
||||||
|
<box
|
||||||
|
width={8}
|
||||||
|
height={imagePreviewHeight()}
|
||||||
|
flexBasis={8}
|
||||||
|
flexShrink={1}
|
||||||
|
alignItems="center"
|
||||||
|
justifyContent="center"
|
||||||
|
onMouseUp={(event: MouseEvent) => {
|
||||||
|
if (event.button !== 0) return
|
||||||
|
event.stopPropagation()
|
||||||
|
openImagePreview(visibleImageAttachments().length)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<text fg={theme.text.subdued} wrapMode="none" truncate>
|
||||||
|
+{imageAttachments().length - visibleImageAttachments().length} more
|
||||||
|
</text>
|
||||||
|
</box>
|
||||||
|
</Show>
|
||||||
|
</box>
|
||||||
|
</Show>
|
||||||
<textarea
|
<textarea
|
||||||
width="100%"
|
width="100%"
|
||||||
placeholder={placeholderText()}
|
placeholder={placeholderText()}
|
||||||
@@ -1413,7 +1549,7 @@ export function Prompt(props: PromptProps) {
|
|||||||
// hangul) is flushed to plainText before we read it for submission.
|
// hangul) is flushed to plainText before we read it for submission.
|
||||||
setTimeout(() => setTimeout(() => submit(), 0), 0)
|
setTimeout(() => setTimeout(() => submit(), 0), 0)
|
||||||
}}
|
}}
|
||||||
onPaste={async (event: PasteEvent) => {
|
onPaste={(event: PasteEvent) => {
|
||||||
if (props.disabled) {
|
if (props.disabled) {
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
return
|
return
|
||||||
@@ -1423,11 +1559,10 @@ export function Prompt(props: PromptProps) {
|
|||||||
// Windows ConPTY/Terminal often sends CR-only newlines in bracketed paste
|
// Windows ConPTY/Terminal often sends CR-only newlines in bracketed paste
|
||||||
// Replace CRLF first, then any remaining CR
|
// Replace CRLF first, then any remaining CR
|
||||||
const normalizedText = decodePasteBytes(event.bytes).replace(/\r\n/g, "\n").replace(/\r/g, "\n")
|
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
|
// Windows Terminal <1.25 can surface image-only clipboard as an
|
||||||
// empty bracketed paste. Windows Terminal 1.25+ does not.
|
// empty bracketed paste. Windows Terminal 1.25+ does not.
|
||||||
if (!pastedContent) {
|
if (event.bytes.byteLength === 0) {
|
||||||
keymap.dispatch("prompt.paste")
|
keymap.dispatch("prompt.paste")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -1436,7 +1571,7 @@ export function Prompt(props: PromptProps) {
|
|||||||
// default paste unless we suppress it first and handle insertion ourselves.
|
// default paste unless we suppress it first and handle insertion ourselves.
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
|
|
||||||
await pasteInputText(normalizedText)
|
void enqueuePaste((changed) => pasteInputText(normalizedText, changed))
|
||||||
}}
|
}}
|
||||||
ref={(r: TextareaRenderable) => {
|
ref={(r: TextareaRenderable) => {
|
||||||
input = r
|
input = r
|
||||||
|
|||||||
@@ -1,9 +1,13 @@
|
|||||||
import { readFile } from "node:fs/promises"
|
|
||||||
import path from "node:path"
|
import path from "node:path"
|
||||||
|
import { fileURLToPath } from "node:url"
|
||||||
|
|
||||||
|
// Bound filesystem work per terminal paste; the byte budget also bounds staged data.
|
||||||
|
const MAX_PASTED_FILEPATHS = 32
|
||||||
|
export const MAX_LOCAL_ATTACHMENT_BYTES = 20 * 1024 * 1024
|
||||||
|
|
||||||
export type LocalFiles = Readonly<{
|
export type LocalFiles = Readonly<{
|
||||||
readText(path: string): Promise<string>
|
readText(path: string, maxBytes: number): Promise<string>
|
||||||
readBytes(path: string): Promise<Uint8Array>
|
readBytes(path: string, maxBytes: number): Promise<Uint8Array>
|
||||||
mime(path: string): Promise<string>
|
mime(path: string): Promise<string>
|
||||||
}>
|
}>
|
||||||
|
|
||||||
@@ -11,14 +15,15 @@ export type LocalAttachment =
|
|||||||
| Readonly<{ type: "text"; mime: "image/svg+xml"; content: string }>
|
| Readonly<{ type: "text"; mime: "image/svg+xml"; content: string }>
|
||||||
| Readonly<{ type: "binary"; mime: string; content: Uint8Array }>
|
| Readonly<{ type: "binary"; mime: string; content: Uint8Array }>
|
||||||
|
|
||||||
export function readLocalAttachment(file: string) {
|
export function readLocalAttachment(file: string, maxBytes = MAX_LOCAL_ATTACHMENT_BYTES) {
|
||||||
return readLocalAttachmentWith(
|
return readLocalAttachmentWith(
|
||||||
{
|
{
|
||||||
readText: (value) => readFile(value, "utf8"),
|
readText: async (value, limit) => (await readFileBounded(value, limit)).toString("utf8"),
|
||||||
readBytes: (value) => readFile(value),
|
readBytes: readFileBounded,
|
||||||
mime: async (value) => mimeTypes[path.extname(value).toLowerCase()] ?? "application/octet-stream",
|
mime: async (value) => mimeTypes[path.extname(value).toLowerCase()] ?? "application/octet-stream",
|
||||||
},
|
},
|
||||||
file,
|
file,
|
||||||
|
maxBytes,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -33,16 +38,99 @@ const mimeTypes: Record<string, string> = {
|
|||||||
".webp": "image/webp",
|
".webp": "image/webp",
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function readLocalAttachmentWith(files: LocalFiles, path: string): Promise<LocalAttachment | undefined> {
|
async function readFileBounded(file: string, maxBytes: number) {
|
||||||
|
const source = Bun.file(file)
|
||||||
|
if (!(await source.exists())) throw new Error("Attachment does not exist")
|
||||||
|
if (source.size > maxBytes) throw new Error("Attachment exceeds the local file limit")
|
||||||
|
const content = Buffer.from(await source.slice(0, maxBytes + 1).arrayBuffer())
|
||||||
|
if (content.byteLength > maxBytes) throw new Error("Attachment exceeds the local file limit")
|
||||||
|
return content
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizePastedFilepath(value: string, platform: string) {
|
||||||
|
const raw = value.replace(/^['"]+|['"]+$/g, "")
|
||||||
|
const url = decodeFileURL(raw)
|
||||||
|
if (url) return url
|
||||||
|
if (platform === "win32") return raw
|
||||||
|
return raw.replace(/\\(.)/g, "$1")
|
||||||
|
}
|
||||||
|
|
||||||
|
function decodeFileURL(value: string): string | undefined {
|
||||||
|
if (!value.startsWith("file://")) return undefined
|
||||||
|
try {
|
||||||
|
return fileURLToPath(value)
|
||||||
|
} catch {
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parsePastedFilepaths(value: string, platform: string) {
|
||||||
|
const result: string[] = []
|
||||||
|
let current = ""
|
||||||
|
let quote = ""
|
||||||
|
|
||||||
|
function push() {
|
||||||
|
if (!current) return
|
||||||
|
result.push(decodeFileURL(current) ?? current)
|
||||||
|
current = ""
|
||||||
|
}
|
||||||
|
|
||||||
|
const input = value.includes("file://")
|
||||||
|
? value
|
||||||
|
.split(/\r?\n/)
|
||||||
|
.filter((line) => !line.trimStart().startsWith("#"))
|
||||||
|
.join("\n")
|
||||||
|
: value
|
||||||
|
for (let index = 0; index < input.length; index++) {
|
||||||
|
const character = input[index]
|
||||||
|
if (quote) {
|
||||||
|
if (character === quote) {
|
||||||
|
quote = ""
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if (character === "\\" && platform !== "win32" && quote === '"' && index + 1 < input.length) {
|
||||||
|
current += input[++index]
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
current += character
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if (character === "'" || character === '"') {
|
||||||
|
quote = character
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if (character === "\\" && platform !== "win32" && index + 1 < input.length) {
|
||||||
|
current += input[++index]
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if (/\s/.test(character)) {
|
||||||
|
push()
|
||||||
|
if (result.length > MAX_PASTED_FILEPATHS) return []
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
current += character
|
||||||
|
}
|
||||||
|
|
||||||
|
if (quote) return []
|
||||||
|
push()
|
||||||
|
if (result.length > MAX_PASTED_FILEPATHS) return []
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function readLocalAttachmentWith(
|
||||||
|
files: LocalFiles,
|
||||||
|
path: string,
|
||||||
|
maxBytes = MAX_LOCAL_ATTACHMENT_BYTES,
|
||||||
|
): Promise<LocalAttachment | undefined> {
|
||||||
const mime = await files.mime(path).catch(() => undefined)
|
const mime = await files.mime(path).catch(() => undefined)
|
||||||
if (!mime) return
|
if (!mime) return undefined
|
||||||
|
if (!mime.startsWith("image/") && mime !== "application/pdf") return undefined
|
||||||
if (mime === "image/svg+xml") {
|
if (mime === "image/svg+xml") {
|
||||||
const content = await files.readText(path).catch(() => undefined)
|
const content = await files.readText(path, maxBytes).catch(() => undefined)
|
||||||
if (!content) return
|
if (!content || Buffer.byteLength(content) > maxBytes) return undefined
|
||||||
return { type: "text", mime, content }
|
return { type: "text", mime, content }
|
||||||
}
|
}
|
||||||
if (!mime.startsWith("image/") && mime !== "application/pdf") return
|
const content = await files.readBytes(path, maxBytes).catch(() => undefined)
|
||||||
const content = await files.readBytes(path).catch(() => undefined)
|
if (!content || content.byteLength > maxBytes) return undefined
|
||||||
if (!content) return
|
|
||||||
return { type: "binary", mime, content }
|
return { type: "binary", mime, content }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -105,6 +105,9 @@ export const Info = Schema.Struct({
|
|||||||
paste: Schema.optional(Schema.Literals(["compact", "full"])).annotate({
|
paste: Schema.optional(Schema.Literals(["compact", "full"])).annotate({
|
||||||
description: "Display large pastes as compact placeholders or full text",
|
description: "Display large pastes as compact placeholders or full text",
|
||||||
}),
|
}),
|
||||||
|
image_preview: Schema.optional(Schema.Boolean).annotate({
|
||||||
|
description: "Show image attachment previews above the prompt input",
|
||||||
|
}),
|
||||||
}),
|
}),
|
||||||
).annotate({ description: "Prompt input behavior" }),
|
).annotate({ description: "Prompt input behavior" }),
|
||||||
session: Schema.optional(
|
session: Schema.optional(
|
||||||
@@ -119,6 +122,9 @@ export const Info = Schema.Struct({
|
|||||||
grouping: Schema.optional(Schema.Literals(["auto", "none"])).annotate({
|
grouping: Schema.optional(Schema.Literals(["auto", "none"])).annotate({
|
||||||
description: "Group related transcript items automatically or render each item separately",
|
description: "Group related transcript items automatically or render each item separately",
|
||||||
}),
|
}),
|
||||||
|
image_preview: Schema.optional(Schema.Boolean).annotate({
|
||||||
|
description: "Show user attachment and tool-result images in the session transcript",
|
||||||
|
}),
|
||||||
markdown: Schema.optional(Schema.Literals(["source", "rendered"])).annotate({
|
markdown: Schema.optional(Schema.Literals(["source", "rendered"])).annotate({
|
||||||
description: "Show Markdown syntax markers or conceal them in rendered transcript content",
|
description: "Show Markdown syntax markers or conceal them in rendered transcript content",
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -162,6 +162,7 @@ export const Definitions = {
|
|||||||
|
|
||||||
prompt_submit: keybind("none", "Submit prompt"),
|
prompt_submit: keybind("none", "Submit prompt"),
|
||||||
prompt_editor_context_clear: keybind("none", "Clear editor context"),
|
prompt_editor_context_clear: keybind("none", "Clear editor context"),
|
||||||
|
prompt_images_view: keybind("<leader>i", "View image attachments"),
|
||||||
prompt_skills: keybind("none", "Open skill selector"),
|
prompt_skills: keybind("none", "Open skill selector"),
|
||||||
prompt_stash: keybind("none", "Stash prompt"),
|
prompt_stash: keybind("none", "Stash prompt"),
|
||||||
prompt_stash_pop: keybind("none", "Pop stashed prompt"),
|
prompt_stash_pop: keybind("none", "Pop stashed prompt"),
|
||||||
@@ -360,6 +361,7 @@ export const CommandMap = {
|
|||||||
display_thinking: "session.toggle.thinking",
|
display_thinking: "session.toggle.thinking",
|
||||||
prompt_submit: "prompt.submit",
|
prompt_submit: "prompt.submit",
|
||||||
prompt_editor_context_clear: "prompt.editor_context.clear",
|
prompt_editor_context_clear: "prompt.editor_context.clear",
|
||||||
|
prompt_images_view: "prompt.images.view",
|
||||||
prompt_skills: "prompt.skills",
|
prompt_skills: "prompt.skills",
|
||||||
prompt_stash: "prompt.stash",
|
prompt_stash: "prompt.stash",
|
||||||
prompt_stash_pop: "prompt.stash.pop",
|
prompt_stash_pop: "prompt.stash.pop",
|
||||||
|
|||||||
@@ -1,18 +1,19 @@
|
|||||||
import { createContext, type JSX, useContext } from "solid-js"
|
import { createContext, type JSX, useContext } from "solid-js"
|
||||||
import { read, write } from "../clipboard"
|
|
||||||
|
|
||||||
export type ClipboardContent = Readonly<{ data: string; mime: string }>
|
export type ClipboardContent = Readonly<{ data: string; mime: string }>
|
||||||
export type ClipboardService = Readonly<{
|
export type ClipboardService = Readonly<{
|
||||||
read?(): Promise<ClipboardContent | undefined>
|
read(): Promise<ClipboardContent | undefined>
|
||||||
write?(text: string): Promise<void>
|
write(text: string): Promise<void>
|
||||||
}>
|
}>
|
||||||
const clipboard = { read, write }
|
|
||||||
const ClipboardContext = createContext<ClipboardService>(clipboard)
|
|
||||||
|
|
||||||
export function ClipboardProvider(props: { value?: ClipboardService; children: JSX.Element }) {
|
const ClipboardContext = createContext<ClipboardService>()
|
||||||
return <ClipboardContext.Provider value={props.value ?? clipboard}>{props.children}</ClipboardContext.Provider>
|
|
||||||
|
export function ClipboardProvider(props: { value: ClipboardService; children: JSX.Element }) {
|
||||||
|
return <ClipboardContext.Provider value={props.value}>{props.children}</ClipboardContext.Provider>
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useClipboard() {
|
export function useClipboard() {
|
||||||
return useContext(ClipboardContext)
|
const value = useContext(ClipboardContext)
|
||||||
|
if (!value) throw new Error("useClipboard must be used within a ClipboardProvider")
|
||||||
|
return value
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -66,8 +66,12 @@ export function DialogMessage(props: {
|
|||||||
: "text" in value
|
: "text" in value
|
||||||
? value.text
|
? value.text
|
||||||
: ""
|
: ""
|
||||||
await clipboard.write?.(text)
|
try {
|
||||||
dialog.clear()
|
await clipboard.write(text)
|
||||||
|
dialog.clear()
|
||||||
|
} catch (error) {
|
||||||
|
toast.error(error)
|
||||||
|
}
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -364,7 +364,7 @@ export function FormPrompt(props: { form: FormWithLocation }) {
|
|||||||
|
|
||||||
function copyExternal() {
|
function copyExternal() {
|
||||||
const current = externalField()
|
const current = externalField()
|
||||||
if (!current || !clipboard.write) return
|
if (!current) return
|
||||||
void clipboard
|
void clipboard
|
||||||
.write(current.url)
|
.write(current.url)
|
||||||
.then(() => {
|
.then(() => {
|
||||||
@@ -992,7 +992,7 @@ export function FormPrompt(props: { form: FormWithLocation }) {
|
|||||||
>
|
>
|
||||||
enter <span style={{ fg: theme.text.subdued }}>{actionLabel()}</span>
|
enter <span style={{ fg: theme.text.subdued }}>{actionLabel()}</span>
|
||||||
</text>
|
</text>
|
||||||
<Show when={externalField() && clipboard.write}>
|
<Show when={externalField()}>
|
||||||
<text fg={theme.text.default} onMouseUp={copyExternal}>
|
<text fg={theme.text.default} onMouseUp={copyExternal}>
|
||||||
c <span style={{ fg: theme.text.subdued }}>copy</span>
|
c <span style={{ fg: theme.text.subdued }}>copy</span>
|
||||||
</text>
|
</text>
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ import { useTuiPaths, useTuiTerminalEnvironment } from "../../context/runtime"
|
|||||||
import { Spinner, SPINNER_FRAMES } from "../../component/spinner"
|
import { Spinner, SPINNER_FRAMES } from "../../component/spinner"
|
||||||
import { PatchDiff } from "../../component/patch-diff"
|
import { PatchDiff } from "../../component/patch-diff"
|
||||||
import { ThemeContextProvider, useTheme, useThemes } from "../../context/theme"
|
import { ThemeContextProvider, useTheme, useThemes } from "../../context/theme"
|
||||||
import { BoxRenderable, ScrollBoxRenderable, addDefaultParsers, TextAttributes, RGBA } from "@opentui/core"
|
import { BoxRenderable, ScrollBoxRenderable, addDefaultParsers, TextAttributes, RGBA, MouseEvent } from "@opentui/core"
|
||||||
import { Prompt, type PromptRef } from "../../component/prompt"
|
import { Prompt, type PromptRef } from "../../component/prompt"
|
||||||
import type {
|
import type {
|
||||||
ModelInfo,
|
ModelInfo,
|
||||||
@@ -53,6 +53,7 @@ import { useEditorContext } from "../../context/editor"
|
|||||||
import { openEditor } from "../../editor"
|
import { openEditor } from "../../editor"
|
||||||
import { useDialog } from "../../ui/dialog"
|
import { useDialog } from "../../ui/dialog"
|
||||||
import { DialogSessionRename } from "../../component/dialog-session-rename"
|
import { DialogSessionRename } from "../../component/dialog-session-rename"
|
||||||
|
import { DialogImagePreview } from "../../component/dialog-image-preview"
|
||||||
import { DialogMessage } from "./dialog-message"
|
import { DialogMessage } from "./dialog-message"
|
||||||
import { DialogFork } from "./dialog-fork"
|
import { DialogFork } from "./dialog-fork"
|
||||||
import { DialogTimeline } from "./dialog-timeline"
|
import { DialogTimeline } from "./dialog-timeline"
|
||||||
@@ -781,7 +782,7 @@ export function Session() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
clipboard
|
clipboard
|
||||||
.write?.(text)
|
.write(text)
|
||||||
.then(() => toast.show({ message: "Message copied to clipboard!", variant: "success" }))
|
.then(() => toast.show({ message: "Message copied to clipboard!", variant: "success" }))
|
||||||
.catch(() => toast.show({ message: "Failed to copy to clipboard", variant: "error" }))
|
.catch(() => toast.show({ message: "Failed to copy to clipboard", variant: "error" }))
|
||||||
dialog.clear()
|
dialog.clear()
|
||||||
@@ -799,7 +800,7 @@ export function Session() {
|
|||||||
const sessionData = session()
|
const sessionData = session()
|
||||||
if (!sessionData) return
|
if (!sessionData) return
|
||||||
const transcript = formatSessionTranscript(sessionData, messages(), showThinking())
|
const transcript = formatSessionTranscript(sessionData, messages(), showThinking())
|
||||||
await clipboard.write?.(transcript)
|
await clipboard.write(transcript)
|
||||||
toast.show({ message: "Session transcript copied to clipboard!", variant: "success" })
|
toast.show({ message: "Session transcript copied to clipboard!", variant: "success" })
|
||||||
} catch {
|
} catch {
|
||||||
toast.show({ message: "Failed to copy session transcript", variant: "error" })
|
toast.show({ message: "Failed to copy session transcript", variant: "error" })
|
||||||
@@ -833,7 +834,7 @@ export function Session() {
|
|||||||
) + EOL
|
) + EOL
|
||||||
|
|
||||||
if (options.action === "copy") {
|
if (options.action === "copy") {
|
||||||
await clipboard.write?.(content)
|
await clipboard.write(content)
|
||||||
dialog.clear()
|
dialog.clear()
|
||||||
toast.show({ message: "Copied to clipboard", variant: "success" })
|
toast.show({ message: "Copied to clipboard", variant: "success" })
|
||||||
return
|
return
|
||||||
@@ -1524,8 +1525,9 @@ function SessionGroupView(props: {
|
|||||||
</InlineToolRow>
|
</InlineToolRow>
|
||||||
</Show>
|
</Show>
|
||||||
<Show when={expanded() && grouped().length > 0}>
|
<Show when={expanded() && grouped().length > 0}>
|
||||||
<For each={grouped()}>{(part) => <ToolPart part={part} />}</For>
|
<For each={grouped()}>{(part) => <ToolPart part={part} images={false} />}</For>
|
||||||
</Show>
|
</Show>
|
||||||
|
<ToolImages parts={grouped()} />
|
||||||
<For each={pending()}>{(part) => <ToolPart part={part} />}</For>
|
<For each={pending()}>{(part) => <ToolPart part={part} />}</For>
|
||||||
</Show>
|
</Show>
|
||||||
</Show>
|
</Show>
|
||||||
@@ -1835,6 +1837,11 @@ function UserMessage(props: { message: SessionMessageUser }) {
|
|||||||
const data = useData()
|
const data = useData()
|
||||||
const local = useLocal()
|
const local = useLocal()
|
||||||
const files = createMemo(() => props.message.files ?? [])
|
const files = createMemo(() => props.message.files ?? [])
|
||||||
|
const images = createMemo(() =>
|
||||||
|
files().flatMap((file) =>
|
||||||
|
file.mime.startsWith("image/") ? [{ uri: `data:${file.mime};base64,${file.data}` }] : [],
|
||||||
|
),
|
||||||
|
)
|
||||||
const themes = useThemes()
|
const themes = useThemes()
|
||||||
const theme = useTheme("elevated")
|
const theme = useTheme("elevated")
|
||||||
const mode = themes.mode
|
const mode = themes.mode
|
||||||
@@ -1854,6 +1861,7 @@ function UserMessage(props: { message: SessionMessageUser }) {
|
|||||||
borderColor={queued() ? theme.border.default : color()}
|
borderColor={queued() ? theme.border.default : color()}
|
||||||
customBorderChars={SplitBorder.customBorderChars}
|
customBorderChars={SplitBorder.customBorderChars}
|
||||||
>
|
>
|
||||||
|
<SessionImages images={images()} />
|
||||||
<box
|
<box
|
||||||
onMouseOver={() => {
|
onMouseOver={() => {
|
||||||
setHover(true)
|
setHover(true)
|
||||||
@@ -2077,7 +2085,7 @@ function TextPart(props: { last: boolean; part: SessionMessageAssistantText }) {
|
|||||||
|
|
||||||
// Pending messages moved to individual tool pending functions
|
// Pending messages moved to individual tool pending functions
|
||||||
|
|
||||||
function ToolPart(props: { part: SessionMessageAssistantTool }) {
|
function ToolPart(props: { part: SessionMessageAssistantTool; images?: boolean }) {
|
||||||
const display = createMemo(() => toolDisplay(props.part.name))
|
const display = createMemo(() => toolDisplay(props.part.name))
|
||||||
|
|
||||||
const toolprops = {
|
const toolprops = {
|
||||||
@@ -2101,7 +2109,7 @@ function ToolPart(props: { part: SessionMessageAssistantTool }) {
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
const content = (
|
||||||
<Switch>
|
<Switch>
|
||||||
<Match when={display() === "shell"}>
|
<Match when={display() === "shell"}>
|
||||||
<Shell {...toolprops} />
|
<Shell {...toolprops} />
|
||||||
@@ -2147,6 +2155,79 @@ function ToolPart(props: { part: SessionMessageAssistantTool }) {
|
|||||||
</Match>
|
</Match>
|
||||||
</Switch>
|
</Switch>
|
||||||
)
|
)
|
||||||
|
return [
|
||||||
|
content,
|
||||||
|
<Show when={props.images !== false}>
|
||||||
|
<ToolImages parts={[props.part]} />
|
||||||
|
</Show>,
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
function ToolImages(props: { parts: readonly SessionMessageAssistantTool[] }) {
|
||||||
|
const images = createMemo(() => props.parts.flatMap(inlineToolImages))
|
||||||
|
return <SessionImages images={images()} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function SessionImages(props: { images: readonly { uri: string }[] }) {
|
||||||
|
const ctx = use()
|
||||||
|
const dialog = useDialog()
|
||||||
|
const dimensions = useTerminalDimensions()
|
||||||
|
const images = createMemo(() => (ctx.config.session?.image_preview ? props.images : []))
|
||||||
|
const height = createMemo(() => Math.max(4, Math.min(8, Math.floor(dimensions().height / 4))))
|
||||||
|
const visible = createMemo(() => images().slice(0, 3))
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Show when={visible().length > 0}>
|
||||||
|
<box flexDirection="row" flexShrink={0} paddingTop={1} paddingLeft={2} paddingRight={2} paddingBottom={1} gap={1}>
|
||||||
|
<For each={visible()}>
|
||||||
|
{(image, index) => {
|
||||||
|
const [failed, setFailed] = createSignal(false)
|
||||||
|
return (
|
||||||
|
<box
|
||||||
|
width={height() * 2}
|
||||||
|
height={height()}
|
||||||
|
flexBasis={height() * 2}
|
||||||
|
flexShrink={1}
|
||||||
|
alignItems="center"
|
||||||
|
justifyContent="center"
|
||||||
|
onMouseUp={(event: MouseEvent) => {
|
||||||
|
if (event.button !== 0) return
|
||||||
|
event.stopPropagation()
|
||||||
|
dialog.replace(() => <DialogImagePreview images={images()} initial={index()} />)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Show when={!failed()} fallback={<text>No preview</text>}>
|
||||||
|
<image
|
||||||
|
source={image.uri}
|
||||||
|
fit="cover"
|
||||||
|
protocol="auto"
|
||||||
|
width="100%"
|
||||||
|
height="100%"
|
||||||
|
onError={() => setFailed(true)}
|
||||||
|
/>
|
||||||
|
</Show>
|
||||||
|
</box>
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
</For>
|
||||||
|
<Show when={images().length > visible().length}>
|
||||||
|
<box width={8} height={height()} flexShrink={1} alignItems="center" justifyContent="center">
|
||||||
|
<text wrapMode="none" truncate>
|
||||||
|
+{images().length - visible().length} more
|
||||||
|
</text>
|
||||||
|
</box>
|
||||||
|
</Show>
|
||||||
|
</box>
|
||||||
|
</Show>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function inlineToolImages(part: SessionMessageAssistantTool) {
|
||||||
|
return toolDisplayContent(part.state).flatMap((content) =>
|
||||||
|
content.type === "file" && content.mime.startsWith("image/") && content.uri.startsWith("data:image/")
|
||||||
|
? [{ uri: content.uri }]
|
||||||
|
: [],
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
type ToolProps = {
|
type ToolProps = {
|
||||||
|
|||||||
@@ -204,7 +204,7 @@ export function DialogProvider(props: ParentProps) {
|
|||||||
|
|
||||||
function copySelection() {
|
function copySelection() {
|
||||||
const text = renderer.getSelection()?.getSelectedText()
|
const text = renderer.getSelection()?.getSelectedText()
|
||||||
if (!text || !clipboard.write) return false
|
if (!text) return false
|
||||||
void clipboard.write(text).then(
|
void clipboard.write(text).then(
|
||||||
() => toast.show({ message: "Copied to clipboard", variant: "info" }),
|
() => toast.show({ message: "Copied to clipboard", variant: "info" }),
|
||||||
(error) => toast.error(error),
|
(error) => toast.error(error),
|
||||||
|
|||||||
@@ -35,7 +35,7 @@ export function copy(renderer: Renderer, toast: Toast, clipboard: ClipboardServi
|
|||||||
focus?.getClipboardText && selection.selectedRenderables.includes(focus) ? focus.getClipboardText(text) : text
|
focus?.getClipboardText && selection.selectedRenderables.includes(focus) ? focus.getClipboardText(text) : text
|
||||||
|
|
||||||
clipboard
|
clipboard
|
||||||
?.write?.(clipboardText)
|
.write(clipboardText)
|
||||||
.then(() => toast.show({ message: "Copied to clipboard", variant: "info" }))
|
.then(() => toast.show({ message: "Copied to clipboard", variant: "info" }))
|
||||||
.catch(toast.error)
|
.catch(toast.error)
|
||||||
|
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ export function primitiveInputSummary(input: Record<string, unknown>, omit: read
|
|||||||
export function webSearchProviderLabel(provider: unknown) {
|
export function webSearchProviderLabel(provider: unknown) {
|
||||||
if (provider === "parallel") return "Parallel Web Search"
|
if (provider === "parallel") return "Parallel Web Search"
|
||||||
if (provider === "exa") return "Exa Web Search"
|
if (provider === "exa") return "Exa Web Search"
|
||||||
|
if (provider === "firecrawl") return "Firecrawl Web Search"
|
||||||
return "Web Search"
|
return "Web Search"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -74,11 +74,11 @@ test("searches settings globally and opens the matching setting", async () => {
|
|||||||
await app.waitFor(() => app.renderer.currentFocusedEditor instanceof InputRenderable)
|
await app.waitFor(() => app.renderer.currentFocusedEditor instanceof InputRenderable)
|
||||||
|
|
||||||
app.mockInput.pressArrow("down")
|
app.mockInput.pressArrow("down")
|
||||||
for (const key of "sounds") app.mockInput.pressKey(key)
|
for (const key of "image preview") app.mockInput.pressKey(key)
|
||||||
app.mockInput.pressEnter()
|
app.mockInput.pressEnter()
|
||||||
await app.waitForFrame((frame) => frame.includes("Settings") && frame.includes("Sounds"))
|
await app.waitForFrame((frame) => frame.includes("Settings") && frame.includes("Image previews"))
|
||||||
app.mockInput.pressEnter()
|
app.mockInput.pressEnter()
|
||||||
await app.waitFor(() => current.attention?.sound === false)
|
await app.waitFor(() => current.prompt?.image_preview === true)
|
||||||
} finally {
|
} finally {
|
||||||
app.renderer.destroy()
|
app.renderer.destroy()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -60,6 +60,9 @@ async function mountForm(root: string, width = 80) {
|
|||||||
>
|
>
|
||||||
<ClipboardProvider
|
<ClipboardProvider
|
||||||
value={{
|
value={{
|
||||||
|
async read() {
|
||||||
|
return undefined
|
||||||
|
},
|
||||||
write(text) {
|
write(text) {
|
||||||
copied.push(text)
|
copied.push(text)
|
||||||
return Promise.resolve()
|
return Promise.resolve()
|
||||||
|
|||||||
@@ -1,19 +1,129 @@
|
|||||||
import { expect, test } from "bun:test"
|
import { expect, test } from "bun:test"
|
||||||
import { copyCommand } from "../src/clipboard"
|
import {
|
||||||
|
createClipboard,
|
||||||
|
type ClipboardReadOptions,
|
||||||
|
type ClipboardReadResult,
|
||||||
|
type ClipboardService as CoreClipboardService,
|
||||||
|
type ClipboardWriteOptions,
|
||||||
|
type ClipboardWriteResult,
|
||||||
|
type HostClipboardService,
|
||||||
|
} from "@opentui/core"
|
||||||
|
import { createClipboardAdapter } from "../src/clipboard"
|
||||||
|
|
||||||
test("prefers Wayland clipboard when available", () => {
|
function coreClipboard(options: {
|
||||||
expect(copyCommand("linux", true, (name) => name === "wl-copy")).toEqual(["wl-copy"])
|
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", () => {
|
test("uses all available routes but skips the process host remotely", async () => {
|
||||||
expect(copyCommand("darwin", false, (name) => name === "osascript")).toEqual(["osascript"])
|
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", () => {
|
test("rejects only when no clipboard route accepted the write", async () => {
|
||||||
expect(copyCommand("linux", true, (name) => name === "xclip")).toEqual(["xclip", "-selection", "clipboard"])
|
const writes: [string, ClipboardWriteOptions][] = []
|
||||||
expect(copyCommand("linux", false, (name) => name === "xsel")).toEqual(["xsel", "--clipboard", "--input"])
|
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(await fallback.write("hello")).toBeUndefined()
|
||||||
expect(copyCommand("linux", false, () => false)).toBeUndefined()
|
expect(await clipboard.write("hello").then(undefined, (error) => error)).toBe(failure)
|
||||||
|
expect(writes).toEqual([["hello", { destination: "all-available", selection: "clipboard" }]])
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -23,6 +23,8 @@ test("validates the session tabs setting", () => {
|
|||||||
})
|
})
|
||||||
expect(() => decode({ tabs: { layout: true } })).toThrow()
|
expect(() => decode({ tabs: { layout: true } })).toThrow()
|
||||||
expect(() => decode({ tabs: { enabled: "on" } })).toThrow()
|
expect(() => decode({ tabs: { enabled: "on" } })).toThrow()
|
||||||
|
expect(decode({ prompt: { image_preview: true } })).toEqual({ prompt: { image_preview: true } })
|
||||||
|
expect(decode({ session: { image_preview: true } })).toEqual({ session: { image_preview: true } })
|
||||||
})
|
})
|
||||||
|
|
||||||
test("resolves nested config and keybind defaults", () => {
|
test("resolves nested config and keybind defaults", () => {
|
||||||
|
|||||||
@@ -7,6 +7,14 @@ import {
|
|||||||
} from "../../src/context/runtime"
|
} from "../../src/context/runtime"
|
||||||
import type { ParentProps } from "solid-js"
|
import type { ParentProps } from "solid-js"
|
||||||
import { LogProvider, type LogSink } from "../../src/context/log"
|
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(
|
export function TestTuiContexts(
|
||||||
props: ParentProps<{
|
props: ParentProps<{
|
||||||
@@ -14,6 +22,7 @@ export function TestTuiContexts(
|
|||||||
directory?: string
|
directory?: string
|
||||||
paths?: Partial<TuiPaths>
|
paths?: Partial<TuiPaths>
|
||||||
log?: LogSink
|
log?: LogSink
|
||||||
|
clipboard?: ClipboardService
|
||||||
}>,
|
}>,
|
||||||
) {
|
) {
|
||||||
return (
|
return (
|
||||||
@@ -28,7 +37,9 @@ export function TestTuiContexts(
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<TuiTerminalEnvironmentProvider value={{ platform: "linux" }}>
|
<TuiTerminalEnvironmentProvider value={{ platform: "linux" }}>
|
||||||
<TuiStartupProvider value={{ skipInitialLoading: false }}>{props.children}</TuiStartupProvider>
|
<TuiStartupProvider value={{ skipInitialLoading: false }}>
|
||||||
|
<ClipboardProvider value={props.clipboard ?? clipboard}>{props.children}</ClipboardProvider>
|
||||||
|
</TuiStartupProvider>
|
||||||
</TuiTerminalEnvironmentProvider>
|
</TuiTerminalEnvironmentProvider>
|
||||||
</TuiPathsProvider>
|
</TuiPathsProvider>
|
||||||
</LogProvider>
|
</LogProvider>
|
||||||
|
|||||||
@@ -0,0 +1,239 @@
|
|||||||
|
import { afterAll, expect, mock, test } from "bun:test"
|
||||||
|
import { mkdtemp, rm, writeFile } from "node:fs/promises"
|
||||||
|
import { tmpdir } from "node:os"
|
||||||
|
import path from "node:path"
|
||||||
|
import { ImageRenderable, TextareaRenderable, type ClipboardReadResult, type HostClipboardService } from "@opentui/core"
|
||||||
|
import { createTestRenderer, MouseButtons } 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, type PromptRef } from "../../src/component/prompt"
|
||||||
|
import { createEventStream, createFetch } from "../fixture/tui-client"
|
||||||
|
|
||||||
|
const openTui = { ...(await import("@opentui/core")) }
|
||||||
|
const PNG_1X1_BASE64 =
|
||||||
|
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR4AWP4z8DwHwAFAAH/e+m+7wAAAABJRU5ErkJggg=="
|
||||||
|
const PNG_1X1 = Buffer.from(PNG_1X1_BASE64, "base64")
|
||||||
|
const readPngClipboard = async (): Promise<ClipboardReadResult> => ({
|
||||||
|
status: "read",
|
||||||
|
representation: { mimeType: "image/png", bytes: PNG_1X1 },
|
||||||
|
})
|
||||||
|
let activeSetup: Awaited<ReturnType<typeof createTestRenderer>> | undefined
|
||||||
|
let activeHost: HostClipboardService | undefined
|
||||||
|
let activePromptRef: PromptRef | undefined
|
||||||
|
|
||||||
|
await mock.module("@opentui/core", () => ({
|
||||||
|
...openTui,
|
||||||
|
createCliRenderer: async () => {
|
||||||
|
if (!activeSetup) throw new Error("Prompt renderer is not mounted")
|
||||||
|
return activeSetup.renderer
|
||||||
|
},
|
||||||
|
createHostClipboard: () => {
|
||||||
|
if (!activeHost) throw new Error("Prompt clipboard is not mounted")
|
||||||
|
return activeHost
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
await mock.module("../../src/routes/home", () => ({
|
||||||
|
Home: () =>
|
||||||
|
createComponent(Prompt, {
|
||||||
|
ref: (value) => (activePromptRef = value),
|
||||||
|
showPlaceholder: false,
|
||||||
|
}),
|
||||||
|
}))
|
||||||
|
const { run } = await import("../../src/app")
|
||||||
|
|
||||||
|
afterAll(() => mock.restore())
|
||||||
|
|
||||||
|
async function mountPrompt(read: () => Promise<ClipboardReadResult>, imagePreview = false) {
|
||||||
|
const setup = await createTestRenderer({ width: 80, height: 24, useThread: false })
|
||||||
|
let reads = 0
|
||||||
|
let ready!: () => void
|
||||||
|
const mounted = new Promise<void>((resolve) => (ready = resolve))
|
||||||
|
const setTitle = setup.renderer.setTerminalTitle.bind(setup.renderer)
|
||||||
|
setup.renderer.setTerminalTitle = (title) => {
|
||||||
|
if (title === "OpenCode") ready()
|
||||||
|
setTitle(title)
|
||||||
|
}
|
||||||
|
|
||||||
|
const host: HostClipboardService = {
|
||||||
|
maxWriteBytes: 8 * 1024 * 1024,
|
||||||
|
async read() {
|
||||||
|
reads++
|
||||||
|
return read()
|
||||||
|
},
|
||||||
|
async writeText() {
|
||||||
|
return { status: "written" }
|
||||||
|
},
|
||||||
|
async clear() {
|
||||||
|
return { status: "cleared" }
|
||||||
|
},
|
||||||
|
async dispose() {},
|
||||||
|
}
|
||||||
|
activeSetup = setup
|
||||||
|
activeHost = host
|
||||||
|
activePromptRef = undefined
|
||||||
|
|
||||||
|
const events = createEventStream()
|
||||||
|
const calls = createFetch(undefined, events)
|
||||||
|
let preloaded!: () => void
|
||||||
|
const preload = new Promise<void>((resolve) => (preloaded = resolve))
|
||||||
|
const server = Bun.serve({
|
||||||
|
port: 0,
|
||||||
|
fetch: async (request) => {
|
||||||
|
const response = await calls.fetch(request)
|
||||||
|
const url = new URL(request.url)
|
||||||
|
if (url.pathname === "/api/session/active") preloaded()
|
||||||
|
return response
|
||||||
|
},
|
||||||
|
})
|
||||||
|
let task: Promise<unknown> | undefined
|
||||||
|
try {
|
||||||
|
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, image_preview: imagePreview } }),
|
||||||
|
update: async () => ({}),
|
||||||
|
},
|
||||||
|
packages: { resolve: async () => undefined },
|
||||||
|
args: {},
|
||||||
|
log: () => {},
|
||||||
|
}).pipe(Effect.provide(AppNodeBuilder.build(Global.node)), Effect.provide(FileSystem.layerNoop({}))),
|
||||||
|
)
|
||||||
|
await mounted
|
||||||
|
await setup.waitFor(() => activePromptRef?.focused === true)
|
||||||
|
await preload
|
||||||
|
await Bun.sleep(0)
|
||||||
|
} catch (error) {
|
||||||
|
setup.renderer.destroy()
|
||||||
|
await task?.catch(() => undefined)
|
||||||
|
await server.stop()
|
||||||
|
activeSetup = undefined
|
||||||
|
activeHost = undefined
|
||||||
|
activePromptRef = undefined
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
setup,
|
||||||
|
get input() {
|
||||||
|
const input = setup.renderer.currentFocusedEditor
|
||||||
|
if (!(input instanceof TextareaRenderable)) throw new Error("Prompt textarea is not focused")
|
||||||
|
return input
|
||||||
|
},
|
||||||
|
get reads() {
|
||||||
|
return reads
|
||||||
|
},
|
||||||
|
get prompt() {
|
||||||
|
if (!activePromptRef) throw new Error("Prompt ref is not mounted")
|
||||||
|
return activePromptRef
|
||||||
|
},
|
||||||
|
async dispose() {
|
||||||
|
activePromptRef?.reset()
|
||||||
|
setup.renderer.destroy()
|
||||||
|
await task
|
||||||
|
await server.stop()
|
||||||
|
activeSetup = undefined
|
||||||
|
activeHost = undefined
|
||||||
|
activePromptRef = undefined
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function pasteImages(prompt: Awaited<ReturnType<typeof mountPrompt>>, count: number) {
|
||||||
|
for (let index = 0; index < count; index++) {
|
||||||
|
prompt.setup.renderer.keyInput.processPaste(new Uint8Array())
|
||||||
|
await prompt.setup.waitFor(() => prompt.reads === index + 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
test("creates one image mention from PNG clipboard bytes", async () => {
|
||||||
|
const prompt = await mountPrompt(readPngClipboard)
|
||||||
|
try {
|
||||||
|
prompt.setup.renderer.keyInput.processPaste(new Uint8Array())
|
||||||
|
await prompt.setup.waitFor(() => prompt.input.plainText === "[Image 1] ")
|
||||||
|
|
||||||
|
expect(prompt.input.plainText).toBe("[Image 1] ")
|
||||||
|
expect(prompt.input.extmarks.getVirtual()).toHaveLength(1)
|
||||||
|
expect(prompt.prompt.current.files).toEqual([
|
||||||
|
{
|
||||||
|
uri: `data:image/png;base64,${PNG_1X1_BASE64}`,
|
||||||
|
name: "clipboard",
|
||||||
|
mention: { start: 0, end: 9, text: "[Image 1]" },
|
||||||
|
},
|
||||||
|
])
|
||||||
|
expect(prompt.setup.renderer.root.findDescendantById("prompt-image-preview-0")).toBeUndefined()
|
||||||
|
expect(prompt.reads).toBe(1)
|
||||||
|
} finally {
|
||||||
|
await prompt.dispose()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
test("renders at most three left-aligned cropped thumbnails", async () => {
|
||||||
|
const prompt = await mountPrompt(readPngClipboard, true)
|
||||||
|
try {
|
||||||
|
await pasteImages(prompt, 4)
|
||||||
|
|
||||||
|
const first = prompt.setup.renderer.root.findDescendantById("prompt-image-preview-0")
|
||||||
|
if (!(first instanceof ImageRenderable)) throw new Error("Image preview did not render")
|
||||||
|
await first.loadPromise
|
||||||
|
expect(first.fit).toBe("cover")
|
||||||
|
expect(prompt.setup.renderer.root.findDescendantById("prompt-image-preview-1")).toBeInstanceOf(ImageRenderable)
|
||||||
|
expect(prompt.setup.renderer.root.findDescendantById("prompt-image-preview-2")).toBeInstanceOf(ImageRenderable)
|
||||||
|
expect(prompt.setup.renderer.root.findDescendantById("prompt-image-preview-3")).toBeUndefined()
|
||||||
|
await prompt.setup.waitForFrame((frame) => frame.includes("+1 more"))
|
||||||
|
} finally {
|
||||||
|
await prompt.dispose()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
test("opens image attachments by keyboard and mouse", async () => {
|
||||||
|
const prompt = await mountPrompt(readPngClipboard, true)
|
||||||
|
try {
|
||||||
|
await pasteImages(prompt, 2)
|
||||||
|
|
||||||
|
const thumbnail = prompt.setup.renderer.root.findDescendantById("prompt-image-preview-1")
|
||||||
|
if (!(thumbnail instanceof ImageRenderable)) throw new Error("Second image thumbnail did not render")
|
||||||
|
|
||||||
|
prompt.setup.mockInput.pressKey("x", { ctrl: true })
|
||||||
|
prompt.setup.mockInput.pressKey("i")
|
||||||
|
await prompt.setup.waitForFrame((frame) => frame.includes("Image 1 of 2"))
|
||||||
|
prompt.setup.mockInput.pressCtrlC()
|
||||||
|
await prompt.setup.waitForFrame((frame) => !frame.includes("Image 1 of 2"))
|
||||||
|
|
||||||
|
await prompt.setup.mockMouse.click(thumbnail.x, thumbnail.y, MouseButtons.LEFT)
|
||||||
|
await prompt.setup.waitForFrame((frame) => frame.includes("Image 2 of 2"))
|
||||||
|
const large = prompt.setup.renderer.root.findDescendantById("prompt-image-viewer-image")
|
||||||
|
if (!(large instanceof ImageRenderable)) throw new Error("Large image preview did not render")
|
||||||
|
expect(large.fit).toBe("fit")
|
||||||
|
expect(large.height).toBeGreaterThan(thumbnail.height)
|
||||||
|
|
||||||
|
prompt.setup.mockInput.pressArrow("left")
|
||||||
|
await prompt.setup.waitForFrame((frame) => frame.includes("Image 1 of 2"))
|
||||||
|
prompt.setup.mockInput.pressCtrlC()
|
||||||
|
await prompt.setup.waitForFrame((frame) => !frame.includes("Image 1 of 2"))
|
||||||
|
await prompt.setup.waitFor(() => prompt.setup.renderer.currentFocusedEditor === prompt.input)
|
||||||
|
} finally {
|
||||||
|
await prompt.dispose()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
test("attaches multiple images from one terminal drop", async () => {
|
||||||
|
const directory = await mkdtemp(path.join(tmpdir(), "opencode-drop-"))
|
||||||
|
const first = path.join(directory, "one image.png")
|
||||||
|
const second = path.join(directory, "two image.png")
|
||||||
|
await Promise.all([writeFile(first, PNG_1X1), writeFile(second, PNG_1X1)])
|
||||||
|
const prompt = await mountPrompt(async () => ({ status: "empty" }), true)
|
||||||
|
try {
|
||||||
|
await prompt.setup.mockInput.pasteBracketedText(`'${first}' '${second}'`)
|
||||||
|
await prompt.setup.waitFor(() => prompt.prompt.current.files?.length === 2)
|
||||||
|
|
||||||
|
expect(prompt.input.plainText).toBe("[Image 1] [Image 2] ")
|
||||||
|
expect(prompt.prompt.current.files?.map((file) => file.name)).toEqual(["one image.png", "two image.png"])
|
||||||
|
} finally {
|
||||||
|
await prompt.dispose()
|
||||||
|
await rm(directory, { recursive: true, force: true })
|
||||||
|
}
|
||||||
|
})
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import { describe, expect, test } from "bun:test"
|
import { describe, expect, test } from "bun:test"
|
||||||
import { readLocalAttachmentWith } from "../../src/component/prompt/local-attachment"
|
import { parsePastedFilepaths, readLocalAttachmentWith } from "../../src/component/prompt/local-attachment"
|
||||||
import type { LocalFiles } from "../../src/component/prompt/local-attachment"
|
import type { LocalFiles } from "../../src/component/prompt/local-attachment"
|
||||||
|
|
||||||
function files(input: { mime: string; text?: string; bytes?: Uint8Array }): LocalFiles {
|
function files(input: { mime: string; text?: string; bytes?: Uint8Array }): LocalFiles {
|
||||||
@@ -11,6 +11,40 @@ function files(input: { mime: string; text?: string; bytes?: Uint8Array }): Loca
|
|||||||
}
|
}
|
||||||
|
|
||||||
describe("prompt local attachments", () => {
|
describe("prompt local attachments", () => {
|
||||||
|
test("parses multi-file drops from POSIX, URI-list, and Windows terminals", () => {
|
||||||
|
expect(parsePastedFilepaths("'/tmp/one image.png' /tmp/two\\ image.webp", "linux")).toEqual([
|
||||||
|
"/tmp/one image.png",
|
||||||
|
"/tmp/two image.webp",
|
||||||
|
])
|
||||||
|
expect(parsePastedFilepaths("file:///tmp/one%20image.png\r\nfile:///tmp/two.webp", "linux")).toEqual([
|
||||||
|
"/tmp/one image.png",
|
||||||
|
"/tmp/two.webp",
|
||||||
|
])
|
||||||
|
expect(parsePastedFilepaths("# dropped files\nfile:///tmp/one.png\nfile:///tmp/two.webp", "linux")).toEqual([
|
||||||
|
"/tmp/one.png",
|
||||||
|
"/tmp/two.webp",
|
||||||
|
])
|
||||||
|
expect(parsePastedFilepaths("/tmp/one\\\\image.png /tmp/two.webp", "linux")).toEqual([
|
||||||
|
"/tmp/one\\image.png",
|
||||||
|
"/tmp/two.webp",
|
||||||
|
])
|
||||||
|
expect(parsePastedFilepaths('"C:\\one image.png" "C:\\two.webp"', "win32")).toEqual([
|
||||||
|
"C:\\one image.png",
|
||||||
|
"C:\\two.webp",
|
||||||
|
])
|
||||||
|
expect(parsePastedFilepaths('"/tmp/O\'Brien.png" /tmp/two.webp', "linux")).toEqual([
|
||||||
|
"/tmp/O'Brien.png",
|
||||||
|
"/tmp/two.webp",
|
||||||
|
])
|
||||||
|
})
|
||||||
|
|
||||||
|
test("rejects unbounded and malformed multi-file drops", () => {
|
||||||
|
expect(parsePastedFilepaths("'/tmp/one.png /tmp/two.png", "linux")).toEqual([])
|
||||||
|
expect(
|
||||||
|
parsePastedFilepaths(Array.from({ length: 33 }, (_, index) => `/tmp/${index}.png`).join(" "), "linux"),
|
||||||
|
).toEqual([])
|
||||||
|
})
|
||||||
|
|
||||||
test("reads SVG attachments as text", async () => {
|
test("reads SVG attachments as text", async () => {
|
||||||
expect(await readLocalAttachmentWith(files({ mime: "image/svg+xml", text: "<svg />" }), "/tmp/image.svg")).toEqual({
|
expect(await readLocalAttachmentWith(files({ mime: "image/svg+xml", text: "<svg />" }), "/tmp/image.svg")).toEqual({
|
||||||
type: "text",
|
type: "text",
|
||||||
@@ -39,5 +73,8 @@ describe("prompt local attachments", () => {
|
|||||||
"/tmp/missing.png",
|
"/tmp/missing.png",
|
||||||
),
|
),
|
||||||
).toBeUndefined()
|
).toBeUndefined()
|
||||||
|
expect(
|
||||||
|
await readLocalAttachmentWith(files({ mime: "image/png", bytes: new Uint8Array(2) }), "/tmp/large.png", 1),
|
||||||
|
).toBeUndefined()
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
Reference in New Issue
Block a user