Compare commits

..

2 Commits

Author SHA1 Message Date
Brendan Allan 55874ccaeb simplify repalceServerConnection 2026-08-07 19:34:47 +08:00
Brendan Allan b8ed70d6d3 refactor(app): split server management controllers 2026-08-07 17:37:35 +08:00
87 changed files with 1108 additions and 3797 deletions
+56 -31
View File
@@ -597,9 +597,9 @@
}, },
"peerDependencies": { "peerDependencies": {
"@opencode-ai/theme": "workspace:*", "@opencode-ai/theme": "workspace:*",
"@opentui/core": "0.0.0-20260807-6b4336f3", "@opentui/core": ">=0.4.5",
"@opentui/keymap": "0.0.0-20260807-6b4336f3", "@opentui/keymap": ">=0.4.5",
"@opentui/solid": "0.0.0-20260807-6b4336f3", "@opentui/solid": ">=0.4.5",
"solid-js": ">=1.9.0", "solid-js": ">=1.9.0",
}, },
"optionalPeers": [ "optionalPeers": [
@@ -890,6 +890,7 @@
"@opentui/keymap": "catalog:", "@opentui/keymap": "catalog:",
"@opentui/solid": "catalog:", "@opentui/solid": "catalog:",
"@solid-primitives/event-bus": "1.1.2", "@solid-primitives/event-bus": "1.1.2",
"clipboardy": "4.0.0",
"effect": "catalog:", "effect": "catalog:",
"fuzzysort": "catalog:", "fuzzysort": "catalog:",
"get-east-asian-width": "catalog:", "get-east-asian-width": "catalog:",
@@ -1073,9 +1074,9 @@
"@silvia-odwyer/photon-node@0.3.4": "patches/@silvia-odwyer%2Fphoton-node@0.3.4.patch", "@silvia-odwyer/photon-node@0.3.4": "patches/@silvia-odwyer%2Fphoton-node@0.3.4.patch",
}, },
"overrides": { "overrides": {
"@opentui/core": "0.0.0-20260807-6b4336f3", "@opentui/core": "catalog:",
"@opentui/keymap": "0.0.0-20260807-6b4336f3", "@opentui/keymap": "catalog:",
"@opentui/solid": "0.0.0-20260807-6b4336f3", "@opentui/solid": "catalog:",
"@types/bun": "catalog:", "@types/bun": "catalog:",
"@types/node": "catalog:", "@types/node": "catalog:",
"effect": "catalog:", "effect": "catalog:",
@@ -1093,9 +1094,9 @@
"@npmcli/arborist": "9.4.0", "@npmcli/arborist": "9.4.0",
"@octokit/rest": "22.0.0", "@octokit/rest": "22.0.0",
"@openauthjs/openauth": "0.0.0-20250322224806", "@openauthjs/openauth": "0.0.0-20250322224806",
"@opentui/core": "0.0.0-20260807-6b4336f3", "@opentui/core": "0.4.5",
"@opentui/keymap": "0.0.0-20260807-6b4336f3", "@opentui/keymap": "0.4.5",
"@opentui/solid": "0.0.0-20260807-6b4336f3", "@opentui/solid": "0.4.5",
"@pierre/diffs": "1.2.10", "@pierre/diffs": "1.2.10",
"@playwright/test": "1.59.1", "@playwright/test": "1.59.1",
"@sentry/solid": "10.36.0", "@sentry/solid": "10.36.0",
@@ -2132,27 +2133,27 @@
"@opentelemetry/semantic-conventions": ["@opentelemetry/semantic-conventions@1.43.0", "", {}, "sha512-eSYWTm620tTk45EKSedaUL8MFYI8hW164hIXsgIHyxu3VobUB3fFCu5t0hQby6OoWRPsG1KkKUG2M5UadiLiVg=="], "@opentelemetry/semantic-conventions": ["@opentelemetry/semantic-conventions@1.43.0", "", {}, "sha512-eSYWTm620tTk45EKSedaUL8MFYI8hW164hIXsgIHyxu3VobUB3fFCu5t0hQby6OoWRPsG1KkKUG2M5UadiLiVg=="],
"@opentui/core": ["@opentui/core@0.0.0-20260807-6b4336f3", "", { "dependencies": { "bun-ffi-structs": "0.3.1", "diff": "9.0.0", "marked": "17.0.1", "string-width": "7.2.0", "strip-ansi": "7.1.2" }, "optionalDependencies": { "@opentui/core-darwin-arm64": "0.0.0-20260807-6b4336f3", "@opentui/core-darwin-x64": "0.0.0-20260807-6b4336f3", "@opentui/core-linux-arm64": "0.0.0-20260807-6b4336f3", "@opentui/core-linux-arm64-musl": "0.0.0-20260807-6b4336f3", "@opentui/core-linux-x64": "0.0.0-20260807-6b4336f3", "@opentui/core-linux-x64-musl": "0.0.0-20260807-6b4336f3", "@opentui/core-win32-arm64": "0.0.0-20260807-6b4336f3", "@opentui/core-win32-x64": "0.0.0-20260807-6b4336f3" }, "peerDependencies": { "web-tree-sitter": "0.25.10" } }, "sha512-ExOJ/jlJwDEh/S+IadNdG4ueTSG1rMu5r7Vyr6PXHqMNd1oY4T94aabRCXZJ+weL3flO07Iw8OW92EvvC4Nf+A=="], "@opentui/core": ["@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-20260807-6b4336f3", "", { "os": "darwin", "cpu": "arm64" }, "sha512-cL9wjMoQdXbIVi60Xc0ktkbsHEvG9wePXcmS55LXIAoPQE2xbbGOOBmRQL8nrBzs5kdOAehzaYaIrbT5H290KA=="], "@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-20260807-6b4336f3", "", { "os": "darwin", "cpu": "x64" }, "sha512-Vtdr6QCBhIc+gZYpcBhPBbUNV6j3IcfBdf3auKEAYUGEYjyD0aHlKsur8nKndeEDoEOuSeWMxAs97on2ejpetw=="], "@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-20260807-6b4336f3", "", { "os": "linux", "cpu": "arm64" }, "sha512-zN82yIVO7pEse3ZdOmIJAuFtCMbAURkMausD769NdJiJP0OAVs+21T0TMPRNB9fLgaIWDy1Ojk0mn2xPnF3QWg=="], "@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-20260807-6b4336f3", "", { "os": "linux", "cpu": "arm64" }, "sha512-0xjJU9q/v5YUTzEE2pxdNFCYVlNzvNx45zSTOIPGrodXo1ecuwBh+0IyCa0EnCIciK7y2wHBd5AatUv0WXEbsw=="], "@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-20260807-6b4336f3", "", { "os": "linux", "cpu": "x64" }, "sha512-RuUEbnNfb8gzwVODCF5mUii0FW2r6P0rorSQbI/4+CwJgq9kTr5Nml4/J2wvxvxFiROWFgciohyFfiIgR7bhDA=="], "@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-20260807-6b4336f3", "", { "os": "linux", "cpu": "x64" }, "sha512-2DvIF+3CArHx7cY/KI3lmMdqKjPoxAuP/Haj+Fm+uCKRT4mQGGfkfRnparbFPs2dNxl4zmrT8/DolDZhpvcX8g=="], "@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-20260807-6b4336f3", "", { "os": "win32", "cpu": "arm64" }, "sha512-JYj7hDjx6qvpuL8C9bvhlvkD01sj3wSzCbuTMb1hq9lq1nMbtaekUWklLr6tEyxjDmu5+XkxCoxbUSbO7BvT6A=="], "@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-20260807-6b4336f3", "", { "os": "win32", "cpu": "x64" }, "sha512-++aXqPN0LlWUvNpzqUztGa3iI6EFrGxLC/EZxhi6NzBCRZN8nyfT8lzEyr6A5cMtjtv0/c0otSOU9fy1wgJkAQ=="], "@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-20260807-6b4336f3", "", { "dependencies": { "@opentui/core": "0.0.0-20260807-6b4336f3" }, "peerDependencies": { "@opentui/react": "0.0.0-20260807-6b4336f3", "@opentui/solid": "0.0.0-20260807-6b4336f3", "react": ">=19.2.0", "solid-js": "1.9.12" }, "optionalPeers": ["@opentui/react", "@opentui/solid", "react", "solid-js"] }, "sha512-WE24FrJqv/cZSuNRB0MAIZrTZRNBFIeTahscRYD8C0PoQ9eMJAcVT0RDy85gMONfCOW9G1O2g1WQlWU1nz/cWg=="], "@opentui/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-20260807-6b4336f3", "", { "dependencies": { "@babel/core": "7.28.0", "@babel/preset-typescript": "7.27.1", "@opentui/core": "0.0.0-20260807-6b4336f3", "babel-plugin-module-resolver": "5.0.2", "babel-preset-solid": "1.9.12", "entities": "7.0.1", "s-js": "^0.4.9" }, "peerDependencies": { "solid-js": "1.9.12" } }, "sha512-Vn8SG0Zh2uWICXlz5ms0CSiRZrVO7zbQZOfj/NFE4h1fRm0n3oqXJBtmSbX/KWCzq9l181hP0PtbLomqgmONiQ=="], "@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=="], "@orama/orama": ["@orama/orama@3.1.18", "", {}, "sha512-a61ljmRVVyG5MC/698C8/FfFDw5a8LOIvyOLW5fztgUXqUpc1jOfQzOitSCbge657OgXXThmY3Tk8fpiDb4UcA=="],
@@ -3500,7 +3501,7 @@
"builder-util-runtime": ["builder-util-runtime@9.7.0", "", { "dependencies": { "debug": "^4.3.4", "sax": "^1.2.4" } }, "sha512-g/kR520giAFYkSXTzcmF3kqQq7wi8F6N6SzeDgZrqTBN+VHdmgWOyTdD1yD7AATDId/yXLvuP34CxW46/BwCdw=="], "builder-util-runtime": ["builder-util-runtime@9.7.0", "", { "dependencies": { "debug": "^4.3.4", "sax": "^1.2.4" } }, "sha512-g/kR520giAFYkSXTzcmF3kqQq7wi8F6N6SzeDgZrqTBN+VHdmgWOyTdD1yD7AATDId/yXLvuP34CxW46/BwCdw=="],
"bun-ffi-structs": ["bun-ffi-structs@0.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=="], "bun-pty": ["bun-pty@0.4.8", "", {}, "sha512-rO70Mrbr13+jxHHHu2YBkk2pNqrJE5cJn29WE++PUr+GFA0hq/VgtQPZANJ8dJo6d7XImvBk37Innt8GM7O28w=="],
@@ -3580,6 +3581,8 @@
"cli-truncate": ["cli-truncate@4.0.0", "", { "dependencies": { "slice-ansi": "^5.0.0", "string-width": "^7.0.0" } }, "sha512-nPdaFdQ0h/GEigbPClz11D0v/ZJEwxmeVZGeMo3Z5StPtUTkA9o1lD6QwoirYiSDzbcwn2XcjwmCp68W1IS4TA=="], "cli-truncate": ["cli-truncate@4.0.0", "", { "dependencies": { "slice-ansi": "^5.0.0", "string-width": "^7.0.0" } }, "sha512-nPdaFdQ0h/GEigbPClz11D0v/ZJEwxmeVZGeMo3Z5StPtUTkA9o1lD6QwoirYiSDzbcwn2XcjwmCp68W1IS4TA=="],
"clipboardy": ["clipboardy@4.0.0", "", { "dependencies": { "execa": "^8.0.1", "is-wsl": "^3.1.0", "is64bit": "^2.0.0" } }, "sha512-5mOlNS0mhX0707P2I0aZ2V/cmHUEO/fL7VFLqszkhUsxt7RwnmrInf/eEQKlf5GzvYeHIjT+Ov1HRfNmymlG0w=="],
"cliui": ["cliui@8.0.1", "", { "dependencies": { "string-width": "^4.2.0", "strip-ansi": "^6.0.1", "wrap-ansi": "^7.0.0" } }, "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ=="], "cliui": ["cliui@8.0.1", "", { "dependencies": { "string-width": "^4.2.0", "strip-ansi": "^6.0.1", "wrap-ansi": "^7.0.0" } }, "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ=="],
"clone": ["clone@2.1.2", "", {}, "sha512-3Pe/CF1Nn94hyhIYpjtiLhdCoEoz0DqQ+988E9gmeEdQZlojxnOb74wctFyuwWQHzqyf9X7C7MG8juUpqBJT8w=="], "clone": ["clone@2.1.2", "", {}, "sha512-3Pe/CF1Nn94hyhIYpjtiLhdCoEoz0DqQ+988E9gmeEdQZlojxnOb74wctFyuwWQHzqyf9X7C7MG8juUpqBJT8w=="],
@@ -3996,7 +3999,7 @@
"eventsource-parser": ["eventsource-parser@3.1.0", "", {}, "sha512-kJezFj9YFAMLeORyi7aCLxLbD5/qWMQnoMVlVPyHIll7lgRJCc3JVln9Vgl9nwQi0YkMnhdGTMNn7CkRRAptMg=="], "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=="], "exit-hook": ["exit-hook@2.2.1", "", {}, "sha512-eNTPlAD67BmP31LDINZ3U7HSF8l57TxOY2PmBJ1shpCvpnxBF93mWCE8YHBnXs8qiUZJc9WDcWIeC3a2HIAMfw=="],
@@ -4278,7 +4281,7 @@
"https-proxy-agent": ["https-proxy-agent@7.0.6", "", { "dependencies": { "agent-base": "^7.1.2", "debug": "4" } }, "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw=="], "https-proxy-agent": ["https-proxy-agent@7.0.6", "", { "dependencies": { "agent-base": "^7.1.2", "debug": "4" } }, "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw=="],
"human-signals": ["human-signals@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=="], "humanize-ms": ["humanize-ms@1.2.1", "", { "dependencies": { "ms": "^2.0.0" } }, "sha512-Fl70vYtsAFb/C06PTS9dZBo7ihau+Tu/DNCk/OyHhea07S+aeMWpFFkUaXRa8fI+ScZbEI8dfSxwY7gxZ9SAVQ=="],
@@ -4422,6 +4425,8 @@
"is-wsl": ["is-wsl@3.1.1", "", { "dependencies": { "is-inside-container": "^1.0.0" } }, "sha512-e6rvdUCiQCAuumZslxRJWR/Doq4VpPR82kqclvcS0efgt430SlGIk05vdCN58+VrzgtIcfNODjozVielycD4Sw=="], "is-wsl": ["is-wsl@3.1.1", "", { "dependencies": { "is-inside-container": "^1.0.0" } }, "sha512-e6rvdUCiQCAuumZslxRJWR/Doq4VpPR82kqclvcS0efgt430SlGIk05vdCN58+VrzgtIcfNODjozVielycD4Sw=="],
"is64bit": ["is64bit@2.0.0", "", { "dependencies": { "system-architecture": "^0.1.0" } }, "sha512-jv+8jaWCl0g2lSBkNSVXdzfBA0npK1HGC2KtWM9FumFRoGS94g3NbCCLVnCYHLjp4GrW2KZeeSTMo5ddtznmGw=="],
"isarray": ["isarray@2.0.5", "", {}, "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw=="], "isarray": ["isarray@2.0.5", "", {}, "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw=="],
"isbinaryfile": ["isbinaryfile@5.0.7", "", {}, "sha512-gnWD14Jh3FzS3CPhF0AxNOJ8CxqeblPTADzI38r0wt8ZyQl5edpy75myt08EG2oKvpyiqSqsx+Wkz9vtkbTqYQ=="], "isbinaryfile": ["isbinaryfile@5.0.7", "", {}, "sha512-gnWD14Jh3FzS3CPhF0AxNOJ8CxqeblPTADzI38r0wt8ZyQl5edpy75myt08EG2oKvpyiqSqsx+Wkz9vtkbTqYQ=="],
@@ -4760,7 +4765,7 @@
"mime-types": ["mime-types@3.0.2", "", { "dependencies": { "mime-db": "^1.54.0" } }, "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A=="], "mime-types": ["mime-types@3.0.2", "", { "dependencies": { "mime-db": "^1.54.0" } }, "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A=="],
"mimic-fn": ["mimic-fn@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=="], "mimic-function": ["mimic-function@5.0.1", "", {}, "sha512-VP79XUPxV2CigYP3jWwAUFSku2aKqBH7uTAapFWCBqutsbmDo96KY5o8uh6U+/YSIn5OxJnXp73beVkpqMIGhA=="],
@@ -4880,7 +4885,7 @@
"npm-registry-fetch": ["npm-registry-fetch@19.1.1", "", { "dependencies": { "@npmcli/redact": "^4.0.0", "jsonparse": "^1.3.1", "make-fetch-happen": "^15.0.0", "minipass": "^7.0.2", "minipass-fetch": "^5.0.0", "minizlib": "^3.0.1", "npm-package-arg": "^13.0.0", "proc-log": "^6.0.0" } }, "sha512-TakBap6OM1w0H73VZVDf44iFXsOS3h+L4wVMXmbWOQroZgFhMch0juN6XSzBNlD965yIKvWg2dfu7NSiaYLxtw=="], "npm-registry-fetch": ["npm-registry-fetch@19.1.1", "", { "dependencies": { "@npmcli/redact": "^4.0.0", "jsonparse": "^1.3.1", "make-fetch-happen": "^15.0.0", "minipass": "^7.0.2", "minipass-fetch": "^5.0.0", "minizlib": "^3.0.1", "npm-package-arg": "^13.0.0", "proc-log": "^6.0.0" } }, "sha512-TakBap6OM1w0H73VZVDf44iFXsOS3h+L4wVMXmbWOQroZgFhMch0juN6XSzBNlD965yIKvWg2dfu7NSiaYLxtw=="],
"npm-run-path": ["npm-run-path@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=="], "nth-check": ["nth-check@2.1.1", "", { "dependencies": { "boolbase": "^1.0.0" } }, "sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w=="],
@@ -4906,7 +4911,7 @@
"once": ["once@1.4.0", "", { "dependencies": { "wrappy": "1" } }, "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w=="], "once": ["once@1.4.0", "", { "dependencies": { "wrappy": "1" } }, "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w=="],
"onetime": ["onetime@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=="], "oniguruma-parser": ["oniguruma-parser@0.12.2", "", {}, "sha512-6HVa5oIrgMC6aA6WF6XyyqbhRPJrKR02L20+2+zpDtO5QAzGHAUGw5TKQvwi5vctNnRHkJYmjAhRVQF2EKdTQw=="],
@@ -5506,7 +5511,7 @@
"strip-bom-string": ["strip-bom-string@1.0.0", "", {}, "sha512-uCC2VHvQRYu+lMh4My/sFNmF2klFymLX1wHJeXnbEJERpV/ZsVuonzerjfrGpIGF7LBVa1O7i9kjiWvJiFck8g=="], "strip-bom-string": ["strip-bom-string@1.0.0", "", {}, "sha512-uCC2VHvQRYu+lMh4My/sFNmF2klFymLX1wHJeXnbEJERpV/ZsVuonzerjfrGpIGF7LBVa1O7i9kjiWvJiFck8g=="],
"strip-final-newline": ["strip-final-newline@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=="], "strip-indent": ["strip-indent@3.0.0", "", { "dependencies": { "min-indent": "^1.0.0" } }, "sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ=="],
@@ -5538,6 +5543,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=="], "svgo": ["svgo@4.0.2", "", { "dependencies": { "commander": "^11.1.0", "css-select": "^5.1.0", "css-tree": "^3.0.1", "css-what": "^6.1.0", "csso": "^5.0.5", "picocolors": "^1.1.1", "sax": "^1.5.0" }, "bin": "./bin/svgo.js" }, "sha512-ekx94z1rRc5LDi6oSUaeRnYhd0UOJxdtQCL2rF8xpWxD3TPAsISWOrxezqGovqS38GRZOdpDfvQe3ts6F7nsng=="],
"system-architecture": ["system-architecture@0.1.0", "", {}, "sha512-ulAk51I9UVUyJgxlv9M6lFot2WP3e7t8Kz9+IS6D4rVba1tR9kON+Ey69f+1R4Q8cd45Lod6a4IcJIxnzGc/zA=="],
"tagged-tag": ["tagged-tag@1.0.0", "", {}, "sha512-yEFYrVhod+hdNyx7g5Bnkkb0G6si8HJurOoOEgC8B/O0uXLHlaey/65KRv6cuWBNhBgHKAROVpc7QyYqE5gFng=="], "tagged-tag": ["tagged-tag@1.0.0", "", {}, "sha512-yEFYrVhod+hdNyx7g5Bnkkb0G6si8HJurOoOEgC8B/O0uXLHlaey/65KRv6cuWBNhBgHKAROVpc7QyYqE5gFng=="],
"tailwindcss": ["tailwindcss@4.1.11", "", {}, "sha512-2E9TBm6MDD/xKYe+dvJZAmg3yxIEDNRc0jwlNyDg/4Fil2QcSLjFKGVff0lAf1jjeaArlG/M75Ey/EYr/OJtBA=="], "tailwindcss": ["tailwindcss@4.1.11", "", {}, "sha512-2E9TBm6MDD/xKYe+dvJZAmg3yxIEDNRc0jwlNyDg/4Fil2QcSLjFKGVff0lAf1jjeaArlG/M75Ey/EYr/OJtBA=="],
@@ -6476,6 +6483,8 @@
"@vercel/cli-config/zod": ["zod@4.1.11", "", {}, "sha512-WPsqwxITS2tzx1bzhIKsEs19ABD5vmCVa4xBo2tq/SrV4RNZtfws1EnCWQXM6yh8bD08a1idvkB5MZSBiZsjwg=="], "@vercel/cli-config/zod": ["zod@4.1.11", "", {}, "sha512-WPsqwxITS2tzx1bzhIKsEs19ABD5vmCVa4xBo2tq/SrV4RNZtfws1EnCWQXM6yh8bD08a1idvkB5MZSBiZsjwg=="],
"@vercel/cli-exec/execa": ["execa@5.1.1", "", { "dependencies": { "cross-spawn": "^7.0.3", "get-stream": "^6.0.0", "human-signals": "^2.1.0", "is-stream": "^2.0.0", "merge-stream": "^2.0.0", "npm-run-path": "^4.0.1", "onetime": "^5.1.2", "signal-exit": "^3.0.3", "strip-final-newline": "^2.0.0" } }, "sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg=="],
"@vercel/functions/@vercel/oidc": ["@vercel/oidc@3.8.1", "", { "dependencies": { "@vercel/cli-config": "0.2.1", "@vercel/cli-exec": "1.0.0", "jose": "^5.9.6" } }, "sha512-ufdalm2MWOYksyj8KVpWjoOFPJO6zoYpuyvIggIQ2bB0CFCjTCiTkGXHqAKwG77GVRjOaN3/8S5ITlZpXWmqOw=="], "@vercel/functions/@vercel/oidc": ["@vercel/oidc@3.8.1", "", { "dependencies": { "@vercel/cli-config": "0.2.1", "@vercel/cli-exec": "1.0.0", "jose": "^5.9.6" } }, "sha512-ufdalm2MWOYksyj8KVpWjoOFPJO6zoYpuyvIggIQ2bB0CFCjTCiTkGXHqAKwG77GVRjOaN3/8S5ITlZpXWmqOw=="],
"@vercel/nft/acorn": ["acorn@8.17.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-xRQbDb9BnwDafYNn6Vwl839DYVjqXYb1XVGtWAZ1kcDc6iwAL4hg3B1dZlRiuENFeO2H53gFG3in621AdERVAg=="], "@vercel/nft/acorn": ["acorn@8.17.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-xRQbDb9BnwDafYNn6Vwl839DYVjqXYb1XVGtWAZ1kcDc6iwAL4hg3B1dZlRiuENFeO2H53gFG3in621AdERVAg=="],
@@ -6660,11 +6669,9 @@
"estree-util-to-js/source-map": ["source-map@0.7.6", "", {}, "sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ=="], "estree-util-to-js/source-map": ["source-map@0.7.6", "", {}, "sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ=="],
"execa/get-stream": ["get-stream@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/is-stream": ["is-stream@3.0.0", "", {}, "sha512-LnQR4bZ9IADDRSkvpqMGvt/tEJWclzklNgSw48V5EAaAeDd6qGvN8ei6k5p0tvxSR171VmGyHuTiAOfxAbr8kA=="],
"execa/signal-exit": ["signal-exit@3.0.7", "", {}, "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ=="],
"express/cookie": ["cookie@0.7.2", "", {}, "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w=="], "express/cookie": ["cookie@0.7.2", "", {}, "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w=="],
@@ -6736,6 +6743,8 @@
"node-gyp/undici": ["undici@6.28.0", "", {}, "sha512-LIY910g9TI13YS95lrMFrs8Rm/u/irgHeTWoKCoteeJ04CUJ92eEfj0rVn+7VKMPBpUPiUoBKfhNyLI23EE/KA=="], "node-gyp/undici": ["undici@6.28.0", "", {}, "sha512-LIY910g9TI13YS95lrMFrs8Rm/u/irgHeTWoKCoteeJ04CUJ92eEfj0rVn+7VKMPBpUPiUoBKfhNyLI23EE/KA=="],
"npm-run-path/path-key": ["path-key@4.0.0", "", {}, "sha512-haREypq7xkM7ErfgIyA0z+Bj4AGKlMSdlQE2jvJo6huWD1EdkKYV+G/T4nq0YEF2vgTT8kqMFKo1uHn950r4SQ=="],
"openid-client/jose": ["jose@4.15.9", "", {}, "sha512-1vUQX+IdDMVPj4k8kOxgUqlcK518yluMuGZwqlr44FS1ppZB/5GWh4rZG89erpOBOJjU/OBsnCVFfapsRz6nEA=="], "openid-client/jose": ["jose@4.15.9", "", {}, "sha512-1vUQX+IdDMVPj4k8kOxgUqlcK518yluMuGZwqlr44FS1ppZB/5GWh4rZG89erpOBOJjU/OBsnCVFfapsRz6nEA=="],
"openid-client/lru-cache": ["lru-cache@6.0.0", "", { "dependencies": { "yallist": "^4.0.0" } }, "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA=="], "openid-client/lru-cache": ["lru-cache@6.0.0", "", { "dependencies": { "yallist": "^4.0.0" } }, "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA=="],
@@ -7506,6 +7515,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=="], "@tailwindcss/oxide-wasm32-wasi/@napi-rs/wasm-runtime/@tybys/wasm-util": ["@tybys/wasm-util@0.10.3", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg=="],
"@vercel/cli-exec/execa/get-stream": ["get-stream@6.0.1", "", {}, "sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg=="],
"@vercel/cli-exec/execa/human-signals": ["human-signals@2.1.0", "", {}, "sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw=="],
"@vercel/cli-exec/execa/is-stream": ["is-stream@2.0.1", "", {}, "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg=="],
"@vercel/cli-exec/execa/npm-run-path": ["npm-run-path@4.0.1", "", { "dependencies": { "path-key": "^3.0.0" } }, "sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw=="],
"@vercel/cli-exec/execa/onetime": ["onetime@5.1.2", "", { "dependencies": { "mimic-fn": "^2.1.0" } }, "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg=="],
"@vercel/cli-exec/execa/signal-exit": ["signal-exit@3.0.7", "", {}, "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ=="],
"@vercel/cli-exec/execa/strip-final-newline": ["strip-final-newline@2.0.0", "", {}, "sha512-BrpvfNAE3dcvq7ll3xVumzjKjZQ5tI1sEUIKr3Uoks0XUl45St3FlatVqef9prk4jRDzhW6WZg+3bk93y6pLjA=="],
"@vercel/functions/@vercel/oidc/jose": ["jose@5.9.6", "", {}, "sha512-AMlnetc9+CV9asI19zHmrgS/WYsWUwCn2R7RzlbJWD7F9eWYUTGyBmU9o6PxngtLGOiDGPRu+Uc4fhKzbpteZQ=="], "@vercel/functions/@vercel/oidc/jose": ["jose@5.9.6", "", {}, "sha512-AMlnetc9+CV9asI19zHmrgS/WYsWUwCn2R7RzlbJWD7F9eWYUTGyBmU9o6PxngtLGOiDGPRu+Uc4fhKzbpteZQ=="],
"@vercel/routing-utils/ajv/json-schema-traverse": ["json-schema-traverse@0.4.1", "", {}, "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg=="], "@vercel/routing-utils/ajv/json-schema-traverse": ["json-schema-traverse@0.4.1", "", {}, "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg=="],
@@ -8562,6 +8585,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=="], "@solidjs/start/shiki/@shikijs/engine-javascript/oniguruma-to-es": ["oniguruma-to-es@2.3.0", "", { "dependencies": { "emoji-regex-xs": "^1.0.0", "regex": "^5.1.1", "regex-recursion": "^5.1.1" } }, "sha512-bwALDxriqfKGfUufKGGepCzu9x7nJQuoRoAFp4AnwehhC2crqrDIAP/uN2qdlsAvSMpeRC3+Yzhqc7hLmle5+g=="],
"@vercel/cli-exec/execa/onetime/mimic-fn": ["mimic-fn@2.1.0", "", {}, "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg=="],
"ansi-align/string-width/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], "ansi-align/string-width/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="],
"app-builder-lib/@electron/get/fs-extra/universalify": ["universalify@0.1.2", "", {}, "sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg=="], "app-builder-lib/@electron/get/fs-extra/universalify": ["universalify@0.1.2", "", {}, "sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg=="],
+6 -6
View File
@@ -46,9 +46,9 @@
"@octokit/rest": "22.0.0", "@octokit/rest": "22.0.0",
"@hono/standard-validator": "0.2.0", "@hono/standard-validator": "0.2.0",
"@hono/zod-validator": "0.4.2", "@hono/zod-validator": "0.4.2",
"@opentui/core": "0.0.0-20260807-6b4336f3", "@opentui/core": "0.4.5",
"@opentui/keymap": "0.0.0-20260807-6b4336f3", "@opentui/keymap": "0.4.5",
"@opentui/solid": "0.0.0-20260807-6b4336f3", "@opentui/solid": "0.4.5",
"@tanstack/solid-virtual": "3.13.32", "@tanstack/solid-virtual": "3.13.32",
"@shikijs/stream": "4.2.0", "@shikijs/stream": "4.2.0",
"@standard-schema/spec": "1.1.0", "@standard-schema/spec": "1.1.0",
@@ -149,9 +149,9 @@
"electron" "electron"
], ],
"overrides": { "overrides": {
"@opentui/core": "0.0.0-20260807-6b4336f3", "@opentui/core": "catalog:",
"@opentui/keymap": "0.0.0-20260807-6b4336f3", "@opentui/keymap": "catalog:",
"@opentui/solid": "0.0.0-20260807-6b4336f3", "@opentui/solid": "catalog:",
"@types/bun": "catalog:", "@types/bun": "catalog:",
"@types/node": "catalog:", "@types/node": "catalog:",
"effect": "catalog:" "effect": "catalog:"
@@ -6,21 +6,17 @@ import { Icon } from "@opencode-ai/ui/icon"
import { IconButton } from "@opencode-ai/ui/icon-button" import { IconButton } from "@opencode-ai/ui/icon-button"
import { List } from "@opencode-ai/ui/list" import { List } from "@opencode-ai/ui/list"
import { TextField } from "@opencode-ai/ui/text-field" import { TextField } from "@opencode-ai/ui/text-field"
import { useMutation } from "@tanstack/solid-query" import { Show } from "solid-js"
import { showToast } from "@/utils/toast"
import { useNavigate } from "@solidjs/router"
import { createEffect, createMemo, createResource, Show } from "solid-js"
import { createStore } from "solid-js/store"
import { ServerHealthIndicator, ServerRow } from "@/components/server/server-row" import { ServerHealthIndicator, ServerRow } from "@/components/server/server-row"
import { useGlobal } from "@/context/global"
import { useLanguage } from "@/context/language" import { useLanguage } from "@/context/language"
import { usePlatform } from "@/context/platform" import { ServerConnection } from "@/context/server"
import { normalizeServerUrl, ServerConnection, useServer } from "@/context/server"
import { type ServerHealth, useCheckServerHealth } from "@/utils/server-health"
import { useSettings } from "@/context/settings" import { useSettings } from "@/context/settings"
import { useTabs } from "@/context/tabs" import {
type ServerDomainController,
const DEFAULT_USERNAME = "opencode" type ServerFormController,
useServerDomainController,
useServerFormController,
} from "@/components/server/server-management-controller"
interface ServerFormProps { interface ServerFormProps {
value: string value: string
@@ -39,76 +35,6 @@ interface ServerFormProps {
onBack: () => void onBack: () => void
} }
function showRequestError(language: ReturnType<typeof useLanguage>, err: unknown) {
showToast({
variant: "error",
title: language.t("common.requestFailed"),
description: err instanceof Error ? err.message : String(err),
})
}
function useDefaultServer() {
const language = useLanguage()
const platform = usePlatform()
const [defaultKey, defaultUrlActions] = createResource(
async () => {
try {
const key = await platform.getDefaultServer?.()
if (!key) return null
return key
} catch (err) {
showRequestError(language, err)
return null
}
},
{ initialValue: null },
)
const canDefault = createMemo(() => !!platform.getDefaultServer && !!platform.setDefaultServer)
const setDefault = async (key: ServerConnection.Key | null) => {
try {
await platform.setDefaultServer?.(key)
defaultUrlActions.mutate(key)
} catch (err) {
showRequestError(language, err)
}
}
return { defaultKey: () => defaultKey.latest, canDefault, setDefault }
}
function useServerPreview() {
const checkServerHealth = useCheckServerHealth()
const looksComplete = (value: string) => {
const normalized = normalizeServerUrl(value)
if (!normalized) return false
const host = normalized.replace(/^https?:\/\//, "").split("/")[0]
if (!host) return false
if (host.includes("localhost") || host.startsWith("127.0.0.1")) return true
return host.includes(".") || host.includes(":")
}
const previewStatus = async (
value: string,
username: string,
password: string,
setStatus: (value: boolean | undefined) => void,
) => {
setStatus(undefined)
if (!looksComplete(value)) return
const normalized = normalizeServerUrl(value)
if (!normalized) return
const http: ServerConnection.HttpBase = { url: normalized }
if (username) http.username = username
if (password) http.password = password
const result = await checkServerHealth(http)
setStatus(result.healthy)
}
return { previewStatus }
}
function ServerForm(props: ServerFormProps) { function ServerForm(props: ServerFormProps) {
const language = useLanguage() const language = useLanguage()
const keyDown = (event: KeyboardEvent) => { const keyDown = (event: KeyboardEvent) => {
@@ -176,385 +102,40 @@ function ServerForm(props: ServerFormProps) {
export function DialogSelectServer() { export function DialogSelectServer() {
const dialog = useDialog() const dialog = useDialog()
const controller = useServerManagementController({ onSelect: dialog.close }) const language = useLanguage()
const domain = useServerDomainController({ onSelect: () => dialog.close() })
const form = useServerFormController({ onSelect: () => dialog.close() })
const title = () => {
if (!form.state.open()) return language.t("dialog.server.title")
return (
<div class="flex items-center gap-2 -ml-2">
<IconButton icon="arrow-left" variant="ghost" onClick={form.reset} aria-label={language.t("common.goBack")} />
<span>
{form.state.adding() ? language.t("dialog.server.add.title") : language.t("dialog.server.edit.title")}
</span>
</div>
)
}
return ( return (
<Dialog title={controller.formTitle()}> <Dialog title={title()}>
<div class="flex flex-1 min-h-0 flex-col px-5"> <div class="flex flex-1 min-h-0 flex-col px-5">
<Show when={controller.isFormMode()} fallback={<ServerConnectionList controller={controller} />}> <Show
<ServerConnectionForm controller={controller} /> when={form.state.open()}
fallback={<ServerConnectionList domain={domain} onAdd={form.start.add} onEdit={form.start.edit} />}
>
<ServerConnectionForm form={form} />
</Show> </Show>
</div> </div>
</Dialog> </Dialog>
) )
} }
export function useServerManagementController(options: { onSelect?: () => void; navigateOnAdd?: boolean } = {}) { export function ServerConnectionList(props: {
const navigate = useNavigate() domain: ServerDomainController
const server = useServer() onAdd: () => void
const tabs = useTabs() onEdit: (server: ServerConnection.Http) => void
const global = useGlobal() }) {
const platform = usePlatform()
const language = useLanguage()
const { defaultKey, canDefault, setDefault } = useDefaultServer()
const { previewStatus } = useServerPreview()
const checkServerHealth = useCheckServerHealth()
const [store, setStore] = createStore({
addServer: {
url: "",
name: "",
username: DEFAULT_USERNAME,
password: "",
error: "",
showForm: false,
status: undefined as boolean | undefined,
},
editServer: {
id: undefined as string | undefined,
value: "",
name: "",
username: "",
password: "",
error: "",
status: undefined as boolean | undefined,
},
})
const resetAdd = () => {
setStore("addServer", {
url: "",
name: "",
username: DEFAULT_USERNAME,
password: "",
error: "",
showForm: false,
status: undefined,
})
}
const resetEdit = () => {
setStore("editServer", {
id: undefined,
value: "",
name: "",
username: "",
password: "",
error: "",
status: undefined,
})
}
const addMutation = useMutation(() => ({
mutationFn: async (value: string) => {
const normalized = normalizeServerUrl(value)
if (!normalized) {
resetAdd()
return
}
const conn: ServerConnection.Http = {
type: "http",
http: { url: normalized },
}
if (store.addServer.name.trim()) conn.displayName = store.addServer.name.trim()
if (store.addServer.password) conn.http.password = store.addServer.password
if (store.addServer.password && store.addServer.username) conn.http.username = store.addServer.username
const result = await checkServerHealth(conn.http)
if (!result.healthy) {
setStore("addServer", { error: language.t("dialog.server.add.error") })
return
}
resetAdd()
if (options.navigateOnAdd === false) {
server.add(conn)
options.onSelect?.()
return
}
await select(conn, true)
},
}))
const editMutation = useMutation(() => ({
mutationFn: async (input: { original: ServerConnection.Any; value: string }) => {
if (input.original.type !== "http") return
const normalized = normalizeServerUrl(input.value)
if (!normalized) {
resetEdit()
return
}
const name = store.editServer.name.trim() || undefined
const username = store.editServer.username || undefined
const password = store.editServer.password || undefined
const existingName = input.original.displayName
if (
normalized === input.original.http.url &&
name === existingName &&
username === input.original.http.username &&
password === input.original.http.password
) {
resetEdit()
return
}
const conn: ServerConnection.Http = {
type: "http",
displayName: name,
http: { url: normalized, username, password },
}
const result = await checkServerHealth(conn.http)
if (!result.healthy) {
setStore("editServer", { error: language.t("dialog.server.add.error") })
return
}
if (normalized === input.original.http.url) {
server.add(conn)
} else {
replaceServer(input.original, conn)
}
resetEdit()
},
}))
const replaceServer = (original: ServerConnection.Http, next: ServerConnection.Http) => {
const originalKey = ServerConnection.key(original)
const active = server.key
tabs.removeServer(originalKey)
const newConn = server.add(next)
if (!newConn) return
const nextActive = active === originalKey ? ServerConnection.key(newConn) : active
if (nextActive) server.setActive(nextActive)
server.remove(originalKey)
}
const items = createMemo(() => {
const current = server.current
const list = server.list
if (!current) return list
if (!list.includes(current)) return [current, ...list]
return [current, ...list.filter((x) => x !== current)]
})
const settings = useSettings()
const current = createMemo<ServerConnection.Any | undefined>(() =>
settings.general.newLayoutDesigns()
? undefined
: (items().find((x) => ServerConnection.key(x) === server.key) ?? items()[0]),
)
const sortedItems = createMemo(() => {
const raw = items()
const list = raw
if (!list.length) return list
const active = current()
const order = new Map(list.map((url, index) => [url, index] as const))
const rank = (value?: ServerHealth) => {
if (value?.healthy === true) return 0
if (value?.healthy === false) return 2
return 1
}
return list.slice().sort((a, b) => {
if (a === active) return -1
if (b === active) return 1
const diff =
rank(global.servers.health[ServerConnection.key(a)]) - rank(global.servers.health[ServerConnection.key(b)])
if (diff !== 0) return diff
return (order.get(a) ?? 0) - (order.get(b) ?? 0)
})
})
async function select(conn: ServerConnection.Any, persist?: boolean) {
if (!persist && global.servers.health[ServerConnection.key(conn)]?.healthy === false) return
options.onSelect?.()
if (persist && conn.type === "http") {
server.add(conn)
navigate("/")
return
}
navigate("/")
queueMicrotask(() => server.setActive(ServerConnection.key(conn)))
}
const handleAddChange = (value: string) => {
if (addMutation.isPending) return
setStore("addServer", { url: value, error: "" })
void previewStatus(value, store.addServer.username, store.addServer.password, (next) =>
setStore("addServer", { status: next }),
)
}
const handleAddNameChange = (value: string) => {
if (addMutation.isPending) return
setStore("addServer", { name: value, error: "" })
}
const handleAddUsernameChange = (value: string) => {
if (addMutation.isPending) return
setStore("addServer", { username: value, error: "" })
void previewStatus(store.addServer.url, value, store.addServer.password, (next) =>
setStore("addServer", { status: next }),
)
}
const handleAddPasswordChange = (value: string) => {
if (addMutation.isPending) return
setStore("addServer", { password: value, error: "" })
void previewStatus(store.addServer.url, store.addServer.username, value, (next) =>
setStore("addServer", { status: next }),
)
}
const handleEditChange = (value: string) => {
if (editMutation.isPending) return
setStore("editServer", { value, error: "" })
void previewStatus(value, store.editServer.username, store.editServer.password, (next) =>
setStore("editServer", { status: next }),
)
}
const handleEditNameChange = (value: string) => {
if (editMutation.isPending) return
setStore("editServer", { name: value, error: "" })
}
const handleEditUsernameChange = (value: string) => {
if (editMutation.isPending) return
setStore("editServer", { username: value, error: "" })
void previewStatus(store.editServer.value, value, store.editServer.password, (next) =>
setStore("editServer", { status: next }),
)
}
const handleEditPasswordChange = (value: string) => {
if (editMutation.isPending) return
setStore("editServer", { password: value, error: "" })
void previewStatus(store.editServer.value, store.editServer.username, value, (next) =>
setStore("editServer", { status: next }),
)
}
const mode = createMemo<"list" | "add" | "edit">(() => {
if (store.editServer.id) return "edit"
if (store.addServer.showForm) return "add"
return "list"
})
const editing = createMemo(() => {
if (!store.editServer.id) return
return items().find((x) => x.type === "http" && x.http.url === store.editServer.id)
})
const resetForm = () => {
resetAdd()
resetEdit()
}
const startAdd = () => {
resetEdit()
setStore("addServer", {
showForm: true,
url: "",
name: "",
username: DEFAULT_USERNAME,
password: "",
error: "",
status: undefined,
})
}
const startEdit = (conn: ServerConnection.Http) => {
resetAdd()
setStore("editServer", {
id: conn.http.url,
value: conn.http.url,
name: conn.displayName ?? "",
username: conn.http.username ?? "",
password: conn.http.password ?? "",
error: "",
status: global.servers.health[ServerConnection.key(conn)]?.healthy,
})
}
const submitForm = () => {
if (mode() === "add") {
if (addMutation.isPending) return
setStore("addServer", { error: "" })
addMutation.mutate(store.addServer.url)
return
}
const original = editing()
if (!original) return
if (editMutation.isPending) return
setStore("editServer", { error: "" })
editMutation.mutate({ original, value: store.editServer.value })
}
const isFormMode = createMemo(() => mode() !== "list")
const isAddMode = createMemo(() => mode() === "add")
const formBusy = createMemo(() => (isAddMode() ? addMutation.isPending : editMutation.isPending))
const formTitle = createMemo(() => {
if (!isFormMode()) return language.t("dialog.server.title")
return (
<div class="flex items-center gap-2 -ml-2">
<IconButton icon="arrow-left" variant="ghost" onClick={resetForm} aria-label={language.t("common.goBack")} />
<span>{isAddMode() ? language.t("dialog.server.add.title") : language.t("dialog.server.edit.title")}</span>
</div>
)
})
createEffect(() => {
if (!store.editServer.id) return
if (editing()) return
resetEdit()
})
async function handleRemove(key: ServerConnection.Key) {
try {
if (key.startsWith("wsl:")) await platform.wslServers?.removeServer(key)
tabs.removeServer(key)
server.remove(key)
if ((await platform.getDefaultServer?.()) === key) {
await setDefault(null)
}
} catch (err) {
showRequestError(language, err)
}
}
return {
defaultKey,
canDefault,
current,
sortedItems,
status: () => global.servers.health,
isFormMode,
isAddMode,
formTitle,
formBusy,
formValue: () => (isAddMode() ? store.addServer.url : store.editServer.value),
formName: () => (isAddMode() ? store.addServer.name : store.editServer.name),
formUsername: () => (isAddMode() ? store.addServer.username : store.editServer.username),
formPassword: () => (isAddMode() ? store.addServer.password : store.editServer.password),
formError: () => (isAddMode() ? store.addServer.error : store.editServer.error),
formStatus: () => (isAddMode() ? store.addServer.status : store.editServer.status),
select,
setDefault,
startAdd,
startEdit,
resetForm,
submitForm,
canRemove: server.canRemove,
handleRemove,
handleFormChange: () => (isAddMode() ? handleAddChange : handleEditChange),
handleFormNameChange: () => (isAddMode() ? handleAddNameChange : handleEditNameChange),
handleFormUsernameChange: () => (isAddMode() ? handleAddUsernameChange : handleEditUsernameChange),
handleFormPasswordChange: () => (isAddMode() ? handleAddPasswordChange : handleEditPasswordChange),
}
}
export function ServerConnectionList(props: { controller: ReturnType<typeof useServerManagementController> }) {
const language = useLanguage() const language = useLanguage()
const settings = useSettings() const settings = useSettings()
@@ -568,10 +149,10 @@ export function ServerConnectionList(props: { controller: ReturnType<typeof useS
}} }}
noInitialSelection noInitialSelection
emptyMessage={language.t("dialog.server.empty")} emptyMessage={language.t("dialog.server.empty")}
items={props.controller.sortedItems} items={props.domain.collection.items}
key={(x) => x.http.url} key={(x) => x.http.url}
onSelect={(x) => { onSelect={(x) => {
if (x && !settings.general.newLayoutDesigns()) void props.controller.select(x) if (x && !settings.general.newLayoutDesigns()) void props.domain.selection.select(x)
}} }}
divider={true} divider={true}
> >
@@ -580,15 +161,15 @@ export function ServerConnectionList(props: { controller: ReturnType<typeof useS
return ( return (
<div class="flex items-center gap-3 min-w-0 flex-1 w-full group/item"> <div class="flex items-center gap-3 min-w-0 flex-1 w-full group/item">
<div class="flex flex-col h-full items-center w-5"> <div class="flex flex-col h-full items-center w-5">
<ServerHealthIndicator health={props.controller.status()[key]} /> <ServerHealthIndicator health={props.domain.collection.health()[key]} />
</div> </div>
<ServerRow <ServerRow
conn={i} conn={i}
dimmed={props.controller.status()[key]?.healthy === false} dimmed={props.domain.collection.health()[key]?.healthy === false}
status={props.controller.status()[key]} status={props.domain.collection.health()[key]}
class="flex items-center gap-3 min-w-0 flex-1" class="flex items-center gap-3 min-w-0 flex-1"
badge={ badge={
<Show when={props.controller.defaultKey() === ServerConnection.key(i)}> <Show when={props.domain.defaults.key() === ServerConnection.key(i)}>
<span class="text-text-base bg-surface-base text-14-regular px-1.5 rounded-xs"> <span class="text-text-base bg-surface-base text-14-regular px-1.5 rounded-xs">
{language.t("dialog.server.status.default")} {language.t("dialog.server.status.default")}
</span> </span>
@@ -597,7 +178,12 @@ export function ServerConnectionList(props: { controller: ReturnType<typeof useS
showCredentials showCredentials
/> />
<div class="flex items-center justify-center gap-4 pl-4"> <div class="flex items-center justify-center gap-4 pl-4">
<Show when={props.controller.current() && ServerConnection.key(props.controller.current()!) === key}> <Show
when={
props.domain.collection.current() &&
ServerConnection.key(props.domain.collection.current()!) === key
}
>
<Icon name="check" class="h-6" /> <Icon name="check" class="h-6" />
</Show> </Show>
@@ -616,27 +202,27 @@ export function ServerConnectionList(props: { controller: ReturnType<typeof useS
<DropdownMenu.Item <DropdownMenu.Item
onSelect={() => { onSelect={() => {
if (i.type !== "http") return if (i.type !== "http") return
props.controller.startEdit(i) props.onEdit(i)
}} }}
> >
<DropdownMenu.ItemLabel>{language.t("dialog.server.menu.edit")}</DropdownMenu.ItemLabel> <DropdownMenu.ItemLabel>{language.t("dialog.server.menu.edit")}</DropdownMenu.ItemLabel>
</DropdownMenu.Item> </DropdownMenu.Item>
<Show when={props.controller.canDefault() && props.controller.defaultKey() !== key}> <Show when={props.domain.defaults.available() && props.domain.defaults.key() !== key}>
<DropdownMenu.Item onSelect={() => props.controller.setDefault(key)}> <DropdownMenu.Item onSelect={() => props.domain.defaults.set(key)}>
<DropdownMenu.ItemLabel>{language.t("dialog.server.menu.default")}</DropdownMenu.ItemLabel> <DropdownMenu.ItemLabel>{language.t("dialog.server.menu.default")}</DropdownMenu.ItemLabel>
</DropdownMenu.Item> </DropdownMenu.Item>
</Show> </Show>
<Show when={props.controller.canDefault() && props.controller.defaultKey() === key}> <Show when={props.domain.defaults.available() && props.domain.defaults.key() === key}>
<DropdownMenu.Item onSelect={() => props.controller.setDefault(null)}> <DropdownMenu.Item onSelect={() => props.domain.defaults.set(null)}>
<DropdownMenu.ItemLabel> <DropdownMenu.ItemLabel>
{language.t("dialog.server.menu.defaultRemove")} {language.t("dialog.server.menu.defaultRemove")}
</DropdownMenu.ItemLabel> </DropdownMenu.ItemLabel>
</DropdownMenu.Item> </DropdownMenu.Item>
</Show> </Show>
<Show when={props.controller.canRemove(key)}> <Show when={props.domain.connection.canRemove(key)}>
<DropdownMenu.Separator /> <DropdownMenu.Separator />
<DropdownMenu.Item <DropdownMenu.Item
onSelect={() => props.controller.handleRemove(ServerConnection.key(i))} onSelect={() => props.domain.connection.remove(key)}
class="text-text-on-critical-base hover:bg-surface-critical-weak" class="text-text-on-critical-base hover:bg-surface-critical-weak"
> >
<DropdownMenu.ItemLabel>{language.t("dialog.server.menu.delete")}</DropdownMenu.ItemLabel> <DropdownMenu.ItemLabel>{language.t("dialog.server.menu.delete")}</DropdownMenu.ItemLabel>
@@ -657,7 +243,7 @@ export function ServerConnectionList(props: { controller: ReturnType<typeof useS
variant="secondary" variant="secondary"
icon="plus-small" icon="plus-small"
size="large" size="large"
onClick={props.controller.startAdd} onClick={props.onAdd}
class="py-1.5 pl-1.5 pr-3 flex items-center gap-1.5" class="py-1.5 pl-1.5 pr-3 flex items-center gap-1.5"
> >
{language.t("dialog.server.add.button")} {language.t("dialog.server.add.button")}
@@ -667,38 +253,38 @@ export function ServerConnectionList(props: { controller: ReturnType<typeof useS
) )
} }
export function ServerConnectionForm(props: { controller: ReturnType<typeof useServerManagementController> }) { export function ServerConnectionForm(props: { form: ServerFormController }) {
const language = useLanguage() const language = useLanguage()
return ( return (
<div class="flex flex-1 min-h-0 flex-col gap-4"> <div class="flex flex-1 min-h-0 flex-col gap-4">
<ServerForm <ServerForm
value={props.controller.formValue()} value={props.form.state.value()}
name={props.controller.formName()} name={props.form.state.name()}
username={props.controller.formUsername()} username={props.form.state.username()}
password={props.controller.formPassword()} password={props.form.state.password()}
placeholder={language.t("dialog.server.add.placeholder")} placeholder={language.t("dialog.server.add.placeholder")}
busy={props.controller.formBusy()} busy={props.form.state.busy()}
error={props.controller.formError()} error={props.form.state.error()}
status={props.controller.formStatus()} status={props.form.state.status()}
onChange={props.controller.handleFormChange()} onChange={props.form.change.value}
onNameChange={props.controller.handleFormNameChange()} onNameChange={props.form.change.name}
onUsernameChange={props.controller.handleFormUsernameChange()} onUsernameChange={props.form.change.username}
onPasswordChange={props.controller.handleFormPasswordChange()} onPasswordChange={props.form.change.password}
onSubmit={props.controller.submitForm} onSubmit={props.form.submit}
onBack={props.controller.resetForm} onBack={props.form.reset}
/> />
<div class="shrink-0 pb-5"> <div class="shrink-0 pb-5">
<Button <Button
variant="primary" variant="primary"
size="large" size="large"
onClick={props.controller.submitForm} onClick={props.form.submit}
disabled={props.controller.formBusy()} disabled={props.form.state.busy()}
class="px-3 py-1.5" class="px-3 py-1.5"
> >
{props.controller.formBusy() {props.form.state.busy()
? language.t("dialog.server.add.checking") ? language.t("dialog.server.add.checking")
: props.controller.isAddMode() : props.form.state.adding()
? language.t("dialog.server.add.button") ? language.t("dialog.server.add.button")
: language.t("common.save")} : language.t("common.save")}
</Button> </Button>
@@ -0,0 +1,323 @@
import { useNavigate } from "@solidjs/router"
import { useMutation } from "@tanstack/solid-query"
import { createEffect, createMemo, createResource, onCleanup } from "solid-js"
import { createStore } from "solid-js/store"
import { useGlobal } from "@/context/global"
import { useLanguage } from "@/context/language"
import { usePlatform } from "@/context/platform"
import { normalizeServerUrl, ServerConnection, useServer } from "@/context/server"
import { useSettings } from "@/context/settings"
import { useTabs } from "@/context/tabs"
import { type ServerHealth, useCheckServerHealth } from "@/utils/server-health"
import { showToast } from "@/utils/toast"
import { createServerHealthPreview, replaceServerConnection, type ServerFormValues } from "./server-management"
const DEFAULT_USERNAME = "opencode"
type FormMode = "list" | "add" | "edit"
function showRequestError(language: ReturnType<typeof useLanguage>, err: unknown) {
showToast({
variant: "error",
title: language.t("common.requestFailed"),
description: err instanceof Error ? err.message : String(err),
})
}
function useDefaultServer() {
const language = useLanguage()
const platform = usePlatform()
const [defaultKey, defaultKeyActions] = createResource(
async () => {
try {
return (await platform.getDefaultServer?.()) ?? null
} catch (err) {
showRequestError(language, err)
return null
}
},
{ initialValue: null },
)
const set = async (key: ServerConnection.Key | null) => {
try {
await platform.setDefaultServer?.(key)
defaultKeyActions.mutate(key)
} catch (err) {
showRequestError(language, err)
}
}
return {
key: () => defaultKey.latest,
available: createMemo(() => !!platform.getDefaultServer && !!platform.setDefaultServer),
set,
}
}
function useServerMutations() {
const server = useServer()
const tabs = useTabs()
return {
add: (connection: ServerConnection.Http) => server.add(connection),
replace: (originalKey: ServerConnection.Key, next: ServerConnection.Http) =>
replaceServerConnection(originalKey, next, {
active: () => server.key,
removeTabs: (key) => tabs.removeServer(key),
add: (connection) => server.add(connection),
setActive: (key) => server.setActive(key),
remove: (key) => server.remove(key),
}),
}
}
export function useServerActionsController() {
const server = useServer()
const tabs = useTabs()
const platform = usePlatform()
const language = useLanguage()
const defaults = useDefaultServer()
const remove = async (key: ServerConnection.Key) => {
try {
if (key.startsWith("wsl:")) await platform.wslServers?.removeServer(key)
tabs.removeServer(key)
server.remove(key)
if ((await platform.getDefaultServer?.()) === key) await defaults.set(null)
} catch (err) {
showRequestError(language, err)
}
}
return { defaults, connection: { canRemove: server.canRemove, remove } }
}
export type ServerActionsController = ReturnType<typeof useServerActionsController>
export function useServerCollectionController() {
const server = useServer()
const global = useGlobal()
const settings = useSettings()
const actions = useServerActionsController()
const items = createMemo(() => {
const current = server.current
const list = server.list
if (!current) return list
if (!list.includes(current)) return [current, ...list]
return [current, ...list.filter((item) => item !== current)]
})
const current = createMemo<ServerConnection.Any | undefined>(() =>
settings.general.newLayoutDesigns()
? undefined
: (items().find((item) => ServerConnection.key(item) === server.key) ?? items()[0]),
)
const sorted = createMemo(() => {
const raw = items()
const list = raw
if (!list.length) return list
const active = current()
const order = new Map(list.map((item, index) => [item, index] as const))
const rank = (value?: ServerHealth) => {
if (value?.healthy === true) return 0
if (value?.healthy === false) return 2
return 1
}
return list.slice().sort((a, b) => {
if (a === active) return -1
if (b === active) return 1
const diff =
rank(global.servers.health[ServerConnection.key(a)]) - rank(global.servers.health[ServerConnection.key(b)])
if (diff !== 0) return diff
return (order.get(a) ?? 0) - (order.get(b) ?? 0)
})
})
return {
collection: {
items: sorted,
current,
health: () => global.servers.health,
},
...actions,
}
}
export type ServerCollectionController = ReturnType<typeof useServerCollectionController>
export function useServerDomainController(options: { onSelect?: () => void } = {}) {
const navigate = useNavigate()
const server = useServer()
const global = useGlobal()
const collection = useServerCollectionController()
const select = async (connection: ServerConnection.Any) => {
if (global.servers.health[ServerConnection.key(connection)]?.healthy === false) return
options.onSelect?.()
navigate("/")
queueMicrotask(() => server.setActive(ServerConnection.key(connection)))
}
return { ...collection, selection: { select } }
}
export type ServerDomainController = ReturnType<typeof useServerDomainController>
export function useServerFormController(options: { onSelect?: () => void; navigateOnAdd?: boolean } = {}) {
const navigate = useNavigate()
const server = useServer()
const global = useGlobal()
const language = useLanguage()
const mutations = useServerMutations()
const checkServerHealth = useCheckServerHealth()
const healthPreview = createServerHealthPreview(checkServerHealth)
const [store, setStore] = createStore({
mode: "list" as FormMode,
originalUrl: undefined as string | undefined,
values: { url: "", name: "", username: DEFAULT_USERNAME, password: "" },
error: "",
status: undefined as boolean | undefined,
})
onCleanup(healthPreview.cancel)
const reset = () => {
healthPreview.cancel()
setStore({
mode: "list",
originalUrl: undefined,
values: { url: "", name: "", username: DEFAULT_USERNAME, password: "" },
error: "",
status: undefined,
})
}
const allServers = () => {
if (!server.current || server.list.includes(server.current)) return server.list
return [server.current, ...server.list]
}
const editing = createMemo(() =>
allServers().find((item) => item.type === "http" && item.http.url === store.originalUrl),
)
const request = useMutation(() => ({
mutationFn: async () => {
const normalized = normalizeServerUrl(store.values.url)
if (!normalized) {
reset()
return
}
const original = store.mode === "edit" ? editing() : undefined
if (store.mode === "edit" && !original) return
const name = store.values.name.trim() || undefined
const username = store.values.username || undefined
const password = store.values.password || undefined
if (
original?.type === "http" &&
normalized === original.http.url &&
name === original.displayName &&
username === original.http.username &&
password === original.http.password
) {
reset()
return
}
const connection: ServerConnection.Http = {
type: "http",
displayName: name,
http: {
url: normalized,
username: store.mode === "add" && !password ? undefined : username,
password,
},
}
const result = await checkServerHealth(connection.http)
if (!result.healthy) {
setStore("error", language.t("dialog.server.add.error"))
return
}
if (original?.type === "http") {
if (normalized === original.http.url) mutations.add(connection)
if (normalized !== original.http.url) mutations.replace(ServerConnection.key(original), connection)
reset()
return
}
reset()
if (options.navigateOnAdd === false) {
mutations.add(connection)
options.onSelect?.()
return
}
mutations.add(connection)
options.onSelect?.()
navigate("/")
},
}))
const preview = () => void healthPreview.preview(store.values, (status) => setStore("status", status))
const change = (field: keyof ServerFormValues, value: string) => {
if (request.isPending) return
setStore("values", field, value)
setStore("error", "")
if (field !== "name") preview()
}
const startAdd = () => {
reset()
setStore("mode", "add")
}
const startEdit = (connection: ServerConnection.Http) => {
reset()
setStore({
mode: "edit",
originalUrl: connection.http.url,
values: {
url: connection.http.url,
name: connection.displayName ?? "",
username: connection.http.username ?? "",
password: connection.http.password ?? "",
},
error: "",
status: global.servers.health[ServerConnection.key(connection)]?.healthy,
})
}
const submit = () => {
if (store.mode === "list" || request.isPending) return
setStore("error", "")
request.mutate()
}
createEffect(() => {
if (store.mode !== "edit") return
if (editing()) return
reset()
})
return {
state: {
mode: () => store.mode,
open: () => store.mode !== "list",
adding: () => store.mode === "add",
busy: () => request.isPending,
value: () => store.values.url,
name: () => store.values.name,
username: () => store.values.username,
password: () => store.values.password,
error: () => store.error,
status: () => store.status,
},
change: {
value: (value: string) => change("url", value),
name: (value: string) => change("name", value),
username: (value: string) => change("username", value),
password: (value: string) => change("password", value),
},
start: { add: startAdd, edit: startEdit },
reset,
submit,
}
}
export type ServerFormController = ReturnType<typeof useServerFormController>
@@ -0,0 +1,99 @@
import { describe, expect, test } from "bun:test"
import { ServerConnection } from "@/context/server"
import { createServerHealthPreview, replaceServerConnection, type ServerFormValues } from "./server-management"
function deferred<T>() {
let resolve!: (value: T) => void
const promise = new Promise<T>((done) => {
resolve = done
})
return { promise, resolve }
}
const values = (url: string): ServerFormValues => ({ url, name: "", username: "opencode", password: "" })
describe("createServerHealthPreview", () => {
test("ignores an older response that resolves after the latest response", async () => {
const first = deferred<{ healthy: boolean }>()
const second = deferred<{ healthy: boolean }>()
const requests = [first, second]
const status: Array<boolean | undefined> = []
const preview = createServerHealthPreview(() => requests.shift()!.promise)
const older = preview.preview(values("old.example.com"), (value) => status.push(value))
const latest = preview.preview(values("new.example.com"), (value) => status.push(value))
second.resolve({ healthy: true })
await latest
first.resolve({ healthy: false })
await older
expect(status).toEqual([undefined, undefined, true])
})
test("an incomplete value invalidates an in-flight response", async () => {
const request = deferred<{ healthy: boolean }>()
const status: Array<boolean | undefined> = []
const preview = createServerHealthPreview(() => request.promise)
const pending = preview.preview(values("server.example.com"), (value) => status.push(value))
await preview.preview(values("server"), (value) => status.push(value))
request.resolve({ healthy: true })
await pending
expect(status).toEqual([undefined, undefined])
})
test("cancellation prevents an in-flight response from updating status", async () => {
const request = deferred<{ healthy: boolean }>()
const status: Array<boolean | undefined> = []
const preview = createServerHealthPreview(() => request.promise)
const pending = preview.preview(values("server.example.com"), (value) => status.push(value))
preview.cancel()
request.resolve({ healthy: true })
await pending
expect(status).toEqual([undefined])
})
})
describe("replaceServerConnection", () => {
const original: ServerConnection.Http = { type: "http", http: { url: "https://old.example.com" } }
const next: ServerConnection.Http = { type: "http", http: { url: "https://new.example.com" } }
test("moves active selection after adding the replacement and removes the original", () => {
const calls: string[] = []
replaceServerConnection(ServerConnection.key(original), next, {
active: () => ServerConnection.key(original),
removeTabs: (key) => calls.push(`tabs:${key}`),
add: (server) => {
calls.push(`add:${ServerConnection.key(server)}`)
return server
},
setActive: (key) => calls.push(`active:${key}`),
remove: (key) => calls.push(`remove:${key}`),
})
expect(calls).toEqual([
"tabs:https://old.example.com",
"add:https://new.example.com",
"active:https://new.example.com",
"remove:https://old.example.com",
])
})
test("keeps the original when the replacement cannot be added", () => {
const removed: ServerConnection.Key[] = []
replaceServerConnection(ServerConnection.key(original), next, {
active: () => ServerConnection.key(original),
removeTabs: () => {},
add: () => undefined,
setActive: () => {},
remove: (key) => removed.push(key),
})
expect(removed).toEqual([])
})
})
@@ -0,0 +1,59 @@
import { normalizeServerUrl, ServerConnection } from "@/context/server"
import type { ServerHealth } from "@/utils/server-health"
export type ServerFormValues = {
url: string
name: string
username: string
password: string
}
export function createServerHealthPreview(
check: (server: ServerConnection.HttpBase) => Promise<Pick<ServerHealth, "healthy">>,
) {
let generation = 0
const cancel = () => {
generation += 1
}
const preview = async (values: ServerFormValues, setStatus: (value: boolean | undefined) => void) => {
const current = ++generation
setStatus(undefined)
const normalized = normalizeServerUrl(values.url)
if (!normalized) return
const host = normalized.replace(/^https?:\/\//, "").split("/")[0]
if (!host) return
if (!host.includes("localhost") && !host.startsWith("127.0.0.1") && !host.includes(".") && !host.includes(":"))
return
const http: ServerConnection.HttpBase = { url: normalized }
if (values.username) http.username = values.username
if (values.password) http.password = values.password
const result = await check(http)
if (current !== generation) return
setStatus(result.healthy)
}
return { cancel, preview }
}
export function replaceServerConnection(
originalKey: ServerConnection.Key,
next: ServerConnection.Http,
operations: {
active: () => ServerConnection.Key | undefined
removeTabs: (key: ServerConnection.Key) => void
add: (server: ServerConnection.Http) => ServerConnection.Any | undefined
setActive: (key: ServerConnection.Key) => void
remove: (key: ServerConnection.Key) => void
},
) {
const active = operations.active()
operations.removeTabs(originalKey)
const added = operations.add(next)
if (!added) return
const nextActive = active === originalKey ? ServerConnection.key(added) : active
if (nextActive) operations.setActive(nextActive)
operations.remove(originalKey)
}
@@ -2,13 +2,13 @@ import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon"
import { IconButtonV2 } from "@opencode-ai/ui/v2/icon-button-v2" import { IconButtonV2 } from "@opencode-ai/ui/v2/icon-button-v2"
import { MenuV2 } from "@opencode-ai/ui/v2/menu-v2" import { MenuV2 } from "@opencode-ai/ui/v2/menu-v2"
import { type Component, Show } from "solid-js" import { type Component, Show } from "solid-js"
import { useServerManagementController } from "@/components/dialog-select-server" import type { ServerActionsController } from "@/components/server/server-management-controller"
import { useLanguage } from "@/context/language" import { useLanguage } from "@/context/language"
import { ServerConnection } from "@/context/server" import { ServerConnection } from "@/context/server"
export const ServerRowMenu: Component<{ export const ServerRowMenu: Component<{
server: ServerConnection.Any server: ServerConnection.Any
controller: ReturnType<typeof useServerManagementController> domain: ServerActionsController
onEdit: (server: ServerConnection.Http) => void onEdit: (server: ServerConnection.Http) => void
open?: boolean open?: boolean
onOpenChange?: (open: boolean) => void onOpenChange?: (open: boolean) => void
@@ -19,13 +19,13 @@ export const ServerRowMenu: Component<{
<ServerRowMenuView <ServerRowMenuView
server={props.server} server={props.server}
labels={serverMenuLabels(language)} labels={serverMenuLabels(language)}
canDefault={props.controller.canDefault()} canDefault={props.domain.defaults.available()}
isDefault={props.controller.defaultKey() === key} isDefault={props.domain.defaults.key() === key}
canRemove={props.controller.canRemove(key)} canRemove={props.domain.connection.canRemove(key)}
onEdit={props.onEdit} onEdit={props.onEdit}
onSetDefault={() => props.controller.setDefault(key)} onSetDefault={() => props.domain.defaults.set(key)}
onRemoveDefault={() => props.controller.setDefault(null)} onRemoveDefault={() => props.domain.defaults.set(null)}
onRemove={() => props.controller.handleRemove(key)} onRemove={() => props.domain.connection.remove(key)}
open={props.open} open={props.open}
onOpenChange={props.onOpenChange} onOpenChange={props.onOpenChange}
/> />
@@ -6,7 +6,7 @@ import { useDialog } from "@opencode-ai/ui/context/dialog"
import { type Component, Show, createEffect, createSignal, onCleanup, onMount } from "solid-js" import { type Component, Show, createEffect, createSignal, onCleanup, onMount } from "solid-js"
import { useLanguage } from "@/context/language" import { useLanguage } from "@/context/language"
import { type ServerConnection } from "@/context/server" import { type ServerConnection } from "@/context/server"
import { useServerManagementController } from "../dialog-select-server" import { useServerFormController } from "../server/server-management-controller"
import "./settings-v2.css" import "./settings-v2.css"
export const DialogServerV2: Component<{ export const DialogServerV2: Component<{
@@ -15,39 +15,39 @@ export const DialogServerV2: Component<{
}> = (props) => { }> = (props) => {
const dialog = useDialog() const dialog = useDialog()
const language = useLanguage() const language = useLanguage()
const controller = useServerManagementController({ const form = useServerFormController({
onSelect: () => dialog.close(), onSelect: () => dialog.close(),
navigateOnAdd: false, navigateOnAdd: false,
}) })
const [opened, setOpened] = createSignal(false) const [opened, setOpened] = createSignal(false)
onMount(() => { onMount(() => {
if (props.mode === "add") controller.startAdd() if (props.mode === "add") form.start.add()
if (props.mode === "edit" && props.server) controller.startEdit(props.server) if (props.mode === "edit" && props.server) form.start.edit(props.server)
setOpened(true) setOpened(true)
}) })
onCleanup(() => { onCleanup(() => {
controller.resetForm() form.reset()
}) })
createEffect(() => { createEffect(() => {
if (!opened()) return if (!opened()) return
if (controller.isFormMode()) return if (form.state.open()) return
dialog.close() dialog.close()
}) })
const keyDown = (event: KeyboardEvent) => { const keyDown = (event: KeyboardEvent) => {
if (event.key !== "Enter" || event.isComposing) return if (event.key !== "Enter" || event.isComposing) return
event.preventDefault() event.preventDefault()
controller.submitForm() form.submit()
} }
const title = () => const title = () =>
props.mode === "add" ? language.t("dialog.server.add.title") : language.t("dialog.server.edit.title") props.mode === "add" ? language.t("dialog.server.add.title") : language.t("dialog.server.edit.title")
const submitLabel = () => { const submitLabel = () => {
if (controller.formBusy()) return language.t("dialog.server.add.checking") if (form.state.busy()) return language.t("dialog.server.add.checking")
if (props.mode === "add") return language.t("dialog.server.add.button") if (props.mode === "add") return language.t("dialog.server.add.button")
return language.t("common.save") return language.t("common.save")
} }
@@ -66,16 +66,16 @@ export const DialogServerV2: Component<{
type="text" type="text"
appearance="large" appearance="large"
class="!w-full self-stretch" class="!w-full self-stretch"
value={controller.formValue()} value={form.state.value()}
placeholder={language.t("dialog.server.add.placeholder")} placeholder={language.t("dialog.server.add.placeholder")}
invalid={!!controller.formError()} invalid={!!form.state.error()}
disabled={controller.formBusy()} disabled={form.state.busy()}
autofocus autofocus
onInput={(event) => controller.handleFormChange()(event.currentTarget.value)} onInput={(event) => form.change.value(event.currentTarget.value)}
onKeyDown={keyDown} onKeyDown={keyDown}
/> />
<Show when={controller.formError()}> <Show when={form.state.error()}>
<span class="settings-v2-server-dialog-error">{controller.formError()}</span> <span class="settings-v2-server-dialog-error">{form.state.error()}</span>
</Show> </Show>
</div> </div>
<div class="flex w-full min-w-0 flex-col gap-2"> <div class="flex w-full min-w-0 flex-col gap-2">
@@ -84,10 +84,10 @@ export const DialogServerV2: Component<{
type="text" type="text"
appearance="large" appearance="large"
class="!w-full self-stretch" class="!w-full self-stretch"
value={controller.formName()} value={form.state.name()}
placeholder={language.t("dialog.server.add.namePlaceholder")} placeholder={language.t("dialog.server.add.namePlaceholder")}
disabled={controller.formBusy()} disabled={form.state.busy()}
onInput={(event) => controller.handleFormNameChange()(event.currentTarget.value)} onInput={(event) => form.change.name(event.currentTarget.value)}
onKeyDown={keyDown} onKeyDown={keyDown}
/> />
</div> </div>
@@ -98,10 +98,10 @@ export const DialogServerV2: Component<{
type="text" type="text"
appearance="large" appearance="large"
class="!w-full self-stretch" class="!w-full self-stretch"
value={controller.formUsername()} value={form.state.username()}
placeholder={language.t("dialog.server.add.usernamePlaceholder")} placeholder={language.t("dialog.server.add.usernamePlaceholder")}
disabled={controller.formBusy()} disabled={form.state.busy()}
onInput={(event) => controller.handleFormUsernameChange()(event.currentTarget.value)} onInput={(event) => form.change.username(event.currentTarget.value)}
onKeyDown={keyDown} onKeyDown={keyDown}
/> />
</div> </div>
@@ -111,10 +111,10 @@ export const DialogServerV2: Component<{
type="password" type="password"
appearance="large" appearance="large"
class="!w-full self-stretch" class="!w-full self-stretch"
value={controller.formPassword()} value={form.state.password()}
placeholder={language.t("dialog.server.add.passwordPlaceholder")} placeholder={language.t("dialog.server.add.passwordPlaceholder")}
disabled={controller.formBusy()} disabled={form.state.busy()}
onInput={(event) => controller.handleFormPasswordChange()(event.currentTarget.value)} onInput={(event) => form.change.password(event.currentTarget.value)}
onKeyDown={keyDown} onKeyDown={keyDown}
/> />
</div> </div>
@@ -122,10 +122,10 @@ export const DialogServerV2: Component<{
</div> </div>
</DialogBody> </DialogBody>
<DialogFooter> <DialogFooter>
<ButtonV2 variant="neutral" disabled={controller.formBusy()} onClick={() => dialog.close()}> <ButtonV2 variant="neutral" disabled={form.state.busy()} onClick={() => dialog.close()}>
{language.t("common.cancel")} {language.t("common.cancel")}
</ButtonV2> </ButtonV2>
<ButtonV2 variant="contrast" disabled={controller.formBusy()} onClick={controller.submitForm}> <ButtonV2 variant="contrast" disabled={form.state.busy()} onClick={form.submit}>
{submitLabel()} {submitLabel()}
</ButtonV2> </ButtonV2>
</DialogFooter> </DialogFooter>
@@ -10,7 +10,7 @@ import { ServerRowMenu } from "@/components/server/server-row-menu"
import { ServerHealthIndicator } from "@/components/server/server-row" import { ServerHealthIndicator } from "@/components/server/server-row"
import { useLanguage } from "@/context/language" import { useLanguage } from "@/context/language"
import { ServerConnection, serverName } from "@/context/server" import { ServerConnection, serverName } from "@/context/server"
import { useServerManagementController } from "../dialog-select-server" import { useServerCollectionController } from "../server/server-management-controller"
import { DialogServerV2 } from "./dialog-server-v2" import { DialogServerV2 } from "./dialog-server-v2"
import { SettingsListV2 } from "./parts/list" import { SettingsListV2 } from "./parts/list"
import { AddServerMenu, isWslServer, useFilteredWslServers, WslServerSettings } from "@/wsl/settings" import { AddServerMenu, isWslServer, useFilteredWslServers, WslServerSettings } from "@/wsl/settings"
@@ -19,16 +19,16 @@ import "./settings-v2.css"
export const SettingsServersV2: Component = () => { export const SettingsServersV2: Component = () => {
const dialog = useDialog() const dialog = useDialog()
const language = useLanguage() const language = useLanguage()
const controller = useServerManagementController() const domain = useServerCollectionController()
const [store, setStore] = createStore({ filter: "" }) const [store, setStore] = createStore({ filter: "" })
const wslServers = useFilteredWslServers(() => store.filter) const wslServers = useFilteredWslServers(() => store.filter)
const showSearch = createMemo( const showSearch = createMemo(
() => controller.sortedItems().filter((item) => !isWslServer(item)).length + wslServers().length > 1, () => domain.collection.items().filter((item) => !isWslServer(item)).length + wslServers().length > 1,
) )
const filtered = createMemo(() => { const filtered = createMemo(() => {
const items = controller.sortedItems().filter((item) => !isWslServer(item)) const items = domain.collection.items().filter((item) => !isWslServer(item))
const query = store.filter.trim() const query = store.filter.trim()
if (!query) return items if (!query) return items
return fuzzysort return fuzzysort
@@ -39,11 +39,11 @@ export const SettingsServersV2: Component = () => {
}) })
const openAdd = () => { const openAdd = () => {
dialog.push(() => <DialogServerV2 mode="add" />) void dialog.push(() => <DialogServerV2 mode="add" />)
} }
const openEdit = (server: ServerConnection.Http) => { const openEdit = (server: ServerConnection.Http) => {
dialog.push(() => <DialogServerV2 mode="edit" server={server} />) void dialog.push(() => <DialogServerV2 mode="edit" server={server} />)
} }
return ( return (
@@ -97,12 +97,12 @@ export const SettingsServersV2: Component = () => {
} }
> >
<SettingsListV2> <SettingsListV2>
<WslServerSettings controller={controller} servers={wslServers} /> <WslServerSettings domain={domain} servers={wslServers} />
<For each={filtered()}> <For each={filtered()}>
{(item) => { {(item) => {
const key = ServerConnection.key(item) const key = ServerConnection.key(item)
const health = () => controller.status()[key] const health = () => domain.collection.health()[key]
const isDefault = () => controller.defaultKey() === key const isDefault = () => domain.defaults.key() === key
return ( return (
<div class="settings-v2-servers-row"> <div class="settings-v2-servers-row">
<div class="settings-v2-servers-lead"> <div class="settings-v2-servers-lead">
@@ -122,10 +122,10 @@ export const SettingsServersV2: Component = () => {
</div> </div>
</div> </div>
<div class="settings-v2-servers-actions"> <div class="settings-v2-servers-actions">
<Show when={controller.canDefault() && isDefault()}> <Show when={domain.defaults.available() && isDefault()}>
<Tag>{language.t("dialog.server.status.default")}</Tag> <Tag>{language.t("dialog.server.status.default")}</Tag>
</Show> </Show>
<ServerRowMenu server={item} controller={controller} onEdit={openEdit} /> <ServerRowMenu server={item} domain={domain} onEdit={openEdit} />
</div> </div>
</div> </div>
) )
@@ -153,88 +153,4 @@ describe("v2 session reducer", () => {
expect(result).toMatchObject({ sessionID: "ses_1", missing: "msg_user", touched: [] }) expect(result).toMatchObject({ sessionID: "ses_1", missing: "msg_user", touched: [] })
}) })
test("removes cancelled input from the pending promotion fold", () => {
const reducer = createV2SessionReducer()
reducer.reduce(
[],
event({
...base,
id: "evt_admitted",
type: "session.input.admitted",
data: {
sessionID: "ses_1",
inputID: "msg_user",
input: { type: "user", delivery: "queue", data: { text: "cancel me" } },
},
}),
)
reducer.reduce(
[],
event({
...base,
id: "evt_cancelled",
type: "session.input.cancelled",
data: { sessionID: "ses_1", inputID: "msg_user" },
}),
)
const result = reducer.reduce(
[],
event({
...base,
id: "evt_promoted",
type: "session.input.promoted",
data: { sessionID: "ses_1", inputID: "msg_user" },
}),
)
expect(result).toMatchObject({ missing: "msg_user" })
})
test("keeps steered input available to the promotion fold", () => {
const reducer = createV2SessionReducer()
reducer.reduce(
[],
event({
...base,
id: "evt_admitted",
type: "session.input.admitted",
data: {
sessionID: "ses_1",
inputID: "msg_user",
input: { type: "user", delivery: "queue", data: { text: "steer me" } },
},
}),
)
reducer.reduce(
[],
event({
...base,
id: "evt_steered",
type: "session.input.steered",
data: { sessionID: "ses_1", inputID: "msg_user" },
}),
)
reducer.reduce(
[],
event({
...base,
id: "evt_queued",
type: "session.input.queued",
data: { sessionID: "ses_1", inputID: "msg_user" },
}),
)
const result = reducer.reduce(
[],
event({
...base,
id: "evt_promoted",
type: "session.input.promoted",
data: { sessionID: "ses_1", inputID: "msg_user" },
}),
)
expect(result?.messages).toMatchObject([{ id: "msg_user", type: "user", text: "steer me" }])
})
}) })
@@ -29,9 +29,6 @@ export function createV2SessionReducer() {
case "session.input.admitted": case "session.input.admitted":
pending.set(key(sessionID, event.data.inputID), event.data.input) pending.set(key(sessionID, event.data.inputID), event.data.input)
return result([...source]) return result([...source])
case "session.input.cancelled":
pending.delete(key(sessionID, event.data.inputID))
return
case "session.input.promoted": { case "session.input.promoted": {
const input = pending.get(key(sessionID, event.data.inputID)) const input = pending.get(key(sessionID, event.data.inputID))
pending.delete(key(sessionID, event.data.inputID)) pending.delete(key(sessionID, event.data.inputID))
@@ -1,5 +1,5 @@
import { useDirectoryPicker } from "@/components/directory-picker" import { useDirectoryPicker } from "@/components/directory-picker"
import { useServerManagementController } from "@/components/dialog-select-server" import { useServerActionsController } from "@/components/server/server-management-controller"
import { useSettingsCommand } from "@/components/settings-dialog" import { useSettingsCommand } from "@/components/settings-dialog"
import { DialogServerV2 } from "@/components/settings-v2/dialog-server-v2" import { DialogServerV2 } from "@/components/settings-v2/dialog-server-v2"
import { type LocalProject } from "@/context/layout" import { type LocalProject } from "@/context/layout"
@@ -22,7 +22,7 @@ export function createHomeProjectsController(home: HomeController) {
const language = useLanguage() const language = useLanguage()
const notification = useNotification() const notification = useNotification()
const openSettings = useSettingsCommand() const openSettings = useSettingsCommand()
const serverManagement = useServerManagementController({ navigateOnAdd: false }) const serverManagement = useServerActionsController()
const [_state, setState, _, ready] = persisted( const [_state, setState, _, ready] = persisted(
Persist.global("home.servers", ["home.servers.v1"]), Persist.global("home.servers", ["home.servers.v1"]),
createStore({ collapsed: {} as Record<string, boolean> }), createStore({ collapsed: {} as Record<string, boolean> }),
@@ -56,12 +56,12 @@ export function createHomeProjectsController(home: HomeController) {
const key = ServerConnection.key(conn) const key = ServerConnection.key(conn)
setState("collapsed", key, !state().collapsed[key]) setState("collapsed", key, !state().collapsed[key])
}, },
canDefault: serverManagement.canDefault, canDefault: serverManagement.defaults.available,
defaultKey: serverManagement.defaultKey, defaultKey: serverManagement.defaults.key,
setDefault: (conn: ServerConnection.Any | undefined) => setDefault: (conn: ServerConnection.Any | undefined) =>
serverManagement.setDefault(conn ? ServerConnection.key(conn) : null), serverManagement.defaults.set(conn ? ServerConnection.key(conn) : null),
canRemove: (conn: ServerConnection.Any) => serverManagement.canRemove(ServerConnection.key(conn)), canRemove: (conn: ServerConnection.Any) => serverManagement.connection.canRemove(ServerConnection.key(conn)),
remove: (conn: ServerConnection.Any) => serverManagement.handleRemove(ServerConnection.key(conn)), remove: (conn: ServerConnection.Any) => serverManagement.connection.remove(ServerConnection.key(conn)),
edit: (conn: ServerConnection.Http) => dialog.show(() => <DialogServerV2 mode="edit" server={conn} />), edit: (conn: ServerConnection.Http) => dialog.show(() => <DialogServerV2 mode="edit" server={conn} />),
focus: home.selection.focusServer, focus: home.selection.focusServer,
}, },
+10 -12
View File
@@ -7,7 +7,7 @@ import { MenuV2 } from "@opencode-ai/ui/v2/menu-v2"
import { useMutation } from "@tanstack/solid-query" import { useMutation } from "@tanstack/solid-query"
import fuzzysort from "fuzzysort" import fuzzysort from "fuzzysort"
import { type Accessor, For, Show, createMemo } from "solid-js" import { type Accessor, For, Show, createMemo } from "solid-js"
import type { useServerManagementController } from "@/components/dialog-select-server" import type { ServerCollectionController } from "@/components/server/server-management-controller"
import { ServerHealthIndicator } from "@/components/server/server-row" import { ServerHealthIndicator } from "@/components/server/server-row"
import { useLanguage } from "@/context/language" import { useLanguage } from "@/context/language"
import { usePlatform } from "@/context/platform" import { usePlatform } from "@/context/platform"
@@ -17,8 +17,6 @@ import { DialogAddWslServer } from "./dialog-add-server"
import { useWslServers } from "./context" import { useWslServers } from "./context"
import { wslOpencodeAction, wslRuntimeRetryable } from "./settings-model" import { wslOpencodeAction, wslRuntimeRetryable } from "./settings-model"
type Controller = ReturnType<typeof useServerManagementController>
export function isWslServer(server: ServerConnection.Any) { export function isWslServer(server: ServerConnection.Any) {
return server.type === "sidecar" && server.variant === "wsl" return server.type === "sidecar" && server.variant === "wsl"
} }
@@ -28,7 +26,7 @@ export function AddServerMenu(props: { onAddServer: () => void }) {
const dialog = useDialog() const dialog = useDialog()
const language = useLanguage() const language = useLanguage()
const openAddWsl = () => { const openAddWsl = () => {
dialog.push(() => <DialogAddWslServer />) void dialog.push(() => <DialogAddWslServer />)
} }
return ( return (
<Show <Show
@@ -67,7 +65,7 @@ export function useFilteredWslServers(filter: Accessor<string>) {
} }
export function WslServerSettings(props: { export function WslServerSettings(props: {
controller: Controller domain: Pick<ServerCollectionController, "collection" | "defaults" | "connection">
servers: ReturnType<typeof useFilteredWslServers> servers: ReturnType<typeof useFilteredWslServers>
}) { }) {
const platform = usePlatform() const platform = usePlatform()
@@ -86,7 +84,7 @@ export function WslServerSettings(props: {
})) }))
const remove = (key: ServerConnection.Key) => { const remove = (key: ServerConnection.Key) => {
request.mutate(() => props.controller.handleRemove(key)) request.mutate(() => props.domain.connection.remove(key))
} }
return ( return (
@@ -100,7 +98,7 @@ export function WslServerSettings(props: {
return ( return (
<div class="settings-v2-servers-row"> <div class="settings-v2-servers-row">
<div class="settings-v2-servers-lead"> <div class="settings-v2-servers-lead">
<ServerHealthIndicator health={props.controller.status()[key]} /> <ServerHealthIndicator health={props.domain.collection.health()[key]} />
<div class="settings-v2-servers-copy"> <div class="settings-v2-servers-copy">
<span class="flex min-w-0 items-center gap-1"> <span class="flex min-w-0 items-center gap-1">
<span class="settings-v2-servers-name">{item.config.distro}</span> <span class="settings-v2-servers-name">{item.config.distro}</span>
@@ -114,7 +112,7 @@ export function WslServerSettings(props: {
</div> </div>
</div> </div>
<div class="settings-v2-servers-actions"> <div class="settings-v2-servers-actions">
<Show when={props.controller.canDefault() && props.controller.defaultKey() === key}> <Show when={props.domain.defaults.available() && props.domain.defaults.key() === key}>
<Tag>{language.t("dialog.server.status.default")}</Tag> <Tag>{language.t("dialog.server.status.default")}</Tag>
</Show> </Show>
<Show when={opencodeAction()}> <Show when={opencodeAction()}>
@@ -145,13 +143,13 @@ export function WslServerSettings(props: {
{language.t("wsl.server.retryStart")} {language.t("wsl.server.retryStart")}
</MenuV2.Item> </MenuV2.Item>
</Show> </Show>
<Show when={props.controller.canDefault() && props.controller.defaultKey() !== key}> <Show when={props.domain.defaults.available() && props.domain.defaults.key() !== key}>
<MenuV2.Item onSelect={() => props.controller.setDefault(key)}> <MenuV2.Item onSelect={() => props.domain.defaults.set(key)}>
{language.t("dialog.server.menu.default")} {language.t("dialog.server.menu.default")}
</MenuV2.Item> </MenuV2.Item>
</Show> </Show>
<Show when={props.controller.canDefault() && props.controller.defaultKey() === key}> <Show when={props.domain.defaults.available() && props.domain.defaults.key() === key}>
<MenuV2.Item onSelect={() => props.controller.setDefault(null)}> <MenuV2.Item onSelect={() => props.domain.defaults.set(null)}>
{language.t("dialog.server.menu.defaultRemove")} {language.t("dialog.server.menu.defaultRemove")}
</MenuV2.Item> </MenuV2.Item>
</Show> </Show>
+27 -73
View File
@@ -263,52 +263,38 @@ export type Endpoint5_23Input = { readonly sessionID: Session.ID }
export type Endpoint5_23Output = ReadonlyArray<SessionPending.Info> export type Endpoint5_23Output = ReadonlyArray<SessionPending.Info>
export type SessionPendingListOperation<E = never> = (input: Endpoint5_23Input) => Effect.Effect<Endpoint5_23Output, E> export type SessionPendingListOperation<E = never> = (input: Endpoint5_23Input) => Effect.Effect<Endpoint5_23Output, E>
export type Endpoint5_24Input = { readonly sessionID: Session.ID; readonly inputID: SessionMessage.ID } export type Endpoint5_24Input = { readonly sessionID: Session.ID }
export type Endpoint5_24Output = void export type Endpoint5_24Output = ReadonlyArray<InstructionEntry.Info>
export type SessionPendingCancelOperation<E = never> = ( export type SessionInstructionsEntryListOperation<E = never> = (
input: Endpoint5_24Input, input: Endpoint5_24Input,
) => Effect.Effect<Endpoint5_24Output, E> ) => Effect.Effect<Endpoint5_24Output, E>
export type Endpoint5_25Input = { readonly sessionID: Session.ID; readonly inputID: SessionMessage.ID } export type Endpoint5_25Input = {
export type Endpoint5_25Output = void
export type SessionPendingSteerOperation<E = never> = (input: Endpoint5_25Input) => Effect.Effect<Endpoint5_25Output, E>
export type Endpoint5_26Input = { readonly sessionID: Session.ID; readonly inputID: SessionMessage.ID }
export type Endpoint5_26Output = void
export type SessionPendingQueueOperation<E = never> = (input: Endpoint5_26Input) => Effect.Effect<Endpoint5_26Output, E>
export type Endpoint5_27Input = { readonly sessionID: Session.ID }
export type Endpoint5_27Output = ReadonlyArray<InstructionEntry.Info>
export type SessionInstructionsEntryListOperation<E = never> = (
input: Endpoint5_27Input,
) => Effect.Effect<Endpoint5_27Output, E>
export type Endpoint5_28Input = {
readonly sessionID: Session.ID readonly sessionID: Session.ID
readonly key: InstructionEntry.Key readonly key: InstructionEntry.Key
readonly value: Schema.Json readonly value: Schema.Json
} }
export type Endpoint5_28Output = void export type Endpoint5_25Output = void
export type SessionInstructionsEntryPutOperation<E = never> = ( export type SessionInstructionsEntryPutOperation<E = never> = (
input: Endpoint5_28Input, input: Endpoint5_25Input,
) => Effect.Effect<Endpoint5_28Output, E> ) => Effect.Effect<Endpoint5_25Output, E>
export type Endpoint5_29Input = { readonly sessionID: Session.ID; readonly key: InstructionEntry.Key } export type Endpoint5_26Input = { readonly sessionID: Session.ID; readonly key: InstructionEntry.Key }
export type Endpoint5_29Output = void export type Endpoint5_26Output = void
export type SessionInstructionsEntryRemoveOperation<E = never> = ( export type SessionInstructionsEntryRemoveOperation<E = never> = (
input: Endpoint5_29Input, input: Endpoint5_26Input,
) => Effect.Effect<Endpoint5_29Output, E> ) => Effect.Effect<Endpoint5_26Output, E>
export type Endpoint5_30Input = { readonly sessionID: Session.ID; readonly prompt: string } export type Endpoint5_27Input = { readonly sessionID: Session.ID; readonly prompt: string }
export type Endpoint5_30Output = { readonly text: string } export type Endpoint5_27Output = { readonly text: string }
export type SessionGenerateOperation<E = never> = (input: Endpoint5_30Input) => Effect.Effect<Endpoint5_30Output, E> export type SessionGenerateOperation<E = never> = (input: Endpoint5_27Input) => Effect.Effect<Endpoint5_27Output, E>
export type Endpoint5_31Input = { export type Endpoint5_28Input = {
readonly sessionID: Session.ID readonly sessionID: Session.ID
readonly after?: Event.Seq | undefined readonly after?: Event.Seq | undefined
readonly follow?: boolean | undefined readonly follow?: boolean | undefined
} }
export type Endpoint5_31Output = export type Endpoint5_28Output =
| ( | (
| { | {
readonly id: Event.ID readonly id: Event.ID
@@ -418,33 +404,6 @@ export type Endpoint5_31Output =
readonly input: SessionPending.Message readonly input: SessionPending.Message
} }
} }
| {
readonly id: Event.ID
readonly created: DateTime.Utc
readonly metadata?: { readonly [x: string]: unknown } | undefined
readonly type: "session.input.cancelled"
readonly durable: { readonly aggregateID: string; readonly seq: Event.Seq; readonly version: Event.Version }
readonly location?: Location.Ref | undefined
readonly data: { readonly sessionID: Session.ID; readonly inputID: SessionMessage.ID }
}
| {
readonly id: Event.ID
readonly created: DateTime.Utc
readonly metadata?: { readonly [x: string]: unknown } | undefined
readonly type: "session.input.steered"
readonly durable: { readonly aggregateID: string; readonly seq: Event.Seq; readonly version: Event.Version }
readonly location?: Location.Ref | undefined
readonly data: { readonly sessionID: Session.ID; readonly inputID: SessionMessage.ID }
}
| {
readonly id: Event.ID
readonly created: DateTime.Utc
readonly metadata?: { readonly [x: string]: unknown } | undefined
readonly type: "session.input.queued"
readonly durable: { readonly aggregateID: string; readonly seq: Event.Seq; readonly version: Event.Version }
readonly location?: Location.Ref | undefined
readonly data: { readonly sessionID: Session.ID; readonly inputID: SessionMessage.ID }
}
| { | {
readonly id: Event.ID readonly id: Event.ID
readonly created: DateTime.Utc readonly created: DateTime.Utc
@@ -903,19 +862,19 @@ export type Endpoint5_31Output =
} }
) )
| EventLog.Synced | EventLog.Synced
export type SessionLogOperation<E = never> = (input: Endpoint5_31Input) => Stream.Stream<Endpoint5_31Output, E> export type SessionLogOperation<E = never> = (input: Endpoint5_28Input) => Stream.Stream<Endpoint5_28Output, E>
export type Endpoint5_32Input = { readonly sessionID: Session.ID } export type Endpoint5_29Input = { readonly sessionID: Session.ID }
export type Endpoint5_32Output = void export type Endpoint5_29Output = void
export type SessionInterruptOperation<E = never> = (input: Endpoint5_32Input) => Effect.Effect<Endpoint5_32Output, E> export type SessionInterruptOperation<E = never> = (input: Endpoint5_29Input) => Effect.Effect<Endpoint5_29Output, E>
export type Endpoint5_33Input = { readonly sessionID: Session.ID } export type Endpoint5_30Input = { readonly sessionID: Session.ID }
export type Endpoint5_33Output = void export type Endpoint5_30Output = void
export type SessionBackgroundOperation<E = never> = (input: Endpoint5_33Input) => Effect.Effect<Endpoint5_33Output, E> export type SessionBackgroundOperation<E = never> = (input: Endpoint5_30Input) => Effect.Effect<Endpoint5_30Output, E>
export type Endpoint5_34Input = { readonly sessionID: Session.ID; readonly messageID: SessionMessage.ID } export type Endpoint5_31Input = { readonly sessionID: Session.ID; readonly messageID: SessionMessage.ID }
export type Endpoint5_34Output = SessionMessage.Info export type Endpoint5_31Output = SessionMessage.Info
export type SessionMessageOperation<E = never> = (input: Endpoint5_34Input) => Effect.Effect<Endpoint5_34Output, E> export type SessionMessageOperation<E = never> = (input: Endpoint5_31Input) => Effect.Effect<Endpoint5_31Output, E>
export interface SessionApi<E = never> { export interface SessionApi<E = never> {
readonly list: SessionListOperation<E> readonly list: SessionListOperation<E>
@@ -943,12 +902,7 @@ export interface SessionApi<E = never> {
readonly commit: SessionRevertCommitOperation<E> readonly commit: SessionRevertCommitOperation<E>
} }
readonly context: SessionContextOperation<E> readonly context: SessionContextOperation<E>
readonly pending: { readonly pending: { readonly list: SessionPendingListOperation<E> }
readonly list: SessionPendingListOperation<E>
readonly cancel: SessionPendingCancelOperation<E>
readonly steer: SessionPendingSteerOperation<E>
readonly queue: SessionPendingQueueOperation<E>
}
readonly instructions: { readonly instructions: {
readonly entry: { readonly entry: {
readonly list: SessionInstructionsEntryListOperation<E> readonly list: SessionInstructionsEntryListOperation<E>
+21 -48
View File
@@ -80,12 +80,6 @@ import type {
Endpoint5_30Output, Endpoint5_30Output,
Endpoint5_31Input, Endpoint5_31Input,
Endpoint5_31Output, Endpoint5_31Output,
Endpoint5_32Input,
Endpoint5_32Output,
Endpoint5_33Input,
Endpoint5_33Output,
Endpoint5_34Input,
Endpoint5_34Output,
Endpoint6_0Input, Endpoint6_0Input,
Endpoint6_0Output, Endpoint6_0Output,
Endpoint7_0Input, Endpoint7_0Input,
@@ -529,58 +523,37 @@ const Endpoint5_23 = (raw: RawClient["server.session"]) => (input: Endpoint5_23I
const Endpoint5_24 = (raw: RawClient["server.session"]) => (input: Endpoint5_24Input) => const Endpoint5_24 = (raw: RawClient["server.session"]) => (input: Endpoint5_24Input) =>
preserveEffect<Endpoint5_24Output>()( preserveEffect<Endpoint5_24Output>()(
raw["session.pending.cancel"]({ params: { sessionID: input["sessionID"], inputID: input["inputID"] } }).pipe(
Effect.mapError(mapClientError),
),
)
const Endpoint5_25 = (raw: RawClient["server.session"]) => (input: Endpoint5_25Input) =>
preserveEffect<Endpoint5_25Output>()(
raw["session.pending.steer"]({ params: { sessionID: input["sessionID"], inputID: input["inputID"] } }).pipe(
Effect.mapError(mapClientError),
),
)
const Endpoint5_26 = (raw: RawClient["server.session"]) => (input: Endpoint5_26Input) =>
preserveEffect<Endpoint5_26Output>()(
raw["session.pending.queue"]({ params: { sessionID: input["sessionID"], inputID: input["inputID"] } }).pipe(
Effect.mapError(mapClientError),
),
)
const Endpoint5_27 = (raw: RawClient["server.session"]) => (input: Endpoint5_27Input) =>
preserveEffect<Endpoint5_27Output>()(
raw["session.instructions.entry.list"]({ params: { sessionID: input["sessionID"] } }).pipe( raw["session.instructions.entry.list"]({ params: { sessionID: input["sessionID"] } }).pipe(
Effect.mapError(mapClientError), Effect.mapError(mapClientError),
Effect.map((value) => value.data), Effect.map((value) => value.data),
), ),
) )
const Endpoint5_28 = (raw: RawClient["server.session"]) => (input: Endpoint5_28Input) => const Endpoint5_25 = (raw: RawClient["server.session"]) => (input: Endpoint5_25Input) =>
preserveEffect<Endpoint5_28Output>()( preserveEffect<Endpoint5_25Output>()(
raw["session.instructions.entry.put"]({ raw["session.instructions.entry.put"]({
params: { sessionID: input["sessionID"], key: input["key"] }, params: { sessionID: input["sessionID"], key: input["key"] },
payload: { value: input["value"] }, payload: { value: input["value"] },
}).pipe(Effect.mapError(mapClientError)), }).pipe(Effect.mapError(mapClientError)),
) )
const Endpoint5_29 = (raw: RawClient["server.session"]) => (input: Endpoint5_29Input) => const Endpoint5_26 = (raw: RawClient["server.session"]) => (input: Endpoint5_26Input) =>
preserveEffect<Endpoint5_29Output>()( preserveEffect<Endpoint5_26Output>()(
raw["session.instructions.entry.remove"]({ params: { sessionID: input["sessionID"], key: input["key"] } }).pipe( raw["session.instructions.entry.remove"]({ params: { sessionID: input["sessionID"], key: input["key"] } }).pipe(
Effect.mapError(mapClientError), Effect.mapError(mapClientError),
), ),
) )
const Endpoint5_30 = (raw: RawClient["server.session"]) => (input: Endpoint5_30Input) => const Endpoint5_27 = (raw: RawClient["server.session"]) => (input: Endpoint5_27Input) =>
preserveEffect<Endpoint5_30Output>()( preserveEffect<Endpoint5_27Output>()(
raw["session.generate"]({ params: { sessionID: input["sessionID"] }, payload: { prompt: input["prompt"] } }).pipe( raw["session.generate"]({ params: { sessionID: input["sessionID"] }, payload: { prompt: input["prompt"] } }).pipe(
Effect.mapError(mapClientError), Effect.mapError(mapClientError),
Effect.map((value) => value.data), Effect.map((value) => value.data),
), ),
) )
const Endpoint5_31 = (raw: RawClient["server.session"]) => (input: Endpoint5_31Input) => const Endpoint5_28 = (raw: RawClient["server.session"]) => (input: Endpoint5_28Input) =>
preserveStream<Endpoint5_31Output>()( preserveStream<Endpoint5_28Output>()(
Stream.unwrap( Stream.unwrap(
raw["session.log"]({ raw["session.log"]({
params: { sessionID: input["sessionID"] }, params: { sessionID: input["sessionID"] },
@@ -592,18 +565,18 @@ const Endpoint5_31 = (raw: RawClient["server.session"]) => (input: Endpoint5_31I
), ),
) )
const Endpoint5_32 = (raw: RawClient["server.session"]) => (input: Endpoint5_32Input) => const Endpoint5_29 = (raw: RawClient["server.session"]) => (input: Endpoint5_29Input) =>
preserveEffect<Endpoint5_32Output>()( preserveEffect<Endpoint5_29Output>()(
raw["session.interrupt"]({ params: { sessionID: input["sessionID"] } }).pipe(Effect.mapError(mapClientError)), raw["session.interrupt"]({ params: { sessionID: input["sessionID"] } }).pipe(Effect.mapError(mapClientError)),
) )
const Endpoint5_33 = (raw: RawClient["server.session"]) => (input: Endpoint5_33Input) => const Endpoint5_30 = (raw: RawClient["server.session"]) => (input: Endpoint5_30Input) =>
preserveEffect<Endpoint5_33Output>()( preserveEffect<Endpoint5_30Output>()(
raw["session.background"]({ params: { sessionID: input["sessionID"] } }).pipe(Effect.mapError(mapClientError)), raw["session.background"]({ params: { sessionID: input["sessionID"] } }).pipe(Effect.mapError(mapClientError)),
) )
const Endpoint5_34 = (raw: RawClient["server.session"]) => (input: Endpoint5_34Input) => const Endpoint5_31 = (raw: RawClient["server.session"]) => (input: Endpoint5_31Input) =>
preserveEffect<Endpoint5_34Output>()( preserveEffect<Endpoint5_31Output>()(
raw["session.message"]({ params: { sessionID: input["sessionID"], messageID: input["messageID"] } }).pipe( raw["session.message"]({ params: { sessionID: input["sessionID"], messageID: input["messageID"] } }).pipe(
Effect.mapError(mapClientError), Effect.mapError(mapClientError),
Effect.map((value) => value.data), Effect.map((value) => value.data),
@@ -632,13 +605,13 @@ const adaptGroup5 = (raw: RawClient["server.session"]) => ({
wait: Endpoint5_18(raw), wait: Endpoint5_18(raw),
revert: { stage: Endpoint5_19(raw), clear: Endpoint5_20(raw), commit: Endpoint5_21(raw) }, revert: { stage: Endpoint5_19(raw), clear: Endpoint5_20(raw), commit: Endpoint5_21(raw) },
context: Endpoint5_22(raw), context: Endpoint5_22(raw),
pending: { list: Endpoint5_23(raw), cancel: Endpoint5_24(raw), steer: Endpoint5_25(raw), queue: Endpoint5_26(raw) }, pending: { list: Endpoint5_23(raw) },
instructions: { entry: { list: Endpoint5_27(raw), put: Endpoint5_28(raw), remove: Endpoint5_29(raw) } }, instructions: { entry: { list: Endpoint5_24(raw), put: Endpoint5_25(raw), remove: Endpoint5_26(raw) } },
generate: Endpoint5_30(raw), generate: Endpoint5_27(raw),
log: Endpoint5_31(raw), log: Endpoint5_28(raw),
interrupt: Endpoint5_32(raw), interrupt: Endpoint5_29(raw),
background: Endpoint5_33(raw), background: Endpoint5_30(raw),
message: Endpoint5_34(raw), message: Endpoint5_31(raw),
}) })
const Endpoint6_0 = (raw: RawClient["server.message"]) => (input: Endpoint6_0Input) => const Endpoint6_0 = (raw: RawClient["server.message"]) => (input: Endpoint6_0Input) =>
@@ -58,12 +58,6 @@ import type {
SessionContextOutput, SessionContextOutput,
SessionPendingListInput, SessionPendingListInput,
SessionPendingListOutput, SessionPendingListOutput,
SessionPendingCancelInput,
SessionPendingCancelOutput,
SessionPendingSteerInput,
SessionPendingSteerOutput,
SessionPendingQueueInput,
SessionPendingQueueOutput,
SessionInstructionsEntryListInput, SessionInstructionsEntryListInput,
SessionInstructionsEntryListOutput, SessionInstructionsEntryListOutput,
SessionInstructionsEntryPutInput, SessionInstructionsEntryPutInput,
@@ -772,39 +766,6 @@ export function make(options: ClientOptions) {
}, },
requestOptions, requestOptions,
).then((value) => value.data), ).then((value) => value.data),
cancel: (input: SessionPendingCancelInput, requestOptions?: RequestOptions) =>
request<SessionPendingCancelOutput>(
{
method: "DELETE",
path: `/api/session/${encodeURIComponent(input.sessionID)}/pending/${encodeURIComponent(input.inputID)}`,
successStatus: 204,
declaredStatuses: [409, 404, 401, 400],
empty: true,
},
requestOptions,
),
steer: (input: SessionPendingSteerInput, requestOptions?: RequestOptions) =>
request<SessionPendingSteerOutput>(
{
method: "POST",
path: `/api/session/${encodeURIComponent(input.sessionID)}/pending/${encodeURIComponent(input.inputID)}/steer`,
successStatus: 204,
declaredStatuses: [409, 404, 401, 400],
empty: true,
},
requestOptions,
),
queue: (input: SessionPendingQueueInput, requestOptions?: RequestOptions) =>
request<SessionPendingQueueOutput>(
{
method: "POST",
path: `/api/session/${encodeURIComponent(input.sessionID)}/pending/${encodeURIComponent(input.inputID)}/queue`,
successStatus: 204,
declaredStatuses: [409, 404, 401, 400],
empty: true,
},
requestOptions,
),
}, },
instructions: { instructions: {
entry: { entry: {
@@ -502,36 +502,6 @@ export type SessionInputPromoted = {
data: { sessionID: string; inputID: string } data: { sessionID: string; inputID: string }
} }
export type SessionInputCancelled = {
id: string
created: number
metadata?: { [x: string]: any }
type: "session.input.cancelled"
durable: { aggregateID: string; seq: number; version: 1 }
location?: LocationRef
data: { sessionID: string; inputID: string }
}
export type SessionInputSteered = {
id: string
created: number
metadata?: { [x: string]: any }
type: "session.input.steered"
durable: { aggregateID: string; seq: number; version: 1 }
location?: LocationRef
data: { sessionID: string; inputID: string }
}
export type SessionInputQueued = {
id: string
created: number
metadata?: { [x: string]: any }
type: "session.input.queued"
durable: { aggregateID: string; seq: number; version: 1 }
location?: LocationRef
data: { sessionID: string; inputID: string }
}
export type SessionExecutionStarted = { export type SessionExecutionStarted = {
id: string id: string
created: number created: number
@@ -2000,9 +1970,6 @@ export type SessionEventDurable =
| SessionForked | SessionForked
| SessionInputPromoted | SessionInputPromoted
| SessionInputAdmitted | SessionInputAdmitted
| SessionInputCancelled
| SessionInputSteered
| SessionInputQueued
| SessionExecutionStarted | SessionExecutionStarted
| SessionExecutionSucceeded | SessionExecutionSucceeded
| SessionExecutionFailed | SessionExecutionFailed
@@ -2057,9 +2024,6 @@ export type V2Event =
| SessionForked | SessionForked
| SessionInputPromoted | SessionInputPromoted
| SessionInputAdmitted | SessionInputAdmitted
| SessionInputCancelled
| SessionInputSteered
| SessionInputQueued
| SessionExecutionStarted | SessionExecutionStarted
| SessionExecutionSucceeded | SessionExecutionSucceeded
| SessionExecutionFailed | SessionExecutionFailed
@@ -3725,27 +3689,6 @@ export type SessionPendingListInput = { readonly sessionID: { readonly sessionID
export type SessionPendingListOutput = { data: Array<SessionPendingInfo> }["data"] export type SessionPendingListOutput = { data: Array<SessionPendingInfo> }["data"]
export type SessionPendingCancelInput = {
readonly sessionID: { readonly sessionID: string; readonly inputID: string }["sessionID"]
readonly inputID: { readonly sessionID: string; readonly inputID: string }["inputID"]
}
export type SessionPendingCancelOutput = void
export type SessionPendingSteerInput = {
readonly sessionID: { readonly sessionID: string; readonly inputID: string }["sessionID"]
readonly inputID: { readonly sessionID: string; readonly inputID: string }["inputID"]
}
export type SessionPendingSteerOutput = void
export type SessionPendingQueueInput = {
readonly sessionID: { readonly sessionID: string; readonly inputID: string }["sessionID"]
readonly inputID: { readonly sessionID: string; readonly inputID: string }["inputID"]
}
export type SessionPendingQueueOutput = void
export type SessionInstructionsEntryListInput = { readonly sessionID: { readonly sessionID: string }["sessionID"] } export type SessionInstructionsEntryListInput = { readonly sessionID: { readonly sessionID: string }["sessionID"] }
export type SessionInstructionsEntryListOutput = { data: Array<InstructionEntryInfo> }["data"] export type SessionInstructionsEntryListOutput = { data: Array<InstructionEntryInfo> }["data"]
@@ -19,7 +19,7 @@ test("effect entrypoint exposes canonical Schema contracts", () => {
test("generated Effect API names canonical and composed outputs", async () => { test("generated Effect API names canonical and composed outputs", async () => {
const source = await Bun.file(new URL("../src/effect/api/api.ts", import.meta.url)).text() const source = await Bun.file(new URL("../src/effect/api/api.ts", import.meta.url)).text()
expect(source).toContain("export type Endpoint5_5Output = Session.Info") expect(source).toContain("export type Endpoint5_3Output = Session.Info")
expect(source).toContain("export type Endpoint19_0Output = OpenCodeEvent") expect(source).toContain("export type Endpoint19_0Output = OpenCodeEvent")
expect(source).not.toContain("HttpApiClient.ForApi") expect(source).not.toContain("HttpApiClient.ForApi")
}) })
-23
View File
@@ -32,7 +32,6 @@ test("exposes every standard HTTP API group", () => {
"projectCopy", "projectCopy",
"vcs", "vcs",
"debug", "debug",
"migration",
"websearch", "websearch",
"config", "config",
]) ])
@@ -357,28 +356,6 @@ test("session.pending.list uses the public HTTP contract", async () => {
expect(requests).toEqual([{ method: "GET", url: "http://localhost:3000/api/session/ses_test/pending" }]) expect(requests).toEqual([{ method: "GET", url: "http://localhost:3000/api/session/ses_test/pending" }])
}) })
test("session.pending mutations use the public HTTP contract", async () => {
const requests: Array<{ method: string; url: string }> = []
const client = OpenCode.make({
baseUrl: "http://localhost:3000",
fetch: async (input, init) => {
const request = input instanceof Request ? input : new Request(input, init)
requests.push({ method: request.method, url: request.url })
return new Response(null, { status: 204 })
},
})
await client.session.pending.cancel({ sessionID: "ses_test", inputID: "msg_cancel" })
await client.session.pending.steer({ sessionID: "ses_test", inputID: "msg_steer" })
await client.session.pending.queue({ sessionID: "ses_test", inputID: "msg_queue" })
expect(requests).toEqual([
{ method: "DELETE", url: "http://localhost:3000/api/session/ses_test/pending/msg_cancel" },
{ method: "POST", url: "http://localhost:3000/api/session/ses_test/pending/msg_steer/steer" },
{ method: "POST", url: "http://localhost:3000/api/session/ses_test/pending/msg_queue/queue" },
])
})
test("event.subscribe exposes the Promise event stream wire projection", async () => { test("event.subscribe exposes the Promise event stream wire projection", async () => {
const client = OpenCode.make({ const client = OpenCode.make({
baseUrl: "http://localhost:3000", baseUrl: "http://localhost:3000",
-1
View File
@@ -17,7 +17,6 @@
"opencode": "./bin/opencode" "opencode": "./bin/opencode"
}, },
"exports": { "exports": {
"./environment": "./src/environment/index.ts",
"./session/runner": "./src/session/runner/index.ts", "./session/runner": "./src/session/runner/index.ts",
"./instructions": "./src/instructions/index.ts", "./instructions": "./src/instructions/index.ts",
"./*": "./src/*.ts" "./*": "./src/*.ts"
-9
View File
@@ -1,9 +0,0 @@
import type { ChildProcessSpawner } from "effect/unstable/process/ChildProcessSpawner"
import type { FilesImpl } from "./files"
export interface Driver {
readonly spawner: ChildProcessSpawner["Service"]
readonly overrides?: Partial<FilesImpl>
}
export * as EnvironmentDriver from "./driver"
@@ -1,192 +0,0 @@
import { Effect, Stream } from "effect"
import { ChildProcess } from "effect/unstable/process"
import type { ChildProcessSpawner } from "effect/unstable/process/ChildProcessSpawner"
import { collectStream } from "@opencode-ai/util/process"
import { Failed, NotFound, WrongKind, type FileInfo, type FileType, type FilesImpl } from "./files"
/**
* Files derived from spawning processes: one process per intent, "$1" is
* always the target path. Scripts report classification through an exit-code
* protocol (44/45/46) so failures never require parsing localized error text;
* LC_ALL=C pins the one stderr match that remains. Requires GNU coreutils and
* findutils in the target image — BSD and busybox userlands will not work.
* Malformed output from these scripts is our own bug and dies as a defect.
*/
const MAX_DATA_BYTES = 64 * 1024 * 1024
const MAX_ERROR_BYTES = 64 * 1024
const NOT_FOUND = 44
const WRONG_KIND = 45
const FAILED = 46
const TAB = "\t"
const loadMetadata = (flags = "") => `
metadata=$(stat ${flags} -c '%F${TAB}%s${TAB}%Y' -- "$1" 2>&1) || {
case "$metadata" in
*'No such file or directory'*|*'Not a directory'*) exit ${NOT_FOUND} ;;
*) printf '%s' "$metadata" >&2; exit ${FAILED} ;;
esac
}
`
const statScript = `
${loadMetadata()}
printf '%s\n' "$metadata"
`
const readScript = `
${loadMetadata("-L")}
kind=\${metadata%%${TAB}*}
if [ "$kind" != 'regular file' ] && [ "$kind" != 'regular empty file' ]; then
printf '%s' "$kind" >&2
exit ${WRONG_KIND}
fi
printf '%s\n' "$metadata"
if [ "$2" = range ]; then
dd if="$1" iflag=skip_bytes,count_bytes skip="$3" count="$4" status=none
else
cat -- "$1"
fi
`
const listScript = `
${loadMetadata()}
kind=\${metadata%%${TAB}*}
if [ "$kind" != directory ]; then
printf '%s' "$kind" >&2
exit ${WRONG_KIND}
fi
find "$1" -mindepth 1 -maxdepth 1 -printf '%y\\0%f\\0'
`
const moveScript = `
${loadMetadata()}
mv -- "$1" "$2"
`
interface Result {
readonly exitCode: number
readonly stdout: Uint8Array
readonly stderr: Uint8Array
}
export const execDefaults = (spawner: ChildProcessSpawner["Service"]): FilesImpl => {
const run = (
path: string,
script: string,
args: ReadonlyArray<string> = [],
stdin?: Uint8Array,
): Effect.Effect<Result, Failed> =>
Effect.scoped(
Effect.gen(function* () {
const command = ChildProcess.make("sh", ["-c", script, "sh", path, ...args], {
env: { LC_ALL: "C" },
extendEnv: true,
stdin: stdin === undefined ? undefined : Stream.make(stdin),
})
const handle = yield* spawner.spawn(command).pipe(Effect.mapError((cause) => new Failed({ path, cause })))
const [stdout, stderr, exitCode] = yield* Effect.all(
[
collectStream(handle.stdout, MAX_DATA_BYTES),
collectStream(handle.stderr, MAX_ERROR_BYTES),
handle.exitCode,
],
{ concurrency: "unbounded" },
).pipe(Effect.mapError((cause) => new Failed({ path, cause })))
if (stdout.truncated || stderr.truncated) {
return yield* new Failed({ path, cause: new Error("Process output exceeded its collection limit") })
}
return { exitCode, stdout: stdout.buffer, stderr: stderr.buffer }
}),
)
const classify = <A>(
path: string,
result: Result,
success: (stdout: Uint8Array) => A,
): Effect.Effect<A, NotFound | WrongKind | Failed> => {
if (result.exitCode === 0) return Effect.sync(() => success(result.stdout))
if (result.exitCode === NOT_FOUND) return Effect.fail(new NotFound({ path }))
if (result.exitCode === WRONG_KIND) {
return Effect.fail(new WrongKind({ path, actual: parseType(new TextDecoder().decode(result.stderr)) }))
}
return Effect.fail(processFailure(path, result))
}
const complete = (path: string, result: Result) =>
result.exitCode === 0 ? Effect.void : Effect.fail(processFailure(path, result))
return {
stat: (path) => run(path, statScript).pipe(Effect.flatMap((result) => classifyPlain(path, result, parseInfo))),
read: (path, range) =>
run(
path,
readScript,
range === undefined ? ["whole"] : ["range", String(range.offset), String(range.length)],
).pipe(
Effect.flatMap((result) =>
classify(path, result, (stdout) => {
const newline = stdout.indexOf(10)
if (newline < 0) throw new Error("Missing read metadata header")
return {
info: parseInfo(stdout.slice(0, newline)),
bytes: stdout.slice(newline + 1),
}
}),
),
),
write: (path, bytes) =>
run(path, `mkdir -p "$(dirname "$1")" && cat > "$1"`, [], bytes).pipe(
Effect.flatMap((result) => complete(path, result)),
),
list: (path) => run(path, listScript).pipe(Effect.flatMap((result) => classify(path, result, parseList))),
remove: (path) => run(path, `rm -rf -- "$1"`).pipe(Effect.flatMap((result) => complete(path, result))),
move: (from, to) =>
run(from, moveScript, [to]).pipe(Effect.flatMap((result) => classifyPlain(from, result, () => undefined))),
mkdir: (path) => run(path, `mkdir -p -- "$1"`).pipe(Effect.flatMap((result) => complete(path, result))),
}
}
/** `classify` for scripts whose protocol never reports WrongKind. */
const classifyPlain = <A>(
path: string,
result: Result,
success: (stdout: Uint8Array) => A,
): Effect.Effect<A, NotFound | Failed> => {
if (result.exitCode === 0) return Effect.sync(() => success(result.stdout))
if (result.exitCode === NOT_FOUND) return Effect.fail(new NotFound({ path }))
return Effect.fail(processFailure(path, result))
}
const processFailure = (path: string, result: Result) =>
new Failed({
path,
cause: new Error(new TextDecoder().decode(result.stderr).trim() || `Process exited with code ${result.exitCode}`),
})
const parseInfo = (bytes: Uint8Array): FileInfo => {
const [rawType, rawSize, rawMtime] = new TextDecoder().decode(bytes).trim().split(TAB)
const size = Number(rawSize)
const mtimeMs = Number(rawMtime) * 1_000
if (!rawType || !Number.isFinite(size) || !Number.isFinite(mtimeMs)) throw new Error("Invalid stat output")
return { type: parseType(rawType), size, mtimeMs }
}
const parseType = (value: string): FileType => {
if (value === "regular file" || value === "regular empty file" || value === "f") return "file"
if (value === "directory" || value === "d") return "directory"
if (value === "symbolic link" || value === "l") return "symlink"
return "other"
}
const parseList = (bytes: Uint8Array) => {
const fields = new TextDecoder().decode(bytes).split("\0")
fields.pop()
if (fields.length % 2 !== 0) throw new Error("Invalid find output")
return Array.from({ length: fields.length / 2 }, (_, index) => ({
name: fields[index * 2 + 1],
type: parseType(fields[index * 2]),
}))
}
export * as EnvironmentExecDefaults from "./exec-defaults"
-53
View File
@@ -1,53 +0,0 @@
import { Effect, Schema } from "effect"
export const FileType = Schema.Literals(["file", "directory", "symlink", "other"])
export type FileType = typeof FileType.Type
export interface FileInfo {
readonly type: FileType
readonly size: number
readonly mtimeMs: number
}
export interface DirEntry {
readonly name: string
readonly type: FileType
}
export class NotFound extends Schema.TaggedErrorClass<NotFound>()("Environment.NotFound", {
path: Schema.String,
}) {}
export class WrongKind extends Schema.TaggedErrorClass<WrongKind>()("Environment.WrongKind", {
path: Schema.String,
actual: FileType,
}) {}
export class Failed extends Schema.TaggedErrorClass<Failed>()("Environment.Failed", {
path: Schema.String,
cause: Schema.Defect(),
}) {}
export interface FilesImpl {
/**
* Reads a file, following a final symlink so `info` describes the target whose bytes are returned.
* The process-backed default caps collected output at 64 MiB; larger whole-file reads fail with
* `Failed`, so callers must use ranges for larger files.
*/
readonly read: (
path: string,
range?: { readonly offset: number; readonly length: number },
) => Effect.Effect<{ readonly info: FileInfo; readonly bytes: Uint8Array }, NotFound | WrongKind | Failed>
readonly write: (path: string, bytes: Uint8Array) => Effect.Effect<void, Failed>
/** Describes the path entry itself, so a final symlink is reported as `symlink` rather than followed. */
readonly stat: (path: string) => Effect.Effect<FileInfo, NotFound | Failed>
/** Lists a directory entry without following a final symlink; intermediate symlinks are traversed. */
readonly list: (path: string) => Effect.Effect<ReadonlyArray<DirEntry>, NotFound | WrongKind | Failed>
readonly remove: (path: string) => Effect.Effect<void, Failed>
readonly move: (from: string, to: string) => Effect.Effect<void, NotFound | Failed>
readonly mkdir: (path: string) => Effect.Effect<void, Failed>
}
export interface Files extends FilesImpl {}
export * as EnvironmentFiles from "./files"
-24
View File
@@ -1,24 +0,0 @@
export * as Environment from "./index"
export { type Driver } from "./driver"
export {
type DirEntry,
Failed,
type FileInfo,
type Files,
type FilesImpl,
type FileType,
NotFound,
WrongKind,
} from "./files"
export { execDefaults } from "./exec-defaults"
export { makeMemoryDriver, type MemoryDriver } from "./memory"
import type { Driver } from "./driver"
import { execDefaults } from "./exec-defaults"
import type { Files } from "./files"
export const makeFiles = (driver: Driver): Files => ({
...execDefaults(driver.spawner),
...driver.overrides,
})
-168
View File
@@ -1,168 +0,0 @@
import path from "node:path"
import { Effect, PlatformError } from "effect"
import { make } from "effect/unstable/process/ChildProcessSpawner"
import type { Driver } from "./driver"
import { Failed, NotFound, WrongKind, type FileInfo, type FilesImpl, type FileType } from "./files"
type Node =
| { readonly type: "file"; readonly bytes: Uint8Array; readonly mtimeMs: number }
| { readonly type: "directory"; readonly mtimeMs: number }
| { readonly type: "symlink"; readonly target: string; readonly mtimeMs: number }
export interface MemoryDriver extends Driver {
readonly symlink: (target: string, path: string) => Effect.Effect<void, Failed>
}
export const makeMemoryDriver = (): MemoryDriver => {
const nodes = new Map<string, Node>([["/", { type: "directory", mtimeMs: Date.now() }]])
const key = (value: string) => path.posix.resolve("/", value)
const info = (node: Node): FileInfo => ({
type: node.type,
size:
node.type === "file"
? node.bytes.length
: node.type === "symlink"
? new TextEncoder().encode(node.target).length
: 0,
mtimeMs: node.mtimeMs,
})
const resolveKey = (value: string, followFinal: boolean, seen = new Set<string>()): string | undefined => {
const normalized = key(value)
const parts = normalized.split("/").filter(Boolean)
const base = "/"
const walk = (current: string, index: number): string | undefined => {
if (index === parts.length) return current
const part = parts[index]
const candidate = path.posix.join(current, part)
const node = nodes.get(candidate)
if (node?.type !== "symlink" || (!followFinal && index === parts.length - 1)) return walk(candidate, index + 1)
if (seen.has(candidate)) return undefined
seen.add(candidate)
const target = path.posix.resolve(path.posix.dirname(candidate), node.target)
return resolveKey(path.posix.join(target, ...parts.slice(index + 1)), followFinal, seen)
}
return walk(base, 0)
}
const lookup = (value: string) => nodes.get(resolveKey(value, false) ?? key(value))
const requireParent = (value: string) => {
const parentPath = path.posix.dirname(key(value))
const parent = nodes.get(resolveKey(parentPath, true) ?? parentPath)
if (!parent) throw new Error(`Parent directory does not exist: ${path.posix.dirname(value)}`)
if (parent.type !== "directory") throw new Error(`Parent is not a directory: ${path.posix.dirname(value)}`)
}
const mkdirSync = (value: string) => {
const target = resolveKey(value, false) ?? key(value)
const existing = nodes.get(target)
if (existing?.type === "directory") return
if (existing) throw new Error(`Path is not a directory: ${value}`)
const parent = path.posix.dirname(target)
if (parent !== target) mkdirSync(parent)
nodes.set(target, { type: "directory", mtimeMs: Date.now() })
}
const failed = (value: string, cause: unknown) => new Failed({ path: value, cause })
const overrides: FilesImpl = {
stat: (value) => {
const node = lookup(value)
return node ? Effect.succeed(info(node)) : Effect.fail(new NotFound({ path: value }))
},
read: (value, range) => {
const original = lookup(value)
if (!original) return Effect.fail(new NotFound({ path: value }))
if (original.type === "directory") return Effect.fail(new WrongKind({ path: value, actual: "directory" }))
const resolved = resolveKey(value, true)
const node = resolved === undefined ? undefined : nodes.get(resolved)
if (!node) return Effect.fail(new NotFound({ path: value }))
if (node.type !== "file") return Effect.fail(new WrongKind({ path: value, actual: node.type }))
const bytes = range === undefined ? node.bytes : node.bytes.subarray(range.offset, range.offset + range.length)
return Effect.succeed({ info: info(node), bytes: bytes.slice() })
},
write: (value, bytes) =>
Effect.try({
try: () => {
mkdirSync(path.posix.dirname(key(value)))
const existing = lookup(value)
if (existing?.type === "directory") throw new Error(`Path is a directory: ${value}`)
const target = existing?.type === "symlink" ? resolveKey(value, true) : resolveKey(value, false)
if (!target) throw new Error(`Cannot resolve symlink: ${value}`)
requireParent(target)
nodes.set(target, { type: "file", bytes: bytes.slice(), mtimeMs: Date.now() })
},
catch: (cause) => failed(value, cause),
}),
list: (value) => {
const target = resolveKey(value, false) ?? key(value)
const node = nodes.get(target)
if (!node) return Effect.fail(new NotFound({ path: value }))
if (node.type !== "directory") return Effect.fail(new WrongKind({ path: value, actual: node.type }))
const entries = [...nodes.entries()]
.filter(([entry]) => entry !== target && path.posix.dirname(entry) === target)
.map(([entry, child]) => ({ name: path.posix.basename(entry), type: child.type satisfies FileType }))
.sort((a, b) => a.name.localeCompare(b.name))
return Effect.succeed(entries)
},
remove: (value) =>
Effect.sync(() => {
const target = resolveKey(value, false) ?? key(value)
for (const entry of nodes.keys()) {
if (entry === target || entry.startsWith(`${target}/`)) nodes.delete(entry)
}
}),
move: (from, to) => {
const source = resolveKey(from, false) ?? key(from)
const node = nodes.get(source)
if (!node) return Effect.fail(new NotFound({ path: from }))
return Effect.try({
try: () => {
const requested = resolveKey(to, false) ?? key(to)
const destination =
nodes.get(requested)?.type === "directory"
? path.posix.join(requested, path.posix.basename(source))
: requested
if (node.type === "directory" && destination.startsWith(`${source}/`)) {
throw new Error(`Cannot move a directory into itself: ${from}`)
}
const existing = nodes.get(destination)
if (node.type === "directory" && existing && existing.type !== "directory") {
throw new Error(`Cannot overwrite a non-directory with a directory: ${to}`)
}
requireParent(destination)
const moved = [...nodes.entries()].filter(([entry]) => entry === source || entry.startsWith(`${source}/`))
for (const [entry] of moved) nodes.delete(entry)
for (const [entry, child] of moved) nodes.set(`${destination}${entry.slice(source.length)}`, child)
},
catch: (cause) => failed(from, cause),
})
},
mkdir: (value) => Effect.try({ try: () => mkdirSync(value), catch: (cause) => failed(value, cause) }),
}
const spawner = make((command) =>
Effect.suspend(() => {
const description = command._tag === "StandardCommand" ? command.command : "pipeline"
return Effect.fail(
PlatformError.systemError({
_tag: "Unknown",
module: "EnvironmentMemory",
method: "spawn",
pathOrDescriptor: description,
cause: failed(description, new Error("The memory driver cannot spawn processes")),
}),
)
}),
)
return {
spawner,
overrides,
symlink: (target, value) =>
Effect.try({
try: () => {
requireParent(value)
nodes.set(resolveKey(value, false) ?? key(value), { type: "symlink", target, mtimeMs: Date.now() })
},
catch: (cause) => failed(value, cause),
}),
}
}
export * as EnvironmentMemory from "./memory"
@@ -1,84 +0,0 @@
export * as WebSearchFirecrawl from "./firecrawl"
import { define } from "@opencode-ai/plugin/effect/plugin"
import { Effect, Option, Schema, Scope } from "effect"
import { HttpClient } from "effect/unstable/http"
import { App } from "../../app"
import { WebSearchMcp } from "./mcp"
export const endpoint = "https://mcp.firecrawl.dev/v2/mcp"
const McpInput = Schema.Struct({
query: Schema.String,
limit: Schema.Number.pipe(Schema.optional),
})
const McpOutput = Schema.Struct({
content: Schema.Array(Schema.Struct({ type: Schema.Literal("text"), text: Schema.String })),
})
const SearchResponse = Schema.fromJsonString(
Schema.Struct({
success: Schema.Boolean,
data: Schema.Struct({
web: Schema.Array(
Schema.Struct({
url: Schema.String,
title: Schema.NullOr(Schema.String).pipe(Schema.optional),
description: Schema.NullOr(Schema.String).pipe(Schema.optional),
}),
),
}),
}),
)
const decodeSearchResponse = Schema.decodeUnknownOption(SearchResponse)
export const Plugin = define<HttpClient.HttpClient | Scope.Scope>({
id: "opencode.websearch.firecrawl",
effect: Effect.fn("WebSearchFirecrawl.Plugin")(function* (ctx) {
const http = yield* HttpClient.HttpClient
yield* ctx.integration.transform((draft) => {
draft.update("firecrawl", (integration) => (integration.name = "Firecrawl"))
draft.method.update({
integrationID: "firecrawl",
method: { type: "key", label: "API key (optional)" },
})
draft.method.update({
integrationID: "firecrawl",
method: { type: "env", names: ["FIRECRAWL_API_KEY"] },
})
})
yield* ctx.websearch.transform((draft) => {
draft.add({
id: "firecrawl",
name: "Firecrawl",
execute: (input) =>
Effect.gen(function* () {
const connection = yield* ctx.integration.connection.active("firecrawl")
const credential = connection ? yield* ctx.integration.connection.resolve(connection) : undefined
const result = yield* WebSearchMcp.call(
http,
endpoint,
"firecrawl_search",
{ input: McpInput, output: McpOutput },
{ query: input.query, limit: 8 },
{
"User-Agent": App.useragent(ctx.app),
...(credential?.type === "key" ? { Authorization: `Bearer ${credential.key}` } : {}),
},
)
const content = result?.content.find((item) => item.text)
const response = content ? Option.getOrUndefined(decodeSearchResponse(content.text)) : undefined
return (
response?.data.web.map((item) => ({
url: item.url,
...(item.title ? { title: item.title } : {}),
...(item.description ? { content: item.description } : {}),
time: {},
})) ?? []
)
}),
})
})
}),
})
+1 -2
View File
@@ -1,5 +1,4 @@
import { WebSearchExa } from "./exa" import { WebSearchExa } from "./exa"
import { WebSearchFirecrawl } from "./firecrawl"
import { WebSearchParallel } from "./parallel" import { WebSearchParallel } from "./parallel"
export const WebSearchPlugins = [WebSearchExa.Plugin, WebSearchFirecrawl.Plugin, WebSearchParallel.Plugin] as const export const WebSearchPlugins = [WebSearchExa.Plugin, WebSearchParallel.Plugin] as const
-39
View File
@@ -133,14 +133,6 @@ export class CompactionConflictError extends Schema.TaggedErrorClass<CompactionC
export class BusyError extends Schema.TaggedErrorClass<BusyError>()("Session.BusyError", { export class BusyError extends Schema.TaggedErrorClass<BusyError>()("Session.BusyError", {
sessionID: SessionSchema.ID, sessionID: SessionSchema.ID,
}) {} }) {}
export class PendingInputConflictError extends Schema.TaggedErrorClass<PendingInputConflictError>()(
"Session.PendingInputConflictError",
{
sessionID: SessionSchema.ID,
inputID: SessionMessage.ID,
},
) {}
type PendingInputRef = { readonly sessionID: SessionSchema.ID; readonly inputID: SessionMessage.ID }
export class SkillNotFoundError extends Schema.TaggedErrorClass<SkillNotFoundError>()("Session.SkillNotFoundError", { export class SkillNotFoundError extends Schema.TaggedErrorClass<SkillNotFoundError>()("Session.SkillNotFoundError", {
skill: Skill.ID, skill: Skill.ID,
}) {} }) {}
@@ -189,9 +181,6 @@ export interface Interface {
* unhandled compaction barriers. * unhandled compaction barriers.
*/ */
readonly pending: (sessionID: SessionSchema.ID) => Effect.Effect<SessionPending.Info[], NotFoundError> readonly pending: (sessionID: SessionSchema.ID) => Effect.Effect<SessionPending.Info[], NotFoundError>
readonly cancelPending: (input: PendingInputRef) => Effect.Effect<void, NotFoundError | PendingInputConflictError>
readonly steerPending: (input: PendingInputRef) => Effect.Effect<void, NotFoundError | PendingInputConflictError>
readonly queuePending: (input: PendingInputRef) => Effect.Effect<void, NotFoundError | PendingInputConflictError>
/** /**
* Durable, ordered session log read. Replays durable session bus after * Durable, ordered session log read. Replays durable session bus after
* the exclusive `after` cursor, emits a `Synced` marker at the captured * the exclusive `after` cursor, emits a `Synced` marker at the captured
@@ -329,31 +318,6 @@ const layer = Layer.effect(
), ),
) )
const pendingConflict = Effect.fn("Session.pendingConflict")(function* (input: PendingInputRef) {
yield* result.get(input.sessionID)
return yield* new PendingInputConflictError(input)
})
const mutatePending = (
input: PendingInputRef,
mutation: (
bus: Bus.Interface,
input: { readonly id: SessionMessage.ID; readonly sessionID: SessionSchema.ID },
) => Effect.Effect<unknown>,
wake = false,
) =>
Effect.uninterruptible(
Effect.gen(function* () {
yield* mutation(bus, { sessionID: input.sessionID, id: input.inputID }).pipe(
Effect.catchDefect((defect) =>
defect instanceof SessionPending.LifecycleConflict
? pendingConflict(input)
: Effect.die(defect),
),
)
if (wake) yield* execution.wake(input.sessionID)
}),
)
const result = Service.of({ const result = Service.of({
create: Effect.fn("Session.create")(function* (input) { create: Effect.fn("Session.create")(function* (input) {
const sessionID = input.id ?? SessionSchema.ID.create() const sessionID = input.id ?? SessionSchema.ID.create()
@@ -543,9 +507,6 @@ const layer = Layer.effect(
yield* result.get(sessionID) yield* result.get(sessionID)
return yield* SessionPending.list(db, sessionID) return yield* SessionPending.list(db, sessionID)
}), }),
cancelPending: Effect.fn("Session.cancelPending")((input) => mutatePending(input, SessionPending.cancel)),
steerPending: Effect.fn("Session.steerPending")((input) => mutatePending(input, SessionPending.steer, true)),
queuePending: Effect.fn("Session.queuePending")((input) => mutatePending(input, SessionPending.queue)),
log: (input) => log: (input) =>
Stream.unwrap( Stream.unwrap(
result result
@@ -90,9 +90,6 @@ export function update(adapter: Adapter, event: SessionEvent.DurableEvent) {
"session.forked": () => Effect.void, "session.forked": () => Effect.void,
"session.input.promoted": () => Effect.void, "session.input.promoted": () => Effect.void,
"session.input.admitted": () => Effect.void, "session.input.admitted": () => Effect.void,
"session.input.cancelled": () => Effect.void,
"session.input.steered": () => Effect.void,
"session.input.queued": () => Effect.void,
"session.execution.started": () => Effect.void, "session.execution.started": () => Effect.void,
"session.execution.succeeded": () => clearCurrentRetry, "session.execution.succeeded": () => clearCurrentRetry,
"session.execution.failed": () => clearCurrentRetry, "session.execution.failed": () => clearCurrentRetry,
+4 -84
View File
@@ -37,7 +37,6 @@ const decodeSynthetic = Schema.decodeUnknownSync(SyntheticData)
const encodeSynthetic = Schema.encodeSync(SyntheticData) const encodeSynthetic = Schema.encodeSync(SyntheticData)
const decodeMessage = Schema.decodeUnknownSync(SessionMessage.Info) const decodeMessage = Schema.decodeUnknownSync(SessionMessage.Info)
const inboxLocks = KeyedMutex.makeUnsafe<SessionSchema.ID>() const inboxLocks = KeyedMutex.makeUnsafe<SessionSchema.ID>()
type PendingRef = { readonly id: SessionMessage.ID; readonly sessionID: SessionSchema.ID }
export class LifecycleConflict extends Schema.TaggedErrorClass<LifecycleConflict>()( export class LifecycleConflict extends Schema.TaggedErrorClass<LifecycleConflict>()(
"SessionPending.LifecycleConflict", "SessionPending.LifecycleConflict",
@@ -295,7 +294,10 @@ export const projectCompactionAdmitted = Effect.fn("SessionPending.projectCompac
*/ */
export const projectPromoted = Effect.fn("SessionPending.projectPromoted")(function* ( export const projectPromoted = Effect.fn("SessionPending.projectPromoted")(function* (
db: DatabaseService, db: DatabaseService,
input: PendingRef, input: {
readonly id: SessionMessage.ID
readonly sessionID: SessionSchema.ID
},
) { ) {
if (yield* compaction(db, input.sessionID)) return yield* Effect.die(new LifecycleConflict({ id: input.id })) if (yield* compaction(db, input.sessionID)) return yield* Effect.die(new LifecycleConflict({ id: input.id }))
const deleted = yield* db const deleted = yield* db
@@ -310,55 +312,6 @@ export const projectPromoted = Effect.fn("SessionPending.projectPromoted")(funct
return stored return stored
}) })
export const projectCancelled = Effect.fn("SessionPending.projectCancelled")(function* (
db: DatabaseService,
input: PendingRef,
) {
const deleted = yield* db
.delete(SessionPendingTable)
.where(
and(
eq(SessionPendingTable.id, input.id),
eq(SessionPendingTable.session_id, input.sessionID),
or(eq(SessionPendingTable.delivery, "queue"), eq(SessionPendingTable.delivery, "steer")),
),
)
.returning({ id: SessionPendingTable.id })
.get()
.pipe(Effect.orDie)
if (!deleted) return yield* Effect.die(new LifecycleConflict({ id: input.id }))
})
const projectDelivery = Effect.fn("SessionPending.projectDelivery")(function* (
db: DatabaseService,
input: PendingRef & { readonly from: Delivery; readonly to: Delivery },
) {
const updated = yield* db
.update(SessionPendingTable)
.set({ delivery: input.to })
.where(
and(
eq(SessionPendingTable.id, input.id),
eq(SessionPendingTable.session_id, input.sessionID),
eq(SessionPendingTable.delivery, input.from),
),
)
.returning({ id: SessionPendingTable.id })
.get()
.pipe(Effect.orDie)
if (!updated) return yield* Effect.die(new LifecycleConflict({ id: input.id }))
})
export const projectSteered = Effect.fn("SessionPending.projectSteered")(
(db: DatabaseService, input: PendingRef) =>
projectDelivery(db, { ...input, from: "queue", to: "steer" }),
)
export const projectQueued = Effect.fn("SessionPending.projectQueued")(
(db: DatabaseService, input: PendingRef) =>
projectDelivery(db, { ...input, from: "steer", to: "queue" }),
)
export const settleCompaction = Effect.fn("SessionPending.settleCompaction")(function* ( export const settleCompaction = Effect.fn("SessionPending.settleCompaction")(function* (
db: DatabaseService, db: DatabaseService,
input: { readonly sessionID: SessionSchema.ID }, input: { readonly sessionID: SessionSchema.ID },
@@ -436,39 +389,6 @@ export const equivalent = (
return false return false
} }
const publishMutation = <A, E, R>(input: PendingRef, effect: Effect.Effect<A, E, R>) =>
inboxLocks.withLock(input.sessionID)(effect).pipe(Effect.asVoid)
export const cancel = Effect.fn("SessionPending.cancel")((bus: Bus.Interface, input: PendingRef) =>
publishMutation(
input,
bus.publish(SessionEvent.InputCancelled, {
sessionID: input.sessionID,
inputID: input.id,
}),
),
)
export const steer = Effect.fn("SessionPending.steer")((bus: Bus.Interface, input: PendingRef) =>
publishMutation(
input,
bus.publish(SessionEvent.InputSteered, {
sessionID: input.sessionID,
inputID: input.id,
}),
),
)
export const queue = Effect.fn("SessionPending.queue")((bus: Bus.Interface, input: PendingRef) =>
publishMutation(
input,
bus.publish(SessionEvent.InputQueued, {
sessionID: input.sessionID,
inputID: input.id,
}),
),
)
const publish = Effect.fn("SessionPending.publish")(function* ( const publish = Effect.fn("SessionPending.publish")(function* (
db: DatabaseService, db: DatabaseService,
bus: Bus.Interface, bus: Bus.Interface,
-18
View File
@@ -485,24 +485,6 @@ const layer = Layer.effectDiscard(
.pipe(Effect.orDie) .pipe(Effect.orDie)
}), }),
) )
yield* bus.project(SessionEvent.InputCancelled, (event) =>
SessionPending.projectCancelled(db, {
id: event.data.inputID,
sessionID: event.data.sessionID,
}),
)
yield* bus.project(SessionEvent.InputSteered, (event) =>
SessionPending.projectSteered(db, {
id: event.data.inputID,
sessionID: event.data.sessionID,
}),
)
yield* bus.project(SessionEvent.InputQueued, (event) =>
SessionPending.projectQueued(db, {
id: event.data.inputID,
sessionID: event.data.sessionID,
}),
)
yield* bus.project(SessionEvent.Compaction.Admitted, (event) => yield* bus.project(SessionEvent.Compaction.Admitted, (event) =>
Effect.gen(function* () { Effect.gen(function* () {
if (event.durable === undefined) if (event.durable === undefined)
-39
View File
@@ -1,39 +0,0 @@
import fs from "node:fs/promises"
import { Effect } from "effect"
import { ChildProcessSpawner } from "effect/unstable/process"
import { CrossSpawnSpawner } from "@opencode-ai/util/cross-spawn-spawner"
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
import { execDefaults, Failed, makeFiles, makeMemoryDriver } from "../src/environment/index"
import { tmpdir } from "./fixture/tmpdir"
import { environmentConformance } from "./lib/environment-conformance"
environmentConformance("memory environment", () =>
Effect.sync(() => {
const driver = makeMemoryDriver()
return {
files: makeFiles(driver),
root: `/workspace-${crypto.randomUUID()}`,
symlink: driver.symlink,
}
}),
)
environmentConformance(
"GNU exec environment",
() =>
Effect.gen(function* () {
const spawner = yield* ChildProcessSpawner.ChildProcessSpawner
const tmp = yield* Effect.promise(() => tmpdir("opencode-environment-"))
return {
files: execDefaults(spawner),
root: tmp.path,
symlink: (target: string, link: string) =>
Effect.tryPromise({
try: () => fs.symlink(target, link),
catch: (cause) => new Failed({ path: link, cause }),
}),
dispose: Effect.promise(() => tmp[Symbol.asyncDispose]()),
}
}).pipe(Effect.provide(LayerNode.compile(CrossSpawnSpawner.node))),
process.platform !== "linux",
)
@@ -1,159 +0,0 @@
import { describe, expect } from "bun:test"
import { Effect } from "effect"
import { Failed, NotFound, WrongKind, type Files } from "../../src/environment/index"
import { it } from "./effect"
export interface EnvironmentHarness {
readonly files: Files
readonly root: string
readonly symlink?: (target: string, path: string) => Effect.Effect<void, Failed>
readonly dispose?: Effect.Effect<void>
}
export const environmentConformance = <E>(
name: string,
makeHarness: () => Effect.Effect<EnvironmentHarness, E>,
skip = false,
) => {
const check = <A, E2>(title: string, body: (harness: EnvironmentHarness) => Effect.Effect<A, E2>) =>
it.live(title, () =>
Effect.gen(function* () {
const harness = yield* Effect.acquireRelease(makeHarness(), (harness) =>
Effect.gen(function* () {
yield* Effect.ignore(harness.files.remove(harness.root))
if (harness.dispose) yield* harness.dispose
}),
)
yield* harness.files.mkdir(harness.root)
return yield* body(harness)
}),
)
const bytes = (value: string) => new TextEncoder().encode(value)
const text = (value: Uint8Array) => new TextDecoder().decode(value)
const suite = skip ? describe.skip : describe
suite(name, () => {
check("writes, stats, and reads a file with its info", (harness) =>
Effect.gen(function* () {
const target = `${harness.root}/hello.txt`
yield* harness.files.write(target, bytes("hello"))
const result = yield* harness.files.read(target)
expect(text(result.bytes)).toBe("hello")
expect(result.info.type).toBe("file")
expect(result.info.size).toBe(5)
expect(yield* harness.files.stat(target)).toEqual(result.info)
}),
)
check("reports missing paths", (harness) =>
Effect.gen(function* () {
const target = `${harness.root}/missing`
expect(yield* Effect.flip(harness.files.read(target))).toBeInstanceOf(NotFound)
expect(yield* Effect.flip(harness.files.stat(target))).toBeInstanceOf(NotFound)
expect(yield* Effect.flip(harness.files.list(target))).toBeInstanceOf(NotFound)
expect(yield* Effect.flip(harness.files.move(target, `${harness.root}/other`))).toBeInstanceOf(NotFound)
}),
)
check("reports the actual kind", (harness) =>
Effect.gen(function* () {
const directory = `${harness.root}/directory`
const file = `${harness.root}/file`
yield* harness.files.mkdir(directory)
yield* harness.files.write(file, bytes("data"))
const readError = yield* Effect.flip(harness.files.read(directory))
const listError = yield* Effect.flip(harness.files.list(file))
expect(readError).toBeInstanceOf(WrongKind)
expect((readError as WrongKind).actual).toBe("directory")
expect(listError).toBeInstanceOf(WrongKind)
expect((listError as WrongKind).actual).toBe("file")
}),
)
check("write creates parent directories", (harness) =>
Effect.gen(function* () {
const target = `${harness.root}/one/two/file`
yield* harness.files.write(target, bytes("nested"))
yield* harness.files.write(`${harness.root}/empty`, new Uint8Array())
expect((yield* harness.files.stat(`${harness.root}/one/two`)).type).toBe("directory")
expect(yield* harness.files.stat(`${harness.root}/empty`)).toMatchObject({ type: "file", size: 0 })
expect(text((yield* harness.files.read(target)).bytes)).toBe("nested")
}),
)
check("reads byte ranges", (harness) =>
Effect.gen(function* () {
const target = `${harness.root}/range`
yield* harness.files.write(target, bytes("0123456789"))
expect(text((yield* harness.files.read(target, { offset: 2, length: 4 })).bytes)).toBe("2345")
expect(text((yield* harness.files.read(target, { offset: 8, length: 8 })).bytes)).toBe("89")
expect(text((yield* harness.files.read(target, { offset: 20, length: 4 })).bytes)).toBe("")
}),
)
check("lists immediate entries with their kinds", (harness) =>
Effect.gen(function* () {
yield* harness.files.write(`${harness.root}/file name`, bytes("data"))
yield* harness.files.mkdir(`${harness.root}/directory`)
yield* harness.files.write(`${harness.root}/directory/nested`, bytes("nested"))
const entries = yield* harness.files.list(harness.root)
expect(entries.toSorted((a, b) => a.name.localeCompare(b.name))).toEqual([
{ name: "directory", type: "directory" },
{ name: "file name", type: "file" },
])
}),
)
check("reports symlinks without resolving them", (harness) =>
Effect.gen(function* () {
if (!harness.symlink) return
yield* harness.files.write(`${harness.root}/target`, bytes("target"))
yield* harness.files.write(`${harness.root}/target-dir/file`, bytes("through link"))
yield* harness.symlink("target", `${harness.root}/link`)
yield* harness.symlink("target-dir", `${harness.root}/link-dir`)
expect((yield* harness.files.stat(`${harness.root}/link`)).type).toBe("symlink")
expect(yield* harness.files.list(harness.root)).toContainEqual({ name: "link", type: "symlink" })
expect(text((yield* harness.files.read(`${harness.root}/link-dir/file`)).bytes)).toBe("through link")
const listError = yield* Effect.flip(harness.files.list(`${harness.root}/link-dir`))
expect(listError).toBeInstanceOf(WrongKind)
expect((listError as WrongKind).actual).toBe("symlink")
}),
)
check("follows symlinks when reading", (harness) =>
Effect.gen(function* () {
if (!harness.symlink) return
yield* harness.files.write(`${harness.root}/target`, bytes("target content"))
yield* harness.files.mkdir(`${harness.root}/directory`)
yield* harness.symlink("target", `${harness.root}/file-link`)
yield* harness.symlink("directory", `${harness.root}/directory-link`)
yield* harness.symlink("missing", `${harness.root}/dangling-link`)
const result = yield* harness.files.read(`${harness.root}/file-link`)
expect(text(result.bytes)).toBe("target content")
expect(result.info.type).toBe("file")
expect(result.info.size).toBe(bytes("target content").length)
const directoryError = yield* Effect.flip(harness.files.read(`${harness.root}/directory-link`))
expect(directoryError).toBeInstanceOf(WrongKind)
expect((directoryError as WrongKind).actual).toBe("directory")
expect(yield* Effect.flip(harness.files.read(`${harness.root}/dangling-link`))).toBeInstanceOf(NotFound)
}),
)
check("moves files and removes trees idempotently", (harness) =>
Effect.gen(function* () {
const source = `${harness.root}/source/file`
const destination = `${harness.root}/destination`
yield* harness.files.write(source, bytes("moved"))
yield* harness.files.move(source, destination)
expect(text((yield* harness.files.read(destination)).bytes)).toBe("moved")
expect(yield* Effect.flip(harness.files.stat(source))).toBeInstanceOf(NotFound)
yield* harness.files.remove(`${harness.root}/source`)
yield* harness.files.remove(`${harness.root}/source`)
expect(yield* Effect.flip(harness.files.stat(`${harness.root}/source`))).toBeInstanceOf(NotFound)
}),
)
})
}
-74
View File
@@ -1086,78 +1086,4 @@ describe("Session.pending", () => {
expect(yield* session.pending(sessionID)).toEqual([]) expect(yield* session.pending(sessionID)).toEqual([])
}), }),
) )
it.effect("cancels pending input and allows its ID to be admitted again", () =>
Effect.gen(function* () {
yield* setup
const session = yield* Session.Service
const inputID = SessionMessage.ID.make("msg_cancelled_queue")
yield* session.prompt({
id: inputID,
sessionID,
text: "Queue this",
delivery: "queue",
resume: false,
})
yield* session.cancelPending({ sessionID, inputID })
expect(yield* session.pending(sessionID)).toEqual([])
expect(yield* eventCount(Bus.versionedType(SessionEvent.InputCancelled.type, 1))).toBe(1)
expect(
yield* session.cancelPending({ sessionID, inputID }).pipe(Effect.flip),
).toMatchObject({ _tag: "Session.PendingInputConflictError", sessionID, inputID })
expect(yield* eventCount(Bus.versionedType(SessionEvent.InputCancelled.type, 1))).toBe(1)
const retried = yield* session.prompt({
id: inputID,
sessionID,
text: "Queue this",
delivery: "queue",
resume: false,
})
expect(retried).toMatchObject({ id: inputID, delivery: "queue" })
}),
)
it.effect("moves pending input between steer and queue delivery", () =>
Effect.gen(function* () {
yield* setup
const session = yield* Session.Service
const queued = yield* session.synthetic({
sessionID,
text: "Steer this",
delivery: "queue",
resume: false,
})
const alreadySteered = yield* session.prompt({ sessionID, text: "Already steer", resume: false })
wakeCalls.length = 0
yield* session.steerPending({ sessionID, inputID: queued.id })
expect(yield* session.pending(sessionID)).toMatchObject([
{ id: queued.id, delivery: "steer" },
{ id: alreadySteered.id, delivery: "steer" },
])
expect(wakeCalls).toEqual([sessionID])
expect(yield* eventCount(Bus.versionedType(SessionEvent.InputSteered.type, 1))).toBe(1)
wakeCalls.length = 0
yield* session.queuePending({ sessionID, inputID: queued.id })
expect(yield* session.pending(sessionID)).toMatchObject([
{ id: queued.id, delivery: "queue" },
{ id: alreadySteered.id, delivery: "steer" },
])
expect(wakeCalls).toEqual([])
expect(yield* eventCount(Bus.versionedType(SessionEvent.InputQueued.type, 1))).toBe(1)
expect(
yield* session.steerPending({ sessionID, inputID: alreadySteered.id }).pipe(Effect.flip),
).toMatchObject({ _tag: "Session.PendingInputConflictError", sessionID, inputID: alreadySteered.id })
yield* session.cancelPending({ sessionID, inputID: alreadySteered.id })
expect(wakeCalls).toEqual([])
expect(yield* eventCount(Bus.versionedType(SessionEvent.InputSteered.type, 1))).toBe(1)
expect(yield* eventCount(Bus.versionedType(SessionEvent.InputCancelled.type, 1))).toBe(1)
}),
)
}) })
+3 -3
View File
@@ -31,9 +31,9 @@
}, },
"peerDependencies": { "peerDependencies": {
"@opencode-ai/theme": "workspace:*", "@opencode-ai/theme": "workspace:*",
"@opentui/core": "0.0.0-20260807-6b4336f3", "@opentui/core": ">=0.4.5",
"@opentui/keymap": "0.0.0-20260807-6b4336f3", "@opentui/keymap": ">=0.4.5",
"@opentui/solid": "0.0.0-20260807-6b4336f3", "@opentui/solid": ">=0.4.5",
"solid-js": ">=1.9.0" "solid-js": ">=1.9.0"
}, },
"peerDependenciesMeta": { "peerDependenciesMeta": {
-39
View File
@@ -522,45 +522,6 @@ export const makeSessionGroup = <I extends HttpApiMiddleware.AnyId, S>(sessionLo
}), }),
), ),
) )
.add(
HttpApiEndpoint.delete("session.pending.cancel", "/api/session/:sessionID/pending/:inputID", {
params: { sessionID: Session.ID, inputID: SessionMessage.ID },
success: HttpApiSchema.NoContent,
error: [ConflictError, SessionNotFoundError],
}).annotateMerge(
OpenApi.annotations({
identifier: "v2.session.pending.cancel",
summary: "Cancel pending input",
description: "Cancel an input that has not yet been promoted into session history.",
}),
),
)
.add(
HttpApiEndpoint.post("session.pending.steer", "/api/session/:sessionID/pending/:inputID/steer", {
params: { sessionID: Session.ID, inputID: SessionMessage.ID },
success: HttpApiSchema.NoContent,
error: [ConflictError, SessionNotFoundError],
}).annotateMerge(
OpenApi.annotations({
identifier: "v2.session.pending.steer",
summary: "Steer queued input",
description: "Change a queued input to steer delivery and wake session execution.",
}),
),
)
.add(
HttpApiEndpoint.post("session.pending.queue", "/api/session/:sessionID/pending/:inputID/queue", {
params: { sessionID: Session.ID, inputID: SessionMessage.ID },
success: HttpApiSchema.NoContent,
error: [ConflictError, SessionNotFoundError],
}).annotateMerge(
OpenApi.annotations({
identifier: "v2.session.pending.queue",
summary: "Queue pending steer",
description: "Change a pending steer to queued delivery.",
}),
),
)
.add( .add(
HttpApiEndpoint.get("session.instructions.entry.list", "/api/session/:sessionID/instructions/entries", { HttpApiEndpoint.get("session.instructions.entry.list", "/api/session/:sessionID/instructions/entries", {
params: { sessionID: Session.ID }, params: { sessionID: Session.ID },
+7 -35
View File
@@ -152,15 +152,13 @@ export const Forked = Event.durable({
}) })
export type Forked = typeof Forked.Type export type Forked = typeof Forked.Type
const InputRef = {
...Base,
inputID: SessionMessage.ID,
}
export const InputPromoted = Event.durable({ export const InputPromoted = Event.durable({
type: "session.input.promoted", type: "session.input.promoted",
...options, ...options,
schema: InputRef, schema: {
sessionID: SessionID,
inputID: SessionMessage.ID,
},
}) })
export type InputPromoted = typeof InputPromoted.Type export type InputPromoted = typeof InputPromoted.Type
@@ -168,33 +166,13 @@ export const InputAdmitted = Event.durable({
type: "session.input.admitted", type: "session.input.admitted",
...options, ...options,
schema: { schema: {
...InputRef, ...Base,
inputID: SessionMessage.ID,
input: SessionPending.Message, input: SessionPending.Message,
}, },
}) })
export type InputAdmitted = typeof InputAdmitted.Type export type InputAdmitted = typeof InputAdmitted.Type
export const InputCancelled = Event.durable({
type: "session.input.cancelled",
...options,
schema: InputRef,
})
export type InputCancelled = typeof InputCancelled.Type
export const InputSteered = Event.durable({
type: "session.input.steered",
...options,
schema: InputRef,
})
export type InputSteered = typeof InputSteered.Type
export const InputQueued = Event.durable({
type: "session.input.queued",
...options,
schema: InputRef,
})
export type InputQueued = typeof InputQueued.Type
export namespace Execution { export namespace Execution {
export const Started = Event.durable({ type: "session.execution.started", ...options, schema: Base }) export const Started = Event.durable({ type: "session.execution.started", ...options, schema: Base })
export type Started = typeof Started.Type export type Started = typeof Started.Type
@@ -602,9 +580,6 @@ export const Definitions = Event.inventory(
Forked, Forked,
InputPromoted, InputPromoted,
InputAdmitted, InputAdmitted,
InputCancelled,
InputSteered,
InputQueued,
Execution.Started, Execution.Started,
Execution.Succeeded, Execution.Succeeded,
Execution.Failed, Execution.Failed,
@@ -646,16 +621,13 @@ export const DurableDefinitions = Event.inventory(
...Definitions.filter((definition) => definition.durability === "durable"), ...Definitions.filter((definition) => definition.durability === "durable"),
UsageRecorded, UsageRecorded,
) )
export const EphemeralDefinitions = Event.inventory(
...Definitions.filter((definition) => definition.durability === "ephemeral"),
)
export const Durable = Schema.Union(DurableDefinitions, { mode: "oneOf" }) export const Durable = Schema.Union(DurableDefinitions, { mode: "oneOf" })
.pipe(Schema.toTaggedUnion("type")) .pipe(Schema.toTaggedUnion("type"))
.annotate({ identifier: "Session.Event.Durable" }) .annotate({ identifier: "Session.Event.Durable" })
export type DurableEvent = typeof Durable.Type export type DurableEvent = typeof Durable.Type
export const All = Schema.Union([Durable, ...EphemeralDefinitions], { mode: "oneOf" }).pipe( export const All = Schema.Union(Event.inventory(...Definitions, UsageRecorded), { mode: "oneOf" }).pipe(
Schema.toTaggedUnion("type"), Schema.toTaggedUnion("type"),
) )
export type Event = typeof All.Type export type Event = typeof All.Type
@@ -84,9 +84,6 @@ describe("public event manifest", () => {
"session.forked.2", "session.forked.2",
"session.input.promoted.1", "session.input.promoted.1",
"session.input.admitted.1", "session.input.admitted.1",
"session.input.cancelled.1",
"session.input.steered.1",
"session.input.queued.1",
"session.execution.started.1", "session.execution.started.1",
"session.execution.succeeded.1", "session.execution.succeeded.1",
"session.execution.failed.1", "session.execution.failed.1",
-43
View File
@@ -26,22 +26,6 @@ export const SessionHandler = HttpApiBuilder.group(Api, "server.session", (handl
Effect.gen(function* () { Effect.gen(function* () {
const session = yield* Session.Service const session = yield* Session.Service
const transfer = yield* SessionTransfer.Service const transfer = yield* SessionTransfer.Service
const pendingMutation = (effect: ReturnType<typeof session.cancelPending>, conflict: string) =>
effect.pipe(
Effect.catchTag(
"Session.NotFoundError",
(error) =>
new SessionNotFoundError({
sessionID: error.sessionID,
message: `Session not found: ${error.sessionID}`,
}),
),
Effect.catchTag(
"Session.PendingInputConflictError",
(error) => new ConflictError({ resource: error.inputID, message: `${conflict}: ${error.inputID}` }),
),
Effect.as(HttpApiSchema.NoContent.make()),
)
return handlers return handlers
.handle( .handle(
@@ -677,33 +661,6 @@ export const SessionHandler = HttpApiBuilder.group(Api, "server.session", (handl
} }
}), }),
) )
.handle(
"session.pending.cancel",
Effect.fn(function* (ctx) {
return yield* pendingMutation(
session.cancelPending({ sessionID: ctx.params.sessionID, inputID: ctx.params.inputID }),
"Pending input can no longer be cancelled",
)
}),
)
.handle(
"session.pending.steer",
Effect.fn(function* (ctx) {
return yield* pendingMutation(
session.steerPending({ sessionID: ctx.params.sessionID, inputID: ctx.params.inputID }),
"Pending input is no longer queued",
)
}),
)
.handle(
"session.pending.queue",
Effect.fn(function* (ctx) {
return yield* pendingMutation(
session.queuePending({ sessionID: ctx.params.sessionID, inputID: ctx.params.inputID }),
"Pending input is no longer a steer",
)
}),
)
.handle( .handle(
"session.instructions.entry.list", "session.instructions.entry.list",
Effect.fn(function* (ctx) { Effect.fn(function* (ctx) {
@@ -462,7 +462,6 @@ function newLayout() {
function webSearchProviderLabel(provider: unknown) { function webSearchProviderLabel(provider: unknown) {
if (provider === "parallel") return "Parallel Web Search" if (provider === "parallel") return "Parallel Web Search"
if (provider === "exa") return "Exa Web Search" if (provider === "exa") return "Exa Web Search"
if (provider === "firecrawl") return "Firecrawl Web Search"
return "Web Search" return "Web Search"
} }
+1
View File
@@ -92,6 +92,7 @@
"@opentui/keymap": "catalog:", "@opentui/keymap": "catalog:",
"@opentui/solid": "catalog:", "@opentui/solid": "catalog:",
"@solid-primitives/event-bus": "1.1.2", "@solid-primitives/event-bus": "1.1.2",
"clipboardy": "4.0.0",
"effect": "catalog:", "effect": "catalog:",
"fuzzysort": "catalog:", "fuzzysort": "catalog:",
"get-east-asian-width": "catalog:", "get-east-asian-width": "catalog:",
+3 -15
View File
@@ -97,7 +97,6 @@ import { destroyRenderer } from "./util/renderer"
import { cliErrorMessage, errorFormat } from "./util/error" import { cliErrorMessage, errorFormat } from "./util/error"
import { AttentionProvider } from "./context/attention" import { AttentionProvider } from "./context/attention"
import { StorageProvider } from "./context/storage" import { StorageProvider } from "./context/storage"
import { createTuiClipboard } from "./clipboard"
registerOpencodeSpinner() registerOpencodeSpinner()
@@ -251,13 +250,6 @@ export const run = Effect.fn("Tui.run")(function* (input: TuiInput) {
(renderer) => Effect.sync(() => destroyRenderer(renderer)), (renderer) => Effect.sync(() => destroyRenderer(renderer)),
) )
}) })
const clipboard = yield* Effect.acquireRelease(
Effect.sync(() => createTuiClipboard(renderer)),
(clipboard) =>
Effect.tryPromise(() => clipboard.dispose()).pipe(
Effect.catch((error) => Effect.sync(() => log("error", "Failed to dispose TUI clipboard", { error }))),
),
)
win32DisableProcessedInput() win32DisableProcessedInput()
const finalizers = new Set<() => Promise<void>>() const finalizers = new Set<() => Promise<void>>()
yield* Effect.addFinalizer(() => yield* Effect.addFinalizer(() =>
@@ -294,11 +286,7 @@ export const run = Effect.fn("Tui.run")(function* (input: TuiInput) {
<EpilogueProvider set={(value) => (exit.epilogue = value)}> <EpilogueProvider set={(value) => (exit.epilogue = value)}>
<TuiAppProvider value={input.app}> <TuiAppProvider value={input.app}>
<ErrorBoundary <ErrorBoundary
fallback={(error, reset) => ( fallback={(error, reset) => <ErrorComponent error={error} reset={reset} mode={mode} />}
<ClipboardProvider value={clipboard}>
<ErrorComponent error={error} reset={reset} mode={mode} />
</ClipboardProvider>
)}
> >
<TuiPathsProvider <TuiPathsProvider
value={{ value={{
@@ -347,7 +335,7 @@ export const run = Effect.fn("Tui.run")(function* (input: TuiInput) {
skipInitialLoading: Boolean(process.env.OPENCODE_FAST_BOOT), skipInitialLoading: Boolean(process.env.OPENCODE_FAST_BOOT),
}} }}
> >
<ClipboardProvider value={clipboard}> <ClipboardProvider>
<ArgsProvider {...input.args}> <ArgsProvider {...input.args}>
<ConfigProvider <ConfigProvider
config={config} config={config}
@@ -515,7 +503,7 @@ function App(props: { pair?: DialogPairCredentials }) {
if (!text || text.length === 0) return if (!text || text.length === 0) return
await clipboard await clipboard
.write(text) .write?.(text)
.then(() => toast.show({ message: "Copied to clipboard", variant: "info" })) .then(() => toast.show({ message: "Copied to clipboard", variant: "info" }))
.catch(toast.error) .catch(toast.error)
+117 -59
View File
@@ -1,66 +1,124 @@
import { import { execFile, spawn } from "node:child_process"
createClipboard, import { readFile, rm } from "node:fs/promises"
createHostClipboard, import { platform, release, tmpdir } from "node:os"
createRendererClipboardAdapter, import path from "node:path"
decodePasteBytes, import { promisify } from "node:util"
type ClipboardService as CoreClipboardService,
type RendererClipboardBoundary,
} from "@opentui/core"
import type { ClipboardContent, ClipboardService } from "./context/clipboard"
export type OwnedClipboardService = Required<ClipboardService> & Readonly<{ dispose(): Promise<void> }> const exec = promisify(execFile)
export function createTuiClipboard(renderer: RendererClipboardBoundary): OwnedClipboardService { function command(command: string, args: string[] = [], input?: string) {
return createClipboardAdapter( return new Promise<Buffer>((resolve, reject) => {
createClipboard({ const child = spawn(command, args, { stdio: [input === undefined ? "ignore" : "pipe", "pipe", "ignore"] })
host: createHostClipboard(), const output: Buffer[] = []
terminal: createRendererClipboardAdapter(renderer), child.on("error", reject)
}), child.stdout?.on("data", (chunk: Buffer) => output.push(chunk))
) child.on("close", (code) => {
if (code === 0) return resolve(Buffer.concat(output))
reject(new Error(`${command} exited with code ${code}`))
})
if (input !== undefined) child.stdin?.end(input)
})
} }
export function createClipboardAdapter(clipboard: CoreClipboardService): OwnedClipboardService { function writeOsc52(text: string) {
return { if (!process.stdout.isTTY) return
async read(): Promise<ClipboardContent | undefined> { const sequence = `\x1b]52;c;${Buffer.from(text).toString("base64")}\x07`
const result = await clipboard.read({ process.stdout.write(process.env.TMUX || process.env.STY ? `\x1bPtmux;\x1b${sequence}\x1b\\` : sequence)
preferredTypes: ["image/png", "text/plain"], }
selection: "clipboard",
})
if (result.status !== "read") {
if (result.status === "failed") throw result.error
if (result.status === "timed-out") throw new Error("Clipboard read timed out")
if (result.status === "limit-exceeded") {
throw new RangeError("Clipboard content exceeded configured read or image conversion limits")
}
return undefined
}
if (result.representation.mimeType === "image/png") { export async function read() {
return { if (platform() === "darwin") {
data: Buffer.from(result.representation.bytes).toString("base64"), const file = path.join(tmpdir(), "opencode-clipboard.png")
mime: result.representation.mimeType, try {
} await exec("osascript", [
} "-e",
if (result.representation.mimeType === "text/plain") { 'set imageData to the clipboard as "PNGf"',
if (result.representation.bytes.length === 0) return undefined "-e",
return { `set fileRef to open for access POSIX file "${file}" with write permission`,
data: decodePasteBytes(result.representation.bytes), "-e",
mime: result.representation.mimeType, "set eof fileRef to 0",
} "-e",
} "write imageData to fileRef",
throw new Error(`Unexpected clipboard MIME type: ${result.representation.mimeType}`) "-e",
}, "close access fileRef",
async write(text) { ])
const result = await clipboard.writeText(text, { return { data: (await readFile(file)).toString("base64"), mime: "image/png" }
destination: "all-available", } catch {
selection: "clipboard", // Fall through to text clipboard.
}) } finally {
if (result.host.status === "written" || result.terminal.status === "attempted") return await rm(file, { force: true }).catch(() => {})
if (result.host.status === "failed") throw result.host.error }
throw new Error(`Clipboard write failed (host: ${result.host.status}, terminal: ${result.terminal.status})`) }
},
dispose() { if (platform() === "win32" || release().includes("WSL")) {
return clipboard.dispose() const script =
}, "Add-Type -AssemblyName System.Windows.Forms; $img = [System.Windows.Forms.Clipboard]::GetImage(); if ($img) { $ms = New-Object System.IO.MemoryStream; $img.Save($ms, [System.Drawing.Imaging.ImageFormat]::Png); [System.Convert]::ToBase64String($ms.ToArray()) }"
const image = await command("powershell.exe", ["-NonInteractive", "-NoProfile", "-command", script]).catch(() =>
Buffer.alloc(0),
)
if (image.length) return { data: image.toString().trim(), mime: "image/png" }
}
if (platform() === "linux") {
const wayland = await command("wl-paste", ["-t", "image/png"]).catch(() => Buffer.alloc(0))
if (wayland.length) return { data: wayland.toString("base64"), mime: "image/png" }
const x11 = await command("xclip", ["-selection", "clipboard", "-t", "image/png", "-o"]).catch(() =>
Buffer.alloc(0),
)
if (x11.length) return { data: x11.toString("base64"), mime: "image/png" }
}
const { default: clipboardy } = await import("clipboardy")
const text = await clipboardy.read().catch(() => undefined)
if (text) return { data: text, mime: "text/plain" }
}
export function copyCommand(
os: NodeJS.Platform,
wayland: boolean,
has: (name: string) => boolean,
): string[] | undefined {
if (os === "darwin" && has("osascript")) return ["osascript"]
if (os === "linux" && wayland && has("wl-copy")) return ["wl-copy"]
if (os === "linux" && has("xclip")) return ["xclip", "-selection", "clipboard"]
if (os === "linux" && has("xsel")) return ["xsel", "--clipboard", "--input"]
if (os === "win32" && has("powershell.exe")) {
return [
"powershell.exe",
"-NonInteractive",
"-NoProfile",
"-Command",
"[Console]::InputEncoding = [System.Text.Encoding]::UTF8; Set-Clipboard -Value ([Console]::In.ReadToEnd())",
]
} }
} }
let copyMethod: Promise<(text: string) => Promise<void>> | undefined
function getCopyMethod() {
return (copyMethod ??= (async () => {
const { which } = await import("@opencode-ai/core/util/which")
const native = copyCommand(platform(), Boolean(process.env.WAYLAND_DISPLAY), (name) => Boolean(which(name)))
if (native?.[0] === "osascript") {
return async (text: string) => {
const escaped = text.replace(/\\/g, "\\\\").replace(/"/g, '\\"')
await command("osascript", ["-e", `set the clipboard to "${escaped}"`]).catch(() => undefined)
}
}
if (native) {
return async (text: string) => {
await command(native[0], native.slice(1), text).catch(() => undefined)
}
}
return async (text: string) => {
const { default: clipboardy } = await import("clipboardy")
await clipboardy.write(text).catch(() => undefined)
}
})())
}
export async function write(text: string) {
writeOsc52(text)
const method = await getCopyMethod()
await method(text)
}
@@ -84,15 +84,6 @@ export const settings: Setting[] = [
values: ["none", "auto"], values: ["none", "auto"],
keywords: ["transcript", "messages"], keywords: ["transcript", "messages"],
}, },
{
title: "Transcript images",
category: "Session",
path: ["session", "image_preview"],
default: false,
values: [false, true],
labels: ["off", "on"],
keywords: ["attachments", "images", "tool output"],
},
{ {
title: "Enabled", title: "Enabled",
category: "Tabs", category: "Tabs",
@@ -197,15 +188,6 @@ export const settings: Setting[] = [
values: ["compact", "full"], values: ["compact", "full"],
keywords: ["paste summary", "clipboard", "pasted content"], keywords: ["paste summary", "clipboard", "pasted content"],
}, },
{
title: "Image previews",
category: "Input",
path: ["prompt", "image_preview"],
default: false,
values: [false, true],
labels: ["off", "on"],
keywords: ["attachments", "clipboard", "images", "prompt"],
},
{ {
title: "Leader timeout", title: "Leader timeout",
category: "Input", category: "Input",
+1 -1
View File
@@ -39,7 +39,7 @@ export function DialogDebug() {
.map((entry) => `${entry.label}: ${entry.value}`) .map((entry) => `${entry.label}: ${entry.value}`)
.join("\n") .join("\n")
void clipboard void clipboard
.write(text) .write?.(text)
.then(() => { .then(() => {
setCopied(true) setCopied(true)
toast.show({ message: "Debug info copied to clipboard", variant: "info" }) toast.show({ message: "Debug info copied to clipboard", variant: "info" })
@@ -1,71 +0,0 @@
import { TextAttributes } from "@opentui/core"
import { useTerminalDimensions } from "@opentui/solid"
import { createMemo, createSignal } from "solid-js"
import { Keymap } from "../context/keymap"
import { useTheme } from "../context/theme"
import { useDialog } from "../ui/dialog"
type ImagePreviewItem = Readonly<{
uri: string
mention?: Readonly<{ text: string }>
}>
export function DialogImagePreview(props: { images: readonly ImagePreviewItem[]; initial: number }) {
const dialog = useDialog()
const dimensions = useTerminalDimensions()
const theme = useTheme("elevated")
const [index, setIndex] = createSignal(Math.max(0, Math.min(props.images.length - 1, props.initial)))
const [failed, setFailed] = createSignal(false)
const current = createMemo(() => props.images[index()])
const imageHeight = createMemo(() => Math.max(3, dimensions().height - 8))
dialog.setSize("xlarge")
dialog.setCentered(true)
function move(direction: number) {
if (props.images.length < 2) return
setFailed(false)
setIndex((value) => (value + direction + props.images.length) % props.images.length)
}
Keymap.createLayer(() => ({
mode: "modal",
commands: [
{ bind: "left", title: "Previous image", group: "Dialog", run: () => move(-1) },
{ bind: "right", title: "Next image", group: "Dialog", run: () => move(1) },
],
}))
return (
<box id="prompt-image-viewer" paddingLeft={2} paddingRight={2} paddingBottom={1} gap={1}>
<box flexDirection="row" justifyContent="space-between">
<text attributes={TextAttributes.BOLD} fg={theme.text.default}>
Image {index() + 1} of {props.images.length}
</text>
<text fg={theme.text.subdued} onMouseUp={() => dialog.clear()}>
esc
</text>
</box>
<image
id="prompt-image-viewer-image"
source={current().uri}
fit="fit"
protocol="auto"
width="100%"
height={imageHeight()}
onError={() => setFailed(true)}
/>
<box flexDirection="row" justifyContent="space-between">
<text fg={theme.text.subdued} onMouseUp={() => move(-1)}>
{props.images.length > 1 ? "← previous" : ""}
</text>
<text fg={failed() ? theme.text.feedback.error.default : theme.text.subdued} wrapMode="none" truncate>
{failed() ? "No preview" : (current().mention?.text ?? `Image ${index() + 1}`)}
</text>
<text fg={theme.text.subdued} onMouseUp={() => move(1)}>
{props.images.length > 1 ? "next →" : ""}
</text>
</box>
</box>
)
}
@@ -466,7 +466,7 @@ function OAuthAuto(props: {
run: () => { run: () => {
const value = props.attempt.instructions.match(/[A-Z0-9]{4}-[A-Z0-9]{4,5}/)?.[0] ?? props.attempt.url const value = props.attempt.instructions.match(/[A-Z0-9]{4}-[A-Z0-9]{4,5}/)?.[0] ?? props.attempt.url
clipboard clipboard
.write(value) .write?.(value)
.then(() => toast.show({ message: "Copied to clipboard", variant: "info" })) .then(() => toast.show({ message: "Copied to clipboard", variant: "info" }))
.catch(toast.error) .catch(toast.error)
}, },
@@ -172,6 +172,7 @@ function DialogMcpError(props: { server: McpServer; onBack: () => void }) {
onMount(() => dialog.setSize("large")) onMount(() => dialog.setSize("large"))
const copy = () => { const copy = () => {
if (!clipboard.write) return
void clipboard void clipboard
.write(error()) .write(error())
.then(() => setCopied(true)) .then(() => setCopied(true))
+8 -25
View File
@@ -12,7 +12,7 @@ export function ErrorComponent(props: { error: Error; reset: () => void; mode?:
const exit = useExit() const exit = useExit()
const clipboard = useClipboard() const clipboard = useClipboard()
const app = useTuiApp() const app = useTuiApp()
const [copyState, setCopyState] = createSignal<"idle" | "copied" | "failed">("idle") const [copied, setCopied] = createSignal(false)
// Safe fallback palette per mode (mirrors theme/assets/opencode.json) since the // Safe fallback palette per mode (mirrors theme/assets/opencode.json) since the
// theme context may be the thing that crashed. // theme context may be the thing that crashed.
@@ -46,19 +46,11 @@ export function ErrorComponent(props: { error: Error; reset: () => void; mode?:
const issueURL = buildIssueURL(message, stack, app.version) const issueURL = buildIssueURL(message, stack, app.version)
const copyReport = () => { const copyReport = () => {
void clipboard void clipboard.write?.(issueURL.toString()).then(() => setCopied(true))
.write(issueURL.toString())
.then(() => setCopyState("copied"))
.catch(() => setCopyState("failed"))
} }
const actions = [ const actions = [
{ { key: "c", label: () => (copied() ? "✓ Copied" : "Copy report"), copy: true, onUse: copyReport },
key: "c",
label: () => ({ idle: "Copy report", copied: "✓ Copied", failed: "Copy failed" })[copyState()],
copy: true,
onUse: copyReport,
},
{ key: "r", label: () => "Restart", onUse: props.reset }, { key: "r", label: () => "Restart", onUse: props.reset },
{ key: "q", label: () => "Quit", onUse: () => exit() }, { key: "q", label: () => "Quit", onUse: () => exit() },
] ]
@@ -143,20 +135,13 @@ export function ErrorComponent(props: { error: Error; reset: () => void; mode?:
<For each={actions}> <For each={actions}>
{(action, index) => { {(action, index) => {
const isSelected = () => selected() === index() const isSelected = () => selected() === index()
const copyColor = () => const isCopied = () => action.copy && copied()
action.copy
? copyState() === "copied"
? colors.success
: copyState() === "failed"
? colors.error
: undefined
: undefined
return ( return (
<box flexDirection="column" alignItems="center" flexShrink={0}> <box flexDirection="column" alignItems="center" flexShrink={0}>
<box <box
onMouseDown={() => setSelected(index())} onMouseDown={() => setSelected(index())}
onMouseUp={() => action.onUse()} onMouseUp={() => action.onUse()}
backgroundColor={copyColor() ?? (isSelected() ? colors.primary : colors.element)} backgroundColor={isCopied() ? colors.success : isSelected() ? colors.primary : colors.element}
minWidth={15} minWidth={15}
alignItems="center" alignItems="center"
paddingLeft={2} paddingLeft={2}
@@ -164,7 +149,7 @@ export function ErrorComponent(props: { error: Error; reset: () => void; mode?:
> >
<text <text
attributes={TextAttributes.BOLD} attributes={TextAttributes.BOLD}
fg={copyColor() || isSelected() ? colors.onPrimary : colors.text} fg={isCopied() || isSelected() ? colors.onPrimary : colors.text}
> >
{action.label()} {action.label()}
</text> </text>
@@ -204,11 +189,9 @@ export function ErrorComponent(props: { error: Error; reset: () => void; mode?:
<Show when={showFooter()}> <Show when={showFooter()}>
<box flexDirection="column" alignItems="center" flexShrink={0}> <box flexDirection="column" alignItems="center" flexShrink={0}>
<text fg={colors.muted}> <text fg={colors.muted}>
{copyState() === "copied" {copied()
? "Report copied — paste it into a new GitHub issue." ? "Report copied — paste it into a new GitHub issue."
: copyState() === "failed" : "Copy the report and open a GitHub issue to help us fix this."}
? "Clipboard write failed. Try again or report the crash manually."
: "Copy the report and open a GitHub issue to help us fix this."}
</text> </text>
<text fg={colors.muted}>OpenCode {app.version}</text> <text fg={colors.muted}>OpenCode {app.version}</text>
</box> </box>
+52 -229
View File
@@ -7,8 +7,9 @@ import {
decodePasteBytes, decodePasteBytes,
type KeyEvent, type KeyEvent,
} from "@opentui/core" } 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 path from "path" import path from "path"
import { fileURLToPath } from "url"
import { useLocal } from "../../context/local" import { useLocal } from "../../context/local"
import { useTheme, useThemes } from "../../context/theme" import { useTheme, useThemes } from "../../context/theme"
import { tint } from "../../theme/color" import { tint } from "../../theme/color"
@@ -46,27 +47,18 @@ import { DialogSkill } from "../dialog-skill"
import { useArgs } from "../../context/args" import { useArgs } from "../../context/args"
import { useConfig } from "../../config" import { useConfig } from "../../config"
import { usePromptMove } from "./move" import { usePromptMove } from "./move"
import { import { readLocalAttachment } from "./local-attachment"
normalizePastedFilepath,
parsePastedFilepaths,
readLocalAttachment,
MAX_LOCAL_ATTACHMENT_BYTES,
type LocalAttachment,
} from "./local-attachment"
import { useData } from "../../context/data" import { useData } from "../../context/data"
import { useLocation } from "../../context/location" import { useLocation } from "../../context/location"
import { Keymap, type KeymapCommand } from "../../context/keymap" import { Keymap, type KeymapCommand } from "../../context/keymap"
import { abbreviateHome } from "../../runtime" import { abbreviateHome } from "../../runtime"
import { PluginSlot } from "../../plugin/render" import { PluginSlot } from "../../plugin/render"
import type { SessionPending } from "@opencode-ai/schema/session-pending"
import { DialogImagePreview } from "../dialog-image-preview"
export type PromptProps = { export type PromptProps = {
sessionID?: string sessionID?: string
visible?: boolean visible?: boolean
disabled?: boolean disabled?: boolean
onSubmit?: () => void onSubmit?: () => void
onEmptySubmit?: () => boolean | Promise<boolean>
ref?: (ref: PromptRef | undefined) => void ref?: (ref: PromptRef | undefined) => void
hint?: JSX.Element hint?: JSX.Element
right?: JSX.Element right?: JSX.Element
@@ -77,6 +69,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 = { export type PromptRef = {
focused: boolean focused: boolean
current: PromptInfo current: PromptInfo
@@ -304,41 +307,6 @@ export function Prompt(props: PromptProps) {
extmarkToPart: new Map(), extmarkToPart: new Map(),
interrupt: 0, interrupt: 0,
}) })
let disposed = false
let pasteQueue = Promise.resolve()
function enqueuePaste(run: (changed: () => boolean) => Promise<void>) {
pasteQueue = pasteQueue
.then(async () => {
if (disposed || input.isDestroyed) return
const before = { sessionID: props.sessionID, mode: store.mode, text: input.plainText }
await run(
() =>
disposed ||
input.isDestroyed ||
props.sessionID !== before.sessionID ||
store.mode !== before.mode ||
input.plainText !== before.text,
)
})
.catch((error) => {
if (!disposed) toast.error(error)
})
return pasteQueue
}
const imageAttachments = createMemo(() =>
(store.prompt.files ?? []).filter((file) => typeof file.uri === "string" && file.uri.startsWith("data:image/")),
)
const imagePreviewHeight = createMemo(() => Math.max(4, Math.min(8, Math.floor(dimensions().height / 4))))
const imagePreviewWidth = createMemo(() => imagePreviewHeight() * 2)
const visibleImageAttachments = createMemo(() => imageAttachments().slice(0, 3))
function openImagePreview(initial: number) {
const images = imageAttachments()
if (images.length === 0) return
dialog.replace(() => <DialogImagePreview images={images} initial={initial} />)
}
createEffect( createEffect(
on( on(
@@ -389,20 +357,6 @@ export function Prompt(props: PromptProps) {
dialog.clear() dialog.clear()
}, },
}, },
{
title: "Queue prompt",
name: "prompt.queue",
category: "Prompt",
palette: undefined,
run: async (_input: string | undefined, event?: KeyEvent) => {
event?.preventDefault()
event?.stopPropagation()
if (!input.focused) return
const handled = await submit("queue")
if (!handled) return
dialog.clear()
},
},
{ {
title: "Remove editor context", title: "Remove editor context",
name: "prompt.editor_context.clear", name: "prompt.editor_context.clear",
@@ -418,32 +372,22 @@ export function Prompt(props: PromptProps) {
name: "prompt.paste", name: "prompt.paste",
category: "Prompt", category: "Prompt",
palette: undefined, palette: undefined,
run: (_input: string | undefined, event?: KeyEvent) => { run: async (_input: string | undefined, event?: KeyEvent) => {
event?.preventDefault() event?.preventDefault()
event?.stopPropagation() event?.stopPropagation()
return enqueuePaste(async (changed) => { const content = await clipboard.read?.()
const content = await clipboard.read() if (content?.mime.startsWith("image/")) {
if (changed()) return await pasteAttachment({
if (content?.mime.startsWith("image/")) { filename: "clipboard",
pasteAttachment({ uri: `data:${content.mime};base64,${content.data}`,
filename: "clipboard", })
uri: `data:${content.mime};base64,${content.data}`, return
}) }
return if (content?.mime === "text/plain") {
} await pasteInputText(content.data)
if (content?.mime === "text/plain") { }
await pasteInputText(content.data, changed)
}
})
}, },
}, },
{
title: "View image attachments",
name: "prompt.images.view",
category: "Prompt",
enabled: imageAttachments().length > 0,
run: () => openImagePreview(0),
},
{ {
title: "Interrupt session", title: "Interrupt session",
name: "session.interrupt", name: "session.interrupt",
@@ -571,17 +515,11 @@ export function Prompt(props: PromptProps) {
commands: promptCommands(), commands: promptCommands(),
})) }))
Keymap.createLayer(() => ({
priority: 1,
bindings: ["prompt.queue"],
}))
Keymap.createLayer(() => ({ Keymap.createLayer(() => ({
bindings: [ bindings: [
"prompt.submit", "prompt.submit",
"prompt.editor", "prompt.editor",
"prompt.editor_context.clear", "prompt.editor_context.clear",
"prompt.images.view",
"prompt.stash", "prompt.stash",
"prompt.stash.pop", "prompt.stash.pop",
"prompt.stash.list", "prompt.stash.list",
@@ -635,7 +573,6 @@ export function Prompt(props: PromptProps) {
}) })
onCleanup(() => { onCleanup(() => {
disposed = true
if (store.prompt.text) { if (store.prompt.text) {
stashed = { prompt: unwrap(store.prompt), cursor: input.cursorOffset } stashed = { prompt: unwrap(store.prompt), cursor: input.cursorOffset }
} }
@@ -963,7 +900,7 @@ export function Prompt(props: PromptProps) {
}) })
let submitting = false let submitting = false
async function submit(delivery: SessionPending.Delivery = "steer") { async function submit() {
// Prevent overlapping invocations (e.g. a double-pressed Enter, or the // Prevent overlapping invocations (e.g. a double-pressed Enter, or the
// input's native onSubmit racing another dispatch). Without this guard, // input's native onSubmit racing another dispatch). Without this guard,
// a second call slips past the empty-input check before the first call // a second call slips past the empty-input check before the first call
@@ -973,13 +910,13 @@ export function Prompt(props: PromptProps) {
if (submitting) return false if (submitting) return false
submitting = true submitting = true
try { try {
return await submitInner(delivery) return await submitInner()
} finally { } finally {
submitting = false submitting = false
} }
} }
async function submitInner(delivery: SessionPending.Delivery) { async function submitInner() {
// IME: double-defer may fire before onContentChange flushes the last // IME: double-defer may fire before onContentChange flushes the last
// composed character (e.g. Korean hangul) to the store, so read // composed character (e.g. Korean hangul) to the store, so read
// plainText directly and sync before any downstream reads. // plainText directly and sync before any downstream reads.
@@ -990,25 +927,14 @@ export function Prompt(props: PromptProps) {
if (props.disabled) return false if (props.disabled) return false
if (move.creating()) return false if (move.creating()) return false
if (auto()?.visible) return false if (auto()?.visible) return false
if (!store.prompt.text) return false
const trimmed = store.prompt.text.trim() const trimmed = store.prompt.text.trim()
if (!trimmed) return delivery === "steer" ? (await props.onEmptySubmit?.()) === true : false
if (
delivery === "queue" &&
(store.mode === "shell" || trimmed === "exit" || trimmed === "quit" || trimmed === ":q")
) {
toast.show({ message: "This prompt cannot be queued", variant: "warning" })
return false
}
if (trimmed === "exit" || trimmed === "quit" || trimmed === ":q") { if (trimmed === "exit" || trimmed === "quit" || trimmed === ":q") {
void exit() void exit()
return true return true
} }
const slash = argumentSlash(store.prompt.text, keymapCommands()) const slash = argumentSlash(store.prompt.text, keymapCommands())
if (slash) { if (slash) {
if (delivery === "queue") {
toast.show({ message: "This prompt cannot be queued", variant: "warning" })
return false
}
clearPrompt() clearPrompt()
await slash.command.run(slash.input) await slash.command.run(slash.input)
return true return true
@@ -1032,16 +958,6 @@ export function Prompt(props: PromptProps) {
const isCommand = const isCommand =
slashHead !== undefined && slashHead !== undefined &&
(data.location.command.list(currentLocation.ref) ?? []).some((command) => command.name === slashHead.name) (data.location.command.list(currentLocation.ref) ?? []).some((command) => command.name === slashHead.name)
if (delivery === "queue" && isSkill) {
toast.show({ message: "Skills cannot be queued", variant: "warning" })
return false
}
const editorSelection = editorContext()
const pendingEditorSelection = editorSelection && editor.labelState() === "pending" ? editorSelection : undefined
if (delivery === "queue" && pendingEditorSelection) {
toast.show({ message: "Editor context cannot be queued", variant: "warning" })
return false
}
const agent = local.agent.current() const agent = local.agent.current()
if (!agent) return false if (!agent) return false
const selection = local.model.selection() const selection = local.model.selection()
@@ -1100,6 +1016,8 @@ export function Prompt(props: PromptProps) {
// Capture mode before it gets reset // Capture mode before it gets reset
const currentMode = store.mode const currentMode = store.mode
const editorSelection = editorContext()
const pendingEditorSelection = editorSelection && editor.labelState() === "pending" ? editorSelection : undefined
if (store.mode === "shell") { if (store.mode === "shell") {
move.startSubmit() move.startSubmit()
@@ -1122,7 +1040,6 @@ export function Prompt(props: PromptProps) {
model, model,
files: store.prompt.files, files: store.prompt.files,
agents: store.prompt.agents, agents: store.prompt.agents,
delivery,
}) })
.catch((error) => { .catch((error) => {
cancelCommit() cancelCommit()
@@ -1132,7 +1049,7 @@ export function Prompt(props: PromptProps) {
move.startSubmit() move.startSubmit()
void client.api.session.skill({ void client.api.session.skill({
sessionID, sessionID,
skill: slashHead.name, skill: slashHead!.name,
}) })
} else { } else {
move.startSubmit() move.startSubmit()
@@ -1188,7 +1105,6 @@ export function Prompt(props: PromptProps) {
text: inputText, text: inputText,
files: store.prompt.files, files: store.prompt.files,
agents: store.prompt.agents, agents: store.prompt.agents,
delivery,
}) })
.then( .then(
() => undefined, () => undefined,
@@ -1264,39 +1180,27 @@ export function Prompt(props: PromptProps) {
return true return true
} }
async function pasteInputText(text: string, changed: () => boolean) { async function pasteInputText(text: string) {
const normalizedText = text.replace(/\r\n/g, "\n").replace(/\r/g, "\n") const normalizedText = text.replace(/\r\n/g, "\n").replace(/\r/g, "\n")
const pastedContent = normalizedText.trim() const pastedContent = normalizedText.trim()
const filepath = normalizePastedFilepath(pastedContent, terminalEnvironment.platform) const filepath = pastedFilepath(pastedContent, terminalEnvironment.platform)
const isUrl = /^(https?):\/\//.test(filepath) const isUrl = /^(https?):\/\//.test(filepath)
if (!isUrl) { if (!isUrl) {
const attachment = await readLocalAttachment(filepath) const attachment = await readLocalAttachment(filepath)
if (attachment) { const filename = path.basename(filepath)
if (changed()) return if (attachment?.type === "text") {
pasteLocalAttachment(filepath, attachment) pasteText(attachment.content, `[SVG: ${filename ?? "image"}]`)
return return
} }
if (attachment?.type === "binary") {
const filepaths = parsePastedFilepaths(pastedContent, terminalEnvironment.platform) await pasteAttachment({
if (filepaths.length > 1) { filename,
let remaining = MAX_LOCAL_ATTACHMENT_BYTES uri: `data:${attachment.mime};base64,${Buffer.from(attachment.content).toString("base64")}`,
const attachments: Array<{ filepath: string; attachment: LocalAttachment }> = [] })
for (const candidate of filepaths) { return
const next = await readLocalAttachment(candidate, remaining)
if (!next) break
remaining -= typeof next.content === "string" ? Buffer.byteLength(next.content) : next.content.byteLength
attachments.push({ filepath: candidate, attachment: next })
}
if (attachments.length === filepaths.length) {
if (changed()) return
for (const item of attachments) pasteLocalAttachment(item.filepath, item.attachment)
return
}
} }
} }
if (changed()) return
const lineCount = (pastedContent.match(/\n/g)?.length ?? 0) + 1 const lineCount = (pastedContent.match(/\n/g)?.length ?? 0) + 1
if ((lineCount >= 3 || pastedContent.length > 150) && config.prompt?.paste !== "full") { if ((lineCount >= 3 || pastedContent.length > 150) && config.prompt?.paste !== "full") {
const extmark = input.extmarks.getAllForTypeId(promptPartTypeId).find((extmark) => { const extmark = input.extmarks.getAllForTypeId(promptPartTypeId).find((extmark) => {
@@ -1321,27 +1225,12 @@ export function Prompt(props: PromptProps) {
}, 0) }, 0)
} }
function pasteLocalAttachment(filepath: string, attachment: LocalAttachment) { async function pasteAttachment(file: { filename?: string; uri: string }) {
const filename = path.basename(filepath)
if (attachment.type === "text") {
pasteText(attachment.content, `[SVG: ${filename || "image"}]`)
return
}
pasteAttachment({
filename,
uri: `data:${attachment.mime};base64,${Buffer.from(attachment.content).toString("base64")}`,
})
}
function pasteAttachment(file: { filename?: string; uri: string }) {
const currentOffset = input.cursorOffset const currentOffset = input.cursorOffset
const extmarkStart = currentOffset const extmarkStart = currentOffset
const pdf = file.uri.startsWith("data:application/pdf;") const pdf = file.uri.startsWith("data:application/pdf;")
const count = pdf const prefix = pdf ? "data:application/pdf;" : "data:image/"
? (store.prompt.files?.filter( const count = store.prompt.files?.filter((attachment) => attachment.uri.startsWith(prefix)).length ?? 0
(attachment) => typeof attachment.uri === "string" && attachment.uri.startsWith("data:application/pdf;"),
).length ?? 0)
: imageAttachments().length
const virtualText = pdf ? `[PDF ${count + 1}]` : `[Image ${count + 1}]` const virtualText = pdf ? `[PDF ${count + 1}]` : `[Image ${count + 1}]`
const extmarkEnd = extmarkStart + virtualText.length const extmarkEnd = extmarkStart + virtualText.length
const textToInsert = virtualText + " " const textToInsert = virtualText + " "
@@ -1373,6 +1262,7 @@ export function Prompt(props: PromptProps) {
draft.extmarkToPart.set(extmarkId, { type: "file", index }) draft.extmarkToPart.set(extmarkId, { type: "file", index })
}), }),
) )
return
} }
function clearPrompt() { function clearPrompt() {
@@ -1496,74 +1386,6 @@ export function Prompt(props: PromptProps) {
flexGrow={1} flexGrow={1}
width="100%" width="100%"
> >
<Show when={config.prompt?.image_preview && visibleImageAttachments().length > 0}>
<box
width="100%"
height={imagePreviewHeight() + 1}
flexDirection="row"
flexShrink={0}
justifyContent="flex-start"
gap={1}
paddingBottom={1}
>
<For each={visibleImageAttachments()}>
{(file, index) => {
const [failed, setFailed] = createSignal(false)
return (
<box
width={imagePreviewWidth()}
height={imagePreviewHeight()}
flexBasis={imagePreviewWidth()}
flexShrink={1}
onMouseUp={(event: MouseEvent) => {
if (event.button !== 0) return
event.stopPropagation()
openImagePreview(index())
}}
>
<Show
when={!failed()}
fallback={
<box width="100%" height="100%" alignItems="center" justifyContent="center">
<text fg={theme.text.subdued}>No preview</text>
</box>
}
>
<image
id={`prompt-image-preview-${index()}`}
source={file.uri}
fit="cover"
protocol="auto"
width="100%"
height="100%"
onError={() => setFailed(true)}
/>
</Show>
</box>
)
}}
</For>
<Show when={imageAttachments().length > visibleImageAttachments().length}>
<box
width={8}
height={imagePreviewHeight()}
flexBasis={8}
flexShrink={1}
alignItems="center"
justifyContent="center"
onMouseUp={(event: MouseEvent) => {
if (event.button !== 0) return
event.stopPropagation()
openImagePreview(visibleImageAttachments().length)
}}
>
<text fg={theme.text.subdued} wrapMode="none" truncate>
+{imageAttachments().length - visibleImageAttachments().length} more
</text>
</box>
</Show>
</box>
</Show>
<textarea <textarea
width="100%" width="100%"
placeholder={placeholderText()} placeholder={placeholderText()}
@@ -1591,7 +1413,7 @@ export function Prompt(props: PromptProps) {
// hangul) is flushed to plainText before we read it for submission. // hangul) is flushed to plainText before we read it for submission.
setTimeout(() => setTimeout(() => submit(), 0), 0) setTimeout(() => setTimeout(() => submit(), 0), 0)
}} }}
onPaste={(event: PasteEvent) => { onPaste={async (event: PasteEvent) => {
if (props.disabled) { if (props.disabled) {
event.preventDefault() event.preventDefault()
return return
@@ -1601,10 +1423,11 @@ export function Prompt(props: PromptProps) {
// Windows ConPTY/Terminal often sends CR-only newlines in bracketed paste // Windows ConPTY/Terminal often sends CR-only newlines in bracketed paste
// Replace CRLF first, then any remaining CR // Replace CRLF first, then any remaining CR
const normalizedText = decodePasteBytes(event.bytes).replace(/\r\n/g, "\n").replace(/\r/g, "\n") const normalizedText = decodePasteBytes(event.bytes).replace(/\r\n/g, "\n").replace(/\r/g, "\n")
const pastedContent = normalizedText.trim()
// Windows Terminal <1.25 can surface image-only clipboard as an // Windows Terminal <1.25 can surface image-only clipboard as an
// empty bracketed paste. Windows Terminal 1.25+ does not. // empty bracketed paste. Windows Terminal 1.25+ does not.
if (event.bytes.byteLength === 0) { if (!pastedContent) {
keymap.dispatch("prompt.paste") keymap.dispatch("prompt.paste")
return return
} }
@@ -1613,7 +1436,7 @@ export function Prompt(props: PromptProps) {
// default paste unless we suppress it first and handle insertion ourselves. // default paste unless we suppress it first and handle insertion ourselves.
event.preventDefault() event.preventDefault()
void enqueuePaste((changed) => pasteInputText(normalizedText, changed)) await pasteInputText(normalizedText)
}} }}
ref={(r: TextareaRenderable) => { ref={(r: TextareaRenderable) => {
input = r input = r
@@ -1,13 +1,9 @@
import { readFile } from "node:fs/promises"
import path from "node:path" import path from "node:path"
import { fileURLToPath } from "node:url"
// Bound filesystem work per terminal paste; the byte budget also bounds staged data.
const MAX_PASTED_FILEPATHS = 32
export const MAX_LOCAL_ATTACHMENT_BYTES = 20 * 1024 * 1024
export type LocalFiles = Readonly<{ export type LocalFiles = Readonly<{
readText(path: string, maxBytes: number): Promise<string> readText(path: string): Promise<string>
readBytes(path: string, maxBytes: number): Promise<Uint8Array> readBytes(path: string): Promise<Uint8Array>
mime(path: string): Promise<string> mime(path: string): Promise<string>
}> }>
@@ -15,15 +11,14 @@ export type LocalAttachment =
| Readonly<{ type: "text"; mime: "image/svg+xml"; content: string }> | Readonly<{ type: "text"; mime: "image/svg+xml"; content: string }>
| Readonly<{ type: "binary"; mime: string; content: Uint8Array }> | Readonly<{ type: "binary"; mime: string; content: Uint8Array }>
export function readLocalAttachment(file: string, maxBytes = MAX_LOCAL_ATTACHMENT_BYTES) { export function readLocalAttachment(file: string) {
return readLocalAttachmentWith( return readLocalAttachmentWith(
{ {
readText: async (value, limit) => (await readFileBounded(value, limit)).toString("utf8"), readText: (value) => readFile(value, "utf8"),
readBytes: readFileBounded, readBytes: (value) => readFile(value),
mime: async (value) => mimeTypes[path.extname(value).toLowerCase()] ?? "application/octet-stream", mime: async (value) => mimeTypes[path.extname(value).toLowerCase()] ?? "application/octet-stream",
}, },
file, file,
maxBytes,
) )
} }
@@ -38,99 +33,16 @@ const mimeTypes: Record<string, string> = {
".webp": "image/webp", ".webp": "image/webp",
} }
async function readFileBounded(file: string, maxBytes: number) { export async function readLocalAttachmentWith(files: LocalFiles, path: string): Promise<LocalAttachment | undefined> {
const source = Bun.file(file)
if (!(await source.exists())) throw new Error("Attachment does not exist")
if (source.size > maxBytes) throw new Error("Attachment exceeds the local file limit")
const content = Buffer.from(await source.slice(0, maxBytes + 1).arrayBuffer())
if (content.byteLength > maxBytes) throw new Error("Attachment exceeds the local file limit")
return content
}
export function normalizePastedFilepath(value: string, platform: string) {
const raw = value.replace(/^['"]+|['"]+$/g, "")
const url = decodeFileURL(raw)
if (url) return url
if (platform === "win32") return raw
return raw.replace(/\\(.)/g, "$1")
}
function decodeFileURL(value: string): string | undefined {
if (!value.startsWith("file://")) return undefined
try {
return fileURLToPath(value)
} catch {
return undefined
}
}
export function parsePastedFilepaths(value: string, platform: string) {
const result: string[] = []
let current = ""
let quote = ""
function push() {
if (!current) return
result.push(decodeFileURL(current) ?? current)
current = ""
}
const input = value.includes("file://")
? value
.split(/\r?\n/)
.filter((line) => !line.trimStart().startsWith("#"))
.join("\n")
: value
for (let index = 0; index < input.length; index++) {
const character = input[index]
if (quote) {
if (character === quote) {
quote = ""
continue
}
if (character === "\\" && platform !== "win32" && quote === '"' && index + 1 < input.length) {
current += input[++index]
continue
}
current += character
continue
}
if (character === "'" || character === '"') {
quote = character
continue
}
if (character === "\\" && platform !== "win32" && index + 1 < input.length) {
current += input[++index]
continue
}
if (/\s/.test(character)) {
push()
if (result.length > MAX_PASTED_FILEPATHS) return []
continue
}
current += character
}
if (quote) return []
push()
if (result.length > MAX_PASTED_FILEPATHS) return []
return result
}
export async function readLocalAttachmentWith(
files: LocalFiles,
path: string,
maxBytes = MAX_LOCAL_ATTACHMENT_BYTES,
): Promise<LocalAttachment | undefined> {
const mime = await files.mime(path).catch(() => undefined) const mime = await files.mime(path).catch(() => undefined)
if (!mime) return undefined if (!mime) return
if (!mime.startsWith("image/") && mime !== "application/pdf") return undefined
if (mime === "image/svg+xml") { if (mime === "image/svg+xml") {
const content = await files.readText(path, maxBytes).catch(() => undefined) const content = await files.readText(path).catch(() => undefined)
if (!content || Buffer.byteLength(content) > maxBytes) return undefined if (!content) return
return { type: "text", mime, content } return { type: "text", mime, content }
} }
const content = await files.readBytes(path, maxBytes).catch(() => undefined) if (!mime.startsWith("image/") && mime !== "application/pdf") return
if (!content || content.byteLength > maxBytes) return undefined const content = await files.readBytes(path).catch(() => undefined)
if (!content) return
return { type: "binary", mime, content } return { type: "binary", mime, content }
} }
-6
View File
@@ -105,9 +105,6 @@ export const Info = Schema.Struct({
paste: Schema.optional(Schema.Literals(["compact", "full"])).annotate({ paste: Schema.optional(Schema.Literals(["compact", "full"])).annotate({
description: "Display large pastes as compact placeholders or full text", description: "Display large pastes as compact placeholders or full text",
}), }),
image_preview: Schema.optional(Schema.Boolean).annotate({
description: "Show image attachment previews above the prompt input",
}),
}), }),
).annotate({ description: "Prompt input behavior" }), ).annotate({ description: "Prompt input behavior" }),
session: Schema.optional( session: Schema.optional(
@@ -122,9 +119,6 @@ export const Info = Schema.Struct({
grouping: Schema.optional(Schema.Literals(["auto", "none"])).annotate({ grouping: Schema.optional(Schema.Literals(["auto", "none"])).annotate({
description: "Group related transcript items automatically or render each item separately", description: "Group related transcript items automatically or render each item separately",
}), }),
image_preview: Schema.optional(Schema.Boolean).annotate({
description: "Show user attachment and tool-result images in the session transcript",
}),
markdown: Schema.optional(Schema.Literals(["source", "rendered"])).annotate({ markdown: Schema.optional(Schema.Literals(["source", "rendered"])).annotate({
description: "Show Markdown syntax markers or conceal them in rendered transcript content", description: "Show Markdown syntax markers or conceal them in rendered transcript content",
}), }),
+2 -8
View File
@@ -103,8 +103,7 @@ export const Definitions = {
session_interrupt: keybind("escape", "Interrupt current session"), session_interrupt: keybind("escape", "Interrupt current session"),
session_background: keybind("ctrl+b", "Background blocking session tools"), session_background: keybind("ctrl+b", "Background blocking session tools"),
session_compact: keybind("<leader>c", "Compact the session"), session_compact: keybind("<leader>c", "Compact the session"),
session_queued_prompts: keybind("<leader>q", "View queued prompts"), session_queued_prompts: keybind("<leader>q", "View pending work"),
queued_prompt_delete: keybind("ctrl+d", "Delete queued prompt"),
session_child_first: keybind("down", "Toggle subagent picker"), session_child_first: keybind("down", "Toggle subagent picker"),
session_parent: keybind("up", "Go to parent session"), session_parent: keybind("up", "Go to parent session"),
session_pin_toggle: keybind("ctrl+f", "Pin or unpin session in the session list"), session_pin_toggle: keybind("ctrl+f", "Pin or unpin session in the session list"),
@@ -162,9 +161,7 @@ export const Definitions = {
display_thinking: keybind("none", "Toggle thinking blocks visibility"), display_thinking: keybind("none", "Toggle thinking blocks visibility"),
prompt_submit: keybind("none", "Submit prompt"), prompt_submit: keybind("none", "Submit prompt"),
prompt_queue: keybind("alt+return", "Queue prompt"),
prompt_editor_context_clear: keybind("none", "Clear editor context"), prompt_editor_context_clear: keybind("none", "Clear editor context"),
prompt_images_view: keybind("<leader>i", "View image attachments"),
prompt_skills: keybind("none", "Open skill selector"), prompt_skills: keybind("none", "Open skill selector"),
prompt_stash: keybind("none", "Stash prompt"), prompt_stash: keybind("none", "Stash prompt"),
prompt_stash_pop: keybind("none", "Pop stashed prompt"), prompt_stash_pop: keybind("none", "Pop stashed prompt"),
@@ -173,7 +170,7 @@ export const Definitions = {
input_clear: keybind("ctrl+c", "Clear input field"), input_clear: keybind("ctrl+c", "Clear input field"),
input_paste: keybind({ key: "ctrl+v", preventDefault: false }, "Paste from clipboard"), input_paste: keybind({ key: "ctrl+v", preventDefault: false }, "Paste from clipboard"),
input_submit: keybind("return", "Submit input"), input_submit: keybind("return", "Submit input"),
input_newline: keybind("shift+return,ctrl+return,ctrl+j", "Insert newline in input"), input_newline: keybind("shift+return,ctrl+return,alt+return,ctrl+j", "Insert newline in input"),
input_move_left: keybind("left,ctrl+b", "Move cursor left in input"), input_move_left: keybind("left,ctrl+b", "Move cursor left in input"),
input_move_right: keybind("right,ctrl+f", "Move cursor right in input"), input_move_right: keybind("right,ctrl+f", "Move cursor right in input"),
input_move_up: keybind("up", "Move cursor up in input"), input_move_up: keybind("up", "Move cursor up in input"),
@@ -308,7 +305,6 @@ export const CommandMap = {
session_background: "session.background", session_background: "session.background",
session_compact: "session.compact", session_compact: "session.compact",
session_queued_prompts: "session.queued_prompts", session_queued_prompts: "session.queued_prompts",
queued_prompt_delete: "queued_prompt.delete",
session_child_first: "session.child.first", session_child_first: "session.child.first",
session_parent: "session.parent", session_parent: "session.parent",
session_pin_toggle: "session.pin.toggle", session_pin_toggle: "session.pin.toggle",
@@ -363,9 +359,7 @@ export const CommandMap = {
messages_redo: "session.redo", messages_redo: "session.redo",
display_thinking: "session.toggle.thinking", display_thinking: "session.toggle.thinking",
prompt_submit: "prompt.submit", prompt_submit: "prompt.submit",
prompt_queue: "prompt.queue",
prompt_editor_context_clear: "prompt.editor_context.clear", prompt_editor_context_clear: "prompt.editor_context.clear",
prompt_images_view: "prompt.images.view",
prompt_skills: "prompt.skills", prompt_skills: "prompt.skills",
prompt_stash: "prompt.stash", prompt_stash: "prompt.stash",
prompt_stash_pop: "prompt.stash.pop", prompt_stash_pop: "prompt.stash.pop",
+8 -9
View File
@@ -1,19 +1,18 @@
import { createContext, type JSX, useContext } from "solid-js" import { createContext, type JSX, useContext } from "solid-js"
import { read, write } from "../clipboard"
export type ClipboardContent = Readonly<{ data: string; mime: string }> export type ClipboardContent = Readonly<{ data: string; mime: string }>
export type ClipboardService = Readonly<{ export type ClipboardService = Readonly<{
read(): Promise<ClipboardContent | undefined> read?(): Promise<ClipboardContent | undefined>
write(text: string): Promise<void> write?(text: string): Promise<void>
}> }>
const clipboard = { read, write }
const ClipboardContext = createContext<ClipboardService>(clipboard)
const ClipboardContext = createContext<ClipboardService>() export function ClipboardProvider(props: { value?: ClipboardService; children: JSX.Element }) {
return <ClipboardContext.Provider value={props.value ?? clipboard}>{props.children}</ClipboardContext.Provider>
export function ClipboardProvider(props: { value: ClipboardService; children: JSX.Element }) {
return <ClipboardContext.Provider value={props.value}>{props.children}</ClipboardContext.Provider>
} }
export function useClipboard() { export function useClipboard() {
const value = useContext(ClipboardContext) return useContext(ClipboardContext)
if (!value) throw new Error("useClipboard must be used within a ClipboardProvider")
return value
} }
+15 -49
View File
@@ -38,7 +38,6 @@ import { createStore, produce, reconcile } from "solid-js/store"
import { createSimpleContext } from "./helper" import { createSimpleContext } from "./helper"
import { useClient } from "./client" import { useClient } from "./client"
import { nonEmptyToolContent } from "../util/tool-display" import { nonEmptyToolContent } from "../util/tool-display"
import type { SessionPending } from "@opencode-ai/schema/session-pending"
import { createEffect, createSignal, onCleanup } from "solid-js" import { createEffect, createSignal, onCleanup } from "solid-js"
export type DataSessionStatus = "idle" | "running" export type DataSessionStatus = "idle" | "running"
@@ -171,20 +170,12 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
function removePending(sessionID: string, inputID?: string) { function removePending(sessionID: string, inputID?: string) {
if (!inputID) return if (!inputID) return
if (store.session.pending[sessionID]?.some((item) => item.id === inputID)) setStore(
setStore( "session",
"session", "pending",
"pending", sessionID,
sessionID, (store.session.pending[sessionID] ?? []).filter((item) => item.id !== inputID),
(store.session.pending[sessionID] ?? []).filter((item) => item.id !== inputID), )
)
if (store.session.input[sessionID]?.includes(inputID))
setStore(
"session",
"input",
sessionID,
(store.session.input[sessionID] ?? []).filter((id) => id !== inputID),
)
} }
function removePermission(sessionID: string, requestID: string) { function removePermission(sessionID: string, requestID: string) {
@@ -198,13 +189,6 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
) )
} }
function updatePending(sessionID: string, inputID: string, delivery: SessionPending.Delivery) {
const index = store.session.pending[sessionID]?.findIndex((item) => item.id === inputID) ?? -1
const item = store.session.pending[sessionID]?.[index]
if (index < 0 || !item || item.type === "compaction" || item.delivery === delivery) return
setStore("session", "pending", sessionID, index, { ...item, delivery })
}
const message = { const message = {
update(sessionID: string, fn: (messages: SessionMessageInfo[], index: Map<string, number>) => void) { update(sessionID: string, fn: (messages: SessionMessageInfo[], index: Map<string, number>) => void) {
setStore( setStore(
@@ -251,12 +235,6 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
(item): item is SessionMessageAssistantReasoning => item.type === "reasoning" && !item.time?.completed, (item): item is SessionMessageAssistantReasoning => item.type === "reasoning" && !item.time?.completed,
) )
}, },
reindex(messages: SessionMessageInfo[], index: Map<string, number>, start: number) {
for (let position = start; position < messages.length; position++) {
const item = messages[position]
if (item) index.set(item.id, position)
}
},
} }
function index(sessionID: string) { function index(sessionID: string) {
@@ -438,36 +416,24 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
} }
break break
case "session.input.promoted": { case "session.input.promoted": {
const admitted = store.session.input[event.data.sessionID]?.includes(event.data.inputID) ?? false
removePending(event.data.sessionID, event.data.inputID) removePending(event.data.sessionID, event.data.inputID)
message.update(event.data.sessionID, (draft, index) => { message.update(event.data.sessionID, (draft, index) => {
const position = index.get(event.data.inputID) const position = index.get(event.data.inputID)
if (position === undefined) return if (position === undefined) return
const existing = draft[position] const existing = draft[position]
if (!existing || !admitted) return if (!existing || !store.session.input[event.data.sessionID]?.includes(event.data.inputID)) return
existing.time.created = event.created existing.time.created = event.created
draft.splice(position, 1) draft.splice(position, 1)
draft.push(existing) draft.push(existing)
message.reindex(draft, index, position) index.clear()
draft.forEach((message, indexValue) => index.set(message.id, indexValue))
}) })
break setStore(
} "session",
case "session.input.steered": "input",
updatePending(event.data.sessionID, event.data.inputID, "steer") event.data.sessionID,
break (store.session.input[event.data.sessionID] ?? []).filter((id) => id !== event.data.inputID),
case "session.input.queued": )
updatePending(event.data.sessionID, event.data.inputID, "queue")
break
case "session.input.cancelled": {
removePending(event.data.sessionID, event.data.inputID)
if (messageIndex.get(event.data.sessionID)?.has(event.data.inputID))
message.update(event.data.sessionID, (draft, index) => {
const position = index.get(event.data.inputID)
if (position === undefined) return
draft.splice(position, 1)
index.delete(event.data.inputID)
message.reindex(draft, index, position)
})
break break
} }
case "session.input.admitted": case "session.input.admitted":
+6 -33
View File
@@ -3,9 +3,7 @@ import { TextAttributes, type InputRenderable, type KeyEvent } from "@opentui/co
import { useKeyboard, type JSX } from "@opentui/solid" import { useKeyboard, type JSX } from "@opentui/solid"
import fuzzysort from "fuzzysort" import fuzzysort from "fuzzysort"
import { createEffect, createMemo, createSignal, type Accessor } from "solid-js" import { createEffect, createMemo, createSignal, type Accessor } from "solid-js"
import { Keymap } from "../context/keymap"
import { RunFooterMenu, createFooterMenuState, type RunFooterMenuItem } from "./footer.menu" import { RunFooterMenu, createFooterMenuState, type RunFooterMenuItem } from "./footer.menu"
import { monoShortcut } from "./mono"
import type { RunFooterTheme } from "./theme" import type { RunFooterTheme } from "./theme"
import type { import type {
FooterQueuedPrompt, FooterQueuedPrompt,
@@ -58,10 +56,6 @@ type SkillEntry = PanelEntry & {
name: string name: string
} }
type QueuedPromptEntry = PanelEntry & {
prompt: FooterQueuedPrompt
}
type SubagentEntry = PanelEntry & { type SubagentEntry = PanelEntry & {
sessionID: string sessionID: string
current: boolean current: boolean
@@ -448,7 +442,7 @@ export function RunCommandMenuBody(props: {
{ {
action: "queued" as const, action: "queued" as const,
category: "Agent", category: "Agent",
display: "View queued prompts", display: "View pending work",
footer: `${props.queued().length} pending`, footer: `${props.queued().length} pending`,
keywords: props keywords: props
.queued() .queued()
@@ -843,48 +837,28 @@ export function RunQueuedPromptSelectBody(props: {
theme: Accessor<RunFooterTheme> theme: Accessor<RunFooterTheme>
prompts: Accessor<FooterQueuedPrompt[]> prompts: Accessor<FooterQueuedPrompt[]>
onClose: () => void onClose: () => void
onSteer: (prompt: FooterQueuedPrompt) => void
onDelete: (prompt: FooterQueuedPrompt) => void
onRows?: (rows: number) => void onRows?: (rows: number) => void
mono?: boolean mono?: boolean
}) { }) {
const entries = createMemo<QueuedPromptEntry[]>(() => const entries = createMemo(() =>
props.prompts().map((prompt) => ({ props.prompts().map((prompt) => ({
category: "", category: "",
display: prompt.prompt.text.replaceAll("\n", " "), display: prompt.prompt.text.replaceAll("\n", " "),
footer: "queued", footer: prompt.delivery,
keywords: prompt.prompt.text, keywords: prompt.prompt.text,
prompt,
})), })),
) )
const controller = createSearchablePanelController({ const controller = createSearchablePanelController({
entries, entries,
limit: SUBAGENT_LIST_ROWS, limit: SUBAGENT_LIST_ROWS,
onClose: props.onClose, onClose: props.onClose,
onSelect: (item) => props.onSteer(item.prompt), onSelect: props.onClose,
onRows: props.onRows, onRows: props.onRows,
}) })
const shortcuts = Keymap.useShortcuts()
const deleteShortcut = () => monoShortcut(shortcuts.get("queued_prompt.delete") ?? "", props.mono ?? false)
Keymap.createLayer(() => ({
priority: 1,
commands: [
{
id: "queued_prompt.delete",
title: "Delete queued prompt",
group: "Prompt",
run() {
const item = controller.items()[controller.menu.selected()]
if (!item) return false
props.onDelete(item.prompt)
},
},
],
}))
return ( return (
<PanelShell <PanelShell
title="Queued prompts" title="Pending work"
query={controller.query()} query={controller.query()}
count={controller.items().length} count={controller.items().length}
total={entries().length} total={entries().length}
@@ -892,7 +866,6 @@ export function RunQueuedPromptSelectBody(props: {
theme={props.theme} theme={props.theme}
inputRef={controller.inputRef} inputRef={controller.inputRef}
onQuery={controller.setQuery} onQuery={controller.setQuery}
hint={["enter steer", deleteShortcut() ? `${deleteShortcut()} delete` : undefined].filter(Boolean).join(" · ")}
mono={props.mono} mono={props.mono}
> >
<RunFooterMenu <RunFooterMenu
@@ -902,7 +875,7 @@ export function RunQueuedPromptSelectBody(props: {
offset={controller.menu.offset} offset={controller.menu.offset}
rows={controller.menu.rows} rows={controller.menu.rows}
limit={SUBAGENT_LIST_ROWS} limit={SUBAGENT_LIST_ROWS}
empty="No queued prompts" empty="No pending work"
border={false} border={false}
paddingLeft={panelPad(props.mono)} paddingLeft={panelPad(props.mono)}
paddingRight={panelPad(props.mono)} paddingRight={panelPad(props.mono)}
+13 -62
View File
@@ -19,7 +19,6 @@ import {
displayCharAt, displayCharAt,
displaySlice, displaySlice,
isExitCommand, isExitCommand,
isCompactCommand,
mentionTriggerIndex, mentionTriggerIndex,
isNewCommand, isNewCommand,
movePromptHistory, movePromptHistory,
@@ -32,16 +31,7 @@ import { realignEditorPromptParts, resolveEditorSlashValue } from "./prompt.edit
import { monoTruncateMiddle } from "./mono" import { monoTruncateMiddle } from "./mono"
import { FOOTER_MENU_ROWS, createFooterMenuState, type RunFooterMenuItem } from "./footer.menu" import { FOOTER_MENU_ROWS, createFooterMenuState, type RunFooterMenuItem } from "./footer.menu"
import type { RunFooterTheme } from "./theme" import type { RunFooterTheme } from "./theme"
import type { import type { FooterState, RunAgent, RunCommand, RunPrompt, RunPromptPart, RunReference } from "./types"
FooterQueuedPrompt,
FooterState,
RunAgent,
RunCommand,
RunDelivery,
RunPrompt,
RunPromptPart,
RunReference,
} from "./types"
const AUTOCOMPLETE_ROWS = FOOTER_MENU_ROWS const AUTOCOMPLETE_ROWS = FOOTER_MENU_ROWS
const AUTOCOMPLETE_BOTTOM_ROWS = 1 const AUTOCOMPLETE_BOTTOM_ROWS = 1
@@ -82,8 +72,6 @@ type PromptInput = {
theme: Accessor<RunFooterTheme> theme: Accessor<RunFooterTheme>
mono: Accessor<boolean> mono: Accessor<boolean>
history?: Accessor<RunPrompt[]> history?: Accessor<RunPrompt[]>
queuedPrompts: Accessor<FooterQueuedPrompt[]>
onQueuedPromptSteer: (inputID: string) => Promise<boolean>
onSubmit: (input: RunPrompt) => boolean | Promise<boolean> onSubmit: (input: RunPrompt) => boolean | Promise<boolean>
onCycle: () => void onCycle: () => void
onInterrupt: () => boolean onInterrupt: () => boolean
@@ -992,18 +980,8 @@ export function createPromptState(input: PromptInput): PromptState {
})) }))
Keymap.createLayer(() => ({ Keymap.createLayer(() => ({
priority: 1,
enabled: input.prompt() && !visible(), enabled: input.prompt() && !visible(),
commands: [ commands: [
{
id: "prompt.queue",
title: "Queue prompt",
group: "Prompt",
run() {
syncDraft()
submitPrompt(promptCopy(draft), "queue")
},
},
{ {
id: "prompt.editor", id: "prompt.editor",
title: "Open editor", title: "Open editor",
@@ -1138,8 +1116,7 @@ export function createPromptState(input: PromptInput): PromptState {
} }
} }
let submitting = false const submitPrompt = (next: RunPrompt) => {
const submitPrompt = (next: RunPrompt, delivery: RunDelivery = "steer") => {
if (!area || area.isDestroyed) { if (!area || area.isDestroyed) {
draft = promptCopy(next) draft = promptCopy(next)
} }
@@ -1153,34 +1130,12 @@ export function createPromptState(input: PromptInput): PromptState {
hide() hide()
} }
if (submitting) return
if (!next.text.trim()) { if (!next.text.trim()) {
const queued = delivery === "steer" ? input.queuedPrompts()[0] : undefined
if (queued) {
submitting = true
void input.onQueuedPromptSteer(queued.messageID).finally(() => {
submitting = false
})
return
}
input.onStatus(input.state().phase === "running" ? "waiting for current response" : "empty prompt ignored") input.onStatus(input.state().phase === "running" ? "waiting for current response" : "empty prompt ignored")
return return
} }
const command = next.mode === "shell" ? undefined : selectedCommand(next.text, next.command) const command = next.mode === "shell" ? undefined : selectedCommand(next.text, next.command)
if (
delivery === "queue" &&
(next.mode === "shell" ||
command?.source === "skill" ||
isNewCommand(next.text) ||
isCompactCommand(next.text) ||
isExitCommand(next.text) ||
next.text.trim().toLowerCase() === "/settings")
) {
input.onStatus("this prompt cannot be queued")
return
}
if (!command && next.mode !== "shell" && isExitCommand(next.text)) { if (!command && next.mode !== "shell" && isExitCommand(next.text)) {
input.onExit() input.onExit()
return return
@@ -1202,28 +1157,24 @@ export function createPromptState(input: PromptInput): PromptState {
} }
const submit = command const submit = command
? { ...next, command, delivery } ? { ...next, command }
: parsed?.type === "command" : parsed?.type === "command"
? { ...next, command: parsed.command, delivery } ? { ...next, command: parsed.command }
: { ...next, delivery } : next
const shellMode = next.mode === "shell" const shellMode = next.mode === "shell"
submitting = true
resetDraft() resetDraft()
queueMicrotask(async () => { queueMicrotask(async () => {
try { if (await input.onSubmit(submit)) {
if (await input.onSubmit(submit)) { push(next)
push(next) if (shellMode) {
if (shellMode) { setShellMode(false)
setShellMode(false) draft = emptyPrompt(false)
draft = emptyPrompt(false)
}
return
} }
restore(next) return
} finally {
submitting = false
} }
restore(next)
}) })
} }
-3
View File
@@ -51,7 +51,6 @@ import type {
MiniSettingChange, MiniSettingChange,
MiniSettings, MiniSettings,
PermissionReply, PermissionReply,
QueuedPromptAction,
RunAgent, RunAgent,
RunCommand, RunCommand,
RunInput, RunInput,
@@ -97,7 +96,6 @@ type RunFooterOptions = {
onVariantSelect?: (variant: string | undefined) => CycleResult | void | Promise<CycleResult | void> onVariantSelect?: (variant: string | undefined) => CycleResult | void | Promise<CycleResult | void>
onInterrupt?: () => void onInterrupt?: () => void
onBackground?: () => void onBackground?: () => void
onQueuedPromptAction?: (action: QueuedPromptAction, inputID: string) => Promise<void>
onEditorOpen: (input: { value: string }) => Promise<string | undefined> onEditorOpen: (input: { value: string }) => Promise<string | undefined>
onSubagentSelect?: (sessionID: string | undefined) => void onSubagentSelect?: (sessionID: string | undefined) => void
onSubagentInterrupt?: (sessionID: string) => void onSubagentInterrupt?: (sessionID: string) => void
@@ -345,7 +343,6 @@ export class RunFooter implements FooterApi {
onCycle: footer.handleCycle, onCycle: footer.handleCycle,
onInterrupt: footer.handleInterrupt, onInterrupt: footer.handleInterrupt,
onBackground: options.onBackground, onBackground: options.onBackground,
onQueuedPromptAction: options.onQueuedPromptAction,
onEditorOpen: options.onEditorOpen, onEditorOpen: options.onEditorOpen,
onInputClear: footer.handleInputClear, onInputClear: footer.handleInputClear,
onExitRequest: footer.handleExit, onExitRequest: footer.handleExit,
+11 -41
View File
@@ -34,8 +34,6 @@ import { Keymap } from "../context/keymap"
import { modelInfo } from "./variant.shared" import { modelInfo } from "./variant.shared"
import { monoShortcut } from "./mono" import { monoShortcut } from "./mono"
import { stringWidth } from "../util/string-width" import { stringWidth } from "../util/string-width"
import { errorMessage } from "../util/error"
import { createSingleFlight } from "../util/single-flight"
import type { import type {
FooterPromptRoute, FooterPromptRoute,
@@ -48,7 +46,6 @@ import type {
MiniSettingChange, MiniSettingChange,
MiniSettings, MiniSettings,
PermissionReply, PermissionReply,
QueuedPromptAction,
RunAgent, RunAgent,
RunCommand, RunCommand,
RunInput, RunInput,
@@ -95,14 +92,13 @@ type RunFooterViewProps = {
mono: boolean mono: boolean
miniSettings: () => MiniSettings miniSettings: () => MiniSettings
history?: () => RunPrompt[] history?: () => RunPrompt[]
onSubmit: (input: RunPrompt) => boolean | Promise<boolean> onSubmit: (input: RunPrompt) => boolean
onPermissionReply: (input: PermissionReply) => void | Promise<void> onPermissionReply: (input: PermissionReply) => void | Promise<void>
onFormReply: (input: FormReply) => void | Promise<void> onFormReply: (input: FormReply) => void | Promise<void>
onFormCancel: (input: FormCancel) => void | Promise<void> onFormCancel: (input: FormCancel) => void | Promise<void>
onCycle: () => void onCycle: () => void
onInterrupt: () => boolean onInterrupt: () => boolean
onBackground?: () => void onBackground?: () => void
onQueuedPromptAction?: (action: QueuedPromptAction, inputID: string) => Promise<void>
onEditorOpen: (input: { value: string }) => Promise<string | undefined> onEditorOpen: (input: { value: string }) => Promise<string | undefined>
onInputClear: () => void onInputClear: () => void
onExitRequest?: () => boolean onExitRequest?: () => boolean
@@ -136,7 +132,6 @@ export function RunFooterView(props: RunFooterViewProps) {
const [route, setRoute] = createSignal<FooterPromptRoute>({ type: "composer" }) const [route, setRoute] = createSignal<FooterPromptRoute>({ type: "composer" })
const [subagentMenuRows, setSubagentMenuRows] = createSignal(RUN_SUBAGENT_PANEL_ROWS) const [subagentMenuRows, setSubagentMenuRows] = createSignal(RUN_SUBAGENT_PANEL_ROWS)
const queuedPrompts = createMemo(() => props.queuedPrompts?.() ?? []) const queuedPrompts = createMemo(() => props.queuedPrompts?.() ?? [])
const queue = createMemo(() => queuedPrompts().filter((item) => item.delivery === "queue"))
const skills = createMemo(() => (props.commands() ?? []).filter((item) => item.source === "skill")) const skills = createMemo(() => (props.commands() ?? []).filter((item) => item.source === "skill"))
const prompt = createMemo(() => active().type === "prompt" && route().type === "composer") const prompt = createMemo(() => active().type === "prompt" && route().type === "composer")
const selectingSubagent = createMemo(() => active().type === "prompt" && route().type === "subagent-menu") const selectingSubagent = createMemo(() => active().type === "prompt" && route().type === "subagent-menu")
@@ -234,7 +229,7 @@ export function RunFooterView(props: RunFooterViewProps) {
const details = [busy() ? "running" : "idle", `agent ${props.currentAgent()}`] const details = [busy() ? "running" : "idle", `agent ${props.currentAgent()}`]
if (current) details.push(variant ? `${current} ${variant}` : current) if (current) details.push(variant ? `${current} ${variant}` : current)
if (usage()) details.push(props.mono ? usage().replaceAll(" · ", " - ") : usage()) if (usage()) details.push(props.mono ? usage().replaceAll(" · ", " - ") : usage())
if (queue().length > 0) details.push(`${queue().length} queued`) if (queuedPrompts().length > 0) details.push(`${queuedPrompts().length} pending`)
if (activeTabs().length > 0) details.push(`${activeTabs().length} subagent${activeTabs().length === 1 ? "" : "s"}`) if (activeTabs().length > 0) details.push(`${activeTabs().length} subagent${activeTabs().length === 1 ? "" : "s"}`)
return details.join(props.mono ? " - " : " · ") return details.join(props.mono ? " - " : " · ")
}) })
@@ -314,7 +309,7 @@ export function RunFooterView(props: RunFooterViewProps) {
} }
const openQueuedMenu = () => { const openQueuedMenu = () => {
if (queue().length === 0) return if (queuedPrompts().length === 0) return
setRoute({ type: "queued-menu" }) setRoute({ type: "queued-menu" })
props.onSubagentSelect?.(undefined) props.onSubagentSelect?.(undefined)
} }
@@ -323,22 +318,6 @@ export function RunFooterView(props: RunFooterViewProps) {
setRoute({ type: "composer" }) setRoute({ type: "composer" })
} }
const runQueuedAction = createSingleFlight<string>()
const queuedPromptAction = async (action: QueuedPromptAction, inputID: string) => {
const run = props.onQueuedPromptAction
if (!run) return false
const result = await runQueuedAction(inputID, async () => {
const error = await run(action, inputID).then(
() => undefined,
(error) => error,
)
if (!error) return true
props.onStatus(`failed to ${action === "cancel" ? "delete" : action} queued prompt: ${errorMessage(error)}`)
return false
})
return result ?? false
}
const openTab = (sessionID: string) => { const openTab = (sessionID: string) => {
setRoute({ type: "subagent", sessionID }) setRoute({ type: "subagent", sessionID })
props.onSubagentSelect?.(sessionID) props.onSubagentSelect?.(sessionID)
@@ -378,8 +357,6 @@ export function RunFooterView(props: RunFooterViewProps) {
theme, theme,
mono: () => props.mono, mono: () => props.mono,
history: props.history, history: props.history,
queuedPrompts: queue,
onQueuedPromptSteer: (inputID) => queuedPromptAction("steer", inputID),
onSubmit: props.onSubmit, onSubmit: props.onSubmit,
onCycle: props.onCycle, onCycle: props.onCycle,
onInterrupt: props.onInterrupt, onInterrupt: props.onInterrupt,
@@ -474,12 +451,13 @@ export function RunFooterView(props: RunFooterViewProps) {
if (foregroundSubagents() && backgroundShortcut()) { if (foregroundSubagents() && backgroundShortcut()) {
items.push({ key: backgroundShortcut(), label: "background" }) items.push({ key: backgroundShortcut(), label: "background" })
} }
if (queue().length > 0 && queuedShortcut()) { if (queuedPrompts().length > 0 && queuedShortcut()) {
items.push({ key: queuedShortcut(), label: `${queue().length} queued` }) items.push({ key: queuedShortcut(), label: `${queuedPrompts().length} pending` })
} }
if (activeTabs().length > 0 && subagentShortcut()) { if (activeTabs().length > 0 && subagentShortcut()) {
items.push({ key: subagentShortcut(), label: "subagents" }) items.push({ key: subagentShortcut(), label: "subagents" })
} }
return items return items
}) })
const commandHint = createMemo(() => { const commandHint = createMemo(() => {
@@ -590,11 +568,11 @@ export function RunFooterView(props: RunFooterViewProps) {
})) }))
Keymap.createLayer(() => ({ Keymap.createLayer(() => ({
enabled: active().type === "prompt" && route().type === "composer" && queue().length > 0, enabled: active().type === "prompt" && route().type === "composer" && queuedPrompts().length > 0,
commands: [ commands: [
{ {
id: "session.queued_prompts", id: "session.queued_prompts",
title: "View queued prompts", title: "View pending work",
group: "Session", group: "Session",
run: openQueuedMenu, run: openQueuedMenu,
}, },
@@ -652,7 +630,7 @@ export function RunFooterView(props: RunFooterViewProps) {
}) })
createEffect(() => { createEffect(() => {
if (route().type !== "queued-menu" || queue().length > 0) return if (route().type !== "queued-menu" || queuedPrompts().length > 0) return
closePanel() closePanel()
}) })
@@ -756,16 +734,8 @@ export function RunFooterView(props: RunFooterViewProps) {
<Match when={selectingQueued()}> <Match when={selectingQueued()}>
<RunQueuedPromptSelectBody <RunQueuedPromptSelectBody
theme={theme} theme={theme}
prompts={queue} prompts={queuedPrompts}
onClose={closePanel} onClose={closePanel}
onSteer={(item) => {
void queuedPromptAction("steer", item.messageID).then((steered) => {
if (steered) closePanel()
})
}}
onDelete={(item) => {
void queuedPromptAction("cancel", item.messageID)
}}
onRows={setSubagentMenuRows} onRows={setSubagentMenuRows}
mono={props.mono} mono={props.mono}
/> />
@@ -775,7 +745,7 @@ export function RunFooterView(props: RunFooterViewProps) {
theme={theme} theme={theme}
commands={props.commands} commands={props.commands}
subagents={tabs} subagents={tabs}
queued={queue} queued={queuedPrompts}
variants={props.variants} variants={props.variants}
variantCycle={variantCycle()} variantCycle={variantCycle()}
onClose={closePanel} onClose={closePanel}
@@ -22,7 +22,6 @@ import type {
MiniSettings, MiniSettings,
MiniHost, MiniHost,
PermissionReply, PermissionReply,
QueuedPromptAction,
RunAgent, RunAgent,
RunInput, RunInput,
RunPrompt, RunPrompt,
@@ -71,7 +70,6 @@ export type LifecycleInput = {
onVariantSelect?: (variant: string | undefined) => CycleResult | void | Promise<CycleResult | void> onVariantSelect?: (variant: string | undefined) => CycleResult | void | Promise<CycleResult | void>
onInterrupt?: () => void onInterrupt?: () => void
onBackground?: () => void onBackground?: () => void
onQueuedPromptAction?: (action: QueuedPromptAction, inputID: string) => Promise<void>
onSubagentSelect?: (sessionID: string | undefined) => void onSubagentSelect?: (sessionID: string | undefined) => void
onSubagentInterrupt?: (sessionID: string) => void onSubagentInterrupt?: (sessionID: string) => void
} }
@@ -245,7 +243,6 @@ export async function createRuntimeLifecycle(input: LifecycleInput): Promise<Lif
onVariantSelect: input.onVariantSelect, onVariantSelect: input.onVariantSelect,
onInterrupt: input.onInterrupt, onInterrupt: input.onInterrupt,
onBackground: input.onBackground, onBackground: input.onBackground,
onQueuedPromptAction: input.onQueuedPromptAction,
onEditorOpen: async ({ value }) => { onEditorOpen: async ({ value }) => {
if (closed || renderer.isDestroyed) { if (closed || renderer.isDestroyed) {
return return
+6 -7
View File
@@ -11,7 +11,7 @@
import { SessionMessage } from "@opencode-ai/schema/session-message" import { SessionMessage } from "@opencode-ai/schema/session-message"
import { Locale } from "../util/locale" import { Locale } from "../util/locale"
import { isCompactCommand, isExitCommand, isNewCommand } from "./prompt.shared" import { isCompactCommand, isExitCommand, isNewCommand } from "./prompt.shared"
import type { FooterApi, FooterEvent, RunDelivery, RunPrompt } from "./types" import type { FooterApi, FooterEvent, RunPrompt } from "./types"
type Trace = { type Trace = {
write(type: string, data?: unknown): void write(type: string, data?: unknown): void
@@ -21,11 +21,11 @@ export type QueueInput = {
footer: FooterApi footer: FooterApi
initialInput?: string initialInput?: string
trace?: Trace trace?: Trace
onSend?: (prompt: RunPrompt, delivery: RunDelivery) => void onSend?: (prompt: RunPrompt, delivery: "steer" | "queue") => void
onAdmissionError?: (prompt: RunPrompt, error: unknown) => void | Promise<void> onAdmissionError?: (prompt: RunPrompt, error: unknown) => void | Promise<void>
onNewSession?: () => void | Promise<void> onNewSession?: () => void | Promise<void>
onCompact?: () => void | Promise<void> onCompact?: () => void | Promise<void>
admit: (prompt: RunPrompt, delivery: RunDelivery, signal: AbortSignal) => Promise<void> admit: (prompt: RunPrompt, signal: AbortSignal) => Promise<void>
settle: () => Promise<void> settle: () => Promise<void>
run: (prompt: RunPrompt, signal: AbortSignal, admitted: () => void) => Promise<void> run: (prompt: RunPrompt, signal: AbortSignal, admitted: () => void) => Promise<void>
} }
@@ -183,7 +183,7 @@ export async function runPromptQueue(input: QueueInput): Promise<void> {
input.trace?.write("ui.commit", commit) input.trace?.write("ui.commit", commit)
input.footer.append(commit) input.footer.append(commit)
} }
input.onSend?.(sent, sent.delivery ?? "steer") input.onSend?.(sent, "steer")
if (state.closed) { if (state.closed) {
break break
@@ -276,11 +276,10 @@ export async function runPromptQueue(input: QueueInput): Promise<void> {
const sent = { ...prompt, messageID: SessionMessage.ID.create() } const sent = { ...prompt, messageID: SessionMessage.ID.create() }
const admission = state.admission const admission = state.admission
admissionVersion += 1 admissionVersion += 1
const delivery = prompt.delivery ?? "queue" input.onSend?.(sent, "queue")
input.onSend?.(sent, delivery)
admissions = admissions admissions = admissions
.then(() => admission) .then(() => admission)
.then(() => input.admit(sent, delivery, admissionController.signal)) .then(() => input.admit(sent, admissionController.signal))
.catch((error) => (state.closed ? undefined : input.onAdmissionError?.(sent, error))) .catch((error) => (state.closed ? undefined : input.onAdmissionError?.(sent, error)))
return return
} }
+11 -23
View File
@@ -390,15 +390,6 @@ async function runInteractiveRuntime(input: RunRuntimeInput, deps: RunRuntimeDep
log?.write("send.background", { sessionID: state.sessionID }) log?.write("send.background", { sessionID: state.sessionID })
void state.sdk.session.background({ sessionID: state.sessionID }).catch(() => {}) void state.sdk.session.background({ sessionID: state.sessionID }).catch(() => {})
}, },
onQueuedPromptAction: async (action, inputID) => {
if (!state.sessionID) return
log?.write(`send.pending.${action}`, { sessionID: state.sessionID, inputID })
if (action === "steer") {
await state.sdk.session.pending.steer({ sessionID: state.sessionID, inputID })
return
}
await state.sdk.session.pending.cancel({ sessionID: state.sessionID, inputID })
},
onSubagentInterrupt: (sessionID) => { onSubagentInterrupt: (sessionID) => {
log?.write("send.subagent.interrupt", { sessionID }) log?.write("send.subagent.interrupt", { sessionID })
void state.sdk.session.interrupt({ sessionID }).catch(() => {}) void state.sdk.session.interrupt({ sessionID }).catch(() => {})
@@ -901,7 +892,7 @@ async function runInteractiveRuntime(input: RunRuntimeInput, deps: RunRuntimeDep
trace: log, trace: log,
onSend: (prompt, delivery) => { onSend: (prompt, delivery) => {
state.shown = true state.shown = true
state.history.push({ ...prompt, delivery: undefined }) state.history.push(prompt)
if (prompt.mode !== "shell" && delivery === "steer") { if (prompt.mode !== "shell" && delivery === "steer") {
rememberLocal({ rememberLocal({
kind: "user", kind: "user",
@@ -912,21 +903,18 @@ async function runInteractiveRuntime(input: RunRuntimeInput, deps: RunRuntimeDep
}) })
} }
}, },
admit: async (prompt, delivery, signal) => { admit: async (prompt, signal) => {
await state.switching?.catch(() => {}) await state.switching?.catch(() => {})
const next = await ensureStream() const next = await ensureStream()
await next.handle.admitPromptTurn( await next.handle.queuePromptTurn({
{ agent: state.agent,
agent: state.agent, model: state.model,
model: state.model, variant: state.activeVariant,
variant: state.activeVariant, prompt,
prompt, files: input.files,
files: input.files, includeFiles: false,
includeFiles: false, signal,
signal, })
},
delivery,
)
}, },
onAdmissionError: renderPromptError, onAdmissionError: renderPromptError,
onCompact: async () => { onCompact: async () => {
@@ -653,10 +653,6 @@ export function createSubagentTracker(input: SubagentTrackerInput): SubagentTrac
} }
return return
} }
if (event.type === "session.input.cancelled") {
child.prompts.delete(event.data.inputID)
return
}
if (event.type === "session.step.started") { if (event.type === "session.step.started") {
touch(child, event.created) touch(child, event.created)
if (child.label === FALLBACK_LABEL && event.data.agent) child.label = Locale.titlecase(event.data.agent) if (child.label === FALLBACK_LABEL && event.data.agent) child.label = Locale.titlecase(event.data.agent)
+7 -48
View File
@@ -26,7 +26,6 @@ import type {
FooterQueuedPrompt, FooterQueuedPrompt,
RunFilePart, RunFilePart,
RunInput, RunInput,
RunDelivery,
RunPrompt, RunPrompt,
RunPromptPart, RunPromptPart,
StreamCommit, StreamCommit,
@@ -72,7 +71,7 @@ export type SessionResizeReplayInput = {
export type SessionTransport = { export type SessionTransport = {
runPromptTurn(input: SessionTurnInput, admitted?: () => void): Promise<void> runPromptTurn(input: SessionTurnInput, admitted?: () => void): Promise<void>
admitPromptTurn(input: SessionTurnInput, delivery: RunDelivery): Promise<void> queuePromptTurn(input: SessionTurnInput): Promise<void>
waitForIdle(): Promise<void> waitForIdle(): Promise<void>
interruptActiveTurn(): Promise<void> interruptActiveTurn(): Promise<void>
selectSubagent(sessionID: string | undefined): void selectSubagent(sessionID: string | undefined): void
@@ -516,12 +515,8 @@ export async function createSessionTransport(input: StreamInput): Promise<Sessio
) )
} }
let syncedPending: string[] | undefined
const syncPending = () => { const syncPending = () => {
const prompts = [...state.pending.values()].filter((item) => item.delivery === "queue") const prompts = [...state.pending.values()]
const ids = prompts.map((item) => item.messageID)
if (syncedPending?.length === ids.length && syncedPending.every((id, index) => id === ids[index])) return
syncedPending = ids
input.trace?.write("ui.patch", { pending: prompts.length }) input.trace?.write("ui.patch", { pending: prompts.length })
input.footer.event({ type: "queued.prompts", prompts }) input.footer.event({ type: "queued.prompts", prompts })
} }
@@ -939,36 +934,6 @@ export async function createSessionTransport(input: StreamInput): Promise<Sessio
write([], { phase: "running", status: "waiting for assistant" }) write([], { phase: "running", status: "waiting for assistant" })
return return
} }
if (event.type === "session.input.steered") {
const pending = state.pending.get(event.data.inputID)
if (!pending) return
state.pending.set(event.data.inputID, { ...pending, delivery: "steer" })
syncPending()
if (state.messageIDs.has(event.data.inputID)) return
state.messageIDs.add(event.data.inputID)
write([
{
kind: "user",
source: "system",
text: pending.prompt.text,
phase: "start",
messageID: event.data.inputID,
},
])
return
}
if (event.type === "session.input.queued") {
const pending = state.pending.get(event.data.inputID)
if (!pending) return
state.pending.set(event.data.inputID, { ...pending, delivery: "queue" })
syncPending()
return
}
if (event.type === "session.input.cancelled") {
state.admitted.delete(event.data.inputID)
if (state.pending.delete(event.data.inputID)) syncPending()
return
}
if (event.type === "session.step.started") { if (event.type === "session.step.started") {
state.stepModel = { providerID: event.data.model.providerID, modelID: event.data.model.id } state.stepModel = { providerID: event.data.model.providerID, modelID: event.data.model.id }
write([], { phase: "running", status: "assistant responding" }) write([], { phase: "running", status: "assistant responding" })
@@ -1612,7 +1577,7 @@ export async function createSessionTransport(input: StreamInput): Promise<Sessio
let queuedResizeReplay: SessionResizeReplayInput | undefined let queuedResizeReplay: SessionResizeReplayInput | undefined
let closing: Promise<void> | undefined let closing: Promise<void> | undefined
const admitPrompt = async (next: SessionTurnInput, client: OpenCodeClient, delivery: RunDelivery) => { const admitPrompt = async (next: SessionTurnInput, client: OpenCodeClient, delivery: "steer" | "queue") => {
const messageID = next.prompt.messageID const messageID = next.prompt.messageID
if (!messageID) throw new Error("Prompt message ID is required") if (!messageID) throw new Error("Prompt message ID is required")
const command = next.prompt.command const command = next.prompt.command
@@ -1678,20 +1643,14 @@ export async function createSessionTransport(input: StreamInput): Promise<Sessio
} }
return { return {
async admitPromptTurn(next, delivery) { async queuePromptTurn(next) {
if (next.prompt.mode === "shell" || next.prompt.command?.source === "skill") if (next.prompt.mode === "shell" || next.prompt.command?.source === "skill")
throw new Error("This prompt cannot be queued") throw new Error("This prompt cannot be queued")
if (!state.connected) throw new Error("Event stream is reconnecting") if (!state.connected) throw new Error("Event stream is reconnecting")
const client = sdk const client = sdk
if (next.agent) if (next.agent)
await client.session.switchAgent({ sessionID: input.sessionID, agent: next.agent }, { signal: next.signal }) await client.session.switchAgent({ sessionID: input.sessionID, agent: next.agent }, { signal: next.signal })
if (!next.prompt.command) { mergePending(await admitPrompt(next, client, "queue"))
const selected = await resolveSelectedModel(input, client, next)
if (next.variant && !selected) throw new Error("Cannot select a variant before selecting a model")
if (selected)
await client.session.switchModel({ sessionID: input.sessionID, model: selected }, { signal: next.signal })
}
mergePending(await admitPrompt(next, client, delivery))
settlementClient = client settlementClient = client
}, },
async waitForIdle() { async waitForIdle() {
@@ -1729,7 +1688,7 @@ export async function createSessionTransport(input: StreamInput): Promise<Sessio
return return
} }
if (command) { if (command) {
await runTurnWait(next, messageID, client, () => admitPrompt(next, client, next.prompt.delivery ?? "steer"), admitted) await runTurnWait(next, messageID, client, () => admitPrompt(next, client, "steer"), admitted)
return return
} }
@@ -1741,7 +1700,7 @@ export async function createSessionTransport(input: StreamInput): Promise<Sessio
if (selected) if (selected)
await client.session.switchModel({ sessionID: input.sessionID, model: selected }, { signal: next.signal }) await client.session.switchModel({ sessionID: input.sessionID, model: selected }, { signal: next.signal })
await runTurnWait(next, messageID, client, () => admitPrompt(next, client, next.prompt.delivery ?? "steer"), admitted) await runTurnWait(next, messageID, client, () => admitPrompt(next, client, "steer"), admitted)
}, },
async interruptActiveTurn() { async interruptActiveTurn() {
// A running shell holds no drain, so session.interrupt cannot reach it; // A running shell holds no drain, so session.interrupt cannot reach it;
+1 -7
View File
@@ -23,7 +23,6 @@ import type {
} from "@opencode-ai/client/promise" } from "@opencode-ai/client/promise"
import type { Config } from "../config" import type { Config } from "../config"
import type { CliRenderer } from "@opentui/core" import type { CliRenderer } from "@opentui/core"
import type { SessionPending } from "@opencode-ai/schema/session-pending"
export type RunFilePart = { export type RunFilePart = {
type: "file" type: "file"
@@ -72,13 +71,10 @@ export type RunProvider = {
models: Record<string, RunProviderModel> models: Record<string, RunProviderModel>
} }
export type RunDelivery = SessionPending.Delivery
export type RunPrompt = { export type RunPrompt = {
messageID?: string messageID?: string
text: string text: string
parts: RunPromptPart[] parts: RunPromptPart[]
delivery?: RunDelivery
mode?: "shell" mode?: "shell"
command?: { command?: {
name: string name: string
@@ -91,11 +87,9 @@ export type RunPrompt = {
export type FooterQueuedPrompt = { export type FooterQueuedPrompt = {
messageID: string messageID: string
prompt: RunPrompt prompt: RunPrompt
delivery: RunDelivery delivery: "steer" | "queue"
} }
export type QueuedPromptAction = "steer" | "cancel"
export type RunAgent = { export type RunAgent = {
id: string id: string
name: string name: string
@@ -66,12 +66,8 @@ export function DialogMessage(props: {
: "text" in value : "text" in value
? value.text ? value.text
: "" : ""
try { await clipboard.write?.(text)
await clipboard.write(text) dialog.clear()
dialog.clear()
} catch (error) {
toast.error(error)
}
}, },
}, },
{ {
+2 -2
View File
@@ -364,7 +364,7 @@ export function FormPrompt(props: { form: FormWithLocation }) {
function copyExternal() { function copyExternal() {
const current = externalField() const current = externalField()
if (!current) return if (!current || !clipboard.write) return
void clipboard void clipboard
.write(current.url) .write(current.url)
.then(() => { .then(() => {
@@ -992,7 +992,7 @@ export function FormPrompt(props: { form: FormWithLocation }) {
> >
enter <span style={{ fg: theme.text.subdued }}>{actionLabel()}</span> enter <span style={{ fg: theme.text.subdued }}>{actionLabel()}</span>
</text> </text>
<Show when={externalField()}> <Show when={externalField() && clipboard.write}>
<text fg={theme.text.default} onMouseUp={copyExternal}> <text fg={theme.text.default} onMouseUp={copyExternal}>
c <span style={{ fg: theme.text.subdued }}>copy</span> c <span style={{ fg: theme.text.subdued }}>copy</span>
</text> </text>
+11 -218
View File
@@ -24,7 +24,7 @@ import { useTuiPaths, useTuiTerminalEnvironment } from "../../context/runtime"
import { Spinner, SPINNER_FRAMES } from "../../component/spinner" import { Spinner, SPINNER_FRAMES } from "../../component/spinner"
import { PatchDiff } from "../../component/patch-diff" import { PatchDiff } from "../../component/patch-diff"
import { ThemeContextProvider, useTheme, useThemes } from "../../context/theme" import { ThemeContextProvider, useTheme, useThemes } from "../../context/theme"
import { BoxRenderable, ScrollBoxRenderable, addDefaultParsers, TextAttributes, RGBA, MouseEvent } from "@opentui/core" import { BoxRenderable, ScrollBoxRenderable, addDefaultParsers, TextAttributes, RGBA } from "@opentui/core"
import { Prompt, type PromptRef } from "../../component/prompt" import { Prompt, type PromptRef } from "../../component/prompt"
import type { import type {
ModelInfo, ModelInfo,
@@ -52,9 +52,7 @@ import { useClient } from "../../context/client"
import { useEditorContext } from "../../context/editor" import { useEditorContext } from "../../context/editor"
import { openEditor } from "../../editor" import { openEditor } from "../../editor"
import { useDialog } from "../../ui/dialog" import { useDialog } from "../../ui/dialog"
import { DialogSelect } from "../../ui/dialog-select"
import { DialogSessionRename } from "../../component/dialog-session-rename" import { DialogSessionRename } from "../../component/dialog-session-rename"
import { DialogImagePreview } from "../../component/dialog-image-preview"
import { DialogMessage } from "./dialog-message" import { DialogMessage } from "./dialog-message"
import { DialogFork } from "./dialog-fork" import { DialogFork } from "./dialog-fork"
import { DialogTimeline } from "./dialog-timeline" import { DialogTimeline } from "./dialog-timeline"
@@ -99,8 +97,6 @@ import { stringWidth } from "../../util/string-width"
import { useArgs } from "../../context/args" import { useArgs } from "../../context/args"
import { withTimestampedFallback } from "@opencode-ai/util/session-title-fallback" import { withTimestampedFallback } from "@opencode-ai/util/session-title-fallback"
import { useSessionTabs } from "../../context/session-tabs" import { useSessionTabs } from "../../context/session-tabs"
import { createSingleFlight } from "../../util/single-flight"
import type { SessionPending } from "@opencode-ai/schema/session-pending"
addDefaultParsers(parsers.parsers) addDefaultParsers(parsers.parsers)
@@ -113,7 +109,6 @@ const NAVIGATION_SLACK_ID = "session-navigation-slack"
const TRANSCRIPT_TAIL_ROWS = 40 const TRANSCRIPT_TAIL_ROWS = 40
const TRANSCRIPT_BACKFILL_CHUNK = 60 const TRANSCRIPT_BACKFILL_CHUNK = 60
const TRANSCRIPT_BACKFILL_DELAY = 120 const TRANSCRIPT_BACKFILL_DELAY = 120
type PendingAction = "steer" | "queue" | "cancel"
const context = createContext<{ const context = createContext<{
width: number width: number
@@ -125,8 +120,6 @@ const context = createContext<{
diffWrapMode: () => "word" | "none" diffWrapMode: () => "word" | "none"
models: () => ModelInfo[] models: () => ModelInfo[]
config: ReturnType<typeof useConfig>["data"] config: ReturnType<typeof useConfig>["data"]
mutatePending: (action: PendingAction, inputID: string) => Promise<boolean>
pendingDelivery: (inputID: string) => SessionPending.Delivery | undefined
}>() }>()
function use() { function use() {
@@ -182,13 +175,6 @@ export function Session() {
.flatMap((sessionID) => data.session.form.list(sessionID) ?? []) .flatMap((sessionID) => data.session.form.list(sessionID) ?? [])
.concat(global) .concat(global)
}) })
const pendingUsers = createMemo(() =>
data.session.pending.list(route.sessionID).flatMap((item) => (item.type === "user" ? [item] : [])),
)
const pendingDeliveries = createMemo(() => new Map(pendingUsers().map((item) => [item.id, item.delivery])))
const queuedPrompts = createMemo(() =>
pendingUsers().flatMap((item) => (item.delivery === "queue" ? [{ id: item.id, text: item.data.text }] : [])),
)
const [composer, setComposer] = createStore({ const [composer, setComposer] = createStore({
open: false, open: false,
tab: undefined as string | undefined, tab: undefined as string | undefined,
@@ -383,55 +369,6 @@ export function Session() {
}) })
const dialog = useDialog() const dialog = useDialog()
const renderer = useRenderer() const renderer = useRenderer()
const runPendingAction = createSingleFlight<string>()
const mutatePending = async (action: PendingAction, inputID: string) => {
const result = await runPendingAction(inputID, async () => {
const request =
action === "steer"
? client.api.session.pending.steer({ sessionID: route.sessionID, inputID })
: action === "queue"
? client.api.session.pending.queue({ sessionID: route.sessionID, inputID })
: client.api.session.pending.cancel({ sessionID: route.sessionID, inputID })
const error = await request.then(
() => undefined,
(error) => error,
)
if (!error) return true
const label = action === "cancel" ? "delete" : action
toast.show({ title: `Failed to ${label} pending prompt`, message: errorMessage(error), variant: "error" })
return false
})
return result ?? false
}
const openQueuedPrompts = () =>
dialog.replace(() => (
<DialogSelect
title="Queued prompts"
options={queuedPrompts().map((prompt, index) => ({
title: prompt.text,
value: prompt.id,
footer: `${index + 1} of ${queuedPrompts().length}`,
}))}
onSelect={(option) => {
void mutatePending("steer", option.value).then((steered) => {
if (steered) dialog.clear()
})
}}
actions={[
{
command: "queued_prompt.delete",
title: "delete",
onTrigger: (option) => {
const last = queuedPrompts().length === 1
void mutatePending("cancel", option.value).then((cancelled) => {
if (cancelled && last) dialog.clear()
})
},
},
]}
footerHints={[{ title: "steer", label: "enter" }]}
/>
))
const unavailable = (feature: string) => { const unavailable = (feature: string) => {
toast.show({ message: `${feature} is not implemented for V2 sessions yet`, variant: "error", duration: 5000 }) toast.show({ message: `${feature} is not implemented for V2 sessions yet`, variant: "error", duration: 5000 })
dialog.clear() dialog.clear()
@@ -844,7 +781,7 @@ export function Session() {
} }
clipboard clipboard
.write(text) .write?.(text)
.then(() => toast.show({ message: "Message copied to clipboard!", variant: "success" })) .then(() => toast.show({ message: "Message copied to clipboard!", variant: "success" }))
.catch(() => toast.show({ message: "Failed to copy to clipboard", variant: "error" })) .catch(() => toast.show({ message: "Failed to copy to clipboard", variant: "error" }))
dialog.clear() dialog.clear()
@@ -862,7 +799,7 @@ export function Session() {
const sessionData = session() const sessionData = session()
if (!sessionData) return if (!sessionData) return
const transcript = formatSessionTranscript(sessionData, messages(), showThinking()) const transcript = formatSessionTranscript(sessionData, messages(), showThinking())
await clipboard.write(transcript) await clipboard.write?.(transcript)
toast.show({ message: "Session transcript copied to clipboard!", variant: "success" }) toast.show({ message: "Session transcript copied to clipboard!", variant: "success" })
} catch { } catch {
toast.show({ message: "Failed to copy session transcript", variant: "error" }) toast.show({ message: "Failed to copy session transcript", variant: "error" })
@@ -896,7 +833,7 @@ export function Session() {
) + EOL ) + EOL
if (options.action === "copy") { if (options.action === "copy") {
await clipboard.write(content) await clipboard.write?.(content)
dialog.clear() dialog.clear()
toast.show({ message: "Copied to clipboard", variant: "success" }) toast.show({ message: "Copied to clipboard", variant: "success" })
return return
@@ -934,13 +871,6 @@ export function Session() {
dialog.clear() dialog.clear()
}, },
}, },
{
title: "View queued prompts",
id: "session.queued_prompts",
group: "Session",
enabled: queuedPrompts().length > 0,
run: openQueuedPrompts,
},
{ {
title: "Go to parent session", title: "Go to parent session",
id: "session.parent", id: "session.parent",
@@ -1012,8 +942,6 @@ export function Session() {
diffWrapMode, diffWrapMode,
models, models,
config, config,
mutatePending,
pendingDelivery: (inputID) => pendingDeliveries().get(inputID),
}} }}
> >
<box flexDirection="row" flexGrow={1} minHeight={0}> <box flexDirection="row" flexGrow={1} minHeight={0}>
@@ -1069,9 +997,6 @@ export function Session() {
</Show> </Show>
</scrollbox> </scrollbox>
<box flexShrink={0}> <box flexShrink={0}>
<Show when={!composer.open && !disabled() && queuedPrompts().length > 0}>
<QueuedPromptDock prompts={queuedPrompts()} onOpen={openQueuedPrompts} />
</Show>
<PluginSlot name="session.composer.top" input={{ sessionID: route.sessionID }} mode="all" /> <PluginSlot name="session.composer.top" input={{ sessionID: route.sessionID }} mode="all" />
<Composer <Composer
sessionID={route.sessionID} sessionID={route.sessionID}
@@ -1107,11 +1032,6 @@ export function Session() {
onSubmit={() => { onSubmit={() => {
toBottom() toBottom()
}} }}
onEmptySubmit={async () => {
const next = queuedPrompts()[0]
if (!next) return false
return mutatePending("steer", next.id)
}}
sessionID={route.sessionID} sessionID={route.sessionID}
/> />
</Match> </Match>
@@ -1604,9 +1524,8 @@ function SessionGroupView(props: {
</InlineToolRow> </InlineToolRow>
</Show> </Show>
<Show when={expanded() && grouped().length > 0}> <Show when={expanded() && grouped().length > 0}>
<For each={grouped()}>{(part) => <ToolPart part={part} images={false} />}</For> <For each={grouped()}>{(part) => <ToolPart part={part} />}</For>
</Show> </Show>
<ToolImages parts={grouped()} />
<For each={pending()}>{(part) => <ToolPart part={part} />}</For> <For each={pending()}>{(part) => <ToolPart part={part} />}</For>
</Show> </Show>
</Show> </Show>
@@ -1894,7 +1813,6 @@ function ShellMessage(props: { message: Extract<SessionMessageInfo, { type: "she
return ( return (
<box <box
width="100%"
border={["left"]} border={["left"]}
paddingTop={1} paddingTop={1}
paddingBottom={1} paddingBottom={1}
@@ -1917,33 +1835,25 @@ function UserMessage(props: { message: SessionMessageUser }) {
const data = useData() const data = useData()
const local = useLocal() const local = useLocal()
const files = createMemo(() => props.message.files ?? []) const files = createMemo(() => props.message.files ?? [])
const images = createMemo(() =>
files().flatMap((file) =>
file.mime.startsWith("image/") ? [{ uri: `data:${file.mime};base64,${file.data}` }] : [],
),
)
const themes = useThemes() const themes = useThemes()
const theme = useTheme("elevated") const theme = useTheme("elevated")
const mode = themes.mode const mode = themes.mode
const [hover, setHover] = createSignal(false) const [hover, setHover] = createSignal(false)
const color = createMemo(() => local.agent.color(data.session.get(ctx.sessionID)?.agent ?? "build")) const color = createMemo(() => local.agent.color(data.session.get(ctx.sessionID)?.agent ?? "build"))
const delivery = createMemo(() => ctx.pendingDelivery(props.message.id)) const queued = createMemo(
() => data.session.status(ctx.sessionID) === "running" && data.session.input.has(ctx.sessionID, props.message.id),
)
const dialog = useDialog() const dialog = useDialog()
const renderer = useRenderer() const renderer = useRenderer()
const promptRef = usePromptRef() const promptRef = usePromptRef()
const updatePendingSteer = async (action: "queue" | "cancel") => {
if (await ctx.mutatePending(action, props.message.id)) dialog.clear()
}
return ( return (
<Show when={props.message.text.trim() || files().length}> <Show when={props.message.text.trim() || files().length}>
<box <box
border={["left"]} border={["left"]}
borderColor={delivery() ? theme.border.default : color()} borderColor={queued() ? theme.border.default : color()}
customBorderChars={SplitBorder.customBorderChars} customBorderChars={SplitBorder.customBorderChars}
> >
<SessionImages images={images()} />
<box <box
onMouseOver={() => { onMouseOver={() => {
setHover(true) setHover(true)
@@ -1953,21 +1863,6 @@ function UserMessage(props: { message: SessionMessageUser }) {
}} }}
onMouseUp={() => { onMouseUp={() => {
if (renderer.getSelection()?.getSelectedText()) return if (renderer.getSelection()?.getSelectedText()) return
if (delivery() === "steer") {
dialog.replace(() => (
<DialogSelect
title="Pending steer"
options={[
{ title: "Move to queue", value: "queue" as const },
{ title: "Delete", value: "cancel" as const },
]}
onSelect={(option) => {
void updatePendingSteer(option.value)
}}
/>
))
return
}
dialog.replace(() => ( dialog.replace(() => (
<DialogMessage <DialogMessage
messageID={props.message.id} messageID={props.message.id}
@@ -2015,35 +1910,6 @@ function UserMessage(props: { message: SessionMessageUser }) {
) )
} }
function QueuedPromptDock(props: { prompts: { id: string; text: string }[]; onOpen: () => void }) {
const theme = useTheme("elevated")
const next = createMemo(() => props.prompts[0]?.text)
return (
<box
border={["left"]}
borderColor={theme.border.default}
customBorderChars={SplitBorder.customBorderChars}
onMouseUp={props.onOpen}
>
<box
width="100%"
paddingTop={1}
paddingBottom={1}
paddingLeft={2}
paddingRight={1}
backgroundColor={theme.background.default}
flexDirection="row"
>
<text fg={theme.text.subdued} wrapMode="none" truncate flexGrow={1} flexShrink={1} minWidth={0}>
<span style={{ fg: theme.text.default }}>{props.prompts.length} queued</span>
<Show when={next()}>{(text) => <> · {text()}</>}</Show>
</text>
</box>
</box>
)
}
function AssistantRetry(props: { retry: SessionMessageAssistant["retry"] }) { function AssistantRetry(props: { retry: SessionMessageAssistant["retry"] }) {
const theme = useTheme() const theme = useTheme()
return ( return (
@@ -2211,7 +2077,7 @@ function TextPart(props: { last: boolean; part: SessionMessageAssistantText }) {
// Pending messages moved to individual tool pending functions // Pending messages moved to individual tool pending functions
function ToolPart(props: { part: SessionMessageAssistantTool; images?: boolean }) { function ToolPart(props: { part: SessionMessageAssistantTool }) {
const display = createMemo(() => toolDisplay(props.part.name)) const display = createMemo(() => toolDisplay(props.part.name))
const toolprops = { const toolprops = {
@@ -2235,7 +2101,7 @@ function ToolPart(props: { part: SessionMessageAssistantTool; images?: boolean }
}, },
} }
const content = ( return (
<Switch> <Switch>
<Match when={display() === "shell"}> <Match when={display() === "shell"}>
<Shell {...toolprops} /> <Shell {...toolprops} />
@@ -2281,79 +2147,6 @@ function ToolPart(props: { part: SessionMessageAssistantTool; images?: boolean }
</Match> </Match>
</Switch> </Switch>
) )
return [
content,
<Show when={props.images !== false}>
<ToolImages parts={[props.part]} />
</Show>,
]
}
function ToolImages(props: { parts: readonly SessionMessageAssistantTool[] }) {
const images = createMemo(() => props.parts.flatMap(inlineToolImages))
return <SessionImages images={images()} />
}
function SessionImages(props: { images: readonly { uri: string }[] }) {
const ctx = use()
const dialog = useDialog()
const dimensions = useTerminalDimensions()
const images = createMemo(() => (ctx.config.session?.image_preview ? props.images : []))
const height = createMemo(() => Math.max(4, Math.min(8, Math.floor(dimensions().height / 4))))
const visible = createMemo(() => images().slice(0, 3))
return (
<Show when={visible().length > 0}>
<box flexDirection="row" flexShrink={0} paddingTop={1} paddingLeft={2} paddingRight={2} paddingBottom={1} gap={1}>
<For each={visible()}>
{(image, index) => {
const [failed, setFailed] = createSignal(false)
return (
<box
width={height() * 2}
height={height()}
flexBasis={height() * 2}
flexShrink={1}
alignItems="center"
justifyContent="center"
onMouseUp={(event: MouseEvent) => {
if (event.button !== 0) return
event.stopPropagation()
dialog.replace(() => <DialogImagePreview images={images()} initial={index()} />)
}}
>
<Show when={!failed()} fallback={<text>No preview</text>}>
<image
source={image.uri}
fit="cover"
protocol="auto"
width="100%"
height="100%"
onError={() => setFailed(true)}
/>
</Show>
</box>
)
}}
</For>
<Show when={images().length > visible().length}>
<box width={8} height={height()} flexShrink={1} alignItems="center" justifyContent="center">
<text wrapMode="none" truncate>
+{images().length - visible().length} more
</text>
</box>
</Show>
</box>
</Show>
)
}
function inlineToolImages(part: SessionMessageAssistantTool) {
return toolDisplayContent(part.state).flatMap((content) =>
content.type === "file" && content.mime.startsWith("image/") && content.uri.startsWith("data:image/")
? [{ uri: content.uri }]
: [],
)
} }
type ToolProps = { type ToolProps = {
+8 -15
View File
@@ -46,14 +46,9 @@ export function createSessionRows(sessionID: Accessor<string>) {
function reduce() { function reduce() {
const messages = data.session.message.list(sessionID()) const messages = data.session.message.list(sessionID())
const inputs = new Set(data.session.input.list(sessionID())) const inputs = new Set(data.session.input.list(sessionID()))
const pending = data.session.pending.list(sessionID())
const queued = new Set(
pending.flatMap((item) => (item.type === "user" && item.delivery === "queue" ? [item.id] : [])),
)
const visible = queued.size === 0 ? messages : messages.filter((message) => !queued.has(message.id))
const boundary = revertBoundary() const boundary = revertBoundary()
const rows = reduceSessionRows( const rows = reduceSessionRows(
boundary ? visible.filter((message) => message.id < boundary) : visible, boundary ? messages.filter((message) => message.id < boundary) : messages,
inputs, inputs,
turnTokens(), turnTokens(),
) )
@@ -62,7 +57,8 @@ export function createSessionRows(sessionID: Accessor<string>) {
rows.splice( rows.splice(
position === -1 ? rows.length : position, position === -1 ? rows.length : position,
0, 0,
...pending ...data.session.pending
.list(sessionID())
.filter((item) => item.type === "compaction") .filter((item) => item.type === "compaction")
.map((item): SessionRow => ({ type: "compaction-queued", inputID: item.id })), .map((item): SessionRow => ({ type: "compaction-queued", inputID: item.id })),
) )
@@ -116,11 +112,10 @@ export function createSessionRows(sessionID: Accessor<string>) {
createEffect( createEffect(
on( on(
() => () =>
data.session.pending.list(sessionID()).flatMap((item) => { data.session.pending
if (item.type === "compaction") return [`${item.id}:compaction`] .list(sessionID())
if (item.type === "user" && item.delivery === "queue") return [`${item.id}:queue`] .filter((item) => item.type === "compaction")
return [] .map((item) => item.id),
}),
() => setRows(reconcile(reduce())), () => setRows(reconcile(reduce())),
{ defer: true }, { defer: true },
), ),
@@ -201,9 +196,7 @@ export function createSessionRows(sessionID: Accessor<string>) {
const queuedStart = (rows: SessionRow[]) => { const queuedStart = (rows: SessionRow[]) => {
const index = rows.findIndex( const index = rows.findIndex(
(row) => (row) => row.type === "compaction-queued" || (row.type === "message" && isPending(row.messageID)),
row.type === "compaction-queued" ||
(row.type === "message" && isPending(row.messageID)),
) )
return index === -1 ? rows.length : index return index === -1 ? rows.length : index
} }
+1 -1
View File
@@ -204,7 +204,7 @@ export function DialogProvider(props: ParentProps) {
function copySelection() { function copySelection() {
const text = renderer.getSelection()?.getSelectedText() const text = renderer.getSelection()?.getSelectedText()
if (!text) return false if (!text || !clipboard.write) return false
void clipboard.write(text).then( void clipboard.write(text).then(
() => toast.show({ message: "Copied to clipboard", variant: "info" }), () => toast.show({ message: "Copied to clipboard", variant: "info" }),
(error) => toast.error(error), (error) => toast.error(error),
+1 -1
View File
@@ -35,7 +35,7 @@ export function copy(renderer: Renderer, toast: Toast, clipboard: ClipboardServi
focus?.getClipboardText && selection.selectedRenderables.includes(focus) ? focus.getClipboardText(text) : text focus?.getClipboardText && selection.selectedRenderables.includes(focus) ? focus.getClipboardText(text) : text
clipboard clipboard
.write(clipboardText) ?.write?.(clipboardText)
.then(() => toast.show({ message: "Copied to clipboard", variant: "info" })) .then(() => toast.show({ message: "Copied to clipboard", variant: "info" }))
.catch(toast.error) .catch(toast.error)
-12
View File
@@ -1,12 +0,0 @@
export function createSingleFlight<Key>() {
const pending = new Set<Key>()
return async <Value>(key: Key, run: () => Promise<Value>) => {
if (pending.has(key)) return
pending.add(key)
try {
return await run()
} finally {
pending.delete(key)
}
}
}
-1
View File
@@ -22,7 +22,6 @@ export function primitiveInputSummary(input: Record<string, unknown>, omit: read
export function webSearchProviderLabel(provider: unknown) { export function webSearchProviderLabel(provider: unknown) {
if (provider === "parallel") return "Parallel Web Search" if (provider === "parallel") return "Parallel Web Search"
if (provider === "exa") return "Exa Web Search" if (provider === "exa") return "Exa Web Search"
if (provider === "firecrawl") return "Firecrawl Web Search"
return "Web Search" return "Web Search"
} }
@@ -74,11 +74,11 @@ test("searches settings globally and opens the matching setting", async () => {
await app.waitFor(() => app.renderer.currentFocusedEditor instanceof InputRenderable) await app.waitFor(() => app.renderer.currentFocusedEditor instanceof InputRenderable)
app.mockInput.pressArrow("down") app.mockInput.pressArrow("down")
for (const key of "image preview") app.mockInput.pressKey(key) for (const key of "sounds") app.mockInput.pressKey(key)
app.mockInput.pressEnter() 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() app.mockInput.pressEnter()
await app.waitFor(() => current.prompt?.image_preview === true) await app.waitFor(() => current.attention?.sound === false)
} finally { } finally {
app.renderer.destroy() app.renderer.destroy()
} }
-100
View File
@@ -914,106 +914,6 @@ test("completes exploration when a queued prompt is promoted", async () => {
} }
}) })
test("updates and removes queued inputs from durable lifecycle events", async () => {
const events = createEventStream()
const sessionID = "session-queue-management"
const calls = createFetch((url) => {
if (url.pathname === `/api/session/${sessionID}/message`) return json({ data: [], cursor: {} })
}, events)
let data!: ReturnType<typeof useData>
let rows!: ReturnType<typeof createSessionRows>
let client!: ReturnType<typeof useClient>
function Probe() {
client = useClient()
data = useData()
rows = createSessionRows(() => sessionID)
return <box />
}
const app = await testRender(() => (
<TestTuiContexts>
<ClientProvider api={createApi(calls.fetch)}>
<ProjectProvider>
<DataProvider>
<Probe />
</DataProvider>
</ProjectProvider>
</ClientProvider>
</TestTuiContexts>
))
try {
await wait(() => client.connection.status() === "connected")
emitEvent(events, {
id: "evt_queue_admitted",
created: 1,
type: "session.input.admitted",
durable: durable(sessionID),
data: {
sessionID,
inputID: "message-queued",
input: { type: "user", data: { text: "Steer me" }, delivery: "queue" },
},
})
await wait(() => data.session.pending.list(sessionID).length === 1)
expect(rows).not.toContainEqual({ type: "message", messageID: "message-queued" })
emitEvent(events, {
id: "evt_queue_steered",
created: 2,
type: "session.input.steered",
durable: durable(sessionID, 1),
data: { sessionID, inputID: "message-queued" },
})
await wait(() =>
data.session.pending
.list(sessionID)
.some((item) => item.id === "message-queued" && item.type !== "compaction" && item.delivery === "steer"),
)
expect(rows).toContainEqual({ type: "message", messageID: "message-queued" })
emitEvent(events, {
id: "evt_queue_restored",
created: 3,
type: "session.input.queued",
durable: durable(sessionID, 2),
data: { sessionID, inputID: "message-queued" },
})
await wait(() =>
data.session.pending
.list(sessionID)
.some((item) => item.id === "message-queued" && item.type !== "compaction" && item.delivery === "queue"),
)
expect(rows).not.toContainEqual({ type: "message", messageID: "message-queued" })
emitEvent(events, {
id: "evt_cancel_admitted",
created: 4,
type: "session.input.admitted",
durable: durable(sessionID, 3),
data: {
sessionID,
inputID: "message-cancelled",
input: { type: "user", data: { text: "Delete me" }, delivery: "queue" },
},
})
await wait(() => data.session.pending.list(sessionID).length === 2)
emitEvent(events, {
id: "evt_queue_cancelled",
created: 5,
type: "session.input.cancelled",
durable: durable(sessionID, 4),
data: { sessionID, inputID: "message-cancelled" },
})
await wait(() => !data.session.input.has(sessionID, "message-cancelled"))
expect(data.session.pending.list(sessionID).map((item) => item.id)).toEqual(["message-queued"])
expect(data.session.message.get(sessionID, "message-cancelled")).toBeUndefined()
} finally {
app.renderer.destroy()
}
})
test("classifies live tool rows independently of their call ID", async () => { test("classifies live tool rows independently of their call ID", async () => {
const events = createEventStream() const events = createEventStream()
const sessionID = "session-tool-call-id" const sessionID = "session-tool-call-id"
-3
View File
@@ -60,9 +60,6 @@ async function mountForm(root: string, width = 80) {
> >
<ClipboardProvider <ClipboardProvider
value={{ value={{
async read() {
return undefined
},
write(text) { write(text) {
copied.push(text) copied.push(text)
return Promise.resolve() return Promise.resolve()
+12 -122
View File
@@ -1,129 +1,19 @@
import { expect, test } from "bun:test" import { expect, test } from "bun:test"
import { import { copyCommand } from "../src/clipboard"
createClipboard,
type ClipboardReadOptions,
type ClipboardReadResult,
type ClipboardService as CoreClipboardService,
type ClipboardWriteOptions,
type ClipboardWriteResult,
type HostClipboardService,
} from "@opentui/core"
import { createClipboardAdapter } from "../src/clipboard"
function coreClipboard(options: { test("prefers Wayland clipboard when available", () => {
read?: ClipboardReadResult expect(copyCommand("linux", true, (name) => name === "wl-copy")).toEqual(["wl-copy"])
write?: ClipboardWriteResult
onRead?: (input: ClipboardReadOptions) => void
onWrite?: (text: string, input: ClipboardWriteOptions) => void
}): CoreClipboardService {
return {
async read(input) {
options.onRead?.(input)
return options.read ?? { status: "empty" }
},
async writeText(text, input) {
options.onWrite?.(text, input)
return (
options.write ?? {
host: { status: "written" },
terminal: { status: "not-attempted", capability: "unknown" },
}
)
},
async clear() {
return {
host: { status: "cleared" },
terminal: { status: "not-attempted", capability: "unknown" },
}
},
async dispose() {},
}
}
test("adapts OpenTUI image and text reads", async () => {
const requests: ClipboardReadOptions[] = []
const image = createClipboardAdapter(
coreClipboard({
read: {
status: "read",
representation: { mimeType: "image/png", bytes: new Uint8Array([0, 1, 2, 255]) },
},
onRead: (input) => requests.push(input),
}),
)
const text = "line 1\r\n\t世界"
const plain = createClipboardAdapter(
coreClipboard({
read: {
status: "read",
representation: { mimeType: "text/plain", bytes: new TextEncoder().encode(text) },
},
}),
)
expect(await image.read()).toEqual({ data: "AAEC/w==", mime: "image/png" })
expect(await plain.read()).toEqual({ data: text, mime: "text/plain" })
expect(requests).toEqual([{ preferredTypes: ["image/png", "text/plain"], selection: "clipboard" }])
}) })
test("uses all available routes but skips the process host remotely", async () => { test("uses osascript on macOS", () => {
const writes = { host: 0, terminal: 0 } expect(copyCommand("darwin", false, (name) => name === "osascript")).toEqual(["osascript"])
const host: HostClipboardService = {
maxWriteBytes: 8 * 1024 * 1024,
async read() {
return { status: "empty" }
},
async writeText() {
writes.host++
return { status: "written" }
},
async clear() {
return { status: "cleared" }
},
async dispose() {},
}
const clipboard = createClipboardAdapter(
createClipboard({
host,
terminal: {
remote: true,
writeText() {
writes.terminal++
return { status: "attempted", capability: "supported" }
},
clear() {
return { status: "attempted", capability: "supported" }
},
},
}),
)
expect(await clipboard.write("hello")).toBeUndefined()
expect(writes).toEqual({ host: 0, terminal: 1 })
}) })
test("rejects only when no clipboard route accepted the write", async () => { test("falls back through X11 clipboard commands", () => {
const writes: [string, ClipboardWriteOptions][] = [] expect(copyCommand("linux", true, (name) => name === "xclip")).toEqual(["xclip", "-selection", "clipboard"])
const failure = new Error("native clipboard failed") expect(copyCommand("linux", false, (name) => name === "xsel")).toEqual(["xsel", "--clipboard", "--input"])
const fallback = createClipboardAdapter( })
coreClipboard({
write: { test("returns undefined when native clipboard is unavailable", () => {
host: { status: "written" }, expect(copyCommand("linux", false, () => false)).toBeUndefined()
terminal: { status: "local-failure", capability: "supported" },
},
}),
)
const clipboard = createClipboardAdapter(
coreClipboard({
write: {
host: { status: "failed", error: failure },
terminal: { status: "local-failure", capability: "supported" },
},
onWrite: (text, input) => writes.push([text, input]),
}),
)
expect(await fallback.write("hello")).toBeUndefined()
expect(await clipboard.write("hello").then(undefined, (error) => error)).toBe(failure)
expect(writes).toEqual([["hello", { destination: "all-available", selection: "clipboard" }]])
}) })
-2
View File
@@ -23,8 +23,6 @@ test("validates the session tabs setting", () => {
}) })
expect(() => decode({ tabs: { layout: true } })).toThrow() expect(() => decode({ tabs: { layout: true } })).toThrow()
expect(() => decode({ tabs: { enabled: "on" } })).toThrow() expect(() => decode({ tabs: { enabled: "on" } })).toThrow()
expect(decode({ prompt: { image_preview: true } })).toEqual({ prompt: { image_preview: true } })
expect(decode({ session: { image_preview: true } })).toEqual({ session: { image_preview: true } })
}) })
test("resolves nested config and keybind defaults", () => { test("resolves nested config and keybind defaults", () => {
+1 -12
View File
@@ -7,14 +7,6 @@ import {
} from "../../src/context/runtime" } from "../../src/context/runtime"
import type { ParentProps } from "solid-js" import type { ParentProps } from "solid-js"
import { LogProvider, type LogSink } from "../../src/context/log" import { LogProvider, type LogSink } from "../../src/context/log"
import { ClipboardProvider, type ClipboardService } from "../../src/context/clipboard"
const clipboard: ClipboardService = {
async read() {
return undefined
},
async write() {},
}
export function TestTuiContexts( export function TestTuiContexts(
props: ParentProps<{ props: ParentProps<{
@@ -22,7 +14,6 @@ export function TestTuiContexts(
directory?: string directory?: string
paths?: Partial<TuiPaths> paths?: Partial<TuiPaths>
log?: LogSink log?: LogSink
clipboard?: ClipboardService
}>, }>,
) { ) {
return ( return (
@@ -37,9 +28,7 @@ export function TestTuiContexts(
}} }}
> >
<TuiTerminalEnvironmentProvider value={{ platform: "linux" }}> <TuiTerminalEnvironmentProvider value={{ platform: "linux" }}>
<TuiStartupProvider value={{ skipInitialLoading: false }}> <TuiStartupProvider value={{ skipInitialLoading: false }}>{props.children}</TuiStartupProvider>
<ClipboardProvider value={props.clipboard ?? clipboard}>{props.children}</ClipboardProvider>
</TuiStartupProvider>
</TuiTerminalEnvironmentProvider> </TuiTerminalEnvironmentProvider>
</TuiPathsProvider> </TuiPathsProvider>
</LogProvider> </LogProvider>
+2 -2
View File
@@ -56,9 +56,9 @@ export function createFooterApiFixture(input: { events?: FooterEvent[]; commits?
commits, commits,
calls, calls,
promptReady, promptReady,
submit(text: string, mode?: RunPrompt["mode"], delivery?: RunPrompt["delivery"]) { submit(text: string, mode?: RunPrompt["mode"]) {
if (prompts.size === 0) return false if (prompts.size === 0) return false
const prompt: RunPrompt = { text, parts: [], ...(mode ? { mode } : {}), ...(delivery ? { delivery } : {}) } const prompt: RunPrompt = mode ? { text, parts: [], mode } : { text, parts: [] }
for (const fn of [...prompts]) fn(prompt) for (const fn of [...prompts]) fn(prompt)
return true return true
}, },
+23 -120
View File
@@ -21,7 +21,6 @@ import { RunFooterView } from "../../src/mini/footer.view"
import { RunEntryContent } from "../../src/mini/scrollback.writer" import { RunEntryContent } from "../../src/mini/scrollback.writer"
import { RUN_THEME_FALLBACK, type RunTheme } from "../../src/mini/theme" import { RUN_THEME_FALLBACK, type RunTheme } from "../../src/mini/theme"
import type { import type {
FooterQueuedPrompt,
FooterState, FooterState,
FooterSubagentState, FooterSubagentState,
FooterSubagentTab, FooterSubagentTab,
@@ -121,15 +120,13 @@ async function renderFooter(
height?: number height?: number
state?: Partial<FooterState> state?: Partial<FooterState>
onCycle?: () => void onCycle?: () => void
onSubmit?: (prompt: RunPrompt) => boolean | Promise<boolean> onSubmit?: (prompt: RunPrompt) => boolean
view?: FooterView view?: FooterView
onFormReply?: (input: unknown) => void onFormReply?: (input: unknown) => void
miniSettings?: MiniSettings miniSettings?: MiniSettings
mono?: boolean mono?: boolean
onStatus?: (status: string) => void onStatus?: (status: string) => void
onMiniSettingChange?: (change: MiniSettingChange) => void onMiniSettingChange?: (change: MiniSettingChange) => void
queuedPrompts?: FooterQueuedPrompt[]
onQueuedPromptAction?: (action: "steer" | "cancel", inputID: string) => Promise<void>
} = {}, } = {},
) { ) {
const [view, setView] = createSignal<FooterView>(input.view ?? { type: "prompt" }) const [view, setView] = createSignal<FooterView>(input.view ?? { type: "prompt" })
@@ -167,7 +164,6 @@ async function renderFooter(
state={state} state={state}
view={view} view={view}
subagent={subagents} subagent={subagents}
queuedPrompts={() => input.queuedPrompts ?? []}
theme={input.theme ?? (() => RUN_THEME_FALLBACK)} theme={input.theme ?? (() => RUN_THEME_FALLBACK)}
mono={input.mono ?? false} mono={input.mono ?? false}
miniSettings={miniSettings} miniSettings={miniSettings}
@@ -177,7 +173,6 @@ async function renderFooter(
onFormCancel={() => {}} onFormCancel={() => {}}
onCycle={input.onCycle ?? (() => {})} onCycle={input.onCycle ?? (() => {})}
onInterrupt={() => false} onInterrupt={() => false}
onQueuedPromptAction={input.onQueuedPromptAction}
onEditorOpen={async () => undefined} onEditorOpen={async () => undefined}
onInputClear={() => {}} onInputClear={() => {}}
onExit={() => {}} onExit={() => {}}
@@ -918,7 +913,7 @@ test("direct subagent panel closes when moving up from the first item", async ()
} }
}) })
test("direct queued panel steers and deletes selected prompts", async () => { test("direct pending panel shows durable delivery without edit actions", async () => {
const [prompts] = createSignal([ const [prompts] = createSignal([
{ {
messageID: "m-1", messageID: "m-1",
@@ -926,22 +921,16 @@ test("direct queued panel steers and deletes selected prompts", async () => {
delivery: "queue" as const, delivery: "queue" as const,
}, },
]) ])
const steered: string[] = []
const deleted: string[] = []
const app = await testRender( const app = await testRender(
() => ( () => (
<Keymap.Provider config={tuiConfig}> <box width={100} height={RUN_SUBAGENT_PANEL_ROWS}>
<box width={100} height={RUN_SUBAGENT_PANEL_ROWS}> <RunQueuedPromptSelectBody
<RunQueuedPromptSelectBody theme={() => RUN_THEME_FALLBACK.footer}
theme={() => RUN_THEME_FALLBACK.footer} prompts={prompts}
prompts={prompts} onClose={() => {}}
onClose={() => {}} />
onSteer={(prompt) => steered.push(prompt.messageID)} </box>
onDelete={(prompt) => deleted.push(prompt.messageID)}
/>
</box>
</Keymap.Provider>
), ),
{ width: 100, height: RUN_SUBAGENT_PANEL_ROWS }, { width: 100, height: RUN_SUBAGENT_PANEL_ROWS },
) )
@@ -951,98 +940,19 @@ test("direct queued panel steers and deletes selected prompts", async () => {
const frame = app.captureCharFrame() const frame = app.captureCharFrame()
const list = panelMenu(app.renderer.root) const list = panelMenu(app.renderer.root)
expect(frame).toContain("Queued prompts") expect(frame).toContain("Pending work")
expect(frame).toContain("fix the auth test") expect(frame).toContain("fix the auth test")
expect(frame).toContain("queued") expect(frame).toContain("queue")
expect(frame).toContain("enter steer · ctrl+d delete")
expect(frame).not.toContain("┌") expect(frame).not.toContain("┌")
expect(frame).not.toContain("┃") expect(frame).not.toContain("┃")
expectPaletteList(list, 0) expectPaletteList(list, 0)
app.mockInput.pressEnter() expect(frame).not.toContain("edit")
app.mockInput.pressKey("d", { ctrl: true }) expect(frame).not.toContain("remove")
expect(steered).toEqual(["m-1"])
expect(deleted).toEqual(["m-1"])
} finally { } finally {
app.renderer.destroy() app.renderer.destroy()
} }
}) })
test("direct footer steers the oldest queued prompt from an empty composer", async () => {
const steered: string[] = []
const app = await renderFooter({
queuedPrompts: [
{ messageID: "m-1", prompt: { text: "first", parts: [] }, delivery: "queue" },
{ messageID: "m-2", prompt: { text: "second", parts: [] }, delivery: "queue" },
],
onQueuedPromptAction: async (action, inputID) => {
if (action === "steer") steered.push(inputID)
},
})
try {
await app.renderOnce()
app.mockInput.pressEnter({ meta: true })
await Bun.sleep(0)
expect(steered).toEqual([])
app.mockInput.pressEnter()
await Bun.sleep(0)
expect(steered).toEqual(["m-1"])
} finally {
app.cleanup()
}
})
test("direct footer does not steer queued work on a double submit", async () => {
const submitted: RunPrompt[] = []
const steered: string[] = []
const app = await renderFooter({
queuedPrompts: [{ messageID: "m-1", prompt: { text: "queued", parts: [] }, delivery: "queue" }],
onSubmit: async (prompt) => {
submitted.push(prompt)
await Bun.sleep(10)
return true
},
onQueuedPromptAction: async (action, inputID) => {
if (action === "steer") steered.push(inputID)
},
})
try {
await app.renderOnce()
await app.mockInput.typeText("send once")
app.mockInput.pressEnter()
app.mockInput.pressEnter()
await Bun.sleep(20)
expect(submitted).toHaveLength(1)
expect(steered).toEqual([])
} finally {
app.cleanup()
}
})
test("direct footer rejects local commands submitted with the queue shortcut", async () => {
const submitted: RunPrompt[] = []
const statuses: string[] = []
const app = await renderFooter({
onSubmit: (prompt) => {
submitted.push(prompt)
return true
},
onStatus: (status) => statuses.push(status),
})
try {
await app.renderOnce()
await app.mockInput.typeText("/settings ")
app.mockInput.pressEnter({ meta: true })
await Bun.sleep(0)
expect(submitted).toEqual([])
expect(statuses).toContain("this prompt cannot be queued")
} finally {
app.cleanup()
}
})
// OpenTUI currently crashes Bun in the full `test/cli/run` directory run here. // OpenTUI currently crashes Bun in the full `test/cli/run` directory run here.
// Re-enable after the upstream OpenTUI fix lands in this repo. // Re-enable after the upstream OpenTUI fix lands in this repo.
test.skip("direct footer recreates the frame across command panel transitions", async () => { test.skip("direct footer recreates the frame across command panel transitions", async () => {
@@ -1158,11 +1068,11 @@ test("direct footer submits slash autocomplete selections without dispatching sh
await app.renderOnce() await app.renderOnce()
expect(submits).toEqual([ expect(submits).toEqual([
{ text: "/review ", parts: [], command: { name: "review", arguments: "" }, delivery: "steer" }, { text: "/review ", parts: [], command: { name: "review", arguments: "" } },
{ text: "/review ", parts: [], command: { name: "review", arguments: "" }, delivery: "steer" }, { text: "/review ", parts: [], command: { name: "review", arguments: "" } },
{ text: "/review branch", parts: [], command: { name: "review", arguments: "branch" }, delivery: "steer" }, { text: "/review branch", parts: [], command: { name: "review", arguments: "branch" } },
{ text: "/new ", parts: [], delivery: "steer" }, { text: "/new ", parts: [] },
{ text: "/new ", parts: [], delivery: "steer" }, { text: "/new ", parts: [] },
]) ])
expect(app.renderer.currentFocusedEditor?.plainText).toBe("/settings ") expect(app.renderer.currentFocusedEditor?.plainText).toBe("/settings ")
} finally { } finally {
@@ -1190,9 +1100,7 @@ test("direct footer slash autocomplete keeps a real skills command", async () =>
app.mockInput.pressEnter() app.mockInput.pressEnter()
await app.renderOnce() await app.renderOnce()
expect(submits).toEqual([ expect(submits).toEqual([{ text: "/skills ", parts: [], command: { name: "skills", arguments: "" } }])
{ text: "/skills ", parts: [], command: { name: "skills", arguments: "" }, delivery: "steer" },
])
expect(app.captureCharFrame()).not.toContain("Apply formatter fixes") expect(app.captureCharFrame()).not.toContain("Apply formatter fixes")
} finally { } finally {
app.cleanup() app.cleanup()
@@ -1250,12 +1158,7 @@ test("direct footer tags skill slash submissions with their catalog source", asy
await app.renderOnce() await app.renderOnce()
expect(submits).toEqual([ expect(submits).toEqual([
{ { text: "/formatter src", parts: [], command: { name: "formatter", arguments: "src", source: "skill" } },
text: "/formatter src",
parts: [],
command: { name: "formatter", arguments: "src", source: "skill" },
delivery: "steer",
},
]) ])
} finally { } finally {
app.cleanup() app.cleanup()
@@ -1335,7 +1238,7 @@ test.skip("direct footer clears the synthetic skills draft when the panel closes
} }
}) })
test("direct footer shows authoritative queued work while running", async () => { test("direct footer shows authoritative pending work while running", async () => {
const [state] = createSignal<FooterState>({ const [state] = createSignal<FooterState>({
phase: "running", phase: "running",
status: "", status: "",
@@ -1439,9 +1342,9 @@ test("direct footer shows authoritative queued work while running", async () =>
const hint = statusItems.at(-1)! const hint = statusItems.at(-1)!
expect(spinner).toBeDefined() expect(spinner).toBeDefined()
expect(frame).toContain("1 queued") expect(frame).toContain("1 pending")
expect(frame).toContain("ctrl+b background") expect(frame).toContain("ctrl+b background")
expect(frame).toContain("ctrl+x q 1 queued") expect(frame).toContain("ctrl+x q 1 pending")
expect(frame).toContain("↓ subagents") expect(frame).toContain("↓ subagents")
expect(frame).toContain("ctrl+p cmd") expect(frame).toContain("ctrl+p cmd")
expect(frame).toContain("subagents · ctrl+p cmd") expect(frame).toContain("subagents · ctrl+p cmd")
+1 -2
View File
@@ -82,8 +82,7 @@ describe("run runtime boot", () => {
expect(result.keybinds.get("prompt.history.next")?.[0]?.key).toBe("down") expect(result.keybinds.get("prompt.history.next")?.[0]?.key).toBe("down")
expect(result.keybinds.get("prompt.clear")?.[0]?.key).toBe("ctrl+c") expect(result.keybinds.get("prompt.clear")?.[0]?.key).toBe("ctrl+c")
expect(result.keybinds.get("input.submit")?.[0]?.key).toBe("return") expect(result.keybinds.get("input.submit")?.[0]?.key).toBe("return")
expect(result.keybinds.get("input.newline")?.[0]?.key).toBe("shift+return,ctrl+return,ctrl+j") expect(result.keybinds.get("input.newline")?.[0]?.key).toBe("shift+return,ctrl+return,alt+return,ctrl+j")
expect(result.keybinds.get("prompt.queue")?.[0]?.key).toBe("alt+return")
}) })
test("preserves disabled leader from resolved tui config", async () => { test("preserves disabled leader from resolved tui config", async () => {
+1 -28
View File
@@ -265,33 +265,6 @@ describe("run runtime queue", () => {
await task await task
}) })
test("preserves explicit steer and queue delivery for in-flight prompts", async () => {
const ui = createFooterApiFixture()
const admitted: string[] = []
const gate = Promise.withResolvers<void>()
const task = runPromptQueue({
footer: ui.api,
run: async (_input, _signal, onAdmitted) => {
onAdmitted()
await gate.promise
},
admit: async (input, delivery) => {
admitted.push(`${input.text}:${delivery}`)
},
settle: async () => ui.api.close(),
})
ui.submit("one")
ui.submit("two", undefined, "steer")
ui.submit("three", undefined, "queue")
while (admitted.length < 2) await Bun.sleep(0)
expect(admitted).toEqual(["two:steer", "three:queue"])
gate.resolve()
await task
})
test("continues durable admission after one fails", async () => { test("continues durable admission after one fails", async () => {
const ui = createFooterApiFixture() const ui = createFooterApiFixture()
const admitted: string[] = [] const admitted: string[] = []
@@ -335,7 +308,7 @@ describe("run runtime queue", () => {
admitted() admitted()
await new Promise<void>((resolve) => signal.addEventListener("abort", () => resolve(), { once: true })) await new Promise<void>((resolve) => signal.addEventListener("abort", () => resolve(), { once: true }))
}, },
admit: async (_prompt, _delivery, signal) => { admit: async (_prompt, signal) => {
admissionStarted.resolve() admissionStarted.resolve()
await new Promise<void>((resolve) => { await new Promise<void>((resolve) => {
if (signal.aborted) { if (signal.aborted) {
+3 -3
View File
@@ -126,7 +126,7 @@ describe("run interactive runtime", () => {
turnStarted.resolve() turnStarted.resolve()
api.close() api.close()
}, },
admitPromptTurn: async () => {}, queuePromptTurn: async () => {},
waitForIdle: async () => {}, waitForIdle: async () => {},
interruptActiveTurn: async () => {}, interruptActiveTurn: async () => {},
selectSubagent: () => {}, selectSubagent: () => {},
@@ -209,7 +209,7 @@ describe("run interactive runtime", () => {
streamStarted.resolve() streamStarted.resolve()
return { return {
runPromptTurn: async () => {}, runPromptTurn: async () => {},
admitPromptTurn: async () => {}, queuePromptTurn: async () => {},
waitForIdle: async () => {}, waitForIdle: async () => {},
interruptActiveTurn: async () => {}, interruptActiveTurn: async () => {},
selectSubagent: () => {}, selectSubagent: () => {},
@@ -556,7 +556,7 @@ describe("run interactive runtime", () => {
setTimeout(() => input.footer.close(), 0) setTimeout(() => input.footer.close(), 0)
return { return {
runPromptTurn: async () => {}, runPromptTurn: async () => {},
admitPromptTurn: async () => {}, queuePromptTurn: async () => {},
waitForIdle: async () => {}, waitForIdle: async () => {},
interruptActiveTurn: async () => {}, interruptActiveTurn: async () => {},
selectSubagent: () => {}, selectSubagent: () => {},
@@ -669,14 +669,6 @@ describe("V2 mini transport", () => {
data: { text: "follow up" }, data: { text: "follow up" },
delivery: "queue", delivery: "queue",
}, },
{
id: "msg_cancelled",
sessionID: "ses_1",
timeCreated: 2,
type: "user",
data: { text: "remove me" },
delivery: "queue",
},
], ],
}, },
}) })
@@ -692,14 +684,11 @@ describe("V2 mini transport", () => {
.findLast((item) => item.type === "queued.prompts") .findLast((item) => item.type === "queued.prompts")
?.prompts.map((item) => [item.messageID, item.delivery]) ?.prompts.map((item) => [item.messageID, item.delivery])
expect(pending()).toEqual([ expect(pending()).toEqual([["msg_queued", "queue"]])
["msg_queued", "queue"],
["msg_cancelled", "queue"],
])
events.push({ events.push({
id: "evt_steered", id: "evt_promoted",
created: 3, created: 2,
type: "session.input.steered", type: "session.input.promoted",
durable: durable("ses_1", 2), durable: durable("ses_1", 2),
data: { sessionID: "ses_1", inputID: "msg_queued" }, data: { sessionID: "ses_1", inputID: "msg_queued" },
}) })
@@ -708,53 +697,19 @@ describe("V2 mini transport", () => {
expect(ui.commits).toContainEqual( expect(ui.commits).toContainEqual(
expect.objectContaining({ kind: "user", messageID: "msg_queued", text: "follow up" }), expect.objectContaining({ kind: "user", messageID: "msg_queued", text: "follow up" }),
) )
expect(pending()).toEqual([["msg_cancelled", "queue"]]) expect(pending()).toEqual([])
events.push({
id: "evt_queued",
created: 4,
type: "session.input.queued",
durable: durable("ses_1", 3),
data: { sessionID: "ses_1", inputID: "msg_queued" },
})
while (pending()?.length !== 2) await Bun.sleep(0)
expect(pending()).toEqual([
["msg_queued", "queue"],
["msg_cancelled", "queue"],
])
events.push({
id: "evt_cancelled",
created: 5,
type: "session.input.cancelled",
durable: durable("ses_1", 4),
data: { sessionID: "ses_1", inputID: "msg_cancelled" },
})
while (pending()?.length !== 1) await Bun.sleep(0)
expect(pending()).toEqual([["msg_queued", "queue"]])
events.push({
id: "evt_promoted",
created: 6,
type: "session.input.promoted",
durable: durable("ses_1", 5),
data: { sessionID: "ses_1", inputID: "msg_queued" },
})
while (pending()?.length !== 0) await Bun.sleep(0)
expect(ui.commits.filter((item) => item.messageID === "msg_queued")).toHaveLength(1)
const prompt = spyOn(client.session, "prompt").mockImplementation( const prompt = spyOn(client.session, "prompt").mockImplementation(
(request) => ok(promptAdmission(request)) as never, (request) => ok(promptAdmission(request)) as never,
) )
await transport.admitPromptTurn({ await transport.queuePromptTurn({
agent: "review", agent: "review",
model: { providerID: "test", modelID: "next" }, model: undefined,
variant: "high", variant: undefined,
prompt: { messageID: "msg_next", text: "another", parts: [] }, prompt: { messageID: "msg_next", text: "another", parts: [] },
files: [], files: [],
includeFiles: false, includeFiles: false,
}, "queue") })
expect(client.session.switchAgent).toHaveBeenCalledWith({ sessionID: "ses_1", agent: "review" }, expect.anything()) expect(client.session.switchAgent).toHaveBeenCalledWith({ sessionID: "ses_1", agent: "review" }, expect.anything())
expect(client.session.switchModel).toHaveBeenCalledWith(
{ sessionID: "ses_1", model: { providerID: "test", id: "next", variant: "high" } },
expect.anything(),
)
expect(prompt).toHaveBeenCalledWith(expect.objectContaining({ delivery: "queue" }), expect.anything()) expect(prompt).toHaveBeenCalledWith(expect.objectContaining({ delivery: "queue" }), expect.anything())
events.push({ events.push({
id: "evt_earlier_admission", id: "evt_earlier_admission",
@@ -767,8 +722,15 @@ describe("V2 mini transport", () => {
input: { type: "user", data: { text: "earlier" }, delivery: "steer" }, input: { type: "user", data: { text: "earlier" }, delivery: "steer" },
}, },
}) })
await Bun.sleep(10) while (true) {
expect(pending()).toEqual([["msg_next", "queue"]]) const pending = ui.events.findLast((item) => item.type === "queued.prompts")
if (pending?.type === "queued.prompts" && pending.prompts.length >= 2) break
await Bun.sleep(0)
}
expect(pending()).toEqual([
["msg_next", "queue"],
["msg_earlier", "steer"],
])
await transport.close() await transport.close()
}) })
@@ -851,14 +813,14 @@ describe("V2 mini transport", () => {
durable: durable("ses_1", 2), durable: durable("ses_1", 2),
data: { sessionID: "ses_1", inputID: "msg_prompt" }, data: { sessionID: "ses_1", inputID: "msg_prompt" },
}) })
await transport.admitPromptTurn({ await transport.queuePromptTurn({
agent: undefined, agent: undefined,
model: undefined, model: undefined,
variant: undefined, variant: undefined,
prompt: { messageID: "msg_queued", text: "follow up", parts: [] }, prompt: { messageID: "msg_queued", text: "follow up", parts: [] },
files: [], files: [],
includeFiles: false, includeFiles: false,
}, "queue") })
events.push({ events.push({
id: "evt_queued_promoted", id: "evt_queued_promoted",
created: 3, created: 3,
-239
View File
@@ -1,239 +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 { createEventStream, createFetch } from "../fixture/tui-client"
const openTui = { ...(await import("@opentui/core")) }
const PNG_1X1_BASE64 =
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR4AWP4z8DwHwAFAAH/e+m+7wAAAABJRU5ErkJggg=="
const PNG_1X1 = Buffer.from(PNG_1X1_BASE64, "base64")
const readPngClipboard = async (): Promise<ClipboardReadResult> => ({
status: "read",
representation: { mimeType: "image/png", bytes: PNG_1X1 },
})
let activeSetup: Awaited<ReturnType<typeof createTestRenderer>> | undefined
let activeHost: HostClipboardService | undefined
let activePromptRef: PromptRef | undefined
await mock.module("@opentui/core", () => ({
...openTui,
createCliRenderer: async () => {
if (!activeSetup) throw new Error("Prompt renderer is not mounted")
return activeSetup.renderer
},
createHostClipboard: () => {
if (!activeHost) throw new Error("Prompt clipboard is not mounted")
return activeHost
},
}))
await mock.module("../../src/routes/home", () => ({
Home: () =>
createComponent(Prompt, {
ref: (value) => (activePromptRef = value),
showPlaceholder: false,
}),
}))
const { run } = await import("../../src/app")
afterAll(() => mock.restore())
async function mountPrompt(read: () => Promise<ClipboardReadResult>, imagePreview = false) {
const setup = await createTestRenderer({ width: 80, height: 24, useThread: false })
let reads = 0
let ready!: () => void
const mounted = new Promise<void>((resolve) => (ready = resolve))
const setTitle = setup.renderer.setTerminalTitle.bind(setup.renderer)
setup.renderer.setTerminalTitle = (title) => {
if (title === "OpenCode") ready()
setTitle(title)
}
const host: HostClipboardService = {
maxWriteBytes: 8 * 1024 * 1024,
async read() {
reads++
return read()
},
async writeText() {
return { status: "written" }
},
async clear() {
return { status: "cleared" }
},
async dispose() {},
}
activeSetup = setup
activeHost = host
activePromptRef = undefined
const events = createEventStream()
const calls = createFetch(undefined, events)
let preloaded!: () => void
const preload = new Promise<void>((resolve) => (preloaded = resolve))
const server = Bun.serve({
port: 0,
fetch: async (request) => {
const response = await calls.fetch(request)
const url = new URL(request.url)
if (url.pathname === "/api/session/active") preloaded()
return response
},
})
let task: Promise<unknown> | undefined
try {
task = Effect.runPromise(
run({
app: { name: "test", version: "test", channel: "test" },
server: { endpoint: { url: server.url.toString() } },
config: {
get: async () => ({ prompt: { paste: "full" as const, image_preview: imagePreview } }),
update: async () => ({}),
},
packages: { resolve: async () => undefined },
args: {},
log: () => {},
}).pipe(Effect.provide(AppNodeBuilder.build(Global.node)), Effect.provide(FileSystem.layerNoop({}))),
)
await mounted
await setup.waitFor(() => activePromptRef?.focused === true)
await preload
await Bun.sleep(0)
} catch (error) {
setup.renderer.destroy()
await task?.catch(() => undefined)
await server.stop()
activeSetup = undefined
activeHost = undefined
activePromptRef = undefined
throw error
}
return {
setup,
get input() {
const input = setup.renderer.currentFocusedEditor
if (!(input instanceof TextareaRenderable)) throw new Error("Prompt textarea is not focused")
return input
},
get reads() {
return reads
},
get prompt() {
if (!activePromptRef) throw new Error("Prompt ref is not mounted")
return activePromptRef
},
async dispose() {
activePromptRef?.reset()
setup.renderer.destroy()
await task
await server.stop()
activeSetup = undefined
activeHost = undefined
activePromptRef = undefined
},
}
}
async function pasteImages(prompt: Awaited<ReturnType<typeof mountPrompt>>, count: number) {
for (let index = 0; index < count; index++) {
prompt.setup.renderer.keyInput.processPaste(new Uint8Array())
await prompt.setup.waitFor(() => prompt.reads === index + 1)
}
}
test("creates one image mention from PNG clipboard bytes", async () => {
const prompt = await mountPrompt(readPngClipboard)
try {
prompt.setup.renderer.keyInput.processPaste(new Uint8Array())
await prompt.setup.waitFor(() => prompt.input.plainText === "[Image 1] ")
expect(prompt.input.plainText).toBe("[Image 1] ")
expect(prompt.input.extmarks.getVirtual()).toHaveLength(1)
expect(prompt.prompt.current.files).toEqual([
{
uri: `data:image/png;base64,${PNG_1X1_BASE64}`,
name: "clipboard",
mention: { start: 0, end: 9, text: "[Image 1]" },
},
])
expect(prompt.setup.renderer.root.findDescendantById("prompt-image-preview-0")).toBeUndefined()
expect(prompt.reads).toBe(1)
} finally {
await prompt.dispose()
}
})
test("renders at most three left-aligned cropped thumbnails", async () => {
const prompt = await mountPrompt(readPngClipboard, true)
try {
await pasteImages(prompt, 4)
const first = prompt.setup.renderer.root.findDescendantById("prompt-image-preview-0")
if (!(first instanceof ImageRenderable)) throw new Error("Image preview did not render")
await first.loadPromise
expect(first.fit).toBe("cover")
expect(prompt.setup.renderer.root.findDescendantById("prompt-image-preview-1")).toBeInstanceOf(ImageRenderable)
expect(prompt.setup.renderer.root.findDescendantById("prompt-image-preview-2")).toBeInstanceOf(ImageRenderable)
expect(prompt.setup.renderer.root.findDescendantById("prompt-image-preview-3")).toBeUndefined()
await prompt.setup.waitForFrame((frame) => frame.includes("+1 more"))
} finally {
await prompt.dispose()
}
})
test("opens image attachments by keyboard and mouse", async () => {
const prompt = await mountPrompt(readPngClipboard, true)
try {
await pasteImages(prompt, 2)
const thumbnail = prompt.setup.renderer.root.findDescendantById("prompt-image-preview-1")
if (!(thumbnail instanceof ImageRenderable)) throw new Error("Second image thumbnail did not render")
prompt.setup.mockInput.pressKey("x", { ctrl: true })
prompt.setup.mockInput.pressKey("i")
await prompt.setup.waitForFrame((frame) => frame.includes("Image 1 of 2"))
prompt.setup.mockInput.pressCtrlC()
await prompt.setup.waitForFrame((frame) => !frame.includes("Image 1 of 2"))
await prompt.setup.mockMouse.click(thumbnail.x, thumbnail.y, MouseButtons.LEFT)
await prompt.setup.waitForFrame((frame) => frame.includes("Image 2 of 2"))
const large = prompt.setup.renderer.root.findDescendantById("prompt-image-viewer-image")
if (!(large instanceof ImageRenderable)) throw new Error("Large image preview did not render")
expect(large.fit).toBe("fit")
expect(large.height).toBeGreaterThan(thumbnail.height)
prompt.setup.mockInput.pressArrow("left")
await prompt.setup.waitForFrame((frame) => frame.includes("Image 1 of 2"))
prompt.setup.mockInput.pressCtrlC()
await prompt.setup.waitForFrame((frame) => !frame.includes("Image 1 of 2"))
await prompt.setup.waitFor(() => prompt.setup.renderer.currentFocusedEditor === prompt.input)
} finally {
await prompt.dispose()
}
})
test("attaches multiple images from one terminal drop", async () => {
const directory = await mkdtemp(path.join(tmpdir(), "opencode-drop-"))
const first = path.join(directory, "one image.png")
const second = path.join(directory, "two image.png")
await Promise.all([writeFile(first, PNG_1X1), writeFile(second, PNG_1X1)])
const prompt = await mountPrompt(async () => ({ status: "empty" }), true)
try {
await prompt.setup.mockInput.pasteBracketedText(`'${first}' '${second}'`)
await prompt.setup.waitFor(() => prompt.prompt.current.files?.length === 2)
expect(prompt.input.plainText).toBe("[Image 1] [Image 2] ")
expect(prompt.prompt.current.files?.map((file) => file.name)).toEqual(["one image.png", "two image.png"])
} finally {
await prompt.dispose()
await rm(directory, { recursive: true, force: true })
}
})
@@ -1,5 +1,5 @@
import { describe, expect, test } from "bun:test" import { describe, expect, test } from "bun:test"
import { 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" import type { LocalFiles } from "../../src/component/prompt/local-attachment"
function files(input: { mime: string; text?: string; bytes?: Uint8Array }): LocalFiles { function files(input: { mime: string; text?: string; bytes?: Uint8Array }): LocalFiles {
@@ -11,40 +11,6 @@ function files(input: { mime: string; text?: string; bytes?: Uint8Array }): Loca
} }
describe("prompt local attachments", () => { describe("prompt local attachments", () => {
test("parses multi-file drops from POSIX, URI-list, and Windows terminals", () => {
expect(parsePastedFilepaths("'/tmp/one image.png' /tmp/two\\ image.webp", "linux")).toEqual([
"/tmp/one image.png",
"/tmp/two image.webp",
])
expect(parsePastedFilepaths("file:///tmp/one%20image.png\r\nfile:///tmp/two.webp", "linux")).toEqual([
"/tmp/one image.png",
"/tmp/two.webp",
])
expect(parsePastedFilepaths("# dropped files\nfile:///tmp/one.png\nfile:///tmp/two.webp", "linux")).toEqual([
"/tmp/one.png",
"/tmp/two.webp",
])
expect(parsePastedFilepaths("/tmp/one\\\\image.png /tmp/two.webp", "linux")).toEqual([
"/tmp/one\\image.png",
"/tmp/two.webp",
])
expect(parsePastedFilepaths('"C:\\one image.png" "C:\\two.webp"', "win32")).toEqual([
"C:\\one image.png",
"C:\\two.webp",
])
expect(parsePastedFilepaths('"/tmp/O\'Brien.png" /tmp/two.webp', "linux")).toEqual([
"/tmp/O'Brien.png",
"/tmp/two.webp",
])
})
test("rejects unbounded and malformed multi-file drops", () => {
expect(parsePastedFilepaths("'/tmp/one.png /tmp/two.png", "linux")).toEqual([])
expect(
parsePastedFilepaths(Array.from({ length: 33 }, (_, index) => `/tmp/${index}.png`).join(" "), "linux"),
).toEqual([])
})
test("reads SVG attachments as text", async () => { test("reads SVG attachments as text", async () => {
expect(await readLocalAttachmentWith(files({ mime: "image/svg+xml", text: "<svg />" }), "/tmp/image.svg")).toEqual({ expect(await readLocalAttachmentWith(files({ mime: "image/svg+xml", text: "<svg />" }), "/tmp/image.svg")).toEqual({
type: "text", type: "text",
@@ -73,8 +39,5 @@ describe("prompt local attachments", () => {
"/tmp/missing.png", "/tmp/missing.png",
), ),
).toBeUndefined() ).toBeUndefined()
expect(
await readLocalAttachmentWith(files({ mime: "image/png", bytes: new Uint8Array(2) }), "/tmp/large.png", 1),
).toBeUndefined()
}) })
}) })