Compare commits

..

2 Commits

Author SHA1 Message Date
Aiden Cline a5a0d3dac7 docs(core): explain Codex limit parity 2026-07-30 22:07:10 -05:00
Aiden Cline fd6a704a17 fix(core): respect model input limits 2026-07-30 22:03:21 -05:00
49 changed files with 443 additions and 2487 deletions
+56 -31
View File
@@ -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
View File
@@ -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:"
-2
View File
@@ -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.
+4 -58
View File
@@ -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
+1
View File
@@ -6,6 +6,7 @@ export interface Settings extends Readonly<Record<string, unknown>> {
readonly body?: Readonly<Record<string, unknown>>
readonly limits?: {
readonly context: number
readonly input?: number
readonly output: number
}
}
+1
View File
@@ -123,6 +123,7 @@ export const mergeGenerationOptions = (...items: ReadonlyArray<GenerationOptions
export class ModelLimits extends Schema.Class<ModelLimits>("LLM.ModelLimits")({
context: Schema.optional(Schema.Number),
input: Schema.optional(Schema.Number),
output: Schema.optional(Schema.Number),
}) {}
@@ -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" } } },
})
+1 -29
View File
@@ -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 -22
View File
@@ -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":
@@ -50,25 +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 } : {}),
+1 -1
View File
@@ -332,7 +332,7 @@ function modelFromLanguage(info: Info, language: LanguageModelV3) {
body: projected.body === undefined ? undefined : { ...projected.body },
headers: info.headers,
},
limits: { context: info.limit.context, output: info.limit.output },
limits: { context: info.limit.context, input: info.limit.input, output: info.limit.output },
providerOptions,
},
body: {
+2 -2
View File
@@ -81,7 +81,7 @@ const withDefaults = (model: Info, route: AnyRoute) =>
headers: providerHeaders(model),
providerOptions: providerOptions(model),
http: model.body === undefined ? undefined : { body: model.body },
limits: { context: model.limit.context, output: model.limit.output },
limits: { context: model.limit.context, input: model.limit.input, output: model.limit.output },
})
const providerHeaders = (model: Info) => {
@@ -204,7 +204,7 @@ export const fromCatalogModel = (
...nativeCredentialSettings(specifier, credential),
headers: resolved.headers,
body: resolved.body,
limits: { context: resolved.limit.context, output: resolved.limit.output },
limits: { context: resolved.limit.context, input: resolved.limit.input, output: resolved.limit.output },
}
return yield* Effect.try({
try: () => {
+2 -6
View File
@@ -220,12 +220,8 @@ export const OpenAIPlugin = define({
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 }
}
// Match Codex CLI so context consumption and subscription usage stay consistent between clients.
draft.limit = { ...draft.limit, context: 272_000, input: 272_000 }
})
}
})
+5 -1
View File
@@ -351,10 +351,14 @@ const make = (dependencies: Dependencies) => {
)
if (!last) return false
const output = Math.min(input.model.route.defaults.limits?.output ?? 0, OUTPUT_TOKEN_MAX)
const limit = Math.min(
input.model.route.defaults.limits?.input ?? Number.POSITIVE_INFINITY,
context - (output || config.buffer),
)
const used =
last.tokens.input + last.tokens.output + last.tokens.reasoning + last.tokens.cache.read + last.tokens.cache.write
if (used <= 0) return false
return used >= context - (output || config.buffer)
return used >= limit
}
const compactManual = Effect.fn("SessionCompaction.compactManual")(function* (input: ManualInput) {
const content = planContent(input.messages, config.tokens)
-60
View File
@@ -2,66 +2,6 @@ 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", {
+10 -23
View File
@@ -17,6 +17,7 @@ interface ModelOptions {
readonly headers?: Info["headers"]
readonly body?: Info["body"]
readonly variants?: Info["variants"]
readonly limit?: Info["limit"]
}
const model = (packageName: string | undefined, options: ModelOptions = {}) =>
@@ -36,7 +37,7 @@ const model = (packageName: string | undefined, options: ModelOptions = {}) =>
cost: [],
status: "active",
enabled: true,
limit: { context: 100, output: 20 },
limit: options.limit ?? { context: 100, output: 20 },
})
describe("ModelResolver", () => {
@@ -44,6 +45,7 @@ describe("ModelResolver", () => {
Effect.gen(function* () {
const catalog = model(Provider.aisdk("@ai-sdk/openai"), {
settings: { baseURL: "https://openai.example/v1" },
limit: { context: 100, input: 80, output: 20 },
})
const resolved = yield* ModelResolver.fromCatalogModel(catalog)
@@ -55,7 +57,7 @@ describe("ModelResolver", () => {
endpoint: { baseURL: "https://openai.example/v1" },
defaults: {
headers: { "x-test": "header" },
limits: { context: 100, output: 20 },
limits: { context: 100, input: 80, output: 20 },
http: { body: { custom_extension: { enabled: true } } },
},
})
@@ -490,31 +492,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 +518,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 })
},
@@ -74,6 +74,9 @@ describe("OpenAIPlugin", () => {
]
})
catalog.model.update(item.id, Model.ID.make("gpt-5.5-pro"), () => {})
catalog.model.update(item.id, Model.ID.make("gpt-5.4"), (model) => {
model.limit = { context: 1_050_000, input: 922_000, output: 64_000 }
})
catalog.model.update(item.id, Model.ID.make("gpt-5.4-pro"), (model) => {
model.modelID = Model.ID.make("gpt-5.4")
model.body = { reasoning: { mode: "pro" } }
@@ -137,7 +140,7 @@ describe("OpenAIPlugin", () => {
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.limit).toEqual({ context: 272_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,
@@ -145,10 +148,15 @@ describe("OpenAIPlugin", () => {
expect(required(yield* catalog.model.get(Provider.ID.openai, Model.ID.make("gpt-5.4-pro"))).enabled).toBe(
false,
)
expect(required(yield* catalog.model.get(Provider.ID.openai, Model.ID.make("gpt-5.4"))).limit).toEqual({
context: 272_000,
input: 272_000,
output: 64_000,
})
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(gpt56.limit).toEqual({ context: 272_000, input: 272_000, output: 128_000 })
expect(required(yield* catalog.model.get(Provider.ID.openai, Model.ID.make("gpt-4.1"))).enabled).toBe(false)
}),
)
+40 -1
View File
@@ -19,9 +19,11 @@ import { Session } from "@opencode-ai/core/session"
import { Project } from "@opencode-ai/core/project"
import { ProjectTable } from "@opencode-ai/core/project/sql"
import { App } from "@opencode-ai/core/app"
import { Agent } from "@opencode-ai/core/agent"
import { Location } from "@opencode-ai/core/location"
import { AbsolutePath } from "@opencode-ai/core/schema"
import { Money } from "@opencode-ai/schema/money"
import { DateTime, Effect, Fiber, Layer, Stream } from "effect"
import { DateTime, Effect, Fiber, Layer, Schema, Stream } from "effect"
import { asc, eq } from "drizzle-orm"
import { testEffect } from "./lib/effect"
@@ -130,6 +132,43 @@ test("compaction prompt requires the checkpoint headings in order", () => {
expect(prompt).toContain("Keep every section, even when empty.")
})
it.effect("auto compaction respects explicit model input limits", () =>
Effect.gen(function* () {
const compaction = yield* SessionCompaction.Service
const session = Session.Info.make({
id: Session.ID.make("ses_input_limit"),
projectID: Project.ID.global,
cost: Money.USD.zero,
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
time: { created: DateTime.makeUnsafe(0), updated: DateTime.makeUnsafe(0) },
location: Location.Ref.make({ directory: AbsolutePath.make("/tmp") }),
})
const input = (tokens: number) => ({
session,
model: Model.make({
id: "test-model",
provider: "test-provider",
route: OpenAIChat.route.with({ limits: { context: 1_000, input: 100, output: 100 } }),
}),
cost: [],
messages: [
Schema.decodeUnknownSync(SessionMessage.Assistant)({
id: SessionMessage.ID.make("msg_assistant"),
type: "assistant",
agent: Agent.defaultID,
model: { id: "test-model", providerID: "test-provider" },
content: [],
tokens: { input: tokens, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
time: { created: 0, completed: 0 },
}),
],
})
expect(compaction.required(input(99))).toBe(false)
expect(compaction.required(input(100))).toBe(true)
}),
)
it.effect("manual compaction summarizes short context instead of no-op", () =>
Effect.gen(function* () {
requests = []
+3 -3
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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)
}
@@ -189,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",
+5 -15
View File
@@ -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="prompt-image-viewer" paddingLeft={2} paddingRight={2} paddingBottom={1} gap={1}>
<box flexDirection="row" justifyContent="space-between">
<text attributes={TextAttributes.BOLD} fg={theme.text.default}>
Image {index() + 1} of {props.images.length}
</text>
<text fg={theme.text.subdued} onMouseUp={() => dialog.clear()}>
esc
</text>
</box>
<image
id="prompt-image-viewer-image"
source={current().uri}
fit="fit"
protocol="auto"
width="100%"
height={imageHeight()}
onError={() => setFailed(true)}
/>
<box flexDirection="row" justifyContent="space-between">
<text fg={theme.text.subdued} onMouseUp={() => move(-1)}>
{props.images.length > 1 ? "← previous" : ""}
</text>
<text fg={failed() ? theme.text.feedback.error.default : theme.text.subdued} wrapMode="none" truncate>
{failed() ? "No preview" : (current().mention?.text ?? `Image ${index() + 1}`)}
</text>
<text fg={theme.text.subdued} onMouseUp={() => move(1)}>
{props.images.length > 1 ? "next →" : ""}
</text>
</box>
</box>
)
}
@@ -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)
},
},
+2 -7
View File
@@ -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)
}
+9 -38
View File
@@ -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>
+58 -271
View File
@@ -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 }
}
-3
View File
@@ -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(
-2
View File
@@ -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",
+8 -15
View File
@@ -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)
}
@@ -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()
},
},
{
+4 -7
View File
@@ -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>
+6 -21
View File
@@ -74,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"
@@ -797,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()
},
@@ -822,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" })
}
@@ -870,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
}
+2 -4
View File
@@ -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()
+2 -5
View File
@@ -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()
+8 -258
View File
@@ -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()
}
})
@@ -74,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()
}
+1 -11
View File
@@ -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()
},
}}
>
+10 -262
View File
@@ -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()
}
})
+1 -3
View File
@@ -14,13 +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()
})
test("resolves nested config and keybind defaults", () => {
-8
View File
@@ -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()
}
})
+1 -21
View File
@@ -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>
-534
View File
@@ -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("prompt-image-viewer-image")
if (!(large instanceof ImageRenderable)) throw new Error("Large image preview did not render")
expect(large.fit).toBe("fit")
expect(large.height).toBeGreaterThan(thumbnail.height)
prompt.setup.mockInput.pressArrow("left")
await prompt.setup.waitForFrame((frame) => frame.includes("Image 1 of 2"))
prompt.setup.mockInput.pressCtrlC()
await prompt.setup.waitForFrame((frame) => !frame.includes("Image 1 of 2"))
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()
})
})
-104
View File
@@ -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])
})