mirror of
https://github.com/anomalyco/opencode.git
synced 2026-07-31 07:26:47 -04:00
Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| de487e4d10 | |||
| ba00ded12d |
@@ -619,9 +619,9 @@
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@opencode-ai/theme": "workspace:*",
|
||||
"@opentui/core": "0.0.0-20260731-669d7f5c",
|
||||
"@opentui/keymap": "0.0.0-20260731-669d7f5c",
|
||||
"@opentui/solid": "0.0.0-20260731-669d7f5c",
|
||||
"@opentui/core": ">=0.4.5",
|
||||
"@opentui/keymap": ">=0.4.5",
|
||||
"@opentui/solid": ">=0.4.5",
|
||||
"solid-js": ">=1.9.0",
|
||||
},
|
||||
"optionalPeers": [
|
||||
@@ -912,6 +912,7 @@
|
||||
"@opentui/keymap": "catalog:",
|
||||
"@opentui/solid": "catalog:",
|
||||
"@solid-primitives/event-bus": "1.1.2",
|
||||
"clipboardy": "4.0.0",
|
||||
"diff": "catalog:",
|
||||
"effect": "catalog:",
|
||||
"fuzzysort": "catalog:",
|
||||
@@ -1098,9 +1099,9 @@
|
||||
"@ai-sdk/google@3.0.73": "patches/@ai-sdk%2Fgoogle@3.0.73.patch",
|
||||
},
|
||||
"overrides": {
|
||||
"@opentui/core": "0.0.0-20260731-669d7f5c",
|
||||
"@opentui/keymap": "0.0.0-20260731-669d7f5c",
|
||||
"@opentui/solid": "0.0.0-20260731-669d7f5c",
|
||||
"@opentui/core": "catalog:",
|
||||
"@opentui/keymap": "catalog:",
|
||||
"@opentui/solid": "catalog:",
|
||||
"@types/bun": "catalog:",
|
||||
"@types/node": "catalog:",
|
||||
"effect": "catalog:",
|
||||
@@ -1118,9 +1119,9 @@
|
||||
"@npmcli/arborist": "9.4.0",
|
||||
"@octokit/rest": "22.0.0",
|
||||
"@openauthjs/openauth": "0.0.0-20250322224806",
|
||||
"@opentui/core": "0.0.0-20260731-669d7f5c",
|
||||
"@opentui/keymap": "0.0.0-20260731-669d7f5c",
|
||||
"@opentui/solid": "0.0.0-20260731-669d7f5c",
|
||||
"@opentui/core": "0.4.5",
|
||||
"@opentui/keymap": "0.4.5",
|
||||
"@opentui/solid": "0.4.5",
|
||||
"@pierre/diffs": "1.2.10",
|
||||
"@playwright/test": "1.59.1",
|
||||
"@sentry/solid": "10.36.0",
|
||||
@@ -2159,27 +2160,27 @@
|
||||
|
||||
"@opentelemetry/semantic-conventions": ["@opentelemetry/semantic-conventions@1.43.0", "", {}, "sha512-eSYWTm620tTk45EKSedaUL8MFYI8hW164hIXsgIHyxu3VobUB3fFCu5t0hQby6OoWRPsG1KkKUG2M5UadiLiVg=="],
|
||||
|
||||
"@opentui/core": ["@opentui/core@0.0.0-20260731-669d7f5c", "", { "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-20260731-669d7f5c", "@opentui/core-darwin-x64": "0.0.0-20260731-669d7f5c", "@opentui/core-linux-arm64": "0.0.0-20260731-669d7f5c", "@opentui/core-linux-arm64-musl": "0.0.0-20260731-669d7f5c", "@opentui/core-linux-x64": "0.0.0-20260731-669d7f5c", "@opentui/core-linux-x64-musl": "0.0.0-20260731-669d7f5c", "@opentui/core-win32-arm64": "0.0.0-20260731-669d7f5c", "@opentui/core-win32-x64": "0.0.0-20260731-669d7f5c" }, "peerDependencies": { "web-tree-sitter": "0.25.10" } }, "sha512-1+EQqoS+kaUIqzUdt1RJXWS10vcCWPJ5EmAj/3hS6VavuG54nCreBS59QTtiJ+iEQMomxG9rTP9uvPPuOmRiIA=="],
|
||||
"@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-darwin-arm64": ["@opentui/core-darwin-arm64@0.0.0-20260731-669d7f5c", "", { "os": "darwin", "cpu": "arm64" }, "sha512-qS5kQzig/Nm5QJv0y5WPBZH9GzF+vsNyVcens+VJBC7KdiEQmFX3lsT4vMy51Bnp3lzrOhWXKudxJKUkvVEHxA=="],
|
||||
"@opentui/core-darwin-arm64": ["@opentui/core-darwin-arm64@0.4.5", "", { "os": "darwin", "cpu": "arm64" }, "sha512-8KUG0oRidnR+oW1RSZJ72/PhZLl+qRRMk5U/mieF4c0SJ5V3tYACpBZAKzQfHNd1f7QzD8FHZct1lPpQgtmkWg=="],
|
||||
|
||||
"@opentui/core-darwin-x64": ["@opentui/core-darwin-x64@0.0.0-20260731-669d7f5c", "", { "os": "darwin", "cpu": "x64" }, "sha512-HPPUQLEjU25OGeLAr4Os2Bt60fCd5Nz6aDSKpArrpOfQAjLg5VT8qaw9EzAg76LEJeXY65Dp2qRiTb4eevoozw=="],
|
||||
"@opentui/core-darwin-x64": ["@opentui/core-darwin-x64@0.4.5", "", { "os": "darwin", "cpu": "x64" }, "sha512-R2bocsg55gwjOqCp/MWFgFYzRmsduKegB6nzgFAPCvAD/L5Jf30xpWJWFlSg3x8vxe1L9WJ84dfqa4M7mZZ3wA=="],
|
||||
|
||||
"@opentui/core-linux-arm64": ["@opentui/core-linux-arm64@0.0.0-20260731-669d7f5c", "", { "os": "linux", "cpu": "arm64" }, "sha512-bYXbkpMFPxGO4yjXKT4fxEcffW2oNEpPFtmf279OG6ym06TlD3APW+zufTLImHj9pH8jAYLZ5mbsIuF9v4jRVg=="],
|
||||
"@opentui/core-linux-arm64": ["@opentui/core-linux-arm64@0.4.5", "", { "os": "linux", "cpu": "arm64" }, "sha512-R4MZ25a4CzOAGVjW9aj1hUfzQGVfCJwrwBDbNs2SXaIvzcZqkxCVtU4FoQ5LsaD0j/BdNQVg2CIfFkFsm1fDuQ=="],
|
||||
|
||||
"@opentui/core-linux-arm64-musl": ["@opentui/core-linux-arm64-musl@0.0.0-20260731-669d7f5c", "", { "os": "linux", "cpu": "arm64" }, "sha512-B5YlOSwgCz2Oa+m/mXB7iz3vgmvyVyc33Pm+sQJ8ytYPw33Nr/VuX4ubpuK/fWhyLcBriqIY1PrLvOZ9k2PRJA=="],
|
||||
"@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-x64": ["@opentui/core-linux-x64@0.0.0-20260731-669d7f5c", "", { "os": "linux", "cpu": "x64" }, "sha512-17yYVxmesDJgpP8YwGVEtctFTYC2UnN8oit0s40mg0lQQOrTMckUlTnj9WxCffYOS6Ae6jQCXe78Cio/W79UrA=="],
|
||||
"@opentui/core-linux-x64": ["@opentui/core-linux-x64@0.4.5", "", { "os": "linux", "cpu": "x64" }, "sha512-SNyuQoxMKI1vuJhgxSSW96adWM6LqFl2SoS3GM4tGeneGOanVVG2Y06PvlytXvF4cKik97t0rqkVMRetmOs93w=="],
|
||||
|
||||
"@opentui/core-linux-x64-musl": ["@opentui/core-linux-x64-musl@0.0.0-20260731-669d7f5c", "", { "os": "linux", "cpu": "x64" }, "sha512-oFiZxe2LhJZHbpLAYQi+NIAwyyzodYVnVrg7A3uUNXLVY3EOmJwCeg6psSTgH+w+2H//Wv8o7qdAr+c0W2IVGg=="],
|
||||
"@opentui/core-linux-x64-musl": ["@opentui/core-linux-x64-musl@0.4.5", "", { "os": "linux", "cpu": "x64" }, "sha512-mKVKcIcPiSVVZZsdPSBoWwoa2/TCeQAaMDeHF7PFw2kt5bTXZPP7xxWfRQLCNIcA1eaGl59UuwUWHDR2Ve548Q=="],
|
||||
|
||||
"@opentui/core-win32-arm64": ["@opentui/core-win32-arm64@0.0.0-20260731-669d7f5c", "", { "os": "win32", "cpu": "arm64" }, "sha512-h+rAkfCsWUoFimDm8h5CqMm9PCsZzObOxFx1rPVtEFqe3doDflj1KDejsWMPugOImEae4jK+AYn82gq92pQv3Q=="],
|
||||
"@opentui/core-win32-arm64": ["@opentui/core-win32-arm64@0.4.5", "", { "os": "win32", "cpu": "arm64" }, "sha512-GHTTsqeR45q2Iek9Rb7ty+x/hAKn2jZ1ujlCgPR8LBKyF7h0E1dNFryoZ7ehMc3kJndP1sKn836IemKFqxuDdQ=="],
|
||||
|
||||
"@opentui/core-win32-x64": ["@opentui/core-win32-x64@0.0.0-20260731-669d7f5c", "", { "os": "win32", "cpu": "x64" }, "sha512-SgpwoRSiPPBb/ppD2J3Re9slyPJcPuEU64Kmfew4JcTQtp9NxTjXMDhe7Ur/X6Fa6HvRH42WQgUMCqbbOay6mA=="],
|
||||
"@opentui/core-win32-x64": ["@opentui/core-win32-x64@0.4.5", "", { "os": "win32", "cpu": "x64" }, "sha512-Y8T/yXCDGagRGiQrtmuB6AhRcPucKFs/Dre3v8kJwNYqDccI4FzUPKclZ7djfmRZNjl7JUqPhZZP/PwDpQocMg=="],
|
||||
|
||||
"@opentui/keymap": ["@opentui/keymap@0.0.0-20260731-669d7f5c", "", { "dependencies": { "@opentui/core": "0.0.0-20260731-669d7f5c" }, "peerDependencies": { "@opentui/react": "0.0.0-20260731-669d7f5c", "@opentui/solid": "0.0.0-20260731-669d7f5c", "react": ">=19.2.0", "solid-js": "1.9.12" }, "optionalPeers": ["@opentui/react", "@opentui/solid", "react", "solid-js"] }, "sha512-N4aluwBylEtmvtO4SvOZJwycSt4nnmVSEEr21lNHydbQ9+JyUQjCHUR0jqhc7RmZYVYoZLZQKkfeeDJaoAzo3Q=="],
|
||||
"@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/solid": ["@opentui/solid@0.0.0-20260731-669d7f5c", "", { "dependencies": { "@babel/core": "7.28.0", "@babel/preset-typescript": "7.27.1", "@opentui/core": "0.0.0-20260731-669d7f5c", "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-EMt5PF9F6gS3YpbhcrdFpfDQFA2dCSJNJfoRV6mokodwopBB96pmpIaeSn/NfYOeKFMzm+x4blgrFrc6v68+KQ=="],
|
||||
"@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=="],
|
||||
|
||||
"@orama/orama": ["@orama/orama@3.1.18", "", {}, "sha512-a61ljmRVVyG5MC/698C8/FfFDw5a8LOIvyOLW5fztgUXqUpc1jOfQzOitSCbge657OgXXThmY3Tk8fpiDb4UcA=="],
|
||||
|
||||
@@ -3527,7 +3528,7 @@
|
||||
|
||||
"builder-util-runtime": ["builder-util-runtime@9.7.0", "", { "dependencies": { "debug": "^4.3.4", "sax": "^1.2.4" } }, "sha512-g/kR520giAFYkSXTzcmF3kqQq7wi8F6N6SzeDgZrqTBN+VHdmgWOyTdD1yD7AATDId/yXLvuP34CxW46/BwCdw=="],
|
||||
|
||||
"bun-ffi-structs": ["bun-ffi-structs@0.3.1", "", { "peerDependencies": { "typescript": "^5" } }, "sha512-3gM7PpVWLyrwxWjcilSiGuhWanhZivvo6l0u573NziPH6f/gwk6McbaYgn7oJWov6pKGRTDbrg94W5DcJsKTtQ=="],
|
||||
"bun-ffi-structs": ["bun-ffi-structs@0.2.4", "", { "peerDependencies": { "typescript": "^5" } }, "sha512-AJzsqoVFs1KBbJbWHIYrVZLDC3NhTqqh25awRXqzoLzmBAKr5oqk6+CwuYHAekKx+VBCYVohBoKuRq40dV+TYg=="],
|
||||
|
||||
"bun-pty": ["bun-pty@0.4.8", "", {}, "sha512-rO70Mrbr13+jxHHHu2YBkk2pNqrJE5cJn29WE++PUr+GFA0hq/VgtQPZANJ8dJo6d7XImvBk37Innt8GM7O28w=="],
|
||||
|
||||
@@ -3607,6 +3608,8 @@
|
||||
|
||||
"cli-truncate": ["cli-truncate@4.0.0", "", { "dependencies": { "slice-ansi": "^5.0.0", "string-width": "^7.0.0" } }, "sha512-nPdaFdQ0h/GEigbPClz11D0v/ZJEwxmeVZGeMo3Z5StPtUTkA9o1lD6QwoirYiSDzbcwn2XcjwmCp68W1IS4TA=="],
|
||||
|
||||
"clipboardy": ["clipboardy@4.0.0", "", { "dependencies": { "execa": "^8.0.1", "is-wsl": "^3.1.0", "is64bit": "^2.0.0" } }, "sha512-5mOlNS0mhX0707P2I0aZ2V/cmHUEO/fL7VFLqszkhUsxt7RwnmrInf/eEQKlf5GzvYeHIjT+Ov1HRfNmymlG0w=="],
|
||||
|
||||
"cliui": ["cliui@8.0.1", "", { "dependencies": { "string-width": "^4.2.0", "strip-ansi": "^6.0.1", "wrap-ansi": "^7.0.0" } }, "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ=="],
|
||||
|
||||
"clone": ["clone@2.1.2", "", {}, "sha512-3Pe/CF1Nn94hyhIYpjtiLhdCoEoz0DqQ+988E9gmeEdQZlojxnOb74wctFyuwWQHzqyf9X7C7MG8juUpqBJT8w=="],
|
||||
@@ -4023,7 +4026,7 @@
|
||||
|
||||
"eventsource-parser": ["eventsource-parser@3.1.0", "", {}, "sha512-kJezFj9YFAMLeORyi7aCLxLbD5/qWMQnoMVlVPyHIll7lgRJCc3JVln9Vgl9nwQi0YkMnhdGTMNn7CkRRAptMg=="],
|
||||
|
||||
"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=="],
|
||||
"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=="],
|
||||
|
||||
"exit-hook": ["exit-hook@2.2.1", "", {}, "sha512-eNTPlAD67BmP31LDINZ3U7HSF8l57TxOY2PmBJ1shpCvpnxBF93mWCE8YHBnXs8qiUZJc9WDcWIeC3a2HIAMfw=="],
|
||||
|
||||
@@ -4305,7 +4308,7 @@
|
||||
|
||||
"https-proxy-agent": ["https-proxy-agent@7.0.6", "", { "dependencies": { "agent-base": "^7.1.2", "debug": "4" } }, "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw=="],
|
||||
|
||||
"human-signals": ["human-signals@2.1.0", "", {}, "sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw=="],
|
||||
"human-signals": ["human-signals@5.0.0", "", {}, "sha512-AXcZb6vzzrFAUE61HnN4mpLqd/cSIwNQjtNWR0euPm6y0iqx3G4gOXaIDdtdDwZmhwe82LA6+zinmW4UBWVePQ=="],
|
||||
|
||||
"humanize-ms": ["humanize-ms@1.2.1", "", { "dependencies": { "ms": "^2.0.0" } }, "sha512-Fl70vYtsAFb/C06PTS9dZBo7ihau+Tu/DNCk/OyHhea07S+aeMWpFFkUaXRa8fI+ScZbEI8dfSxwY7gxZ9SAVQ=="],
|
||||
|
||||
@@ -4449,6 +4452,8 @@
|
||||
|
||||
"is-wsl": ["is-wsl@3.1.1", "", { "dependencies": { "is-inside-container": "^1.0.0" } }, "sha512-e6rvdUCiQCAuumZslxRJWR/Doq4VpPR82kqclvcS0efgt430SlGIk05vdCN58+VrzgtIcfNODjozVielycD4Sw=="],
|
||||
|
||||
"is64bit": ["is64bit@2.0.0", "", { "dependencies": { "system-architecture": "^0.1.0" } }, "sha512-jv+8jaWCl0g2lSBkNSVXdzfBA0npK1HGC2KtWM9FumFRoGS94g3NbCCLVnCYHLjp4GrW2KZeeSTMo5ddtznmGw=="],
|
||||
|
||||
"isarray": ["isarray@2.0.5", "", {}, "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw=="],
|
||||
|
||||
"isbinaryfile": ["isbinaryfile@5.0.7", "", {}, "sha512-gnWD14Jh3FzS3CPhF0AxNOJ8CxqeblPTADzI38r0wt8ZyQl5edpy75myt08EG2oKvpyiqSqsx+Wkz9vtkbTqYQ=="],
|
||||
@@ -4787,7 +4792,7 @@
|
||||
|
||||
"mime-types": ["mime-types@3.0.2", "", { "dependencies": { "mime-db": "^1.54.0" } }, "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A=="],
|
||||
|
||||
"mimic-fn": ["mimic-fn@2.1.0", "", {}, "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg=="],
|
||||
"mimic-fn": ["mimic-fn@4.0.0", "", {}, "sha512-vqiC06CuhBTUdZH+RYl8sFrL096vA45Ok5ISO6sE/Mr1jRbGH4Csnhi8f3wKVl7x8mO4Au7Ir9D3Oyv1VYMFJw=="],
|
||||
|
||||
"mimic-function": ["mimic-function@5.0.1", "", {}, "sha512-VP79XUPxV2CigYP3jWwAUFSku2aKqBH7uTAapFWCBqutsbmDo96KY5o8uh6U+/YSIn5OxJnXp73beVkpqMIGhA=="],
|
||||
|
||||
@@ -4907,7 +4912,7 @@
|
||||
|
||||
"npm-registry-fetch": ["npm-registry-fetch@19.1.1", "", { "dependencies": { "@npmcli/redact": "^4.0.0", "jsonparse": "^1.3.1", "make-fetch-happen": "^15.0.0", "minipass": "^7.0.2", "minipass-fetch": "^5.0.0", "minizlib": "^3.0.1", "npm-package-arg": "^13.0.0", "proc-log": "^6.0.0" } }, "sha512-TakBap6OM1w0H73VZVDf44iFXsOS3h+L4wVMXmbWOQroZgFhMch0juN6XSzBNlD965yIKvWg2dfu7NSiaYLxtw=="],
|
||||
|
||||
"npm-run-path": ["npm-run-path@4.0.1", "", { "dependencies": { "path-key": "^3.0.0" } }, "sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw=="],
|
||||
"npm-run-path": ["npm-run-path@5.3.0", "", { "dependencies": { "path-key": "^4.0.0" } }, "sha512-ppwTtiJZq0O/ai0z7yfudtBpWIoxM8yE6nHi1X47eFR2EWORqfbu6CnPlNsjeN683eT0qG6H/Pyf9fCcvjnnnQ=="],
|
||||
|
||||
"nth-check": ["nth-check@2.1.1", "", { "dependencies": { "boolbase": "^1.0.0" } }, "sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w=="],
|
||||
|
||||
@@ -4933,7 +4938,7 @@
|
||||
|
||||
"once": ["once@1.4.0", "", { "dependencies": { "wrappy": "1" } }, "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w=="],
|
||||
|
||||
"onetime": ["onetime@5.1.2", "", { "dependencies": { "mimic-fn": "^2.1.0" } }, "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg=="],
|
||||
"onetime": ["onetime@6.0.0", "", { "dependencies": { "mimic-fn": "^4.0.0" } }, "sha512-1FlR+gjXK7X+AsAHso35MnyN5KqGwJRi/31ft6x0M194ht7S+rWAvd7PHss9xSKMzE0asv1pyIHaJYq+BbacAQ=="],
|
||||
|
||||
"oniguruma-parser": ["oniguruma-parser@0.12.2", "", {}, "sha512-6HVa5oIrgMC6aA6WF6XyyqbhRPJrKR02L20+2+zpDtO5QAzGHAUGw5TKQvwi5vctNnRHkJYmjAhRVQF2EKdTQw=="],
|
||||
|
||||
@@ -5533,7 +5538,7 @@
|
||||
|
||||
"strip-bom-string": ["strip-bom-string@1.0.0", "", {}, "sha512-uCC2VHvQRYu+lMh4My/sFNmF2klFymLX1wHJeXnbEJERpV/ZsVuonzerjfrGpIGF7LBVa1O7i9kjiWvJiFck8g=="],
|
||||
|
||||
"strip-final-newline": ["strip-final-newline@2.0.0", "", {}, "sha512-BrpvfNAE3dcvq7ll3xVumzjKjZQ5tI1sEUIKr3Uoks0XUl45St3FlatVqef9prk4jRDzhW6WZg+3bk93y6pLjA=="],
|
||||
"strip-final-newline": ["strip-final-newline@3.0.0", "", {}, "sha512-dOESqjYr96iWYylGObzd39EuNTa5VJxyvVAEm5Jnh7KGo75V43Hk1odPQkNDyXNmUR6k+gEiDVXnjB8HJ3crXw=="],
|
||||
|
||||
"strip-indent": ["strip-indent@3.0.0", "", { "dependencies": { "min-indent": "^1.0.0" } }, "sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ=="],
|
||||
|
||||
@@ -5565,6 +5570,8 @@
|
||||
|
||||
"svgo": ["svgo@4.0.2", "", { "dependencies": { "commander": "^11.1.0", "css-select": "^5.1.0", "css-tree": "^3.0.1", "css-what": "^6.1.0", "csso": "^5.0.5", "picocolors": "^1.1.1", "sax": "^1.5.0" }, "bin": "./bin/svgo.js" }, "sha512-ekx94z1rRc5LDi6oSUaeRnYhd0UOJxdtQCL2rF8xpWxD3TPAsISWOrxezqGovqS38GRZOdpDfvQe3ts6F7nsng=="],
|
||||
|
||||
"system-architecture": ["system-architecture@0.1.0", "", {}, "sha512-ulAk51I9UVUyJgxlv9M6lFot2WP3e7t8Kz9+IS6D4rVba1tR9kON+Ey69f+1R4Q8cd45Lod6a4IcJIxnzGc/zA=="],
|
||||
|
||||
"tagged-tag": ["tagged-tag@1.0.0", "", {}, "sha512-yEFYrVhod+hdNyx7g5Bnkkb0G6si8HJurOoOEgC8B/O0uXLHlaey/65KRv6cuWBNhBgHKAROVpc7QyYqE5gFng=="],
|
||||
|
||||
"tailwindcss": ["tailwindcss@4.1.11", "", {}, "sha512-2E9TBm6MDD/xKYe+dvJZAmg3yxIEDNRc0jwlNyDg/4Fil2QcSLjFKGVff0lAf1jjeaArlG/M75Ey/EYr/OJtBA=="],
|
||||
@@ -6505,6 +6512,8 @@
|
||||
|
||||
"@vercel/cli-config/zod": ["zod@4.1.11", "", {}, "sha512-WPsqwxITS2tzx1bzhIKsEs19ABD5vmCVa4xBo2tq/SrV4RNZtfws1EnCWQXM6yh8bD08a1idvkB5MZSBiZsjwg=="],
|
||||
|
||||
"@vercel/cli-exec/execa": ["execa@5.1.1", "", { "dependencies": { "cross-spawn": "^7.0.3", "get-stream": "^6.0.0", "human-signals": "^2.1.0", "is-stream": "^2.0.0", "merge-stream": "^2.0.0", "npm-run-path": "^4.0.1", "onetime": "^5.1.2", "signal-exit": "^3.0.3", "strip-final-newline": "^2.0.0" } }, "sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg=="],
|
||||
|
||||
"@vercel/functions/@vercel/oidc": ["@vercel/oidc@3.8.1", "", { "dependencies": { "@vercel/cli-config": "0.2.1", "@vercel/cli-exec": "1.0.0", "jose": "^5.9.6" } }, "sha512-ufdalm2MWOYksyj8KVpWjoOFPJO6zoYpuyvIggIQ2bB0CFCjTCiTkGXHqAKwG77GVRjOaN3/8S5ITlZpXWmqOw=="],
|
||||
|
||||
"@vercel/nft/acorn": ["acorn@8.17.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-xRQbDb9BnwDafYNn6Vwl839DYVjqXYb1XVGtWAZ1kcDc6iwAL4hg3B1dZlRiuENFeO2H53gFG3in621AdERVAg=="],
|
||||
@@ -6687,11 +6696,9 @@
|
||||
|
||||
"estree-util-to-js/source-map": ["source-map@0.7.6", "", {}, "sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ=="],
|
||||
|
||||
"execa/get-stream": ["get-stream@6.0.1", "", {}, "sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg=="],
|
||||
"execa/get-stream": ["get-stream@8.0.1", "", {}, "sha512-VaUJspBffn/LMCJVoMvSAdmscJyS1auj5Zulnn5UoYcY531UWmdwhRWkcGKnGU93m5HSXP9LP2usOryrBtQowA=="],
|
||||
|
||||
"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=="],
|
||||
"execa/is-stream": ["is-stream@3.0.0", "", {}, "sha512-LnQR4bZ9IADDRSkvpqMGvt/tEJWclzklNgSw48V5EAaAeDd6qGvN8ei6k5p0tvxSR171VmGyHuTiAOfxAbr8kA=="],
|
||||
|
||||
"express/cookie": ["cookie@0.7.2", "", {}, "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w=="],
|
||||
|
||||
@@ -6765,6 +6772,8 @@
|
||||
|
||||
"node-gyp/undici": ["undici@6.28.0", "", {}, "sha512-LIY910g9TI13YS95lrMFrs8Rm/u/irgHeTWoKCoteeJ04CUJ92eEfj0rVn+7VKMPBpUPiUoBKfhNyLI23EE/KA=="],
|
||||
|
||||
"npm-run-path/path-key": ["path-key@4.0.0", "", {}, "sha512-haREypq7xkM7ErfgIyA0z+Bj4AGKlMSdlQE2jvJo6huWD1EdkKYV+G/T4nq0YEF2vgTT8kqMFKo1uHn950r4SQ=="],
|
||||
|
||||
"openid-client/jose": ["jose@4.15.9", "", {}, "sha512-1vUQX+IdDMVPj4k8kOxgUqlcK518yluMuGZwqlr44FS1ppZB/5GWh4rZG89erpOBOJjU/OBsnCVFfapsRz6nEA=="],
|
||||
|
||||
"openid-client/lru-cache": ["lru-cache@6.0.0", "", { "dependencies": { "yallist": "^4.0.0" } }, "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA=="],
|
||||
@@ -7539,6 +7548,20 @@
|
||||
|
||||
"@tailwindcss/oxide-wasm32-wasi/@napi-rs/wasm-runtime/@tybys/wasm-util": ["@tybys/wasm-util@0.10.3", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg=="],
|
||||
|
||||
"@vercel/cli-exec/execa/get-stream": ["get-stream@6.0.1", "", {}, "sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg=="],
|
||||
|
||||
"@vercel/cli-exec/execa/human-signals": ["human-signals@2.1.0", "", {}, "sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw=="],
|
||||
|
||||
"@vercel/cli-exec/execa/is-stream": ["is-stream@2.0.1", "", {}, "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg=="],
|
||||
|
||||
"@vercel/cli-exec/execa/npm-run-path": ["npm-run-path@4.0.1", "", { "dependencies": { "path-key": "^3.0.0" } }, "sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw=="],
|
||||
|
||||
"@vercel/cli-exec/execa/onetime": ["onetime@5.1.2", "", { "dependencies": { "mimic-fn": "^2.1.0" } }, "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg=="],
|
||||
|
||||
"@vercel/cli-exec/execa/signal-exit": ["signal-exit@3.0.7", "", {}, "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ=="],
|
||||
|
||||
"@vercel/cli-exec/execa/strip-final-newline": ["strip-final-newline@2.0.0", "", {}, "sha512-BrpvfNAE3dcvq7ll3xVumzjKjZQ5tI1sEUIKr3Uoks0XUl45St3FlatVqef9prk4jRDzhW6WZg+3bk93y6pLjA=="],
|
||||
|
||||
"@vercel/functions/@vercel/oidc/jose": ["jose@5.9.6", "", {}, "sha512-AMlnetc9+CV9asI19zHmrgS/WYsWUwCn2R7RzlbJWD7F9eWYUTGyBmU9o6PxngtLGOiDGPRu+Uc4fhKzbpteZQ=="],
|
||||
|
||||
"@vercel/routing-utils/ajv/json-schema-traverse": ["json-schema-traverse@0.4.1", "", {}, "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg=="],
|
||||
@@ -8593,6 +8616,8 @@
|
||||
|
||||
"@solidjs/start/shiki/@shikijs/engine-javascript/oniguruma-to-es": ["oniguruma-to-es@2.3.0", "", { "dependencies": { "emoji-regex-xs": "^1.0.0", "regex": "^5.1.1", "regex-recursion": "^5.1.1" } }, "sha512-bwALDxriqfKGfUufKGGepCzu9x7nJQuoRoAFp4AnwehhC2crqrDIAP/uN2qdlsAvSMpeRC3+Yzhqc7hLmle5+g=="],
|
||||
|
||||
"@vercel/cli-exec/execa/onetime/mimic-fn": ["mimic-fn@2.1.0", "", {}, "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg=="],
|
||||
|
||||
"ansi-align/string-width/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="],
|
||||
|
||||
"app-builder-lib/@electron/get/fs-extra/universalify": ["universalify@0.1.2", "", {}, "sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg=="],
|
||||
|
||||
+6
-6
@@ -45,9 +45,9 @@
|
||||
"@octokit/rest": "22.0.0",
|
||||
"@hono/standard-validator": "0.2.0",
|
||||
"@hono/zod-validator": "0.4.2",
|
||||
"@opentui/core": "0.0.0-20260731-669d7f5c",
|
||||
"@opentui/keymap": "0.0.0-20260731-669d7f5c",
|
||||
"@opentui/solid": "0.0.0-20260731-669d7f5c",
|
||||
"@opentui/core": "0.4.5",
|
||||
"@opentui/keymap": "0.4.5",
|
||||
"@opentui/solid": "0.4.5",
|
||||
"@tanstack/solid-virtual": "3.13.32",
|
||||
"@shikijs/stream": "4.2.0",
|
||||
"@standard-schema/spec": "1.1.0",
|
||||
@@ -148,9 +148,9 @@
|
||||
"electron"
|
||||
],
|
||||
"overrides": {
|
||||
"@opentui/core": "0.0.0-20260731-669d7f5c",
|
||||
"@opentui/keymap": "0.0.0-20260731-669d7f5c",
|
||||
"@opentui/solid": "0.0.0-20260731-669d7f5c",
|
||||
"@opentui/core": "catalog:",
|
||||
"@opentui/keymap": "catalog:",
|
||||
"@opentui/solid": "catalog:",
|
||||
"@types/bun": "catalog:",
|
||||
"@types/node": "catalog:",
|
||||
"effect": "catalog:"
|
||||
|
||||
@@ -12,8 +12,6 @@
|
||||
|
||||
Per-type constructors live on the type, not as top-level re-exports. Use `Message.system(...)`, `Message.user(...)`, `Message.assistant(...)`, `Message.tool(...)`, `Model.make(...)`, `ToolDefinition.make(...)`, `ToolCallPart.make(...)`, `ToolResultPart.make(...)`, `ToolChoice.make(...)`, `ToolChoice.named(...)`, `SystemPart.make(...)`, and `GenerationOptions.make(...)` directly. The top-level `LLM` namespace is reserved for request-shaped call APIs: `LLM.request`, `LLM.generate`, `LLM.stream`, `LLM.updateRequest`, and `LLM.generateObject`. Two ways to construct the same thing is one too many.
|
||||
|
||||
- Keep provider-defined string enums forward-compatible. Expose known values for autocomplete while accepting future values with `Known | (string & {})`; use `Schema.String` at runtime unless rejecting unknown values is required for correctness.
|
||||
|
||||
## Tests
|
||||
|
||||
- Use `testEffect(...)` from `test/lib/effect.ts` for tests requiring Effect layers.
|
||||
|
||||
@@ -29,30 +29,9 @@ export const DEFAULT_BASE_URL = "https://generativelanguage.googleapis.com/v1bet
|
||||
|
||||
export interface OptionsInput {
|
||||
readonly [key: string]: unknown
|
||||
readonly cachedContent?: string
|
||||
readonly safetySettings?: ReadonlyArray<{
|
||||
readonly category:
|
||||
| "HARM_CATEGORY_UNSPECIFIED"
|
||||
| "HARM_CATEGORY_HATE_SPEECH"
|
||||
| "HARM_CATEGORY_DANGEROUS_CONTENT"
|
||||
| "HARM_CATEGORY_HARASSMENT"
|
||||
| "HARM_CATEGORY_SEXUALLY_EXPLICIT"
|
||||
| "HARM_CATEGORY_CIVIC_INTEGRITY"
|
||||
| (string & {})
|
||||
readonly threshold:
|
||||
| "HARM_BLOCK_THRESHOLD_UNSPECIFIED"
|
||||
| "BLOCK_LOW_AND_ABOVE"
|
||||
| "BLOCK_MEDIUM_AND_ABOVE"
|
||||
| "BLOCK_ONLY_HIGH"
|
||||
| "BLOCK_NONE"
|
||||
| "OFF"
|
||||
| (string & {})
|
||||
}>
|
||||
readonly serviceTier?: "standard" | "flex" | "priority" | (string & {})
|
||||
readonly thinkingConfig?: {
|
||||
readonly thinkingBudget?: number
|
||||
readonly includeThoughts?: boolean
|
||||
readonly thinkingLevel?: "minimal" | "low" | "medium" | "high" | (string & {})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -132,12 +111,6 @@ const GeminiToolConfig = Schema.Struct({
|
||||
const GeminiThinkingConfig = Schema.Struct({
|
||||
thinkingBudget: Schema.optional(Schema.Number),
|
||||
includeThoughts: Schema.optional(Schema.Boolean),
|
||||
thinkingLevel: Schema.optional(Schema.String),
|
||||
})
|
||||
|
||||
const GeminiSafetySetting = Schema.Struct({
|
||||
category: Schema.String,
|
||||
threshold: Schema.String,
|
||||
})
|
||||
|
||||
const GeminiGenerationConfig = Schema.Struct({
|
||||
@@ -150,10 +123,7 @@ const GeminiGenerationConfig = Schema.Struct({
|
||||
})
|
||||
|
||||
const GeminiBodyFields = {
|
||||
cachedContent: Schema.optional(Schema.String),
|
||||
contents: Schema.Array(GeminiContent),
|
||||
safetySettings: optionalArray(GeminiSafetySetting),
|
||||
serviceTier: Schema.optional(Schema.String),
|
||||
systemInstruction: Schema.optional(GeminiSystemInstruction),
|
||||
tools: optionalArray(GeminiTool),
|
||||
toolConfig: Schema.optional(GeminiToolConfig),
|
||||
@@ -346,38 +316,17 @@ const lowerMessages = Effect.fn("Gemini.lowerMessages")(function* (request: LLMR
|
||||
})
|
||||
|
||||
const resolveOptions = (request: LLMRequest) => {
|
||||
const input = request.providerOptions?.gemini
|
||||
const value = input?.thinkingConfig
|
||||
const value = request.providerOptions?.gemini?.thinkingConfig
|
||||
if (!ProviderShared.isRecord(value)) return {}
|
||||
const thinkingConfig = {
|
||||
thinkingBudget:
|
||||
ProviderShared.isRecord(value) && typeof value.thinkingBudget === "number" ? value.thinkingBudget : undefined,
|
||||
includeThoughts:
|
||||
ProviderShared.isRecord(value) && typeof value.includeThoughts === "boolean"
|
||||
? value.includeThoughts
|
||||
: ProviderShared.isRecord(value)
|
||||
? true
|
||||
: undefined,
|
||||
thinkingLevel:
|
||||
ProviderShared.isRecord(value) && typeof value.thinkingLevel === "string" ? value.thinkingLevel : undefined,
|
||||
thinkingBudget: typeof value.thinkingBudget === "number" ? value.thinkingBudget : undefined,
|
||||
includeThoughts: typeof value.includeThoughts === "boolean" ? value.includeThoughts : undefined,
|
||||
}
|
||||
return {
|
||||
cachedContent: typeof input?.cachedContent === "string" ? input.cachedContent : undefined,
|
||||
safetySettings: mapSafetySettings(input?.safetySettings),
|
||||
serviceTier: typeof input?.serviceTier === "string" ? input.serviceTier : undefined,
|
||||
thinkingConfig: Object.values(thinkingConfig).some((item) => item !== undefined) ? thinkingConfig : undefined,
|
||||
}
|
||||
}
|
||||
|
||||
function mapSafetySettings(value: unknown) {
|
||||
if (!Array.isArray(value)) return undefined
|
||||
const settings = value.flatMap((item) =>
|
||||
ProviderShared.isRecord(item) && typeof item.category === "string" && typeof item.threshold === "string"
|
||||
? [{ category: item.category, threshold: item.threshold }]
|
||||
: [],
|
||||
)
|
||||
return settings
|
||||
}
|
||||
|
||||
const fromRequest = Effect.fn("Gemini.fromRequest")(function* (request: LLMRequest) {
|
||||
const hasTools = request.tools.length > 0
|
||||
const generation = request.generation
|
||||
@@ -393,10 +342,7 @@ const fromRequest = Effect.fn("Gemini.fromRequest")(function* (request: LLMReque
|
||||
}
|
||||
|
||||
return {
|
||||
cachedContent: options.cachedContent,
|
||||
contents: yield* lowerMessages(request),
|
||||
safetySettings: options.safetySettings,
|
||||
serviceTier: options.serviceTier,
|
||||
systemInstruction:
|
||||
request.system.length === 0 ? undefined : { parts: [{ text: ProviderShared.joinText(request.system) }] },
|
||||
tools: hasTools
|
||||
|
||||
@@ -5,7 +5,7 @@ import { Endpoint, type EndpointPatch } from "./endpoint"
|
||||
import { RequestExecutor } from "./executor"
|
||||
import { Framing } from "./framing"
|
||||
import { HttpTransport } from "./transport"
|
||||
import type { HttpRequestTransform, Transport, TransportRuntime } from "./transport"
|
||||
import type { Transport, TransportRuntime } from "./transport"
|
||||
import { WebSocketExecutor } from "./transport"
|
||||
import type { Protocol } from "./protocol"
|
||||
import { applyCachePolicy } from "../cache-policy"
|
||||
@@ -46,11 +46,7 @@ export interface Route<Body, Prepared = unknown> {
|
||||
readonly body: RouteBody<Body>
|
||||
readonly with: (patch: RoutePatch<Body, Prepared>) => Route<Body, Prepared>
|
||||
readonly model: <Options extends ProviderOptions = ProviderOptions>(input: RouteMappedModelInput) => Model<Options>
|
||||
readonly prepareTransport: (
|
||||
body: Body,
|
||||
request: LLMRequest,
|
||||
options?: StreamOptions,
|
||||
) => Effect.Effect<Prepared, LLMError>
|
||||
readonly prepareTransport: (body: Body, request: LLMRequest) => Effect.Effect<Prepared, LLMError>
|
||||
readonly streamPrepared: (
|
||||
prepared: Prepared,
|
||||
request: LLMRequest,
|
||||
@@ -149,16 +145,12 @@ export interface Interface {
|
||||
readonly generate: GenerateMethod
|
||||
}
|
||||
|
||||
export interface StreamOptions {
|
||||
readonly transform?: HttpRequestTransform
|
||||
}
|
||||
|
||||
export interface StreamMethod {
|
||||
(request: LLMRequest, options?: StreamOptions): Stream.Stream<LLMEvent, LLMError>
|
||||
(request: LLMRequest): Stream.Stream<LLMEvent, LLMError>
|
||||
}
|
||||
|
||||
export interface GenerateMethod {
|
||||
(request: LLMRequest, options?: StreamOptions): Effect.Effect<LLMResponse, LLMError>
|
||||
(request: LLMRequest): Effect.Effect<LLMResponse, LLMError>
|
||||
}
|
||||
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/LLMClient") {}
|
||||
@@ -294,7 +286,7 @@ function makeFromTransport<Body, Prepared, Frame, Event, State>(
|
||||
},
|
||||
model: <Options extends ProviderOptions = ProviderOptions>(input: RouteMappedModelInput) =>
|
||||
makeRouteModel<Options>(route, input),
|
||||
prepareTransport: (body, request, options) =>
|
||||
prepareTransport: (body, request) =>
|
||||
routeInput.transport.prepare({
|
||||
body,
|
||||
request,
|
||||
@@ -302,7 +294,6 @@ function makeFromTransport<Body, Prepared, Frame, Event, State>(
|
||||
auth: routeInput.auth ?? Auth.none,
|
||||
encodeBody,
|
||||
headers: routeInput.headers,
|
||||
transform: options?.transform,
|
||||
}),
|
||||
streamPrepared: (prepared: Prepared, request: LLMRequest, runtime: TransportRuntime) => {
|
||||
const route = `${request.model.provider}/${request.model.route.id}`
|
||||
@@ -368,14 +359,14 @@ export function make<Body, Prepared, Frame, Event, State>(
|
||||
})
|
||||
}
|
||||
|
||||
const compile = Effect.fn("LLM.compile")(function* (request: LLMRequest, options?: StreamOptions) {
|
||||
const compile = Effect.fn("LLM.compile")(function* (request: LLMRequest) {
|
||||
const resolved = applyCachePolicy(resolveRequestOptions(request))
|
||||
const route = resolved.model.route
|
||||
|
||||
const body = yield* route.body
|
||||
.from(resolved)
|
||||
.pipe(Effect.flatMap(ProviderShared.validateWith(Schema.decodeUnknownEffect(route.body.schema))))
|
||||
const prepared = yield* route.prepareTransport(body, resolved, options)
|
||||
const prepared = yield* route.prepareTransport(body, resolved)
|
||||
|
||||
return {
|
||||
request: resolved,
|
||||
@@ -398,17 +389,17 @@ export const compileRequest = Effect.fn("LLM.compileRequest")(function* (request
|
||||
}
|
||||
})
|
||||
|
||||
const streamRequestWith = (runtime: TransportRuntime) => (request: LLMRequest, options?: StreamOptions) =>
|
||||
const streamRequestWith = (runtime: TransportRuntime) => (request: LLMRequest) =>
|
||||
Stream.unwrap(
|
||||
Effect.gen(function* () {
|
||||
const compiled = yield* compile(request, options)
|
||||
const compiled = yield* compile(request)
|
||||
return compiled.route.streamPrepared(compiled.prepared, compiled.request, runtime)
|
||||
}),
|
||||
)
|
||||
|
||||
const generateWith = (stream: Interface["stream"]) =>
|
||||
Effect.fn("LLM.generate")(function* (request: LLMRequest, options?: StreamOptions) {
|
||||
const state = yield* stream(request, options).pipe(Stream.runFold(LLMResponse.empty, LLMResponse.reduce))
|
||||
Effect.fn("LLM.generate")(function* (request: LLMRequest) {
|
||||
const state = yield* stream(request).pipe(Stream.runFold(LLMResponse.empty, LLMResponse.reduce))
|
||||
const response = LLMResponse.complete(state)
|
||||
if (response) return response
|
||||
return yield* ProviderShared.eventError(
|
||||
@@ -417,24 +408,24 @@ const generateWith = (stream: Interface["stream"]) =>
|
||||
)
|
||||
})
|
||||
|
||||
export function stream(request: LLMRequest, options?: StreamOptions): Stream.Stream<LLMEvent, LLMError> {
|
||||
export function stream(request: LLMRequest): Stream.Stream<LLMEvent, LLMError> {
|
||||
return Stream.unwrap(
|
||||
Effect.gen(function* () {
|
||||
return (yield* Service).stream(request, options)
|
||||
return (yield* Service).stream(request)
|
||||
}),
|
||||
) as Stream.Stream<LLMEvent, LLMError>
|
||||
}
|
||||
|
||||
export function generate(request: LLMRequest, options?: StreamOptions): Effect.Effect<LLMResponse, LLMError> {
|
||||
export function generate(request: LLMRequest): Effect.Effect<LLMResponse, LLMError> {
|
||||
return Effect.gen(function* () {
|
||||
return yield* (yield* Service).generate(request, options)
|
||||
return yield* (yield* Service).generate(request)
|
||||
}) as Effect.Effect<LLMResponse, LLMError>
|
||||
}
|
||||
|
||||
export const streamRequest = (request: LLMRequest, options?: StreamOptions) =>
|
||||
export const streamRequest = (request: LLMRequest) =>
|
||||
Stream.unwrap(
|
||||
Effect.gen(function* () {
|
||||
return (yield* Service).stream(request, options)
|
||||
return (yield* Service).stream(request)
|
||||
}),
|
||||
)
|
||||
|
||||
|
||||
@@ -8,7 +8,6 @@ export type {
|
||||
AnyRoute,
|
||||
Interface as LLMClientShape,
|
||||
Service as LLMClientService,
|
||||
StreamOptions,
|
||||
} from "./client"
|
||||
export * from "./executor"
|
||||
export { Auth } from "./auth"
|
||||
@@ -23,4 +22,4 @@ export type { ApiKeyMode, AuthOverride, ProviderAuthOption } from "./auth-option
|
||||
export type { Definition as EndpointFn, EndpointInput } from "./endpoint"
|
||||
export type { Definition as FramingDef } from "./framing"
|
||||
export type { Protocol as ProtocolDef } from "./protocol"
|
||||
export type { HttpRequest, HttpRequestTransform, Transport as TransportDef, TransportRuntime } from "./transport"
|
||||
export type { Transport as TransportDef, TransportRuntime } from "./transport"
|
||||
|
||||
@@ -120,19 +120,14 @@ export const httpJson = <Body, Frame>(input: HttpJsonInput<Body, Frame>): HttpJs
|
||||
id: "http-json",
|
||||
with: (patch) => httpJson({ ...input, ...patch }),
|
||||
prepare: (prepareInput) =>
|
||||
Effect.gen(function* () {
|
||||
const parts = yield* jsonRequestParts({ ...prepareInput })
|
||||
const request = { url: parts.url, method: "POST", headers: { ...parts.headers }, body: parts.bodyText }
|
||||
yield* (prepareInput.transform?.(request) ?? Effect.void)
|
||||
return {
|
||||
request: ProviderShared.jsonPost({
|
||||
url: request.url,
|
||||
body: request.body ?? "",
|
||||
headers: Headers.fromInput(request.headers),
|
||||
}),
|
||||
jsonRequestParts({
|
||||
...prepareInput,
|
||||
}).pipe(
|
||||
Effect.map((parts) => ({
|
||||
request: ProviderShared.jsonPost({ url: parts.url, body: parts.bodyText, headers: parts.headers }),
|
||||
framing: input.framing,
|
||||
}
|
||||
}),
|
||||
})),
|
||||
),
|
||||
frames: (prepared, request, runtime) =>
|
||||
Stream.unwrap(
|
||||
runtime.http
|
||||
|
||||
@@ -10,15 +10,6 @@ export interface TransportRuntime {
|
||||
readonly webSocket?: WebSocketExecutorInterface
|
||||
}
|
||||
|
||||
export interface HttpRequest {
|
||||
url: string
|
||||
readonly method: string
|
||||
headers: Record<string, string>
|
||||
body: string | undefined
|
||||
}
|
||||
|
||||
export type HttpRequestTransform = (request: HttpRequest) => Effect.Effect<void>
|
||||
|
||||
export interface Transport<Body, Prepared, Frame> {
|
||||
readonly id: string
|
||||
readonly prepare: (input: TransportPrepareInput<Body>) => Effect.Effect<Prepared, LLMError>
|
||||
@@ -36,7 +27,6 @@ export interface TransportPrepareInput<Body> {
|
||||
readonly auth: Auth.Definition
|
||||
readonly encodeBody: (body: Body) => string
|
||||
readonly headers?: (input: { readonly request: LLMRequest }) => Record<string, string>
|
||||
readonly transform?: HttpRequestTransform
|
||||
}
|
||||
|
||||
export * as HttpTransport from "./http"
|
||||
|
||||
@@ -137,40 +137,6 @@ describe("request option precedence", () => {
|
||||
),
|
||||
)
|
||||
|
||||
it.effect("transforms the final HTTP request after serialization and authentication", () =>
|
||||
LLMClient.generate(
|
||||
LLM.request({
|
||||
model: OpenAIChat.route
|
||||
.with({ endpoint: { baseURL: "https://api.openai.test/v1/" }, auth: Auth.bearer("fresh-key") })
|
||||
.model({ id: "gpt-4o-mini" }),
|
||||
prompt: "Say hello.",
|
||||
}),
|
||||
{
|
||||
transform: (request) =>
|
||||
Effect.sync(() => {
|
||||
expect(request.headers.authorization).toBe("Bearer fresh-key")
|
||||
request.url = "https://proxy.test/v1/chat/completions"
|
||||
request.headers["x-plugin"] = "transformed"
|
||||
request.body = JSON.stringify({ transformed: true })
|
||||
}),
|
||||
},
|
||||
).pipe(
|
||||
Effect.provide(
|
||||
dynamicResponse((input) =>
|
||||
Effect.gen(function* () {
|
||||
const web = yield* HttpClientRequest.toWeb(input.request).pipe(Effect.orDie)
|
||||
expect(web.url).toBe("https://proxy.test/v1/chat/completions")
|
||||
expect(web.headers.get("x-plugin")).toBe("transformed")
|
||||
expect(decodeJson(input.text)).toEqual({ transformed: true })
|
||||
return input.respond(sseEvents(deltaChunk({}, "stop")), {
|
||||
headers: { "content-type": "text/event-stream" },
|
||||
})
|
||||
}),
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
it.effect("rejects raw body overlays for protocol-owned roots", () =>
|
||||
Effect.gen(function* () {
|
||||
const model = OpenAIChat.route
|
||||
|
||||
@@ -9,39 +9,9 @@ LLM.request({
|
||||
providerOptions: { gemini: { thinkingConfig: { thinkingBudget: 1024 } } },
|
||||
})
|
||||
|
||||
LLM.request({
|
||||
model,
|
||||
prompt: "Hello",
|
||||
providerOptions: {
|
||||
gemini: {
|
||||
// @ts-expect-error Gemini safety settings require a threshold for every category.
|
||||
safetySettings: [{ category: "HARM_CATEGORY_HATE_SPEECH" }],
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
LLM.request({
|
||||
model,
|
||||
prompt: "Hello",
|
||||
providerOptions: {
|
||||
gemini: {
|
||||
cachedContent: "cachedContents/example",
|
||||
safetySettings: [{ category: "HARM_CATEGORY_HATE_SPEECH", threshold: "BLOCK_ONLY_HIGH" }],
|
||||
serviceTier: "future-tier",
|
||||
thinkingConfig: { thinkingLevel: "high", includeThoughts: true },
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
LLM.request({
|
||||
model,
|
||||
prompt: "Hello",
|
||||
// @ts-expect-error Gemini thinking budgets must be numeric.
|
||||
providerOptions: { gemini: { thinkingConfig: { thinkingBudget: "large" } } },
|
||||
})
|
||||
|
||||
LLM.request({
|
||||
model,
|
||||
prompt: "Hello",
|
||||
providerOptions: { gemini: { thinkingConfig: { thinkingLevel: "maximum" } } },
|
||||
})
|
||||
|
||||
@@ -41,14 +41,7 @@ describe("Gemini route", () => {
|
||||
Effect.gen(function* () {
|
||||
const prepared = yield* compileRequest(
|
||||
LLMRequest.update(request, {
|
||||
providerOptions: {
|
||||
gemini: {
|
||||
cachedContent: "cachedContents/example",
|
||||
safetySettings: [{ category: "HARM_CATEGORY_HATE_SPEECH", threshold: "BLOCK_ONLY_HIGH" }],
|
||||
serviceTier: "priority",
|
||||
thinkingConfig: { thinkingBudget: 0, includeThoughts: false, thinkingLevel: "high" },
|
||||
},
|
||||
},
|
||||
providerOptions: { gemini: { thinkingConfig: { thinkingBudget: 0, includeThoughts: false } } },
|
||||
}),
|
||||
)
|
||||
const filtered = yield* compileRequest(
|
||||
@@ -56,33 +49,12 @@ describe("Gemini route", () => {
|
||||
providerOptions: { gemini: { thinkingConfig: { thinkingBudget: "invalid", includeThoughts: false } } },
|
||||
}),
|
||||
)
|
||||
const defaulted = yield* compileRequest(
|
||||
LLMRequest.update(request, {
|
||||
providerOptions: { gemini: { thinkingConfig: { thinkingLevel: "high" } } },
|
||||
}),
|
||||
)
|
||||
const emptySafetySettings = yield* compileRequest(
|
||||
LLMRequest.update(request, {
|
||||
providerOptions: { gemini: { safetySettings: [] } },
|
||||
}),
|
||||
)
|
||||
|
||||
expect(prepared.body.generationConfig?.thinkingConfig).toEqual({
|
||||
thinkingBudget: 0,
|
||||
includeThoughts: false,
|
||||
thinkingLevel: "high",
|
||||
})
|
||||
expect(prepared.body.cachedContent).toBe("cachedContents/example")
|
||||
expect(prepared.body.safetySettings).toEqual([
|
||||
{ category: "HARM_CATEGORY_HATE_SPEECH", threshold: "BLOCK_ONLY_HIGH" },
|
||||
])
|
||||
expect(prepared.body.serviceTier).toBe("priority")
|
||||
expect(filtered.body.generationConfig?.thinkingConfig).toEqual({ includeThoughts: false })
|
||||
expect(defaulted.body.generationConfig?.thinkingConfig).toEqual({
|
||||
includeThoughts: true,
|
||||
thinkingLevel: "high",
|
||||
})
|
||||
expect(emptySafetySettings.body.safetySettings).toEqual([])
|
||||
}),
|
||||
)
|
||||
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
export * as AISDKNative from "./aisdk-native"
|
||||
|
||||
import { isRecord } from "@opencode-ai/ai/utils/record"
|
||||
|
||||
export interface Mapping {
|
||||
readonly package: string
|
||||
readonly settings: Readonly<Record<string, unknown>>
|
||||
@@ -16,7 +14,7 @@ export function map(packageName: string | undefined, settings: Readonly<Record<s
|
||||
settings: {
|
||||
...baseSettings,
|
||||
...mapAPIKey(settings),
|
||||
...mapGoogleOptions(settings),
|
||||
...mapProviderOptions("gemini", settings),
|
||||
},
|
||||
}
|
||||
case "@openrouter/ai-sdk-provider":
|
||||
@@ -34,7 +32,7 @@ export function map(packageName: string | undefined, settings: Readonly<Record<s
|
||||
settings: {
|
||||
...baseSettings,
|
||||
...mapAPIKey(settings),
|
||||
...mapXAIOptions(settings),
|
||||
...mapProviderOptions("xai", settings),
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -50,35 +48,6 @@ function mapAPIKey(settings: Readonly<Record<string, unknown>>) {
|
||||
return typeof settings.apiKey === "string" ? { apiKey: settings.apiKey } : {}
|
||||
}
|
||||
|
||||
function mapGoogleOptions(settings: Readonly<Record<string, unknown>>) {
|
||||
const input = settings.thinkingConfig
|
||||
const thinkingConfig = {
|
||||
...(isRecord(input) && typeof input.thinkingBudget === "number" ? { thinkingBudget: input.thinkingBudget } : {}),
|
||||
...(isRecord(input) && typeof input.includeThoughts === "boolean"
|
||||
? { includeThoughts: input.includeThoughts }
|
||||
: {}),
|
||||
...(isRecord(input) && typeof input.thinkingLevel === "string" ? { thinkingLevel: input.thinkingLevel } : {}),
|
||||
}
|
||||
const options = {
|
||||
...(typeof settings.cachedContent === "string" ? { cachedContent: settings.cachedContent } : {}),
|
||||
...(Array.isArray(settings.safetySettings) ? { safetySettings: settings.safetySettings } : {}),
|
||||
...(typeof settings.serviceTier === "string" ? { serviceTier: settings.serviceTier } : {}),
|
||||
...(Object.keys(thinkingConfig).length > 0 ? { thinkingConfig } : {}),
|
||||
}
|
||||
if (Object.keys(options).length === 0) return {}
|
||||
return { providerOptions: { gemini: options } }
|
||||
}
|
||||
|
||||
function mapXAIOptions(settings: Readonly<Record<string, unknown>>) {
|
||||
const options = {
|
||||
...(typeof settings.reasoningEffort === "string" ? { reasoningEffort: settings.reasoningEffort } : {}),
|
||||
...(typeof settings.store === "boolean" ? { store: settings.store } : {}),
|
||||
...(typeof settings.promptCacheKey === "string" ? { promptCacheKey: settings.promptCacheKey } : {}),
|
||||
}
|
||||
if (Object.keys(options).length === 0) return {}
|
||||
return { providerOptions: { xai: options } }
|
||||
}
|
||||
|
||||
function mapProviderOptions(namespace: string, settings: Readonly<Record<string, unknown>>) {
|
||||
const values = Object.fromEntries(
|
||||
Object.entries(settings).filter(
|
||||
|
||||
@@ -18,6 +18,7 @@ import { Credential } from "./credential"
|
||||
import { Integration } from "./integration"
|
||||
import { Capabilities, ID, Info, Ref, VariantID } from "./model"
|
||||
import { Npm } from "@opencode-ai/util/npm"
|
||||
import { OpenAICodex } from "./plugin/provider/openai-codex"
|
||||
import { Provider } from "./provider"
|
||||
|
||||
export class VariantUnavailableError extends Schema.TaggedErrorClass<VariantUnavailableError>()(
|
||||
@@ -152,7 +153,12 @@ export const fromCatalogModel = (
|
||||
const packageName = Provider.packageName(resolved.package)
|
||||
const key = apiKey(resolved, credential)
|
||||
|
||||
if (OpenAICodex.isChatGPT(credential) && !Provider.isAISDK(resolved.package) && isNativeOpenAI(resolved.package)) {
|
||||
return Effect.succeed(codexModel(resolved, credential, key))
|
||||
}
|
||||
|
||||
if (Provider.isAISDK(resolved.package) && packageName === "@ai-sdk/openai") {
|
||||
if (OpenAICodex.isChatGPT(credential)) return Effect.succeed(codexModel(resolved, credential, key))
|
||||
return Effect.succeed(
|
||||
withDefaults(resolved, OpenAIResponses.route)
|
||||
.with({ auth: key === undefined ? Auth.none : Auth.bearer(key) })
|
||||
@@ -221,6 +227,10 @@ export const fromCatalogModel = (
|
||||
})
|
||||
}
|
||||
|
||||
const isNativeOpenAI = (packageName: string | undefined) =>
|
||||
packageName === "@opencode-ai/ai/providers/openai" ||
|
||||
packageName?.startsWith("@opencode-ai/ai/providers/openai/") === true
|
||||
|
||||
const nativeCredentialSettings = (specifier: string, credential: Credential.Value | undefined) => {
|
||||
if (!credential) return {}
|
||||
if (credential.type === "key") return { apiKey: credential.key }
|
||||
@@ -242,6 +252,22 @@ const withoutNativeAuthSettings = (settings: Record<string, unknown>) => {
|
||||
return rest
|
||||
}
|
||||
|
||||
const codexModel = (
|
||||
model: Info,
|
||||
credential: Credential.Value | undefined,
|
||||
key: ReturnType<typeof Auth.value> | undefined,
|
||||
) => {
|
||||
const account = OpenAICodex.accountID(credential)
|
||||
return withDefaults(model, OpenAIResponses.route)
|
||||
.with({
|
||||
endpoint: { baseURL: OpenAICodex.baseURL },
|
||||
auth: (key === undefined ? Auth.none : Auth.bearer(key)).andThen(
|
||||
account === undefined ? Auth.none : Auth.headers({ "chatgpt-account-id": account }),
|
||||
),
|
||||
})
|
||||
.model({ id: model.modelID ?? model.id, compatibility: model.compatibility })
|
||||
}
|
||||
|
||||
const unsupported = (model: Info) =>
|
||||
new UnsupportedPackageError({
|
||||
providerID: model.providerID,
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
export * as OpenAICodex from "./openai-codex"
|
||||
|
||||
// TEMPORARY SEAM (#34765): plugins have no hook into LLM route construction, so
|
||||
// Codex routing lives in ModelResolver and catalog filtering.
|
||||
// in OpenAIPlugin, sharing this module. Once the native provider packages land
|
||||
// (#33689/#33925/#34462) this should collapse into the native OpenAI provider.
|
||||
// The eligibility rules mirror V1's CodexAuthPlugin allowlist; models.dev has no
|
||||
// plan-eligibility data for OpenAI today, but models other vendors' subscriptions
|
||||
// as dedicated providers (e.g. zai-coding-plan) - a future openai-chatgpt-plan
|
||||
// provider entry could replace the hardcoded rules with catalog data.
|
||||
|
||||
/** ChatGPT-plan requests must target the codex backend instead of the public API. */
|
||||
export const baseURL = "https://chatgpt.com/backend-api/codex"
|
||||
|
||||
const methodIDs: readonly string[] = ["chatgpt-browser", "chatgpt-headless"]
|
||||
|
||||
/** Structural credential shape so both core and plugin-facing credential types fit. */
|
||||
type CredentialLike = {
|
||||
readonly type: string
|
||||
readonly methodID?: string
|
||||
readonly metadata?: Record<string, unknown> | undefined
|
||||
}
|
||||
|
||||
export const isChatGPT = (credential: CredentialLike | undefined) =>
|
||||
credential?.type === "oauth" && credential.methodID !== undefined && methodIDs.includes(credential.methodID)
|
||||
|
||||
export const accountID = (credential: CredentialLike | undefined) => {
|
||||
if (!isChatGPT(credential)) return undefined
|
||||
const value = credential?.metadata?.accountID
|
||||
return typeof value === "string" ? value : undefined
|
||||
}
|
||||
|
||||
const allowed = new Set(["gpt-5.5", "gpt-5.3-codex-spark", "gpt-5.4", "gpt-5.4-mini"])
|
||||
const disallowed = new Set(["gpt-5.5-pro", "gpt-5.6"])
|
||||
|
||||
/** Which API model ids a ChatGPT subscription may call through the codex backend. */
|
||||
export const eligible = (apiID: string) => {
|
||||
if (allowed.has(apiID)) return true
|
||||
if (disallowed.has(apiID)) return false
|
||||
const match = apiID.match(/^gpt-(\d+\.\d+)/)
|
||||
return match ? Number.parseFloat(match[1]) > 5.4 : false
|
||||
}
|
||||
@@ -10,16 +10,14 @@ import { Model } from "../../model"
|
||||
import { OauthCallbackPage } from "../../oauth/page"
|
||||
import { Provider } from "../../provider"
|
||||
import type { PluginInternal } from "../internal"
|
||||
import { OpenAICodex } from "./openai-codex"
|
||||
|
||||
const clientID = "app_EMoamEEZ73f0CkXaXp7hrann"
|
||||
const issuer = "https://auth.openai.com"
|
||||
const callbackPort = 1455
|
||||
const pollingSafetyMargin = 3000
|
||||
const codexBaseURL = "https://chatgpt.com/backend-api/codex"
|
||||
const browserMethodID = Integration.MethodID.make("chatgpt-browser")
|
||||
const headlessMethodID = Integration.MethodID.make("chatgpt-headless")
|
||||
const codexAllowed = new Set(["gpt-5.5", "gpt-5.3-codex-spark", "gpt-5.4", "gpt-5.4-mini"])
|
||||
const codexDisallowed = new Set(["gpt-5.5-pro", "gpt-5.6"])
|
||||
|
||||
type Pkce = {
|
||||
verifier: string
|
||||
@@ -166,18 +164,14 @@ export const OpenAIPlugin = define({
|
||||
effect: Effect.fn(function* (ctx) {
|
||||
const bus = yield* Bus.Service
|
||||
const loading = Semaphore.makeUnsafe(1)
|
||||
let chatgpt: Credential.OAuth | undefined
|
||||
let chatgpt = false
|
||||
|
||||
const load = Effect.fn("OpenAIPlugin.load")(function* () {
|
||||
const connection = yield* ctx.integration.connection.active("openai")
|
||||
const credential = connection
|
||||
? yield* ctx.integration.connection.resolve(connection).pipe(Effect.catch(() => Effect.succeed(undefined)))
|
||||
: undefined
|
||||
chatgpt =
|
||||
credential?.type === "oauth" &&
|
||||
(credential.methodID === browserMethodID || credential.methodID === headlessMethodID)
|
||||
? credential
|
||||
: undefined
|
||||
chatgpt = OpenAICodex.isChatGPT(credential)
|
||||
})
|
||||
|
||||
yield* ctx.integration.transform((draft) => {
|
||||
@@ -189,19 +183,16 @@ export const OpenAIPlugin = define({
|
||||
for (const item of evt.provider.list()) {
|
||||
if (!Provider.isAISDK(item.provider.package)) continue
|
||||
if (Provider.packageName(item.provider.package) !== "@ai-sdk/openai") continue
|
||||
evt.provider.update(item.provider.id, (provider) => {
|
||||
provider.package = "@opencode-ai/ai/providers/openai"
|
||||
if (!item.models.has(Model.ID.make("gpt-5-chat-latest"))) continue
|
||||
evt.model.update(item.provider.id, Model.ID.make("gpt-5-chat-latest"), (model) => {
|
||||
// OpenAIPlugin sends OpenAI models through Responses; this alias is a
|
||||
// chat-completions-only model, so hide it only from OpenAI's catalog.
|
||||
model.enabled = false
|
||||
})
|
||||
}
|
||||
if (!chatgpt) return
|
||||
const item = evt.provider.get(Provider.ID.openai)
|
||||
if (!item) return
|
||||
item.provider.settings = Provider.mergeOverlay(item.provider.settings, { baseURL: codexBaseURL })
|
||||
const account = chatgpt.metadata?.accountID
|
||||
item.provider.headers = Provider.mergeHeaders(
|
||||
item.provider.headers,
|
||||
typeof account === "string" ? { "chatgpt-account-id": account } : undefined,
|
||||
)
|
||||
for (const model of item.models.values()) {
|
||||
// ChatGPT-plan tokens only authorize codex-eligible models, and the
|
||||
// subscription covers usage, so hide the rest and zero the cost.
|
||||
@@ -210,36 +201,14 @@ export const OpenAIPlugin = define({
|
||||
draft.enabled = false
|
||||
return
|
||||
}
|
||||
const apiID = draft.modelID ?? draft.id
|
||||
const match = apiID.match(/^gpt-(\d+\.\d+)/)
|
||||
if (
|
||||
!codexAllowed.has(apiID) &&
|
||||
(codexDisallowed.has(apiID) || !match || Number.parseFloat(match[1]) <= 5.4)
|
||||
) {
|
||||
if (!OpenAICodex.eligible(draft.modelID ?? draft.id)) {
|
||||
draft.enabled = false
|
||||
return
|
||||
}
|
||||
draft.cost = []
|
||||
if (draft.id.includes("gpt-5.5")) {
|
||||
draft.limit = { context: 400_000, input: 272_000, output: 128_000 }
|
||||
}
|
||||
if (draft.id.includes("gpt-5.6")) {
|
||||
draft.limit = { context: 500_000, input: 372_000, output: 128_000 }
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
yield* ctx.session.hook("request", (evt) =>
|
||||
Effect.sync(() => {
|
||||
if (!chatgpt || evt.model.providerID !== Provider.ID.openai) return
|
||||
const url = new URL(evt.url)
|
||||
if (url.origin === "https://api.openai.com") {
|
||||
evt.url = `${codexBaseURL}${url.pathname.replace(/^\/v1/, "")}${url.search}`
|
||||
}
|
||||
evt.headers.originator = "opencode"
|
||||
evt.headers["session-id"] = evt.sessionID
|
||||
}),
|
||||
)
|
||||
|
||||
const refresh = () => loading.withPermit(load().pipe(Effect.andThen(ctx.catalog.reload())))
|
||||
yield* bus.subscribe(Integration.Event.ConnectionUpdated).pipe(
|
||||
@@ -247,6 +216,21 @@ export const OpenAIPlugin = define({
|
||||
Stream.runForEach(refresh),
|
||||
Effect.forkScoped({ startImmediately: true }),
|
||||
)
|
||||
yield* ctx.aisdk.hook(
|
||||
"sdk",
|
||||
Effect.fn(function* (evt) {
|
||||
if (evt.package !== "@ai-sdk/openai") return
|
||||
const mod = yield* Effect.promise(() => import("@ai-sdk/openai"))
|
||||
evt.sdk = mod.createOpenAI(evt.options)
|
||||
}),
|
||||
)
|
||||
yield* ctx.aisdk.hook(
|
||||
"language",
|
||||
Effect.fn(function* (evt) {
|
||||
if (evt.model.providerID !== Provider.ID.openai) return
|
||||
evt.language = evt.sdk.responses(evt.model.modelID ?? evt.model.id)
|
||||
}),
|
||||
)
|
||||
}),
|
||||
} satisfies PluginInternal.InternalPlugin)
|
||||
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
export * as SessionModelRequest from "./model-request"
|
||||
|
||||
import { LLM, Message, SystemPart, type LLMRequest } from "@opencode-ai/ai"
|
||||
import type { StreamOptions } from "@opencode-ai/ai/route"
|
||||
import type { Content } from "@opencode-ai/schema/tool"
|
||||
import { SessionError } from "@opencode-ai/schema/session-error"
|
||||
import { Cause, Config, Context, Effect, Layer, Result } from "effect"
|
||||
@@ -38,7 +37,6 @@ const declineDefect = (cause: Cause.Cause<Tool.Error>) => {
|
||||
|
||||
interface Prepared {
|
||||
readonly request: LLMRequest
|
||||
readonly options: StreamOptions
|
||||
/**
|
||||
* One request-scoped execution operation. Unknown, hook-removed, and
|
||||
* step-limit-violating calls fail individually through the same seam.
|
||||
@@ -164,26 +162,6 @@ export const layer = Layer.effect(
|
||||
tools: hookedTools,
|
||||
toolChoice: stepLimitReached ? "none" : undefined,
|
||||
})
|
||||
const options: StreamOptions = {
|
||||
transform: (request) =>
|
||||
hooks
|
||||
.trigger("session", "request", {
|
||||
sessionID: session.id,
|
||||
agent: agent.id,
|
||||
model: resolved.ref,
|
||||
...request,
|
||||
})
|
||||
.pipe(
|
||||
Effect.tap((event) =>
|
||||
Effect.sync(() => {
|
||||
request.url = event.url
|
||||
request.headers = event.headers
|
||||
request.body = event.body
|
||||
}),
|
||||
),
|
||||
Effect.asVoid,
|
||||
),
|
||||
}
|
||||
if (promptCacheSnapshots) {
|
||||
const current = PromptCacheDiagnostics.snapshot(request)
|
||||
const comparison = PromptCacheDiagnostics.compare(promptCacheSnapshots.get(session.id), current)
|
||||
@@ -212,7 +190,6 @@ export const layer = Layer.effect(
|
||||
}
|
||||
return {
|
||||
request,
|
||||
options,
|
||||
executeTool,
|
||||
stepLimitReached,
|
||||
}
|
||||
|
||||
@@ -278,7 +278,7 @@ const layer = Layer.effect(
|
||||
// event durably, fork one fiber per local tool call, and hold back a virgin
|
||||
// context-overflow provider error so settlement may recover it via compaction.
|
||||
let overflowFailure: ProviderErrorEvent | undefined
|
||||
const providerStream = llm.stream(prepared.request, prepared.options).pipe(
|
||||
const providerStream = llm.stream(prepared.request).pipe(
|
||||
Stream.runForEach((event) =>
|
||||
Effect.gen(function* () {
|
||||
if (overflowFailure || publisher.hasProviderError()) return
|
||||
|
||||
@@ -1,106 +0,0 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { AISDKNative } from "@opencode-ai/core/aisdk-native"
|
||||
|
||||
describe("AISDKNative", () => {
|
||||
test("maps every Google thinking setting", () => {
|
||||
expect(
|
||||
AISDKNative.map("@ai-sdk/google", {
|
||||
cachedContent: "cachedContents/example",
|
||||
safetySettings: [{ category: "HARM_CATEGORY_HARASSMENT", threshold: "BLOCK_NONE" }],
|
||||
serviceTier: "flex",
|
||||
thinkingConfig: {
|
||||
thinkingBudget: 0,
|
||||
includeThoughts: false,
|
||||
thinkingLevel: "high",
|
||||
unknown: true,
|
||||
},
|
||||
}),
|
||||
).toEqual({
|
||||
package: "@opencode-ai/ai/providers/google",
|
||||
settings: {
|
||||
providerOptions: {
|
||||
gemini: {
|
||||
cachedContent: "cachedContents/example",
|
||||
safetySettings: [{ category: "HARM_CATEGORY_HARASSMENT", threshold: "BLOCK_NONE" }],
|
||||
serviceTier: "flex",
|
||||
thinkingConfig: {
|
||||
thinkingBudget: 0,
|
||||
includeThoughts: false,
|
||||
thinkingLevel: "high",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test("maps Google thinking settings independently", () => {
|
||||
for (const thinkingConfig of [{ thinkingBudget: -1 }, { includeThoughts: true }, { thinkingLevel: "medium" }]) {
|
||||
expect(AISDKNative.map("@ai-sdk/google", { thinkingConfig })).toMatchObject({
|
||||
settings: { providerOptions: { gemini: { thinkingConfig } } },
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
test("maps Google request options without thinking settings", () => {
|
||||
expect(
|
||||
AISDKNative.map("@ai-sdk/google", {
|
||||
cachedContent: "cachedContents/example",
|
||||
safetySettings: [{ category: "HARM_CATEGORY_HARASSMENT", threshold: "BLOCK_NONE" }],
|
||||
serviceTier: "future-tier",
|
||||
}),
|
||||
).toMatchObject({
|
||||
settings: {
|
||||
providerOptions: {
|
||||
gemini: {
|
||||
cachedContent: "cachedContents/example",
|
||||
safetySettings: [{ category: "HARM_CATEGORY_HARASSMENT", threshold: "BLOCK_NONE" }],
|
||||
serviceTier: "future-tier",
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test("maps supported xAI settings", () => {
|
||||
expect(
|
||||
AISDKNative.map("@ai-sdk/xai", {
|
||||
apiKey: "secret",
|
||||
baseURL: "https://xai.example/v1",
|
||||
reasoningEffort: "custom",
|
||||
store: true,
|
||||
promptCacheKey: "cache-key",
|
||||
}),
|
||||
).toEqual({
|
||||
package: "@opencode-ai/ai/providers/xai",
|
||||
settings: {
|
||||
apiKey: "secret",
|
||||
baseURL: "https://xai.example/v1",
|
||||
providerOptions: {
|
||||
xai: {
|
||||
reasoningEffort: "custom",
|
||||
store: true,
|
||||
promptCacheKey: "cache-key",
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test("omits invalid and unsupported xAI settings", () => {
|
||||
expect(
|
||||
AISDKNative.map("@ai-sdk/xai", {
|
||||
reasoningEffort: 10,
|
||||
store: "yes",
|
||||
include: ["unknown"],
|
||||
logprobs: true,
|
||||
topLogprobs: 8,
|
||||
previousResponseId: "response-id",
|
||||
searchParameters: { mode: "auto" },
|
||||
}),
|
||||
).toEqual({
|
||||
package: "@opencode-ai/ai/providers/xai",
|
||||
settings: {},
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -307,12 +307,12 @@ describe("ModelResolver", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("applies plugin-projected OpenAI endpoint and headers", () =>
|
||||
it.effect("routes ChatGPT OAuth credentials to the codex backend", () =>
|
||||
Effect.gen(function* () {
|
||||
const resolved = yield* ModelResolver.fromCatalogModel(
|
||||
model("@opencode-ai/ai/providers/openai", {
|
||||
settings: { baseURL: "https://chatgpt.com/backend-api/codex" },
|
||||
headers: { "chatgpt-account-id": "acct_123" },
|
||||
model(Provider.aisdk("@ai-sdk/openai"), {
|
||||
settings: { baseURL: "https://openai.example/v1" },
|
||||
headers: {},
|
||||
body: {},
|
||||
}),
|
||||
Credential.OAuth.make({
|
||||
@@ -337,8 +337,37 @@ describe("ModelResolver", () => {
|
||||
id: "openai-responses",
|
||||
endpoint: { baseURL: "https://chatgpt.com/backend-api/codex" },
|
||||
})
|
||||
expect(resolved.route.defaults.headers).toMatchObject({ "chatgpt-account-id": "acct_123" })
|
||||
expect(headers.authorization).toBe("Bearer chatgpt-token")
|
||||
expect(headers["chatgpt-account-id"]).toBe("acct_123")
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("routes native OpenAI provider packages with ChatGPT credentials to the codex backend", () =>
|
||||
Effect.gen(function* () {
|
||||
const resolved = yield* ModelResolver.fromCatalogModel(
|
||||
model("@opencode-ai/ai/providers/openai", {
|
||||
settings: { baseURL: "https://openai.example/v1" },
|
||||
}),
|
||||
Credential.OAuth.make({
|
||||
type: "oauth",
|
||||
methodID: Integration.MethodID.make("chatgpt-browser"),
|
||||
access: "chatgpt-token",
|
||||
refresh: "refresh",
|
||||
expires: Date.now() + 60_000,
|
||||
metadata: { accountID: "acct_123" },
|
||||
}),
|
||||
)
|
||||
const headers = yield* resolved.route.auth.apply({
|
||||
request: LLM.request({ model: resolved, prompt: "Hello" }),
|
||||
method: "POST",
|
||||
url: "https://chatgpt.com/backend-api/codex/responses",
|
||||
body: "{}",
|
||||
headers: Headers.empty,
|
||||
})
|
||||
|
||||
expect(resolved.route.endpoint.baseURL).toBe("https://chatgpt.com/backend-api/codex")
|
||||
expect(headers.authorization).toBe("Bearer chatgpt-token")
|
||||
expect(headers["chatgpt-account-id"]).toBe("acct_123")
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -378,6 +407,37 @@ describe("ModelResolver", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("routes ChatGPT OAuth credentials without an account id to the codex backend", () =>
|
||||
Effect.gen(function* () {
|
||||
const resolved = yield* ModelResolver.fromCatalogModel(
|
||||
model(Provider.aisdk("@ai-sdk/openai"), {
|
||||
settings: { baseURL: "https://openai.example/v1" },
|
||||
headers: {},
|
||||
body: {},
|
||||
}),
|
||||
Credential.OAuth.make({
|
||||
type: "oauth",
|
||||
methodID: Integration.MethodID.make("chatgpt-headless"),
|
||||
access: "chatgpt-token",
|
||||
refresh: "refresh",
|
||||
expires: Date.now() + 60_000,
|
||||
}),
|
||||
)
|
||||
const request = LLM.request({ model: resolved, prompt: "Hello" })
|
||||
const headers = yield* resolved.route.auth.apply({
|
||||
request,
|
||||
method: "POST",
|
||||
url: "https://chatgpt.com/backend-api/codex/responses",
|
||||
body: "{}",
|
||||
headers: Headers.empty,
|
||||
})
|
||||
|
||||
expect(resolved.route.endpoint.baseURL).toBe("https://chatgpt.com/backend-api/codex")
|
||||
expect(headers.authorization).toBe("Bearer chatgpt-token")
|
||||
expect(headers["chatgpt-account-id"]).toBeUndefined()
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("keeps non-ChatGPT OAuth credentials on the configured endpoint", () =>
|
||||
Effect.gen(function* () {
|
||||
const resolved = yield* ModelResolver.fromCatalogModel(
|
||||
@@ -490,31 +550,16 @@ describe("ModelResolver", () => {
|
||||
Effect.gen(function* () {
|
||||
const native = yield* ModelResolver.fromCatalogModel(model(Provider.aisdk("@ai-sdk/openai")))
|
||||
const packages = [
|
||||
[
|
||||
"@ai-sdk/google",
|
||||
"@opencode-ai/ai/providers/google",
|
||||
{ thinkingConfig: { thinkingLevel: "high" } },
|
||||
{ gemini: { thinkingConfig: { thinkingLevel: "high" } } },
|
||||
],
|
||||
[
|
||||
"@openrouter/ai-sdk-provider",
|
||||
"@opencode-ai/ai/providers/openrouter",
|
||||
{ reasoning: { effort: "high" } },
|
||||
{ openrouter: { reasoning: { effort: "high" } } },
|
||||
],
|
||||
[
|
||||
"@ai-sdk/xai",
|
||||
"@opencode-ai/ai/providers/xai",
|
||||
{ reasoningEffort: "high" },
|
||||
{ xai: { reasoningEffort: "high" } },
|
||||
],
|
||||
["@ai-sdk/google", "@opencode-ai/ai/providers/google", "gemini"],
|
||||
["@openrouter/ai-sdk-provider", "@opencode-ai/ai/providers/openrouter", "openrouter"],
|
||||
["@ai-sdk/xai", "@opencode-ai/ai/providers/xai", "xai"],
|
||||
] as const
|
||||
|
||||
yield* Effect.forEach(packages, ([catalogPackage, nativePackage, sourceOptions, providerOptions]) =>
|
||||
yield* Effect.forEach(packages, ([catalogPackage, nativePackage, optionKey]) =>
|
||||
ModelResolver.fromCatalogModel(
|
||||
model(Provider.aisdk(catalogPackage), {
|
||||
modelID: "api-model",
|
||||
settings: { baseURL: "https://provider.example/v1", ...sourceOptions },
|
||||
settings: { baseURL: "https://provider.example/v1", reasoningEffort: "high" },
|
||||
headers: { "x-provider": "header" },
|
||||
body: { custom: true },
|
||||
}),
|
||||
@@ -531,7 +576,7 @@ describe("ModelResolver", () => {
|
||||
headers: { "x-provider": "header" },
|
||||
body: { custom: true },
|
||||
limits: { context: 100, output: 20 },
|
||||
providerOptions,
|
||||
providerOptions: { [optionKey]: { reasoningEffort: "high" } },
|
||||
})
|
||||
return Model.make({ id: modelID, provider: "native-provider", route: native.route })
|
||||
},
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { AISDK } from "@opencode-ai/core/aisdk"
|
||||
import { Money } from "@opencode-ai/schema/money"
|
||||
import { Agent } from "@opencode-ai/schema/agent"
|
||||
import { Session } from "@opencode-ai/schema/session"
|
||||
import { describe, expect } from "bun:test"
|
||||
import type { LanguageModelV3 } from "@ai-sdk/provider"
|
||||
import { Effect } from "effect"
|
||||
import { Catalog } from "@opencode-ai/core/catalog"
|
||||
import { Credential } from "@opencode-ai/core/credential"
|
||||
@@ -9,7 +9,6 @@ import { Integration } from "@opencode-ai/core/integration"
|
||||
import { Model } from "@opencode-ai/core/model"
|
||||
import { Plugin } from "@opencode-ai/core/plugin"
|
||||
import { PluginHost } from "@opencode-ai/core/plugin/host"
|
||||
import { PluginHooks } from "@opencode-ai/core/plugin/hooks"
|
||||
import { OpenAIPlugin } from "@opencode-ai/core/plugin/provider/openai"
|
||||
import { Provider } from "@opencode-ai/core/provider"
|
||||
import { testEffect } from "../lib/effect"
|
||||
@@ -19,6 +18,7 @@ const it = testEffect(PluginTestLayer)
|
||||
|
||||
const addPlugin = Effect.fn(function* () {
|
||||
const plugin = yield* Plugin.Service
|
||||
const aisdk = yield* AISDK.Service
|
||||
const host = yield* PluginHost.make(plugin)
|
||||
const integrations = yield* Integration.Service
|
||||
yield* OpenAIPlugin.effect(host).pipe(Effect.provideService(Integration.Service, integrations))
|
||||
@@ -29,6 +29,19 @@ function required<T>(value: T | undefined): T {
|
||||
return value
|
||||
}
|
||||
|
||||
function fakeSelectorSdk(calls: string[]) {
|
||||
const make = (method: string) => (id: string) => {
|
||||
calls.push(`${method}:${id}`)
|
||||
return { modelId: id, provider: method, specificationVersion: "v3" } as unknown as LanguageModelV3
|
||||
}
|
||||
return {
|
||||
responses: make("responses"),
|
||||
messages: make("messages"),
|
||||
chat: make("chat"),
|
||||
languageModel: make("languageModel"),
|
||||
}
|
||||
}
|
||||
|
||||
describe("OpenAIPlugin", () => {
|
||||
it.effect("registers browser and headless ChatGPT OAuth methods", () =>
|
||||
Effect.gen(function* () {
|
||||
@@ -48,6 +61,104 @@ describe("OpenAIPlugin", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("creates an OpenAI SDK for @ai-sdk/openai using the provider ID as SDK name", () =>
|
||||
Effect.gen(function* () {
|
||||
const plugin = yield* Plugin.Service
|
||||
const aisdk = yield* AISDK.Service
|
||||
yield* addPlugin()
|
||||
const result = yield* aisdk.runSDK({
|
||||
model: Model.Info.make({
|
||||
...Model.Info.default(Provider.ID.make("custom-openai"), Model.ID.make("gpt-5")),
|
||||
modelID: Model.ID.make("gpt-5"),
|
||||
package: Provider.aisdk("test-provider"),
|
||||
}),
|
||||
package: "@ai-sdk/openai",
|
||||
options: { name: "custom-openai", apiKey: "test" },
|
||||
})
|
||||
expect(result.sdk?.responses("gpt-5").provider).toBe("custom-openai.responses")
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("ignores non-OpenAI SDK packages", () =>
|
||||
Effect.gen(function* () {
|
||||
const plugin = yield* Plugin.Service
|
||||
const aisdk = yield* AISDK.Service
|
||||
yield* addPlugin()
|
||||
const result = yield* aisdk.runSDK({
|
||||
model: Model.Info.make({
|
||||
...Model.Info.default(Provider.ID.openai, Model.ID.make("gpt-5")),
|
||||
modelID: Model.ID.make("gpt-5"),
|
||||
package: Provider.aisdk("test-provider"),
|
||||
}),
|
||||
package: "@ai-sdk/openai-compatible",
|
||||
options: { name: "openai" },
|
||||
})
|
||||
expect(result.sdk).toBeUndefined()
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("uses the Responses API for language models", () =>
|
||||
Effect.gen(function* () {
|
||||
const plugin = yield* Plugin.Service
|
||||
const aisdk = yield* AISDK.Service
|
||||
const calls: string[] = []
|
||||
yield* addPlugin()
|
||||
const result = yield* aisdk.runLanguage({
|
||||
model: Model.Info.make({
|
||||
...Model.Info.default(Provider.ID.openai, Model.ID.make("alias")),
|
||||
modelID: Model.ID.make("gpt-5"),
|
||||
package: Provider.aisdk("test-provider"),
|
||||
}),
|
||||
sdk: fakeSelectorSdk(calls),
|
||||
options: {},
|
||||
})
|
||||
expect(calls).toEqual(["responses:gpt-5"])
|
||||
expect(result.language).toBeDefined()
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("ignores non-OpenAI providers", () =>
|
||||
Effect.gen(function* () {
|
||||
const plugin = yield* Plugin.Service
|
||||
const aisdk = yield* AISDK.Service
|
||||
const calls: string[] = []
|
||||
yield* addPlugin()
|
||||
const result = yield* aisdk.runLanguage({
|
||||
model: Model.Info.make({
|
||||
...Model.Info.default(Provider.ID.anthropic, Model.ID.make("gpt-5")),
|
||||
modelID: Model.ID.make("gpt-5"),
|
||||
package: Provider.aisdk("test-provider"),
|
||||
}),
|
||||
sdk: fakeSelectorSdk(calls),
|
||||
options: {},
|
||||
})
|
||||
expect(calls).toEqual([])
|
||||
expect(result.language).toBeUndefined()
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("disables gpt-5-chat-latest during catalog transforms", () =>
|
||||
Effect.gen(function* () {
|
||||
const catalog = yield* Catalog.Service
|
||||
yield* catalog.transform((catalog) => {
|
||||
const item = Provider.Info.make({
|
||||
...Provider.Info.empty(Provider.ID.openai),
|
||||
package: Provider.aisdk("@ai-sdk/openai"),
|
||||
})
|
||||
catalog.provider.update(item.id, (draft) => {
|
||||
draft.package = item.package
|
||||
})
|
||||
catalog.model.update(item.id, Model.ID.make("gpt-5"), () => {})
|
||||
catalog.model.update(item.id, Model.ID.make("gpt-5-chat-latest"), () => {})
|
||||
})
|
||||
yield* addPlugin()
|
||||
expect(required(yield* catalog.model.get(Provider.ID.openai, Model.ID.make("gpt-5"))).enabled).toBe(true)
|
||||
expect(
|
||||
required(yield* catalog.model.get(Provider.ID.openai, Model.ID.make("gpt-5-chat-latest"))).enabled,
|
||||
).toBe(false)
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("filters the OpenAI catalog to codex-eligible models under a ChatGPT connection", () =>
|
||||
Effect.gen(function* () {
|
||||
const catalog = yield* Catalog.Service
|
||||
@@ -61,7 +172,6 @@ describe("OpenAIPlugin", () => {
|
||||
draft.package = item.package
|
||||
})
|
||||
catalog.model.update(item.id, Model.ID.make("gpt-5.5"), (model) => {
|
||||
model.limit = { context: 1_050_000, input: 922_000, output: 128_000 }
|
||||
model.cost = [
|
||||
{
|
||||
input: Money.USDPerMillionTokens.make(1),
|
||||
@@ -79,9 +189,7 @@ describe("OpenAIPlugin", () => {
|
||||
model.body = { reasoning: { mode: "pro" } }
|
||||
})
|
||||
catalog.model.update(item.id, Model.ID.make("gpt-5.6"), () => {})
|
||||
catalog.model.update(item.id, Model.ID.make("gpt-5.6-sol"), (model) => {
|
||||
model.limit = { context: 1_050_000, input: 922_000, output: 128_000 }
|
||||
})
|
||||
catalog.model.update(item.id, Model.ID.make("gpt-5.6-sol"), () => {})
|
||||
catalog.model.update(item.id, Model.ID.make("gpt-4.1"), () => {})
|
||||
})
|
||||
yield* credentials.create({
|
||||
@@ -97,47 +205,8 @@ describe("OpenAIPlugin", () => {
|
||||
})
|
||||
yield* addPlugin()
|
||||
|
||||
const request = yield* (yield* PluginHooks.Service).trigger("session", "request", {
|
||||
sessionID: Session.ID.make("ses_test"),
|
||||
agent: Agent.ID.make("build"),
|
||||
model: Model.Ref.make({ providerID: Provider.ID.openai, id: Model.ID.make("gpt-5.5") }),
|
||||
url: "https://api.openai.com/v1/responses",
|
||||
method: "POST",
|
||||
headers: {},
|
||||
body: "{}",
|
||||
})
|
||||
const custom = yield* (yield* PluginHooks.Service).trigger("session", "request", {
|
||||
sessionID: Session.ID.make("ses_test"),
|
||||
agent: Agent.ID.make("build"),
|
||||
model: Model.Ref.make({ providerID: Provider.ID.make("custom-openai"), id: Model.ID.make("gpt-5.5") }),
|
||||
url: "https://custom.example/v1/responses",
|
||||
method: "POST",
|
||||
headers: {},
|
||||
body: "{}",
|
||||
})
|
||||
const proxy = yield* (yield* PluginHooks.Service).trigger("session", "request", {
|
||||
sessionID: Session.ID.make("ses_test"),
|
||||
agent: Agent.ID.make("build"),
|
||||
model: Model.Ref.make({ providerID: Provider.ID.openai, id: Model.ID.make("gpt-5.5") }),
|
||||
url: "https://proxy.example/v1/responses?region=us",
|
||||
method: "POST",
|
||||
headers: {},
|
||||
body: "{}",
|
||||
})
|
||||
|
||||
const provider = required(yield* catalog.provider.get(Provider.ID.openai))
|
||||
expect(provider.package).toBe("@opencode-ai/ai/providers/openai")
|
||||
expect(provider.settings).toMatchObject({ baseURL: "https://chatgpt.com/backend-api/codex" })
|
||||
expect(provider.headers).toMatchObject({ "chatgpt-account-id": "acct_123" })
|
||||
expect(request.url).toBe("https://chatgpt.com/backend-api/codex/responses")
|
||||
expect(request.headers).toMatchObject({ originator: "opencode", "session-id": "ses_test" })
|
||||
expect(custom.headers).toEqual({})
|
||||
expect(proxy.url).toBe("https://proxy.example/v1/responses?region=us")
|
||||
expect(proxy.headers).toMatchObject({ originator: "opencode", "session-id": "ses_test" })
|
||||
const eligible = required(yield* catalog.model.get(Provider.ID.openai, Model.ID.make("gpt-5.5")))
|
||||
expect(eligible.package).toBe("@opencode-ai/ai/providers/openai")
|
||||
expect(eligible.cost).toEqual([])
|
||||
expect(eligible.limit).toEqual({ context: 400_000, input: 272_000, output: 128_000 })
|
||||
expect(eligible.enabled).toBe(true)
|
||||
expect(required(yield* catalog.model.get(Provider.ID.openai, Model.ID.make("gpt-5.5-pro"))).enabled).toBe(
|
||||
false,
|
||||
@@ -146,9 +215,9 @@ describe("OpenAIPlugin", () => {
|
||||
false,
|
||||
)
|
||||
expect(required(yield* catalog.model.get(Provider.ID.openai, Model.ID.make("gpt-5.6"))).enabled).toBe(false)
|
||||
const gpt56 = required(yield* catalog.model.get(Provider.ID.openai, Model.ID.make("gpt-5.6-sol")))
|
||||
expect(gpt56.enabled).toBe(true)
|
||||
expect(gpt56.limit).toEqual({ context: 500_000, input: 372_000, output: 128_000 })
|
||||
expect(required(yield* catalog.model.get(Provider.ID.openai, Model.ID.make("gpt-5.6-sol"))).enabled).toBe(
|
||||
true,
|
||||
)
|
||||
expect(required(yield* catalog.model.get(Provider.ID.openai, Model.ID.make("gpt-4.1"))).enabled).toBe(false)
|
||||
}),
|
||||
)
|
||||
@@ -165,9 +234,7 @@ describe("OpenAIPlugin", () => {
|
||||
catalog.provider.update(item.id, (draft) => {
|
||||
draft.package = item.package
|
||||
})
|
||||
catalog.model.update(item.id, Model.ID.make("gpt-5.5"), (model) => {
|
||||
model.limit = { context: 1_050_000, input: 922_000, output: 128_000 }
|
||||
})
|
||||
catalog.model.update(item.id, Model.ID.make("gpt-5.5"), () => {})
|
||||
catalog.model.update(item.id, Model.ID.make("gpt-4.1"), () => {})
|
||||
})
|
||||
yield* credentials.create({
|
||||
@@ -176,23 +243,29 @@ describe("OpenAIPlugin", () => {
|
||||
})
|
||||
yield* addPlugin()
|
||||
|
||||
const request = yield* (yield* PluginHooks.Service).trigger("session", "request", {
|
||||
sessionID: Session.ID.make("ses_test"),
|
||||
agent: Agent.ID.make("build"),
|
||||
model: Model.Ref.make({ providerID: Provider.ID.openai, id: Model.ID.make("gpt-5.5") }),
|
||||
url: "https://api.openai.com/v1/responses",
|
||||
method: "POST",
|
||||
headers: {},
|
||||
body: "{}",
|
||||
})
|
||||
|
||||
const model = required(yield* catalog.model.get(Provider.ID.openai, Model.ID.make("gpt-5.5")))
|
||||
expect(model.package).toBe("@opencode-ai/ai/providers/openai")
|
||||
expect(model.enabled).toBe(true)
|
||||
expect(model.limit).toEqual({ context: 1_050_000, input: 922_000, output: 128_000 })
|
||||
expect(request.headers).toEqual({})
|
||||
expect(required(yield* catalog.model.get(Provider.ID.openai, Model.ID.make("gpt-5.5"))).enabled).toBe(true)
|
||||
expect(required(yield* catalog.model.get(Provider.ID.openai, Model.ID.make("gpt-4.1"))).enabled).toBe(true)
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("does not disable gpt-5-chat-latest for non-OpenAI providers", () =>
|
||||
Effect.gen(function* () {
|
||||
const catalog = yield* Catalog.Service
|
||||
yield* catalog.transform((catalog) => {
|
||||
const item = Provider.Info.make({
|
||||
...Provider.Info.empty(Provider.ID.make("custom-openai")),
|
||||
package: Provider.aisdk("test-provider"),
|
||||
})
|
||||
catalog.provider.update(item.id, (draft) => {
|
||||
draft.package = item.package
|
||||
})
|
||||
catalog.model.update(item.id, Model.ID.make("gpt-5-chat-latest"), () => {})
|
||||
})
|
||||
yield* addPlugin()
|
||||
expect(
|
||||
required(yield* catalog.model.get(Provider.ID.make("custom-openai"), Model.ID.make("gpt-5-chat-latest")))
|
||||
.enabled,
|
||||
).toBe(true)
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
@@ -31,9 +31,9 @@
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@opencode-ai/theme": "workspace:*",
|
||||
"@opentui/core": "0.0.0-20260731-669d7f5c",
|
||||
"@opentui/keymap": "0.0.0-20260731-669d7f5c",
|
||||
"@opentui/solid": "0.0.0-20260731-669d7f5c",
|
||||
"@opentui/core": ">=0.4.5",
|
||||
"@opentui/keymap": ">=0.4.5",
|
||||
"@opentui/solid": ">=0.4.5",
|
||||
"solid-js": ">=1.9.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import type { SessionApi } from "@opencode-ai/client/effect/api"
|
||||
import type { Message, SystemPart } from "@opencode-ai/ai"
|
||||
import type { HttpRequest } from "@opencode-ai/ai/route"
|
||||
import type { Agent } from "@opencode-ai/schema/agent"
|
||||
import type { Model } from "@opencode-ai/schema/model"
|
||||
import type { Session } from "@opencode-ai/schema/session"
|
||||
@@ -16,15 +15,8 @@ export interface SessionContext {
|
||||
tools: Record<string, { description: string; input: JsonSchema.JsonSchema }>
|
||||
}
|
||||
|
||||
export interface SessionRequest extends HttpRequest {
|
||||
readonly sessionID: Session.ID
|
||||
readonly agent: Agent.ID
|
||||
readonly model: Model.Ref
|
||||
}
|
||||
|
||||
export interface SessionHooks {
|
||||
readonly context: SessionContext
|
||||
readonly request: SessionRequest
|
||||
}
|
||||
|
||||
export type SessionDomain = Pick<
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import type { SessionApi } from "@opencode-ai/client/promise/api"
|
||||
import type { Message, SystemPart } from "@opencode-ai/ai"
|
||||
import type { HttpRequest } from "@opencode-ai/ai/route"
|
||||
import type { Agent } from "@opencode-ai/schema/agent"
|
||||
import type { Model } from "@opencode-ai/schema/model"
|
||||
import type { Session } from "@opencode-ai/schema/session"
|
||||
@@ -16,15 +15,8 @@ export interface SessionContext {
|
||||
tools: Record<string, { description: string; input: JsonSchema.JsonSchema }>
|
||||
}
|
||||
|
||||
export interface SessionRequest extends HttpRequest {
|
||||
readonly sessionID: Session.ID
|
||||
readonly agent: Agent.ID
|
||||
readonly model: Model.Ref
|
||||
}
|
||||
|
||||
export interface SessionHooks {
|
||||
readonly context: SessionContext
|
||||
readonly request: SessionRequest
|
||||
}
|
||||
|
||||
export type SessionDomain = Pick<
|
||||
|
||||
@@ -92,6 +92,7 @@
|
||||
"@opentui/keymap": "catalog:",
|
||||
"@opentui/solid": "catalog:",
|
||||
"@solid-primitives/event-bus": "1.1.2",
|
||||
"clipboardy": "4.0.0",
|
||||
"diff": "catalog:",
|
||||
"effect": "catalog:",
|
||||
"fuzzysort": "catalog:",
|
||||
|
||||
+52
-62
@@ -92,7 +92,6 @@ import { destroyRenderer } from "./util/renderer"
|
||||
import { cliErrorMessage, errorFormat } from "./util/error"
|
||||
import { AttentionProvider } from "./context/attention"
|
||||
import { StorageProvider } from "./context/storage"
|
||||
import { createTuiClipboard, formatClipboardWriteNotification } from "./clipboard"
|
||||
|
||||
registerOpencodeSpinner()
|
||||
|
||||
@@ -259,13 +258,6 @@ export const run = Effect.fn("Tui.run")(function* (input: TuiInput) {
|
||||
(renderer) => Effect.sync(() => destroyRenderer(renderer)),
|
||||
)
|
||||
})
|
||||
const clipboard = yield* Effect.acquireRelease(
|
||||
Effect.sync(() => createTuiClipboard(renderer)),
|
||||
(clipboard) =>
|
||||
Effect.tryPromise(() => clipboard.dispose()).pipe(
|
||||
Effect.catch((error) => Effect.sync(() => log("error", "Failed to dispose TUI clipboard", { error }))),
|
||||
),
|
||||
)
|
||||
win32DisableProcessedInput()
|
||||
const finalizers = new Set<() => Promise<void>>()
|
||||
yield* Effect.addFinalizer(() =>
|
||||
@@ -301,57 +293,57 @@ export const run = Effect.fn("Tui.run")(function* (input: TuiInput) {
|
||||
>
|
||||
<EpilogueProvider set={(value) => (exit.epilogue = value)}>
|
||||
<TuiAppProvider value={input.app}>
|
||||
<ClipboardProvider value={clipboard}>
|
||||
<ErrorBoundary
|
||||
fallback={(error, reset) => <ErrorComponent error={error} reset={reset} mode={mode} />}
|
||||
<ErrorBoundary
|
||||
fallback={(error, reset) => <ErrorComponent error={error} reset={reset} mode={mode} />}
|
||||
>
|
||||
<TuiPathsProvider
|
||||
value={{
|
||||
cwd: process.cwd(),
|
||||
home: global.home,
|
||||
state: global.state,
|
||||
worktree: global.data + "/worktree",
|
||||
}}
|
||||
>
|
||||
<TuiPathsProvider
|
||||
value={{
|
||||
cwd: process.cwd(),
|
||||
home: global.home,
|
||||
state: global.state,
|
||||
worktree: global.data + "/worktree",
|
||||
}}
|
||||
>
|
||||
<StorageProvider>
|
||||
<TuiLifecycleProvider
|
||||
<StorageProvider>
|
||||
<TuiLifecycleProvider
|
||||
value={{
|
||||
add(finalizer) {
|
||||
finalizers.add(finalizer)
|
||||
return () => finalizers.delete(finalizer)
|
||||
},
|
||||
}}
|
||||
>
|
||||
<TuiTerminalEnvironmentProvider
|
||||
value={{
|
||||
add(finalizer) {
|
||||
finalizers.add(finalizer)
|
||||
return () => finalizers.delete(finalizer)
|
||||
},
|
||||
platform: process.platform,
|
||||
multiplexer: process.env.TMUX ? "tmux" : process.env.STY ? "screen" : undefined,
|
||||
displayServer: process.env.WAYLAND_DISPLAY
|
||||
? "wayland"
|
||||
: process.env.DISPLAY
|
||||
? "x11"
|
||||
: undefined,
|
||||
}}
|
||||
>
|
||||
<TuiTerminalEnvironmentProvider
|
||||
<TuiStartupProvider
|
||||
value={{
|
||||
platform: process.platform,
|
||||
multiplexer: process.env.TMUX ? "tmux" : process.env.STY ? "screen" : undefined,
|
||||
displayServer: process.env.WAYLAND_DISPLAY
|
||||
? "wayland"
|
||||
: process.env.DISPLAY
|
||||
? "x11"
|
||||
initialRoute: process.env.OPENCODE_STORY
|
||||
? {
|
||||
type: "plugin",
|
||||
id: "opencode.storybook",
|
||||
name: "storybook",
|
||||
// OPENCODE_STORY=1 opens the index; any other value opens that story.
|
||||
data:
|
||||
process.env.OPENCODE_STORY === "1"
|
||||
? undefined
|
||||
: { story: process.env.OPENCODE_STORY },
|
||||
}
|
||||
: process.env.OPENCODE_ROUTE
|
||||
? JSON.parse(process.env.OPENCODE_ROUTE)
|
||||
: undefined,
|
||||
skipInitialLoading: Boolean(process.env.OPENCODE_FAST_BOOT),
|
||||
}}
|
||||
>
|
||||
<TuiStartupProvider
|
||||
value={{
|
||||
initialRoute: process.env.OPENCODE_STORY
|
||||
? {
|
||||
type: "plugin",
|
||||
id: "opencode.storybook",
|
||||
name: "storybook",
|
||||
// OPENCODE_STORY=1 opens the index; any other value opens that story.
|
||||
data:
|
||||
process.env.OPENCODE_STORY === "1"
|
||||
? undefined
|
||||
: { story: process.env.OPENCODE_STORY },
|
||||
}
|
||||
: process.env.OPENCODE_ROUTE
|
||||
? JSON.parse(process.env.OPENCODE_ROUTE)
|
||||
: undefined,
|
||||
skipInitialLoading: Boolean(process.env.OPENCODE_FAST_BOOT),
|
||||
}}
|
||||
>
|
||||
<ClipboardProvider>
|
||||
<ArgsProvider {...input.args}>
|
||||
<ConfigProvider
|
||||
config={config}
|
||||
@@ -416,13 +408,13 @@ export const run = Effect.fn("Tui.run")(function* (input: TuiInput) {
|
||||
</Keymap.Provider>
|
||||
</ConfigProvider>
|
||||
</ArgsProvider>
|
||||
</TuiStartupProvider>
|
||||
</TuiTerminalEnvironmentProvider>
|
||||
</TuiLifecycleProvider>
|
||||
</StorageProvider>
|
||||
</TuiPathsProvider>
|
||||
</ErrorBoundary>
|
||||
</ClipboardProvider>
|
||||
</ClipboardProvider>
|
||||
</TuiStartupProvider>
|
||||
</TuiTerminalEnvironmentProvider>
|
||||
</TuiLifecycleProvider>
|
||||
</StorageProvider>
|
||||
</TuiPathsProvider>
|
||||
</ErrorBoundary>
|
||||
</TuiAppProvider>
|
||||
</EpilogueProvider>
|
||||
</ExitProvider>
|
||||
@@ -517,10 +509,8 @@ function App(props: { pair?: DialogPairCredentials }) {
|
||||
if (!text || text.length === 0) return
|
||||
|
||||
await clipboard
|
||||
.write(text)
|
||||
.then((outcome) =>
|
||||
toast.show(formatClipboardWriteNotification(outcome, { message: "Copied to clipboard", variant: "info" })),
|
||||
)
|
||||
.write?.(text)
|
||||
.then(() => toast.show({ message: "Copied to clipboard", variant: "info" }))
|
||||
.catch(toast.error)
|
||||
|
||||
renderer.clearSelection()
|
||||
|
||||
+109
-121
@@ -1,136 +1,124 @@
|
||||
import {
|
||||
createClipboard,
|
||||
createHostClipboard,
|
||||
createRendererClipboardAdapter,
|
||||
decodePasteBytes,
|
||||
type ClipboardService as CoreClipboardService,
|
||||
type ClipboardWriteResult,
|
||||
type RendererClipboardBoundary,
|
||||
} from "@opentui/core"
|
||||
import type { ClipboardService, ClipboardWriteOutcome } from "./context/clipboard"
|
||||
import { execFile, spawn } from "node:child_process"
|
||||
import { readFile, rm } from "node:fs/promises"
|
||||
import { platform, release, tmpdir } from "node:os"
|
||||
import path from "node:path"
|
||||
import { promisify } from "node:util"
|
||||
|
||||
const timeoutMs = 1_000
|
||||
const maxReadBytes = 8 * 1024 * 1024
|
||||
const exec = promisify(execFile)
|
||||
|
||||
export type ClipboardNotification = Readonly<{
|
||||
message: string
|
||||
variant: "info" | "success" | "warning"
|
||||
}>
|
||||
function command(command: string, args: string[] = [], input?: string) {
|
||||
return new Promise<Buffer>((resolve, reject) => {
|
||||
const child = spawn(command, args, { stdio: [input === undefined ? "ignore" : "pipe", "pipe", "ignore"] })
|
||||
const output: Buffer[] = []
|
||||
child.on("error", reject)
|
||||
child.stdout?.on("data", (chunk: Buffer) => output.push(chunk))
|
||||
child.on("close", (code) => {
|
||||
if (code === 0) return resolve(Buffer.concat(output))
|
||||
reject(new Error(`${command} exited with code ${code}`))
|
||||
})
|
||||
if (input !== undefined) child.stdin?.end(input)
|
||||
})
|
||||
}
|
||||
|
||||
export type ClipboardCopyState =
|
||||
| "idle"
|
||||
| "confirmed"
|
||||
| "confirmed-partial"
|
||||
| "attempted"
|
||||
| "attempted-partial"
|
||||
| "failed"
|
||||
function writeOsc52(text: string) {
|
||||
if (!process.stdout.isTTY) return
|
||||
const sequence = `\x1b]52;c;${Buffer.from(text).toString("base64")}\x07`
|
||||
process.stdout.write(process.env.TMUX || process.env.STY ? `\x1bPtmux;\x1b${sequence}\x1b\\` : sequence)
|
||||
}
|
||||
|
||||
export type OwnedClipboardService = ClipboardService & Readonly<{ dispose(): Promise<void> }>
|
||||
export async function read() {
|
||||
if (platform() === "darwin") {
|
||||
const file = path.join(tmpdir(), "opencode-clipboard.png")
|
||||
try {
|
||||
await exec("osascript", [
|
||||
"-e",
|
||||
'set imageData to the clipboard as "PNGf"',
|
||||
"-e",
|
||||
`set fileRef to open for access POSIX file "${file}" with write permission`,
|
||||
"-e",
|
||||
"set eof fileRef to 0",
|
||||
"-e",
|
||||
"write imageData to fileRef",
|
||||
"-e",
|
||||
"close access fileRef",
|
||||
])
|
||||
return { data: (await readFile(file)).toString("base64"), mime: "image/png" }
|
||||
} catch {
|
||||
// Fall through to text clipboard.
|
||||
} finally {
|
||||
await rm(file, { force: true }).catch(() => {})
|
||||
}
|
||||
}
|
||||
|
||||
export class ClipboardWriteError extends Error {
|
||||
readonly result: ClipboardWriteResult
|
||||
|
||||
constructor(result: ClipboardWriteResult) {
|
||||
super(
|
||||
`Clipboard write failed (host: ${result.host.status}, terminal: ${result.terminal.status})`,
|
||||
result.host.status === "failed" ? { cause: result.host.error } : undefined,
|
||||
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),
|
||||
)
|
||||
this.name = "ClipboardWriteError"
|
||||
this.result = result
|
||||
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())",
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
export function createTuiClipboard(renderer: RendererClipboardBoundary): OwnedClipboardService {
|
||||
return createClipboardAdapter(
|
||||
createClipboard({
|
||||
host: createHostClipboard({
|
||||
timeoutMs,
|
||||
maxReadBytes,
|
||||
maxWriteBytes: 8 * 1024 * 1024,
|
||||
maxImagePixels: 64 * 1024 * 1024,
|
||||
maxConversionBytes: 512 * 1024 * 1024,
|
||||
maxConcurrentOperations: 16,
|
||||
maxProviderTransfers: 16,
|
||||
maxWorkUnitsPerDrain: 64,
|
||||
}),
|
||||
terminal: createRendererClipboardAdapter(renderer),
|
||||
}),
|
||||
)
|
||||
}
|
||||
let copyMethod: Promise<(text: string) => Promise<void>> | undefined
|
||||
|
||||
export function createClipboardAdapter(clipboard: CoreClipboardService): OwnedClipboardService {
|
||||
return {
|
||||
async read() {
|
||||
const result = await clipboard.read({
|
||||
preferredTypes: ["image/png", "text/plain"],
|
||||
selection: "clipboard",
|
||||
})
|
||||
if (result.status !== "read") {
|
||||
if (result.status === "empty" || result.status === "unsupported" || result.status === "cancelled") return
|
||||
if (result.status === "failed") throw result.error
|
||||
if (result.status === "timed-out") throw new Error(`Clipboard read timed out after ${timeoutMs}ms`)
|
||||
if (result.status === "limit-exceeded") {
|
||||
throw new RangeError("Clipboard content exceeded configured read or image conversion limits")
|
||||
}
|
||||
throw new Error(`Unexpected clipboard read status: ${result.status}`)
|
||||
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 (result.representation.mimeType === "image/png") {
|
||||
return {
|
||||
data: Buffer.from(result.representation.bytes).toString("base64"),
|
||||
mime: result.representation.mimeType,
|
||||
}
|
||||
}
|
||||
if (native) {
|
||||
return async (text: string) => {
|
||||
await command(native[0], native.slice(1), text).catch(() => undefined)
|
||||
}
|
||||
if (result.representation.mimeType === "text/plain") {
|
||||
if (result.representation.bytes.length === 0) return
|
||||
return {
|
||||
data: decodePasteBytes(result.representation.bytes),
|
||||
mime: result.representation.mimeType,
|
||||
}
|
||||
}
|
||||
throw new Error(`Unexpected clipboard MIME type: ${result.representation.mimeType}`)
|
||||
},
|
||||
async write(text) {
|
||||
return classifyClipboardWriteResult(
|
||||
await clipboard.writeText(text, {
|
||||
destination: "all-available",
|
||||
selection: "clipboard",
|
||||
}),
|
||||
)
|
||||
},
|
||||
dispose() {
|
||||
return clipboard.dispose()
|
||||
},
|
||||
}
|
||||
}
|
||||
return async (text: string) => {
|
||||
const { default: clipboardy } = await import("clipboardy")
|
||||
await clipboardy.write(text).catch(() => undefined)
|
||||
}
|
||||
})())
|
||||
}
|
||||
|
||||
export function classifyClipboardWriteResult(result: ClipboardWriteResult): ClipboardWriteOutcome {
|
||||
const partial =
|
||||
result.host.status === "failed" ||
|
||||
result.host.status === "timed-out" ||
|
||||
result.host.status === "cancelled" ||
|
||||
result.terminal.status === "local-failure"
|
||||
|
||||
if (result.host.status === "written") return { delivery: "confirmed", partial, result }
|
||||
if (result.terminal.status === "attempted") return { delivery: "attempted", partial, result }
|
||||
throw new ClipboardWriteError(result)
|
||||
}
|
||||
|
||||
export function formatClipboardWriteNotification(
|
||||
outcome: ClipboardWriteOutcome,
|
||||
confirmed: ClipboardNotification,
|
||||
): ClipboardNotification {
|
||||
if (outcome.delivery === "confirmed" && !outcome.partial) return confirmed
|
||||
if (outcome.delivery === "attempted" && !outcome.partial) {
|
||||
return { message: "Sent to terminal clipboard (acceptance unconfirmed)", variant: "info" }
|
||||
}
|
||||
if (outcome.delivery === "confirmed") {
|
||||
return { message: "Copied to host clipboard; terminal clipboard dispatch failed", variant: "warning" }
|
||||
}
|
||||
return { message: "Sent to terminal clipboard; host clipboard write failed", variant: "warning" }
|
||||
}
|
||||
|
||||
export function clipboardCopyState(outcome: ClipboardWriteOutcome): ClipboardCopyState {
|
||||
if (!outcome.partial) return outcome.delivery
|
||||
return `${outcome.delivery}-partial`
|
||||
export async function write(text: string) {
|
||||
writeOsc52(text)
|
||||
const method = await getCopyMethod()
|
||||
await method(text)
|
||||
}
|
||||
|
||||
@@ -93,15 +93,6 @@ export const settings: Setting[] = [
|
||||
values: ["none", "auto"],
|
||||
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",
|
||||
category: "Tabs",
|
||||
@@ -114,7 +105,7 @@ export const settings: Setting[] = [
|
||||
title: "Scope",
|
||||
category: "Tabs",
|
||||
path: ["tabs", "scope"],
|
||||
default: "cwd",
|
||||
default: "global",
|
||||
values: ["cwd", "global"],
|
||||
labels: ["current directory", "global"],
|
||||
},
|
||||
@@ -198,15 +189,6 @@ export const settings: Setting[] = [
|
||||
values: ["compact", "full"],
|
||||
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",
|
||||
category: "Input",
|
||||
|
||||
@@ -9,7 +9,6 @@ import { useClipboard } from "../context/clipboard"
|
||||
import { useToast } from "../ui/toast"
|
||||
import { describeOS, describeTerminal } from "../util/system"
|
||||
import { useTuiApp } from "../context/runtime"
|
||||
import { formatClipboardWriteNotification } from "../clipboard"
|
||||
|
||||
export function DialogDebug() {
|
||||
const theme = useTheme()
|
||||
@@ -39,22 +38,13 @@ export function DialogDebug() {
|
||||
const text = entries()
|
||||
.map((entry) => `${entry.label}: ${entry.value}`)
|
||||
.join("\n")
|
||||
setCopied(false)
|
||||
void clipboard
|
||||
.write(text)
|
||||
.then((outcome) => {
|
||||
setCopied(outcome.delivery === "confirmed")
|
||||
toast.show(
|
||||
formatClipboardWriteNotification(outcome, {
|
||||
message: "Debug info copied to clipboard",
|
||||
variant: "info",
|
||||
}),
|
||||
)
|
||||
})
|
||||
.catch((error) => {
|
||||
setCopied(false)
|
||||
toast.error(error)
|
||||
.write?.(text)
|
||||
.then(() => {
|
||||
setCopied(true)
|
||||
toast.show({ message: "Debug info copied to clipboard", variant: "info" })
|
||||
})
|
||||
.catch(toast.error)
|
||||
}
|
||||
|
||||
Keymap.createLayer(() => ({
|
||||
|
||||
@@ -1,77 +0,0 @@
|
||||
import { TextAttributes } from "@opentui/core"
|
||||
import { useTerminalDimensions } from "@opentui/solid"
|
||||
import { createEffect, createMemo, createSignal, on } 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
|
||||
setIndex((value) => (value + direction + props.images.length) % props.images.length)
|
||||
}
|
||||
|
||||
createEffect(
|
||||
on(
|
||||
() => current()?.uri,
|
||||
() => setFailed(false),
|
||||
),
|
||||
)
|
||||
|
||||
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="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="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>
|
||||
)
|
||||
}
|
||||
@@ -8,7 +8,6 @@ import type {
|
||||
} from "@opencode-ai/client"
|
||||
import { createMemo, createSignal, onCleanup, onMount, Show } from "solid-js"
|
||||
import { useClipboard } from "../context/clipboard"
|
||||
import { formatClipboardWriteNotification } from "../clipboard"
|
||||
import { useData } from "../context/data"
|
||||
import { useClient } from "../context/client"
|
||||
import { Keymap } from "../context/keymap"
|
||||
@@ -453,12 +452,8 @@ function OAuthAuto(props: {
|
||||
run: () => {
|
||||
const value = props.attempt.instructions.match(/[A-Z0-9]{4}-[A-Z0-9]{4,5}/)?.[0] ?? props.attempt.url
|
||||
clipboard
|
||||
.write(value)
|
||||
.then((outcome) =>
|
||||
toast.show(
|
||||
formatClipboardWriteNotification(outcome, { message: "Copied to clipboard", variant: "info" }),
|
||||
),
|
||||
)
|
||||
.write?.(value)
|
||||
.then(() => toast.show({ message: "Copied to clipboard", variant: "info" }))
|
||||
.catch(toast.error)
|
||||
},
|
||||
},
|
||||
|
||||
@@ -9,7 +9,6 @@ import { useTheme } from "../context/theme"
|
||||
import { TextAttributes, type ScrollBoxRenderable } from "@opentui/core"
|
||||
import type { McpServer } from "@opencode-ai/client"
|
||||
import { useClipboard } from "../context/clipboard"
|
||||
import { formatClipboardWriteNotification } from "../clipboard"
|
||||
import { useToast } from "../ui/toast"
|
||||
import { useKeyboard, useTerminalDimensions } from "@opentui/solid"
|
||||
import { useConfig } from "../config"
|
||||
@@ -147,14 +146,10 @@ function DialogMcpError(props: { server: McpServer; onBack: () => void }) {
|
||||
onMount(() => dialog.setSize("large"))
|
||||
|
||||
const copy = () => {
|
||||
if (!clipboard.write) return
|
||||
void clipboard
|
||||
.write(error())
|
||||
.then((outcome) => {
|
||||
setCopied(outcome.delivery === "confirmed")
|
||||
toast.show(
|
||||
formatClipboardWriteNotification(outcome, { message: "Copied to clipboard", variant: "info" }),
|
||||
)
|
||||
})
|
||||
.then(() => setCopied(true))
|
||||
.catch(toast.error)
|
||||
}
|
||||
|
||||
|
||||
@@ -77,13 +77,13 @@ export function DialogOpen() {
|
||||
.slice(0, RECENT_LIMIT)
|
||||
const sessionOptions = recent.map((session) => {
|
||||
const project = data.project.get(session.projectID)
|
||||
const name = project?.canonical === "/" ? undefined : project?.name || path.basename(project?.canonical ?? "")
|
||||
const name = project?.name || path.basename(project?.canonical ?? session.location.directory)
|
||||
const running = data.session.family(session.id).some((id) => data.session.status(id) === "running")
|
||||
return {
|
||||
title: sessionTitle(session),
|
||||
value: { type: "session", sessionID: session.id } as OpenTarget,
|
||||
category: "Sessions",
|
||||
footer: `${name ? `${Locale.truncate(name, 20)} · ` : ""}${timeAgo(session.time.updated)}`,
|
||||
footer: `${Locale.truncate(name, 20)} · ${timeAgo(session.time.updated)}`,
|
||||
gutter: running
|
||||
? () => <Spinner />
|
||||
: tabs.has(session.id)
|
||||
@@ -103,13 +103,13 @@ export function DialogOpen() {
|
||||
})
|
||||
.map((project) => {
|
||||
const title = project.name ?? path.basename(project.canonical)
|
||||
const footer = abbreviateHome(project.canonical, paths.home)
|
||||
const description = abbreviateHome(project.canonical, paths.home)
|
||||
// Dialog padding, the gutter column, title padding, and the separating space use nine columns.
|
||||
const width = Math.min(60, dimensions().width - 2) - 9 - stringWidth(title)
|
||||
return {
|
||||
title,
|
||||
footer: truncateFilePath(footer, width),
|
||||
searchText: footer,
|
||||
description: truncateFilePath(description, width),
|
||||
searchText: description,
|
||||
value: { type: "project", directory: project.canonical } as OpenTarget,
|
||||
category: "Projects",
|
||||
}
|
||||
|
||||
@@ -19,6 +19,7 @@ import { errorMessage } from "../util/error"
|
||||
import { useSessionTabs } from "../context/session-tabs"
|
||||
import { useStorage } from "../context/storage"
|
||||
import { sessionTitle } from "../util/session"
|
||||
import { useConfig } from "../config"
|
||||
|
||||
export function DialogSessionList() {
|
||||
const dialog = useDialog()
|
||||
@@ -30,12 +31,15 @@ export function DialogSessionList() {
|
||||
const client = useClient()
|
||||
const local = useLocal()
|
||||
const sessionTabs = useSessionTabs()
|
||||
const config = useConfig().data
|
||||
const toast = useToast()
|
||||
const [filter, setFilter] = createSignal("")
|
||||
const shortcuts = Keymap.useShortcuts()
|
||||
const [search, setSearch] = createDebouncedSignal("", 150)
|
||||
const [toDelete, setToDelete] = createSignal<string>()
|
||||
const [prefs, updatePrefs] = useStorage().store("session-list", { initial: { allProjects: false } })
|
||||
const [prefs, updatePrefs] = useStorage().store("session-list", {
|
||||
initial: { allProjects: config.tabs?.scope !== "cwd" },
|
||||
})
|
||||
const allProjects = () => prefs.allProjects
|
||||
|
||||
const [searchResults, { mutate: setSearchResults }] = createResource(
|
||||
@@ -236,7 +240,9 @@ export function DialogSessionList() {
|
||||
.remove({ sessionID: option.value })
|
||||
.then(() => {
|
||||
setSearchResults((result) =>
|
||||
result ? { ...result, sessions: result.sessions.filter((session) => session.id !== option.value) } : result,
|
||||
result
|
||||
? { ...result, sessions: result.sessions.filter((session) => session.id !== option.value) }
|
||||
: result,
|
||||
)
|
||||
})
|
||||
.catch((error) => {
|
||||
|
||||
@@ -6,14 +6,13 @@ import { useClipboard } from "../context/clipboard"
|
||||
import { useExit } from "../context/exit"
|
||||
import { useTuiApp } from "../context/runtime"
|
||||
import { describeOS, describeTerminal } from "../util/system"
|
||||
import { clipboardCopyState, type ClipboardCopyState } from "../clipboard"
|
||||
|
||||
export function ErrorComponent(props: { error: Error; reset: () => void; mode?: "dark" | "light" }) {
|
||||
const term = useTerminalDimensions()
|
||||
const exit = useExit()
|
||||
const clipboard = useClipboard()
|
||||
const app = useTuiApp()
|
||||
const [copyState, setCopyState] = createSignal<ClipboardCopyState>("idle")
|
||||
const [copied, setCopied] = createSignal(false)
|
||||
|
||||
// Safe fallback palette per mode (mirrors theme/assets/opencode.json) since the
|
||||
// theme context may be the thing that crashed.
|
||||
@@ -29,7 +28,6 @@ export function ErrorComponent(props: { error: Error; reset: () => void; mode?:
|
||||
onPrimary: "#ffffff",
|
||||
error: "#d1383d",
|
||||
success: "#3d9a57",
|
||||
warning: "#c18401",
|
||||
}
|
||||
: {
|
||||
bg: "#0a0a0a",
|
||||
@@ -41,7 +39,6 @@ export function ErrorComponent(props: { error: Error; reset: () => void; mode?:
|
||||
onPrimary: "#0a0a0a",
|
||||
error: "#e06c75",
|
||||
success: "#7fd88f",
|
||||
warning: "#e5c07b",
|
||||
}
|
||||
|
||||
const message = props.error.message || "An unknown error occurred."
|
||||
@@ -49,27 +46,11 @@ export function ErrorComponent(props: { error: Error; reset: () => void; mode?:
|
||||
const issueURL = buildIssueURL(message, stack, app.version)
|
||||
|
||||
const copyReport = () => {
|
||||
void clipboard
|
||||
.write(issueURL.toString())
|
||||
.then((outcome) => setCopyState(clipboardCopyState(outcome)))
|
||||
.catch(() => setCopyState("failed"))
|
||||
void clipboard.write?.(issueURL.toString()).then(() => setCopied(true))
|
||||
}
|
||||
|
||||
const actions = [
|
||||
{
|
||||
key: "c",
|
||||
label: () =>
|
||||
({
|
||||
idle: "Copy report",
|
||||
confirmed: "Copied",
|
||||
"confirmed-partial": "Copied (terminal failed)",
|
||||
attempted: "Sent",
|
||||
"attempted-partial": "Sent (host failed)",
|
||||
failed: "Copy failed",
|
||||
})[copyState()],
|
||||
copy: true,
|
||||
onUse: copyReport,
|
||||
},
|
||||
{ key: "c", label: () => (copied() ? "✓ Copied" : "Copy report"), copy: true, onUse: copyReport },
|
||||
{ key: "r", label: () => "Restart", onUse: props.reset },
|
||||
{ key: "q", label: () => "Quit", onUse: () => exit() },
|
||||
]
|
||||
@@ -154,18 +135,13 @@ export function ErrorComponent(props: { error: Error; reset: () => void; mode?:
|
||||
<For each={actions}>
|
||||
{(action, index) => {
|
||||
const isSelected = () => selected() === index()
|
||||
const copyColor = () => {
|
||||
if (!action.copy) return
|
||||
if (copyState() === "confirmed") return colors.success
|
||||
if (copyState() === "confirmed-partial" || copyState() === "attempted-partial") return colors.warning
|
||||
if (copyState() === "failed") return colors.error
|
||||
}
|
||||
const isCopied = () => action.copy && copied()
|
||||
return (
|
||||
<box flexDirection="column" alignItems="center" flexShrink={0}>
|
||||
<box
|
||||
onMouseDown={() => setSelected(index())}
|
||||
onMouseUp={() => action.onUse()}
|
||||
backgroundColor={copyColor() ?? (isSelected() ? colors.primary : colors.element)}
|
||||
backgroundColor={isCopied() ? colors.success : isSelected() ? colors.primary : colors.element}
|
||||
minWidth={15}
|
||||
alignItems="center"
|
||||
paddingLeft={2}
|
||||
@@ -173,7 +149,7 @@ export function ErrorComponent(props: { error: Error; reset: () => void; mode?:
|
||||
>
|
||||
<text
|
||||
attributes={TextAttributes.BOLD}
|
||||
fg={copyColor() || isSelected() ? colors.onPrimary : colors.text}
|
||||
fg={isCopied() || isSelected() ? colors.onPrimary : colors.text}
|
||||
>
|
||||
{action.label()}
|
||||
</text>
|
||||
@@ -213,14 +189,9 @@ export function ErrorComponent(props: { error: Error; reset: () => void; mode?:
|
||||
<Show when={showFooter()}>
|
||||
<box flexDirection="column" alignItems="center" flexShrink={0}>
|
||||
<text fg={colors.muted}>
|
||||
{{
|
||||
idle: "Copy the report and open a GitHub issue to help us fix this.",
|
||||
confirmed: "Report copied. Paste it into a new GitHub issue.",
|
||||
"confirmed-partial": "Report copied to host clipboard; terminal dispatch failed.",
|
||||
attempted: "Report sent to terminal clipboard; acceptance is unconfirmed.",
|
||||
"attempted-partial": "Report sent to terminal clipboard; host clipboard write failed.",
|
||||
failed: "Clipboard write failed. Try again or report the crash manually.",
|
||||
}[copyState()]}
|
||||
{copied()
|
||||
? "Report copied — paste it into a new GitHub issue."
|
||||
: "Copy the report and open a GitHub issue to help us fix this."}
|
||||
</text>
|
||||
<text fg={colors.muted}>OpenCode {app.version}</text>
|
||||
</box>
|
||||
|
||||
@@ -7,9 +7,10 @@ import {
|
||||
decodePasteBytes,
|
||||
type KeyEvent,
|
||||
} from "@opentui/core"
|
||||
import { createEffect, createMemo, onMount, createSignal, onCleanup, on, Show, Switch, Match, For } from "solid-js"
|
||||
import { createEffect, createMemo, onMount, createSignal, onCleanup, on, Show, Switch, Match } from "solid-js"
|
||||
import { registerOpencodeSpinner } from "../register-spinner"
|
||||
import path from "path"
|
||||
import { fileURLToPath } from "url"
|
||||
import { useLocal } from "../../context/local"
|
||||
import { useTheme, useThemes } from "../../context/theme"
|
||||
import { tint } from "../../theme/color"
|
||||
@@ -47,19 +48,12 @@ import { DialogSkill } from "../dialog-skill"
|
||||
import { useArgs } from "../../context/args"
|
||||
import { useConfig } from "../../config"
|
||||
import { usePromptMove } from "./move"
|
||||
import {
|
||||
normalizePastedFilepath,
|
||||
parsePastedFilepaths,
|
||||
readLocalAttachment,
|
||||
MAX_LOCAL_ATTACHMENT_BYTES,
|
||||
type LocalAttachment,
|
||||
} from "./local-attachment"
|
||||
import { readLocalAttachment } from "./local-attachment"
|
||||
import { useData } from "../../context/data"
|
||||
import { useLocation } from "../../context/location"
|
||||
import { Keymap, type KeymapCommand } from "../../context/keymap"
|
||||
import { abbreviateHome } from "../../runtime"
|
||||
import { PluginSlot } from "../../plugin/context"
|
||||
import { DialogImagePreview } from "../dialog-image-preview"
|
||||
|
||||
registerOpencodeSpinner()
|
||||
|
||||
@@ -78,6 +72,17 @@ 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 = {
|
||||
focused: boolean
|
||||
current: PromptInfo
|
||||
@@ -305,114 +310,6 @@ export function Prompt(props: PromptProps) {
|
||||
extmarkToPart: new Map(),
|
||||
interrupt: 0,
|
||||
})
|
||||
let disposed = false
|
||||
let pasteQueue = Promise.resolve()
|
||||
let pasteEpoch = 0
|
||||
let pasteMutating = false
|
||||
let pasteMutation = 0
|
||||
|
||||
function capturePrompt() {
|
||||
return {
|
||||
epoch: pasteEpoch,
|
||||
sessionID: props.sessionID,
|
||||
mode: store.mode,
|
||||
text: input.plainText,
|
||||
cursor: input.cursorOffset,
|
||||
files: store.prompt.files && unwrap(store.prompt.files),
|
||||
agents: store.prompt.agents && unwrap(store.prompt.agents),
|
||||
pasted: unwrap(store.prompt.pasted),
|
||||
}
|
||||
}
|
||||
|
||||
function promptChanged(before: ReturnType<typeof capturePrompt>) {
|
||||
if (disposed || input.isDestroyed) return true
|
||||
return (
|
||||
pasteEpoch !== before.epoch ||
|
||||
props.sessionID !== before.sessionID ||
|
||||
store.mode !== before.mode ||
|
||||
input.plainText !== before.text ||
|
||||
input.cursorOffset !== before.cursor ||
|
||||
(store.prompt.files && unwrap(store.prompt.files)) !== before.files ||
|
||||
(store.prompt.agents && unwrap(store.prompt.agents)) !== before.agents ||
|
||||
unwrap(store.prompt.pasted) !== before.pasted
|
||||
)
|
||||
}
|
||||
|
||||
function cancelChangedPrompt(before: ReturnType<typeof capturePrompt>) {
|
||||
if (!promptChanged(before)) return false
|
||||
pasteEpoch = Math.max(pasteEpoch, before.epoch + 1)
|
||||
if (!disposed && !input.isDestroyed) {
|
||||
toast.show({ message: "Attachment paste canceled because the prompt changed", variant: "warning" })
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
function enqueuePaste(run: (before: ReturnType<typeof capturePrompt>) => Promise<void>) {
|
||||
const epoch = pasteEpoch
|
||||
pasteQueue = pasteQueue
|
||||
.then(async () => {
|
||||
if (disposed || epoch !== pasteEpoch) return
|
||||
await run(capturePrompt())
|
||||
})
|
||||
.catch((error) => {
|
||||
if (!disposed) toast.error(error)
|
||||
})
|
||||
return pasteQueue
|
||||
}
|
||||
|
||||
function setPromptMode(mode: "normal" | "shell") {
|
||||
if (store.mode === mode) return
|
||||
pasteEpoch++
|
||||
setStore("mode", mode)
|
||||
}
|
||||
|
||||
function applyPaste(run: () => void) {
|
||||
const mutation = ++pasteMutation
|
||||
pasteMutating = true
|
||||
try {
|
||||
run()
|
||||
} finally {
|
||||
queueMicrotask(() => {
|
||||
if (pasteMutation === mutation) pasteMutating = false
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
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 imagePreviewAvailableWidth = createMemo(() => Math.min(70, Math.max(0, dimensions().width - 9)))
|
||||
const imagePreviewLimit = createMemo(() =>
|
||||
Math.max(1, Math.min(3, Math.floor((imagePreviewAvailableWidth() - 8) / (imagePreviewWidth() + 1)))),
|
||||
)
|
||||
const visibleImageCount = createMemo(() => Math.min(imagePreviewLimit(), imageAttachments().length))
|
||||
const hiddenImageAttachmentCount = createMemo(() => imageAttachments().length - visibleImageCount())
|
||||
const imagePreviewsVisible = createMemo(
|
||||
() => imageAttachments().length > 0 && imagePreviewAvailableWidth() >= imagePreviewWidth(),
|
||||
)
|
||||
const imageOverflowVisible = createMemo(
|
||||
() => hiddenImageAttachmentCount() > 0 && imagePreviewAvailableWidth() >= imagePreviewWidth() + 9,
|
||||
)
|
||||
|
||||
function openImagePreview(initial: number) {
|
||||
const images = imageAttachments()
|
||||
if (images.length === 0) return
|
||||
dialog.replace(() => <DialogImagePreview images={images} initial={initial} />)
|
||||
}
|
||||
|
||||
function imagePreviewMouseIndex(event: MouseEvent): number | undefined {
|
||||
if (!config.prompt?.image_preview || !imagePreviewsVisible()) return undefined
|
||||
const x = event.x - anchor.x - 3
|
||||
const y = event.y - anchor.y - 1
|
||||
if (x < 0 || y < 0 || y >= imagePreviewHeight()) return undefined
|
||||
const stride = imagePreviewWidth() + 1
|
||||
const index = Math.floor(x / stride)
|
||||
if (index < visibleImageCount() && x % stride < imagePreviewWidth()) return index
|
||||
if (index === visibleImageCount() && imageOverflowVisible() && x % stride < 8) return visibleImageCount()
|
||||
return undefined
|
||||
}
|
||||
|
||||
createEffect(
|
||||
on(
|
||||
@@ -482,32 +379,22 @@ export function Prompt(props: PromptProps) {
|
||||
name: "prompt.paste",
|
||||
category: "Prompt",
|
||||
palette: undefined,
|
||||
run: (_input: string | undefined, event?: KeyEvent) => {
|
||||
run: async (_input: string | undefined, event?: KeyEvent) => {
|
||||
event?.preventDefault()
|
||||
event?.stopPropagation()
|
||||
return enqueuePaste(async (before) => {
|
||||
const content = await clipboard.read()
|
||||
if (cancelChangedPrompt(before)) return
|
||||
if (content?.mime.startsWith("image/")) {
|
||||
pasteAttachment({
|
||||
filename: "clipboard",
|
||||
uri: `data:${content.mime};base64,${content.data}`,
|
||||
})
|
||||
return
|
||||
}
|
||||
if (content?.mime === "text/plain") {
|
||||
await pasteInputText(content.data, before)
|
||||
}
|
||||
})
|
||||
const content = await clipboard.read?.()
|
||||
if (content?.mime.startsWith("image/")) {
|
||||
await pasteAttachment({
|
||||
filename: "clipboard",
|
||||
uri: `data:${content.mime};base64,${content.data}`,
|
||||
})
|
||||
return
|
||||
}
|
||||
if (content?.mime === "text/plain") {
|
||||
await pasteInputText(content.data)
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
title: "View image attachments",
|
||||
name: "prompt.images.view",
|
||||
category: "Prompt",
|
||||
enabled: imageAttachments().length > 0,
|
||||
run: () => openImagePreview(0),
|
||||
},
|
||||
{
|
||||
title: "Interrupt session",
|
||||
name: "session.interrupt",
|
||||
@@ -519,7 +406,7 @@ export function Prompt(props: PromptProps) {
|
||||
if (!input.focused) return
|
||||
// TODO: this should be its own command
|
||||
if (store.mode === "shell") {
|
||||
setPromptMode("normal")
|
||||
setStore("mode", "normal")
|
||||
return
|
||||
}
|
||||
if (!props.sessionID) return
|
||||
@@ -640,7 +527,6 @@ export function Prompt(props: PromptProps) {
|
||||
"prompt.submit",
|
||||
"prompt.editor",
|
||||
"prompt.editor_context.clear",
|
||||
"prompt.images.view",
|
||||
"prompt.stash",
|
||||
"prompt.stash.pop",
|
||||
"prompt.stash.list",
|
||||
@@ -665,14 +551,12 @@ export function Prompt(props: PromptProps) {
|
||||
input.blur()
|
||||
},
|
||||
set(prompt) {
|
||||
pasteEpoch++
|
||||
input.setText(prompt.text)
|
||||
setStore("prompt", prompt)
|
||||
restoreExtmarksFromPrompt(prompt)
|
||||
input.gotoBufferEnd()
|
||||
},
|
||||
reset() {
|
||||
pasteEpoch++
|
||||
input.clear()
|
||||
input.extmarks.clear()
|
||||
setStore("prompt", emptyPrompt())
|
||||
@@ -696,7 +580,6 @@ export function Prompt(props: PromptProps) {
|
||||
})
|
||||
|
||||
onCleanup(() => {
|
||||
disposed = true
|
||||
if (store.prompt.text) {
|
||||
stashed = { prompt: unwrap(store.prompt), cursor: input.cursorOffset }
|
||||
}
|
||||
@@ -920,7 +803,7 @@ export function Prompt(props: PromptProps) {
|
||||
group: "Prompt",
|
||||
run: () => {
|
||||
setStore("placeholder", randomIndex(shell().length))
|
||||
setPromptMode("shell")
|
||||
setStore("mode", "shell")
|
||||
},
|
||||
},
|
||||
],
|
||||
@@ -931,7 +814,7 @@ export function Prompt(props: PromptProps) {
|
||||
return {
|
||||
target: inputTarget,
|
||||
enabled: inputTarget() !== undefined && store.mode === "shell",
|
||||
commands: [{ bind: "escape", title: "Exit shell mode", group: "Prompt", run: () => setPromptMode("normal") }],
|
||||
commands: [{ bind: "escape", title: "Exit shell mode", group: "Prompt", run: () => setStore("mode", "normal") }],
|
||||
}
|
||||
})
|
||||
|
||||
@@ -942,7 +825,9 @@ export function Prompt(props: PromptProps) {
|
||||
cursorVersion()
|
||||
return inputTarget() !== undefined && store.mode === "shell" && input?.visualCursor.offset === 0
|
||||
})(),
|
||||
commands: [{ bind: "backspace", title: "Exit shell mode", group: "Prompt", run: () => setPromptMode("normal") }],
|
||||
commands: [
|
||||
{ bind: "backspace", title: "Exit shell mode", group: "Prompt", run: () => setStore("mode", "normal") },
|
||||
],
|
||||
}
|
||||
})
|
||||
|
||||
@@ -973,7 +858,7 @@ export function Prompt(props: PromptProps) {
|
||||
if (!item) return false
|
||||
input.setText(item.text)
|
||||
setStore("prompt", item)
|
||||
setPromptMode(item.mode ?? "normal")
|
||||
setStore("mode", item.mode ?? "normal")
|
||||
restoreExtmarksFromPrompt(item)
|
||||
input.cursorOffset = 0
|
||||
},
|
||||
@@ -1012,7 +897,7 @@ export function Prompt(props: PromptProps) {
|
||||
if (!item) return false
|
||||
input.setText(item.text)
|
||||
setStore("prompt", item)
|
||||
setPromptMode(item.mode ?? "normal")
|
||||
setStore("mode", item.mode ?? "normal")
|
||||
restoreExtmarksFromPrompt(item)
|
||||
input.cursorOffset = input.plainText.length
|
||||
},
|
||||
@@ -1130,7 +1015,7 @@ export function Prompt(props: PromptProps) {
|
||||
sessionID,
|
||||
command: inputText,
|
||||
})
|
||||
setPromptMode("normal")
|
||||
setStore("mode", "normal")
|
||||
} else if (
|
||||
inputText.startsWith("/") &&
|
||||
(data.location.command.list(currentLocation.current) ?? []).some(
|
||||
@@ -1261,7 +1146,7 @@ export function Prompt(props: PromptProps) {
|
||||
const extmarkStart = currentOffset
|
||||
const extmarkEnd = extmarkStart + promptOffsetWidth(virtualText)
|
||||
|
||||
applyPaste(() => input.insertText(virtualText + " "))
|
||||
input.insertText(virtualText + " ")
|
||||
|
||||
const extmarkId = input.extmarks.create({
|
||||
start: extmarkStart,
|
||||
@@ -1283,46 +1168,34 @@ export function Prompt(props: PromptProps) {
|
||||
)
|
||||
}
|
||||
|
||||
async function pasteInputText(text: string, before: ReturnType<typeof capturePrompt>) {
|
||||
async function pasteInputText(text: string) {
|
||||
const normalizedText = text.replace(/\r\n/g, "\n").replace(/\r/g, "\n")
|
||||
const pastedContent = normalizedText.trim()
|
||||
const filepath = normalizePastedFilepath(pastedContent, terminalEnvironment.platform)
|
||||
const filepath = pastedFilepath(pastedContent, terminalEnvironment.platform)
|
||||
const isUrl = /^(https?):\/\//.test(filepath)
|
||||
if (!isUrl) {
|
||||
const attachment = await readLocalAttachment(filepath)
|
||||
if (attachment) {
|
||||
if (cancelChangedPrompt(before)) return
|
||||
pasteLocalAttachment(filepath, attachment)
|
||||
const filename = path.basename(filepath)
|
||||
if (attachment?.type === "text") {
|
||||
pasteText(attachment.content, `[SVG: ${filename ?? "image"}]`)
|
||||
return
|
||||
}
|
||||
|
||||
const filepaths = parsePastedFilepaths(pastedContent, terminalEnvironment.platform)
|
||||
if (filepaths.length > 1) {
|
||||
let remaining = MAX_LOCAL_ATTACHMENT_BYTES
|
||||
const attachments: Array<{ filepath: string; attachment: LocalAttachment }> = []
|
||||
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 (cancelChangedPrompt(before)) return
|
||||
for (const item of attachments) pasteLocalAttachment(item.filepath, item.attachment)
|
||||
return
|
||||
}
|
||||
if (attachment?.type === "binary") {
|
||||
await pasteAttachment({
|
||||
filename,
|
||||
uri: `data:${attachment.mime};base64,${Buffer.from(attachment.content).toString("base64")}`,
|
||||
})
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if (cancelChangedPrompt(before)) return
|
||||
|
||||
const lineCount = (pastedContent.match(/\n/g)?.length ?? 0) + 1
|
||||
if ((lineCount >= 3 || pastedContent.length > 150) && config.prompt?.paste !== "full") {
|
||||
pasteText(pastedContent, `[Pasted ~${lineCount} lines]`)
|
||||
return
|
||||
}
|
||||
|
||||
applyPaste(() => input.insertText(normalizedText))
|
||||
input.insertText(normalizedText)
|
||||
|
||||
setTimeout(() => {
|
||||
if (!input || input.isDestroyed) return
|
||||
@@ -1331,32 +1204,17 @@ export function Prompt(props: PromptProps) {
|
||||
}, 0)
|
||||
}
|
||||
|
||||
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 }) {
|
||||
async function pasteAttachment(file: { filename?: string; uri: string }) {
|
||||
const currentOffset = input.cursorOffset
|
||||
const extmarkStart = currentOffset
|
||||
const pdf = file.uri.startsWith("data:application/pdf;")
|
||||
const count = pdf
|
||||
? (store.prompt.files?.filter(
|
||||
(attachment) => typeof attachment.uri === "string" && attachment.uri.startsWith("data:application/pdf;"),
|
||||
).length ?? 0)
|
||||
: imageAttachments().length
|
||||
const prefix = pdf ? "data:application/pdf;" : "data:image/"
|
||||
const count = store.prompt.files?.filter((attachment) => attachment.uri.startsWith(prefix)).length ?? 0
|
||||
const virtualText = pdf ? `[PDF ${count + 1}]` : `[Image ${count + 1}]`
|
||||
const extmarkEnd = extmarkStart + virtualText.length
|
||||
const textToInsert = virtualText + " "
|
||||
|
||||
applyPaste(() => input.insertText(textToInsert))
|
||||
input.insertText(textToInsert)
|
||||
|
||||
const extmarkId = input.extmarks.create({
|
||||
start: extmarkStart,
|
||||
@@ -1383,6 +1241,7 @@ export function Prompt(props: PromptProps) {
|
||||
draft.extmarkToPart.set(extmarkId, { type: "file", index })
|
||||
}),
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
function clearPrompt() {
|
||||
@@ -1496,76 +1355,7 @@ export function Prompt(props: PromptProps) {
|
||||
backgroundColor={promptBg()}
|
||||
flexGrow={1}
|
||||
width="100%"
|
||||
onMouseDown={(event: MouseEvent) => {
|
||||
if (event.button !== 0 || imagePreviewMouseIndex(event) === undefined) return
|
||||
event.preventDefault()
|
||||
}}
|
||||
onMouseUp={(event: MouseEvent) => {
|
||||
if (event.button !== 0) return
|
||||
const index = imagePreviewMouseIndex(event)
|
||||
if (index === undefined) return
|
||||
event.preventDefault()
|
||||
openImagePreview(index)
|
||||
}}
|
||||
>
|
||||
<Show when={config.prompt?.image_preview && imagePreviewsVisible()}>
|
||||
<box
|
||||
width="100%"
|
||||
height={imagePreviewHeight() + 1}
|
||||
flexDirection="row"
|
||||
flexShrink={0}
|
||||
justifyContent="flex-start"
|
||||
gap={1}
|
||||
paddingBottom={1}
|
||||
>
|
||||
<For each={imageAttachments().slice(0, visibleImageCount())}>
|
||||
{(file, index) => {
|
||||
const [failed, setFailed] = createSignal(false)
|
||||
return (
|
||||
<box
|
||||
width={imagePreviewWidth()}
|
||||
height={imagePreviewHeight()}
|
||||
flexBasis={imagePreviewWidth()}
|
||||
flexShrink={1}
|
||||
>
|
||||
<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={imageOverflowVisible()}>
|
||||
<box
|
||||
width={8}
|
||||
height={imagePreviewHeight()}
|
||||
flexBasis={8}
|
||||
flexShrink={1}
|
||||
alignItems="center"
|
||||
justifyContent="center"
|
||||
>
|
||||
<text fg={theme.text.subdued} wrapMode="none" truncate>
|
||||
+{hiddenImageAttachmentCount()} more
|
||||
</text>
|
||||
</box>
|
||||
</Show>
|
||||
</box>
|
||||
</Show>
|
||||
<textarea
|
||||
width="100%"
|
||||
placeholder={placeholderText()}
|
||||
@@ -1575,17 +1365,13 @@ export function Prompt(props: PromptProps) {
|
||||
minHeight={1}
|
||||
maxHeight={maxHeight()}
|
||||
onContentChange={() => {
|
||||
if (!pasteMutating) pasteEpoch++
|
||||
const value = input.plainText
|
||||
setStore("prompt", "text", value)
|
||||
auto()?.onInput(value)
|
||||
syncExtmarksWithPromptParts()
|
||||
setCursorVersion((value) => value + 1)
|
||||
}}
|
||||
onCursorChange={() => {
|
||||
if (!pasteMutating) pasteEpoch++
|
||||
setCursorVersion((value) => value + 1)
|
||||
}}
|
||||
onCursorChange={() => setCursorVersion((value) => value + 1)}
|
||||
onKeyDown={(e: { preventDefault(): void }) => {
|
||||
if (props.disabled) {
|
||||
e.preventDefault()
|
||||
@@ -1597,7 +1383,7 @@ export function Prompt(props: PromptProps) {
|
||||
// hangul) is flushed to plainText before we read it for submission.
|
||||
setTimeout(() => setTimeout(() => submit(), 0), 0)
|
||||
}}
|
||||
onPaste={(event: PasteEvent) => {
|
||||
onPaste={async (event: PasteEvent) => {
|
||||
if (props.disabled) {
|
||||
event.preventDefault()
|
||||
return
|
||||
@@ -1607,10 +1393,11 @@ export function Prompt(props: PromptProps) {
|
||||
// Windows ConPTY/Terminal often sends CR-only newlines in bracketed paste
|
||||
// Replace CRLF first, then any remaining CR
|
||||
const normalizedText = decodePasteBytes(event.bytes).replace(/\r\n/g, "\n").replace(/\r/g, "\n")
|
||||
const pastedContent = normalizedText.trim()
|
||||
|
||||
// Windows Terminal <1.25 can surface image-only clipboard as an
|
||||
// empty bracketed paste. Windows Terminal 1.25+ does not.
|
||||
if (event.bytes.byteLength === 0) {
|
||||
if (!pastedContent) {
|
||||
keymap.dispatch("prompt.paste")
|
||||
return
|
||||
}
|
||||
@@ -1619,7 +1406,7 @@ export function Prompt(props: PromptProps) {
|
||||
// default paste unless we suppress it first and handle insertion ourselves.
|
||||
event.preventDefault()
|
||||
|
||||
void enqueuePaste((before) => pasteInputText(normalizedText, before))
|
||||
await pasteInputText(normalizedText)
|
||||
}}
|
||||
ref={(r: TextareaRenderable) => {
|
||||
input = r
|
||||
|
||||
@@ -1,15 +1,9 @@
|
||||
import { constants } from "node:fs"
|
||||
import { open } from "node:fs/promises"
|
||||
import { readFile } from "node:fs/promises"
|
||||
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<{
|
||||
readText(path: string, maxBytes: number): Promise<string>
|
||||
readBytes(path: string, maxBytes: number): Promise<Uint8Array>
|
||||
readText(path: string): Promise<string>
|
||||
readBytes(path: string): Promise<Uint8Array>
|
||||
mime(path: string): Promise<string>
|
||||
}>
|
||||
|
||||
@@ -17,15 +11,14 @@ export type LocalAttachment =
|
||||
| Readonly<{ type: "text"; mime: "image/svg+xml"; content: string }>
|
||||
| Readonly<{ type: "binary"; mime: string; content: Uint8Array }>
|
||||
|
||||
export function readLocalAttachment(file: string, maxBytes = MAX_LOCAL_ATTACHMENT_BYTES) {
|
||||
export function readLocalAttachment(file: string) {
|
||||
return readLocalAttachmentWith(
|
||||
{
|
||||
readText: async (value, limit) => (await readFileBounded(value, limit)).toString("utf8"),
|
||||
readBytes: readFileBounded,
|
||||
readText: (value) => readFile(value, "utf8"),
|
||||
readBytes: (value) => readFile(value),
|
||||
mime: async (value) => mimeTypes[path.extname(value).toLowerCase()] ?? "application/octet-stream",
|
||||
},
|
||||
file,
|
||||
maxBytes,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -40,109 +33,16 @@ const mimeTypes: Record<string, string> = {
|
||||
".webp": "image/webp",
|
||||
}
|
||||
|
||||
async function readFileBounded(file: string, maxBytes: number) {
|
||||
const handle = await open(file, constants.O_RDONLY | constants.O_NONBLOCK)
|
||||
try {
|
||||
const info = await handle.stat()
|
||||
if (!info.isFile() || info.size > maxBytes) throw new Error("Attachment exceeds the local file limit")
|
||||
const content = Buffer.allocUnsafe(info.size + 1)
|
||||
let offset = 0
|
||||
while (offset < content.byteLength) {
|
||||
const { bytesRead } = await handle.read(content, offset, content.byteLength - offset, offset)
|
||||
if (bytesRead === 0) break
|
||||
offset += bytesRead
|
||||
}
|
||||
if (offset !== info.size) throw new Error("Attachment changed while being read")
|
||||
return content.subarray(0, offset)
|
||||
} finally {
|
||||
await handle.close()
|
||||
}
|
||||
}
|
||||
|
||||
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> {
|
||||
export async function readLocalAttachmentWith(files: LocalFiles, path: string): Promise<LocalAttachment | undefined> {
|
||||
const mime = await files.mime(path).catch(() => undefined)
|
||||
if (!mime) return undefined
|
||||
if (!mime.startsWith("image/") && mime !== "application/pdf") return undefined
|
||||
if (!mime) return
|
||||
if (mime === "image/svg+xml") {
|
||||
const content = await files.readText(path, maxBytes).catch(() => undefined)
|
||||
if (!content || Buffer.byteLength(content) > maxBytes) return undefined
|
||||
const content = await files.readText(path).catch(() => undefined)
|
||||
if (!content) return
|
||||
return { type: "text", mime, content }
|
||||
}
|
||||
const content = await files.readBytes(path, maxBytes).catch(() => undefined)
|
||||
if (!content || content.byteLength > maxBytes) return undefined
|
||||
if (!mime.startsWith("image/") && mime !== "application/pdf") return
|
||||
const content = await files.readBytes(path).catch(() => undefined)
|
||||
if (!content) return
|
||||
return { type: "binary", mime, content }
|
||||
}
|
||||
|
||||
@@ -105,9 +105,6 @@ export const Info = Schema.Struct({
|
||||
paste: Schema.optional(Schema.Literals(["compact", "full"])).annotate({
|
||||
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" }),
|
||||
session: Schema.optional(
|
||||
@@ -122,9 +119,6 @@ export const Info = Schema.Struct({
|
||||
grouping: Schema.optional(Schema.Literals(["auto", "none"])).annotate({
|
||||
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({
|
||||
description: "Show Markdown syntax markers or conceal them in rendered transcript content",
|
||||
}),
|
||||
|
||||
@@ -164,7 +164,6 @@ export const Definitions = {
|
||||
|
||||
prompt_submit: keybind("none", "Submit prompt"),
|
||||
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_stash: keybind("none", "Stash prompt"),
|
||||
prompt_stash_pop: keybind("none", "Pop stashed prompt"),
|
||||
@@ -365,7 +364,6 @@ export const CommandMap = {
|
||||
display_thinking: "session.toggle.thinking",
|
||||
prompt_submit: "prompt.submit",
|
||||
prompt_editor_context_clear: "prompt.editor_context.clear",
|
||||
prompt_images_view: "prompt.images.view",
|
||||
prompt_skills: "prompt.skills",
|
||||
prompt_stash: "prompt.stash",
|
||||
prompt_stash_pop: "prompt.stash.pop",
|
||||
|
||||
@@ -1,25 +1,18 @@
|
||||
import type { ClipboardWriteResult } from "@opentui/core"
|
||||
import { createContext, type JSX, useContext } from "solid-js"
|
||||
import { read, write } from "../clipboard"
|
||||
|
||||
export type ClipboardContent = Readonly<{ data: string; mime: string }>
|
||||
export type ClipboardWriteOutcome = Readonly<{
|
||||
delivery: "confirmed" | "attempted"
|
||||
partial: boolean
|
||||
result: ClipboardWriteResult
|
||||
}>
|
||||
export type ClipboardService = Readonly<{
|
||||
read(): Promise<ClipboardContent | undefined>
|
||||
write(text: string): Promise<ClipboardWriteOutcome>
|
||||
read?(): Promise<ClipboardContent | undefined>
|
||||
write?(text: string): Promise<void>
|
||||
}>
|
||||
const clipboard = { read, write }
|
||||
const ClipboardContext = createContext<ClipboardService>(clipboard)
|
||||
|
||||
const ClipboardContext = createContext<ClipboardService>()
|
||||
|
||||
export function ClipboardProvider(props: { value: ClipboardService; children: JSX.Element }) {
|
||||
return <ClipboardContext.Provider value={props.value}>{props.children}</ClipboardContext.Provider>
|
||||
export function ClipboardProvider(props: { value?: ClipboardService; children: JSX.Element }) {
|
||||
return <ClipboardContext.Provider value={props.value ?? clipboard}>{props.children}</ClipboardContext.Provider>
|
||||
}
|
||||
|
||||
export function useClipboard() {
|
||||
const value = useContext(ClipboardContext)
|
||||
if (!value) throw new Error("useClipboard must be used within a ClipboardProvider")
|
||||
return value
|
||||
return useContext(ClipboardContext)
|
||||
}
|
||||
|
||||
@@ -66,12 +66,12 @@ export const { use: useSessionTabs, provider: SessionTabsProvider } = createSimp
|
||||
let closedTabs: ClosedSessionTab[] = []
|
||||
|
||||
function state() {
|
||||
if (config.tabs?.scope === "global") return store.global
|
||||
return store.cwd[paths.cwd] ?? fallback
|
||||
if (config.tabs?.scope === "cwd") return store.cwd[paths.cwd] ?? fallback
|
||||
return store.global
|
||||
}
|
||||
|
||||
function update(mutation: (draft: TabsState) => void) {
|
||||
const scope = config.tabs?.scope ?? "cwd"
|
||||
const scope = config.tabs?.scope ?? "global"
|
||||
void updateStore((draft) => mutation(scope === "cwd" ? (draft.cwd[paths.cwd] ??= empty()) : draft.global)).catch(
|
||||
() => {},
|
||||
)
|
||||
|
||||
@@ -8,7 +8,6 @@ import { errorMessage } from "../../util/error"
|
||||
import { DialogFork } from "./dialog-fork"
|
||||
import type { PromptInfo } from "../../prompt/history"
|
||||
import { projectedPromptInput } from "../../prompt/codec"
|
||||
import { formatClipboardWriteNotification } from "../../clipboard"
|
||||
|
||||
export function DialogMessage(props: {
|
||||
messageID: string
|
||||
@@ -67,19 +66,8 @@ export function DialogMessage(props: {
|
||||
: "text" in value
|
||||
? value.text
|
||||
: ""
|
||||
if (!text) {
|
||||
toast.show({ message: "No text content found in message", variant: "error" })
|
||||
return
|
||||
}
|
||||
try {
|
||||
const outcome = await clipboard.write(text)
|
||||
toast.show(
|
||||
formatClipboardWriteNotification(outcome, { message: "Copied to clipboard", variant: "info" }),
|
||||
)
|
||||
dialog.clear()
|
||||
} catch (error) {
|
||||
toast.error(error)
|
||||
}
|
||||
await clipboard.write?.(text)
|
||||
dialog.clear()
|
||||
},
|
||||
},
|
||||
{
|
||||
|
||||
@@ -8,7 +8,6 @@ import type { FormField, FormValue } from "@opencode-ai/client"
|
||||
import type { FormWithLocation } from "../../context/data"
|
||||
import { useClient } from "../../context/client"
|
||||
import { useClipboard } from "../../context/clipboard"
|
||||
import { formatClipboardWriteNotification } from "../../clipboard"
|
||||
import { SplitBorder } from "../../ui/border"
|
||||
import { useToast } from "../../ui/toast"
|
||||
import { Keymap } from "../../context/keymap"
|
||||
@@ -365,14 +364,12 @@ export function FormPrompt(props: { form: FormWithLocation }) {
|
||||
|
||||
function copyExternal() {
|
||||
const current = externalField()
|
||||
if (!current) return
|
||||
if (!current || !clipboard.write) return
|
||||
void clipboard
|
||||
.write(current.url)
|
||||
.then((outcome) => {
|
||||
.then(() => {
|
||||
setStore("externalReady", { ...store.externalReady, [current.key]: true })
|
||||
toast.show(
|
||||
formatClipboardWriteNotification(outcome, { message: "Copied URL to clipboard", variant: "info" }),
|
||||
)
|
||||
toast.show({ message: "Copied URL to clipboard", variant: "info" })
|
||||
})
|
||||
.catch(toast.error)
|
||||
}
|
||||
@@ -995,7 +992,7 @@ export function FormPrompt(props: { form: FormWithLocation }) {
|
||||
>
|
||||
enter <span style={{ fg: theme.text.subdued }}>{actionLabel()}</span>
|
||||
</text>
|
||||
<Show when={externalField()}>
|
||||
<Show when={externalField() && clipboard.write}>
|
||||
<text fg={theme.text.default} onMouseUp={copyExternal}>
|
||||
c <span style={{ fg: theme.text.subdued }}>copy</span>
|
||||
</text>
|
||||
|
||||
@@ -23,7 +23,7 @@ import { SplitBorder } from "../../ui/border"
|
||||
import { useTuiPaths, useTuiTerminalEnvironment } from "../../context/runtime"
|
||||
import { Spinner, SPINNER_FRAMES } from "../../component/spinner"
|
||||
import { ThemeContextProvider, useTheme, useThemes } from "../../context/theme"
|
||||
import { BoxRenderable, ScrollBoxRenderable, addDefaultParsers, TextAttributes, RGBA, MouseEvent } from "@opentui/core"
|
||||
import { BoxRenderable, ScrollBoxRenderable, addDefaultParsers, TextAttributes, RGBA } from "@opentui/core"
|
||||
import { Prompt, type PromptRef } from "../../component/prompt"
|
||||
import type {
|
||||
ModelInfo,
|
||||
@@ -53,7 +53,6 @@ import { openEditor } from "../../editor"
|
||||
import { useDialog } from "../../ui/dialog"
|
||||
import { DialogConfirm } from "../../ui/dialog-confirm"
|
||||
import { DialogSessionRename } from "../../component/dialog-session-rename"
|
||||
import { DialogImagePreview } from "../../component/dialog-image-preview"
|
||||
import { DialogMessage } from "./dialog-message"
|
||||
import { DialogFork } from "./dialog-fork"
|
||||
import { DialogTimeline } from "./dialog-timeline"
|
||||
@@ -75,7 +74,6 @@ import { DialogExportResult } from "../../ui/dialog-export-result"
|
||||
import { sessionEpilogue } from "../../util/presentation"
|
||||
import { useConfig } from "../../config"
|
||||
import { useClipboard } from "../../context/clipboard"
|
||||
import { formatClipboardWriteNotification } from "../../clipboard"
|
||||
import { nextThinkingMode, reasoningSummary, type ThinkingMode } from "../../context/thinking"
|
||||
import { getScrollAcceleration } from "../../util/scroll"
|
||||
import { collapseToolOutput } from "../../util/collapse-tool-output"
|
||||
@@ -108,7 +106,6 @@ const NAVIGATION_SLACK_ID = "session-navigation-slack"
|
||||
const TRANSCRIPT_TAIL_ROWS = 40
|
||||
const TRANSCRIPT_BACKFILL_CHUNK = 60
|
||||
const TRANSCRIPT_BACKFILL_DELAY = 120
|
||||
const SESSION_IMAGE_LIMIT = 6
|
||||
|
||||
const context = createContext<{
|
||||
width: number
|
||||
@@ -118,7 +115,6 @@ const context = createContext<{
|
||||
markdownMode: () => "source" | "rendered"
|
||||
groupExploration: () => boolean
|
||||
diffWrapMode: () => "word" | "none"
|
||||
imageKeys: () => ReadonlySet<string>
|
||||
models: () => ModelInfo[]
|
||||
config: ReturnType<typeof useConfig>["data"]
|
||||
}>()
|
||||
@@ -148,9 +144,6 @@ export function Session() {
|
||||
const promptRef = usePromptRef()
|
||||
const session = createMemo(() => data.session.get(route.sessionID))
|
||||
const messages = () => data.session.message.list(route.sessionID)
|
||||
const imageKeys = createMemo(() =>
|
||||
config.session?.image_preview ? sessionImageKeys(messages(), session()?.revert?.messageID) : new Set<string>(),
|
||||
)
|
||||
const location = createMemo(() => session()?.location)
|
||||
const currentLocation = useLocation()
|
||||
|
||||
@@ -803,15 +796,8 @@ export function Session() {
|
||||
}
|
||||
|
||||
clipboard
|
||||
.write(text)
|
||||
.then((outcome) =>
|
||||
toast.show(
|
||||
formatClipboardWriteNotification(outcome, {
|
||||
message: "Message copied to clipboard!",
|
||||
variant: "success",
|
||||
}),
|
||||
),
|
||||
)
|
||||
.write?.(text)
|
||||
.then(() => toast.show({ message: "Message copied to clipboard!", variant: "success" }))
|
||||
.catch(() => toast.show({ message: "Failed to copy to clipboard", variant: "error" }))
|
||||
dialog.clear()
|
||||
},
|
||||
@@ -828,13 +814,8 @@ export function Session() {
|
||||
const sessionData = session()
|
||||
if (!sessionData) return
|
||||
const transcript = formatSessionTranscript(sessionData, messages(), showThinking())
|
||||
const outcome = await clipboard.write(transcript)
|
||||
toast.show(
|
||||
formatClipboardWriteNotification(outcome, {
|
||||
message: "Session transcript copied to clipboard!",
|
||||
variant: "success",
|
||||
}),
|
||||
)
|
||||
await clipboard.write?.(transcript)
|
||||
toast.show({ message: "Session transcript copied to clipboard!", variant: "success" })
|
||||
} catch {
|
||||
toast.show({ message: "Failed to copy session transcript", variant: "error" })
|
||||
}
|
||||
@@ -876,11 +857,9 @@ export function Session() {
|
||||
})()
|
||||
|
||||
if (options.action === "copy") {
|
||||
const outcome = await clipboard.write(content)
|
||||
await clipboard.write?.(content)
|
||||
dialog.clear()
|
||||
toast.show(
|
||||
formatClipboardWriteNotification(outcome, { message: "Copied to clipboard", variant: "success" }),
|
||||
)
|
||||
toast.show({ message: "Copied to clipboard", variant: "success" })
|
||||
return
|
||||
}
|
||||
|
||||
@@ -1001,7 +980,6 @@ export function Session() {
|
||||
markdownMode,
|
||||
groupExploration,
|
||||
diffWrapMode,
|
||||
imageKeys,
|
||||
models,
|
||||
config,
|
||||
}}
|
||||
@@ -1391,7 +1369,7 @@ function SessionPartView(props: { partRef: PartRef; message: (messageID: string)
|
||||
/>
|
||||
</Match>
|
||||
<Match when={item().type === "tool"}>
|
||||
<ToolPartWithImages messageID={props.partRef.messageID} part={item() as SessionMessageAssistantTool} />
|
||||
<ToolPart part={item() as SessionMessageAssistantTool} />
|
||||
</Match>
|
||||
</Switch>
|
||||
)}
|
||||
@@ -1529,7 +1507,6 @@ function SessionGroupView(props: {
|
||||
}) {
|
||||
const theme = useTheme()
|
||||
const ctx = use()
|
||||
const dialog = useDialog()
|
||||
const renderer = useRenderer()
|
||||
const [expanded, setExpanded] = createSignal(false)
|
||||
const [hover, setHover] = createSignal(false)
|
||||
@@ -1539,13 +1516,13 @@ function SessionGroupView(props: {
|
||||
if (message?.type !== "assistant") return []
|
||||
const part = resolvePart(message, ref.partID)
|
||||
if (part?.type !== "tool") return []
|
||||
return [{ messageID: ref.messageID, part }]
|
||||
return [part]
|
||||
})
|
||||
const grouped = createMemo(() => parts(props.refs))
|
||||
const pending = createMemo(() => parts(props.pending))
|
||||
const label = createMemo(() => {
|
||||
const counts = grouped().reduce<Record<string, number>>((result, item) => {
|
||||
const tool = toolDisplay(item.part.name)
|
||||
const counts = grouped().reduce<Record<string, number>>((result, part) => {
|
||||
const tool = toolDisplay(part.name)
|
||||
const name = tool === "grep" || tool === "glob" ? "search" : tool
|
||||
result[name] = (result[name] ?? 0) + 1
|
||||
return result
|
||||
@@ -1559,11 +1536,7 @@ function SessionGroupView(props: {
|
||||
<Show when={grouped().length > 0 || pending().length > 0}>
|
||||
<Show
|
||||
when={ctx.groupExploration()}
|
||||
fallback={
|
||||
<For each={[...grouped(), ...pending()]}>
|
||||
{(item) => <ToolPartWithImages messageID={item.messageID} part={item.part} />}
|
||||
</For>
|
||||
}
|
||||
fallback={<For each={[...grouped(), ...pending()]}>{(part) => <ToolPart part={part} />}</For>}
|
||||
>
|
||||
<Show when={grouped().length > 0}>
|
||||
<InlineToolRow
|
||||
@@ -1583,18 +1556,9 @@ function SessionGroupView(props: {
|
||||
</InlineToolRow>
|
||||
</Show>
|
||||
<Show when={expanded() && grouped().length > 0}>
|
||||
<For each={grouped()}>{(item) => <ToolPart part={item.part} />}</For>
|
||||
<For each={grouped()}>{(part) => <ToolPart part={part} />}</For>
|
||||
</Show>
|
||||
<ToolImages
|
||||
parts={grouped()}
|
||||
visible={ctx.imageKeys()}
|
||||
onOpen={(images, index) =>
|
||||
dialog.replace(() => <DialogImagePreview images={images} initial={index} />)
|
||||
}
|
||||
/>
|
||||
<For each={pending()}>
|
||||
{(item) => <ToolPartWithImages messageID={item.messageID} part={item.part} />}
|
||||
</For>
|
||||
<For each={pending()}>{(part) => <ToolPart part={part} />}</For>
|
||||
</Show>
|
||||
</Show>
|
||||
)
|
||||
@@ -1900,7 +1864,6 @@ function UserMessage(props: { message: SessionMessageUser }) {
|
||||
const data = useData()
|
||||
const local = useLocal()
|
||||
const files = createMemo(() => props.message.files ?? [])
|
||||
const images = createMemo(() => sessionMessageImages(props.message))
|
||||
const themes = useThemes()
|
||||
const theme = useTheme("elevated")
|
||||
const mode = themes.mode
|
||||
@@ -1919,31 +1882,28 @@ function UserMessage(props: { message: SessionMessageUser }) {
|
||||
border={["left"]}
|
||||
borderColor={queued() ? theme.border.default : color()}
|
||||
customBorderChars={SplitBorder.customBorderChars}
|
||||
backgroundColor={hover() ? theme.raise(theme.background.default) : theme.background.default}
|
||||
onMouseOver={() => setHover(true)}
|
||||
onMouseOut={() => setHover(false)}
|
||||
onMouseUp={() => {
|
||||
if (renderer.getSelection()?.getSelectedText()) return
|
||||
dialog.replace(() => (
|
||||
<DialogMessage
|
||||
messageID={props.message.id}
|
||||
sessionID={ctx.sessionID}
|
||||
setPrompt={(value) => promptRef.current?.set(value)}
|
||||
/>
|
||||
))
|
||||
}}
|
||||
>
|
||||
<SessionImages
|
||||
images={images()}
|
||||
visible={ctx.imageKeys()}
|
||||
onOpen={(images, index) =>
|
||||
dialog.replace(() => <DialogImagePreview images={images} initial={index} />)
|
||||
}
|
||||
/>
|
||||
<box
|
||||
onMouseOver={() => {
|
||||
setHover(true)
|
||||
}}
|
||||
onMouseOut={() => {
|
||||
setHover(false)
|
||||
}}
|
||||
onMouseUp={() => {
|
||||
if (renderer.getSelection()?.getSelectedText()) return
|
||||
dialog.replace(() => (
|
||||
<DialogMessage
|
||||
messageID={props.message.id}
|
||||
sessionID={ctx.sessionID}
|
||||
setPrompt={(value) => promptRef.current?.set(value)}
|
||||
/>
|
||||
))
|
||||
}}
|
||||
paddingTop={1}
|
||||
paddingBottom={1}
|
||||
paddingLeft={2}
|
||||
backgroundColor={hover() ? theme.raise(theme.background.default) : theme.background.default}
|
||||
flexShrink={0}
|
||||
>
|
||||
<text fg={theme.text.default}>{props.message.text}</text>
|
||||
@@ -2368,130 +2328,6 @@ function ToolPart(props: { part: SessionMessageAssistantTool }) {
|
||||
)
|
||||
}
|
||||
|
||||
function ToolPartWithImages(props: { messageID: string; part: SessionMessageAssistantTool }) {
|
||||
const ctx = use()
|
||||
const dialog = useDialog()
|
||||
return (
|
||||
<>
|
||||
<ToolPart part={props.part} />
|
||||
<ToolImages
|
||||
parts={[props]}
|
||||
visible={ctx.imageKeys()}
|
||||
onOpen={(images, index) => dialog.replace(() => <DialogImagePreview images={images} initial={index} />)}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export function ToolImages(props: {
|
||||
parts: readonly { messageID: string; part: SessionMessageAssistantTool }[]
|
||||
visible: ReadonlySet<string>
|
||||
onOpen?: (images: readonly { key: string; uri: string }[], index: number) => void
|
||||
}) {
|
||||
const images = createMemo(() => props.parts.flatMap((item) => inlineToolImages(item.messageID, item.part)))
|
||||
|
||||
return <SessionImages images={images()} visible={props.visible} onOpen={props.onOpen} />
|
||||
}
|
||||
|
||||
export function SessionImages(props: {
|
||||
images: readonly { key: string; uri: string }[]
|
||||
visible: ReadonlySet<string>
|
||||
onOpen?: (images: readonly { key: string; uri: string }[], index: number) => void
|
||||
}) {
|
||||
const dimensions = useTerminalDimensions()
|
||||
const images = createMemo(() => props.images.filter((image) => props.visible.has(image.key)))
|
||||
const height = createMemo(() => Math.max(4, Math.min(8, Math.floor(dimensions().height / 4))))
|
||||
const width = createMemo(() => height() * 2)
|
||||
const limit = createMemo(() =>
|
||||
Math.max(0, Math.min(3, Math.floor((Math.max(0, dimensions().width - 6) + 1) / (width() + 1)))),
|
||||
)
|
||||
const count = createMemo(() => Math.min(limit(), images().length))
|
||||
|
||||
return (
|
||||
<Show when={count() > 0}>
|
||||
<box
|
||||
flexDirection="row"
|
||||
flexShrink={0}
|
||||
paddingTop={1}
|
||||
paddingLeft={2}
|
||||
paddingRight={2}
|
||||
paddingBottom={1}
|
||||
gap={1}
|
||||
>
|
||||
<For each={images().slice(0, count())}>
|
||||
{(image, index) => {
|
||||
const [failed, setFailed] = createSignal(false)
|
||||
return (
|
||||
<box
|
||||
width={width()}
|
||||
height={height()}
|
||||
flexBasis={width()}
|
||||
flexShrink={0}
|
||||
alignItems="center"
|
||||
justifyContent="center"
|
||||
onMouseUp={(event: MouseEvent) => {
|
||||
if (event.button !== 0) return
|
||||
event.stopPropagation()
|
||||
props.onOpen?.(images(), index())
|
||||
}}
|
||||
>
|
||||
<Show when={!failed()} fallback={<text>No preview</text>}>
|
||||
<image
|
||||
id={`session-image-${image.key}`}
|
||||
source={image.uri}
|
||||
fit="cover"
|
||||
protocol="auto"
|
||||
width="100%"
|
||||
height="100%"
|
||||
onError={() => setFailed(true)}
|
||||
/>
|
||||
</Show>
|
||||
</box>
|
||||
)
|
||||
}}
|
||||
</For>
|
||||
<Show when={images().length > count()}>
|
||||
<box width={8} height={height()} flexShrink={1} alignItems="center" justifyContent="center">
|
||||
<text wrapMode="none" truncate>
|
||||
+{images().length - count()} more
|
||||
</text>
|
||||
</box>
|
||||
</Show>
|
||||
</box>
|
||||
</Show>
|
||||
)
|
||||
}
|
||||
|
||||
export function sessionImageKeys(messages: readonly SessionMessageInfo[], revertBoundary?: string) {
|
||||
return new Set(
|
||||
messages
|
||||
.filter((message) => !revertBoundary || message.id < revertBoundary)
|
||||
.flatMap(sessionMessageImages)
|
||||
.map((image) => image.key)
|
||||
.slice(-SESSION_IMAGE_LIMIT),
|
||||
)
|
||||
}
|
||||
|
||||
export function sessionMessageImages(message: SessionMessageInfo) {
|
||||
if (message.type === "user") {
|
||||
return (message.files ?? []).flatMap((file, index) =>
|
||||
file.mime.startsWith("image/")
|
||||
? [{ key: `${message.id}:file:${index}`, uri: `data:${file.mime};base64,${file.data}` }]
|
||||
: [],
|
||||
)
|
||||
}
|
||||
if (message.type !== "assistant") return []
|
||||
return message.content.flatMap((part) => (part.type === "tool" ? inlineToolImages(message.id, part) : []))
|
||||
}
|
||||
|
||||
function inlineToolImages(messageID: string, part: SessionMessageAssistantTool) {
|
||||
return toolDisplayContent(part.state).flatMap((content, index) =>
|
||||
content.type === "file" && content.mime.startsWith("image/") && content.uri.startsWith("data:image/")
|
||||
? [{ key: `${messageID}:${part.id}:${index}`, uri: content.uri }]
|
||||
: [],
|
||||
)
|
||||
}
|
||||
|
||||
type ToolProps = {
|
||||
input: Record<string, unknown>
|
||||
metadata: Record<string, unknown>
|
||||
|
||||
@@ -7,7 +7,6 @@ import { createStore } from "solid-js/store"
|
||||
import { useToast } from "./toast"
|
||||
import { useClipboard } from "../context/clipboard"
|
||||
import { useConfig } from "../config"
|
||||
import { formatClipboardWriteNotification } from "../clipboard"
|
||||
|
||||
export function Dialog(
|
||||
props: ParentProps<{
|
||||
@@ -203,10 +202,9 @@ export function DialogProvider(props: ParentProps) {
|
||||
|
||||
function copySelection() {
|
||||
const text = renderer.getSelection()?.getSelectedText()
|
||||
if (!text) return false
|
||||
if (!text || !clipboard.write) return false
|
||||
void clipboard.write(text).then(
|
||||
(outcome) =>
|
||||
toast.show(formatClipboardWriteNotification(outcome, { message: "Copied to clipboard", variant: "info" })),
|
||||
() => toast.show({ message: "Copied to clipboard", variant: "info" }),
|
||||
(error) => toast.error(error),
|
||||
)
|
||||
renderer.clearSelection()
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import type { ClipboardService } from "../context/clipboard"
|
||||
import { formatClipboardWriteNotification } from "../clipboard"
|
||||
|
||||
type Toast = {
|
||||
show: (input: { message: string; variant: "info" | "success" | "warning" | "error" }) => void
|
||||
@@ -36,10 +35,8 @@ export function copy(renderer: Renderer, toast: Toast, clipboard: ClipboardServi
|
||||
focus?.getClipboardText && selection.selectedRenderables.includes(focus) ? focus.getClipboardText(text) : text
|
||||
|
||||
clipboard
|
||||
.write(clipboardText)
|
||||
.then((outcome) =>
|
||||
toast.show(formatClipboardWriteNotification(outcome, { message: "Copied to clipboard", variant: "info" })),
|
||||
)
|
||||
?.write?.(clipboardText)
|
||||
.then(() => toast.show({ message: "Copied to clipboard", variant: "info" }))
|
||||
.catch(toast.error)
|
||||
|
||||
renderer.clearSelection()
|
||||
|
||||
@@ -1,89 +1,14 @@
|
||||
import { expect, mock, test } from "bun:test"
|
||||
import { createTestRenderer } from "@opentui/core/testing"
|
||||
import type {
|
||||
ClipboardOptions,
|
||||
ClipboardService,
|
||||
HostClipboardOptions,
|
||||
HostClipboardService,
|
||||
RendererClipboardBoundary,
|
||||
} from "@opentui/core"
|
||||
import { Effect, FileSystem } from "effect"
|
||||
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
||||
import { Global } from "@opencode-ai/util/global"
|
||||
import { createEventStream, createFetch, directory, json } from "./fixture/tui-client"
|
||||
|
||||
const openTui = { ...(await import("@opentui/core")) }
|
||||
|
||||
function restoreOpenTui() {
|
||||
mock.restore()
|
||||
mock.module("@opentui/core", () => openTui)
|
||||
}
|
||||
|
||||
async function mockOpenTuiClipboard(
|
||||
renderer: RendererClipboardBoundary,
|
||||
options: {
|
||||
dispose?: () => Promise<void>
|
||||
constructionError?: Error
|
||||
} = {},
|
||||
) {
|
||||
const calls = {
|
||||
host: [] as (HostClipboardOptions | undefined)[],
|
||||
adapter: [] as RendererClipboardBoundary[],
|
||||
service: [] as ClipboardOptions[],
|
||||
dispose: 0,
|
||||
hostDispose: 0,
|
||||
hostWrite: 0,
|
||||
}
|
||||
const host: HostClipboardService = {
|
||||
maxWriteBytes: 8 * 1024 * 1024,
|
||||
async read() {
|
||||
return { status: "empty" }
|
||||
},
|
||||
async writeText() {
|
||||
calls.hostWrite++
|
||||
return { status: "written" }
|
||||
},
|
||||
async clear() {
|
||||
return { status: "cleared" }
|
||||
},
|
||||
async dispose() {
|
||||
calls.hostDispose++
|
||||
await options.dispose?.()
|
||||
},
|
||||
}
|
||||
|
||||
mock.module("@opentui/core", () => ({
|
||||
...openTui,
|
||||
createCliRenderer: async () => renderer,
|
||||
createHostClipboard: (input?: HostClipboardOptions) => {
|
||||
if (options.constructionError) throw options.constructionError
|
||||
calls.host.push(input)
|
||||
return host
|
||||
},
|
||||
createRendererClipboardAdapter: (input: RendererClipboardBoundary) => {
|
||||
calls.adapter.push(input)
|
||||
return openTui.createRendererClipboardAdapter(input)
|
||||
},
|
||||
createClipboard: (input: ClipboardOptions) => {
|
||||
calls.service.push(input)
|
||||
const service = openTui.createClipboard(input)
|
||||
return {
|
||||
read: service.read,
|
||||
writeText: service.writeText,
|
||||
clear: service.clear,
|
||||
async dispose() {
|
||||
calls.dispose++
|
||||
await service.dispose()
|
||||
},
|
||||
} satisfies ClipboardService
|
||||
},
|
||||
}))
|
||||
return calls
|
||||
}
|
||||
|
||||
test("SIGHUP clears title and disposes scoped resources once", async () => {
|
||||
const setup = await createTestRenderer({ width: 80, height: 24, useThread: false })
|
||||
const clipboard = await mockOpenTuiClipboard(setup.renderer)
|
||||
const core = await import("@opentui/core")
|
||||
mock.module("@opentui/core", () => ({ ...core, createCliRenderer: async () => setup.renderer }))
|
||||
const titles: string[] = []
|
||||
let started!: () => void
|
||||
const ready = new Promise<void>((resolve) => {
|
||||
@@ -117,46 +42,18 @@ test("SIGHUP clears title and disposes scoped resources once", async () => {
|
||||
|
||||
expect(setup.renderer.isDestroyed).toBe(true)
|
||||
expect(titles.at(-1)).toBe("")
|
||||
expect(clipboard.host).toEqual([
|
||||
{
|
||||
timeoutMs: 1_000,
|
||||
maxReadBytes: 8 * 1024 * 1024,
|
||||
maxWriteBytes: 8 * 1024 * 1024,
|
||||
maxImagePixels: 64 * 1024 * 1024,
|
||||
maxConversionBytes: 512 * 1024 * 1024,
|
||||
maxConcurrentOperations: 16,
|
||||
maxProviderTransfers: 16,
|
||||
maxWorkUnitsPerDrain: 64,
|
||||
},
|
||||
])
|
||||
expect(clipboard.adapter).toEqual([setup.renderer])
|
||||
expect(clipboard.service).toHaveLength(1)
|
||||
expect(clipboard.dispose).toBe(1)
|
||||
expect(clipboard.hostDispose).toBe(1)
|
||||
expect(process.listeners("SIGHUP").every((listener) => listeners.has(listener))).toBe(true)
|
||||
} finally {
|
||||
if (!setup.renderer.isDestroyed) setup.renderer.destroy()
|
||||
await server.stop()
|
||||
restoreOpenTui()
|
||||
mock.restore()
|
||||
}
|
||||
})
|
||||
|
||||
test("session lifecycle updates the terminal title and prints the epilogue after cleanup", async () => {
|
||||
const setup = await createTestRenderer({ width: 80, height: 24, useThread: false })
|
||||
let releaseDispose!: () => void
|
||||
let startDispose!: () => void
|
||||
const disposeStarted = new Promise<void>((resolve) => {
|
||||
startDispose = resolve
|
||||
})
|
||||
const disposeReady = new Promise<void>((resolve) => {
|
||||
releaseDispose = resolve
|
||||
})
|
||||
const clipboard = await mockOpenTuiClipboard(setup.renderer, {
|
||||
dispose: async () => {
|
||||
startDispose()
|
||||
await disposeReady
|
||||
},
|
||||
})
|
||||
const core = await import("@opentui/core")
|
||||
mock.module("@opentui/core", () => ({ ...core, createCliRenderer: async () => setup.renderer }))
|
||||
let initialTitle!: () => void
|
||||
const initialTitleSet = new Promise<void>((resolve) => {
|
||||
initialTitle = resolve
|
||||
@@ -227,164 +124,17 @@ test("session lifecycle updates the terminal title and prints the epilogue after
|
||||
data: { sessionID: "dummy", title: "Renamed session" },
|
||||
})
|
||||
await renamedTitleSet
|
||||
let settled = false
|
||||
void task.then(
|
||||
() => (settled = true),
|
||||
() => (settled = true),
|
||||
)
|
||||
events.emit({
|
||||
id: "evt_exit",
|
||||
created: 2,
|
||||
type: "tui.command.execute",
|
||||
data: { command: "app.exit" },
|
||||
})
|
||||
await disposeStarted
|
||||
expect(settled).toBe(false)
|
||||
expect(stdout).toBe("")
|
||||
releaseDispose()
|
||||
setup.renderer.destroy()
|
||||
await task
|
||||
|
||||
expect(stdout).toContain("Renamed session")
|
||||
expect(stdout).toContain("opencode2 -s dummy")
|
||||
expect(promptRequests).toBe(0)
|
||||
expect(clipboard.dispose).toBe(1)
|
||||
expect(clipboard.hostDispose).toBe(1)
|
||||
} finally {
|
||||
releaseDispose()
|
||||
process.stdout.write = originalWrite
|
||||
if (!setup.renderer.isDestroyed) setup.renderer.destroy()
|
||||
await server.stop()
|
||||
restoreOpenTui()
|
||||
}
|
||||
})
|
||||
|
||||
test("direct renderer destruction disposes the clipboard once", async () => {
|
||||
const setup = await createTestRenderer({ width: 80, height: 24, useThread: false })
|
||||
const clipboard = await mockOpenTuiClipboard(setup.renderer)
|
||||
let started!: () => void
|
||||
const ready = new Promise<void>((resolve) => {
|
||||
started = resolve
|
||||
})
|
||||
const setTitle = setup.renderer.setTerminalTitle.bind(setup.renderer)
|
||||
setup.renderer.setTerminalTitle = (title) => {
|
||||
if (title === "OpenCode") started()
|
||||
setTitle(title)
|
||||
}
|
||||
const events = createEventStream()
|
||||
const calls = createFetch(undefined, events)
|
||||
const server = Bun.serve({ port: 0, fetch: (request) => calls.fetch(request) })
|
||||
|
||||
try {
|
||||
const { run } = await import("../src/app")
|
||||
const task = Effect.runPromise(
|
||||
run({
|
||||
app: { name: "test", version: "test", channel: "test" },
|
||||
server: { endpoint: { url: server.url.toString() } },
|
||||
config: { get: async () => ({}), update: async () => ({}) },
|
||||
packages: { resolve: async () => undefined },
|
||||
args: {},
|
||||
log: () => {},
|
||||
}).pipe(Effect.provide(AppNodeBuilder.build(Global.node)), Effect.provide(FileSystem.layerNoop({}))),
|
||||
)
|
||||
await ready
|
||||
const staleCopy = setup.renderer.console.onCopySelection
|
||||
setup.renderer.destroy()
|
||||
await task
|
||||
|
||||
expect(clipboard.dispose).toBe(1)
|
||||
expect(clipboard.hostDispose).toBe(1)
|
||||
await staleCopy?.("stale")
|
||||
expect(clipboard.hostWrite).toBe(0)
|
||||
} finally {
|
||||
if (!setup.renderer.isDestroyed) setup.renderer.destroy()
|
||||
await server.stop()
|
||||
restoreOpenTui()
|
||||
}
|
||||
})
|
||||
|
||||
test("clipboard construction failure releases the renderer", async () => {
|
||||
const setup = await createTestRenderer({ width: 80, height: 24, useThread: false })
|
||||
const failure = new Error("clipboard construction failed")
|
||||
await mockOpenTuiClipboard(setup.renderer, { constructionError: failure })
|
||||
const events = createEventStream()
|
||||
const calls = createFetch(undefined, events)
|
||||
const server = Bun.serve({ port: 0, fetch: (request) => calls.fetch(request) })
|
||||
|
||||
try {
|
||||
const { run } = await import("../src/app")
|
||||
await expect(
|
||||
Effect.runPromise(
|
||||
run({
|
||||
app: { name: "test", version: "test", channel: "test" },
|
||||
server: { endpoint: { url: server.url.toString() } },
|
||||
config: { get: async () => ({}), update: async () => ({}) },
|
||||
packages: { resolve: async () => undefined },
|
||||
args: {},
|
||||
log: () => {},
|
||||
}).pipe(Effect.provide(AppNodeBuilder.build(Global.node)), Effect.provide(FileSystem.layerNoop({}))),
|
||||
),
|
||||
).rejects.toThrow("clipboard construction failed")
|
||||
expect(setup.renderer.isDestroyed).toBe(true)
|
||||
} finally {
|
||||
if (!setup.renderer.isDestroyed) setup.renderer.destroy()
|
||||
await server.stop()
|
||||
restoreOpenTui()
|
||||
}
|
||||
})
|
||||
|
||||
test("clipboard disposal failure is logged without failing remaining cleanup", async () => {
|
||||
const setup = await createTestRenderer({ width: 80, height: 24, useThread: false })
|
||||
const clipboard = await mockOpenTuiClipboard(setup.renderer, {
|
||||
dispose: async () => {
|
||||
throw new Error("clipboard disposal failed")
|
||||
},
|
||||
})
|
||||
let started!: () => void
|
||||
const ready = new Promise<void>((resolve) => {
|
||||
started = resolve
|
||||
})
|
||||
const titles: string[] = []
|
||||
const setTitle = setup.renderer.setTerminalTitle.bind(setup.renderer)
|
||||
setup.renderer.setTerminalTitle = (title) => {
|
||||
titles.push(title)
|
||||
if (title === "OpenCode") started()
|
||||
setTitle(title)
|
||||
}
|
||||
const events = createEventStream()
|
||||
const calls = createFetch(undefined, events)
|
||||
const server = Bun.serve({ port: 0, fetch: (request) => calls.fetch(request) })
|
||||
const logs: unknown[] = []
|
||||
|
||||
try {
|
||||
const { run } = await import("../src/app")
|
||||
const task = Effect.runPromise(
|
||||
run({
|
||||
app: { name: "test", version: "test", channel: "test" },
|
||||
server: { endpoint: { url: server.url.toString() } },
|
||||
config: { get: async () => ({}), update: async () => ({}) },
|
||||
packages: { resolve: async () => undefined },
|
||||
args: {},
|
||||
log: (_level, message) => void logs.push(message),
|
||||
}).pipe(
|
||||
Effect.provide(AppNodeBuilder.build(Global.node)),
|
||||
Effect.provide(FileSystem.layerNoop({})),
|
||||
),
|
||||
)
|
||||
await ready
|
||||
setup.renderer.destroy()
|
||||
await task
|
||||
|
||||
expect(clipboard.dispose).toBe(1)
|
||||
expect(clipboard.hostDispose).toBe(1)
|
||||
expect(titles.at(-1)).toBe("")
|
||||
expect(
|
||||
logs.some((message) =>
|
||||
(Array.isArray(message) ? message : [message]).some((value) => value === "Failed to dispose TUI clipboard"),
|
||||
),
|
||||
).toBe(true)
|
||||
} finally {
|
||||
if (!setup.renderer.isDestroyed) setup.renderer.destroy()
|
||||
await server.stop()
|
||||
restoreOpenTui()
|
||||
mock.restore()
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@@ -5,7 +5,6 @@ import { expect, test } from "bun:test"
|
||||
import { onMount } from "solid-js"
|
||||
import { ConfigProvider, resolve, type Info, type Interface } from "../../../src/config"
|
||||
import { CommandPaletteDialog } from "../../../src/component/command-palette"
|
||||
import { settingID, settings } from "../../../src/component/dialog-config"
|
||||
import { Keymap } from "../../../src/context/keymap"
|
||||
import { ThemeProvider } from "../../../src/context/theme"
|
||||
import { DialogProvider, useDialog } from "../../../src/ui/dialog"
|
||||
@@ -14,10 +13,6 @@ import { TestTuiContexts } from "../../fixture/tui-environment"
|
||||
|
||||
test("searches settings globally and opens the matching setting", async () => {
|
||||
let current: Info = {}
|
||||
expect(settings.find((setting) => settingID(setting) === "session.image_preview")).toMatchObject({
|
||||
title: "Transcript images",
|
||||
default: false,
|
||||
})
|
||||
const service: Interface = {
|
||||
get: async () => current,
|
||||
update: async (update) => {
|
||||
@@ -79,11 +74,11 @@ test("searches settings globally and opens the matching setting", async () => {
|
||||
await app.waitFor(() => app.renderer.currentFocusedEditor instanceof InputRenderable)
|
||||
|
||||
app.mockInput.pressArrow("down")
|
||||
for (const key of "image preview") app.mockInput.pressKey(key)
|
||||
for (const key of "sounds") app.mockInput.pressKey(key)
|
||||
app.mockInput.pressEnter()
|
||||
await app.waitForFrame((frame) => frame.includes("Settings") && frame.includes("Image previews"))
|
||||
await app.waitForFrame((frame) => frame.includes("Settings") && frame.includes("Sounds"))
|
||||
app.mockInput.pressEnter()
|
||||
await app.waitFor(() => current.prompt?.image_preview === true)
|
||||
await app.waitFor(() => current.attention?.sound === false)
|
||||
} finally {
|
||||
app.renderer.destroy()
|
||||
}
|
||||
|
||||
@@ -60,19 +60,9 @@ async function mountForm(root: string, width = 80) {
|
||||
>
|
||||
<ClipboardProvider
|
||||
value={{
|
||||
async read() {
|
||||
return undefined
|
||||
},
|
||||
write(text) {
|
||||
copied.push(text)
|
||||
return Promise.resolve({
|
||||
delivery: "confirmed" as const,
|
||||
partial: false,
|
||||
result: {
|
||||
host: { status: "written" as const },
|
||||
terminal: { status: "not-attempted" as const, capability: "unknown" as const },
|
||||
},
|
||||
})
|
||||
return Promise.resolve()
|
||||
},
|
||||
}}
|
||||
>
|
||||
|
||||
@@ -1,147 +0,0 @@
|
||||
import { afterEach, expect, test } from "bun:test"
|
||||
import { ImageRenderable } from "@opentui/core"
|
||||
import { MouseButtons } from "@opentui/core/testing"
|
||||
import { testRender } from "@opentui/solid"
|
||||
import type { SessionMessageAssistant, SessionMessageAssistantTool, SessionMessageUser } from "@opencode-ai/client"
|
||||
import { sessionImageKeys, sessionMessageImages, SessionImages, ToolImages } from "../../../src/routes/session"
|
||||
|
||||
const PNG_1X1_BASE64 =
|
||||
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR4AWP4z8DwHwAFAAH/e+m+7wAAAABJRU5ErkJggg=="
|
||||
const image = { type: "file" as const, uri: `data:image/png;base64,${PNG_1X1_BASE64}`, mime: "image/png" }
|
||||
let setup: Awaited<ReturnType<typeof testRender>> | undefined
|
||||
|
||||
afterEach(() => {
|
||||
setup?.renderer.destroy()
|
||||
setup = undefined
|
||||
})
|
||||
|
||||
test("renders bounded inline images from completed tool content", async () => {
|
||||
let opened = -1
|
||||
setup = await testRender(
|
||||
() => toolImages([image, image, image, image, image, image, image], (_, index) => (opened = index)),
|
||||
{
|
||||
width: 80,
|
||||
height: 70,
|
||||
},
|
||||
)
|
||||
await setup.renderOnce()
|
||||
|
||||
const first = setup.renderer.root.findDescendantById("session-image-message-1:call-1:1")
|
||||
const second = setup.renderer.root.findDescendantById("session-image-message-1:call-1:2")
|
||||
if (!(first instanceof ImageRenderable)) throw new Error("Tool image did not render")
|
||||
if (!(second instanceof ImageRenderable)) throw new Error("Second tool image did not render")
|
||||
await first.loadPromise
|
||||
|
||||
expect(first.fit).toBe("cover")
|
||||
expect(first.protocol).toBe("auto")
|
||||
expect(first.width).toBe(16)
|
||||
expect(first.height).toBe(8)
|
||||
expect(second.y).toBe(first.y)
|
||||
expect(second.x).toBe(first.x + first.width + 1)
|
||||
expect(setup.renderer.root.findDescendantById("session-image-message-1:call-1:3")).toBeInstanceOf(ImageRenderable)
|
||||
expect(setup.renderer.root.findDescendantById("session-image-message-1:call-1:0")).toBeUndefined()
|
||||
expect(setup.renderer.root.findDescendantById("session-image-message-1:call-1:4")).toBeUndefined()
|
||||
expect(setup.captureCharFrame()).toContain("+3 more")
|
||||
|
||||
await setup.mockMouse.click(first.x, first.y, MouseButtons.LEFT)
|
||||
expect(opened).toBe(0)
|
||||
})
|
||||
|
||||
test("does not expose external image tool content to the renderer", () => {
|
||||
expect(
|
||||
sessionMessageImages(
|
||||
assistant("message-1", [
|
||||
tool([
|
||||
{ type: "file", uri: "https://example.test/image.png", mime: "image/png" },
|
||||
{ type: "file", uri: "file:///tmp/image.png", mime: "image/png" },
|
||||
{ type: "file", uri: "data:text/plain;base64,SGVsbG8=", mime: "text/plain" },
|
||||
]),
|
||||
]),
|
||||
),
|
||||
).toEqual([])
|
||||
})
|
||||
|
||||
test("does not render session images without the opt-in setting", async () => {
|
||||
const part = tool([image])
|
||||
setup = await testRender(() => <ToolImages parts={[{ messageID: "message-1", part }]} visible={new Set()} />, {
|
||||
width: 80,
|
||||
height: 24,
|
||||
})
|
||||
await setup.renderOnce()
|
||||
|
||||
expect(setup.renderer.root.findDescendantById("session-image-message-1:call-1:0")).toBeUndefined()
|
||||
})
|
||||
|
||||
test("renders images submitted in user prompts", async () => {
|
||||
const message: SessionMessageUser = {
|
||||
type: "user",
|
||||
id: "message-user",
|
||||
text: "What is in this image?",
|
||||
files: [{ data: PNG_1X1_BASE64, mime: "image/png", source: { type: "inline" }, name: "prompt.png" }],
|
||||
time: { created: 1 },
|
||||
}
|
||||
setup = await testRender(
|
||||
() => <SessionImages images={sessionMessageImages(message)} visible={sessionImageKeys([message])} />,
|
||||
{ width: 80, height: 24 },
|
||||
)
|
||||
await setup.renderOnce()
|
||||
|
||||
const preview = setup.renderer.root.findDescendantById("session-image-message-user:file:0")
|
||||
if (!(preview instanceof ImageRenderable)) throw new Error("User image did not render")
|
||||
await preview.loadPromise
|
||||
|
||||
expect(preview.fit).toBe("cover")
|
||||
})
|
||||
|
||||
test("does not reserve image slots for reverted messages", () => {
|
||||
const visible = assistant("message-1", [tool([image])])
|
||||
const reverted = assistant("message-2", [tool([image, image, image, image, image, image])])
|
||||
|
||||
expect([...sessionImageKeys([visible, reverted], reverted.id)]).toEqual(["message-1:call-1:0"])
|
||||
})
|
||||
|
||||
test("falls back when inline image content is malformed", async () => {
|
||||
setup = await testRender(
|
||||
() => toolImages([{ type: "file", uri: "data:image/png;base64,aW52YWxpZA==", mime: "image/png" }]),
|
||||
{ width: 80, height: 24 },
|
||||
)
|
||||
await setup.renderOnce()
|
||||
|
||||
const preview = setup.renderer.root.findDescendantById("session-image-message-1:call-1:0")
|
||||
if (!(preview instanceof ImageRenderable)) throw new Error("Tool image did not render")
|
||||
await preview.loadPromise
|
||||
|
||||
expect(await setup.waitForFrame((frame) => frame.includes("No preview"))).toContain("No preview")
|
||||
})
|
||||
|
||||
function toolImages(
|
||||
content: Extract<SessionMessageAssistantTool["state"], { status: "completed" }>["content"],
|
||||
onOpen?: (images: readonly { key: string; uri: string }[], index: number) => void,
|
||||
) {
|
||||
const part = tool(content)
|
||||
const message = assistant("message-1", [part])
|
||||
return <ToolImages parts={[{ messageID: message.id, part }]} visible={sessionImageKeys([message])} onOpen={onOpen} />
|
||||
}
|
||||
|
||||
function assistant(id: string, content: SessionMessageAssistant["content"]): SessionMessageAssistant {
|
||||
return {
|
||||
type: "assistant",
|
||||
id,
|
||||
agent: "build",
|
||||
model: { id: "model", providerID: "provider" },
|
||||
content,
|
||||
time: { created: 1 },
|
||||
}
|
||||
}
|
||||
|
||||
function tool(
|
||||
content: Extract<SessionMessageAssistantTool["state"], { status: "completed" }>["content"],
|
||||
): SessionMessageAssistantTool {
|
||||
return {
|
||||
type: "tool",
|
||||
id: "call-1",
|
||||
name: "image",
|
||||
state: { status: "completed", input: {}, content },
|
||||
time: { created: 0, completed: 1 },
|
||||
}
|
||||
}
|
||||
@@ -1,271 +1,19 @@
|
||||
import { expect, test } from "bun:test"
|
||||
import {
|
||||
createClipboard,
|
||||
type ClipboardReadOptions,
|
||||
type ClipboardReadResult,
|
||||
type ClipboardService as CoreClipboardService,
|
||||
type ClipboardWriteOptions,
|
||||
type ClipboardWriteResult,
|
||||
type HostClipboardService,
|
||||
} from "@opentui/core"
|
||||
import {
|
||||
ClipboardWriteError,
|
||||
classifyClipboardWriteResult,
|
||||
createClipboardAdapter,
|
||||
formatClipboardWriteNotification,
|
||||
} from "../src/clipboard"
|
||||
import { copyCommand } from "../src/clipboard"
|
||||
|
||||
type OpenTuiFixture = {
|
||||
read?: ClipboardReadResult
|
||||
remote?: boolean
|
||||
onCoreRead?: (options: ClipboardReadOptions) => void
|
||||
onCoreWrite?: (text: string, options: ClipboardWriteOptions) => void
|
||||
onHostWrite?: () => void
|
||||
onTerminalWrite?: () => void
|
||||
}
|
||||
|
||||
function openTuiClipboard(options: OpenTuiFixture = {}) {
|
||||
const host: HostClipboardService = {
|
||||
maxWriteBytes: 8 * 1024 * 1024,
|
||||
async read() {
|
||||
return options.read ?? { status: "empty" }
|
||||
},
|
||||
async writeText() {
|
||||
options.onHostWrite?.()
|
||||
return { status: "written" }
|
||||
},
|
||||
async clear() {
|
||||
return { status: "cleared" }
|
||||
},
|
||||
async dispose() {},
|
||||
}
|
||||
const clipboard = createClipboard({
|
||||
host,
|
||||
terminal: {
|
||||
remote: options.remote ?? false,
|
||||
writeText() {
|
||||
options.onTerminalWrite?.()
|
||||
return { status: "attempted", capability: "supported" }
|
||||
},
|
||||
clear() {
|
||||
return { status: "attempted", capability: "supported" }
|
||||
},
|
||||
},
|
||||
})
|
||||
return {
|
||||
read(input) {
|
||||
options.onCoreRead?.(input)
|
||||
return clipboard.read(input)
|
||||
},
|
||||
writeText(text, input) {
|
||||
options.onCoreWrite?.(text, input)
|
||||
return clipboard.writeText(text, input)
|
||||
},
|
||||
clear: clipboard.clear,
|
||||
dispose: clipboard.dispose,
|
||||
} satisfies CoreClipboardService
|
||||
}
|
||||
|
||||
function writeResult(
|
||||
host: ClipboardWriteResult["host"]["status"],
|
||||
terminal: ClipboardWriteResult["terminal"]["status"],
|
||||
error = new Error("host failed"),
|
||||
): ClipboardWriteResult {
|
||||
return {
|
||||
host: host === "failed" ? { status: host, error } : { status: host },
|
||||
terminal: { status: terminal, capability: "supported" },
|
||||
}
|
||||
}
|
||||
|
||||
function writeError(result: ClipboardWriteResult) {
|
||||
try {
|
||||
classifyClipboardWriteResult(result)
|
||||
} catch (error) {
|
||||
if (error instanceof ClipboardWriteError) return error
|
||||
throw error
|
||||
}
|
||||
throw new Error("Expected clipboard classification to fail")
|
||||
}
|
||||
|
||||
test("requests the standard clipboard with image-first preferences and adapts PNG bytes", async () => {
|
||||
const requests: ClipboardReadOptions[] = []
|
||||
const bytes = new Uint8Array([0, 1, 2, 255])
|
||||
const clipboard = createClipboardAdapter(
|
||||
openTuiClipboard({
|
||||
read: { status: "read", representation: { mimeType: "image/png", bytes } },
|
||||
onCoreRead: (input) => requests.push(input),
|
||||
}),
|
||||
)
|
||||
|
||||
expect(await clipboard.read()).toEqual({ data: "AAEC/w==", mime: "image/png" })
|
||||
expect(bytes).toEqual(new Uint8Array([0, 1, 2, 255]))
|
||||
expect(requests).toEqual([{ preferredTypes: ["image/png", "text/plain"], selection: "clipboard" }])
|
||||
test("prefers Wayland clipboard when available", () => {
|
||||
expect(copyCommand("linux", true, (name) => name === "wl-copy")).toEqual(["wl-copy"])
|
||||
})
|
||||
|
||||
test("decodes text through OpenTUI without normalizing its contents", async () => {
|
||||
const text = "line 1\r\n\t\u001b[31m世界"
|
||||
const bytes = new TextEncoder().encode(text)
|
||||
const clipboard = createClipboardAdapter(
|
||||
openTuiClipboard({ read: { status: "read", representation: { mimeType: "text/plain", bytes } } }),
|
||||
)
|
||||
|
||||
expect(await clipboard.read()).toEqual({ data: text, mime: "text/plain" })
|
||||
test("uses osascript on macOS", () => {
|
||||
expect(copyCommand("darwin", false, (name) => name === "osascript")).toEqual(["osascript"])
|
||||
})
|
||||
|
||||
test("maps empty host results and zero-byte text to no content", async () => {
|
||||
await Promise.all(
|
||||
(["empty", "unsupported", "cancelled"] as const).map(async (status) => {
|
||||
expect(await createClipboardAdapter(openTuiClipboard({ read: { status } })).read()).toBeUndefined()
|
||||
}),
|
||||
)
|
||||
expect(
|
||||
await createClipboardAdapter(
|
||||
openTuiClipboard({
|
||||
read: { status: "read", representation: { mimeType: "text/plain", bytes: new Uint8Array() } },
|
||||
}),
|
||||
).read(),
|
||||
).toBeUndefined()
|
||||
test("falls back through X11 clipboard commands", () => {
|
||||
expect(copyCommand("linux", true, (name) => name === "xclip")).toEqual(["xclip", "-selection", "clipboard"])
|
||||
expect(copyCommand("linux", false, (name) => name === "xsel")).toEqual(["xsel", "--clipboard", "--input"])
|
||||
})
|
||||
|
||||
test("preserves backend read failures and synthesizes operational errors", async () => {
|
||||
const failure = new Error("read failed")
|
||||
const failed = createClipboardAdapter(openTuiClipboard({ read: { status: "failed", error: failure } }))
|
||||
expect(await failed.read().then(undefined, (error) => error)).toBe(failure)
|
||||
|
||||
const timedOut = createClipboardAdapter(openTuiClipboard({ read: { status: "timed-out" } }))
|
||||
await expect(timedOut.read()).rejects.toThrow("Clipboard read timed out after 1000ms")
|
||||
|
||||
const limited = createClipboardAdapter(openTuiClipboard({ read: { status: "limit-exceeded" } }))
|
||||
const limitError = await limited.read().then(undefined, (error) => error)
|
||||
expect(limitError).toBeInstanceOf(RangeError)
|
||||
if (!(limitError instanceof RangeError)) throw limitError
|
||||
expect(limitError.message).toBe("Clipboard content exceeded configured read or image conversion limits")
|
||||
|
||||
const unexpected = createClipboardAdapter(
|
||||
openTuiClipboard({
|
||||
read: { status: "read", representation: { mimeType: "text/html", bytes: new Uint8Array([1]) } },
|
||||
}),
|
||||
)
|
||||
await expect(unexpected.read()).rejects.toThrow("Unexpected clipboard MIME type: text/html")
|
||||
})
|
||||
|
||||
test("uses OpenTUI all-available composition for local writes", async () => {
|
||||
let hostWrites = 0
|
||||
let terminalWrites = 0
|
||||
const writes: [string, ClipboardWriteOptions][] = []
|
||||
const clipboard = createClipboardAdapter(
|
||||
openTuiClipboard({
|
||||
onHostWrite: () => hostWrites++,
|
||||
onTerminalWrite: () => terminalWrites++,
|
||||
onCoreWrite: (text, options) => writes.push([text, options]),
|
||||
}),
|
||||
)
|
||||
|
||||
expect(await clipboard.write("hello")).toMatchObject({ delivery: "confirmed", partial: false })
|
||||
expect(hostWrites).toBe(1)
|
||||
expect(terminalWrites).toBe(1)
|
||||
expect(writes).toEqual([["hello", { destination: "all-available", selection: "clipboard" }]])
|
||||
})
|
||||
|
||||
test("does not authorize a process-host write for remote renderers", async () => {
|
||||
let hostWrites = 0
|
||||
let terminalWrites = 0
|
||||
const clipboard = createClipboardAdapter(
|
||||
openTuiClipboard({
|
||||
remote: true,
|
||||
onHostWrite: () => hostWrites++,
|
||||
onTerminalWrite: () => terminalWrites++,
|
||||
}),
|
||||
)
|
||||
|
||||
expect(await clipboard.write("hello")).toMatchObject({
|
||||
delivery: "attempted",
|
||||
partial: false,
|
||||
result: { host: { status: "not-attempted" }, terminal: { status: "attempted" } },
|
||||
})
|
||||
expect(hostWrites).toBe(0)
|
||||
expect(terminalWrites).toBe(1)
|
||||
})
|
||||
|
||||
test("classifies confirmed, attempted, and partial delivery", () => {
|
||||
const scenarios = [
|
||||
{ result: writeResult("written", "attempted"), delivery: "confirmed", partial: false },
|
||||
{ result: writeResult("written", "not-attempted"), delivery: "confirmed", partial: false },
|
||||
{ result: writeResult("unsupported", "attempted"), delivery: "attempted", partial: false },
|
||||
{ result: writeResult("failed", "attempted"), delivery: "attempted", partial: true },
|
||||
{ result: writeResult("timed-out", "attempted"), delivery: "attempted", partial: true },
|
||||
{ result: writeResult("cancelled", "attempted"), delivery: "attempted", partial: true },
|
||||
{ result: writeResult("written", "local-failure"), delivery: "confirmed", partial: true },
|
||||
] as const
|
||||
|
||||
scenarios.forEach((scenario) => {
|
||||
expect(classifyClipboardWriteResult(scenario.result)).toMatchObject({
|
||||
delivery: scenario.delivery,
|
||||
partial: scenario.partial,
|
||||
result: scenario.result,
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
test("rejects every unavailable total-failure combination", () => {
|
||||
const hosts = ["failed", "unsupported", "cancelled", "timed-out", "not-attempted"] as const
|
||||
const terminals = ["local-failure", "not-attempted"] as const
|
||||
|
||||
hosts.flatMap((host) => terminals.map((terminal) => [host, terminal] as const)).forEach(([host, terminal]) => {
|
||||
const failure = new Error("native write failed")
|
||||
const result = writeResult(host, terminal, failure)
|
||||
const error = writeError(result)
|
||||
expect(error.message).toBe(`Clipboard write failed (host: ${host}, terminal: ${terminal})`)
|
||||
expect(error.result).toBe(result)
|
||||
expect(error.cause).toBe(host === "failed" ? failure : undefined)
|
||||
})
|
||||
})
|
||||
|
||||
test("formats truthful notifications for every delivered outcome", () => {
|
||||
const confirmed = { message: "Copied value", variant: "success" as const }
|
||||
expect(
|
||||
formatClipboardWriteNotification(
|
||||
{ delivery: "confirmed", partial: false, result: writeResult("written", "attempted") },
|
||||
confirmed,
|
||||
),
|
||||
).toBe(confirmed)
|
||||
expect(
|
||||
formatClipboardWriteNotification(
|
||||
{ delivery: "attempted", partial: false, result: writeResult("unsupported", "attempted") },
|
||||
confirmed,
|
||||
),
|
||||
).toEqual({ message: "Sent to terminal clipboard (acceptance unconfirmed)", variant: "info" })
|
||||
expect(
|
||||
formatClipboardWriteNotification(
|
||||
{ delivery: "confirmed", partial: true, result: writeResult("written", "local-failure") },
|
||||
confirmed,
|
||||
),
|
||||
).toEqual({ message: "Copied to host clipboard; terminal clipboard dispatch failed", variant: "warning" })
|
||||
expect(
|
||||
formatClipboardWriteNotification(
|
||||
{ delivery: "attempted", partial: true, result: writeResult("failed", "attempted") },
|
||||
confirmed,
|
||||
),
|
||||
).toEqual({ message: "Sent to terminal clipboard; host clipboard write failed", variant: "warning" })
|
||||
})
|
||||
|
||||
test("retains OpenTUI text validation and the configured 8 MiB UTF-8 bound", async () => {
|
||||
let hostWrites = 0
|
||||
let terminalWrites = 0
|
||||
const clipboard = createClipboardAdapter(
|
||||
openTuiClipboard({
|
||||
onHostWrite: () => hostWrites++,
|
||||
onTerminalWrite: () => terminalWrites++,
|
||||
}),
|
||||
)
|
||||
|
||||
const boundary = "é".repeat(4 * 1024 * 1024)
|
||||
await expect(clipboard.write(boundary)).resolves.toMatchObject({ delivery: "confirmed" })
|
||||
await expect(clipboard.write("")).rejects.toThrow("writeText requires non-empty text")
|
||||
await expect(clipboard.write("before\0after")).rejects.toThrow("writeText does not support NUL characters")
|
||||
await expect(clipboard.write(boundary + "a")).rejects.toThrow(
|
||||
"writeText exceeds the configured 8388608 byte limit",
|
||||
)
|
||||
expect(hostWrites).toBe(1)
|
||||
expect(terminalWrites).toBe(1)
|
||||
test("returns undefined when native clipboard is unavailable", () => {
|
||||
expect(copyCommand("linux", false, () => false)).toBeUndefined()
|
||||
})
|
||||
|
||||
@@ -1,131 +0,0 @@
|
||||
import { expect, test } from "bun:test"
|
||||
import { testRender } from "@opentui/solid"
|
||||
import { ErrorComponent } from "../../src/component/error-component"
|
||||
import { ClipboardProvider, type ClipboardService, type ClipboardWriteOutcome } from "../../src/context/clipboard"
|
||||
import { ExitProvider } from "../../src/context/exit"
|
||||
import { TuiAppProvider } from "../../src/context/runtime"
|
||||
|
||||
const outcomes = [
|
||||
{
|
||||
label: "Copied",
|
||||
absent: "Copied (terminal failed)",
|
||||
outcome: {
|
||||
delivery: "confirmed",
|
||||
partial: false,
|
||||
result: {
|
||||
host: { status: "written" },
|
||||
terminal: { status: "attempted", capability: "supported" },
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
label: "Copied (terminal failed)",
|
||||
absent: undefined,
|
||||
outcome: {
|
||||
delivery: "confirmed",
|
||||
partial: true,
|
||||
result: {
|
||||
host: { status: "written" },
|
||||
terminal: { status: "local-failure", capability: "supported" },
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
label: "Sent",
|
||||
absent: "Sent (host failed)",
|
||||
outcome: {
|
||||
delivery: "attempted",
|
||||
partial: false,
|
||||
result: {
|
||||
host: { status: "unsupported" },
|
||||
terminal: { status: "attempted", capability: "supported" },
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
label: "Sent (host failed)",
|
||||
absent: undefined,
|
||||
outcome: {
|
||||
delivery: "attempted",
|
||||
partial: true,
|
||||
result: {
|
||||
host: { status: "failed", error: new Error("host failed") },
|
||||
terminal: { status: "attempted", capability: "supported" },
|
||||
},
|
||||
},
|
||||
},
|
||||
] as const satisfies readonly { label: string; absent: string | undefined; outcome: ClipboardWriteOutcome }[]
|
||||
|
||||
function clipboard(write: () => Promise<ClipboardWriteOutcome>): ClipboardService {
|
||||
return {
|
||||
async read() {
|
||||
return undefined
|
||||
},
|
||||
write,
|
||||
}
|
||||
}
|
||||
|
||||
async function waitForFrame(
|
||||
app: { renderOnce(): Promise<void>; captureCharFrame(): string },
|
||||
match: (frame: string) => boolean,
|
||||
) {
|
||||
for (let attempts = 0; attempts < 20; attempts++) {
|
||||
await app.renderOnce()
|
||||
const frame = app.captureCharFrame()
|
||||
if (match(frame)) return frame
|
||||
await Bun.sleep(1)
|
||||
}
|
||||
throw new Error("Timed out waiting for clipboard state")
|
||||
}
|
||||
|
||||
test("crash report distinguishes every delivered clipboard state", async () => {
|
||||
for (const scenario of outcomes) {
|
||||
const app = await testRender(
|
||||
() => (
|
||||
<TuiAppProvider value={{ name: "test", version: "test", channel: "test" }}>
|
||||
<ExitProvider exit={() => {}}>
|
||||
<ClipboardProvider value={clipboard(async () => scenario.outcome)}>
|
||||
<ErrorComponent error={new Error("boom")} reset={() => {}} />
|
||||
</ClipboardProvider>
|
||||
</ExitProvider>
|
||||
</TuiAppProvider>
|
||||
),
|
||||
{ width: 100, height: 24 },
|
||||
)
|
||||
try {
|
||||
await app.renderOnce()
|
||||
expect(app.captureCharFrame()).toContain("Copy report")
|
||||
app.mockInput.pressKey("c")
|
||||
const frame = await waitForFrame(app, (frame) => frame.includes(scenario.label))
|
||||
if (scenario.absent) expect(frame).not.toContain(scenario.absent)
|
||||
} finally {
|
||||
app.renderer.destroy()
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
test("crash report catches clipboard rejection", async () => {
|
||||
const app = await testRender(
|
||||
() => (
|
||||
<TuiAppProvider value={{ name: "test", version: "test", channel: "test" }}>
|
||||
<ExitProvider exit={() => {}}>
|
||||
<ClipboardProvider
|
||||
value={clipboard(async () => {
|
||||
throw new Error("copy failed")
|
||||
})}
|
||||
>
|
||||
<ErrorComponent error={new Error("boom")} reset={() => {}} />
|
||||
</ClipboardProvider>
|
||||
</ExitProvider>
|
||||
</TuiAppProvider>
|
||||
),
|
||||
{ width: 100, height: 24 },
|
||||
)
|
||||
try {
|
||||
await app.renderOnce()
|
||||
app.mockInput.pressKey("c")
|
||||
expect(await waitForFrame(app, (frame) => frame.includes("Copy failed"))).toContain("Copy failed")
|
||||
} finally {
|
||||
app.renderer.destroy()
|
||||
}
|
||||
})
|
||||
@@ -14,14 +14,11 @@ test("validates mini replay settings", () => {
|
||||
expect(() => decode({ mini: { replay_limit: 1.5 } })).toThrow()
|
||||
})
|
||||
|
||||
test("validates boolean settings", () => {
|
||||
test("validates the session tabs setting", () => {
|
||||
const decode = Schema.decodeUnknownSync(Info)
|
||||
|
||||
expect(decode({ tabs: { enabled: true } })).toEqual({ tabs: { enabled: true } })
|
||||
expect(() => decode({ tabs: { enabled: "on" } })).toThrow()
|
||||
expect(decode({ prompt: { image_preview: true } })).toEqual({ prompt: { image_preview: true } })
|
||||
expect(() => decode({ prompt: { image_preview: "on" } })).toThrow()
|
||||
expect(decode({ session: { image_preview: true } })).toEqual({ session: { image_preview: true } })
|
||||
})
|
||||
|
||||
test("resolves nested config and keybind defaults", () => {
|
||||
|
||||
@@ -86,14 +86,6 @@ test("resolves a session move keybind", () => {
|
||||
expect(config.keybinds.get("session.move")).toMatchObject([{ key: "ctrl+o" }])
|
||||
})
|
||||
|
||||
test("resolves the image viewer keybind", () => {
|
||||
const defaults = resolve({}, { terminalSuspend: true })
|
||||
const overridden = resolve({ keybinds: { prompt_images_view: "ctrl+shift+i" } }, { terminalSuspend: true })
|
||||
|
||||
expect(defaults.keybinds.get("prompt.images.view")).toMatchObject([{ key: "<leader>i" }])
|
||||
expect(overridden.keybinds.get("prompt.images.view")).toMatchObject([{ key: "ctrl+shift+i" }])
|
||||
})
|
||||
|
||||
test("resolves message navigation defaults", () => {
|
||||
const config = resolve({}, { terminalSuspend: true })
|
||||
|
||||
|
||||
@@ -1,50 +0,0 @@
|
||||
import { expect, test } from "bun:test"
|
||||
import { testRender } from "@opentui/solid"
|
||||
import { ErrorBoundary, type JSX } from "solid-js"
|
||||
import { ClipboardProvider, type ClipboardService, useClipboard } from "../../src/context/clipboard"
|
||||
|
||||
const clipboard: ClipboardService = {
|
||||
async read() {
|
||||
return { data: "text", mime: "text/plain" }
|
||||
},
|
||||
async write() {
|
||||
return {
|
||||
delivery: "confirmed",
|
||||
partial: false,
|
||||
result: {
|
||||
host: { status: "written" },
|
||||
terminal: { status: "not-attempted", capability: "unknown" },
|
||||
},
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
test("requires explicit provider injection", () => {
|
||||
expect(() => useClipboard()).toThrow("useClipboard must be used within a ClipboardProvider")
|
||||
})
|
||||
|
||||
test("keeps clipboard access available to an error boundary fallback", async () => {
|
||||
let value: ClipboardService | undefined
|
||||
function Crash(): JSX.Element {
|
||||
throw new Error("crash")
|
||||
}
|
||||
function Fallback() {
|
||||
value = useClipboard()
|
||||
return <text>fallback</text>
|
||||
}
|
||||
|
||||
const app = await testRender(() => (
|
||||
<ClipboardProvider value={clipboard}>
|
||||
<ErrorBoundary fallback={() => <Fallback />}>
|
||||
<Crash />
|
||||
</ErrorBoundary>
|
||||
</ClipboardProvider>
|
||||
))
|
||||
try {
|
||||
await app.renderOnce()
|
||||
expect(app.captureCharFrame()).toContain("fallback")
|
||||
expect(value).toBe(clipboard)
|
||||
} finally {
|
||||
app.renderer.destroy()
|
||||
}
|
||||
})
|
||||
@@ -64,6 +64,7 @@ async function renderSessionTabs(initialSessionID: string) {
|
||||
return {
|
||||
tabs,
|
||||
route,
|
||||
state,
|
||||
emit: (event: OpenCodeEvent) => events.emit({ ...event, location: { directory } }),
|
||||
destroy() {
|
||||
app.renderer.destroy()
|
||||
@@ -72,6 +73,21 @@ async function renderSessionTabs(initialSessionID: string) {
|
||||
}
|
||||
}
|
||||
|
||||
test("stores session tabs globally by default", async () => {
|
||||
const setup = await renderSessionTabs("first")
|
||||
|
||||
try {
|
||||
const file = path.join(setup.state, "test", "tui", "tabs.json")
|
||||
await wait(() => Bun.file(file).size > 0)
|
||||
expect(await Bun.file(file).json()).toEqual({
|
||||
global: { tabs: [{ sessionID: "first" }], unread: {} },
|
||||
cwd: {},
|
||||
})
|
||||
} finally {
|
||||
setup.destroy()
|
||||
}
|
||||
})
|
||||
|
||||
test("user prompt admissions pulse an already-busy background tab", async () => {
|
||||
const setup = await renderSessionTabs("background")
|
||||
const admitted = (sessionID: string, inputID: string): OpenCodeEvent => ({
|
||||
@@ -106,9 +122,7 @@ test("user prompt admissions pulse an already-busy background tab", async () =>
|
||||
expect(setup.tabs.status("background").promptPulse).toBe(0)
|
||||
|
||||
setup.emit(admitted("background", "msg_1"))
|
||||
await wait(
|
||||
() => setup.tabs.status("background").promptPulse === 1 && setup.tabs.status("background").busy,
|
||||
)
|
||||
await wait(() => setup.tabs.status("background").promptPulse === 1 && setup.tabs.status("background").busy)
|
||||
|
||||
setup.emit(admitted("background", "msg_2"))
|
||||
await wait(() => setup.tabs.status("background").promptPulse === 2)
|
||||
@@ -144,9 +158,7 @@ test("tracks a temporary new session tab across close and creation", async () =>
|
||||
await wait(() => setup.tabs.newTab())
|
||||
setup.route.navigate({ type: "session", sessionID: "third" })
|
||||
expect(setup.tabs.newTab()).toBe(true)
|
||||
await wait(
|
||||
() => setup.tabs.current() === "third" && setup.tabs.tabs().some((tab) => tab.sessionID === "third"),
|
||||
)
|
||||
await wait(() => setup.tabs.current() === "third" && setup.tabs.tabs().some((tab) => tab.sessionID === "third"))
|
||||
|
||||
expect(setup.tabs.newTab()).toBe(false)
|
||||
expect(setup.tabs.tabs().find((tab) => tab.sessionID === "third")?.title).toBe(NEW_SESSION_TAB_TITLE)
|
||||
|
||||
@@ -7,23 +7,6 @@ import {
|
||||
} from "../../src/context/runtime"
|
||||
import type { ParentProps } from "solid-js"
|
||||
import { LogProvider, type LogSink } from "../../src/context/log"
|
||||
import { ClipboardProvider, type ClipboardService } from "../../src/context/clipboard"
|
||||
|
||||
const clipboard: ClipboardService = {
|
||||
async read() {
|
||||
return undefined
|
||||
},
|
||||
async write() {
|
||||
return {
|
||||
delivery: "confirmed",
|
||||
partial: false,
|
||||
result: {
|
||||
host: { status: "written" },
|
||||
terminal: { status: "not-attempted", capability: "unknown" },
|
||||
},
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
export function TestTuiContexts(
|
||||
props: ParentProps<{
|
||||
@@ -31,7 +14,6 @@ export function TestTuiContexts(
|
||||
directory?: string
|
||||
paths?: Partial<TuiPaths>
|
||||
log?: LogSink
|
||||
clipboard?: ClipboardService
|
||||
}>,
|
||||
) {
|
||||
return (
|
||||
@@ -46,9 +28,7 @@ export function TestTuiContexts(
|
||||
}}
|
||||
>
|
||||
<TuiTerminalEnvironmentProvider value={{ platform: "linux" }}>
|
||||
<TuiStartupProvider value={{ skipInitialLoading: false }}>
|
||||
<ClipboardProvider value={props.clipboard ?? clipboard}>{props.children}</ClipboardProvider>
|
||||
</TuiStartupProvider>
|
||||
<TuiStartupProvider value={{ skipInitialLoading: false }}>{props.children}</TuiStartupProvider>
|
||||
</TuiTerminalEnvironmentProvider>
|
||||
</TuiPathsProvider>
|
||||
</LogProvider>
|
||||
|
||||
@@ -1,534 +0,0 @@
|
||||
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 { parsePromptInfo } from "../../src/prompt/history"
|
||||
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, width = 80) {
|
||||
const setup = await createTestRenderer({ width, 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("distinguishes whitespace-only terminal paste from empty clipboard fallback", async () => {
|
||||
const prompt = await mountPrompt(async () => ({ status: "empty" }))
|
||||
try {
|
||||
await prompt.setup.mockInput.pasteBracketedText(" \t\n")
|
||||
await prompt.setup.waitFor(() => prompt.input.plainText === " \t\n")
|
||||
expect(prompt.reads).toBe(0)
|
||||
|
||||
prompt.setup.renderer.keyInput.processPaste(new Uint8Array())
|
||||
await prompt.setup.waitFor(() => prompt.reads === 1)
|
||||
|
||||
expect(prompt.input.plainText).toBe(" \t\n")
|
||||
expect(prompt.reads).toBe(1)
|
||||
} finally {
|
||||
await prompt.dispose()
|
||||
}
|
||||
})
|
||||
|
||||
test("normalizes host clipboard text once before inserting it", async () => {
|
||||
const bytes = new TextEncoder().encode("first\r\nsecond\rthird")
|
||||
const prompt = await mountPrompt(async () => ({ status: "read", representation: { mimeType: "text/plain", bytes } }))
|
||||
try {
|
||||
prompt.setup.renderer.keyInput.processPaste(new Uint8Array())
|
||||
await prompt.setup.waitFor(() => prompt.input.plainText === "first\nsecond\nthird")
|
||||
|
||||
expect(prompt.input.plainText).toBe("first\nsecond\nthird")
|
||||
expect(prompt.reads).toBe(1)
|
||||
} finally {
|
||||
await prompt.dispose()
|
||||
}
|
||||
})
|
||||
|
||||
test("serializes overlapping local image pastes", async () => {
|
||||
const directory = await mkdtemp(path.join(tmpdir(), "opencode-overlap-"))
|
||||
const file = path.join(directory, "image.png")
|
||||
await writeFile(file, PNG_1X1)
|
||||
const prompt = await mountPrompt(async () => ({ status: "empty" }))
|
||||
try {
|
||||
await Promise.all([
|
||||
prompt.setup.mockInput.pasteBracketedText(`'${file}'`),
|
||||
prompt.setup.mockInput.pasteBracketedText(`'${file}'`),
|
||||
])
|
||||
await prompt.setup.waitFor(() => prompt.prompt.current.files?.length === 2)
|
||||
|
||||
expect(prompt.input.plainText).toBe("[Image 1] [Image 2] ")
|
||||
} finally {
|
||||
await prompt.dispose()
|
||||
await rm(directory, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
test("continues queued paste work after a clipboard failure", async () => {
|
||||
const directory = await mkdtemp(path.join(tmpdir(), "opencode-recovery-"))
|
||||
const file = path.join(directory, "image.png")
|
||||
await writeFile(file, PNG_1X1)
|
||||
const result = Promise.withResolvers<ClipboardReadResult>()
|
||||
const prompt = await mountPrompt(() => result.promise)
|
||||
try {
|
||||
prompt.setup.renderer.keyInput.processPaste(new Uint8Array())
|
||||
await prompt.setup.waitFor(() => prompt.reads === 1)
|
||||
await prompt.setup.mockInput.pasteBracketedText(`'${file}'`)
|
||||
result.reject(new Error("clipboard failed"))
|
||||
await prompt.setup.waitFor(() => prompt.prompt.current.files?.length === 1)
|
||||
|
||||
expect(prompt.input.plainText).toBe("[Image 1] ")
|
||||
} finally {
|
||||
await prompt.dispose()
|
||||
await rm(directory, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
test("cancels delayed and queued pastes after a reversible prompt change", async () => {
|
||||
const result = Promise.withResolvers<ClipboardReadResult>()
|
||||
const prompt = await mountPrompt(() => result.promise)
|
||||
try {
|
||||
prompt.setup.renderer.keyInput.processPaste(new Uint8Array())
|
||||
await prompt.setup.waitFor(() => prompt.reads === 1)
|
||||
await prompt.setup.mockInput.pasteBracketedText("queued")
|
||||
prompt.setup.mockInput.pressKey("x")
|
||||
await prompt.setup.waitFor(() => prompt.input.plainText === "x")
|
||||
prompt.setup.mockInput.pressBackspace()
|
||||
await prompt.setup.waitFor(() => prompt.input.plainText === "")
|
||||
prompt.prompt.set({ text: "temporary", files: [], agents: [], pasted: [] })
|
||||
prompt.prompt.reset()
|
||||
await prompt.setup.renderOnce()
|
||||
await prompt.setup.mockInput.pasteBracketedText("new")
|
||||
|
||||
result.resolve({
|
||||
status: "read",
|
||||
representation: { mimeType: "text/plain", bytes: new TextEncoder().encode("/missing-one.png /missing-two.png") },
|
||||
})
|
||||
await prompt.setup.waitForFrame((frame) => frame.includes("Attachment paste canceled"))
|
||||
|
||||
await prompt.setup.waitFor(() => prompt.input.plainText === "new")
|
||||
expect(prompt.prompt.current.files).toEqual([])
|
||||
} finally {
|
||||
await prompt.dispose()
|
||||
}
|
||||
})
|
||||
|
||||
test("does not insert a delayed image after entering shell mode", async () => {
|
||||
const result = Promise.withResolvers<ClipboardReadResult>()
|
||||
const prompt = await mountPrompt(() => result.promise)
|
||||
try {
|
||||
prompt.setup.renderer.keyInput.processPaste(new Uint8Array())
|
||||
await prompt.setup.waitFor(() => prompt.reads === 1)
|
||||
prompt.setup.mockInput.pressKey("!")
|
||||
await prompt.setup.waitForFrame((frame) => frame.includes("Shell"))
|
||||
prompt.setup.mockInput.pressBackspace()
|
||||
await prompt.setup.waitForFrame((frame) => !frame.includes("Shell"))
|
||||
|
||||
result.resolve(await readPngClipboard())
|
||||
await prompt.setup.waitForFrame((frame) => frame.includes("Attachment paste canceled"))
|
||||
|
||||
expect(prompt.input.plainText).toBe("")
|
||||
expect(prompt.prompt.current.files).toEqual([])
|
||||
} finally {
|
||||
await prompt.dispose()
|
||||
}
|
||||
})
|
||||
|
||||
test("ignores a clipboard read that completes after prompt teardown", async () => {
|
||||
const result = Promise.withResolvers<ClipboardReadResult>()
|
||||
const prompt = await mountPrompt(() => result.promise)
|
||||
|
||||
prompt.setup.renderer.keyInput.processPaste(new Uint8Array())
|
||||
await prompt.setup.waitFor(() => prompt.reads === 1)
|
||||
prompt.setup.renderer.destroy()
|
||||
result.resolve(await readPngClipboard())
|
||||
await Bun.sleep(0)
|
||||
await prompt.dispose()
|
||||
})
|
||||
|
||||
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()
|
||||
const frame = await prompt.setup.waitForFrame((frame) => frame.includes("+1 more"))
|
||||
expect(frame).toMatch(/^┃ █/m)
|
||||
expect(frame.match(/\[Image 1\]/g)).toHaveLength(1)
|
||||
expect(frame.match(/\[Image 2\]/g)).toHaveLength(1)
|
||||
expect(frame.match(/\[Image 3\]/g)).toHaveLength(1)
|
||||
|
||||
await prompt.setup.mockMouse.click(49, 1, MouseButtons.LEFT)
|
||||
await prompt.setup.waitForFrame((frame) => frame.includes("Image 4 of 4"))
|
||||
prompt.setup.mockInput.pressCtrlC()
|
||||
await prompt.setup.waitForFrame((frame) => !frame.includes("Image 4 of 4"))
|
||||
await prompt.setup.mockMouse.click(50, 1, MouseButtons.LEFT)
|
||||
await prompt.setup.renderOnce()
|
||||
expect(prompt.setup.captureCharFrame()).not.toContain("Image 4 of 4")
|
||||
} finally {
|
||||
await prompt.dispose()
|
||||
}
|
||||
})
|
||||
|
||||
test("opens image attachments by keyboard, mouse, and command palette", 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(14, 1, MouseButtons.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.mockMouse.click(15, 1, MouseButtons.LEFT)
|
||||
await prompt.setup.renderOnce()
|
||||
expect(prompt.setup.captureCharFrame()).not.toContain("Image 2 of 2")
|
||||
await prompt.setup.mockMouse.click(29, 1, MouseButtons.LEFT)
|
||||
await prompt.setup.renderOnce()
|
||||
expect(prompt.setup.captureCharFrame()).not.toContain("Image 2 of 2")
|
||||
|
||||
await prompt.setup.mockMouse.click(16, 1, MouseButtons.LEFT)
|
||||
|
||||
await prompt.setup.waitForFrame((frame) => frame.includes("Image 2 of 2"))
|
||||
const large = prompt.setup.renderer.root.findDescendantById("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"))
|
||||
expect(large.isDestroyed).toBe(true)
|
||||
await prompt.setup.waitFor(() => prompt.setup.renderer.currentFocusedEditor === prompt.input)
|
||||
|
||||
prompt.setup.mockInput.pressKey("p", { ctrl: true })
|
||||
await prompt.setup.waitForFrame((frame) => frame.includes("Commands"))
|
||||
for (const key of "view image attachments") prompt.setup.mockInput.pressKey(key)
|
||||
const palette = await prompt.setup.waitForFrame((frame) => frame.includes("View image attachments"))
|
||||
expect(palette).toContain("ctrl+x i")
|
||||
prompt.setup.mockInput.pressEnter()
|
||||
await prompt.setup.waitForFrame((frame) => frame.includes("Image 1 of 2"))
|
||||
} 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 })
|
||||
}
|
||||
})
|
||||
|
||||
test("reduces the preview count to fit a narrow terminal", async () => {
|
||||
const prompt = await mountPrompt(readPngClipboard, true, 32)
|
||||
try {
|
||||
await pasteImages(prompt, 4)
|
||||
|
||||
expect(prompt.setup.renderer.root.findDescendantById("prompt-image-preview-0")).toBeInstanceOf(ImageRenderable)
|
||||
expect(prompt.setup.renderer.root.findDescendantById("prompt-image-preview-1")).toBeUndefined()
|
||||
await prompt.setup.waitForFrame((frame) => frame.includes("+3 more"))
|
||||
} finally {
|
||||
await prompt.dispose()
|
||||
}
|
||||
})
|
||||
|
||||
test("does not click a hidden overflow control", async () => {
|
||||
const prompt = await mountPrompt(readPngClipboard, true, 22)
|
||||
try {
|
||||
await pasteImages(prompt, 2)
|
||||
await prompt.setup.mockMouse.click(16, 1, MouseButtons.LEFT)
|
||||
await prompt.setup.renderOnce()
|
||||
|
||||
expect(prompt.setup.captureCharFrame()).not.toContain("Image 2 of 2")
|
||||
} finally {
|
||||
await prompt.dispose()
|
||||
}
|
||||
})
|
||||
|
||||
test("hides thumbnails when their minimum width does not fit", async () => {
|
||||
const prompt = await mountPrompt(readPngClipboard, true, 16)
|
||||
try {
|
||||
await pasteImages(prompt, 1)
|
||||
expect(prompt.setup.renderer.root.findDescendantById("prompt-image-preview-0")).toBeUndefined()
|
||||
|
||||
await prompt.setup.mockMouse.click(12, 1, MouseButtons.LEFT)
|
||||
await prompt.setup.renderOnce()
|
||||
expect(prompt.setup.captureCharFrame()).not.toContain("Image 1 of 1")
|
||||
} finally {
|
||||
await prompt.dispose()
|
||||
}
|
||||
})
|
||||
|
||||
test("removes an image preview when its mention is deleted", async () => {
|
||||
const prompt = await mountPrompt(readPngClipboard, true)
|
||||
try {
|
||||
prompt.setup.renderer.keyInput.processPaste(new Uint8Array())
|
||||
await prompt.setup.waitFor(
|
||||
() => prompt.setup.renderer.root.findDescendantById("prompt-image-preview-0") instanceof ImageRenderable,
|
||||
)
|
||||
const preview = prompt.setup.renderer.root.findDescendantById("prompt-image-preview-0")
|
||||
if (!(preview instanceof ImageRenderable)) throw new Error("Image preview did not render")
|
||||
await preview.loadPromise
|
||||
const image = preview.image
|
||||
expect(image).not.toBeNull()
|
||||
|
||||
prompt.input.cursorOffset = prompt.input.plainText.length
|
||||
prompt.setup.mockInput.pressBackspace()
|
||||
prompt.setup.mockInput.pressBackspace()
|
||||
await prompt.setup.waitFor(() => prompt.prompt.current.files?.length === 0)
|
||||
|
||||
expect(prompt.input.plainText).toBe("")
|
||||
expect(prompt.setup.renderer.root.findDescendantById("prompt-image-preview-0")).toBeUndefined()
|
||||
expect(() => image!.info()).toThrow("NativeImage is disposed")
|
||||
} finally {
|
||||
await prompt.dispose()
|
||||
}
|
||||
})
|
||||
|
||||
test("keeps an attachment when its opt-in preview cannot be decoded", async () => {
|
||||
const bytes = new Uint8Array([137, 80, 78, 71])
|
||||
const prompt = await mountPrompt(
|
||||
async () => ({ status: "read", representation: { mimeType: "image/png", bytes } }),
|
||||
true,
|
||||
)
|
||||
try {
|
||||
prompt.setup.renderer.keyInput.processPaste(new Uint8Array())
|
||||
await prompt.setup.waitForFrame((frame) => frame.includes("No preview"))
|
||||
|
||||
expect(prompt.input.plainText).toBe("[Image 1] ")
|
||||
expect(prompt.prompt.current.files).toEqual([
|
||||
{
|
||||
uri: "data:image/png;base64,iVBORw==",
|
||||
name: "clipboard",
|
||||
mention: { start: 0, end: 9, text: "[Image 1]" },
|
||||
},
|
||||
])
|
||||
} finally {
|
||||
await prompt.dispose()
|
||||
}
|
||||
})
|
||||
|
||||
test("ignores malformed attachment URIs restored into the prompt", async () => {
|
||||
const prompt = await mountPrompt(readPngClipboard, true)
|
||||
try {
|
||||
const restored = parsePromptInfo({
|
||||
text: "[Image 1] ",
|
||||
files: [{ uri: 42 }],
|
||||
agents: [],
|
||||
pasted: [],
|
||||
})
|
||||
if (!restored) throw new Error("Malformed prompt fixture was not restored")
|
||||
prompt.prompt.set(restored)
|
||||
await prompt.setup.renderOnce()
|
||||
|
||||
expect(prompt.prompt.current.text).toBe("[Image 1] ")
|
||||
expect(prompt.setup.renderer.root.findDescendantById("prompt-image-preview-0")).toBeUndefined()
|
||||
|
||||
prompt.setup.renderer.keyInput.processPaste(new Uint8Array())
|
||||
await prompt.setup.waitFor(() => prompt.reads === 1 && prompt.prompt.current.files?.length === 1)
|
||||
expect(prompt.prompt.current.files?.[0]?.uri).toBe(`data:image/png;base64,${PNG_1X1_BASE64}`)
|
||||
} finally {
|
||||
await prompt.dispose()
|
||||
}
|
||||
})
|
||||
|
||||
test("shows clipboard read failures without changing prompt state", async () => {
|
||||
const prompt = await mountPrompt(async () => ({ status: "failed", error: new Error("clipboard read failed") }))
|
||||
try {
|
||||
prompt.setup.renderer.keyInput.processPaste(new Uint8Array())
|
||||
await prompt.setup.waitForFrame((frame) => frame.includes("clipboard read failed"))
|
||||
|
||||
expect(prompt.input.plainText).toBe("")
|
||||
expect(prompt.input.extmarks.getAll()).toHaveLength(0)
|
||||
expect(prompt.reads).toBe(1)
|
||||
} finally {
|
||||
await prompt.dispose()
|
||||
}
|
||||
})
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { parsePastedFilepaths, readLocalAttachmentWith } from "../../src/component/prompt/local-attachment"
|
||||
import { readLocalAttachmentWith } 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 {
|
||||
@@ -11,36 +11,6 @@ function files(input: { mime: string; text?: string; bytes?: Uint8Array }): Loca
|
||||
}
|
||||
|
||||
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",
|
||||
])
|
||||
})
|
||||
|
||||
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 () => {
|
||||
expect(await readLocalAttachmentWith(files({ mime: "image/svg+xml", text: "<svg />" }), "/tmp/image.svg")).toEqual({
|
||||
type: "text",
|
||||
@@ -69,8 +39,5 @@ describe("prompt local attachments", () => {
|
||||
"/tmp/missing.png",
|
||||
),
|
||||
).toBeUndefined()
|
||||
expect(
|
||||
await readLocalAttachmentWith(files({ mime: "image/png", bytes: new Uint8Array(2) }), "/tmp/large.png", 1),
|
||||
).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,104 +0,0 @@
|
||||
import { expect, test } from "bun:test"
|
||||
import type { ClipboardService } from "../../src/context/clipboard"
|
||||
import { Selection } from "../../src/util/selection"
|
||||
|
||||
test("copies semantic selection text, reports attempted partial delivery, and clears immediately", async () => {
|
||||
const notifications: { message: string; variant: "info" | "success" | "warning" | "error" }[] = []
|
||||
const errors: unknown[] = []
|
||||
const writes: string[] = []
|
||||
const events: string[] = []
|
||||
let notify!: () => void
|
||||
const notified = new Promise<void>((resolve) => {
|
||||
notify = resolve
|
||||
})
|
||||
const focus = {
|
||||
hasSelection: () => true,
|
||||
getClipboardText: (text: string) => text.replace("[Pasted ~2 lines]", "first\nsecond"),
|
||||
}
|
||||
const renderer = {
|
||||
getSelection: () => ({
|
||||
getSelectedText: () => "before [Pasted ~2 lines] after",
|
||||
selectedRenderables: [focus],
|
||||
}),
|
||||
clearSelection: () => events.push("clear"),
|
||||
currentFocusedRenderable: focus,
|
||||
}
|
||||
const clipboard: ClipboardService = {
|
||||
async read() {
|
||||
return undefined
|
||||
},
|
||||
async write(text) {
|
||||
events.push("write")
|
||||
writes.push(text)
|
||||
return {
|
||||
delivery: "attempted",
|
||||
partial: true,
|
||||
result: {
|
||||
host: { status: "failed", error: new Error("host failed") },
|
||||
terminal: { status: "attempted", capability: "supported" },
|
||||
},
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
expect(
|
||||
Selection.copy(
|
||||
renderer,
|
||||
{
|
||||
show: (notification) => {
|
||||
notifications.push(notification)
|
||||
notify()
|
||||
},
|
||||
error: (error) => errors.push(error),
|
||||
},
|
||||
clipboard,
|
||||
),
|
||||
).toBe(true)
|
||||
expect(writes).toEqual(["before first\nsecond after"])
|
||||
expect(events).toEqual(["write", "clear"])
|
||||
await notified
|
||||
expect(notifications).toEqual([
|
||||
{ message: "Sent to terminal clipboard; host clipboard write failed", variant: "warning" },
|
||||
])
|
||||
expect(errors).toEqual([])
|
||||
})
|
||||
|
||||
test("reports total selection-copy failure without delaying selection clearing", async () => {
|
||||
const failure = new Error("copy failed")
|
||||
const errors: unknown[] = []
|
||||
const notifications: unknown[] = []
|
||||
let clears = 0
|
||||
let report!: () => void
|
||||
const reported = new Promise<void>((resolve) => {
|
||||
report = resolve
|
||||
})
|
||||
const clipboard: ClipboardService = {
|
||||
async read() {
|
||||
return undefined
|
||||
},
|
||||
async write() {
|
||||
throw failure
|
||||
},
|
||||
}
|
||||
|
||||
expect(
|
||||
Selection.copy(
|
||||
{
|
||||
getSelection: () => ({ getSelectedText: () => "text", selectedRenderables: [] }),
|
||||
clearSelection: () => void clears++,
|
||||
},
|
||||
{
|
||||
show: (notification) => notifications.push(notification),
|
||||
error: (error) => {
|
||||
errors.push(error)
|
||||
report()
|
||||
},
|
||||
},
|
||||
clipboard,
|
||||
),
|
||||
).toBe(true)
|
||||
expect(clears).toBe(1)
|
||||
await reported
|
||||
expect(notifications).toEqual([])
|
||||
expect(errors).toEqual([failure])
|
||||
})
|
||||
Reference in New Issue
Block a user