mirror of
https://github.com/anomalyco/opencode.git
synced 2026-08-13 15:03:43 -04:00
Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 48e0ea45a7 |
@@ -174,7 +174,6 @@ _Avoid_: Response envelope
|
||||
- `sessions.messages(...)` returns a **Page** and uses the same cursor discipline as `sessions.list(...)`: the initial request supplies `sessionID`, ordering, and page size; continuation supplies `sessionID` plus only an opaque branded message cursor carrying ordering, page size, direction, and message anchor. Using a cursor with another Session is invalid.
|
||||
- `sessions.message({ sessionID, messageID })` is a required resource lookup. An unknown Session fails with `SessionNotFoundError`; a known Session with an absent or differently owned message fails with `MessageNotFoundError` without disclosing cross-Session ownership. Absence is not represented as `undefined` across the public HTTP boundary.
|
||||
- `sessions.interrupt({ sessionID })` first verifies that the durable Session exists, failing with `SessionNotFoundError` otherwise. For a known Session, interruption is idempotent: idle, already-settled, or locally unowned execution is a no-op.
|
||||
- `sessions.active()` snapshots the current process's foreground Session drain registry as a record of Session IDs to `{ type: "running" }`. Missing IDs are inactive; background subagents and tasks do not make their parent Session active, and process restart clears the registry.
|
||||
- `sessions.context({ sessionID })` preserves the existing message-only operation. It returns projected conversational messages selected as Session context; it does not include or represent the complete provider request context, whose baseline system context and other contributions remain separate.
|
||||
- **Open question**: Should a future, separately named operation expose the complete provider request context, including baseline system context, selected source contributions, and context-epoch metadata?
|
||||
- `sessions.prompt(...)` exposes `resume?: boolean`. Omitting it preserves durable admission followed by an advisory execution wake; `resume: false` requests durable admit-only behavior.
|
||||
|
||||
@@ -31,10 +31,6 @@
|
||||
"name": "@opencode-ai/app",
|
||||
"version": "1.17.11",
|
||||
"dependencies": {
|
||||
"@dnd-kit/abstract": "0.5.0",
|
||||
"@dnd-kit/dom": "0.5.0",
|
||||
"@dnd-kit/helpers": "0.5.0",
|
||||
"@dnd-kit/solid": "0.5.0",
|
||||
"@kobalte/core": "catalog:",
|
||||
"@opencode-ai/core": "workspace:*",
|
||||
"@opencode-ai/sdk": "workspace:*",
|
||||
@@ -63,7 +59,7 @@
|
||||
"diff": "catalog:",
|
||||
"effect": "catalog:",
|
||||
"fuzzysort": "catalog:",
|
||||
"ghostty-web": "github:anomalyco/ghostty-web#513463a6f1190253057e8a3f0dac8f6ee8393553",
|
||||
"ghostty-web": "github:anomalyco/ghostty-web#main",
|
||||
"luxon": "catalog:",
|
||||
"marked": "catalog:",
|
||||
"marked-shiki": "catalog:",
|
||||
@@ -686,9 +682,9 @@
|
||||
"typescript": "catalog:",
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@opentui/core": ">=0.3.4",
|
||||
"@opentui/keymap": ">=0.3.4",
|
||||
"@opentui/solid": ">=0.3.4",
|
||||
"@opentui/core": ">=0.4.2",
|
||||
"@opentui/keymap": ">=0.4.2",
|
||||
"@opentui/solid": ">=0.4.2",
|
||||
},
|
||||
"optionalPeers": [
|
||||
"@opentui/core",
|
||||
@@ -1076,9 +1072,9 @@
|
||||
"@npmcli/arborist": "9.4.0",
|
||||
"@octokit/rest": "22.0.0",
|
||||
"@openauthjs/openauth": "0.0.0-20250322224806",
|
||||
"@opentui/core": "0.3.4",
|
||||
"@opentui/keymap": "0.3.4",
|
||||
"@opentui/solid": "0.3.4",
|
||||
"@opentui/core": "0.4.2",
|
||||
"@opentui/keymap": "0.4.2",
|
||||
"@opentui/solid": "0.4.2",
|
||||
"@pierre/diffs": "1.2.10",
|
||||
"@playwright/test": "1.59.1",
|
||||
"@sentry/solid": "10.36.0",
|
||||
@@ -1455,20 +1451,6 @@
|
||||
|
||||
"@develar/schema-utils": ["@develar/schema-utils@2.6.5", "", { "dependencies": { "ajv": "^6.12.0", "ajv-keywords": "^3.4.1" } }, "sha512-0cp4PsWQ/9avqTVMCtZ+GirikIA36ikvjtHweU4/j8yLtgObI0+JUPhYFScgwlteveGB1rt3Cm8UhN04XayDig=="],
|
||||
|
||||
"@dnd-kit/abstract": ["@dnd-kit/abstract@0.5.0", "", { "dependencies": { "@dnd-kit/geometry": "^0.5.0", "@dnd-kit/state": "^0.5.0", "tslib": "^2.6.2" } }, "sha512-hi13iMJgjPX/KDYVKg5VeDIhmYiV6buc9bAX+tCLYf4QdyYjPbsXjn2sPo6m7fQ6SGJBEFgHJ2PemeKDUbwBaA=="],
|
||||
|
||||
"@dnd-kit/collision": ["@dnd-kit/collision@0.5.0", "", { "dependencies": { "@dnd-kit/abstract": "^0.5.0", "@dnd-kit/geometry": "^0.5.0", "tslib": "^2.6.2" } }, "sha512-xUqRn3lS7oqLkT0AnnHS/STh/Czvwe1UapZFYiLbsUGxopMsQd4teaPCzPouOThoMdGEe+dHWjfqJl6t9iG4mQ=="],
|
||||
|
||||
"@dnd-kit/dom": ["@dnd-kit/dom@0.5.0", "", { "dependencies": { "@dnd-kit/abstract": "^0.5.0", "@dnd-kit/collision": "^0.5.0", "@dnd-kit/geometry": "^0.5.0", "@dnd-kit/state": "^0.5.0", "tslib": "^2.6.2" } }, "sha512-f2xFJp5SYQ8EW/Fbtaa8iBb66hpkWc7qa8vU826KW11/tb44sH+AisZnGtwOOTWTQ0GraqBDr5ixTErww+eKXw=="],
|
||||
|
||||
"@dnd-kit/geometry": ["@dnd-kit/geometry@0.5.0", "", { "dependencies": { "@dnd-kit/state": "^0.5.0", "tslib": "^2.6.2" } }, "sha512-ubHQS1CiSDH8ssYH2xG5BnpwPSFP1tStXXjug7/Ba6qnQdu/EUH47l6QXKIksQnnanfVfDf0aGeevRxgZlj28A=="],
|
||||
|
||||
"@dnd-kit/helpers": ["@dnd-kit/helpers@0.5.0", "", { "dependencies": { "@dnd-kit/abstract": "^0.5.0", "tslib": "^2.6.2" } }, "sha512-i4y+51/icSw+OHMr/su19qhnmNhAzh8PnBwXvapFYTd+64oodIyJRiRkB+hhfxAfnur7RYSW8qacDTrXjg2XOg=="],
|
||||
|
||||
"@dnd-kit/solid": ["@dnd-kit/solid@0.5.0", "", { "dependencies": { "@dnd-kit/abstract": "^0.5.0", "@dnd-kit/dom": "^0.5.0", "@dnd-kit/state": "^0.5.0", "tslib": "^2.6.2" }, "peerDependencies": { "solid-js": "^1.8.0" } }, "sha512-IKDqVZICS0jEeUzpJMIIF61w0WA4zisyx9U7K7Skbmkb/kQSDa3lB0cOc0947RwSO+ALoxytRNOuoNfyOIm3lQ=="],
|
||||
|
||||
"@dnd-kit/state": ["@dnd-kit/state@0.5.0", "", { "dependencies": { "@preact/signals-core": "^1.10.0", "tslib": "^2.6.2" } }, "sha512-y7XbabQqjF58Lk8YmDQuR8l6QjN+Kh4qlGEjUvHuIeasLk1QP+9L5diXS98VMxQIivyMmUtX2//f+3N7qPJX4w=="],
|
||||
|
||||
"@dot/log": ["@dot/log@0.1.5", "", { "dependencies": { "chalk": "^4.1.2", "loglevelnext": "^6.0.0", "p-defer": "^3.0.0" } }, "sha512-ECraEVJWv2f2mWK93lYiefUkphStVlKD6yKDzisuoEmxuLKrxO9iGetHK2DoEAkj7sxjE886n0OUVVCUx0YPNg=="],
|
||||
|
||||
"@drizzle-team/brocli": ["@drizzle-team/brocli@0.11.0", "", {}, "sha512-hD3pekGiPg0WPCCGAZmusBBJsDqGUR66Y452YgQsZOnkdQ7ViEPKuyP4huUGEZQefp8g34RRodXYmJ2TbCH+tg=="],
|
||||
@@ -2009,27 +1991,27 @@
|
||||
|
||||
"@opentelemetry/semantic-conventions": ["@opentelemetry/semantic-conventions@1.41.1", "", {}, "sha512-/UhIkaZgPutTFmQ7RnIJGgDXZmtEJ7Dvi86xNTFWcnRxVRNk/aotsqDJYeEvDP+FSMB2SdW+pQzNMcWP0rwuNA=="],
|
||||
|
||||
"@opentui/core": ["@opentui/core@0.3.4", "", { "dependencies": { "bun-ffi-structs": "0.2.2", "diff": "9.0.0", "marked": "17.0.1", "string-width": "7.2.0", "strip-ansi": "7.1.2", "yoga-layout": "3.2.1" }, "optionalDependencies": { "@opentui/core-darwin-arm64": "0.3.4", "@opentui/core-darwin-x64": "0.3.4", "@opentui/core-linux-arm64": "0.3.4", "@opentui/core-linux-arm64-musl": "0.3.4", "@opentui/core-linux-x64": "0.3.4", "@opentui/core-linux-x64-musl": "0.3.4", "@opentui/core-win32-arm64": "0.3.4", "@opentui/core-win32-x64": "0.3.4" }, "peerDependencies": { "web-tree-sitter": "0.25.10" } }, "sha512-y0DlrChP9lcJ4jC5z/1wMS34+ygfSTW7gD5OJHwJaAScfmlFvuJOZbwmCGrJURZ+5wFBxuOi9LatZsmeAUIKAA=="],
|
||||
"@opentui/core": ["@opentui/core@0.4.2", "", { "dependencies": { "bun-ffi-structs": "0.2.3", "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.2", "@opentui/core-darwin-x64": "0.4.2", "@opentui/core-linux-arm64": "0.4.2", "@opentui/core-linux-arm64-musl": "0.4.2", "@opentui/core-linux-x64": "0.4.2", "@opentui/core-linux-x64-musl": "0.4.2", "@opentui/core-win32-arm64": "0.4.2", "@opentui/core-win32-x64": "0.4.2" }, "peerDependencies": { "web-tree-sitter": "0.25.10" } }, "sha512-ulx6RMqftf2fm7Itf9e81GcCDMNY6NAhmnKYhllDOMYD+PxYXR+vomy2bxQNV5ow31RE7s8WQFnb7hWTRUbx2g=="],
|
||||
|
||||
"@opentui/core-darwin-arm64": ["@opentui/core-darwin-arm64@0.3.4", "", { "os": "darwin", "cpu": "arm64" }, "sha512-4A7JYXUsZqhu9PPCe07E30ourSJYkitkwMujUyNKjM5e/dHNDVnz+5r5cO3M5snofLafc1DN7+9jEPn4UQzchQ=="],
|
||||
"@opentui/core-darwin-arm64": ["@opentui/core-darwin-arm64@0.4.2", "", { "os": "darwin", "cpu": "arm64" }, "sha512-is+O+sS/l3E9cZXyM9pRF1WhqnE+hYSPYoZkbseR9CthJcaWPGi3R3jUJa1cLj325252jWgxVupnDqFUtKg36w=="],
|
||||
|
||||
"@opentui/core-darwin-x64": ["@opentui/core-darwin-x64@0.3.4", "", { "os": "darwin", "cpu": "x64" }, "sha512-Jvm9E8n2sPhKEyKSXn9GlmJcj8WoJXJTooXb3djwjVaiimjihIj0XxHzCWhdqbDtQp+VxDFyCKoQagOOz20qhA=="],
|
||||
"@opentui/core-darwin-x64": ["@opentui/core-darwin-x64@0.4.2", "", { "os": "darwin", "cpu": "x64" }, "sha512-ACi42h81DurSeybUAD1XyKT6xmXZcKeTxS54lZFi0CVZh46w0g99vNj8PlQzIFXvvFLT0e0IlRS//eWSWS2zGQ=="],
|
||||
|
||||
"@opentui/core-linux-arm64": ["@opentui/core-linux-arm64@0.3.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-0uPuHCeZxm/O7+L+iNQl8zRAfehiwYstKkT9J0uTZO64/byBCLvy5lvn1DiE/72s/nTJ5nwpLN+pQs2/WYVKLQ=="],
|
||||
"@opentui/core-linux-arm64": ["@opentui/core-linux-arm64@0.4.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-RjOx2HcjLRtGSy9WrAGSdr5M9SpJuPifPORpImx6Mciovw0ltnE0uoYjIyor82uf6/LExWC7YA2AcAl+YBxayA=="],
|
||||
|
||||
"@opentui/core-linux-arm64-musl": ["@opentui/core-linux-arm64-musl@0.3.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-sJYUzYcSOb5PCXRlhwsse/fdsMiVomNvIwq/2TDhAANef+YPO3Br+OH9kQRbuj0bjVDmUS36SGYWSTFu2lUO+A=="],
|
||||
"@opentui/core-linux-arm64-musl": ["@opentui/core-linux-arm64-musl@0.4.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-heNciL2ngPU+kq1h01PHLsxn6Fr8iqTFtbxSdVbhaY3XihuIjkuXyEhFeuoa1lsXY7Bb2gpWnX5EQVWnZsAuDQ=="],
|
||||
|
||||
"@opentui/core-linux-x64": ["@opentui/core-linux-x64@0.3.4", "", { "os": "linux", "cpu": "x64" }, "sha512-btYIQeNdPbN4JCrCjVB/RwMGrnRY7qWB2piNEfALSByuULKNjPKQ33PYIj38Yd01zCvCV7FotIeXEGSHx3tgCA=="],
|
||||
"@opentui/core-linux-x64": ["@opentui/core-linux-x64@0.4.2", "", { "os": "linux", "cpu": "x64" }, "sha512-9s0s/ooK+AhWP306By3gu+XhzcVEThC2sqKMPK1nQmGDujQhd+xOrtbtfCVcJSx62UzAovC2VNqypvP8vHByOg=="],
|
||||
|
||||
"@opentui/core-linux-x64-musl": ["@opentui/core-linux-x64-musl@0.3.4", "", { "os": "linux", "cpu": "x64" }, "sha512-fhmUey4oJJ2+N62xlIgAPxAl36Fa7wYffqDOT4QLpm0jfyD5xzo+wL/hr2zUqaEI439R8Iq6jHNxf/Nsx1WuuQ=="],
|
||||
"@opentui/core-linux-x64-musl": ["@opentui/core-linux-x64-musl@0.4.2", "", { "os": "linux", "cpu": "x64" }, "sha512-Cjv6Bv7l3p/KLNJr5RyqCS0FmRlAGJnkA2IK3S+HkHhCOv/O02S1G+DBUY6POnyjp1eNy95vauustApobhdbig=="],
|
||||
|
||||
"@opentui/core-win32-arm64": ["@opentui/core-win32-arm64@0.3.4", "", { "os": "win32", "cpu": "arm64" }, "sha512-sh432vPU+eLp8eA4I0KWKKn7D0VHbk01YTg6mA9/ihCNYHntc6LZ8/sLvsPv8CvKscMotfIkh3M5YhdS36BuXw=="],
|
||||
"@opentui/core-win32-arm64": ["@opentui/core-win32-arm64@0.4.2", "", { "os": "win32", "cpu": "arm64" }, "sha512-mfJZrJ0TNPFRZUzXNsxAPe1YdiWsy/vbTl93+yeXGHPI1B8Qnk9V5hpzSxxEyBGhlTHSfGNtgiO+VrrdRC3kZA=="],
|
||||
|
||||
"@opentui/core-win32-x64": ["@opentui/core-win32-x64@0.3.4", "", { "os": "win32", "cpu": "x64" }, "sha512-dw8FcjUZaLAjw25P3/7BarobCh/QOHn3srYaWYQdysoqyvSlPkQumpI8kV/KgpJtdITU1GW02MQC4EeLIFFalA=="],
|
||||
"@opentui/core-win32-x64": ["@opentui/core-win32-x64@0.4.2", "", { "os": "win32", "cpu": "x64" }, "sha512-P2oguG3ng3OMjAdasFSA3GhHaQXtzDUsIRDGbzWFOimpZ/zMemidp+JQ0V8V6XwK6Utk5G0aQ03oBaRCoLyYDw=="],
|
||||
|
||||
"@opentui/keymap": ["@opentui/keymap@0.3.4", "", { "dependencies": { "@opentui/core": "0.3.4" }, "peerDependencies": { "@opentui/react": "0.3.4", "@opentui/solid": "0.3.4", "react": ">=19.2.0", "solid-js": "1.9.12" }, "optionalPeers": ["@opentui/react", "@opentui/solid", "react", "solid-js"] }, "sha512-8fo6BZWQgCjANfbKkzPo0ghAzS1E7TlHjDDS+SUhrX01qEUO1clFTRssKluHbXd2UJY1Ehle01TV5bFmY78f8w=="],
|
||||
"@opentui/keymap": ["@opentui/keymap@0.4.2", "", { "dependencies": { "@opentui/core": "0.4.2" }, "peerDependencies": { "@opentui/react": "0.4.2", "@opentui/solid": "0.4.2", "react": ">=19.2.0", "solid-js": "1.9.12" }, "optionalPeers": ["@opentui/react", "@opentui/solid", "react", "solid-js"] }, "sha512-wxBEFfWgm3feqCRLckWg1JH4tbMJinpyK3yobkLTsWJ7PDsM+fPoFMyQ8ieKVdUL2eP6ELTmHvM1bHKShZ7SUQ=="],
|
||||
|
||||
"@opentui/solid": ["@opentui/solid@0.3.4", "", { "dependencies": { "@babel/core": "7.28.0", "@babel/preset-typescript": "7.27.1", "@opentui/core": "0.3.4", "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-gin1VnsVBahX0nrU3mpgh5U1qvyJBIZu4NE5mc0YnObWOEf9HVNxKY4/BpUvQPh91kT6zeOzTBvAvYK4R7g9MQ=="],
|
||||
"@opentui/solid": ["@opentui/solid@0.4.2", "", { "dependencies": { "@babel/core": "7.28.0", "@babel/preset-typescript": "7.27.1", "@opentui/core": "0.4.2", "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-zuYXsnrlsMtnXrS7QCYBdPzMtUSonG2LqnJikBR2NjEE2O4zEKvJd48n3eB1igcxjv96tiotTXRNCylYS0SNdQ=="],
|
||||
|
||||
"@oslojs/asn1": ["@oslojs/asn1@1.0.0", "", { "dependencies": { "@oslojs/binary": "1.0.0" } }, "sha512-zw/wn0sj0j0QKbIXfIlnEcTviaCzYOY3V5rAyjR6YtOByFtJiT574+8p9Wlach0lZH9fddD4yb9laEAIl4vXQA=="],
|
||||
|
||||
@@ -2305,8 +2287,6 @@
|
||||
|
||||
"@poppinss/exception": ["@poppinss/exception@1.2.3", "", {}, "sha512-dCED+QRChTVatE9ibtoaxc+WkdzOSjYTKi/+uacHWIsfodVfpsueo3+DKpgU5Px8qXjgmXkSvhXvSCz3fnP9lw=="],
|
||||
|
||||
"@preact/signals-core": ["@preact/signals-core@1.14.3", "", {}, "sha512-m0K3vnbSLC5rHs2ZVfeAMvBtT1zIyq4mxx5OlNncSgMj5Iz6W5Rn3kPrDxAC+iIKmiVe0lSl6U37t5ZkEWoVAw=="],
|
||||
|
||||
"@protobuf-ts/plugin": ["@protobuf-ts/plugin@2.11.1", "", { "dependencies": { "@bufbuild/protobuf": "^2.4.0", "@bufbuild/protoplugin": "^2.4.0", "@protobuf-ts/protoc": "^2.11.1", "@protobuf-ts/runtime": "^2.11.1", "@protobuf-ts/runtime-rpc": "^2.11.1", "typescript": "^3.9" }, "bin": { "protoc-gen-ts": "bin/protoc-gen-ts", "protoc-gen-dump": "bin/protoc-gen-dump" } }, "sha512-HyuprDcw0bEEJqkOWe1rnXUP0gwYLij8YhPuZyZk6cJbIgc/Q0IFgoHQxOXNIXAcXM4Sbehh6kjVnCzasElw1A=="],
|
||||
|
||||
"@protobuf-ts/protoc": ["@protobuf-ts/protoc@2.11.1", "", { "bin": { "protoc": "protoc.js" } }, "sha512-mUZJaV0daGO6HUX90o/atzQ6A7bbN2RSuHtdwo8SSF2Qoe3zHwa4IHyCN1evftTeHfLmdz+45qo47sL+5P8nyg=="],
|
||||
@@ -3209,7 +3189,7 @@
|
||||
|
||||
"builder-util-runtime": ["builder-util-runtime@9.7.0", "", { "dependencies": { "debug": "^4.3.4", "sax": "^1.2.4" } }, "sha512-g/kR520giAFYkSXTzcmF3kqQq7wi8F6N6SzeDgZrqTBN+VHdmgWOyTdD1yD7AATDId/yXLvuP34CxW46/BwCdw=="],
|
||||
|
||||
"bun-ffi-structs": ["bun-ffi-structs@0.2.2", "", { "peerDependencies": { "typescript": "^5" } }, "sha512-N/ZWtyN0piZlrXQT7TO0V+q952orYqkfhXRXM1Hcbb+R3QSiBH4vLnib187Mrs1H7pWIYECAmPeapGYDOMCl+w=="],
|
||||
"bun-ffi-structs": ["bun-ffi-structs@0.2.3", "", { "peerDependencies": { "typescript": "^5" } }, "sha512-pgJiXP+hEgFo9qG51J6ItfY4ocs3vniwNzJ9WhoakB3QB2GdzQxX2EXssentPYlB2hOfJrTjO6iIQkWYzUodpg=="],
|
||||
|
||||
"bun-pty": ["bun-pty@0.4.8", "", {}, "sha512-rO70Mrbr13+jxHHHu2YBkk2pNqrJE5cJn29WE++PUr+GFA0hq/VgtQPZANJ8dJo6d7XImvBk37Innt8GM7O28w=="],
|
||||
|
||||
@@ -3801,7 +3781,7 @@
|
||||
|
||||
"get-tsconfig": ["get-tsconfig@4.14.0", "", { "dependencies": { "resolve-pkg-maps": "^1.0.0" } }, "sha512-yTb+8DXzDREzgvYmh6s9vHsSVCHeC0G3PI5bEXNBHtmshPnO+S5O7qgLEOn0I5QvMy6kpZN8K1NKGyilLb93wA=="],
|
||||
|
||||
"ghostty-web": ["ghostty-web@github:anomalyco/ghostty-web#513463a", {}, "anomalyco-ghostty-web-513463a", "sha512-GZR8LSmgGzViWnBJrqRI8MpAZRCJxhcr1Hi9Tyeh7YRooHZQjK9J97FQRD3tbBaM2wjq05gzGY2UEsG+JtZeBw=="],
|
||||
"ghostty-web": ["ghostty-web@github:anomalyco/ghostty-web#20bd361", {}, "anomalyco-ghostty-web-20bd361", "sha512-dW0nwaiBBcun9y5WJSvm3HxDLe5o9V0xLCndQvWonRVubU8CS1PHxZpLffyPt1YujPWC13ez03aWxcuKBPYYGQ=="],
|
||||
|
||||
"giget": ["giget@2.0.0", "", { "dependencies": { "citty": "^0.1.6", "consola": "^3.4.0", "defu": "^6.1.4", "node-fetch-native": "^1.6.6", "nypm": "^0.6.0", "pathe": "^2.0.3" }, "bin": { "giget": "dist/cli.mjs" } }, "sha512-L5bGsVkxJbJgdnwyuheIunkGatUF/zssUoxxjACCseZYAVbaqdh9Tsmmlkl8vYan09H7sbvKt4pS8GqKLBrEzA=="],
|
||||
|
||||
@@ -5567,8 +5547,6 @@
|
||||
|
||||
"yoctocolors": ["yoctocolors@2.1.2", "", {}, "sha512-CzhO+pFNo8ajLM2d2IW/R93ipy99LWjtwblvC1RsoSUMZgyLbYFr221TnSNT7GjGdYui6P459mw9JH/g/zW2ug=="],
|
||||
|
||||
"yoga-layout": ["yoga-layout@3.2.1", "", {}, "sha512-0LPOt3AxKqMdFBZA3HBAt/t/8vIKq7VaQYbuA8WxCgung+p9TVyKRYdpvCb80HcdTN2NkbIKbhNwKUfm3tQywQ=="],
|
||||
|
||||
"youch": ["youch@4.1.0-beta.10", "", { "dependencies": { "@poppinss/colors": "^4.1.5", "@poppinss/dumper": "^0.6.4", "@speed-highlight/core": "^1.2.7", "cookie": "^1.0.2", "youch-core": "^0.3.3" } }, "sha512-rLfVLB4FgQneDr0dv1oddCVZmKjcJ6yX6mS4pU82Mq/Dt9a3cLZQ62pDBL4AUO+uVrCvtWz3ZFUL2HFAFJ/BXQ=="],
|
||||
|
||||
"youch-core": ["youch-core@0.3.3", "", { "dependencies": { "@poppinss/exception": "^1.2.2", "error-stack-parser-es": "^1.0.5" } }, "sha512-ho7XuGjLaJ2hWHoK8yFnsUGy2Y5uDpqSTq1FkHLK4/oqKtyUU1AFbOOxY4IpC9f0fTLjwYbslUz0Po5BpD1wrA=="],
|
||||
|
||||
+4
-4
@@ -1,8 +1,8 @@
|
||||
{
|
||||
"nodeModules": {
|
||||
"x86_64-linux": "sha256-OiWvZ57vuyHwiIKNtW1n1KX+MLmOXVG3x4fLKvUoGQw=",
|
||||
"aarch64-linux": "sha256-RnPLxVEg/UsL5IeIFWmXMSLUOG6rVrajYxhyDYj1vTA=",
|
||||
"aarch64-darwin": "sha256-KPIgcBA0pTFBPrCTSZgIbvEorbtWcMgXvyX9bFAypVs=",
|
||||
"x86_64-darwin": "sha256-6jVU7/uVId0VD24MVQ8s8Ill5b6PsKdlBgHg+oceKRg="
|
||||
"x86_64-linux": "sha256-drc/Ev96W6b8b0b5LqdZeeGDQ1SMgsz8r5cMO91ei2o=",
|
||||
"aarch64-linux": "sha256-Ti0hNjhUgkVtdb54vea/lpI0ltDwLoPitVyHtx4JGwY=",
|
||||
"aarch64-darwin": "sha256-br4iQ/kK3tSGp+1FefiCTlwsCRhHHhGbKzSixGWaCto=",
|
||||
"x86_64-darwin": "sha256-h7yje968Kyh8/mVY19YmDB5g693XDhIf0XnuwukzCWE="
|
||||
}
|
||||
}
|
||||
|
||||
+3
-3
@@ -39,9 +39,9 @@
|
||||
"@octokit/rest": "22.0.0",
|
||||
"@hono/standard-validator": "0.2.0",
|
||||
"@hono/zod-validator": "0.4.2",
|
||||
"@opentui/core": "0.3.4",
|
||||
"@opentui/keymap": "0.3.4",
|
||||
"@opentui/solid": "0.3.4",
|
||||
"@opentui/core": "0.4.2",
|
||||
"@opentui/keymap": "0.4.2",
|
||||
"@opentui/solid": "0.4.2",
|
||||
"@tanstack/solid-virtual": "3.13.28",
|
||||
"@shikijs/stream": "4.2.0",
|
||||
"ulid": "3.0.1",
|
||||
|
||||
@@ -1,79 +0,0 @@
|
||||
import { expect, test } from "@playwright/test"
|
||||
import { mockOpenCodeServer } from "../utils/mock-server"
|
||||
import { expectSessionTitle } from "../utils/waits"
|
||||
|
||||
const directory = "C:/OpenCode/HiddenTerminalRegression"
|
||||
const projectID = "proj_hidden_terminal_regression"
|
||||
const sessionID = "ses_hidden_terminal_regression"
|
||||
const title = "Hidden terminal regression"
|
||||
|
||||
test("unmounts the terminal renderer while the pane is hidden", async ({ page }) => {
|
||||
await page.setViewportSize({ width: 1400, height: 900 })
|
||||
await mockOpenCodeServer(page, {
|
||||
directory,
|
||||
project: {
|
||||
id: projectID,
|
||||
worktree: directory,
|
||||
vcs: "git",
|
||||
name: "hidden-terminal-regression",
|
||||
time: { created: 1700000000000, updated: 1700000000000 },
|
||||
sandboxes: [],
|
||||
},
|
||||
provider: {
|
||||
all: [
|
||||
{
|
||||
id: "opencode",
|
||||
name: "OpenCode",
|
||||
models: { test: { id: "test", name: "Test", limit: { context: 200_000 } } },
|
||||
},
|
||||
],
|
||||
connected: ["opencode"],
|
||||
default: { providerID: "opencode", modelID: "test" },
|
||||
},
|
||||
sessions: [
|
||||
{
|
||||
id: sessionID,
|
||||
slug: "hidden-terminal-regression",
|
||||
projectID,
|
||||
directory,
|
||||
title,
|
||||
version: "dev",
|
||||
time: { created: 1700000000000, updated: 1700000000000 },
|
||||
},
|
||||
],
|
||||
pageMessages: () => ({ items: [] }),
|
||||
})
|
||||
await page.route("**/pty", (route) =>
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({ id: "pty_hidden_terminal", title: "Terminal 1" }),
|
||||
}),
|
||||
)
|
||||
await page.route("**/pty/pty_hidden_terminal", (route) =>
|
||||
route.fulfill({ status: 200, contentType: "application/json", body: "{}" }),
|
||||
)
|
||||
await page.routeWebSocket("**/pty/pty_hidden_terminal/connect", () => undefined)
|
||||
|
||||
await page.goto(`/${base64Encode(directory)}/session/${sessionID}`)
|
||||
await expectSessionTitle(page, title)
|
||||
|
||||
await page.keyboard.press("Control+Backquote")
|
||||
const panel = page.locator("#terminal-panel")
|
||||
await expect(panel).toHaveAttribute("aria-hidden", "false")
|
||||
await expect(page.locator('[data-component="terminal"]')).toBeVisible()
|
||||
|
||||
await page.keyboard.press("Control+Backquote")
|
||||
await expect(panel).toHaveAttribute("aria-hidden", "true")
|
||||
await expect(page.locator('[data-component="terminal"]')).toHaveCount(0)
|
||||
|
||||
await page.setViewportSize({ width: 1200, height: 700 })
|
||||
await expect(page.locator('[data-component="terminal"]')).toHaveCount(0)
|
||||
|
||||
await page.keyboard.press("Control+Backquote")
|
||||
await expect(page.locator('[data-component="terminal"]')).toBeVisible()
|
||||
})
|
||||
|
||||
function base64Encode(value: string) {
|
||||
return Buffer.from(value, "utf8").toString("base64").replace(/\+/g, "-").replace(/\//g, "_").replace(/=/g, "")
|
||||
}
|
||||
@@ -44,10 +44,6 @@
|
||||
"vite-plugin-solid": "catalog:"
|
||||
},
|
||||
"dependencies": {
|
||||
"@dnd-kit/abstract": "0.5.0",
|
||||
"@dnd-kit/dom": "0.5.0",
|
||||
"@dnd-kit/helpers": "0.5.0",
|
||||
"@dnd-kit/solid": "0.5.0",
|
||||
"@kobalte/core": "catalog:",
|
||||
"@opencode-ai/core": "workspace:*",
|
||||
"@opencode-ai/sdk": "workspace:*",
|
||||
@@ -76,7 +72,7 @@
|
||||
"diff": "catalog:",
|
||||
"effect": "catalog:",
|
||||
"fuzzysort": "catalog:",
|
||||
"ghostty-web": "github:anomalyco/ghostty-web#513463a6f1190253057e8a3f0dac8f6ee8393553",
|
||||
"ghostty-web": "github:anomalyco/ghostty-web#main",
|
||||
"luxon": "catalog:",
|
||||
"marked": "catalog:",
|
||||
"marked-shiki": "catalog:",
|
||||
|
||||
@@ -16,7 +16,6 @@ import {
|
||||
type Component,
|
||||
createEffect,
|
||||
createMemo,
|
||||
createRenderEffect,
|
||||
createResource,
|
||||
createSignal,
|
||||
ErrorBoundary,
|
||||
@@ -279,11 +278,10 @@ function QueryProvider(props: ParentProps) {
|
||||
function BodyDesignClass() {
|
||||
const settings = useSettings()
|
||||
|
||||
createRenderEffect(() => {
|
||||
createEffect(() => {
|
||||
if (typeof document === "undefined") return
|
||||
|
||||
const enabled = settings.general.newLayoutDesigns()
|
||||
document.body.toggleAttribute("data-new-layout", enabled)
|
||||
document.body.classList.toggle("text-12-regular", !enabled)
|
||||
document.body.classList.toggle("font-(family-name:--font-family-text)", enabled)
|
||||
document.body.classList.toggle("text-[13px]", enabled)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import "@pierre/trees/web-components"
|
||||
import { FileTree } from "@pierre/trees"
|
||||
import { Dialog, DialogBody, DialogFooter, DialogHeader, DialogTitle } from "@opencode-ai/ui/v2/dialog-v2"
|
||||
import { Dialog, DialogFooter } from "@opencode-ai/ui/v2/dialog-v2"
|
||||
import { ButtonV2 } from "@opencode-ai/ui/v2/button-v2"
|
||||
import { TextInputV2 } from "@opencode-ai/ui/v2/text-input-v2"
|
||||
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
||||
@@ -27,7 +27,6 @@ import {
|
||||
pickerRoot,
|
||||
} from "./directory-picker-domain"
|
||||
import "./dialog-select-directory-v2.css"
|
||||
import { DividerV2 } from "@opencode-ai/ui/v2/divider-v2"
|
||||
|
||||
interface DialogSelectDirectoryV2Props {
|
||||
title?: string
|
||||
@@ -267,12 +266,8 @@ export function DialogSelectDirectoryV2(props: DialogSelectDirectoryV2Props) {
|
||||
onCleanup(() => tree?.cleanUp())
|
||||
|
||||
return (
|
||||
<Dialog size="large" class="directory-picker-v2">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{props.title ?? language.t("command.project.open")}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<DividerV2 />
|
||||
<DialogBody class="directory-picker-v2-body pt-4!">
|
||||
<Dialog title={props.title ?? language.t("command.project.open")} size="large" class="directory-picker-v2">
|
||||
<div class="directory-picker-v2-body">
|
||||
<div class="directory-picker-v2-path" ref={pathArea}>
|
||||
<TextInputV2
|
||||
value={input()}
|
||||
@@ -354,7 +349,7 @@ export function DialogSelectDirectoryV2(props: DialogSelectDirectoryV2Props) {
|
||||
</Show>
|
||||
</div>
|
||||
<div class="directory-picker-v2-selection">{policy.result(root(), selected(), rootValid())}</div>
|
||||
</DialogBody>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<ButtonV2 variant="neutral" onClick={() => dialog.close()}>
|
||||
{language.t("common.cancel")}
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
import { Popover } from "@kobalte/core/popover"
|
||||
import { For, Show, splitProps, type Accessor, type ComponentProps } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { DropdownMenu } from "@opencode-ai/ui/dropdown-menu"
|
||||
import { Icon } from "@opencode-ai/ui/icon"
|
||||
import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon"
|
||||
import { ProjectAvatar } from "@opencode-ai/ui/v2/project-avatar-v2"
|
||||
import { getProjectAvatarVariant } from "@/context/layout"
|
||||
import { useLanguage } from "@/context/language"
|
||||
@@ -26,23 +25,12 @@ export type PromptProjectControls = {
|
||||
add: (title: string, server?: string) => void
|
||||
}
|
||||
|
||||
const actionPrefix = "action:"
|
||||
const projectPrefix = "project:"
|
||||
|
||||
function projectKey(project: PromptProject) {
|
||||
return `${projectPrefix}${encodeURIComponent(project.server?.key ?? "")}:${encodeURIComponent(project.worktree)}`
|
||||
}
|
||||
|
||||
function actionKey(server?: string) {
|
||||
return `${actionPrefix}${encodeURIComponent(server ?? "")}`
|
||||
}
|
||||
|
||||
export function createPromptProjectController(input: {
|
||||
controls: Accessor<PromptProjectControls>
|
||||
onDone: () => void
|
||||
}) {
|
||||
const language = useLanguage()
|
||||
const [store, setStore] = createStore({ open: false, search: "", active: "" })
|
||||
const [store, setStore] = createStore({ open: false, search: "" })
|
||||
let searchRef: HTMLInputElement | undefined
|
||||
|
||||
const selected = () => {
|
||||
@@ -65,27 +53,8 @@ export function createPromptProjectController(input: {
|
||||
.controls()
|
||||
.available.map((project) => project.server)
|
||||
.filter((server, index, all) => server && all.findIndex((item) => item?.key === server.key) === index)
|
||||
const keys = () => {
|
||||
if (servers().length <= 1) {
|
||||
return [...projects().map(projectKey), actionKey(servers()[0]?.key)]
|
||||
}
|
||||
return [
|
||||
...servers().flatMap((server) =>
|
||||
projects()
|
||||
.filter((project) => project.server?.key === server!.key)
|
||||
.map(projectKey),
|
||||
),
|
||||
actionKey(),
|
||||
]
|
||||
}
|
||||
const initialActive = () => {
|
||||
const selectedKey = selected() ? projectKey(selected()!) : undefined
|
||||
const options = keys()
|
||||
if (selectedKey && options.includes(selectedKey)) return selectedKey
|
||||
return options[0] ?? ""
|
||||
}
|
||||
const close = () => {
|
||||
setStore({ open: false, search: "", active: "" })
|
||||
setStore({ open: false, search: "" })
|
||||
input.onDone()
|
||||
}
|
||||
const select = (project: PromptProject) => {
|
||||
@@ -98,7 +67,7 @@ export function createPromptProjectController(input: {
|
||||
close()
|
||||
}
|
||||
const add = (server?: string) => {
|
||||
setStore({ open: false, search: "", active: "" })
|
||||
setStore("open", false)
|
||||
input.controls().add(language.t("command.project.open"), server)
|
||||
}
|
||||
|
||||
@@ -106,11 +75,8 @@ export function createPromptProjectController(input: {
|
||||
selected,
|
||||
projects,
|
||||
servers,
|
||||
projectKey,
|
||||
actionKey,
|
||||
open: () => store.open,
|
||||
search: () => store.search,
|
||||
active: () => store.active,
|
||||
labels: {
|
||||
add: () => language.t("session.new.project.add"),
|
||||
clear: () => language.t("common.clear"),
|
||||
@@ -120,202 +86,50 @@ export function createPromptProjectController(input: {
|
||||
add,
|
||||
select,
|
||||
setOpen(open: boolean) {
|
||||
if (open) {
|
||||
setStore({ open: true, active: initialActive() })
|
||||
setTimeout(() => requestAnimationFrame(() => searchRef?.focus()))
|
||||
return
|
||||
}
|
||||
setStore({ open: false, search: "", active: "" })
|
||||
setStore("open", open)
|
||||
if (open) requestAnimationFrame(() => searchRef?.focus())
|
||||
},
|
||||
setSearch(value: string) {
|
||||
const search = value.trim().toLowerCase()
|
||||
const first = input
|
||||
.controls()
|
||||
.available.find((project) => !search || displayName(project).toLowerCase().includes(search))
|
||||
setStore({
|
||||
search: value,
|
||||
active: first ? projectKey(first) : actionKey(servers().length > 1 ? undefined : servers()[0]?.key),
|
||||
})
|
||||
},
|
||||
clearSearch() {
|
||||
setStore({ search: "", active: initialActive() })
|
||||
setTimeout(() => searchRef?.focus())
|
||||
},
|
||||
setActive(key: string) {
|
||||
setStore("active", key)
|
||||
},
|
||||
moveActive(delta: number) {
|
||||
const options = keys()
|
||||
if (options.length === 0) return
|
||||
const index = options.indexOf(store.active)
|
||||
const start = index === -1 ? 0 : index
|
||||
setStore("active", options[(start + delta + options.length) % options.length])
|
||||
},
|
||||
activeProject() {
|
||||
return store.active.startsWith(projectPrefix)
|
||||
? projects().find((project) => projectKey(project) === store.active)
|
||||
: undefined
|
||||
},
|
||||
activeServer() {
|
||||
return store.active.startsWith(actionPrefix)
|
||||
? decodeURIComponent(store.active.slice(actionPrefix.length)) || undefined
|
||||
: undefined
|
||||
},
|
||||
activeAction() {
|
||||
return store.active.startsWith(actionPrefix)
|
||||
setStore("search", value)
|
||||
},
|
||||
setSearchRef(el: HTMLInputElement) {
|
||||
searchRef = el
|
||||
},
|
||||
focusSearch() {
|
||||
setTimeout(() => requestAnimationFrame(() => searchRef?.focus()))
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export type PromptProjectController = ReturnType<typeof createPromptProjectController>
|
||||
|
||||
export function PromptProjectSelector(props: {
|
||||
controller: PromptProjectController
|
||||
placement?: "bottom" | "bottom-start"
|
||||
}) {
|
||||
let contentRef: HTMLDivElement | undefined
|
||||
let restoreTrigger = true
|
||||
|
||||
const activeItem = () =>
|
||||
props.controller.active()
|
||||
? contentRef?.querySelector<HTMLElement>(`[data-option-key="${CSS.escape(props.controller.active())}"]`)
|
||||
: undefined
|
||||
const afterClose = (callback: () => void) => {
|
||||
const complete = () => {
|
||||
if (contentRef?.isConnected) {
|
||||
requestAnimationFrame(complete)
|
||||
return
|
||||
}
|
||||
requestAnimationFrame(() => requestAnimationFrame(callback))
|
||||
}
|
||||
requestAnimationFrame(complete)
|
||||
}
|
||||
const selectProject = (project: PromptProject) => {
|
||||
restoreTrigger = false
|
||||
props.controller.setOpen(false)
|
||||
afterClose(() => props.controller.select(project))
|
||||
}
|
||||
const selectAction = (server?: string) => {
|
||||
restoreTrigger = false
|
||||
props.controller.setOpen(false)
|
||||
afterClose(() => props.controller.add(server))
|
||||
}
|
||||
const selectActive = () => {
|
||||
const project = props.controller.activeProject()
|
||||
if (project) {
|
||||
selectProject(project)
|
||||
return
|
||||
}
|
||||
if (props.controller.activeAction() && props.controller.servers().length > 1) {
|
||||
const item = activeItem()
|
||||
item?.focus()
|
||||
item?.dispatchEvent(new KeyboardEvent("keydown", { key: "ArrowRight", bubbles: true }))
|
||||
return
|
||||
}
|
||||
selectAction(props.controller.activeServer())
|
||||
}
|
||||
const moveActive = (delta: number) => {
|
||||
props.controller.moveActive(delta)
|
||||
queueMicrotask(() => activeItem()?.scrollIntoView({ block: "nearest" }))
|
||||
}
|
||||
const focusPreviousControl = () => {
|
||||
const target = Array.from(
|
||||
document.querySelectorAll<HTMLElement>(
|
||||
'button:not([disabled]), a[href], input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])',
|
||||
),
|
||||
)
|
||||
.filter((element) => !contentRef?.contains(element) && !element.hasAttribute("data-focus-trap"))
|
||||
.findLast((element) => element.offsetParent !== null)
|
||||
restoreTrigger = false
|
||||
target?.focus()
|
||||
queueMicrotask(() => {
|
||||
if (props.controller.open()) props.controller.setOpen(false)
|
||||
})
|
||||
}
|
||||
const selectedValue = () => {
|
||||
const project = props.controller.selected()
|
||||
return project ? props.controller.projectKey(project) : undefined
|
||||
}
|
||||
|
||||
export function PromptProjectSelector(props: { controller: PromptProjectController }) {
|
||||
return (
|
||||
<DropdownMenu
|
||||
<Popover
|
||||
open={props.controller.open()}
|
||||
placement={props.placement ?? "bottom"}
|
||||
placement="bottom-start"
|
||||
gutter={4}
|
||||
modal={false}
|
||||
onOpenChange={(open) => props.controller.setOpen(open)}
|
||||
>
|
||||
<DropdownMenu.Trigger as={ProjectTrigger} controller={props.controller} />
|
||||
<DropdownMenu.Portal>
|
||||
<DropdownMenu.Content
|
||||
ref={contentRef}
|
||||
id="prompt-project-menu"
|
||||
class="w-[243px] overflow-hidden rounded-md border-0 bg-v2-background-bg-layer-01 p-0 shadow-[var(--v2-elevation-floating)] focus:outline-none [&[data-closed]]:!animate-none"
|
||||
<Popover.Trigger as={ProjectTrigger} controller={props.controller} />
|
||||
<Popover.Portal>
|
||||
<Popover.Content
|
||||
class="w-[243px] overflow-hidden rounded-md bg-v2-background-bg-layer-01 shadow-[var(--v2-elevation-floating)] focus:outline-none"
|
||||
onOpenAutoFocus={(event) => event.preventDefault()}
|
||||
onPointerDownOutside={() => (restoreTrigger = false)}
|
||||
onFocusOutside={() => (restoreTrigger = false)}
|
||||
onCloseAutoFocus={(event) => {
|
||||
if (!restoreTrigger) event.preventDefault()
|
||||
}}
|
||||
>
|
||||
<div class="flex flex-col p-0.5">
|
||||
<div class="flex h-7 items-center gap-2 rounded-sm pl-3 pr-2.5 text-v2-icon-icon-muted">
|
||||
<div class="flex h-7 items-center gap-2 rounded px-3 text-v2-icon-icon-muted">
|
||||
<Icon name="magnifying-glass" size="small" class="shrink-0" />
|
||||
<input
|
||||
ref={(el) => props.controller.setSearchRef(el)}
|
||||
value={props.controller.search()}
|
||||
placeholder={props.controller.labels.search()}
|
||||
aria-autocomplete="list"
|
||||
aria-controls="prompt-project-menu"
|
||||
aria-activedescendant={props.controller.active() || undefined}
|
||||
class="h-7 min-w-0 flex-1 border-0 bg-transparent text-[13px] font-[440] leading-5 tracking-[-0.04px] text-v2-text-text-base outline-none placeholder:text-v2-text-text-faint"
|
||||
onInput={(event) => props.controller.setSearch(event.currentTarget.value)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Tab") {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
if (event.shiftKey) {
|
||||
focusPreviousControl()
|
||||
return
|
||||
}
|
||||
activeItem()?.focus()
|
||||
return
|
||||
}
|
||||
event.stopPropagation()
|
||||
if (event.key === "Escape") {
|
||||
event.preventDefault()
|
||||
props.controller.setOpen(false)
|
||||
return
|
||||
}
|
||||
if (event.altKey || event.metaKey) return
|
||||
if (event.key === "ArrowDown") {
|
||||
event.preventDefault()
|
||||
moveActive(1)
|
||||
return
|
||||
}
|
||||
if (event.key === "ArrowUp") {
|
||||
event.preventDefault()
|
||||
moveActive(-1)
|
||||
return
|
||||
}
|
||||
if (event.key === "Enter" && !event.isComposing) {
|
||||
event.preventDefault()
|
||||
selectActive()
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<Show when={props.controller.search().trim()}>
|
||||
<button
|
||||
type="button"
|
||||
class="flex size-5 items-center justify-center rounded-sm text-v2-icon-icon-muted hover:bg-v2-overlay-simple-overlay-hover"
|
||||
onPointerDown={(event) => event.preventDefault()}
|
||||
onClick={() => props.controller.clearSearch()}
|
||||
class="flex size-5 items-center justify-center rounded text-v2-icon-icon-muted hover:bg-v2-overlay-simple-overlay-hover"
|
||||
onClick={() => props.controller.setSearch("")}
|
||||
aria-label={props.controller.labels.clear()}
|
||||
>
|
||||
<Icon name="close-small" size="small" />
|
||||
@@ -325,80 +139,53 @@ export function PromptProjectSelector(props: {
|
||||
<Show
|
||||
when={props.controller.servers().length > 1}
|
||||
fallback={
|
||||
<DropdownMenu.RadioGroup value={selectedValue()}>
|
||||
<For each={props.controller.projects()}>
|
||||
{(project) => (
|
||||
<ProjectItem project={project} controller={props.controller} onSelect={selectProject} />
|
||||
)}
|
||||
</For>
|
||||
</DropdownMenu.RadioGroup>
|
||||
<For each={props.controller.projects()}>
|
||||
{(project) => (
|
||||
<ProjectItem
|
||||
project={project}
|
||||
selected={props.controller.selected()}
|
||||
onSelect={props.controller.select}
|
||||
/>
|
||||
)}
|
||||
</For>
|
||||
}
|
||||
>
|
||||
<For
|
||||
each={props.controller
|
||||
.servers()
|
||||
.filter((server) =>
|
||||
props.controller.projects().some((project) => project.server?.key === server!.key),
|
||||
)}
|
||||
>
|
||||
<For each={props.controller.servers()}>
|
||||
{(server) => (
|
||||
<div>
|
||||
<div class="flex h-7 select-none items-center pl-1.5 pr-3 text-[11px] font-[530] leading-none tracking-[0.05px] text-v2-text-text-faint">
|
||||
{server!.name}
|
||||
</div>
|
||||
<DropdownMenu.RadioGroup value={selectedValue()}>
|
||||
<For each={props.controller.projects().filter((project) => project.server?.key === server!.key)}>
|
||||
{(project) => (
|
||||
<ProjectItem project={project} controller={props.controller} onSelect={selectProject} />
|
||||
)}
|
||||
</For>
|
||||
</DropdownMenu.RadioGroup>
|
||||
<For each={props.controller.projects().filter((project) => project.server?.key === server!.key)}>
|
||||
{(project) => (
|
||||
<ProjectItem
|
||||
project={project}
|
||||
selected={props.controller.selected()}
|
||||
onSelect={props.controller.select}
|
||||
/>
|
||||
)}
|
||||
</For>
|
||||
<ProjectAction
|
||||
label={props.controller.labels.add()}
|
||||
onSelect={() => props.controller.add(server!.key)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
</Show>
|
||||
</div>
|
||||
<div class="h-px bg-v2-border-border-muted" />
|
||||
<div class="flex flex-col p-0.5">
|
||||
<Show
|
||||
when={props.controller.servers().length > 1}
|
||||
fallback={
|
||||
<ProjectAction
|
||||
server={props.controller.servers()[0]?.key}
|
||||
controller={props.controller}
|
||||
onSelect={selectAction}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<DropdownMenu.Sub>
|
||||
<DropdownMenu.SubTrigger
|
||||
id={props.controller.actionKey()}
|
||||
data-option-key={props.controller.actionKey()}
|
||||
class={projectActionClass}
|
||||
classList={{
|
||||
"!bg-v2-overlay-simple-overlay-hover": props.controller.active() === props.controller.actionKey(),
|
||||
}}
|
||||
onMouseEnter={() => props.controller.setActive(props.controller.actionKey())}
|
||||
>
|
||||
<Icon name="plus" size="small" />
|
||||
<span data-slot="dropdown-menu-item-label" class="min-w-0 flex-1 truncate leading-5">
|
||||
{props.controller.labels.add()}
|
||||
</span>
|
||||
<Icon name="chevron-right" size="small" class="shrink-0 text-v2-icon-icon-muted" />
|
||||
</DropdownMenu.SubTrigger>
|
||||
<DropdownMenu.Portal>
|
||||
<DropdownMenu.SubContent class="min-w-[180px] overflow-hidden rounded-md border-0 bg-v2-background-bg-layer-01 p-0.5 shadow-[var(--v2-elevation-floating)] focus:outline-none">
|
||||
<For each={props.controller.servers()}>
|
||||
{(server) => <ServerAction server={server!} onSelect={selectAction} />}
|
||||
</For>
|
||||
</DropdownMenu.SubContent>
|
||||
</DropdownMenu.Portal>
|
||||
</DropdownMenu.Sub>
|
||||
</Show>
|
||||
</div>
|
||||
</DropdownMenu.Content>
|
||||
</DropdownMenu.Portal>
|
||||
</DropdownMenu>
|
||||
<Show when={props.controller.servers().length <= 1}>
|
||||
<div class="h-px bg-v2-border-border-muted" />
|
||||
<div class="flex flex-col p-0.5">
|
||||
<ProjectAction
|
||||
label={props.controller.labels.add()}
|
||||
onSelect={() => props.controller.add(props.controller.servers()[0]?.key)}
|
||||
/>
|
||||
</div>
|
||||
</Show>
|
||||
</Popover.Content>
|
||||
</Popover.Portal>
|
||||
</Popover>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -418,29 +205,15 @@ export function PromptProjectAddButton(props: { controller: PromptProjectControl
|
||||
}
|
||||
|
||||
function ProjectTrigger(props: ComponentProps<"button"> & { controller: PromptProjectController }) {
|
||||
const [local, rest] = splitProps(props, ["controller", "class", "classList", "onClick", "onKeyDown"])
|
||||
const [local, rest] = splitProps(props, ["controller", "class", "onClick"])
|
||||
const project = () => local.controller.selected()
|
||||
return (
|
||||
<button
|
||||
{...rest}
|
||||
data-action="prompt-project"
|
||||
type="button"
|
||||
class="flex h-7 min-w-0 max-w-[203px] items-center gap-1.5 rounded-sm px-1.5 transition-colors focus-visible:bg-v2-overlay-simple-overlay-hover focus-visible:outline-none"
|
||||
classList={{
|
||||
...local.classList,
|
||||
"hover:bg-v2-overlay-simple-overlay-hover": !local.controller.open(),
|
||||
"bg-v2-overlay-simple-overlay-pressed": local.controller.open(),
|
||||
"text-v2-text-text-muted": local.controller.open(),
|
||||
}}
|
||||
onClick={local.onClick ?? (() => local.controller.setOpen(true))}
|
||||
onKeyDown={(event) => {
|
||||
if (!local.controller.open() && (event.key === "ArrowDown" || event.key === "ArrowUp")) {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
return
|
||||
}
|
||||
if (typeof local.onKeyDown === "function") local.onKeyDown(event)
|
||||
}}
|
||||
class="flex h-7 min-w-0 max-w-[203px] items-center gap-1.5 rounded-sm px-2 text-[13px] font-[440] leading-5 tracking-[-0.04px] text-v2-text-text-faint transition-colors hover:bg-v2-overlay-simple-overlay-hover focus-visible:bg-v2-overlay-simple-overlay-hover focus-visible:outline-none"
|
||||
onClick={() => local.controller.setOpen(true)}
|
||||
>
|
||||
<Show
|
||||
when={project()}
|
||||
@@ -464,88 +237,42 @@ function ProjectTrigger(props: ComponentProps<"button"> & { controller: PromptPr
|
||||
|
||||
function ProjectItem(props: {
|
||||
project: PromptProject
|
||||
controller: PromptProjectController
|
||||
selected?: PromptProject
|
||||
onSelect: (project: PromptProject) => void
|
||||
}) {
|
||||
const key = () => props.controller.projectKey(props.project)
|
||||
return (
|
||||
<DropdownMenu.RadioItem
|
||||
id={key()}
|
||||
value={key()}
|
||||
data-option-key={key()}
|
||||
class="h-7 gap-2 rounded-sm px-3 text-[13px] font-[440] leading-5 tracking-[-0.04px] text-v2-text-text-base data-[highlighted]:!bg-v2-overlay-simple-overlay-hover"
|
||||
classList={{ "!bg-v2-overlay-simple-overlay-hover": props.controller.active() === key() }}
|
||||
style={{
|
||||
"font-family": "var(--v2-font-family-sans)",
|
||||
"font-size": "13px",
|
||||
"font-weight": 440,
|
||||
"line-height": "20px",
|
||||
"letter-spacing": "-0.04px",
|
||||
color: "var(--v2-text-text-base)",
|
||||
padding: "0 12px",
|
||||
}}
|
||||
closeOnSelect
|
||||
onMouseEnter={() => {
|
||||
props.controller.setActive(key())
|
||||
props.controller.focusSearch()
|
||||
}}
|
||||
onSelect={() => props.onSelect(props.project)}
|
||||
<button
|
||||
type="button"
|
||||
class="flex h-7 w-full items-center gap-2 rounded-sm px-3 text-left text-[13px] font-[440] leading-5 tracking-[-0.04px] text-v2-text-text-base hover:bg-v2-overlay-simple-overlay-hover focus-visible:bg-v2-overlay-simple-overlay-hover focus-visible:outline-none"
|
||||
onClick={() => props.onSelect(props.project)}
|
||||
>
|
||||
<ProjectAvatar
|
||||
fallback={displayName(props.project)}
|
||||
src={getProjectAvatarSource(props.project.id, props.project.icon)}
|
||||
variant={getProjectAvatarVariant(props.project.icon?.color)}
|
||||
/>
|
||||
<DropdownMenu.ItemLabel class="min-w-0 truncate leading-5">{displayName(props.project)}</DropdownMenu.ItemLabel>
|
||||
<DropdownMenu.ItemIndicator style={{ width: "14px", height: "14px", right: "12px" }}>
|
||||
<IconV2 name="check" size="small" class="shrink-0 text-v2-icon-icon-base" />
|
||||
</DropdownMenu.ItemIndicator>
|
||||
</DropdownMenu.RadioItem>
|
||||
<span class="min-w-0 flex-1 truncate leading-5">{displayName(props.project)}</span>
|
||||
<Show
|
||||
when={
|
||||
props.selected?.worktree === props.project.worktree &&
|
||||
props.selected?.server?.key === props.project.server?.key
|
||||
}
|
||||
>
|
||||
<Icon name="check-small" size="small" class="shrink-0 text-v2-icon-icon-base" />
|
||||
</Show>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
const projectActionClass =
|
||||
"h-7 gap-2 rounded-sm px-3 text-[13px] font-[440] leading-5 tracking-[-0.04px] text-v2-text-text-base [font-family:var(--v2-font-family-sans)] data-[highlighted]:!bg-v2-overlay-simple-overlay-hover"
|
||||
|
||||
function ProjectAction(props: {
|
||||
server?: string
|
||||
controller: PromptProjectController
|
||||
onSelect: (server?: string) => void
|
||||
}) {
|
||||
const key = () => props.controller.actionKey(props.server)
|
||||
function ProjectAction(props: { label: string; onSelect: () => void }) {
|
||||
return (
|
||||
<DropdownMenu.Item
|
||||
id={key()}
|
||||
data-option-key={key()}
|
||||
class="h-7 gap-2 rounded-sm px-3 text-[13px] font-[440] leading-5 tracking-[-0.04px] text-v2-text-text-base data-[highlighted]:!bg-v2-overlay-simple-overlay-hover"
|
||||
classList={{ "!bg-v2-overlay-simple-overlay-hover": props.controller.active() === key() }}
|
||||
style={{
|
||||
"font-family": "var(--v2-font-family-sans)",
|
||||
"font-size": "13px",
|
||||
"font-weight": 440,
|
||||
"line-height": "20px",
|
||||
"letter-spacing": "-0.04px",
|
||||
color: "var(--v2-text-text-base)",
|
||||
padding: "0 12px",
|
||||
}}
|
||||
onMouseEnter={() => {
|
||||
props.controller.setActive(key())
|
||||
props.controller.focusSearch()
|
||||
}}
|
||||
onSelect={() => props.onSelect(props.server)}
|
||||
<button
|
||||
type="button"
|
||||
class="flex h-7 w-full items-center gap-2 rounded-sm px-3 text-left text-[13px] font-[440] leading-5 tracking-[-0.04px] text-v2-text-text-base hover:bg-v2-overlay-simple-overlay-hover focus-visible:bg-v2-overlay-simple-overlay-hover focus-visible:outline-none"
|
||||
onClick={props.onSelect}
|
||||
>
|
||||
<Icon name="plus" size="small" />
|
||||
<DropdownMenu.ItemLabel class="min-w-0 truncate leading-5">
|
||||
{props.controller.labels.add()}
|
||||
</DropdownMenu.ItemLabel>
|
||||
</DropdownMenu.Item>
|
||||
)
|
||||
}
|
||||
|
||||
function ServerAction(props: { server: { key: string; name: string }; onSelect: (server: string) => void }) {
|
||||
return (
|
||||
<DropdownMenu.Item class={projectActionClass} onSelect={() => props.onSelect(props.server.key)}>
|
||||
<DropdownMenu.ItemLabel class="min-w-0 flex-1 truncate leading-5">{props.server.name}</DropdownMenu.ItemLabel>
|
||||
</DropdownMenu.Item>
|
||||
<span class="min-w-0 flex-1 truncate leading-5">{props.label}</span>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,102 +0,0 @@
|
||||
import { For, Show } from "solid-js"
|
||||
import { MenuV2 } from "@opencode-ai/ui/v2/menu-v2"
|
||||
import { Icon } from "@opencode-ai/ui/icon"
|
||||
import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon"
|
||||
import { getFilename } from "@opencode-ai/core/util/path"
|
||||
import { useLanguage } from "@/context/language"
|
||||
|
||||
export function PromptWorkspaceSelector(props: {
|
||||
value: string
|
||||
projectRoot: string
|
||||
workspaces: string[]
|
||||
branch?: string
|
||||
onChange: (value: string) => void
|
||||
onDone: () => void
|
||||
}) {
|
||||
const language = useLanguage()
|
||||
let pending: string | undefined
|
||||
const selected = () => (props.value === props.projectRoot ? "main" : props.value)
|
||||
const icon = () => {
|
||||
if (selected() === "main") return "monitor"
|
||||
if (selected() === "create") return "workspace-new"
|
||||
return "workspace"
|
||||
}
|
||||
const select = (value: string) => {
|
||||
pending = value
|
||||
}
|
||||
const onOpenChange = (open: boolean) => {
|
||||
if (open) return
|
||||
const value = pending
|
||||
pending = undefined
|
||||
if (value) props.onChange(value)
|
||||
props.onDone()
|
||||
}
|
||||
const label = () => {
|
||||
if (selected() === "main") return language.t("session.new.workspace.triggerLocal")
|
||||
if (props.value === "create") return language.t("workspace.new")
|
||||
return getFilename(props.value)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<span class="hidden select-none opacity-50 sm:inline mx-1">/</span>
|
||||
<MenuV2 placement="bottom" gutter={4} onOpenChange={onOpenChange}>
|
||||
<MenuV2.Trigger class="flex h-7 min-w-0 max-w-[203px] items-center gap-1.5 rounded-sm px-1.5 hover:bg-v2-overlay-simple-overlay-hover focus-visible:bg-v2-overlay-simple-overlay-hover focus-visible:outline-none data-[expanded]:bg-v2-overlay-simple-overlay-pressed data-[expanded]:text-v2-text-text-muted">
|
||||
<IconV2 name={icon()} class="shrink-0 text-v2-icon-icon-muted" />
|
||||
<span class="min-w-0 truncate">{label()}</span>
|
||||
<Icon name="chevron-down" size="small" class="shrink-0 text-v2-icon-icon-muted" />
|
||||
</MenuV2.Trigger>
|
||||
<MenuV2.Portal>
|
||||
<MenuV2.Content class="w-[180px]">
|
||||
<MenuV2.Group>
|
||||
<MenuV2.GroupLabel>{language.t("session.new.workspace.runIn")}</MenuV2.GroupLabel>
|
||||
<MenuV2.Item onSelect={() => select("main")}>
|
||||
<IconV2 name="monitor" />
|
||||
<span class="min-w-0 flex-1 truncate">{language.t("session.new.workspace.local")}</span>
|
||||
<Show when={selected() === "main"}>
|
||||
<Icon name="check" size="small" class="shrink-0" />
|
||||
</Show>
|
||||
</MenuV2.Item>
|
||||
<MenuV2.Item onSelect={() => select("create")}>
|
||||
<IconV2 name="workspace-new" />
|
||||
<span class="min-w-0 flex-1 truncate">{language.t("workspace.new")}</span>
|
||||
<Show when={selected() === "create"}>
|
||||
<Icon name="check" size="small" class="shrink-0" />
|
||||
</Show>
|
||||
</MenuV2.Item>
|
||||
</MenuV2.Group>
|
||||
<Show when={props.workspaces.length > 0}>
|
||||
<MenuV2.Separator />
|
||||
<MenuV2.Sub gutter={0} overlap overflowPadding={8}>
|
||||
<MenuV2.SubTrigger>
|
||||
<IconV2 name="workspace" />
|
||||
{language.t("session.new.workspace.existing")}
|
||||
</MenuV2.SubTrigger>
|
||||
<MenuV2.Portal>
|
||||
<MenuV2.SubContent class="max-w-[200px]">
|
||||
<For each={props.workspaces}>
|
||||
{(workspace) => (
|
||||
<MenuV2.Item onSelect={() => select(workspace)}>
|
||||
<IconV2 name="workspace-isolated" />
|
||||
<span class="min-w-0 flex-1 truncate">{getFilename(workspace)}</span>
|
||||
<Show when={selected() === workspace}>
|
||||
<Icon name="check" size="small" class="shrink-0" />
|
||||
</Show>
|
||||
</MenuV2.Item>
|
||||
)}
|
||||
</For>
|
||||
</MenuV2.SubContent>
|
||||
</MenuV2.Portal>
|
||||
</MenuV2.Sub>
|
||||
</Show>
|
||||
</MenuV2.Content>
|
||||
</MenuV2.Portal>
|
||||
</MenuV2>
|
||||
<span class="hidden select-none opacity-50 sm:inline mx-1">/</span>
|
||||
<div class="flex h-7 min-w-0 max-w-[220px] items-center gap-1.5 px-2 text-[13px] font-[440] leading-5 tracking-[-0.04px]">
|
||||
<Icon name="branch" size="small" class="shrink-0 text-v2-icon-icon-muted" />
|
||||
<span class="min-w-0 truncate">{props.branch || "main"}</span>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -1,43 +0,0 @@
|
||||
import { useParams } from "@solidjs/router"
|
||||
import { onCleanup } from "solid-js"
|
||||
import { useCommand } from "@/context/command"
|
||||
import { useLanguage } from "@/context/language"
|
||||
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
||||
|
||||
export function useSettingsDialog() {
|
||||
const dialog = useDialog()
|
||||
const params = useParams<{ id?: string }>()
|
||||
let run = 0
|
||||
let dead = false
|
||||
|
||||
onCleanup(() => {
|
||||
dead = true
|
||||
})
|
||||
|
||||
return () => {
|
||||
const current = ++run
|
||||
const sessionID = params.id
|
||||
void import("@/components/settings-v2").then((module) => {
|
||||
if (dead || run !== current) return
|
||||
void dialog.show(() => <module.DialogSettings sessionID={sessionID} />)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
export function useSettingsCommand() {
|
||||
const command = useCommand()
|
||||
const language = useLanguage()
|
||||
const show = useSettingsDialog()
|
||||
|
||||
command.register("settings", () => [
|
||||
{
|
||||
id: "settings.open",
|
||||
title: language.t("command.settings.open"),
|
||||
category: language.t("command.category.settings"),
|
||||
keybind: "mod+comma",
|
||||
onSelect: show,
|
||||
},
|
||||
])
|
||||
|
||||
return show
|
||||
}
|
||||
@@ -1,6 +1,5 @@
|
||||
import { ButtonV2 } from "@opencode-ai/ui/v2/button-v2"
|
||||
import { Dialog, DialogBody, DialogFooter, DialogHeader, DialogTitle } from "@opencode-ai/ui/v2/dialog-v2"
|
||||
import { DividerV2 } from "@opencode-ai/ui/v2/divider-v2"
|
||||
import { Dialog, DialogFooter } from "@opencode-ai/ui/v2/dialog-v2"
|
||||
import { TextInputV2 } from "@opencode-ai/ui/v2/text-input-v2"
|
||||
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
||||
import { type Component, Show, createEffect, createSignal, onCleanup, onMount } from "solid-js"
|
||||
@@ -53,12 +52,8 @@ export const DialogServerV2: Component<{
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog fit class="settings-v2-server-dialog">
|
||||
<DialogHeader hideClose={true}>
|
||||
<DialogTitle>{title()}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<DividerV2 />
|
||||
<DialogBody class="flex w-full min-w-0 flex-1 flex-col px-4 pt-4 pb-2">
|
||||
<Dialog title={title()} fit class="settings-v2-server-dialog">
|
||||
<div class="flex w-full min-w-0 flex-1 flex-col px-4">
|
||||
<div class="flex w-full min-w-0 flex-col gap-6">
|
||||
<div class="flex w-full min-w-0 flex-col gap-2">
|
||||
<label class="settings-v2-server-dialog-label">{language.t("dialog.server.add.url")}</label>
|
||||
@@ -120,7 +115,7 @@ export const DialogServerV2: Component<{
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</DialogBody>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<ButtonV2 variant="neutral" disabled={controller.formBusy()} onClick={() => dialog.close()}>
|
||||
{language.t("common.cancel")}
|
||||
|
||||
@@ -11,9 +11,7 @@ import { SettingsModelsV2 } from "./models"
|
||||
import "./settings-v2.css"
|
||||
import { SettingsServersV2 } from "./servers"
|
||||
|
||||
export const DialogSettings: Component<{
|
||||
sessionID?: string
|
||||
}> = (props) => {
|
||||
export const DialogSettings: Component = () => {
|
||||
const language = useLanguage()
|
||||
const platform = usePlatform()
|
||||
|
||||
@@ -64,7 +62,7 @@ export const DialogSettings: Component<{
|
||||
</div>
|
||||
</TabsV2.List>
|
||||
<TabsV2.Content value="general" class="settings-v2-panel">
|
||||
<SettingsGeneralV2 sessionID={props.sessionID} />
|
||||
<SettingsGeneralV2 />
|
||||
</TabsV2.Content>
|
||||
<TabsV2.Content value="shortcuts" class="settings-v2-panel">
|
||||
<SettingsKeybinds v2 />
|
||||
|
||||
@@ -6,6 +6,7 @@ import { Switch } from "@opencode-ai/ui/v2/switch-v2"
|
||||
import { TextInputV2 } from "@opencode-ai/ui/v2/text-input-v2"
|
||||
import { useTheme, type ColorScheme } from "@opencode-ai/ui/theme/context"
|
||||
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
||||
import { useParams } from "@solidjs/router"
|
||||
import { useLanguage } from "@/context/language"
|
||||
import { usePermission } from "@/context/permission"
|
||||
import { usePlatform } from "@/context/platform"
|
||||
@@ -24,6 +25,7 @@ import {
|
||||
terminalInput,
|
||||
useSettings,
|
||||
} from "@/context/settings"
|
||||
import { decode64 } from "@/utils/base64"
|
||||
import { playSoundById, SOUND_OPTIONS } from "@/utils/sound"
|
||||
import { Link } from "../link"
|
||||
import { SettingsListV2 } from "./parts/list"
|
||||
@@ -80,46 +82,50 @@ const playDemoSound = (id: string | undefined) => {
|
||||
}, 100)
|
||||
}
|
||||
|
||||
export const SettingsGeneralV2: Component<{
|
||||
sessionID?: string
|
||||
}> = (props) => {
|
||||
export const SettingsGeneralV2: Component = () => {
|
||||
const theme = useTheme()
|
||||
const language = useLanguage()
|
||||
const permission = usePermission()
|
||||
const platform = usePlatform()
|
||||
const dialog = useDialog()
|
||||
const params = useParams()
|
||||
const settings = useSettings()
|
||||
const serverSync = useServerSync()
|
||||
const serverSdk = useServerSDK()
|
||||
const mobile = createMediaQuery("(max-width: 767px)")
|
||||
|
||||
const updater = useUpdaterAction()
|
||||
|
||||
const dir = createMemo(() => {
|
||||
if (!props.sessionID) return undefined
|
||||
return serverSync().session.lineage.peek(props.sessionID)?.session.directory
|
||||
})
|
||||
const dir = createMemo(() => decode64(params.dir))
|
||||
const accepting = createMemo(() => {
|
||||
const value = dir()
|
||||
if (!value || !props.sessionID) return false
|
||||
return permission.isAutoAccepting(props.sessionID, value)
|
||||
if (!value) return false
|
||||
if (!params.id) return permission.isAutoAcceptingDirectory(value)
|
||||
return permission.isAutoAccepting(params.id, value)
|
||||
})
|
||||
|
||||
const toggleAccept = (checked: boolean) => {
|
||||
const value = dir()
|
||||
if (!value || !props.sessionID) return
|
||||
if (!value) return
|
||||
|
||||
if (checked) {
|
||||
permission.enableAutoAccept(props.sessionID, value)
|
||||
if (!params.id) {
|
||||
if (permission.isAutoAcceptingDirectory(value) === checked) return
|
||||
permission.toggleAutoAcceptDirectory(value)
|
||||
return
|
||||
}
|
||||
|
||||
permission.disableAutoAccept(props.sessionID, value)
|
||||
if (checked) {
|
||||
permission.enableAutoAccept(params.id, value)
|
||||
return
|
||||
}
|
||||
|
||||
permission.disableAutoAccept(params.id, value)
|
||||
}
|
||||
const desktop = createMemo(() => platform.platform === "desktop")
|
||||
|
||||
const themeOptions = createMemo<ThemeOption[]>(() => theme.ids().map((id) => ({ id, name: theme.name(id) })))
|
||||
|
||||
const serverSync = useServerSync()
|
||||
const serverSdk = useServerSDK()
|
||||
|
||||
const [shells] = createResource(
|
||||
() =>
|
||||
serverSdk()
|
||||
|
||||
@@ -633,7 +633,7 @@
|
||||
|
||||
[data-component="dialog-v2"].settings-v2-server-dialog [data-slot="dialog-header"] {
|
||||
align-items: center;
|
||||
padding: 24px 24px 16px;
|
||||
padding: 24px 24px 0;
|
||||
}
|
||||
|
||||
[data-component="dialog-v2"].settings-v2-server-dialog [data-slot="dialog-body"] {
|
||||
|
||||
@@ -0,0 +1,141 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { captureTabDragLayout, insertIndexFromVirtualLayout } from "./titlebar-tab-drag"
|
||||
import {
|
||||
canOpenTabRename,
|
||||
captureTabPointerDown,
|
||||
canStartTabDrag,
|
||||
createTabDragPreview,
|
||||
forwardTabRef,
|
||||
isPrimaryPointerPressed,
|
||||
isTabCloseTarget,
|
||||
} from "./titlebar-tab-gesture"
|
||||
|
||||
describe("titlebar tab drag", () => {
|
||||
const layout = {
|
||||
listLeft: 100,
|
||||
dividerWidth: 13,
|
||||
tabWidthById: new Map([
|
||||
["a", 40],
|
||||
["b", 40],
|
||||
["c", 40],
|
||||
["d", 40],
|
||||
]),
|
||||
}
|
||||
|
||||
test("moves across multiple tabs from one pointer update", () => {
|
||||
expect(insertIndexFromVirtualLayout(260, ["a", "b", "c", "d"], "a", 0, layout)).toBe(3)
|
||||
expect(insertIndexFromVirtualLayout(90, ["a", "b", "c", "d"], "d", 3, layout)).toBe(0)
|
||||
})
|
||||
|
||||
test("keeps the current index inside the left hysteresis deadband", () => {
|
||||
expect(insertIndexFromVirtualLayout(146, ["a", "b", "c", "d"], "b", 1, layout)).toBe(1)
|
||||
})
|
||||
|
||||
test("includes slot margins in captured divider width", () => {
|
||||
const list = document.createElement("div")
|
||||
const first = document.createElement("div")
|
||||
const second = document.createElement("div")
|
||||
const firstTab = document.createElement("div")
|
||||
const secondTab = document.createElement("div")
|
||||
first.dataset.titlebarTabSlot = ""
|
||||
first.dataset.tabKey = "a"
|
||||
second.dataset.titlebarTabSlot = ""
|
||||
second.dataset.tabKey = "b"
|
||||
second.style.marginLeft = "6px"
|
||||
firstTab.dataset.titlebarTab = ""
|
||||
secondTab.dataset.titlebarTab = ""
|
||||
first.append(firstTab)
|
||||
second.append(secondTab)
|
||||
list.append(first, second)
|
||||
document.body.append(list)
|
||||
firstTab.getBoundingClientRect = () => ({ width: 40 }) as DOMRect
|
||||
secondTab.getBoundingClientRect = () => ({ width: 40 }) as DOMRect
|
||||
second.getBoundingClientRect = () => ({ width: 47 }) as DOMRect
|
||||
list.getBoundingClientRect = () => ({ left: 100 }) as DOMRect
|
||||
|
||||
expect(captureTabDragLayout(list, ["a", "b"]).dividerWidth).toBe(13)
|
||||
list.remove()
|
||||
})
|
||||
|
||||
test("uses the list gap as the divider width", () => {
|
||||
const list = document.createElement("div")
|
||||
const first = document.createElement("div")
|
||||
const second = document.createElement("div")
|
||||
const firstTab = document.createElement("div")
|
||||
const secondTab = document.createElement("div")
|
||||
first.dataset.titlebarTabSlot = ""
|
||||
first.dataset.tabKey = "a"
|
||||
second.dataset.titlebarTabSlot = ""
|
||||
second.dataset.tabKey = "b"
|
||||
firstTab.dataset.titlebarTab = ""
|
||||
secondTab.dataset.titlebarTab = ""
|
||||
first.append(firstTab)
|
||||
second.append(secondTab)
|
||||
list.append(first, second)
|
||||
list.style.columnGap = "13.5px"
|
||||
document.body.append(list)
|
||||
|
||||
expect(captureTabDragLayout(list, ["a", "b"]).dividerWidth).toBe(13.5)
|
||||
list.remove()
|
||||
})
|
||||
})
|
||||
|
||||
describe("titlebar tab gestures", () => {
|
||||
test("excludes close controls from tab gestures", () => {
|
||||
const close = document.createElement("div")
|
||||
const button = document.createElement("button")
|
||||
const link = document.createElement("a")
|
||||
close.dataset.slot = "tab-close"
|
||||
close.append(button)
|
||||
expect(isTabCloseTarget(close)).toBe(true)
|
||||
expect(isTabCloseTarget(button)).toBe(true)
|
||||
expect(isTabCloseTarget(link)).toBe(false)
|
||||
})
|
||||
|
||||
test("forwards component refs", () => {
|
||||
const element = document.createElement("div")
|
||||
let received: HTMLDivElement | undefined
|
||||
forwardTabRef((value) => (received = value), element)
|
||||
expect(received).toBe(element)
|
||||
})
|
||||
|
||||
test("does not reopen rename while a save is pending", () => {
|
||||
expect(canOpenTabRename(false, false, false)).toBe(true)
|
||||
expect(canOpenTabRename(false, false, true)).toBe(false)
|
||||
})
|
||||
|
||||
test("keeps the rendered tab content in the drag preview", () => {
|
||||
const tab = document.createElement("div")
|
||||
tab.innerHTML = '<span data-slot="project-avatar-slot"></span><span data-slot="tab-title">Session</span>'
|
||||
const preview = createTabDragPreview(tab)
|
||||
expect(preview.querySelector('[data-slot="project-avatar-slot"]')).not.toBeNull()
|
||||
expect(preview.querySelector('[data-slot="tab-title"]')?.textContent).toBe("Session")
|
||||
})
|
||||
|
||||
test("captures the grab offset before navigation scrolls the tab", () => {
|
||||
const tab = document.createElement("div")
|
||||
tab.getBoundingClientRect = () => ({ left: 80, top: 10, width: 120 }) as DOMRect
|
||||
|
||||
expect(captureTabPointerDown(tab, 100, 20)).toEqual({
|
||||
startX: 100,
|
||||
startY: 20,
|
||||
grabOffsetX: 20,
|
||||
grabOffsetY: 10,
|
||||
width: 120,
|
||||
element: tab,
|
||||
})
|
||||
})
|
||||
|
||||
test("detects when the primary pointer button was released outside the window", () => {
|
||||
expect(isPrimaryPointerPressed(1)).toBe(true)
|
||||
expect(isPrimaryPointerPressed(3)).toBe(true)
|
||||
expect(isPrimaryPointerPressed(0)).toBe(false)
|
||||
expect(isPrimaryPointerPressed(2)).toBe(false)
|
||||
})
|
||||
|
||||
test("preserves native panning for touch pointers", () => {
|
||||
expect(canStartTabDrag("mouse")).toBe(true)
|
||||
expect(canStartTabDrag("pen")).toBe(true)
|
||||
expect(canStartTabDrag("touch")).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,137 @@
|
||||
export type TabDragLayout = {
|
||||
tabWidthById: Map<string, number>
|
||||
dividerWidth: number
|
||||
listLeft: number
|
||||
}
|
||||
|
||||
export const ACTIVATION_DISTANCE = 4
|
||||
export const HYSTERESIS_DEADBAND = 8
|
||||
export const AUTOSCROLL_EDGE = 24
|
||||
export const AUTOSCROLL_MAX_SPEED = 8
|
||||
export const FLOATER_OVERSHOOT_MAX = 8
|
||||
|
||||
export function pointerDistance(x1: number, y1: number, x2: number, y2: number) {
|
||||
const dx = x2 - x1
|
||||
const dy = y2 - y1
|
||||
return Math.sqrt(dx * dx + dy * dy)
|
||||
}
|
||||
|
||||
export function captureTabDragLayout(list: HTMLElement, order: string[]) {
|
||||
const tabWidthById = new Map<string, number>()
|
||||
const slots = list.querySelectorAll<HTMLElement>("[data-titlebar-tab-slot]")
|
||||
for (const slot of slots) {
|
||||
const id = slot.dataset.tabKey
|
||||
if (!id) continue
|
||||
const tab = slot.matches("[data-titlebar-tab]") ? slot : slot.querySelector<HTMLElement>("[data-titlebar-tab]")
|
||||
if (!tab) continue
|
||||
tabWidthById.set(id, tab.getBoundingClientRect().width)
|
||||
}
|
||||
|
||||
let dividerWidth = 0
|
||||
if (order.length >= 2) {
|
||||
const gap = Number.parseFloat(getComputedStyle(list).columnGap) || 0
|
||||
const secondId = order[1]
|
||||
for (const slot of slots) {
|
||||
if (slot.dataset.tabKey !== secondId) continue
|
||||
const tab = slot.matches("[data-titlebar-tab]") ? slot : slot.querySelector<HTMLElement>("[data-titlebar-tab]")
|
||||
if (!tab) break
|
||||
const style = getComputedStyle(slot)
|
||||
dividerWidth =
|
||||
gap ||
|
||||
slot.getBoundingClientRect().width -
|
||||
tab.getBoundingClientRect().width +
|
||||
(Number.parseFloat(style.marginLeft) || 0) +
|
||||
(Number.parseFloat(style.marginRight) || 0)
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
tabWidthById,
|
||||
dividerWidth,
|
||||
listLeft: list.getBoundingClientRect().left,
|
||||
}
|
||||
}
|
||||
|
||||
export function syncLayoutScroll(list: HTMLElement, layout: TabDragLayout) {
|
||||
layout.listLeft = list.getBoundingClientRect().left
|
||||
}
|
||||
|
||||
function slotWidthAt(order: readonly string[], index: number, layout: TabDragLayout) {
|
||||
const id = order[index]
|
||||
if (!id) return 0
|
||||
const tabWidth = layout.tabWidthById.get(id) ?? 0
|
||||
return index === 0 ? tabWidth : layout.dividerWidth + tabWidth
|
||||
}
|
||||
|
||||
function slotLeft(order: readonly string[], index: number, layout: TabDragLayout) {
|
||||
let left = layout.listLeft
|
||||
for (let i = 0; i < index; i++) {
|
||||
left += slotWidthAt(order, i, layout)
|
||||
}
|
||||
return left
|
||||
}
|
||||
|
||||
export function insertIndexFromVirtualLayout(
|
||||
pointerX: number,
|
||||
order: readonly string[],
|
||||
draggedId: string,
|
||||
currentIndex: number,
|
||||
layout: TabDragLayout,
|
||||
deadband = HYSTERESIS_DEADBAND,
|
||||
) {
|
||||
if (order.length === 0) return 0
|
||||
|
||||
const others = order.filter((id) => id !== draggedId)
|
||||
let target = currentIndex
|
||||
|
||||
while (target > 0 && pointerX < slotLeft(others, target, layout) - deadband) target--
|
||||
while (target < order.length - 1 && pointerX >= slotLeft(others, target + 1, layout)) target++
|
||||
|
||||
return target
|
||||
}
|
||||
|
||||
export function movePlaceholder(order: readonly string[], draggedId: string, toIndex: number) {
|
||||
const fromIndex = order.indexOf(draggedId)
|
||||
if (fromIndex === -1 || fromIndex === toIndex) return [...order]
|
||||
const next = [...order]
|
||||
next.splice(toIndex, 0, ...next.splice(fromIndex, 1))
|
||||
return next
|
||||
}
|
||||
|
||||
export function draftOrderChanged(initial: readonly string[], final: readonly string[]) {
|
||||
if (initial.length === 0 || final.length === 0 || initial.length !== final.length) return false
|
||||
return final.some((key, index) => key !== initial[index])
|
||||
}
|
||||
|
||||
function easeOvershoot(overshoot: number) {
|
||||
return (FLOATER_OVERSHOOT_MAX * overshoot) / (overshoot + FLOATER_OVERSHOOT_MAX)
|
||||
}
|
||||
|
||||
export function clampFloaterLeft(left: number, width: number, stripLeft: number, stripRight: number) {
|
||||
const stripWidth = stripRight - stripLeft
|
||||
if (width >= stripWidth) return stripLeft
|
||||
|
||||
const maxLeft = stripRight - width
|
||||
if (left > maxLeft) return maxLeft + easeOvershoot(left - maxLeft)
|
||||
if (left < stripLeft) return stripLeft - easeOvershoot(stripLeft - left)
|
||||
|
||||
return left
|
||||
}
|
||||
|
||||
export function autoscrollSpeed(pointerX: number, containerLeft: number, containerRight: number) {
|
||||
const leftEdge = containerLeft + AUTOSCROLL_EDGE
|
||||
const rightEdge = containerRight - AUTOSCROLL_EDGE
|
||||
|
||||
if (pointerX < leftEdge) {
|
||||
const depth = (leftEdge - pointerX) / AUTOSCROLL_EDGE
|
||||
return -Math.ceil(AUTOSCROLL_MAX_SPEED * Math.min(depth, 1))
|
||||
}
|
||||
|
||||
if (pointerX > rightEdge) {
|
||||
const depth = (pointerX - rightEdge) / AUTOSCROLL_EDGE
|
||||
return Math.ceil(AUTOSCROLL_MAX_SPEED * Math.min(depth, 1))
|
||||
}
|
||||
|
||||
return 0
|
||||
}
|
||||
@@ -1,33 +0,0 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { canOpenTabRename, canStartTabDrag, forwardTabRef, isTabCloseTarget } from "./titlebar-tab-gesture"
|
||||
|
||||
describe("titlebar tab gestures", () => {
|
||||
test("excludes close controls from tab gestures", () => {
|
||||
const close = document.createElement("div")
|
||||
const button = document.createElement("button")
|
||||
const link = document.createElement("a")
|
||||
close.dataset.slot = "tab-close"
|
||||
close.append(button)
|
||||
expect(isTabCloseTarget(close)).toBe(true)
|
||||
expect(isTabCloseTarget(button)).toBe(true)
|
||||
expect(isTabCloseTarget(link)).toBe(false)
|
||||
})
|
||||
|
||||
test("forwards component refs", () => {
|
||||
const element = document.createElement("div")
|
||||
let received: HTMLDivElement | undefined
|
||||
forwardTabRef((value) => (received = value), element)
|
||||
expect(received).toBe(element)
|
||||
})
|
||||
|
||||
test("does not reopen rename while a save is pending", () => {
|
||||
expect(canOpenTabRename(false, false, false)).toBe(true)
|
||||
expect(canOpenTabRename(false, false, true)).toBe(false)
|
||||
})
|
||||
|
||||
test("preserves native panning for touch pointers", () => {
|
||||
expect(canStartTabDrag("mouse")).toBe(true)
|
||||
expect(canStartTabDrag("pen")).toBe(true)
|
||||
expect(canStartTabDrag("touch")).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -8,6 +8,22 @@ export function canStartTabDrag(pointerType: string) {
|
||||
return pointerType !== "touch"
|
||||
}
|
||||
|
||||
export function isPrimaryPointerPressed(buttons: number) {
|
||||
return (buttons & 1) !== 0
|
||||
}
|
||||
|
||||
export function captureTabPointerDown(element: HTMLDivElement, clientX: number, clientY: number) {
|
||||
const rect = element.getBoundingClientRect()
|
||||
return {
|
||||
startX: clientX,
|
||||
startY: clientY,
|
||||
grabOffsetX: clientX - rect.left,
|
||||
grabOffsetY: clientY - rect.top,
|
||||
width: rect.width,
|
||||
element,
|
||||
}
|
||||
}
|
||||
|
||||
export function forwardTabRef(ref: Ref<HTMLDivElement> | undefined, element: HTMLDivElement) {
|
||||
if (typeof ref === "function") ref(element)
|
||||
}
|
||||
@@ -15,3 +31,7 @@ export function forwardTabRef(ref: Ref<HTMLDivElement> | undefined, element: HTM
|
||||
export function canOpenTabRename(dragging: boolean | undefined, editing: boolean, committing: boolean) {
|
||||
return !dragging && !editing && !committing
|
||||
}
|
||||
|
||||
export function createTabDragPreview(element: HTMLDivElement) {
|
||||
return element.cloneNode(true) as HTMLDivElement
|
||||
}
|
||||
|
||||
@@ -9,10 +9,6 @@
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
[data-titlebar-tab][data-editing="true"] [data-slot="tab-close"] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
[data-titlebar-tab-list] {
|
||||
gap: 13.5px;
|
||||
}
|
||||
|
||||
@@ -29,6 +29,9 @@ export function TabNavItem(props: {
|
||||
dragging?: boolean
|
||||
pressed?: boolean
|
||||
hidden?: boolean
|
||||
tabKey: string
|
||||
dragActive: boolean
|
||||
onPointerDown: (event: PointerEvent) => void
|
||||
}) {
|
||||
const language = useLanguage()
|
||||
const [editing, setEditing] = createSignal(false)
|
||||
@@ -170,105 +173,112 @@ export function TabNavItem(props: {
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={(el) => {
|
||||
tabRoot = el
|
||||
forwardTabRef(props.ref, el)
|
||||
}}
|
||||
data-titlebar-tab
|
||||
data-slot="titlebar-tab-item"
|
||||
data-title-overflow={titleOverflowing()}
|
||||
data-editing={editing()}
|
||||
class="group relative flex h-7 w-full min-w-0 select-none flex-row items-center gap-1.5 overflow-hidden whitespace-nowrap rounded-[6px] bg-[var(--tab-bg)] px-1.5 [container-type:inline-size] [--tab-bg:var(--v2-background-bg-deep)] hover:[--tab-bg:var(--v2-background-bg-layer-02)] has-[>a:focus-visible]:[--tab-bg:var(--v2-background-bg-layer-02)] data-[active='true']:[--tab-bg:var(--v2-background-bg-layer-02)] data-[dragging='true']:[--tab-bg:var(--v2-background-bg-layer-02)] data-[pressed='true']:[--tab-bg:var(--v2-background-bg-layer-02)] data-[editing='true']:[--tab-bg:var(--v2-background-bg-layer-02)]"
|
||||
classList={{ invisible: props.hidden }}
|
||||
data-active={props.active}
|
||||
data-dragging={props.dragging}
|
||||
data-pressed={props.pressed}
|
||||
onMouseDown={(event) => {
|
||||
if (event.button !== 1) return
|
||||
closeTab(event)
|
||||
}}
|
||||
data-titlebar-tab-slot
|
||||
data-tab-key={props.tabKey}
|
||||
class="relative flex w-56 min-w-7 max-w-56 flex-shrink"
|
||||
classList={{ invisible: props.hidden, "pointer-events-none": props.dragActive }}
|
||||
onPointerDown={props.onPointerDown}
|
||||
>
|
||||
<Show when={props.session()}>
|
||||
{(session) => {
|
||||
return (
|
||||
<a
|
||||
data-slot="tab-link"
|
||||
data-titlebar-tab-link
|
||||
href={props.href}
|
||||
draggable={false}
|
||||
onDragStart={(event) => {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
}}
|
||||
onClick={(event) => {
|
||||
event.preventDefault()
|
||||
if (editing()) return
|
||||
if (props.suppressNavigation?.()) return
|
||||
props.onNavigate()
|
||||
}}
|
||||
class="flex h-full min-w-0 flex-1 flex-row items-center gap-1.5 text-[13px] font-medium text-v2-text-text-faint group-data-[active='true']:text-v2-text-text-base group-data-[editing='true']:text-v2-text-text-base [-webkit-user-drag:none]"
|
||||
>
|
||||
<span data-slot="project-avatar-slot">
|
||||
<SessionTabAvatar
|
||||
project={project()}
|
||||
directory={session().directory}
|
||||
sessionId={session().id}
|
||||
activeServer={props.activeServer}
|
||||
/>
|
||||
</span>
|
||||
<span
|
||||
ref={(el) => {
|
||||
titleEl = el
|
||||
titleEl.textContent = session().title
|
||||
}}
|
||||
data-slot="tab-title"
|
||||
data-titlebar-tab-title
|
||||
class="min-w-0 flex-1 outline-none leading-4"
|
||||
classList={{
|
||||
"overflow-hidden text-clip whitespace-nowrap": !editing(),
|
||||
"select-text": editing(),
|
||||
}}
|
||||
contenteditable={editing() ? true : undefined}
|
||||
onDblClick={openRename}
|
||||
onKeyDown={(event) => {
|
||||
event.stopPropagation()
|
||||
if (event.key === "Enter") {
|
||||
event.preventDefault()
|
||||
void closeRename(true)
|
||||
return
|
||||
}
|
||||
if (event.key !== "Escape") return
|
||||
<div
|
||||
ref={(el) => {
|
||||
tabRoot = el
|
||||
forwardTabRef(props.ref, el)
|
||||
}}
|
||||
data-titlebar-tab
|
||||
data-slot="titlebar-tab-item"
|
||||
data-title-overflow={titleOverflowing()}
|
||||
data-editing={editing()}
|
||||
class="group relative flex h-7 w-full min-w-0 select-none flex-row items-center gap-1.5 overflow-hidden whitespace-nowrap rounded-[6px] bg-[var(--tab-bg)] px-1.5 [container-type:inline-size] [--tab-bg:var(--v2-background-bg-deep)] hover:[--tab-bg:var(--v2-background-bg-layer-02)] has-[>a:focus-visible]:[--tab-bg:var(--v2-background-bg-layer-02)] data-[active='true']:[--tab-bg:var(--v2-background-bg-layer-02)] data-[dragging='true']:[--tab-bg:var(--v2-background-bg-layer-02)] data-[pressed='true']:[--tab-bg:var(--v2-background-bg-layer-02)] data-[editing='true']:[--tab-bg:var(--v2-background-bg-layer-02)]"
|
||||
data-active={props.active}
|
||||
data-dragging={props.dragging}
|
||||
data-pressed={props.pressed}
|
||||
onMouseDown={(event) => {
|
||||
if (event.button !== 1) return
|
||||
closeTab(event)
|
||||
}}
|
||||
>
|
||||
<Show when={props.session()}>
|
||||
{(session) => {
|
||||
return (
|
||||
<a
|
||||
data-slot="tab-link"
|
||||
data-titlebar-tab-link
|
||||
href={props.href}
|
||||
draggable={false}
|
||||
onDragStart={(event) => {
|
||||
event.preventDefault()
|
||||
titleEl.textContent = session().title
|
||||
void closeRename(false)
|
||||
}}
|
||||
onBlur={() => void closeRename(true)}
|
||||
onPointerDown={(event) => {
|
||||
if (!editing()) return
|
||||
event.stopPropagation()
|
||||
}}
|
||||
onClick={(event) => {
|
||||
if (!editing()) return
|
||||
event.preventDefault()
|
||||
if (editing()) return
|
||||
if (props.suppressNavigation?.()) return
|
||||
props.onNavigate()
|
||||
}}
|
||||
/>
|
||||
</a>
|
||||
)
|
||||
}}
|
||||
</Show>
|
||||
|
||||
<div data-slot="tab-close" class="group-hover:bg-[var(--tab-bg)] group-data-[active=true]:bg-[var(--tab-bg)]">
|
||||
<IconButtonV2
|
||||
size="small"
|
||||
variant="ghost-muted"
|
||||
class="hover-reveal relative z-10 group-hover:opacity-100 group-data-[active=true]:opacity-100 group-data-[editing=true]:opacity-100"
|
||||
onPointerDown={(event) => {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
class="flex h-full min-w-0 flex-1 flex-row items-center gap-1.5 text-[13px] font-medium text-v2-text-text-faint group-data-[active='true']:text-v2-text-text-base group-data-[editing='true']:text-v2-text-text-base [-webkit-user-drag:none]"
|
||||
>
|
||||
<span data-slot="project-avatar-slot">
|
||||
<SessionTabAvatar
|
||||
project={project()}
|
||||
directory={session().directory}
|
||||
sessionId={session().id}
|
||||
activeServer={props.activeServer}
|
||||
/>
|
||||
</span>
|
||||
<span
|
||||
ref={(el) => {
|
||||
titleEl = el
|
||||
titleEl.textContent = session().title
|
||||
}}
|
||||
data-slot="tab-title"
|
||||
data-titlebar-tab-title
|
||||
class="min-w-0 flex-1 outline-none leading-4"
|
||||
classList={{
|
||||
"overflow-hidden text-clip whitespace-nowrap": !editing(),
|
||||
"select-text": editing(),
|
||||
}}
|
||||
contenteditable={editing() ? true : undefined}
|
||||
onDblClick={openRename}
|
||||
onKeyDown={(event) => {
|
||||
event.stopPropagation()
|
||||
if (event.key === "Enter") {
|
||||
event.preventDefault()
|
||||
void closeRename(true)
|
||||
return
|
||||
}
|
||||
if (event.key !== "Escape") return
|
||||
event.preventDefault()
|
||||
titleEl.textContent = session().title
|
||||
void closeRename(false)
|
||||
}}
|
||||
onBlur={() => void closeRename(true)}
|
||||
onPointerDown={(event) => {
|
||||
if (!editing()) return
|
||||
event.stopPropagation()
|
||||
}}
|
||||
onClick={(event) => {
|
||||
if (!editing()) return
|
||||
event.preventDefault()
|
||||
}}
|
||||
/>
|
||||
</a>
|
||||
)
|
||||
}}
|
||||
onClick={closeTab}
|
||||
icon={<IconV2 name="xmark-small" />}
|
||||
/>
|
||||
</Show>
|
||||
|
||||
<div data-slot="tab-close" class="group-hover:bg-[var(--tab-bg)] group-data-[active=true]:bg-[var(--tab-bg)]">
|
||||
<IconButtonV2
|
||||
size="small"
|
||||
variant="ghost-muted"
|
||||
class="hover-reveal relative z-10 group-hover:opacity-100 group-data-[active=true]:opacity-100 group-data-[editing=true]:opacity-100"
|
||||
onPointerDown={(event) => {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
}}
|
||||
onClick={closeTab}
|
||||
icon={<IconV2 name="xmark-small" />}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
@@ -285,6 +295,9 @@ export function DraftTabItem(props: {
|
||||
dragging?: boolean
|
||||
pressed?: boolean
|
||||
hidden?: boolean
|
||||
tabKey: string
|
||||
dragActive: boolean
|
||||
onPointerDown: (event: PointerEvent) => void
|
||||
}) {
|
||||
const closeTab = (event: MouseEvent) => {
|
||||
event.preventDefault()
|
||||
@@ -293,62 +306,69 @@ export function DraftTabItem(props: {
|
||||
}
|
||||
return (
|
||||
<div
|
||||
ref={(el) => forwardTabRef(props.ref, el)}
|
||||
data-titlebar-tab
|
||||
data-slot="titlebar-tab-item"
|
||||
data-active={props.active}
|
||||
data-dragging={props.dragging}
|
||||
data-pressed={props.pressed}
|
||||
class="group relative flex h-7 w-full min-w-0 flex-row items-center gap-1.5 overflow-hidden rounded-[6px] bg-[var(--tab-bg)] px-1.5 [container-type:inline-size] whitespace-nowrap [--tab-bg:var(--v2-background-bg-deep)] hover:[--tab-bg:var(--v2-background-bg-layer-02)] has-[>a:focus-visible]:[--tab-bg:var(--v2-background-bg-layer-02)] data-[active='true']:[--tab-bg:var(--v2-background-bg-layer-02)] data-[dragging='true']:[--tab-bg:var(--v2-background-bg-layer-02)] data-[pressed='true']:[--tab-bg:var(--v2-background-bg-layer-02)] data-[editing='true']:[--tab-bg:var(--v2-background-bg-layer-02)]"
|
||||
classList={{ invisible: props.hidden }}
|
||||
onMouseDown={(event) => {
|
||||
if (event.button !== 1) return
|
||||
closeTab(event)
|
||||
}}
|
||||
data-titlebar-tab-slot
|
||||
data-tab-key={props.tabKey}
|
||||
class="relative flex w-56 min-w-7 max-w-56 flex-shrink"
|
||||
classList={{ invisible: props.hidden, "pointer-events-none": props.dragActive }}
|
||||
onPointerDown={props.onPointerDown}
|
||||
>
|
||||
<a
|
||||
data-slot="tab-link"
|
||||
data-titlebar-tab-link
|
||||
href={props.href}
|
||||
draggable={false}
|
||||
onDragStart={(event) => {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
<div
|
||||
ref={(el) => forwardTabRef(props.ref, el)}
|
||||
data-titlebar-tab
|
||||
data-slot="titlebar-tab-item"
|
||||
data-active={props.active}
|
||||
data-dragging={props.dragging}
|
||||
data-pressed={props.pressed}
|
||||
class="group relative flex h-7 w-full min-w-0 select-none flex-row items-center gap-1.5 overflow-hidden whitespace-nowrap rounded-[6px] bg-[var(--tab-bg)] px-1.5 [container-type:inline-size] [--tab-bg:var(--v2-background-bg-deep)] hover:[--tab-bg:var(--v2-background-bg-layer-02)] has-[>a:focus-visible]:[--tab-bg:var(--v2-background-bg-layer-02)] data-[active='true']:[--tab-bg:var(--v2-background-bg-layer-02)] data-[dragging='true']:[--tab-bg:var(--v2-background-bg-layer-02)] data-[pressed='true']:[--tab-bg:var(--v2-background-bg-layer-02)]"
|
||||
onMouseDown={(event) => {
|
||||
if (event.button !== 1) return
|
||||
closeTab(event)
|
||||
}}
|
||||
onClick={(event) => {
|
||||
event.preventDefault()
|
||||
if (props.suppressNavigation?.()) return
|
||||
props.onNavigate()
|
||||
}}
|
||||
class="flex h-full min-w-0 flex-1 flex-row items-center gap-1.5 text-[13px] font-medium text-v2-text-text-faint group-data-[active='true']:text-v2-text-text-base [-webkit-user-drag:none]"
|
||||
>
|
||||
<span class="flex size-4 shrink-0 items-center justify-center">
|
||||
<IconV2 name="edit" />
|
||||
</span>
|
||||
<span
|
||||
data-titlebar-tab-title
|
||||
class="min-w-0 flex-1 overflow-hidden text-clip whitespace-nowrap outline-none leading-4"
|
||||
<a
|
||||
data-slot="tab-link"
|
||||
data-titlebar-tab-link
|
||||
href={props.href}
|
||||
draggable={false}
|
||||
onDragStart={(event) => {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
}}
|
||||
onClick={(event) => {
|
||||
event.preventDefault()
|
||||
if (props.suppressNavigation?.()) return
|
||||
props.onNavigate()
|
||||
}}
|
||||
class="flex h-full min-w-0 flex-1 flex-row items-center gap-1.5 text-[13px] font-medium text-v2-text-text-faint group-data-[active='true']:text-v2-text-text-base [-webkit-user-drag:none]"
|
||||
>
|
||||
{props.title}
|
||||
</span>
|
||||
</a>
|
||||
<div data-slot="tab-close" class="group-hover:bg-[var(--tab-bg)] group-data-[active=true]:bg-[var(--tab-bg)]">
|
||||
<IconButtonV2
|
||||
size="small"
|
||||
variant="ghost-muted"
|
||||
onPointerDown={(event) => {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
}}
|
||||
onMouseDown={(event) => {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
}}
|
||||
class="hover-reveal relative z-10 group-hover:opacity-100 group-data-[active=true]:opacity-100 group-data-[editing=true]:opacity-100"
|
||||
onClick={closeTab}
|
||||
icon={<IconV2 name="xmark-small" />}
|
||||
aria-label="Close tab"
|
||||
/>
|
||||
<span class="flex size-4 shrink-0 items-center justify-center">
|
||||
<IconV2 name="edit" />
|
||||
</span>
|
||||
<span
|
||||
data-titlebar-tab-title
|
||||
class="min-w-0 flex-1 overflow-hidden text-clip whitespace-nowrap outline-none leading-4"
|
||||
>
|
||||
{props.title}
|
||||
</span>
|
||||
</a>
|
||||
<div data-slot="tab-close" class="group-hover:bg-[var(--tab-bg)] group-data-[active=true]:bg-[var(--tab-bg)]">
|
||||
<IconButtonV2
|
||||
size="small"
|
||||
variant="ghost-muted"
|
||||
onPointerDown={(event) => {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
}}
|
||||
onMouseDown={(event) => {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
}}
|
||||
class="hover-reveal relative z-10 group-hover:opacity-100 group-data-[active=true]:opacity-100 group-data-[editing=true]:opacity-100"
|
||||
onClick={closeTab}
|
||||
icon={<IconV2 name="xmark-small" />}
|
||||
aria-label="Close tab"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -1,11 +1,18 @@
|
||||
import { createEffect, createMemo, createResource, createRoot, For, onCleanup, onMount } from "solid-js"
|
||||
import {
|
||||
createEffect,
|
||||
createMemo,
|
||||
createResource,
|
||||
createRoot,
|
||||
createSignal,
|
||||
For,
|
||||
onCleanup,
|
||||
onMount,
|
||||
Show,
|
||||
} from "solid-js"
|
||||
import { Portal } from "solid-js/web"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { makeEventListener } from "@solid-primitives/event-listener"
|
||||
import { createResizeObserver } from "@solid-primitives/resize-observer"
|
||||
import { DragDropProvider, PointerSensor } from "@dnd-kit/solid"
|
||||
import { isSortable, useSortable } from "@dnd-kit/solid/sortable"
|
||||
import { Accessibility, AutoScroller, Feedback, PointerActivationConstraints } from "@dnd-kit/dom"
|
||||
import { RestrictToHorizontalAxis } from "@dnd-kit/abstract/modifiers"
|
||||
import { RestrictToElement } from "@dnd-kit/dom/modifiers"
|
||||
import { arrayMove } from "@dnd-kit/helpers"
|
||||
import { tabHref, tabKey, type SessionTab, type Tab } from "@/context/tabs"
|
||||
import { ServerConnection } from "@/context/server"
|
||||
import { DraftTabItem, TabNavItem } from "@/components/titlebar-tab-nav"
|
||||
@@ -15,30 +22,42 @@ import { useCommand } from "@/context/command"
|
||||
import { useTabs } from "@/context/tabs"
|
||||
import { createTabPromptState } from "@/context/prompt"
|
||||
import { base64Encode } from "@opencode-ai/core/util/encode"
|
||||
import { canStartTabDrag, isTabCloseTarget } from "./titlebar-tab-gesture"
|
||||
|
||||
const sortableTransition = { duration: 0 }
|
||||
import {
|
||||
captureTabPointerDown,
|
||||
canStartTabDrag,
|
||||
createTabDragPreview,
|
||||
isPrimaryPointerPressed,
|
||||
isTabCloseTarget,
|
||||
} from "./titlebar-tab-gesture"
|
||||
import {
|
||||
ACTIVATION_DISTANCE,
|
||||
autoscrollSpeed,
|
||||
captureTabDragLayout,
|
||||
clampFloaterLeft,
|
||||
draftOrderChanged,
|
||||
insertIndexFromVirtualLayout,
|
||||
movePlaceholder,
|
||||
pointerDistance,
|
||||
syncLayoutScroll,
|
||||
type TabDragLayout,
|
||||
} from "@/components/titlebar-tab-drag"
|
||||
|
||||
function SessionTabSlot(props: {
|
||||
tab: SessionTab
|
||||
id: string
|
||||
index: () => number
|
||||
active: () => boolean
|
||||
activeServerKey: ServerConnection.Key
|
||||
forceTruncate: boolean
|
||||
dragActive: boolean
|
||||
dragged: () => boolean
|
||||
pressed: () => boolean
|
||||
serverCtx: () => ServerCtx | undefined
|
||||
suppressNavigation: () => boolean
|
||||
onPointerDown: (event: PointerEvent) => void
|
||||
onNavigate: (element: HTMLDivElement) => void
|
||||
onClose: () => void
|
||||
}) {
|
||||
const tabs = useTabs()
|
||||
const sortable = useSortable({
|
||||
get id() {
|
||||
return props.id
|
||||
},
|
||||
get index() {
|
||||
return props.index()
|
||||
},
|
||||
})
|
||||
let ref!: HTMLDivElement
|
||||
const sdk = createMemo(() => props.serverCtx()?.sdk ?? null)
|
||||
const cachedSession = createMemo(() => props.serverCtx()?.sync.session.peek(props.tab.sessionId))
|
||||
@@ -81,79 +100,33 @@ function SessionTabSlot(props: {
|
||||
})
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={sortable.ref}
|
||||
data-titlebar-tab-slot
|
||||
data-tab-key={props.id}
|
||||
class="relative flex w-56 min-w-7 max-w-56 flex-shrink"
|
||||
classList={{ hidden: !session() }}
|
||||
>
|
||||
<TabNavItem
|
||||
ref={(el) => {
|
||||
ref = el
|
||||
}}
|
||||
href={tabHref(props.tab)}
|
||||
server={props.tab.server}
|
||||
session={session}
|
||||
onTitleChange={(title) => {
|
||||
const value = session()
|
||||
const ctx = props.serverCtx()
|
||||
if (value && ctx) ctx.sync.session.remember({ ...value, title })
|
||||
}}
|
||||
onTitleChangeFailed={(title) => {
|
||||
const value = session()
|
||||
const ctx = props.serverCtx()
|
||||
if (value && ctx) ctx.sync.session.remember({ ...value, title })
|
||||
}}
|
||||
onNavigate={() => props.onNavigate(ref)}
|
||||
onClose={props.onClose}
|
||||
active={props.active()}
|
||||
activeServer={props.tab.server === props.activeServerKey}
|
||||
forceTruncate={props.forceTruncate}
|
||||
dragging={sortable.isDragSource()}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function DraftTabSlot(props: {
|
||||
tab: Extract<Tab, { type: "draft" }>
|
||||
id: string
|
||||
index: () => number
|
||||
active: () => boolean
|
||||
title: string
|
||||
onNavigate: (element: HTMLDivElement) => void
|
||||
onClose: () => void
|
||||
}) {
|
||||
const sortable = useSortable({
|
||||
get id() {
|
||||
return props.id
|
||||
},
|
||||
get index() {
|
||||
return props.index()
|
||||
},
|
||||
})
|
||||
let ref!: HTMLDivElement
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={sortable.ref}
|
||||
data-titlebar-tab-slot
|
||||
data-tab-key={props.id}
|
||||
class="relative flex w-56 min-w-7 max-w-56 flex-shrink"
|
||||
>
|
||||
<DraftTabItem
|
||||
ref={(el) => {
|
||||
ref = el
|
||||
}}
|
||||
href={tabHref(props.tab)}
|
||||
title={props.title}
|
||||
onNavigate={() => props.onNavigate(ref)}
|
||||
onClose={props.onClose}
|
||||
active={props.active()}
|
||||
dragging={sortable.isDragSource()}
|
||||
/>
|
||||
</div>
|
||||
<TabNavItem
|
||||
tabKey={props.id}
|
||||
dragActive={props.dragActive}
|
||||
onPointerDown={props.onPointerDown}
|
||||
ref={ref}
|
||||
href={tabHref(props.tab)}
|
||||
server={props.tab.server}
|
||||
session={session}
|
||||
onTitleChange={(title) => {
|
||||
const value = session()
|
||||
const ctx = props.serverCtx()
|
||||
if (value && ctx) ctx.sync.session.remember({ ...value, title })
|
||||
}}
|
||||
onTitleChangeFailed={(title) => {
|
||||
const value = session()
|
||||
const ctx = props.serverCtx()
|
||||
if (value && ctx) ctx.sync.session.remember({ ...value, title })
|
||||
}}
|
||||
onNavigate={() => props.onNavigate(ref)}
|
||||
onClose={props.onClose}
|
||||
active={props.active()}
|
||||
activeServer={props.tab.server === props.activeServerKey}
|
||||
forceTruncate={props.forceTruncate}
|
||||
suppressNavigation={props.suppressNavigation}
|
||||
pressed={props.pressed()}
|
||||
hidden={props.dragged() || !session()}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -169,12 +142,52 @@ export function TitlebarTabStrip(props: {
|
||||
}) {
|
||||
const global = useGlobal()
|
||||
const language = useLanguage()
|
||||
const [drag, setDrag] = createStore({
|
||||
active: false,
|
||||
draggedId: undefined as string | undefined,
|
||||
placeholderIndex: 0,
|
||||
draftOrder: [] as string[],
|
||||
initialOrder: [] as string[],
|
||||
draggedWidth: 0,
|
||||
pointerX: 0,
|
||||
grabOffsetX: 0,
|
||||
floaterTop: 0,
|
||||
})
|
||||
|
||||
const [gesture, setGesture] = createStore({
|
||||
pending: undefined as
|
||||
| {
|
||||
id: string
|
||||
startX: number
|
||||
startY: number
|
||||
grabOffsetX: number
|
||||
grabOffsetY: number
|
||||
pointerId: number
|
||||
width: number
|
||||
element: HTMLDivElement
|
||||
}
|
||||
| undefined,
|
||||
})
|
||||
|
||||
const [suppressNavigation, setSuppressNavigation] = createSignal(false)
|
||||
const [pressedId, setPressedId] = createSignal<string | undefined>()
|
||||
const [stripScrollLeft, setStripScrollLeft] = createSignal(0)
|
||||
let scrollRef!: HTMLDivElement
|
||||
let listRef!: HTMLDivElement
|
||||
let dragLayout: TabDragLayout | undefined
|
||||
let dragPointerId: number | undefined
|
||||
let autoscrollFrame: number | undefined
|
||||
let resizeFrame: number | undefined
|
||||
let dragPreview: HTMLDivElement | undefined
|
||||
|
||||
const tabIds = () => props.tabs.map(tabKey)
|
||||
|
||||
const displayTabs = createMemo(() => {
|
||||
if (!drag.active || drag.draftOrder.length === 0) return props.tabs
|
||||
const byKey = new Map(props.tabs.map((tab) => [tabKey(tab), tab]))
|
||||
return drag.draftOrder.map((key) => byKey.get(key)).filter((tab): tab is Tab => !!tab)
|
||||
})
|
||||
|
||||
function refreshOverflow() {
|
||||
if (!scrollRef) return
|
||||
props.onOverflowChange(scrollRef.scrollWidth > scrollRef.clientWidth)
|
||||
@@ -187,14 +200,220 @@ export function TitlebarTabStrip(props: {
|
||||
resizeFrame = requestAnimationFrame(() => {
|
||||
resizeFrame = undefined
|
||||
refreshOverflow()
|
||||
if (!drag.active || !listRef) return
|
||||
dragLayout = captureTabDragLayout(listRef, drag.draftOrder)
|
||||
updateInsertIndex()
|
||||
})
|
||||
},
|
||||
)
|
||||
|
||||
onMount(() => {
|
||||
function syncScroll() {
|
||||
if (!scrollRef || !listRef || !dragLayout) return
|
||||
syncLayoutScroll(listRef, dragLayout)
|
||||
setStripScrollLeft(scrollRef.scrollLeft)
|
||||
updateInsertIndex()
|
||||
}
|
||||
|
||||
function stopAutoscroll() {
|
||||
if (autoscrollFrame === undefined) return
|
||||
cancelAnimationFrame(autoscrollFrame)
|
||||
autoscrollFrame = undefined
|
||||
}
|
||||
|
||||
function tickAutoscroll() {
|
||||
if (!drag.active || !scrollRef) return
|
||||
|
||||
const strip = scrollRef.getBoundingClientRect()
|
||||
const speed = autoscrollSpeed(drag.pointerX, strip.left, strip.right)
|
||||
|
||||
if (speed !== 0) {
|
||||
scrollRef.scrollLeft += speed
|
||||
syncScroll()
|
||||
}
|
||||
|
||||
autoscrollFrame = requestAnimationFrame(tickAutoscroll)
|
||||
}
|
||||
|
||||
function startAutoscroll() {
|
||||
stopAutoscroll()
|
||||
autoscrollFrame = requestAnimationFrame(tickAutoscroll)
|
||||
}
|
||||
|
||||
function applyPlaceholderIndex(nextIndex: number) {
|
||||
const id = drag.draggedId
|
||||
if (!id) return
|
||||
const next = movePlaceholder(drag.draftOrder, id, nextIndex)
|
||||
setDrag({
|
||||
draftOrder: next,
|
||||
placeholderIndex: nextIndex,
|
||||
})
|
||||
}
|
||||
|
||||
function updateInsertIndex() {
|
||||
if (!drag.active || !dragLayout) return
|
||||
const draggedId = drag.draggedId
|
||||
if (!draggedId) return
|
||||
const nextIndex = insertIndexFromVirtualLayout(
|
||||
drag.pointerX,
|
||||
drag.draftOrder,
|
||||
draggedId,
|
||||
drag.placeholderIndex,
|
||||
dragLayout,
|
||||
)
|
||||
if (nextIndex === drag.placeholderIndex) return
|
||||
applyPlaceholderIndex(nextIndex)
|
||||
}
|
||||
|
||||
function startDrag(id: string) {
|
||||
const order = tabIds()
|
||||
const index = order.indexOf(id)
|
||||
const pending = gesture.pending
|
||||
if (index === -1 || !pending || !listRef || !scrollRef) return
|
||||
|
||||
dragLayout = captureTabDragLayout(listRef, order)
|
||||
dragPreview = createTabDragPreview(pending.element)
|
||||
dragPointerId = pending.pointerId
|
||||
setGesture("pending", undefined)
|
||||
|
||||
setDrag({
|
||||
active: true,
|
||||
draggedId: id,
|
||||
placeholderIndex: index,
|
||||
draftOrder: order,
|
||||
initialOrder: order,
|
||||
draggedWidth: pending.width,
|
||||
pointerX: pending.startX,
|
||||
grabOffsetX: pending.grabOffsetX,
|
||||
floaterTop: pending.startY - pending.grabOffsetY,
|
||||
})
|
||||
setPressedId(undefined)
|
||||
setStripScrollLeft(scrollRef.scrollLeft)
|
||||
startAutoscroll()
|
||||
}
|
||||
|
||||
function endDrag(commit: boolean) {
|
||||
const initial = drag.initialOrder
|
||||
const final = drag.draftOrder
|
||||
const moved = drag.active
|
||||
|
||||
if (commit && moved && draftOrderChanged(initial, final)) {
|
||||
props.onReorder(final)
|
||||
}
|
||||
|
||||
if (moved) setSuppressNavigation(true)
|
||||
|
||||
setDrag({
|
||||
active: false,
|
||||
draggedId: undefined,
|
||||
placeholderIndex: 0,
|
||||
draftOrder: [],
|
||||
initialOrder: [],
|
||||
draggedWidth: 0,
|
||||
pointerX: 0,
|
||||
grabOffsetX: 0,
|
||||
floaterTop: 0,
|
||||
})
|
||||
|
||||
dragLayout = undefined
|
||||
dragPreview = undefined
|
||||
dragPointerId = undefined
|
||||
setGesture("pending", undefined)
|
||||
setPressedId(undefined)
|
||||
stopAutoscroll()
|
||||
refreshOverflow()
|
||||
requestAnimationFrame(() => setSuppressNavigation(false))
|
||||
}
|
||||
|
||||
function onPointerDown(id: string, event: PointerEvent) {
|
||||
if (event.button !== 0 || drag.active) return
|
||||
if (!canStartTabDrag(event.pointerType)) return
|
||||
if (isTabCloseTarget(event.target)) return
|
||||
const target = event.currentTarget as HTMLDivElement
|
||||
const tabEl = target.matches("[data-titlebar-tab]")
|
||||
? target
|
||||
: target.querySelector<HTMLDivElement>("[data-titlebar-tab]")
|
||||
if (!tabEl) return
|
||||
if (!tabEl.querySelector('[data-slot="tab-link"]')) return
|
||||
const tab = props.tabs.find((item) => tabKey(item) === id)
|
||||
if (!tab) return
|
||||
const pointer = captureTabPointerDown(tabEl, event.clientX, event.clientY)
|
||||
setSuppressNavigation(true)
|
||||
props.onNavigate(tab, tabEl)
|
||||
setPressedId(id)
|
||||
setGesture("pending", {
|
||||
id,
|
||||
pointerId: event.pointerId,
|
||||
...pointer,
|
||||
})
|
||||
}
|
||||
|
||||
function onPointerMove(event: PointerEvent) {
|
||||
const pending = gesture.pending
|
||||
if (pending && event.pointerId !== pending.pointerId) return
|
||||
if (drag.active && dragPointerId !== undefined && event.pointerId !== dragPointerId) return
|
||||
if (!isPrimaryPointerPressed(event.buttons)) {
|
||||
if (drag.active) endDrag(true)
|
||||
if (pending) {
|
||||
setGesture("pending", undefined)
|
||||
setPressedId(undefined)
|
||||
requestAnimationFrame(() => setSuppressNavigation(false))
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if (pending && !drag.active) {
|
||||
if (pointerDistance(pending.startX, pending.startY, event.clientX, event.clientY) < ACTIVATION_DISTANCE) return
|
||||
startDrag(pending.id)
|
||||
}
|
||||
|
||||
if (!drag.active) return
|
||||
|
||||
setDrag("pointerX", event.clientX)
|
||||
syncScroll()
|
||||
}
|
||||
|
||||
function onPointerUp(event: PointerEvent) {
|
||||
if (drag.active) {
|
||||
if (dragPointerId !== undefined && event.pointerId !== dragPointerId) return
|
||||
setDrag("pointerX", event.clientX)
|
||||
syncScroll()
|
||||
endDrag(true)
|
||||
return
|
||||
}
|
||||
|
||||
const pending = gesture.pending
|
||||
if (pending && event.pointerId !== pending.pointerId) return
|
||||
|
||||
setGesture("pending", undefined)
|
||||
setPressedId(undefined)
|
||||
requestAnimationFrame(() => setSuppressNavigation(false))
|
||||
}
|
||||
|
||||
function onPointerCancel(event: PointerEvent) {
|
||||
if (drag.active) {
|
||||
if (dragPointerId !== undefined && event.pointerId !== dragPointerId) return
|
||||
endDrag(false)
|
||||
return
|
||||
}
|
||||
|
||||
if (!gesture.pending) return
|
||||
if (gesture.pending.pointerId !== event.pointerId) return
|
||||
setGesture("pending", undefined)
|
||||
setPressedId(undefined)
|
||||
requestAnimationFrame(() => setSuppressNavigation(false))
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
const cleanups = [
|
||||
makeEventListener(window, "pointermove", onPointerMove),
|
||||
makeEventListener(window, "pointerup", onPointerUp),
|
||||
makeEventListener(window, "pointercancel", onPointerCancel),
|
||||
]
|
||||
refreshOverflow()
|
||||
onCleanup(() => cleanups.forEach((cleanup) => cleanup()))
|
||||
})
|
||||
|
||||
onCleanup(stopAutoscroll)
|
||||
onCleanup(() => {
|
||||
if (resizeFrame !== undefined) cancelAnimationFrame(resizeFrame)
|
||||
})
|
||||
@@ -205,54 +424,50 @@ export function TitlebarTabStrip(props: {
|
||||
refreshOverflow()
|
||||
})
|
||||
|
||||
return (
|
||||
<div data-slot="titlebar-tabs" class="relative min-w-0">
|
||||
<div
|
||||
data-slot="titlebar-tabs-scroll"
|
||||
class="flex min-w-0 flex-row items-center gap-1.5 overflow-x-auto no-scrollbar [app-region:no-drag]"
|
||||
ref={scrollRef}
|
||||
>
|
||||
<DragDropProvider
|
||||
sensors={[
|
||||
PointerSensor.configure({
|
||||
activationConstraints: [new PointerActivationConstraints.Distance({ value: 4 })],
|
||||
preventActivation: (event) =>
|
||||
!canStartTabDrag(event.pointerType) ||
|
||||
isTabCloseTarget(event.target) ||
|
||||
(event.target instanceof Element && !!event.target.closest('[contenteditable="true"]')),
|
||||
}),
|
||||
]}
|
||||
modifiers={[RestrictToHorizontalAxis, RestrictToElement.configure({ element: () => listRef })]}
|
||||
plugins={(defaults) => [
|
||||
...defaults.filter((plugin) => plugin !== Accessibility),
|
||||
AutoScroller.configure({ acceleration: 8, threshold: { x: 0.05, y: 0 } }),
|
||||
Feedback.configure({ dropAnimation: null }),
|
||||
]}
|
||||
onDragStart={(event) => {
|
||||
const source = event.operation.source
|
||||
if (!source) return
|
||||
const tab = props.tabs.find((item) => tabKey(item) === source.id.toString())
|
||||
if (!tab) return
|
||||
const tabEl = source.element?.querySelector<HTMLDivElement>("[data-titlebar-tab]")
|
||||
props.onNavigate(tab, tabEl ?? undefined)
|
||||
}}
|
||||
onDragEnd={(event) => {
|
||||
const current = tabIds()
|
||||
const source = event.operation.source
|
||||
if (event.canceled || !isSortable(source)) return
|
||||
createEffect(() => {
|
||||
if (!drag.active || !scrollRef) return
|
||||
onCleanup(makeEventListener(scrollRef, "scroll", syncScroll))
|
||||
})
|
||||
|
||||
const { initialIndex, index } = source
|
||||
if (initialIndex !== index) {
|
||||
props.onReorder(arrayMove(current, source.initialIndex, source.index))
|
||||
}
|
||||
}}
|
||||
const floaterStyle = () => {
|
||||
stripScrollLeft()
|
||||
const strip = scrollRef?.getBoundingClientRect()
|
||||
const left = strip
|
||||
? clampFloaterLeft(drag.pointerX - drag.grabOffsetX, drag.draggedWidth, strip.left, strip.right)
|
||||
: drag.pointerX - drag.grabOffsetX
|
||||
|
||||
return {
|
||||
position: "fixed" as const,
|
||||
top: `${drag.floaterTop}px`,
|
||||
left: `${left}px`,
|
||||
width: `${drag.draggedWidth}px`,
|
||||
"z-index": "10000",
|
||||
"pointer-events": "none" as const,
|
||||
}
|
||||
}
|
||||
|
||||
const draggedTab = createMemo(() => {
|
||||
const id = drag.draggedId
|
||||
if (!id) return
|
||||
return props.tabs.find((tab) => tabKey(tab) === id)
|
||||
})
|
||||
|
||||
return (
|
||||
<>
|
||||
<div data-slot="titlebar-tabs" class="relative min-w-0">
|
||||
<div
|
||||
data-slot="titlebar-tabs-scroll"
|
||||
class="flex min-w-0 flex-row items-center gap-1.5 overflow-x-auto no-scrollbar [app-region:no-drag]"
|
||||
ref={scrollRef}
|
||||
>
|
||||
<div data-titlebar-tab-list class="flex w-full min-w-0 flex-row items-center" ref={listRef}>
|
||||
<For each={props.tabs}>
|
||||
<div data-titlebar-tab-list class="flex min-w-0 flex-row items-center" ref={listRef}>
|
||||
<For each={displayTabs()}>
|
||||
{(tab, index) => {
|
||||
const id = tabKey(tab)
|
||||
let ref!: HTMLDivElement
|
||||
useTabShortcut(index, () => props.onNavigate(tab, ref))
|
||||
|
||||
const dragged = () => drag.active && drag.draggedId === id
|
||||
const serverCtx = createMemo(() => {
|
||||
if (tab.type !== "session") return
|
||||
const conn = global.servers.list().find((item) => ServerConnection.key(item) === tab.server)
|
||||
@@ -264,50 +479,68 @@ export function TitlebarTabStrip(props: {
|
||||
<SessionTabSlot
|
||||
tab={tab}
|
||||
id={id}
|
||||
index={index}
|
||||
active={() => props.currentTab() === tab}
|
||||
activeServerKey={props.activeServerKey}
|
||||
forceTruncate={props.forceTruncate}
|
||||
dragActive={drag.active}
|
||||
dragged={dragged}
|
||||
pressed={() => pressedId() === id}
|
||||
serverCtx={serverCtx}
|
||||
onNavigate={(element) => {
|
||||
ref = element
|
||||
props.onNavigate(tab, element)
|
||||
suppressNavigation={() => suppressNavigation()}
|
||||
onPointerDown={(event) => {
|
||||
if (dragged()) return
|
||||
onPointerDown(id, event)
|
||||
}}
|
||||
onNavigate={(element) => props.onNavigate(tab, element)}
|
||||
onClose={() => props.onClose(tab)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<DraftTabSlot
|
||||
tab={tab}
|
||||
id={id}
|
||||
index={index}
|
||||
active={() => props.currentTab() === tab}
|
||||
title={language.t("command.session.new")}
|
||||
onNavigate={(element) => {
|
||||
ref = element
|
||||
props.onNavigate(tab, element)
|
||||
<DraftTabItem
|
||||
tabKey={id}
|
||||
dragActive={drag.active}
|
||||
onPointerDown={(event) => {
|
||||
if (dragged()) return
|
||||
onPointerDown(id, event)
|
||||
}}
|
||||
ref={ref}
|
||||
href={tabHref(tab)}
|
||||
title={language.t("command.session.new")}
|
||||
onNavigate={() => props.onNavigate(tab, ref)}
|
||||
onClose={() => props.onClose(tab)}
|
||||
suppressNavigation={() => suppressNavigation()}
|
||||
active={props.currentTab() === tab}
|
||||
pressed={pressedId() === id}
|
||||
hidden={dragged()}
|
||||
/>
|
||||
)
|
||||
}}
|
||||
</For>
|
||||
</div>
|
||||
</DragDropProvider>
|
||||
</div>
|
||||
<div
|
||||
data-slot="titlebar-tabs-fade-left"
|
||||
aria-hidden="true"
|
||||
class="pointer-events-none absolute inset-y-0 left-0 z-10 w-6 bg-[linear-gradient(to_right,var(--v2-background-bg-deep),transparent)]"
|
||||
/>
|
||||
<div
|
||||
data-slot="titlebar-tabs-fade-right"
|
||||
aria-hidden="true"
|
||||
class="pointer-events-none absolute inset-y-0 right-0 z-10 w-6 bg-[linear-gradient(to_left,var(--v2-background-bg-deep),transparent)]"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
data-slot="titlebar-tabs-fade-left"
|
||||
aria-hidden="true"
|
||||
class="pointer-events-none absolute inset-y-0 left-0 z-10 w-6 bg-[linear-gradient(to_right,var(--v2-background-bg-deep),transparent)]"
|
||||
/>
|
||||
<div
|
||||
data-slot="titlebar-tabs-fade-right"
|
||||
aria-hidden="true"
|
||||
class="pointer-events-none absolute inset-y-0 right-0 z-10 w-6 bg-[linear-gradient(to_left,var(--v2-background-bg-deep),transparent)]"
|
||||
/>
|
||||
</div>
|
||||
<Show when={drag.active && draggedTab() && dragPreview}>
|
||||
{(_) => (
|
||||
<Portal>
|
||||
<div data-titlebar-tab-preview style={floaterStyle()}>
|
||||
{dragPreview}
|
||||
</div>
|
||||
</Portal>
|
||||
)}
|
||||
</Show>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -656,10 +656,6 @@ export const dict = {
|
||||
"session.new.worktree.main": "Main branch",
|
||||
"session.new.worktree.mainWithBranch": "Main branch ({{branch}})",
|
||||
"session.new.worktree.create": "Create new worktree",
|
||||
"session.new.workspace.runIn": "Run session in",
|
||||
"session.new.workspace.triggerLocal": "Local",
|
||||
"session.new.workspace.local": "Local repository",
|
||||
"session.new.workspace.existing": "Workspace…",
|
||||
"session.new.lastModified": "Last modified",
|
||||
|
||||
"session.header.search.placeholder": "Search {{project}}",
|
||||
|
||||
@@ -2,7 +2,6 @@ import type { Session } from "@opencode-ai/sdk/v2/client"
|
||||
import {
|
||||
createEffect,
|
||||
createMemo,
|
||||
createResource,
|
||||
createRoot,
|
||||
For,
|
||||
Match,
|
||||
@@ -34,7 +33,6 @@ import { usePlatform } from "@/context/platform"
|
||||
import { DateTime } from "luxon"
|
||||
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
||||
import { useDirectoryPicker } from "@/components/directory-picker"
|
||||
import { useSettingsCommand } from "@/components/settings-dialog"
|
||||
import { DialogSelectServer, useServerManagementController } from "@/components/dialog-select-server"
|
||||
import { DialogServerV2 } from "@/components/settings-v2/dialog-server-v2"
|
||||
import { ServerConnection, serverName, useServer } from "@/context/server"
|
||||
@@ -146,7 +144,6 @@ export function NewHome() {
|
||||
const command = useCommand()
|
||||
const notification = useNotification()
|
||||
const marked = useMarked()
|
||||
const openSettings = useSettingsCommand()
|
||||
let focusSessionSearch: (() => void) | undefined
|
||||
const [state, setState] = createStore({
|
||||
search: "",
|
||||
@@ -405,6 +402,12 @@ export function NewHome() {
|
||||
})
|
||||
}
|
||||
|
||||
function openSettings() {
|
||||
void import("@/components/settings-v2").then((x) => {
|
||||
dialog.show(() => <x.DialogSettings />)
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<div class="rounded-[10px] shadow-[var(--v2-elevation-raised)] m-2 min-h-0 lg:overflow-hidden bg-v2-background-bg-base self-stretch flex-1">
|
||||
<div class="mx-auto grid h-full w-full max-w-[1080px] grid-rows-[auto_minmax(0,1fr)_auto] gap-4 px-3 pb-3 lg:grid-cols-[280px_minmax(0,720px)] lg:grid-rows-1 lg:gap-8 lg:px-6 lg:pb-16">
|
||||
@@ -526,16 +529,10 @@ function HomeProjectColumn(props: {
|
||||
const global = useGlobal()
|
||||
const dialog = useDialog()
|
||||
const controller = useServerManagementController({ navigateOnAdd: false })
|
||||
const [_state, setState, _, ready] = persisted(
|
||||
const [state, setState] = persisted(
|
||||
Persist.global("home.servers", ["home.servers.v1"]),
|
||||
createStore({ collapsed: {} as Record<string, boolean> }),
|
||||
)
|
||||
const [state] = createResource(
|
||||
() => ready.promise ?? Promise.resolve(),
|
||||
(p) => p.then(() => _state),
|
||||
{ initialValue: _state },
|
||||
)
|
||||
|
||||
return (
|
||||
<aside
|
||||
class="mt-6 flex min-w-0 flex-col gap-4 lg:mt-14 lg:pt-[52px]"
|
||||
@@ -567,7 +564,7 @@ function HomeProjectColumn(props: {
|
||||
const key = ServerConnection.key(item)
|
||||
const healthy = () => !!global.servers.health[key]?.healthy
|
||||
const serverCtx = global.ensureServerCtx(item)
|
||||
const collapsed = () => !!state().collapsed[key]
|
||||
const collapsed = () => !!state.collapsed[key]
|
||||
return (
|
||||
<div class="flex max-h-[min(572px,calc(100vh_-_300px))] min-w-0 flex-col gap-1 overflow-y-auto [scrollbar-width:none] [&::-webkit-scrollbar]:hidden">
|
||||
<HomeServerRow
|
||||
@@ -580,7 +577,7 @@ function HomeProjectColumn(props: {
|
||||
focusServer={props.focusServer}
|
||||
chooseProject={props.chooseProject}
|
||||
openEdit={(server) => dialog.show(() => <DialogServerV2 mode="edit" server={server} />)}
|
||||
toggleCollapsed={() => setState("collapsed", key, !state().collapsed[key])}
|
||||
toggleCollapsed={() => setState("collapsed", key, !state.collapsed[key])}
|
||||
language={props.language}
|
||||
/>
|
||||
<Show when={healthy() && !collapsed()}>
|
||||
|
||||
@@ -3,12 +3,18 @@ import { useNavigate, useParams } from "@solidjs/router"
|
||||
import { DebugBar } from "@/components/debug-bar"
|
||||
import { HelpButton } from "@/components/help-button"
|
||||
import { Titlebar, type TitlebarUpdate } from "@/components/titlebar"
|
||||
import { useCommand } from "@/context/command"
|
||||
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
||||
import { useLanguage } from "@/context/language"
|
||||
import { useNotification } from "@/context/notification"
|
||||
import { usePlatform } from "@/context/platform"
|
||||
import { setNavigate } from "@/utils/notification-click"
|
||||
import { setV2Toast, ToastRegion } from "@/utils/toast"
|
||||
|
||||
export default function NewLayout(props: ParentProps) {
|
||||
const command = useCommand()
|
||||
const dialog = useDialog()
|
||||
const language = useLanguage()
|
||||
const platform = usePlatform()
|
||||
const notification = useNotification()
|
||||
const navigate = useNavigate()
|
||||
@@ -22,6 +28,20 @@ export default function NewLayout(props: ParentProps) {
|
||||
notification.session.markViewed(params.id)
|
||||
})
|
||||
|
||||
command.register("layout", () => [
|
||||
{
|
||||
id: "settings.open",
|
||||
title: language.t("command.settings.open"),
|
||||
category: language.t("command.category.settings"),
|
||||
keybind: "mod+comma",
|
||||
onSelect: () => {
|
||||
void import("@/components/settings-v2").then((x) => {
|
||||
dialog.show(() => <x.DialogSettings />)
|
||||
})
|
||||
},
|
||||
},
|
||||
])
|
||||
|
||||
const update: TitlebarUpdate = {
|
||||
version: () => {
|
||||
const state = platform.updater?.state()
|
||||
|
||||
@@ -3,7 +3,6 @@ import { createStore } from "solid-js/store"
|
||||
import { useSearchParams } from "@solidjs/router"
|
||||
import { NewSessionDesignView } from "@/components/session"
|
||||
import { PromptInput } from "@/components/prompt-input"
|
||||
import { useSettingsCommand } from "@/components/settings-dialog"
|
||||
import {
|
||||
PromptProjectAddButton,
|
||||
PromptProjectSelector,
|
||||
@@ -19,9 +18,6 @@ import { createPromptInputController, createPromptProjectControls } from "@/page
|
||||
import { useSessionKey } from "@/pages/session/session-layout"
|
||||
import { useComposerCommands } from "@/pages/session/use-composer-commands"
|
||||
import { NEW_SESSION_CONTENT_WIDTH } from "@/pages/session/new-session-layout"
|
||||
import { PromptWorkspaceSelector } from "@/components/prompt-workspace-selector"
|
||||
|
||||
const showWorkspaceBar = import.meta.env.VITE_OPENCODE_CHANNEL !== "prod"
|
||||
|
||||
/**
|
||||
* The `/new-session` draft page. Unlike `session.tsx`, this only renders the prompt
|
||||
@@ -39,7 +35,6 @@ export default function NewSessionPage() {
|
||||
const [searchParams, setSearchParams] = useSearchParams<{ draftId?: string; prompt?: string }>()
|
||||
|
||||
useComposerCommands()
|
||||
useSettingsCommand()
|
||||
|
||||
let inputRef: HTMLDivElement | undefined
|
||||
|
||||
@@ -54,21 +49,16 @@ export default function NewSessionPage() {
|
||||
onDone: () => inputRef?.focus(),
|
||||
})
|
||||
|
||||
const [store, setStore] = createStore<{ worktree?: string }>({})
|
||||
const [store, setStore] = createStore({
|
||||
worktree: "main",
|
||||
})
|
||||
|
||||
const newSessionWorktree = createMemo(() => {
|
||||
if (store.worktree) return store.worktree
|
||||
if (store.worktree === "create") return "create"
|
||||
const project = sync().project
|
||||
if (project && sdk().directory !== project.worktree) return sdk().directory
|
||||
return "main"
|
||||
})
|
||||
const projectRoot = createMemo(() => sync().project?.worktree ?? sdk().directory)
|
||||
const localBranch = createMemo(() => serverSync().child(projectRoot())[0].vcs?.branch)
|
||||
const selectedBranch = createMemo(() => {
|
||||
const worktree = newSessionWorktree()
|
||||
if (worktree === "main" || worktree === "create") return localBranch()
|
||||
return serverSync().child(worktree)[0].vcs?.branch ?? localBranch()
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
if (!prompt.ready()) return
|
||||
@@ -105,7 +95,7 @@ export default function NewSessionPage() {
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div class="flex flex-col" classList={{ "gap-8": showWorkspaceBar, "gap-3": !showWorkspaceBar }}>
|
||||
<div class="flex flex-col gap-3">
|
||||
<PromptInput
|
||||
controls={inputController()}
|
||||
variant="new-session"
|
||||
@@ -113,7 +103,7 @@ export default function NewSessionPage() {
|
||||
inputRef = el
|
||||
}}
|
||||
newSessionWorktree={newSessionWorktree()}
|
||||
onNewSessionWorktreeReset={() => setStore("worktree", undefined)}
|
||||
onNewSessionWorktreeReset={() => setStore("worktree", "main")}
|
||||
onSubmit={() => comments.clear()}
|
||||
toolbar={
|
||||
<Show when={!projectController.selected()}>
|
||||
@@ -122,34 +112,8 @@ export default function NewSessionPage() {
|
||||
}
|
||||
/>
|
||||
<Show when={projectController.selected()}>
|
||||
<div
|
||||
class="flex min-h-7 min-w-0 items-center gap-0 text-v2-text-text-faint"
|
||||
classList={{
|
||||
"flex-col justify-center sm:flex-row": showWorkspaceBar,
|
||||
"justify-start": !showWorkspaceBar,
|
||||
}}
|
||||
>
|
||||
<PromptProjectSelector
|
||||
controller={projectController}
|
||||
placement={showWorkspaceBar ? "bottom" : "bottom-start"}
|
||||
/>
|
||||
<Show when={showWorkspaceBar}>
|
||||
<PromptWorkspaceSelector
|
||||
value={newSessionWorktree()}
|
||||
projectRoot={projectRoot()}
|
||||
workspaces={sync().project?.sandboxes ?? []}
|
||||
branch={selectedBranch()}
|
||||
onChange={(value) =>
|
||||
setStore(
|
||||
"worktree",
|
||||
value === "main" && sync().project?.worktree !== sdk().directory
|
||||
? sync().project?.worktree
|
||||
: value,
|
||||
)
|
||||
}
|
||||
onDone={() => inputRef?.focus()}
|
||||
/>
|
||||
</Show>
|
||||
<div class="flex h-7 min-w-0 items-center gap-0 px-2">
|
||||
<PromptProjectSelector controller={projectController} />
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
|
||||
@@ -43,7 +43,6 @@ import { useSettings } from "@/context/settings"
|
||||
import { useSync } from "@/context/sync"
|
||||
import { useTerminal } from "@/context/terminal"
|
||||
import { PromptInput } from "@/components/prompt-input"
|
||||
import { useSettingsCommand } from "@/components/settings-dialog"
|
||||
import { type FollowupDraft, sendFollowupDraft } from "@/components/prompt-input/submit"
|
||||
import {
|
||||
createPromptInputController,
|
||||
@@ -788,7 +787,6 @@ export default function Page() {
|
||||
}
|
||||
|
||||
useComposerCommands()
|
||||
useSettingsCommand()
|
||||
useSessionCommands({
|
||||
navigateMessageByOffset,
|
||||
setActiveMessage,
|
||||
@@ -908,13 +906,7 @@ export default function Page() {
|
||||
)
|
||||
|
||||
const reviewPanel = () => (
|
||||
<div
|
||||
classList={{
|
||||
"flex flex-col h-full overflow-hidden contain-strict": true,
|
||||
"bg-v2-background-bg-base": settings.general.newLayoutDesigns(),
|
||||
"bg-background-stronger": !settings.general.newLayoutDesigns(),
|
||||
}}
|
||||
>
|
||||
<div class="flex flex-col h-full overflow-hidden bg-background-stronger contain-strict">
|
||||
<div class="relative pt-2 flex-1 min-h-0 overflow-hidden">
|
||||
{reviewContent({
|
||||
diffStyle: layout.review.diffStyle(),
|
||||
@@ -1719,9 +1711,7 @@ export default function Page() {
|
||||
>
|
||||
<div
|
||||
classList={{
|
||||
"flex-1 min-h-0 flex flex-col": true,
|
||||
"bg-v2-background-bg-base": settings.general.newLayoutDesigns(),
|
||||
"bg-background-stronger": !settings.general.newLayoutDesigns(),
|
||||
"flex-1 min-h-0 flex flex-col bg-background-stronger": true,
|
||||
"rounded-[10px] overflow-hidden": settings.general.newLayoutDesigns(),
|
||||
"shadow-[var(--v2-elevation-raised)]": settings.general.newLayoutDesigns() && !!params.id,
|
||||
}}
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { Show, type JSX } from "solid-js"
|
||||
import { useLanguage } from "@/context/language"
|
||||
import { useSettings } from "@/context/settings"
|
||||
import { SessionPermissionDock } from "@/pages/session/composer/session-permission-dock"
|
||||
import { SessionQuestionDock } from "@/pages/session/composer/session-question-dock"
|
||||
import { SessionFollowupDock } from "@/pages/session/composer/session-followup-dock"
|
||||
@@ -14,7 +13,6 @@ export function SessionComposerRegion(props: {
|
||||
}) {
|
||||
const language = useLanguage()
|
||||
const controller = props.controller
|
||||
const settings = useSettings()
|
||||
const rolled = () => {
|
||||
const revert = controller.revert()
|
||||
return revert?.items.length ? revert : undefined
|
||||
@@ -24,11 +22,7 @@ export function SessionComposerRegion(props: {
|
||||
<div
|
||||
ref={controller.setDockRef}
|
||||
data-component="session-prompt-dock"
|
||||
classList={{
|
||||
"w-full shrink-0 flex flex-col justify-center items-center pb-3 pointer-events-none": true,
|
||||
"bg-v2-background-bg-base": settings.general.newLayoutDesigns(),
|
||||
"bg-background-stronger": !settings.general.newLayoutDesigns(),
|
||||
}}
|
||||
class="w-full shrink-0 flex flex-col justify-center items-center pb-3 bg-background-stronger pointer-events-none"
|
||||
>
|
||||
<div
|
||||
classList={{
|
||||
|
||||
@@ -293,7 +293,7 @@ export function TerminalPanel() {
|
||||
</Tabs.List>
|
||||
</Tabs>
|
||||
<div class="flex-1 min-h-0 relative">
|
||||
<Show when={opened() && terminal.active()} keyed>
|
||||
<Show when={terminal.active()} keyed>
|
||||
{(id) => {
|
||||
const ops = terminal.bind()
|
||||
return (
|
||||
|
||||
@@ -1019,13 +1019,7 @@ export function MessageTimeline(props: {
|
||||
<div class="flex w-max min-w-full justify-end gap-2">
|
||||
<Index each={comments()}>
|
||||
{(comment) => (
|
||||
<div
|
||||
classList={{
|
||||
"shrink-0 max-w-[260px] rounded-[6px] border-border-weak-base bg-background-stronger px-2.5 py-2": true,
|
||||
"border-[0.5px]": settings.general.newLayoutDesigns(),
|
||||
border: !settings.general.newLayoutDesigns(),
|
||||
}}
|
||||
>
|
||||
<div class="shrink-0 max-w-[260px] rounded-[6px] border border-border-weak-base bg-background-stronger px-2.5 py-2">
|
||||
<div class="flex items-center gap-1.5 min-w-0 text-11-medium text-text-strong">
|
||||
<FileIcon node={{ path: comment().path, type: "file" }} class="size-3.5 shrink-0" />
|
||||
<span class="truncate">{getFilename(comment().path)}</span>
|
||||
@@ -1294,11 +1288,7 @@ export function MessageTimeline(props: {
|
||||
<div
|
||||
data-session-title
|
||||
classList={{
|
||||
"sticky top-0 z-30": true,
|
||||
"bg-[linear-gradient(to_bottom,var(--v2-background-bg-base)_48px,transparent)]":
|
||||
settings.general.newLayoutDesigns(),
|
||||
"bg-[linear-gradient(to_bottom,var(--background-stronger)_48px,transparent)]":
|
||||
!settings.general.newLayoutDesigns(),
|
||||
"sticky top-0 z-30 bg-[linear-gradient(to_bottom,var(--background-stronger)_48px,transparent)]": true,
|
||||
"w-full": true,
|
||||
"pb-4": true,
|
||||
"pr-3": true,
|
||||
@@ -1519,11 +1509,7 @@ export function MessageTimeline(props: {
|
||||
<Button
|
||||
size="large"
|
||||
variant="secondary"
|
||||
class={
|
||||
settings.general.newLayoutDesigns()
|
||||
? "w-full shadow-none border-[0.5px] border-border-weak-base"
|
||||
: "w-full shadow-none border border-border-weak-base"
|
||||
}
|
||||
class="w-full shadow-none border border-border-weak-base"
|
||||
onClick={unshareSession}
|
||||
disabled={unshareMutation.isPending}
|
||||
>
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
||||
import { Tag } from "@opencode-ai/ui/v2/badge-v2"
|
||||
import { ButtonV2 } from "@opencode-ai/ui/v2/button-v2"
|
||||
import { Dialog, DialogBody, DialogHeader, DialogTitle } from "@opencode-ai/ui/v2/dialog-v2"
|
||||
import { DividerV2 } from "@opencode-ai/ui/v2/divider-v2"
|
||||
import { Dialog } from "@opencode-ai/ui/v2/dialog-v2"
|
||||
import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon"
|
||||
import { IconButtonV2 } from "@opencode-ai/ui/v2/icon-button-v2"
|
||||
import { MenuV2 } from "@opencode-ai/ui/v2/menu-v2"
|
||||
@@ -31,14 +30,8 @@ export function AddServerMenu(props: { onAddServer: () => void }) {
|
||||
const language = useLanguage()
|
||||
const openAddWsl = () => {
|
||||
dialog.push(() => (
|
||||
<Dialog size="large" fit class="settings-v2-wsl-dialog">
|
||||
<DialogHeader hideClose={true}>
|
||||
<DialogTitle>{language.t("wsl.server.add")}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<DividerV2 />
|
||||
<DialogBody>
|
||||
<DialogAddWslServer />
|
||||
</DialogBody>
|
||||
<Dialog title={language.t("wsl.server.add")} size="large" fit class="settings-v2-wsl-dialog">
|
||||
<DialogAddWslServer />
|
||||
</Dialog>
|
||||
))
|
||||
}
|
||||
|
||||
@@ -55,49 +55,43 @@ const Endpoint0_1 = (raw: RawClient["server.session"]) => (input?: Endpoint0_1In
|
||||
Effect.map((value) => value.data),
|
||||
)
|
||||
|
||||
const Endpoint0_2 = (raw: RawClient["server.session"]) => () =>
|
||||
raw["session.active"]({}).pipe(
|
||||
Effect.mapError(mapClientError),
|
||||
Effect.map((value) => value.data),
|
||||
)
|
||||
|
||||
type Endpoint0_3Request = Parameters<RawClient["server.session"]["session.get"]>[0]
|
||||
type Endpoint0_3Input = { readonly sessionID: Endpoint0_3Request["params"]["sessionID"] }
|
||||
const Endpoint0_3 = (raw: RawClient["server.session"]) => (input: Endpoint0_3Input) =>
|
||||
type Endpoint0_2Request = Parameters<RawClient["server.session"]["session.get"]>[0]
|
||||
type Endpoint0_2Input = { readonly sessionID: Endpoint0_2Request["params"]["sessionID"] }
|
||||
const Endpoint0_2 = (raw: RawClient["server.session"]) => (input: Endpoint0_2Input) =>
|
||||
raw["session.get"]({ params: { sessionID: input.sessionID } }).pipe(
|
||||
Effect.mapError(mapClientError),
|
||||
Effect.map((value) => value.data),
|
||||
)
|
||||
|
||||
type Endpoint0_4Request = Parameters<RawClient["server.session"]["session.switchAgent"]>[0]
|
||||
type Endpoint0_4Input = {
|
||||
readonly sessionID: Endpoint0_4Request["params"]["sessionID"]
|
||||
readonly agent: Endpoint0_4Request["payload"]["agent"]
|
||||
type Endpoint0_3Request = Parameters<RawClient["server.session"]["session.switchAgent"]>[0]
|
||||
type Endpoint0_3Input = {
|
||||
readonly sessionID: Endpoint0_3Request["params"]["sessionID"]
|
||||
readonly agent: Endpoint0_3Request["payload"]["agent"]
|
||||
}
|
||||
const Endpoint0_4 = (raw: RawClient["server.session"]) => (input: Endpoint0_4Input) =>
|
||||
const Endpoint0_3 = (raw: RawClient["server.session"]) => (input: Endpoint0_3Input) =>
|
||||
raw["session.switchAgent"]({ params: { sessionID: input.sessionID }, payload: { agent: input.agent } }).pipe(
|
||||
Effect.mapError(mapClientError),
|
||||
)
|
||||
|
||||
type Endpoint0_5Request = Parameters<RawClient["server.session"]["session.switchModel"]>[0]
|
||||
type Endpoint0_5Input = {
|
||||
readonly sessionID: Endpoint0_5Request["params"]["sessionID"]
|
||||
readonly model: Endpoint0_5Request["payload"]["model"]
|
||||
type Endpoint0_4Request = Parameters<RawClient["server.session"]["session.switchModel"]>[0]
|
||||
type Endpoint0_4Input = {
|
||||
readonly sessionID: Endpoint0_4Request["params"]["sessionID"]
|
||||
readonly model: Endpoint0_4Request["payload"]["model"]
|
||||
}
|
||||
const Endpoint0_5 = (raw: RawClient["server.session"]) => (input: Endpoint0_5Input) =>
|
||||
const Endpoint0_4 = (raw: RawClient["server.session"]) => (input: Endpoint0_4Input) =>
|
||||
raw["session.switchModel"]({ params: { sessionID: input.sessionID }, payload: { model: input.model } }).pipe(
|
||||
Effect.mapError(mapClientError),
|
||||
)
|
||||
|
||||
type Endpoint0_6Request = Parameters<RawClient["server.session"]["session.prompt"]>[0]
|
||||
type Endpoint0_6Input = {
|
||||
readonly sessionID: Endpoint0_6Request["params"]["sessionID"]
|
||||
readonly id?: Endpoint0_6Request["payload"]["id"]
|
||||
readonly prompt: Endpoint0_6Request["payload"]["prompt"]
|
||||
readonly delivery?: Endpoint0_6Request["payload"]["delivery"]
|
||||
readonly resume?: Endpoint0_6Request["payload"]["resume"]
|
||||
type Endpoint0_5Request = Parameters<RawClient["server.session"]["session.prompt"]>[0]
|
||||
type Endpoint0_5Input = {
|
||||
readonly sessionID: Endpoint0_5Request["params"]["sessionID"]
|
||||
readonly id?: Endpoint0_5Request["payload"]["id"]
|
||||
readonly prompt: Endpoint0_5Request["payload"]["prompt"]
|
||||
readonly delivery?: Endpoint0_5Request["payload"]["delivery"]
|
||||
readonly resume?: Endpoint0_5Request["payload"]["resume"]
|
||||
}
|
||||
const Endpoint0_6 = (raw: RawClient["server.session"]) => (input: Endpoint0_6Input) =>
|
||||
const Endpoint0_5 = (raw: RawClient["server.session"]) => (input: Endpoint0_5Input) =>
|
||||
raw["session.prompt"]({
|
||||
params: { sessionID: input.sessionID },
|
||||
payload: { id: input.id, prompt: input.prompt, delivery: input.delivery, resume: input.resume },
|
||||
@@ -106,23 +100,23 @@ const Endpoint0_6 = (raw: RawClient["server.session"]) => (input: Endpoint0_6Inp
|
||||
Effect.map((value) => value.data),
|
||||
)
|
||||
|
||||
type Endpoint0_7Request = Parameters<RawClient["server.session"]["session.compact"]>[0]
|
||||
type Endpoint0_7Input = { readonly sessionID: Endpoint0_7Request["params"]["sessionID"] }
|
||||
const Endpoint0_7 = (raw: RawClient["server.session"]) => (input: Endpoint0_7Input) =>
|
||||
type Endpoint0_6Request = Parameters<RawClient["server.session"]["session.compact"]>[0]
|
||||
type Endpoint0_6Input = { readonly sessionID: Endpoint0_6Request["params"]["sessionID"] }
|
||||
const Endpoint0_6 = (raw: RawClient["server.session"]) => (input: Endpoint0_6Input) =>
|
||||
raw["session.compact"]({ params: { sessionID: input.sessionID } }).pipe(Effect.mapError(mapClientError))
|
||||
|
||||
type Endpoint0_8Request = Parameters<RawClient["server.session"]["session.wait"]>[0]
|
||||
type Endpoint0_8Input = { readonly sessionID: Endpoint0_8Request["params"]["sessionID"] }
|
||||
const Endpoint0_8 = (raw: RawClient["server.session"]) => (input: Endpoint0_8Input) =>
|
||||
type Endpoint0_7Request = Parameters<RawClient["server.session"]["session.wait"]>[0]
|
||||
type Endpoint0_7Input = { readonly sessionID: Endpoint0_7Request["params"]["sessionID"] }
|
||||
const Endpoint0_7 = (raw: RawClient["server.session"]) => (input: Endpoint0_7Input) =>
|
||||
raw["session.wait"]({ params: { sessionID: input.sessionID } }).pipe(Effect.mapError(mapClientError))
|
||||
|
||||
type Endpoint0_9Request = Parameters<RawClient["server.session"]["session.revert.stage"]>[0]
|
||||
type Endpoint0_9Input = {
|
||||
readonly sessionID: Endpoint0_9Request["params"]["sessionID"]
|
||||
readonly messageID: Endpoint0_9Request["payload"]["messageID"]
|
||||
readonly files?: Endpoint0_9Request["payload"]["files"]
|
||||
type Endpoint0_8Request = Parameters<RawClient["server.session"]["session.revert.stage"]>[0]
|
||||
type Endpoint0_8Input = {
|
||||
readonly sessionID: Endpoint0_8Request["params"]["sessionID"]
|
||||
readonly messageID: Endpoint0_8Request["payload"]["messageID"]
|
||||
readonly files?: Endpoint0_8Request["payload"]["files"]
|
||||
}
|
||||
const Endpoint0_9 = (raw: RawClient["server.session"]) => (input: Endpoint0_9Input) =>
|
||||
const Endpoint0_8 = (raw: RawClient["server.session"]) => (input: Endpoint0_8Input) =>
|
||||
raw["session.revert.stage"]({
|
||||
params: { sessionID: input.sessionID },
|
||||
payload: { messageID: input.messageID, files: input.files },
|
||||
@@ -131,30 +125,30 @@ const Endpoint0_9 = (raw: RawClient["server.session"]) => (input: Endpoint0_9Inp
|
||||
Effect.map((value) => value.data),
|
||||
)
|
||||
|
||||
type Endpoint0_10Request = Parameters<RawClient["server.session"]["session.revert.clear"]>[0]
|
||||
type Endpoint0_10Input = { readonly sessionID: Endpoint0_10Request["params"]["sessionID"] }
|
||||
const Endpoint0_10 = (raw: RawClient["server.session"]) => (input: Endpoint0_10Input) =>
|
||||
type Endpoint0_9Request = Parameters<RawClient["server.session"]["session.revert.clear"]>[0]
|
||||
type Endpoint0_9Input = { readonly sessionID: Endpoint0_9Request["params"]["sessionID"] }
|
||||
const Endpoint0_9 = (raw: RawClient["server.session"]) => (input: Endpoint0_9Input) =>
|
||||
raw["session.revert.clear"]({ params: { sessionID: input.sessionID } }).pipe(Effect.mapError(mapClientError))
|
||||
|
||||
type Endpoint0_11Request = Parameters<RawClient["server.session"]["session.revert.commit"]>[0]
|
||||
type Endpoint0_11Input = { readonly sessionID: Endpoint0_11Request["params"]["sessionID"] }
|
||||
const Endpoint0_11 = (raw: RawClient["server.session"]) => (input: Endpoint0_11Input) =>
|
||||
type Endpoint0_10Request = Parameters<RawClient["server.session"]["session.revert.commit"]>[0]
|
||||
type Endpoint0_10Input = { readonly sessionID: Endpoint0_10Request["params"]["sessionID"] }
|
||||
const Endpoint0_10 = (raw: RawClient["server.session"]) => (input: Endpoint0_10Input) =>
|
||||
raw["session.revert.commit"]({ params: { sessionID: input.sessionID } }).pipe(Effect.mapError(mapClientError))
|
||||
|
||||
type Endpoint0_12Request = Parameters<RawClient["server.session"]["session.context"]>[0]
|
||||
type Endpoint0_12Input = { readonly sessionID: Endpoint0_12Request["params"]["sessionID"] }
|
||||
const Endpoint0_12 = (raw: RawClient["server.session"]) => (input: Endpoint0_12Input) =>
|
||||
type Endpoint0_11Request = Parameters<RawClient["server.session"]["session.context"]>[0]
|
||||
type Endpoint0_11Input = { readonly sessionID: Endpoint0_11Request["params"]["sessionID"] }
|
||||
const Endpoint0_11 = (raw: RawClient["server.session"]) => (input: Endpoint0_11Input) =>
|
||||
raw["session.context"]({ params: { sessionID: input.sessionID } }).pipe(
|
||||
Effect.mapError(mapClientError),
|
||||
Effect.map((value) => value.data),
|
||||
)
|
||||
|
||||
type Endpoint0_13Request = Parameters<RawClient["server.session"]["session.events"]>[0]
|
||||
type Endpoint0_13Input = {
|
||||
readonly sessionID: Endpoint0_13Request["params"]["sessionID"]
|
||||
readonly after?: Endpoint0_13Request["query"]["after"]
|
||||
type Endpoint0_12Request = Parameters<RawClient["server.session"]["session.events"]>[0]
|
||||
type Endpoint0_12Input = {
|
||||
readonly sessionID: Endpoint0_12Request["params"]["sessionID"]
|
||||
readonly after?: Endpoint0_12Request["query"]["after"]
|
||||
}
|
||||
const Endpoint0_13 = (raw: RawClient["server.session"]) => (input: Endpoint0_13Input) =>
|
||||
const Endpoint0_12 = (raw: RawClient["server.session"]) => (input: Endpoint0_12Input) =>
|
||||
Stream.unwrap(
|
||||
raw["session.events"]({ params: { sessionID: input.sessionID }, query: { after: input.after } }).pipe(
|
||||
Effect.mapError(mapClientError),
|
||||
@@ -162,17 +156,17 @@ const Endpoint0_13 = (raw: RawClient["server.session"]) => (input: Endpoint0_13I
|
||||
),
|
||||
)
|
||||
|
||||
type Endpoint0_14Request = Parameters<RawClient["server.session"]["session.interrupt"]>[0]
|
||||
type Endpoint0_14Input = { readonly sessionID: Endpoint0_14Request["params"]["sessionID"] }
|
||||
const Endpoint0_14 = (raw: RawClient["server.session"]) => (input: Endpoint0_14Input) =>
|
||||
type Endpoint0_13Request = Parameters<RawClient["server.session"]["session.interrupt"]>[0]
|
||||
type Endpoint0_13Input = { readonly sessionID: Endpoint0_13Request["params"]["sessionID"] }
|
||||
const Endpoint0_13 = (raw: RawClient["server.session"]) => (input: Endpoint0_13Input) =>
|
||||
raw["session.interrupt"]({ params: { sessionID: input.sessionID } }).pipe(Effect.mapError(mapClientError))
|
||||
|
||||
type Endpoint0_15Request = Parameters<RawClient["server.session"]["session.message"]>[0]
|
||||
type Endpoint0_15Input = {
|
||||
readonly sessionID: Endpoint0_15Request["params"]["sessionID"]
|
||||
readonly messageID: Endpoint0_15Request["params"]["messageID"]
|
||||
type Endpoint0_14Request = Parameters<RawClient["server.session"]["session.message"]>[0]
|
||||
type Endpoint0_14Input = {
|
||||
readonly sessionID: Endpoint0_14Request["params"]["sessionID"]
|
||||
readonly messageID: Endpoint0_14Request["params"]["messageID"]
|
||||
}
|
||||
const Endpoint0_15 = (raw: RawClient["server.session"]) => (input: Endpoint0_15Input) =>
|
||||
const Endpoint0_14 = (raw: RawClient["server.session"]) => (input: Endpoint0_14Input) =>
|
||||
raw["session.message"]({ params: { sessionID: input.sessionID, messageID: input.messageID } }).pipe(
|
||||
Effect.mapError(mapClientError),
|
||||
Effect.map((value) => value.data),
|
||||
@@ -181,20 +175,19 @@ const Endpoint0_15 = (raw: RawClient["server.session"]) => (input: Endpoint0_15I
|
||||
const adaptGroup0 = (raw: RawClient["server.session"]) => ({
|
||||
list: Endpoint0_0(raw),
|
||||
create: Endpoint0_1(raw),
|
||||
active: Endpoint0_2(raw),
|
||||
get: Endpoint0_3(raw),
|
||||
switchAgent: Endpoint0_4(raw),
|
||||
switchModel: Endpoint0_5(raw),
|
||||
prompt: Endpoint0_6(raw),
|
||||
compact: Endpoint0_7(raw),
|
||||
wait: Endpoint0_8(raw),
|
||||
stage: Endpoint0_9(raw),
|
||||
clear: Endpoint0_10(raw),
|
||||
commit: Endpoint0_11(raw),
|
||||
context: Endpoint0_12(raw),
|
||||
events: Endpoint0_13(raw),
|
||||
interrupt: Endpoint0_14(raw),
|
||||
message: Endpoint0_15(raw),
|
||||
get: Endpoint0_2(raw),
|
||||
switchAgent: Endpoint0_3(raw),
|
||||
switchModel: Endpoint0_4(raw),
|
||||
prompt: Endpoint0_5(raw),
|
||||
compact: Endpoint0_6(raw),
|
||||
wait: Endpoint0_7(raw),
|
||||
stage: Endpoint0_8(raw),
|
||||
clear: Endpoint0_9(raw),
|
||||
commit: Endpoint0_10(raw),
|
||||
context: Endpoint0_11(raw),
|
||||
events: Endpoint0_12(raw),
|
||||
interrupt: Endpoint0_13(raw),
|
||||
message: Endpoint0_14(raw),
|
||||
})
|
||||
|
||||
const adaptClient = (raw: RawClient) => ({ sessions: adaptGroup0(raw["server.session"]) })
|
||||
|
||||
@@ -3,7 +3,6 @@ import type {
|
||||
SessionsListOutput,
|
||||
SessionsCreateInput,
|
||||
SessionsCreateOutput,
|
||||
SessionsActiveOutput,
|
||||
SessionsGetInput,
|
||||
SessionsGetOutput,
|
||||
SessionsSwitchAgentInput,
|
||||
@@ -199,17 +198,6 @@ export function make(options: ClientOptions) {
|
||||
},
|
||||
requestOptions,
|
||||
).then((value) => value.data),
|
||||
active: (requestOptions?: RequestOptions) =>
|
||||
request<{ readonly data: SessionsActiveOutput }>(
|
||||
{
|
||||
method: "GET",
|
||||
path: `/api/session/active`,
|
||||
successStatus: 200,
|
||||
declaredStatuses: [401, 400],
|
||||
empty: false,
|
||||
},
|
||||
requestOptions,
|
||||
).then((value) => value.data),
|
||||
get: (input: SessionsGetInput, requestOptions?: RequestOptions) =>
|
||||
request<{ readonly data: SessionsGetOutput }>(
|
||||
{
|
||||
|
||||
@@ -243,8 +243,6 @@ export type SessionsCreateOutput = {
|
||||
}
|
||||
}["data"]
|
||||
|
||||
export type SessionsActiveOutput = { readonly data: { readonly [x: string]: { readonly type: "running" } } }["data"]
|
||||
|
||||
export type SessionsGetInput = { readonly sessionID: { readonly sessionID: string }["sessionID"] }
|
||||
|
||||
export type SessionsGetOutput = {
|
||||
|
||||
@@ -37,11 +37,6 @@ test("session methods retain decoded Effect inputs and outputs", async () => {
|
||||
if (url.includes("/message/")) {
|
||||
return Effect.succeed(HttpClientResponse.fromWeb(request, Response.json({ data: modelSwitchedMessage })))
|
||||
}
|
||||
if (url.endsWith("/api/session/active")) {
|
||||
return Effect.succeed(
|
||||
HttpClientResponse.fromWeb(request, Response.json({ data: { ses_test: { type: "running" } } })),
|
||||
)
|
||||
}
|
||||
if (request.method === "POST" && url.endsWith("/api/session")) {
|
||||
return Effect.succeed(HttpClientResponse.fromWeb(request, Response.json(session)))
|
||||
}
|
||||
@@ -55,7 +50,6 @@ test("session methods retain decoded Effect inputs and outputs", async () => {
|
||||
const result = await Effect.gen(function* () {
|
||||
const client = yield* OpenCode.make({ baseUrl: "http://localhost:3000" })
|
||||
const page = yield* client.sessions.list({ limit: 10 })
|
||||
const active = yield* client.sessions.active()
|
||||
const created = yield* client.sessions.create({
|
||||
location: Location.Ref.make({ directory: AbsolutePath.make("/tmp/project") }),
|
||||
})
|
||||
@@ -80,11 +74,10 @@ test("session methods retain decoded Effect inputs and outputs", async () => {
|
||||
sessionID: Session.ID.make("ses_test"),
|
||||
messageID: SessionMessage.ID.make("msg_model"),
|
||||
})
|
||||
return { page, active, created, admitted, context, events, message }
|
||||
return { page, created, admitted, context, events, message }
|
||||
}).pipe(Effect.provideService(HttpClient.HttpClient, httpClient), Effect.runPromise)
|
||||
|
||||
expect(DateTime.toEpochMillis(result.page.data[0].time.created)).toBe(1_717_171_717_000)
|
||||
expect(result.active).toEqual({ ses_test: { type: "running" } })
|
||||
expect(Object.getPrototypeOf(result.page.data[0])).toBe(Object.prototype)
|
||||
expect(Object.getPrototypeOf(result.created)).toBe(Object.prototype)
|
||||
expect(result.created.id).toBe("ses_test")
|
||||
|
||||
@@ -32,7 +32,6 @@ test("session methods use the public HTTP contract", async () => {
|
||||
if (url.includes("/prompt")) return Response.json(admission)
|
||||
if (url.includes("/context")) return Response.json({ data: [] })
|
||||
if (url.includes("/message/")) return Response.json({ data: modelSwitchedMessage })
|
||||
if (url.endsWith("/api/session/active")) return Response.json({ data: { ses_test: { type: "running" } } })
|
||||
if (init?.method === "POST" && url.endsWith("/api/session")) return Response.json(session)
|
||||
if (init?.method === "POST") return new Response(null, { status: 204 })
|
||||
return Response.json({ data: [session.data], cursor: { next: "next" } })
|
||||
@@ -40,7 +39,6 @@ test("session methods use the public HTTP contract", async () => {
|
||||
})
|
||||
|
||||
const page = await client.sessions.list({ limit: "10", order: "desc" })
|
||||
const active = await client.sessions.active()
|
||||
const created = await client.sessions.create({ location: { directory: "/tmp/project" } })
|
||||
await client.sessions.switchAgent({ sessionID: "ses_test", agent: "build" })
|
||||
await client.sessions.switchModel({
|
||||
@@ -61,7 +59,6 @@ test("session methods use the public HTTP contract", async () => {
|
||||
const message = await client.sessions.message({ sessionID: "ses_test", messageID: "msg_model" })
|
||||
|
||||
expect(page.cursor.next).toBe("next")
|
||||
expect(active).toEqual({ ses_test: { type: "running" } })
|
||||
expect(created.id).toBe("ses_test")
|
||||
expect(admitted.id).toBe("msg_test")
|
||||
expect(context).toEqual([])
|
||||
@@ -69,7 +66,6 @@ test("session methods use the public HTTP contract", async () => {
|
||||
expect(message).toEqual(modelSwitchedMessage)
|
||||
expect(requests.map((request) => [request.init?.method, request.url])).toEqual([
|
||||
["GET", "http://localhost:3000/api/session?limit=10&order=desc"],
|
||||
["GET", "http://localhost:3000/api/session/active"],
|
||||
["POST", "http://localhost:3000/api/session"],
|
||||
["POST", "http://localhost:3000/api/session/ses_test/agent"],
|
||||
["POST", "http://localhost:3000/api/session/ses_test/model"],
|
||||
@@ -81,7 +77,7 @@ test("session methods use the public HTTP contract", async () => {
|
||||
["POST", "http://localhost:3000/api/session/ses_test/interrupt"],
|
||||
["GET", "http://localhost:3000/api/session/ses_test/message/msg_model"],
|
||||
])
|
||||
const body = requests.find((request) => request.url.endsWith("/api/session/ses_test/prompt"))?.init?.body
|
||||
const body = requests[4]?.init?.body
|
||||
if (typeof body !== "string") throw new Error("Expected JSON request body")
|
||||
expect(JSON.parse(body)).toEqual({
|
||||
prompt: { text: "Hello" },
|
||||
|
||||
@@ -1,15 +1,10 @@
|
||||
import type { APIEvent } from "@solidjs/start/server"
|
||||
import { Resource } from "@opencode-ai/console-resource"
|
||||
import { LOCALE_HEADER, cookie, localeFromRequest, route, tag } from "~/lib/language"
|
||||
|
||||
const dataPath = "/data"
|
||||
|
||||
export async function statsProxy(evt: APIEvent) {
|
||||
const req = evt.request.clone()
|
||||
const locale = localeFromRequest(req)
|
||||
const redirect = redirectToLocalizedData(req, new URL(req.url), locale)
|
||||
if (redirect) return redirect
|
||||
|
||||
const targetUrl = new URL(req.url)
|
||||
targetUrl.protocol = "https:"
|
||||
targetUrl.hostname = Resource.App.stage === "production" ? "stats.opencode.ai" : "stats.dev.opencode.ai"
|
||||
@@ -23,13 +18,9 @@ export async function statsProxy(evt: APIEvent) {
|
||||
targetUrl.pathname = targetUrl.pathname.slice(dataPath.length)
|
||||
}
|
||||
|
||||
const requestHeaders = new Headers(req.headers)
|
||||
requestHeaders.set(LOCALE_HEADER, locale)
|
||||
requestHeaders.set("accept-language", tag(locale))
|
||||
|
||||
const response = await fetch(targetUrl, {
|
||||
method: req.method,
|
||||
headers: requestHeaders,
|
||||
headers: req.headers,
|
||||
body: req.body,
|
||||
})
|
||||
|
||||
@@ -39,7 +30,6 @@ export async function statsProxy(evt: APIEvent) {
|
||||
headers.delete("content-encoding")
|
||||
headers.delete("content-length")
|
||||
headers.delete("etag")
|
||||
appendVary(headers, "Accept-Language", "Cookie")
|
||||
|
||||
return new Response(rewriteStatsHtml(await response.text()), {
|
||||
status: response.status,
|
||||
@@ -62,60 +52,3 @@ export function statsRedirect(evt: APIEvent) {
|
||||
function rewriteStatsHtml(html: string) {
|
||||
return html.replaceAll('"/_build/', `"${dataPath}/_build/`).replaceAll("'/_build/", `'${dataPath}/_build/`)
|
||||
}
|
||||
|
||||
function redirectToLocalizedData(request: Request, url: URL, locale: ReturnType<typeof localeFromRequest>) {
|
||||
if (locale === "en") return null
|
||||
if (request.headers.get(LOCALE_HEADER)) return null
|
||||
if (request.method !== "GET" && request.method !== "HEAD") return null
|
||||
if (!acceptsHtml(request)) return null
|
||||
if (!url.pathname.startsWith(`${dataPath}/`) && url.pathname !== dataPath) return null
|
||||
if (isDataBypassPath(url.pathname)) return null
|
||||
|
||||
const next = new URL(url)
|
||||
next.pathname = route(locale, url.pathname)
|
||||
|
||||
const headers = new Headers({
|
||||
Location: next.toString(),
|
||||
})
|
||||
headers.append("set-cookie", cookie(locale))
|
||||
appendVary(headers, "Accept-Language", "Cookie")
|
||||
|
||||
return new Response(null, {
|
||||
status: 308,
|
||||
headers,
|
||||
})
|
||||
}
|
||||
|
||||
function acceptsHtml(request: Request) {
|
||||
const accept = request.headers.get("accept")
|
||||
return !accept || accept.includes("text/html") || accept.includes("*/*")
|
||||
}
|
||||
|
||||
function isDataBypassPath(pathname: string) {
|
||||
return (
|
||||
pathname.startsWith(`${dataPath}/_build/`) ||
|
||||
pathname.startsWith(`${dataPath}/api/`) ||
|
||||
pathname.startsWith(`${dataPath}/_server`) ||
|
||||
pathname === `${dataPath}/banner.jpg` ||
|
||||
pathname === `${dataPath}/banner.png`
|
||||
)
|
||||
}
|
||||
|
||||
function appendVary(headers: Headers, ...values: string[]) {
|
||||
const existing = headers
|
||||
.get("vary")
|
||||
?.split(",")
|
||||
.map((value) => value.trim())
|
||||
.filter(Boolean)
|
||||
|
||||
headers.set(
|
||||
"vary",
|
||||
values
|
||||
.reduce(
|
||||
(result, value) =>
|
||||
result.some((item) => item.toLowerCase() === value.toLowerCase()) ? result : [...result, value],
|
||||
existing ?? [],
|
||||
)
|
||||
.join(", "),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -6,9 +6,9 @@ import { Git } from "../git"
|
||||
import { Location } from "../location"
|
||||
import { ProjectV2 } from "../project"
|
||||
import { SessionV2 } from "../session"
|
||||
import { SessionExecution } from "../session/execution"
|
||||
import { SessionEvent } from "../session/event"
|
||||
import { SessionSchema } from "../session/schema"
|
||||
import { SessionStore } from "../session/store"
|
||||
import { AbsolutePath, RelativePath } from "../schema"
|
||||
import path from "path"
|
||||
|
||||
@@ -71,11 +71,10 @@ export const layer = Layer.effect(
|
||||
const git = yield* Git.Service
|
||||
const events = yield* EventV2.Service
|
||||
const project = yield* ProjectV2.Service
|
||||
const sessions = yield* SessionStore.Service
|
||||
const session = yield* SessionV2.Service
|
||||
|
||||
const moveSession = Effect.fn("MoveSession.moveSession")(function* (input: Input) {
|
||||
const current = yield* sessions.get(input.sessionID)
|
||||
if (!current) return yield* new SessionV2.NotFoundError({ sessionID: input.sessionID })
|
||||
const current = yield* session.get(input.sessionID)
|
||||
const directory = AbsolutePath.make(input.destination.directory)
|
||||
if (current.location.directory === directory) return
|
||||
|
||||
@@ -144,5 +143,6 @@ export const defaultLayer = layer.pipe(
|
||||
Layer.provide(Git.defaultLayer),
|
||||
Layer.provide(EventV2.defaultLayer),
|
||||
Layer.provide(ProjectV2.defaultLayer),
|
||||
Layer.provide(SessionStore.defaultLayer),
|
||||
Layer.provide(SessionExecution.noopLayer),
|
||||
Layer.provide(SessionV2.defaultLayer),
|
||||
)
|
||||
|
||||
@@ -48,7 +48,6 @@ export const Flag = {
|
||||
|
||||
OPENCODE_WORKSPACE_ID: process.env["OPENCODE_WORKSPACE_ID"],
|
||||
OPENCODE_EXPERIMENTAL_WORKSPACES: enabledByExperimental("OPENCODE_EXPERIMENTAL_WORKSPACES"),
|
||||
OPENCODE_EXPERIMENTAL_TAB_SCROLL: enabledByExperimental("OPENCODE_EXPERIMENTAL_TAB_SCROLL"),
|
||||
|
||||
// Evaluated at access time (not module load) because tests, the CLI, and
|
||||
// external tooling set these env vars at runtime.
|
||||
|
||||
@@ -1,276 +0,0 @@
|
||||
// Branded HTML pages for local OAuth callback servers.
|
||||
//
|
||||
// These are served by the loopback HTTP servers that finish an OAuth exchange
|
||||
// (MCP, Codex/ChatGPT, xAI, Snowflake, DigitalOcean, ...). The functions return
|
||||
// a fully self-contained HTML string with no external assets, so they work
|
||||
// offline and drop into any transport (`res.end(...)`, Effect `response.end`,
|
||||
// etc.).
|
||||
//
|
||||
// The visual language mirrors the OpenCode app: the design tokens are a curated
|
||||
// subset of the OC-2 semantic tokens in `packages/ui/src/styles/theme.css`, and
|
||||
// the wordmark is the same geometry as `packages/ui/src/components/logo.tsx`.
|
||||
// Keep this file in sync with those sources when the brand changes.
|
||||
|
||||
export interface CallbackPageOptions {
|
||||
/** Friendly integration name shown as a subtitle, e.g. "xAI", "Snowflake", "MCP". */
|
||||
provider?: string
|
||||
/** Attempt to close the window shortly after success. Defaults to true. */
|
||||
autoClose?: boolean
|
||||
}
|
||||
|
||||
export function success(options?: CallbackPageOptions) {
|
||||
const provider = options?.provider
|
||||
return renderDocument({
|
||||
title: "Authorization successful",
|
||||
body: renderCard({
|
||||
status: "success",
|
||||
headline: "Authorization successful",
|
||||
message: provider ? `OpenCode is now connected to ${escapeHtml(provider)}.` : "OpenCode is now authorized.",
|
||||
footnote: "You can close this window.",
|
||||
}),
|
||||
script: options?.autoClose === false ? undefined : AUTO_CLOSE_SCRIPT,
|
||||
})
|
||||
}
|
||||
|
||||
export function error(detail: string, options?: CallbackPageOptions) {
|
||||
const provider = options?.provider
|
||||
return renderDocument({
|
||||
title: "Authorization failed",
|
||||
body: renderCard({
|
||||
status: "error",
|
||||
headline: "Authorization failed",
|
||||
message: provider
|
||||
? `OpenCode couldn't finish connecting to ${escapeHtml(provider)}.`
|
||||
: "OpenCode couldn't complete authorization.",
|
||||
detail,
|
||||
footnote: "Close this window and try again from OpenCode.",
|
||||
}),
|
||||
})
|
||||
}
|
||||
|
||||
export interface BootstrapOptions {
|
||||
/** Same-origin path the in-browser script POSTs the parsed callback to. */
|
||||
tokenPath: string
|
||||
provider?: string
|
||||
}
|
||||
|
||||
// For flows where the credential arrives in the URL fragment (implicit grant),
|
||||
// the browser must relay it back to the loopback server. This renders a pending
|
||||
// page whose script reads the fragment, POSTs it to `tokenPath`, then resolves
|
||||
// to the success or error state in place.
|
||||
export function bootstrap(options: BootstrapOptions) {
|
||||
return renderDocument({
|
||||
title: "Finishing sign-in",
|
||||
body: renderCard({
|
||||
status: "pending",
|
||||
headline: "Finishing sign-in",
|
||||
message: options.provider
|
||||
? `Completing your ${escapeHtml(options.provider)} authorization.`
|
||||
: "Completing authorization.",
|
||||
footnote: "You can close this window once sign-in finishes.",
|
||||
}),
|
||||
script: bootstrapScript(options),
|
||||
})
|
||||
}
|
||||
|
||||
export * as OauthCallbackPage from "./page"
|
||||
|
||||
type Status = "pending" | "success" | "error"
|
||||
|
||||
function renderCard(input: { status: Status; headline: string; message: string; detail?: string; footnote: string }) {
|
||||
const detail = input.detail?.trim()
|
||||
return `<main class="card" id="oc-card" data-status="${input.status}" role="status" aria-live="polite">
|
||||
<div class="brand">${WORDMARK}</div>
|
||||
<div class="status" aria-hidden="true">
|
||||
<span class="icon icon-pending">${ICON_SPINNER}</span>
|
||||
<span class="icon icon-success">${ICON_CHECK}</span>
|
||||
<span class="icon icon-error">${ICON_CROSS}</span>
|
||||
</div>
|
||||
<h1 class="headline" id="oc-headline">${escapeHtml(input.headline)}</h1>
|
||||
<p class="message" id="oc-message">${input.message}</p>
|
||||
<pre class="detail" id="oc-detail"${detail ? "" : " hidden"}>${detail ? escapeHtml(detail) : ""}</pre>
|
||||
<p class="footnote" id="oc-footnote">${escapeHtml(input.footnote)}</p>
|
||||
</main>`
|
||||
}
|
||||
|
||||
function renderDocument(input: { title: string; body: string; script?: string }) {
|
||||
return `<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<meta name="robots" content="noindex" />
|
||||
<title>${escapeHtml(input.title)} · OpenCode</title>
|
||||
<style>${STYLES}</style>
|
||||
</head>
|
||||
<body>
|
||||
${input.body}${input.script ? `\n <script>${input.script}</script>` : ""}
|
||||
</body>
|
||||
</html>`
|
||||
}
|
||||
|
||||
const AUTO_CLOSE_SCRIPT = `setTimeout(function(){try{window.close()}catch(e){}},2500)`
|
||||
|
||||
function bootstrapScript(options: BootstrapOptions) {
|
||||
return `var PROVIDER=${scriptString(options.provider ?? "")};
|
||||
var TOKEN_URL=new URL(${scriptString(options.tokenPath)},window.location.origin).href;
|
||||
(function(){
|
||||
var card=document.getElementById("oc-card"),headline=document.getElementById("oc-headline"),message=document.getElementById("oc-message"),detail=document.getElementById("oc-detail"),footnote=document.getElementById("oc-footnote");
|
||||
function fail(text){card.dataset.status="error";headline.textContent="Authorization failed";message.textContent=PROVIDER?("OpenCode couldn't finish connecting to "+PROVIDER+"."):"OpenCode couldn't complete authorization.";if(text){detail.textContent=text;detail.hidden=false}footnote.textContent="Close this window and try again from OpenCode."}
|
||||
function ok(){card.dataset.status="success";headline.textContent="Authorization successful";message.textContent=PROVIDER?("OpenCode is now connected to "+PROVIDER+"."):"OpenCode is now authorized.";detail.hidden=true;footnote.textContent="You can close this window.";setTimeout(function(){try{window.close()}catch(e){}},2500)}
|
||||
try{
|
||||
var hash=new URLSearchParams((window.location.hash||"").slice(1));
|
||||
var search=new URLSearchParams(window.location.search||"");
|
||||
var err=hash.get("error")||search.get("error");
|
||||
var errDescription=hash.get("error_description")||search.get("error_description");
|
||||
var body=err?{error:err,error_description:errDescription||""}:{access_token:hash.get("access_token")||"",expires_in:hash.get("expires_in")||"0",state:hash.get("state")||""};
|
||||
fetch(TOKEN_URL,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(body)}).then(function(res){
|
||||
if(!res.ok)return res.text().catch(function(){return""}).then(function(t){throw new Error(t||("callback failed ("+res.status+")"))});
|
||||
if(err){fail(errDescription||err);return}
|
||||
ok();
|
||||
}).catch(function(e){fail(String(e&&e.message?e.message:e))});
|
||||
}catch(e){fail(String(e&&e.message?e.message:e))}
|
||||
})()`
|
||||
}
|
||||
|
||||
function scriptString(value: string) {
|
||||
return JSON.stringify(value).replaceAll("<", "\\u003c")
|
||||
}
|
||||
|
||||
function escapeHtml(value: string) {
|
||||
return value
|
||||
.replaceAll("&", "&")
|
||||
.replaceAll("<", "<")
|
||||
.replaceAll(">", ">")
|
||||
.replaceAll('"', """)
|
||||
.replaceAll("'", "'")
|
||||
}
|
||||
|
||||
// Curated subset of OC-2 tokens (packages/ui/src/styles/theme.css). Default is
|
||||
// light; dark applies via prefers-color-scheme. The [data-theme] selectors let a
|
||||
// host force a scheme without changing the default.
|
||||
const LIGHT_VARS = `
|
||||
--oc-bg: #f8f8f8;
|
||||
--oc-card: #fcfcfc;
|
||||
--oc-text-strong: #171717;
|
||||
--oc-text-base: #6f6f6f;
|
||||
--oc-text-weak: #8f8f8f;
|
||||
--oc-border-weak: #e5e5e5;
|
||||
--oc-icon-strong: #171717;
|
||||
--oc-icon-base: #8f8f8f;
|
||||
--oc-icon-weak: #dbdbdb;
|
||||
--oc-success: #2dba26;
|
||||
--oc-error: #ed4831;
|
||||
--oc-detail-bg: #fff8f6;
|
||||
--oc-detail-border: #fdc3b7;
|
||||
--oc-shadow: 0 16px 48px -6px rgba(0,0,0,.10), 0 6px 12px -2px rgba(0,0,0,.05), 0 1px 2px rgba(0,0,0,.06);`
|
||||
|
||||
const DARK_VARS = `
|
||||
--oc-bg: #101010;
|
||||
--oc-card: #161616;
|
||||
--oc-text-strong: rgba(255,255,255,.936);
|
||||
--oc-text-base: rgba(255,255,255,.618);
|
||||
--oc-text-weak: rgba(255,255,255,.422);
|
||||
--oc-border-weak: #282828;
|
||||
--oc-icon-strong: #ededed;
|
||||
--oc-icon-base: #7e7e7e;
|
||||
--oc-icon-weak: #343434;
|
||||
--oc-success: #12c905;
|
||||
--oc-error: #fc533a;
|
||||
--oc-detail-bg: #28110c;
|
||||
--oc-detail-border: #6a1206;
|
||||
--oc-shadow: 0 16px 48px -6px rgba(0,0,0,.55), 0 6px 12px -2px rgba(0,0,0,.35), 0 1px 2px rgba(0,0,0,.4);`
|
||||
|
||||
const STYLES = `
|
||||
:root { color-scheme: light dark;${LIGHT_VARS}
|
||||
--oc-font-sans: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
--oc-font-mono: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
|
||||
}
|
||||
@media (prefers-color-scheme: dark) { :root:not([data-theme="light"]) {${DARK_VARS} } }
|
||||
:root[data-theme="dark"] {${DARK_VARS} }
|
||||
:root[data-theme="light"] {${LIGHT_VARS} }
|
||||
|
||||
* { box-sizing: border-box; }
|
||||
html, body { margin: 0; height: 100%; }
|
||||
body {
|
||||
min-height: 100vh;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
padding: 24px;
|
||||
background: var(--oc-bg);
|
||||
color: var(--oc-text-base);
|
||||
font-family: var(--oc-font-sans);
|
||||
line-height: 1.5;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
text-rendering: optimizeLegibility;
|
||||
}
|
||||
.card {
|
||||
width: min(100%, 28rem);
|
||||
padding: 2.25rem 2rem 1.75rem;
|
||||
background: var(--oc-card);
|
||||
border: 1px solid var(--oc-border-weak);
|
||||
border-radius: 14px;
|
||||
box-shadow: var(--oc-shadow);
|
||||
text-align: center;
|
||||
}
|
||||
.brand { display: flex; justify-content: center; margin-bottom: 1.75rem; }
|
||||
.brand svg { height: 19px; width: auto; }
|
||||
.status { display: flex; justify-content: center; margin-bottom: 1.125rem; }
|
||||
.icon { display: none; line-height: 0; }
|
||||
.icon svg { display: block; }
|
||||
.card[data-status="pending"] .icon-pending,
|
||||
.card[data-status="success"] .icon-success,
|
||||
.card[data-status="error"] .icon-error { display: block; }
|
||||
.icon-success { color: var(--oc-success); }
|
||||
.icon-error { color: var(--oc-error); }
|
||||
.icon-pending { color: var(--oc-text-weak); }
|
||||
.headline { margin: 0; font-size: 1.1875rem; font-weight: 500; line-height: 1.3; letter-spacing: -0.012em; color: var(--oc-text-strong); }
|
||||
.message { margin: 0.5rem 0 0; font-size: 0.9375rem; color: var(--oc-text-base); }
|
||||
.detail {
|
||||
margin: 1.25rem 0 0;
|
||||
padding: 0.75rem 0.875rem;
|
||||
text-align: left;
|
||||
font-family: var(--oc-font-mono);
|
||||
font-size: 0.8125rem;
|
||||
line-height: 1.55;
|
||||
color: var(--oc-text-strong);
|
||||
background: var(--oc-detail-bg);
|
||||
border: 1px solid var(--oc-detail-border);
|
||||
border-radius: 8px;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
max-height: 9.5rem;
|
||||
overflow: auto;
|
||||
}
|
||||
.detail[hidden] { display: none; }
|
||||
.footnote { margin: 1.5rem 0 0; font-size: 0.8125rem; color: var(--oc-text-weak); }
|
||||
.spinner { animation: oc-spin 0.8s linear infinite; transform-origin: center; }
|
||||
@keyframes oc-spin { to { transform: rotate(360deg); } }
|
||||
@media (prefers-reduced-motion: reduce) { .spinner { animation: none; } }
|
||||
`
|
||||
|
||||
// OpenCode wordmark — same path geometry as packages/ui/src/components/logo.tsx (Logo).
|
||||
const WORDMARK = `<svg class="wordmark" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 234 42" fill="none" aria-label="OpenCode" role="img">
|
||||
<path d="M18 30H6V18H18V30Z" fill="var(--oc-icon-weak)" />
|
||||
<path d="M18 12H6V30H18V12ZM24 36H0V6H24V36Z" fill="var(--oc-icon-base)" />
|
||||
<path d="M48 30H36V18H48V30Z" fill="var(--oc-icon-weak)" />
|
||||
<path d="M36 30H48V12H36V30ZM54 36H36V42H30V6H54V36Z" fill="var(--oc-icon-base)" />
|
||||
<path d="M84 24V30H66V24H84Z" fill="var(--oc-icon-weak)" />
|
||||
<path d="M84 24H66V30H84V36H60V6H84V24ZM66 18H78V12H66V18Z" fill="var(--oc-icon-base)" />
|
||||
<path d="M108 36H96V18H108V36Z" fill="var(--oc-icon-weak)" />
|
||||
<path d="M108 12H96V36H90V6H108V12ZM114 36H108V12H114V36Z" fill="var(--oc-icon-base)" />
|
||||
<path d="M144 30H126V18H144V30Z" fill="var(--oc-icon-weak)" />
|
||||
<path d="M144 12H126V30H144V36H120V6H144V12Z" fill="var(--oc-icon-strong)" />
|
||||
<path d="M168 30H156V18H168V30Z" fill="var(--oc-icon-weak)" />
|
||||
<path d="M168 12H156V30H168V12ZM174 36H150V6H174V36Z" fill="var(--oc-icon-strong)" />
|
||||
<path d="M198 30H186V18H198V30Z" fill="var(--oc-icon-weak)" />
|
||||
<path d="M198 12H186V30H198V12ZM204 36H180V6H198V0H204V36Z" fill="var(--oc-icon-strong)" />
|
||||
<path d="M234 24V30H216V24H234Z" fill="var(--oc-icon-weak)" />
|
||||
<path d="M216 12V18H228V12H216ZM234 24H216V30H234V36H210V6H234V24Z" fill="var(--oc-icon-strong)" />
|
||||
</svg>`
|
||||
|
||||
const ICON_CHECK = `<svg viewBox="0 0 24 24" width="30" height="30" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="9" /><path d="m8.5 12.5 2.4 2.4 4.6-5.4" /></svg>`
|
||||
|
||||
const ICON_CROSS = `<svg viewBox="0 0 24 24" width="30" height="30" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="9" /><path d="m9 9 6 6m0-6-6 6" /></svg>`
|
||||
|
||||
const ICON_SPINNER = `<svg class="spinner" viewBox="0 0 24 24" width="30" height="30" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><circle cx="12" cy="12" r="9" opacity="0.2" /><path d="M21 12a9 9 0 0 0-9-9" /></svg>`
|
||||
@@ -7,7 +7,6 @@ import { Credential } from "../../credential"
|
||||
import { InstallationVersion } from "../../installation/version"
|
||||
import { Integration } from "../../integration"
|
||||
import { ModelV2 } from "../../model"
|
||||
import { OauthCallbackPage } from "../../oauth/page"
|
||||
import { ProviderV2 } from "../../provider"
|
||||
import type { PluginInternal } from "../internal"
|
||||
|
||||
@@ -59,21 +58,17 @@ const browser = {
|
||||
const value = url.searchParams.get("code")
|
||||
if (error) {
|
||||
Effect.runFork(Deferred.fail(code, new Error(error)))
|
||||
response
|
||||
.writeHead(400, { "Content-Type": "text/html" })
|
||||
.end(OauthCallbackPage.error(error, { provider: "ChatGPT" }))
|
||||
response.writeHead(400, { "Content-Type": "text/html" }).end(errorPage(error))
|
||||
return
|
||||
}
|
||||
if (!value || url.searchParams.get("state") !== state) {
|
||||
const message = value ? "Invalid OAuth state" : "Missing authorization code"
|
||||
Effect.runFork(Deferred.fail(code, new Error(message)))
|
||||
response
|
||||
.writeHead(400, { "Content-Type": "text/html" })
|
||||
.end(OauthCallbackPage.error(message, { provider: "ChatGPT" }))
|
||||
response.writeHead(400, { "Content-Type": "text/html" }).end(errorPage(message))
|
||||
return
|
||||
}
|
||||
Effect.runFork(Deferred.succeed(code, value))
|
||||
response.writeHead(200, { "Content-Type": "text/html" }).end(OauthCallbackPage.success({ provider: "ChatGPT" }))
|
||||
response.writeHead(200, { "Content-Type": "text/html" }).end(successPage)
|
||||
})
|
||||
yield* Effect.callback<void, Error>((resume) => {
|
||||
server.once("error", (error) => resume(Effect.fail(error)))
|
||||
@@ -290,3 +285,8 @@ function claim(token: string) {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
const successPage =
|
||||
"<!doctype html><title>OpenCode</title><h1>Authorization successful</h1><p>You can close this window.</p>"
|
||||
const errorPage = (message: string) =>
|
||||
`<!doctype html><title>OpenCode</title><h1>Authorization failed</h1><p>${message.replace(/[&<>"']/g, "")}</p>`
|
||||
|
||||
@@ -155,7 +155,6 @@ export interface Interface {
|
||||
}) => Effect.Effect<void, OperationUnavailableError>
|
||||
readonly compact: (input: CompactInput) => Effect.Effect<void, NotFoundError | OperationUnavailableError>
|
||||
readonly wait: (id: SessionSchema.ID) => Effect.Effect<void, NotFoundError | OperationUnavailableError>
|
||||
readonly active: Effect.Effect<ReadonlySet<SessionSchema.ID>>
|
||||
readonly resume: (sessionID: SessionSchema.ID) => Effect.Effect<void, NotFoundError | SessionRunner.RunError>
|
||||
readonly interrupt: (sessionID: SessionSchema.ID) => Effect.Effect<void>
|
||||
readonly revert: {
|
||||
@@ -403,7 +402,6 @@ export const layer = Layer.unwrap(
|
||||
yield* result.get(sessionID)
|
||||
return yield* new OperationUnavailableError({ operation: "wait" })
|
||||
}),
|
||||
active: execution.active,
|
||||
resume: Effect.fn("V2Session.resume")(function* (sessionID) {
|
||||
yield* result.get(sessionID)
|
||||
yield* execution.resume(sessionID)
|
||||
@@ -445,6 +443,7 @@ export const defaultLayer = layer.pipe(
|
||||
Layer.provide(
|
||||
Layer.unwrap(Effect.promise(() => import("./location-layer")).pipe(Effect.map((m) => m.LocationServiceMap.layer))),
|
||||
),
|
||||
Layer.provide(SessionExecution.noopLayer),
|
||||
Layer.provide(SessionStore.defaultLayer),
|
||||
Layer.provide(SessionProjector.defaultLayer),
|
||||
Layer.provide(EventV2.defaultLayer),
|
||||
|
||||
@@ -5,8 +5,6 @@ import { SessionRunner } from "./runner/index"
|
||||
import { SessionSchema } from "./schema"
|
||||
|
||||
export interface Interface {
|
||||
/** Snapshots active execution owned by this process. */
|
||||
readonly active: Effect.Effect<ReadonlySet<SessionSchema.ID>>
|
||||
/** Starts execution while idle or joins the active execution. */
|
||||
readonly resume: (sessionID: SessionSchema.ID) => Effect.Effect<void, SessionRunner.RunError>
|
||||
/** Registers newly recorded work. Repeated wakeups may coalesce. */
|
||||
@@ -21,10 +19,5 @@ export class Service extends Context.Service<Service, Interface>()("@opencode/v2
|
||||
/** Low-level compatibility layer for callers that only need durable Session recording. */
|
||||
export const noopLayer = Layer.succeed(
|
||||
Service,
|
||||
Service.of({
|
||||
active: Effect.succeed(new Set()),
|
||||
resume: () => Effect.void,
|
||||
wake: () => Effect.void,
|
||||
interrupt: () => Effect.void,
|
||||
}),
|
||||
Service.of({ resume: () => Effect.void, wake: () => Effect.void, interrupt: () => Effect.void }),
|
||||
)
|
||||
|
||||
@@ -28,7 +28,6 @@ export const layer = Layer.effect(
|
||||
})
|
||||
|
||||
return SessionExecution.Service.of({
|
||||
active: coordinator.active,
|
||||
interrupt: coordinator.interrupt,
|
||||
resume: coordinator.run,
|
||||
wake: coordinator.wake,
|
||||
|
||||
@@ -4,8 +4,6 @@ import { Deferred, Effect, Exit, Fiber, FiberSet, Scope } from "effect"
|
||||
|
||||
/** Serializes execution for each key while allowing different keys to run concurrently. */
|
||||
export interface Coordinator<Key, E> {
|
||||
/** Snapshots keys with an execution owned by this coordinator. */
|
||||
readonly active: Effect.Effect<ReadonlySet<Key>>
|
||||
/** Starts execution while idle or joins the active execution. */
|
||||
readonly run: (key: Key) => Effect.Effect<void, E>
|
||||
/** Registers one coalesced follow-up after newly recorded work. */
|
||||
@@ -100,5 +98,5 @@ export const make = <Key, E>(options: {
|
||||
return Fiber.interrupt(entry.owner)
|
||||
})
|
||||
|
||||
return { active: Effect.sync(() => new Set(active.keys())), run, wake, interrupt }
|
||||
return { run, wake, interrupt }
|
||||
})
|
||||
|
||||
@@ -52,7 +52,6 @@ function isSafeRelativePath(value: string) {
|
||||
|
||||
class IndexSkill extends Schema.Class<IndexSkill>("SkillDiscovery.IndexSkill")({
|
||||
name: Schema.String,
|
||||
version: Schema.optional(Schema.String),
|
||||
files: Schema.Array(Schema.String),
|
||||
}) {}
|
||||
|
||||
@@ -81,15 +80,12 @@ export const layer = Layer.effect(
|
||||
)
|
||||
|
||||
const download = Effect.fn("SkillDiscovery.download")(function* (url: string, destination: string) {
|
||||
if (yield* fs.exists(destination).pipe(Effect.orDie)) return true
|
||||
return yield* HttpClientRequest.get(url).pipe(
|
||||
if (yield* fs.exists(destination).pipe(Effect.orDie)) return
|
||||
yield* HttpClientRequest.get(url).pipe(
|
||||
http.execute,
|
||||
Effect.flatMap((response) => response.arrayBuffer),
|
||||
Effect.flatMap((body) => fs.writeWithDirs(destination, new Uint8Array(body))),
|
||||
Effect.as(true),
|
||||
Effect.catch((error) =>
|
||||
Effect.logError("failed to download skill file", { url, error }).pipe(Effect.as(false)),
|
||||
),
|
||||
Effect.catch((error) => Effect.logError("failed to download skill file", { url, error })),
|
||||
)
|
||||
})
|
||||
|
||||
@@ -124,7 +120,6 @@ export const layer = Layer.effect(
|
||||
}
|
||||
|
||||
const skillUrl = new URL(`${encodeURIComponent(skill.name)}/`, source)
|
||||
const versionFile = path.join(root, ".opencode-version")
|
||||
const files = skill.files.map((file) => {
|
||||
if (!isSafeRelativePath(file)) return undefined
|
||||
let resource: URL
|
||||
@@ -140,66 +135,23 @@ export const layer = Layer.effect(
|
||||
return {
|
||||
url: resource.href,
|
||||
destination,
|
||||
file,
|
||||
}
|
||||
})
|
||||
if (files.some((file) => file === undefined)) {
|
||||
return []
|
||||
}
|
||||
return [{ skill, root, versionFile, files: files as { url: string; destination: string; file: string }[] }]
|
||||
return [{ skill, root, files: files as { url: string; destination: string }[] }]
|
||||
}),
|
||||
({ skill, root, versionFile, files }) =>
|
||||
({ skill, root, files }) =>
|
||||
Effect.gen(function* () {
|
||||
const version = skill.version
|
||||
const current =
|
||||
version === undefined
|
||||
? undefined
|
||||
: yield* fs.readFileStringSafe(versionFile).pipe(Effect.catch(() => Effect.succeed(undefined)))
|
||||
if (version === undefined || current === version) {
|
||||
yield* Effect.forEach(files, (file) => download(file.url, file.destination), {
|
||||
concurrency: fileConcurrency,
|
||||
discard: true,
|
||||
})
|
||||
} else {
|
||||
const token = crypto.randomUUID()
|
||||
const staging = `${root}.tmp-${token}`
|
||||
const backup = `${root}.old-${token}`
|
||||
yield* Effect.gen(function* () {
|
||||
const downloaded = yield* Effect.forEach(
|
||||
files,
|
||||
(file) => download(file.url, path.resolve(staging, file.file)),
|
||||
{ concurrency: fileConcurrency },
|
||||
)
|
||||
if (!downloaded.every(Boolean)) return
|
||||
const exists =
|
||||
(yield* fs.exists(path.join(staging, "SKILL.md")).pipe(Effect.orDie)) ||
|
||||
(yield* fs.exists(path.join(staging, `${skill.name}.md`)).pipe(Effect.orDie))
|
||||
if (!exists) return
|
||||
yield* fs.writeFileString(path.join(staging, ".opencode-version"), version)
|
||||
yield* Effect.uninterruptible(
|
||||
Effect.gen(function* () {
|
||||
const cached = yield* fs.exists(root).pipe(Effect.orDie)
|
||||
if (cached) yield* fs.rename(root, backup)
|
||||
yield* fs.rename(staging, root).pipe(
|
||||
Effect.catch((error) =>
|
||||
Effect.gen(function* () {
|
||||
if (cached) yield* fs.rename(backup, root).pipe(Effect.ignore)
|
||||
return yield* Effect.fail(error)
|
||||
}),
|
||||
),
|
||||
)
|
||||
if (cached) yield* fs.remove(backup, { recursive: true, force: true }).pipe(Effect.ignore)
|
||||
}),
|
||||
)
|
||||
}).pipe(
|
||||
Effect.catch((error) => Effect.logError("failed to refresh skill", { skill: skill.name, error })),
|
||||
Effect.ensuring(fs.remove(staging, { recursive: true, force: true }).pipe(Effect.ignore)),
|
||||
)
|
||||
}
|
||||
const exists =
|
||||
(yield* fs.exists(path.join(root, "SKILL.md")).pipe(Effect.orDie)) ||
|
||||
yield* Effect.forEach(files, (file) => download(file.url, file.destination), {
|
||||
concurrency: fileConcurrency,
|
||||
discard: true,
|
||||
})
|
||||
return (yield* fs.exists(path.join(root, "SKILL.md")).pipe(Effect.orDie)) ||
|
||||
(yield* fs.exists(path.join(root, `${skill.name}.md`)).pipe(Effect.orDie))
|
||||
return exists ? [AbsolutePath.make(root)] : []
|
||||
? [AbsolutePath.make(root)]
|
||||
: []
|
||||
}),
|
||||
{ concurrency: skillConcurrency },
|
||||
).pipe(Effect.map((directories) => directories.flat()))
|
||||
|
||||
@@ -42,7 +42,7 @@ const layer = MoveSession.layer.pipe(
|
||||
Layer.provide(Git.defaultLayer),
|
||||
Layer.provide(EventV2.defaultLayer),
|
||||
Layer.provide(project),
|
||||
Layer.provide(SessionStore.defaultLayer),
|
||||
Layer.provide(sessions),
|
||||
)
|
||||
const it = testEffect(
|
||||
Layer.mergeAll(
|
||||
@@ -53,6 +53,7 @@ const it = testEffect(
|
||||
project,
|
||||
SessionProjector.defaultLayer,
|
||||
SessionStore.defaultLayer,
|
||||
SessionExecution.noopLayer,
|
||||
sessions,
|
||||
),
|
||||
)
|
||||
|
||||
@@ -1,15 +0,0 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { OauthCallbackPage } from "../src/oauth/page"
|
||||
|
||||
describe("OauthCallbackPage", () => {
|
||||
test("escapes bootstrap options embedded in the inline script", () => {
|
||||
const html = OauthCallbackPage.bootstrap({
|
||||
provider: `xAI</script><script>alert("provider")</script>`,
|
||||
tokenPath: `/token</script><script>alert("path")</script>`,
|
||||
})
|
||||
|
||||
expect(html.match(/<\/script>/g)).toHaveLength(1)
|
||||
expect(html).toContain(`xAI\\u003c/script>\\u003cscript>alert(\\\"provider\\\")\\u003c/script>`)
|
||||
expect(html).toContain(`/token\\u003c/script>\\u003cscript>alert(\\\"path\\\")\\u003c/script>`)
|
||||
})
|
||||
})
|
||||
@@ -22,11 +22,9 @@ import { testEffect } from "./lib/effect"
|
||||
const executionCalls: SessionV2.ID[] = []
|
||||
const interruptCalls: SessionV2.ID[] = []
|
||||
const wakeCalls: SessionV2.ID[] = []
|
||||
const activeSessions = new Set<SessionV2.ID>()
|
||||
const execution = Layer.succeed(
|
||||
SessionExecution.Service,
|
||||
SessionExecution.Service.of({
|
||||
active: Effect.sync(() => new Set(activeSessions)),
|
||||
resume: (sessionID) =>
|
||||
Effect.sync(() => {
|
||||
executionCalls.push(sessionID)
|
||||
@@ -110,13 +108,6 @@ const eventCount = (type: string) =>
|
||||
)
|
||||
|
||||
describe("SessionV2.prompt", () => {
|
||||
it.effect("exposes the execution registry", () =>
|
||||
Effect.gen(function* () {
|
||||
activeSessions.add(sessionID)
|
||||
expect(Array.from(yield* (yield* SessionV2.Service).active)).toEqual([sessionID])
|
||||
}).pipe(Effect.ensuring(Effect.sync(() => activeSessions.clear()))),
|
||||
)
|
||||
|
||||
it.effect("delegates execution continuation through SessionExecution", () =>
|
||||
Effect.gen(function* () {
|
||||
yield* setup
|
||||
|
||||
@@ -66,78 +66,6 @@ describe("SessionRunCoordinator", () => {
|
||||
),
|
||||
)
|
||||
|
||||
it.effect("snapshots only active executions", () =>
|
||||
Effect.scoped(
|
||||
Effect.gen(function* () {
|
||||
const firstStarted = yield* Deferred.make<void>()
|
||||
const secondStarted = yield* Deferred.make<void>()
|
||||
const firstGate = yield* Deferred.make<void>()
|
||||
const secondGate = yield* Deferred.make<void>()
|
||||
const coordinator = yield* SessionRunCoordinator.make({
|
||||
drain: (key: string) =>
|
||||
Deferred.succeed(key === "first" ? firstStarted : secondStarted, undefined).pipe(
|
||||
Effect.andThen(Deferred.await(key === "first" ? firstGate : secondGate)),
|
||||
),
|
||||
})
|
||||
|
||||
expect(Array.from(yield* coordinator.active)).toEqual([])
|
||||
const first = yield* coordinator.run("first").pipe(Effect.forkChild)
|
||||
yield* Deferred.await(firstStarted)
|
||||
expect(Array.from(yield* coordinator.active)).toEqual(["first"])
|
||||
|
||||
const second = yield* coordinator.run("second").pipe(Effect.forkChild)
|
||||
yield* Deferred.await(secondStarted)
|
||||
expect(Array.from(yield* coordinator.active)).toEqual(["first", "second"])
|
||||
|
||||
yield* Deferred.succeed(firstGate, undefined)
|
||||
yield* Fiber.join(first)
|
||||
expect(Array.from(yield* coordinator.active)).toEqual(["second"])
|
||||
yield* Deferred.succeed(secondGate, undefined)
|
||||
yield* Fiber.join(second)
|
||||
expect(Array.from(yield* coordinator.active)).toEqual([])
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
it.effect("cleans active executions after failure and defect", () =>
|
||||
Effect.scoped(
|
||||
Effect.gen(function* () {
|
||||
const failure = new Error("failed")
|
||||
const defect = new Error("defect")
|
||||
const coordinator = yield* SessionRunCoordinator.make({
|
||||
drain: (key: string) => (key === "failure" ? Effect.fail(failure) : Effect.die(defect)),
|
||||
})
|
||||
|
||||
const failed = yield* coordinator.run("failure").pipe(Effect.exit)
|
||||
expect(Exit.isFailure(failed) && Cause.hasFails(failed.cause)).toBeTrue()
|
||||
expect(Array.from(yield* coordinator.active)).toEqual([])
|
||||
|
||||
const died = yield* coordinator.run("defect").pipe(Effect.exit)
|
||||
expect(Exit.isFailure(died) && Cause.hasDies(died.cause)).toBeTrue()
|
||||
expect(Array.from(yield* coordinator.active)).toEqual([])
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
it.effect("cleans active executions when its scope closes", () =>
|
||||
Effect.gen(function* () {
|
||||
const started = yield* Deferred.make<void>()
|
||||
const coordinator = yield* Effect.scoped(
|
||||
Effect.gen(function* () {
|
||||
const coordinator = yield* SessionRunCoordinator.make({
|
||||
drain: () => Deferred.succeed(started, undefined).pipe(Effect.andThen(Effect.never)),
|
||||
})
|
||||
yield* coordinator.wake("session")
|
||||
yield* Deferred.await(started)
|
||||
expect(Array.from(yield* coordinator.active)).toEqual(["session"])
|
||||
return coordinator
|
||||
}),
|
||||
)
|
||||
|
||||
expect(Array.from(yield* coordinator.active)).toEqual([])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("coalesces wakes received during active execution", () =>
|
||||
Effect.scoped(
|
||||
Effect.gen(function* () {
|
||||
@@ -238,7 +166,6 @@ describe("SessionRunCoordinator", () => {
|
||||
|
||||
const exit = yield* Fiber.await(resumed)
|
||||
expect(Exit.isFailure(exit) && Cause.hasInterruptsOnly(exit.cause)).toBeTrue()
|
||||
expect(Array.from(yield* coordinator.active)).toEqual([])
|
||||
expect(runs).toBe(1)
|
||||
}),
|
||||
),
|
||||
|
||||
@@ -95,7 +95,6 @@ const execution = Layer.effect(
|
||||
drain: (sessionID, force) => sessionRunner.run({ sessionID, force }),
|
||||
})
|
||||
return SessionExecution.Service.of({
|
||||
active: coordinator.active,
|
||||
resume: coordinator.run,
|
||||
wake: coordinator.wake,
|
||||
interrupt: coordinator.interrupt,
|
||||
|
||||
@@ -254,7 +254,6 @@ const execution = Layer.effect(
|
||||
drain: (sessionID, force) => sessionRunner.run({ sessionID, force }),
|
||||
})
|
||||
return SessionExecution.Service.of({
|
||||
active: coordinator.active,
|
||||
resume: coordinator.run,
|
||||
wake: coordinator.wake,
|
||||
interrupt: coordinator.interrupt,
|
||||
|
||||
@@ -10,8 +10,8 @@ import { tmpdir } from "./fixture/tmpdir"
|
||||
|
||||
const base = "https://skills.example.test/catalog/"
|
||||
|
||||
async function pull(skills: unknown[], files: Record<string, string> = {}, cache?: Awaited<ReturnType<typeof tmpdir>>) {
|
||||
const tmp = cache ?? (await tmpdir())
|
||||
async function pull(skills: unknown[], files: Record<string, string> = {}) {
|
||||
const tmp = await tmpdir()
|
||||
const requests: string[] = []
|
||||
const http = Layer.succeed(
|
||||
HttpClient.HttpClient,
|
||||
@@ -101,64 +101,4 @@ describe("SkillDiscovery.pull", () => {
|
||||
await result.tmp[Symbol.asyncDispose]()
|
||||
}
|
||||
})
|
||||
|
||||
test("refreshes cached files when the version changes", async () => {
|
||||
const tmp = await tmpdir()
|
||||
try {
|
||||
const first = await pull(
|
||||
[{ name: "deploy", version: "1", files: ["SKILL.md"] }],
|
||||
{
|
||||
[`${base}deploy/SKILL.md`]: "# Old",
|
||||
},
|
||||
tmp,
|
||||
)
|
||||
const second = await pull(
|
||||
[{ name: "deploy", version: "2", files: ["SKILL.md"] }],
|
||||
{
|
||||
[`${base}deploy/SKILL.md`]: "# New",
|
||||
},
|
||||
tmp,
|
||||
)
|
||||
|
||||
expect(await fs.readFile(path.join(first.directories[0], "SKILL.md"), "utf8")).toBe("# New")
|
||||
expect(second.requests).toContain(`${base}deploy/SKILL.md`)
|
||||
const third = await pull(
|
||||
[{ name: "deploy", version: "2", files: ["SKILL.md"] }],
|
||||
{ [`${base}deploy/SKILL.md`]: "# Ignored" },
|
||||
tmp,
|
||||
)
|
||||
expect(third.requests).toEqual([`${base}index.json`])
|
||||
} finally {
|
||||
await tmp[Symbol.asyncDispose]()
|
||||
}
|
||||
})
|
||||
|
||||
test("publishes complete updates and removes stale files", async () => {
|
||||
const tmp = await tmpdir()
|
||||
try {
|
||||
const first = await pull(
|
||||
[{ name: "deploy", version: "1", files: ["SKILL.md", "old.md"] }],
|
||||
{
|
||||
[`${base}deploy/SKILL.md`]: "# Old",
|
||||
[`${base}deploy/old.md`]: "old reference",
|
||||
},
|
||||
tmp,
|
||||
)
|
||||
const root = first.directories[0]
|
||||
|
||||
await pull(
|
||||
[{ name: "deploy", version: "2", files: ["SKILL.md", "missing.md"] }],
|
||||
{ [`${base}deploy/SKILL.md`]: "# Partial" },
|
||||
tmp,
|
||||
)
|
||||
expect(await fs.readFile(path.join(root, "SKILL.md"), "utf8")).toBe("# Old")
|
||||
expect(await fs.readFile(path.join(root, "old.md"), "utf8")).toBe("old reference")
|
||||
|
||||
await pull([{ name: "deploy", version: "3", files: ["SKILL.md"] }], { [`${base}deploy/SKILL.md`]: "# New" }, tmp)
|
||||
expect(await fs.readFile(path.join(root, "SKILL.md"), "utf8")).toBe("# New")
|
||||
expect(await Bun.file(path.join(root, "old.md")).exists()).toBe(false)
|
||||
} finally {
|
||||
await tmp[Symbol.asyncDispose]()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
@@ -53,7 +53,7 @@ const OpenAIResponsesReasoningSummaryText = Schema.Struct({
|
||||
|
||||
const OpenAIResponsesReasoningItem = Schema.Struct({
|
||||
type: Schema.tag("reasoning"),
|
||||
id: Schema.optionalKey(Schema.String),
|
||||
id: Schema.String,
|
||||
summary: Schema.Array(OpenAIResponsesReasoningSummaryText),
|
||||
encrypted_content: optionalNull(Schema.String),
|
||||
})
|
||||
@@ -101,7 +101,6 @@ type OpenAIResponsesReasoningInput = {
|
||||
summary: Array<{ type: "summary_text"; text: string }>
|
||||
encrypted_content?: string | null
|
||||
}
|
||||
type OpenAIResponsesReasoningReplay = Omit<OpenAIResponsesReasoningInput, "id">
|
||||
|
||||
const OpenAIResponsesTool = Schema.Struct({
|
||||
type: Schema.tag("function"),
|
||||
@@ -367,7 +366,7 @@ const lowerMessages = Effect.fn("OpenAIResponses.lowerMessages")(function* (requ
|
||||
|
||||
if (message.role === "assistant") {
|
||||
const content: TextPart[] = []
|
||||
const reasoningItems: Record<string, OpenAIResponsesReasoningReplay> = {}
|
||||
const reasoningItems: Record<string, OpenAIResponsesReasoningInput> = {}
|
||||
const reasoningReferences = new Set<string>()
|
||||
const hostedToolReferences = new Set<string>()
|
||||
const flushText = () => {
|
||||
@@ -384,7 +383,7 @@ const lowerMessages = Effect.fn("OpenAIResponses.lowerMessages")(function* (requ
|
||||
flushText()
|
||||
const reasoning = lowerReasoning(part)
|
||||
if (!reasoning) continue
|
||||
if (store !== false) {
|
||||
if (store !== false && reasoning.id) {
|
||||
if (!reasoningReferences.has(reasoning.id)) input.push({ type: "item_reference", id: reasoning.id })
|
||||
reasoningReferences.add(reasoning.id)
|
||||
continue
|
||||
@@ -396,13 +395,8 @@ const lowerMessages = Effect.fn("OpenAIResponses.lowerMessages")(function* (requ
|
||||
existing.encrypted_content = reasoning.encrypted_content
|
||||
continue
|
||||
}
|
||||
const replay = {
|
||||
type: reasoning.type,
|
||||
summary: reasoning.summary,
|
||||
encrypted_content: reasoning.encrypted_content,
|
||||
}
|
||||
reasoningItems[reasoning.id] = replay
|
||||
input.push(replay)
|
||||
reasoningItems[reasoning.id] = reasoning
|
||||
input.push(reasoning)
|
||||
continue
|
||||
}
|
||||
if (part.type === "tool-call") {
|
||||
@@ -980,7 +974,6 @@ export const route = Route.make({
|
||||
endpoint,
|
||||
auth,
|
||||
transport: httpTransport,
|
||||
defaults: { providerOptions: { openai: { store: false } } },
|
||||
})
|
||||
|
||||
const decodeWebSocketMessage = ProviderShared.validateWith(Schema.decodeUnknownEffect(OpenAIResponsesWebSocketMessage))
|
||||
@@ -1008,7 +1001,6 @@ export const webSocketRoute = Route.make({
|
||||
endpoint,
|
||||
auth,
|
||||
transport: webSocketTransport,
|
||||
defaults: { providerOptions: { openai: { store: false } } },
|
||||
})
|
||||
|
||||
export * as OpenAIResponses from "./openai-responses"
|
||||
|
||||
+1
-1
@@ -44,7 +44,7 @@
|
||||
"headers": {
|
||||
"content-type": "application/json"
|
||||
},
|
||||
"body": "{\"model\":\"gpt-5.5\",\"input\":[{\"role\":\"user\",\"content\":[{\"type\":\"input_text\",\"text\":\"Think briefly, then reply exactly with: Hello!\"}]},{\"type\":\"reasoning\",\"summary\":[],\"encrypted_content\":\"gAAAAABqEjXoGMCw3WDXpoD9151PEr2Lt8raW7KBKefQhZJGWx5f8jy152bApO6oE-Mr1BhUtfZNq3OPBVfSL4ioQ9bHREfujIBXgk9LUDBAz2Sle7KjOr9HaUV16A4HBiaFIRFjsHPS9G8yEySp1m6F1CD_WR6apyUGgugRh_y39EcOJmxPOzmiac5DVM6fraA1VpcGbqrZ1x2ANHFDOfnYTycPtPNTgzE7LjkYjDDWbT03uN1YxfP4pqjDVRzY14pA8bSZ8ys-pDv5kUFCAsw-OlU4jYKUXp-M8_6KTaRQP71LPwppt__zG_NJPfy-qUil4pOU8_NoxtxerHgLLXbfExZdzfpoGinoEjn7nj7BJDEtl-LNeNEb5c-1ZymNfVMp-Cs3fLEPkAV8rtHFtZ0MhE_07GKbGo7hTrOmkM4DydxmHsdWGNbXAG35cprslEA5P7p3GHFKnRs5hGs2eq-XcZ3yki64ZBOU_Tv6UR7nUH09gF1rdrJo3dpre6M00COwwdZ02zUP5KxCuI8FKu2jsZu9zgMVXDALsdtM5orTCVLXsn4rddWd111zE-vMjNmMMmktW2cHMjH7j1ooA-9P083koNVYiLi4UhMA64gTqgyl8MxkZekl7eFSMa7qk295NaHOKtFxzYYcZ9jdioCwSPSZ0ZZWLoNgrK7SWfRh0uaTHNcMZ3wq8ae6CguktIeVTCPTQAqJLQqd7AU0oOCKCJ7BWnC-L8UC6m7Pm9ZS958uUVeWBhgKHzMAGq9UeQB7IEeAcbMn3EDgOSfd8qCb8iwU9iG9dcu9axQwWU7pd7kd-T-He61W7z5wWgpx1KehWCxrN6kuKSo6p-uUfwVnJukreOn8BJNAzADQgz68bhmN9VGih7YcKVnLgwDwKditrjSd6-tfE0Baarj3jWENvT6ohY17R9FDrKS-2v8IIX6tGjoKJw8SRhaWLNv4vWlmxRgR0gdac3qumd0GKqsWSveNz01naA==\"},{\"role\":\"assistant\",\"content\":[{\"type\":\"output_text\",\"text\":\"Hello!\"}]},{\"role\":\"user\",\"content\":[{\"type\":\"input_text\",\"text\":\"Now reply exactly with: Done.\"}]}],\"store\":false,\"include\":[\"reasoning.encrypted_content\"],\"reasoning\":{\"effort\":\"low\",\"summary\":\"auto\"},\"text\":{\"verbosity\":\"low\"},\"max_output_tokens\":40,\"stream\":true}"
|
||||
"body": "{\"model\":\"gpt-5.5\",\"input\":[{\"role\":\"user\",\"content\":[{\"type\":\"input_text\",\"text\":\"Think briefly, then reply exactly with: Hello!\"}]},{\"type\":\"reasoning\",\"id\":\"rs_0a0794dab3b8ec7d016a1235e7ce3881958a5eca32a36a14c5\",\"summary\":[],\"encrypted_content\":\"gAAAAABqEjXoGMCw3WDXpoD9151PEr2Lt8raW7KBKefQhZJGWx5f8jy152bApO6oE-Mr1BhUtfZNq3OPBVfSL4ioQ9bHREfujIBXgk9LUDBAz2Sle7KjOr9HaUV16A4HBiaFIRFjsHPS9G8yEySp1m6F1CD_WR6apyUGgugRh_y39EcOJmxPOzmiac5DVM6fraA1VpcGbqrZ1x2ANHFDOfnYTycPtPNTgzE7LjkYjDDWbT03uN1YxfP4pqjDVRzY14pA8bSZ8ys-pDv5kUFCAsw-OlU4jYKUXp-M8_6KTaRQP71LPwppt__zG_NJPfy-qUil4pOU8_NoxtxerHgLLXbfExZdzfpoGinoEjn7nj7BJDEtl-LNeNEb5c-1ZymNfVMp-Cs3fLEPkAV8rtHFtZ0MhE_07GKbGo7hTrOmkM4DydxmHsdWGNbXAG35cprslEA5P7p3GHFKnRs5hGs2eq-XcZ3yki64ZBOU_Tv6UR7nUH09gF1rdrJo3dpre6M00COwwdZ02zUP5KxCuI8FKu2jsZu9zgMVXDALsdtM5orTCVLXsn4rddWd111zE-vMjNmMMmktW2cHMjH7j1ooA-9P083koNVYiLi4UhMA64gTqgyl8MxkZekl7eFSMa7qk295NaHOKtFxzYYcZ9jdioCwSPSZ0ZZWLoNgrK7SWfRh0uaTHNcMZ3wq8ae6CguktIeVTCPTQAqJLQqd7AU0oOCKCJ7BWnC-L8UC6m7Pm9ZS958uUVeWBhgKHzMAGq9UeQB7IEeAcbMn3EDgOSfd8qCb8iwU9iG9dcu9axQwWU7pd7kd-T-He61W7z5wWgpx1KehWCxrN6kuKSo6p-uUfwVnJukreOn8BJNAzADQgz68bhmN9VGih7YcKVnLgwDwKditrjSd6-tfE0Baarj3jWENvT6ohY17R9FDrKS-2v8IIX6tGjoKJw8SRhaWLNv4vWlmxRgR0gdac3qumd0GKqsWSveNz01naA==\"},{\"role\":\"assistant\",\"content\":[{\"type\":\"output_text\",\"text\":\"Hello!\"}]},{\"role\":\"user\",\"content\":[{\"type\":\"input_text\",\"text\":\"Now reply exactly with: Done.\"}]}],\"store\":false,\"include\":[\"reasoning.encrypted_content\"],\"reasoning\":{\"effort\":\"low\",\"summary\":\"auto\"},\"text\":{\"verbosity\":\"low\"},\"max_output_tokens\":40,\"stream\":true}"
|
||||
},
|
||||
"response": {
|
||||
"status": 200,
|
||||
|
||||
@@ -50,7 +50,6 @@ describe("OpenAI Responses route", () => {
|
||||
{ role: "system", content: "You are concise." },
|
||||
{ role: "user", content: [{ type: "input_text", text: "Say hello." }] },
|
||||
],
|
||||
store: false,
|
||||
stream: true,
|
||||
max_output_tokens: 20,
|
||||
temperature: 0,
|
||||
@@ -162,16 +161,16 @@ describe("OpenAI Responses route", () => {
|
||||
Effect.gen(function* () {
|
||||
const prepared = yield* LLMClient.prepare(
|
||||
LLM.updateRequest(request, {
|
||||
model: OpenAIResponses.webSocketRoute
|
||||
.with({ endpoint: { baseURL: "https://api.openai.test/v1/" }, auth: Auth.bearer("test") })
|
||||
.model({ id: "gpt-4.1-mini" }),
|
||||
model: OpenAI.configure({ baseURL: "https://api.openai.test/v1/", apiKey: "test" }).responsesWebSocket(
|
||||
"gpt-4.1-mini",
|
||||
),
|
||||
}),
|
||||
)
|
||||
|
||||
expect(prepared.route).toBe("openai-responses-websocket")
|
||||
expect(prepared.protocol).toBe("openai-responses")
|
||||
expect(prepared.metadata).toEqual({ transport: "websocket-json" })
|
||||
expect(prepared.body).toMatchObject({ model: "gpt-4.1-mini", store: false, stream: true })
|
||||
expect(prepared.body).toMatchObject({ model: "gpt-4.1-mini", stream: true })
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -357,13 +356,7 @@ describe("OpenAI Responses route", () => {
|
||||
{ type: "function_call", call_id: "call_1", name: "lookup", arguments: '{"query":"weather"}' },
|
||||
{ type: "function_call_output", call_id: "call_1", output: '{"forecast":"sunny"}' },
|
||||
],
|
||||
store: false,
|
||||
stream: true,
|
||||
max_output_tokens: undefined,
|
||||
temperature: undefined,
|
||||
tool_choice: undefined,
|
||||
tools: undefined,
|
||||
top_p: undefined,
|
||||
})
|
||||
}),
|
||||
)
|
||||
@@ -526,6 +519,7 @@ describe("OpenAI Responses route", () => {
|
||||
},
|
||||
{
|
||||
type: "reasoning",
|
||||
id: "rs_continuation_1",
|
||||
encrypted_content: "encrypted-continuation-state",
|
||||
summary: [{ type: "summary_text", text: "I inspected the previous turn." }],
|
||||
},
|
||||
@@ -870,9 +864,7 @@ describe("OpenAI Responses route", () => {
|
||||
|
||||
it.effect("closes reasoning summary parts when storage is not disabled", () =>
|
||||
Effect.gen(function* () {
|
||||
const response = yield* LLMClient.generate(
|
||||
LLM.updateRequest(request, { providerOptions: { openai: { store: true } } }),
|
||||
).pipe(
|
||||
const response = yield* LLMClient.generate(request).pipe(
|
||||
Effect.provide(
|
||||
fixedResponse(
|
||||
sseEvents(
|
||||
@@ -933,12 +925,12 @@ describe("OpenAI Responses route", () => {
|
||||
dynamicResponse((input) =>
|
||||
Effect.gen(function* () {
|
||||
const web = yield* HttpClientRequest.toWeb(input.request).pipe(Effect.orDie)
|
||||
const body = yield* Effect.promise(() => web.json())
|
||||
expect(body).toMatchObject({
|
||||
expect(yield* Effect.promise(() => web.json())).toMatchObject({
|
||||
input: [
|
||||
{ role: "user", content: [{ type: "input_text", text: "What changed?" }] },
|
||||
{
|
||||
type: "reasoning",
|
||||
id: "rs_1",
|
||||
encrypted_content: "encrypted-state",
|
||||
summary: [{ type: "summary_text", text: "Checked the previous diff." }],
|
||||
},
|
||||
@@ -946,7 +938,6 @@ describe("OpenAI Responses route", () => {
|
||||
{ role: "user", content: [{ type: "input_text", text: "Summarize it." }] },
|
||||
],
|
||||
})
|
||||
expect(body.input[1]).not.toHaveProperty("id")
|
||||
return input.respond(
|
||||
sseEvents(
|
||||
{ type: "response.output_text.delta", item_id: "msg_1", delta: "Parser now round-trips reasoning." },
|
||||
@@ -993,6 +984,7 @@ describe("OpenAI Responses route", () => {
|
||||
{ role: "assistant", content: [{ type: "output_text", text: "Before." }] },
|
||||
{
|
||||
type: "reasoning",
|
||||
id: "rs_1",
|
||||
encrypted_content: "encrypted-state",
|
||||
summary: [{ type: "summary_text", text: "Checked order." }],
|
||||
},
|
||||
@@ -1086,6 +1078,7 @@ describe("OpenAI Responses route", () => {
|
||||
expect(prepared.body.input).toEqual([
|
||||
{
|
||||
type: "reasoning",
|
||||
id: "rs_1",
|
||||
encrypted_content: "encrypted-state",
|
||||
summary: [
|
||||
{ type: "summary_text", text: "First" },
|
||||
|
||||
@@ -598,7 +598,7 @@ describe("LLMClient tools", () => {
|
||||
include: ["reasoning.encrypted_content"],
|
||||
input: [
|
||||
{ role: "user" },
|
||||
{ type: "reasoning", summary: [], encrypted_content: "encrypted-state" },
|
||||
{ type: "reasoning", id: "rs_1", summary: [], encrypted_content: "encrypted-state" },
|
||||
{ type: "function_call", call_id: "call_1", name: "get_weather" },
|
||||
{ type: "function_call_output", call_id: "call_1" },
|
||||
],
|
||||
|
||||
@@ -46,7 +46,6 @@ export class UnsupportedOperationError extends Schema.TaggedErrorClass<Unsupport
|
||||
export class ServiceFailureError extends Schema.TaggedErrorClass<ServiceFailureError>()("ACPServiceFailureError", {
|
||||
safeMessage: Schema.String,
|
||||
service: Schema.optional(Schema.String),
|
||||
errorName: Schema.optional(Schema.String),
|
||||
}) {}
|
||||
|
||||
export type Error =
|
||||
@@ -82,13 +81,7 @@ export function toRequestError(error: Error) {
|
||||
case "ACPUnsupportedOperationError":
|
||||
return RequestError.methodNotFound(error.method)
|
||||
case "ACPServiceFailureError":
|
||||
return RequestError.internalError(
|
||||
{
|
||||
...(error.service ? { service: error.service } : {}),
|
||||
...(error.errorName ? { errorName: error.errorName } : {}),
|
||||
},
|
||||
error.safeMessage,
|
||||
)
|
||||
return RequestError.internalError({ service: error.service }, error.safeMessage)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,16 +1,9 @@
|
||||
import type {
|
||||
AgentSideConnection,
|
||||
PermissionOption,
|
||||
RequestPermissionResponse,
|
||||
ToolCallContent,
|
||||
ToolCallLocation,
|
||||
ToolCallUpdate,
|
||||
} from "@agentclientprotocol/sdk"
|
||||
import type { AgentSideConnection, PermissionOption, RequestPermissionResponse } from "@agentclientprotocol/sdk"
|
||||
import type { Event, OpencodeClient } from "@opencode-ai/sdk/v2"
|
||||
import { applyPatch } from "diff"
|
||||
import { exists, readText } from "@/util/filesystem"
|
||||
import type { ACPSession } from "./session"
|
||||
import { pendingToolCall, toLocations, type ToolInput } from "./tool"
|
||||
import { toLocations, toToolKind, type ToolInput } from "./tool"
|
||||
import { Effect } from "effect"
|
||||
|
||||
type PermissionEvent = Extract<Event, { type: "permission.asked" }>
|
||||
@@ -61,11 +54,14 @@ export class Handler {
|
||||
const result = await this.input.connection
|
||||
.requestPermission({
|
||||
sessionId: permission.sessionID,
|
||||
toolCall: await permissionToolCall({
|
||||
toolCall: {
|
||||
toolCallId: permission.tool?.callID ?? permission.id,
|
||||
toolName: permission.permission,
|
||||
input: permission.metadata,
|
||||
}),
|
||||
status: "pending",
|
||||
title: permission.permission,
|
||||
rawInput: permission.metadata,
|
||||
kind: toToolKind(permission.permission),
|
||||
locations: toLocations(permission.permission, permission.metadata),
|
||||
},
|
||||
options: permissionOptions,
|
||||
})
|
||||
.catch(async () => {
|
||||
@@ -115,107 +111,6 @@ export class Handler {
|
||||
}
|
||||
}
|
||||
|
||||
async function permissionToolCall(input: {
|
||||
readonly toolCallId: string
|
||||
readonly toolName: string
|
||||
readonly input: ToolInput
|
||||
}): Promise<ToolCallUpdate> {
|
||||
const toolCall = pendingToolCall({
|
||||
toolCallId: input.toolCallId,
|
||||
toolName: input.toolName,
|
||||
state: {
|
||||
input: input.input,
|
||||
title: permissionTitle(input.toolName, input.input),
|
||||
},
|
||||
})
|
||||
const content = await permissionContent(input.toolName, input.input)
|
||||
return {
|
||||
...toolCall,
|
||||
locations: permissionLocations(input.toolName, input.input),
|
||||
...(content.length ? { content } : {}),
|
||||
}
|
||||
}
|
||||
|
||||
function permissionTitle(toolName: string, input: ToolInput) {
|
||||
const tool = toolName.toLocaleLowerCase()
|
||||
switch (tool) {
|
||||
case "external_directory":
|
||||
return stringValue(input.description) ?? stringValue(input.command) ?? stringValue(input.parentDir)
|
||||
|
||||
case "webfetch":
|
||||
return stringValue(input.url)
|
||||
|
||||
case "websearch":
|
||||
return stringValue(input.query)
|
||||
|
||||
case "grep":
|
||||
case "glob":
|
||||
return stringValue(input.pattern)
|
||||
|
||||
case "read":
|
||||
case "edit":
|
||||
case "write":
|
||||
return editTitle(input)
|
||||
|
||||
default:
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
function editTitle(input: ToolInput) {
|
||||
const files = fileMetadata(input)
|
||||
if (files.length === 1) return files[0]?.relativePath ?? files[0]?.filePath
|
||||
if (files.length > 1) return `${files.length} files`
|
||||
return stringValue(input.filePath) ?? stringValue(input.filepath) ?? stringValue(input.path)
|
||||
}
|
||||
|
||||
function permissionLocations(toolName: string, input: ToolInput): ToolCallLocation[] {
|
||||
const files = fileMetadata(input)
|
||||
if (files.length) {
|
||||
return Array.from(
|
||||
new Set(files.flatMap((file) => [file.filePath, file.movePath].filter((path): path is string => !!path))),
|
||||
(path) => ({ path }),
|
||||
)
|
||||
}
|
||||
return toLocations(toolName, input)
|
||||
}
|
||||
|
||||
async function permissionContent(toolName: string, input: ToolInput): Promise<ToolCallContent[]> {
|
||||
if (toolName.toLocaleLowerCase() !== "edit") return []
|
||||
|
||||
const files = fileMetadata(input)
|
||||
if (files.length) return diffContentForFiles(files)
|
||||
|
||||
const filepath = stringValue(input.filepath) ?? stringValue(input.filePath)
|
||||
const diff = stringValue(input.diff)
|
||||
if (!filepath || !diff) return []
|
||||
const content = await diffContentForPatch(filepath, diff)
|
||||
return content ? [content] : []
|
||||
}
|
||||
|
||||
async function diffContentForFiles(files: PermissionFileMetadata[]) {
|
||||
const content = await Promise.all(
|
||||
files.map(async (file) => {
|
||||
if (!file.patch) return []
|
||||
const content = await diffContentForPatch(file.filePath, file.patch, file.movePath)
|
||||
return content ? [content] : []
|
||||
}),
|
||||
)
|
||||
return content.flat()
|
||||
}
|
||||
|
||||
async function diffContentForPatch(filepath: string, diff: string, displayPath = filepath) {
|
||||
const content = (await exists(filepath)) ? await readText(filepath) : ""
|
||||
const next = applyPatch(content, diff)
|
||||
if (next === false) return undefined
|
||||
return {
|
||||
type: "diff" as const,
|
||||
path: displayPath,
|
||||
oldText: content,
|
||||
newText: next,
|
||||
}
|
||||
}
|
||||
|
||||
function selectedReply(result: RequestPermissionResponse): Reply {
|
||||
if (result.outcome.outcome !== "selected") return "reject"
|
||||
if (result.outcome.optionId === "once" || result.outcome.optionId === "always") return result.outcome.optionId
|
||||
@@ -226,29 +121,4 @@ function stringValue(value: unknown) {
|
||||
return typeof value === "string" ? value : undefined
|
||||
}
|
||||
|
||||
type PermissionFileMetadata = {
|
||||
readonly filePath: string
|
||||
readonly relativePath?: string
|
||||
readonly movePath?: string
|
||||
readonly patch?: string
|
||||
}
|
||||
|
||||
function fileMetadata(input: ToolInput): PermissionFileMetadata[] {
|
||||
if (!Array.isArray(input.files)) return []
|
||||
return input.files.flatMap((file): PermissionFileMetadata[] => {
|
||||
if (!file || typeof file !== "object") return []
|
||||
const info = file as Record<string, unknown>
|
||||
const filePath = stringValue(info.filePath)
|
||||
if (!filePath) return []
|
||||
return [
|
||||
{
|
||||
filePath,
|
||||
relativePath: stringValue(info.relativePath),
|
||||
movePath: stringValue(info.movePath),
|
||||
patch: stringValue(info.patch),
|
||||
},
|
||||
]
|
||||
})
|
||||
}
|
||||
|
||||
export * as ACPPermission from "./permission"
|
||||
|
||||
@@ -30,7 +30,7 @@ import {
|
||||
type SetSessionModeResponse,
|
||||
} from "@agentclientprotocol/sdk"
|
||||
import { InstallationVersion } from "@opencode-ai/core/installation/version"
|
||||
import type { AssistantMessage, Message, OpencodeClient, SessionMessageResponse } from "@opencode-ai/sdk/v2"
|
||||
import type { Message, OpencodeClient, SessionMessageResponse } from "@opencode-ai/sdk/v2"
|
||||
import { Context, Effect, Layer, ManagedRuntime } from "effect"
|
||||
import * as ACPError from "./error"
|
||||
import { buildConfigOptions, parseModelSelection } from "./config-option"
|
||||
@@ -314,6 +314,7 @@ export function make(input: {
|
||||
|
||||
yield* registerMcpServers(input.sdk, registeredMcp, params.cwd, state.id, params.mcpServers ?? [])
|
||||
yield* sendAvailableCommands(input.connection, state.id, snapshot)
|
||||
yield* replayMessages(events, messages)
|
||||
|
||||
return {
|
||||
configOptions: configOptions(snapshot, {
|
||||
@@ -520,7 +521,7 @@ export function make(input: {
|
||||
"session",
|
||||
)
|
||||
yield* sendUsageUpdate(input.usage, input.sdk, input.connection, current.id, current.cwd)
|
||||
return yield* promptResponse(response.info, params.messageId)
|
||||
return promptResponse(response.info, params.messageId)
|
||||
}
|
||||
|
||||
const known = snapshot.availableCommands.find((item) => item.name === command.name)
|
||||
@@ -542,7 +543,7 @@ export function make(input: {
|
||||
"session",
|
||||
)
|
||||
yield* sendUsageUpdate(input.usage, input.sdk, input.connection, current.id, current.cwd)
|
||||
return yield* promptResponse(response.info, params.messageId)
|
||||
return promptResponse(response.info, params.messageId)
|
||||
}
|
||||
|
||||
if (command.name === "compact") {
|
||||
@@ -562,7 +563,7 @@ export function make(input: {
|
||||
}
|
||||
|
||||
yield* sendUsageUpdate(input.usage, input.sdk, input.connection, current.id, current.cwd)
|
||||
return yield* promptResponse(undefined, params.messageId)
|
||||
return promptResponse(undefined, params.messageId)
|
||||
}),
|
||||
cancel,
|
||||
}
|
||||
@@ -694,8 +695,7 @@ type MessageInfo = {
|
||||
readonly agent?: Message["agent"]
|
||||
}
|
||||
|
||||
type AssistantError = NonNullable<AssistantMessage["error"]>
|
||||
type AssistantInfo = (UsageService.AssistantTokenCost & Pick<AssistantMessage, "error">) | undefined
|
||||
type AssistantInfo = UsageService.AssistantTokenCost | undefined
|
||||
|
||||
function request<T>(fn: () => Promise<T | SdkResponse<T>>, service?: string) {
|
||||
return Effect.tryPromise({
|
||||
@@ -811,60 +811,13 @@ function detectSlashCommand(parts: ReturnType<typeof promptContentToParts>) {
|
||||
return { name, args: rest.join(" ").trim() }
|
||||
}
|
||||
|
||||
const promptResponse = Effect.fn("ACP.promptResponse")(function* (
|
||||
info: AssistantInfo,
|
||||
messageId: string | null | undefined,
|
||||
) {
|
||||
if (!info?.error) {
|
||||
return {
|
||||
stopReason: "end_turn" as const,
|
||||
...(info ? { usage: UsageService.buildUsage(info) } : {}),
|
||||
...(messageId ? { userMessageId: messageId } : {}),
|
||||
_meta: {},
|
||||
}
|
||||
}
|
||||
|
||||
const base = {
|
||||
usage: UsageService.buildUsage(info),
|
||||
function promptResponse(info: AssistantInfo, messageId: string | null | undefined): PromptResponse {
|
||||
return {
|
||||
stopReason: "end_turn",
|
||||
...(info ? { usage: UsageService.buildUsage(info) } : {}),
|
||||
...(messageId ? { userMessageId: messageId } : {}),
|
||||
_meta: {},
|
||||
}
|
||||
|
||||
if (info.error.name === "MessageAbortedError") {
|
||||
return {
|
||||
stopReason: "cancelled" as const,
|
||||
...base,
|
||||
}
|
||||
}
|
||||
|
||||
if (info.error.name === "MessageOutputLengthError") {
|
||||
return {
|
||||
stopReason: "max_tokens" as const,
|
||||
...base,
|
||||
}
|
||||
}
|
||||
|
||||
if (info.error.name === "ContentFilterError") {
|
||||
return {
|
||||
stopReason: "refusal" as const,
|
||||
...base,
|
||||
}
|
||||
}
|
||||
|
||||
if (info.error.name === "ProviderAuthError") {
|
||||
return yield* new ACPError.AuthRequiredError({ providerId: info.error.data.providerID })
|
||||
}
|
||||
|
||||
return yield* new ACPError.ServiceFailureError({
|
||||
service: "session",
|
||||
safeMessage: promptErrorMessage(info.error),
|
||||
errorName: info.error.name,
|
||||
})
|
||||
})
|
||||
|
||||
function promptErrorMessage(error: AssistantError) {
|
||||
if ("message" in error.data && typeof error.data.message === "string") return error.data.message
|
||||
return "OpenCode prompt failed"
|
||||
}
|
||||
|
||||
function sendUsageUpdate(
|
||||
|
||||
@@ -192,8 +192,11 @@ export function completedToolUpdate(input: {
|
||||
return {
|
||||
toolCallId: input.toolCallId,
|
||||
status: "completed",
|
||||
...(input.state.title ? { title: input.state.title } : {}),
|
||||
kind: toToolKind(input.toolName),
|
||||
title: toolTitle(input.toolName, input.state.input, input.state.title),
|
||||
locations: toLocations(input.toolName, input.state.input, input.cwd),
|
||||
content: completedToolContent(input.toolName, input.state),
|
||||
rawInput: rawInput(input.toolName, input.state.input, input.cwd),
|
||||
rawOutput: completedToolRawOutput(input.state),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
|
||||
import path from "path"
|
||||
import { InstanceState } from "@/effect/instance-state"
|
||||
import { EffectBridge } from "@/effect/bridge"
|
||||
import type { InstanceContext } from "@/project/instance-context"
|
||||
@@ -133,19 +132,12 @@ export const layer = Layer.effect(
|
||||
|
||||
for (const item of yield* skill.all()) {
|
||||
if (commands[item.name]) continue
|
||||
const dir = item.location === "<built-in>" ? undefined : path.dirname(item.location)
|
||||
commands[item.name] = {
|
||||
name: item.name,
|
||||
description: item.description,
|
||||
source: "skill",
|
||||
get template() {
|
||||
if (!dir) return item.content
|
||||
return [
|
||||
item.content,
|
||||
"",
|
||||
`Base directory for this skill: ${dir}`,
|
||||
"Relative paths in this skill (e.g., scripts/, references/) are relative to this base directory.",
|
||||
].join("\n")
|
||||
return item.content
|
||||
},
|
||||
hints: [],
|
||||
}
|
||||
|
||||
@@ -22,7 +22,7 @@ import { NamedError } from "@opencode-ai/core/util/error"
|
||||
import { InstallationVersion } from "@opencode-ai/core/installation/version"
|
||||
import { withTimeout } from "@/util/timeout"
|
||||
import { FSUtil } from "@opencode-ai/core/fs-util"
|
||||
import { McpOAuthPendingProvider, McpOAuthProvider, OAUTH_CALLBACK_PATH } from "./oauth-provider"
|
||||
import { McpOAuthProvider, OAUTH_CALLBACK_PATH } from "./oauth-provider"
|
||||
import { McpOAuthCallback } from "./oauth-callback"
|
||||
import { McpAuth } from "./auth"
|
||||
import { EventV2Bridge } from "@/event-v2-bridge"
|
||||
@@ -109,7 +109,7 @@ export type Status = Schema.Schema.Type<typeof Status>
|
||||
|
||||
// Store transports for OAuth servers to allow finishing auth
|
||||
type TransportWithAuth = StreamableHTTPClientTransport | SSEClientTransport
|
||||
const pendingOAuthTransports = new Map<string, { transport: TransportWithAuth; provider?: McpOAuthPendingProvider }>()
|
||||
const pendingOAuthTransports = new Map<string, TransportWithAuth>()
|
||||
|
||||
// Prompt cache types
|
||||
type PromptInfo = Awaited<ReturnType<MCPClient["listPrompts"]>>["prompts"][number]
|
||||
@@ -301,7 +301,7 @@ export const layer = Layer.effect(
|
||||
})
|
||||
.pipe(Effect.ignore, Effect.as(undefined))
|
||||
} else {
|
||||
pendingOAuthTransports.set(key, { transport })
|
||||
pendingOAuthTransports.set(key, transport)
|
||||
lastStatus = { status: "needs_auth" as const }
|
||||
return events
|
||||
.publish(TuiEvent.ToastShow, {
|
||||
@@ -819,7 +819,7 @@ export const layer = Layer.effect(
|
||||
.join("")
|
||||
yield* auth.updateOAuthState(mcpName, oauthState)
|
||||
let capturedUrl: URL | undefined
|
||||
const authProvider = new McpOAuthPendingProvider(
|
||||
const authProvider = new McpOAuthProvider(
|
||||
mcpName,
|
||||
mcpConfig.url,
|
||||
{
|
||||
@@ -845,16 +845,15 @@ export const layer = Layer.effect(
|
||||
return yield* Effect.tryPromise({
|
||||
try: () => {
|
||||
const client = createClient(directory)
|
||||
return client.connect(transport).then(async () => {
|
||||
await authProvider.commit()
|
||||
return { authorizationUrl: "", oauthState, client } satisfies AuthResult
|
||||
})
|
||||
return client
|
||||
.connect(transport)
|
||||
.then(() => ({ authorizationUrl: "", oauthState, client }) satisfies AuthResult)
|
||||
},
|
||||
catch: (error) => error,
|
||||
}).pipe(
|
||||
Effect.catch((error) => {
|
||||
if (error instanceof UnauthorizedError && capturedUrl) {
|
||||
pendingOAuthTransports.set(mcpName, { transport, provider: authProvider })
|
||||
pendingOAuthTransports.set(mcpName, transport)
|
||||
return Effect.succeed({ authorizationUrl: capturedUrl.toString(), oauthState } satisfies AuthResult)
|
||||
}
|
||||
return Effect.die(error)
|
||||
@@ -925,11 +924,11 @@ export const layer = Layer.effect(
|
||||
|
||||
const finishAuth = Effect.fn("MCP.finishAuth")(function* (mcpName: string, authorizationCode: string) {
|
||||
yield* requireMcpConfig(mcpName)
|
||||
const pending = pendingOAuthTransports.get(mcpName)
|
||||
if (!pending) throw new Error(`No pending OAuth flow for MCP server: ${mcpName}`)
|
||||
const transport = pendingOAuthTransports.get(mcpName)
|
||||
if (!transport) throw new Error(`No pending OAuth flow for MCP server: ${mcpName}`)
|
||||
|
||||
const result = yield* Effect.tryPromise({
|
||||
try: () => pending.transport.finishAuth(authorizationCode).then(() => true as const),
|
||||
try: () => transport.finishAuth(authorizationCode).then(() => true as const),
|
||||
catch: (error) => {
|
||||
return error
|
||||
},
|
||||
@@ -939,7 +938,6 @@ export const layer = Layer.effect(
|
||||
return { status: "failed", error: "OAuth completion failed" } satisfies Status
|
||||
}
|
||||
|
||||
yield* Effect.promise(() => pending.provider?.commit() ?? Promise.resolve())
|
||||
yield* auth.clearCodeVerifier(mcpName)
|
||||
pendingOAuthTransports.delete(mcpName)
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { createConnection } from "net"
|
||||
import { createServer } from "http"
|
||||
import { OauthCallbackPage } from "@opencode-ai/core/oauth/page"
|
||||
import { escapeHtml } from "@/util/html"
|
||||
import { OAUTH_CALLBACK_PORT, OAUTH_CALLBACK_PATH, parseRedirectUri } from "./oauth-provider"
|
||||
|
||||
const OAUTH_CALLBACK_HOST = "127.0.0.1"
|
||||
@@ -9,6 +9,47 @@ const OAUTH_CALLBACK_HOST = "127.0.0.1"
|
||||
let currentPort = OAUTH_CALLBACK_PORT
|
||||
let currentPath = OAUTH_CALLBACK_PATH
|
||||
|
||||
const HTML_SUCCESS = `<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<title>OpenCode - Authorization Successful</title>
|
||||
<style>
|
||||
body { font-family: system-ui, -apple-system, sans-serif; display: flex; justify-content: center; align-items: center; height: 100vh; margin: 0; background: #1a1a2e; color: #eee; }
|
||||
.container { text-align: center; padding: 2rem; }
|
||||
h1 { color: #4ade80; margin-bottom: 1rem; }
|
||||
p { color: #aaa; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<h1>Authorization Successful</h1>
|
||||
<p>You can close this window and return to OpenCode.</p>
|
||||
</div>
|
||||
<script>setTimeout(() => window.close(), 2000);</script>
|
||||
</body>
|
||||
</html>`
|
||||
|
||||
const HTML_ERROR = (error: string) => `<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<title>OpenCode - Authorization Failed</title>
|
||||
<style>
|
||||
body { font-family: system-ui, -apple-system, sans-serif; display: flex; justify-content: center; align-items: center; height: 100vh; margin: 0; background: #1a1a2e; color: #eee; }
|
||||
.container { text-align: center; padding: 2rem; }
|
||||
h1 { color: #f87171; margin-bottom: 1rem; }
|
||||
p { color: #aaa; }
|
||||
.error { color: #fca5a5; font-family: monospace; margin-top: 1rem; padding: 1rem; background: rgba(248,113,113,0.1); border-radius: 0.5rem; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<h1>Authorization Failed</h1>
|
||||
<p>An error occurred during authorization.</p>
|
||||
<div class="error">${escapeHtml(error)}</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>`
|
||||
|
||||
interface PendingAuth {
|
||||
resolve: (code: string) => void
|
||||
reject: (error: Error) => void
|
||||
@@ -57,7 +98,7 @@ function handleRequest(req: import("http").IncomingMessage, res: import("http").
|
||||
if (!state) {
|
||||
const errorMsg = "Missing required state parameter - potential CSRF attack"
|
||||
res.writeHead(400, { "Content-Type": "text/html; charset=utf-8" })
|
||||
res.end(OauthCallbackPage.error(errorMsg, { provider: "MCP" }))
|
||||
res.end(HTML_ERROR(errorMsg))
|
||||
return
|
||||
}
|
||||
|
||||
@@ -71,14 +112,14 @@ function handleRequest(req: import("http").IncomingMessage, res: import("http").
|
||||
pending.reject(new Error(errorMsg))
|
||||
}
|
||||
res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" })
|
||||
res.end(OauthCallbackPage.error(errorMsg, { provider: "MCP" }))
|
||||
res.end(HTML_ERROR(errorMsg))
|
||||
stopIfIdle()
|
||||
return
|
||||
}
|
||||
|
||||
if (!code) {
|
||||
res.writeHead(400, { "Content-Type": "text/html; charset=utf-8" })
|
||||
res.end(OauthCallbackPage.error("No authorization code provided", { provider: "MCP" }))
|
||||
res.end(HTML_ERROR("No authorization code provided"))
|
||||
return
|
||||
}
|
||||
|
||||
@@ -86,7 +127,7 @@ function handleRequest(req: import("http").IncomingMessage, res: import("http").
|
||||
if (!pendingAuths.has(state)) {
|
||||
const errorMsg = "Invalid or expired state parameter - potential CSRF attack"
|
||||
res.writeHead(400, { "Content-Type": "text/html; charset=utf-8" })
|
||||
res.end(OauthCallbackPage.error(errorMsg, { provider: "MCP" }))
|
||||
res.end(HTML_ERROR(errorMsg))
|
||||
return
|
||||
}
|
||||
|
||||
@@ -98,7 +139,7 @@ function handleRequest(req: import("http").IncomingMessage, res: import("http").
|
||||
pending.resolve(code)
|
||||
|
||||
res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" })
|
||||
res.end(OauthCallbackPage.success({ provider: "MCP" }))
|
||||
res.end(HTML_SUCCESS)
|
||||
stopIfIdle()
|
||||
}
|
||||
|
||||
|
||||
@@ -25,11 +25,11 @@ export interface McpOAuthCallbacks {
|
||||
|
||||
export class McpOAuthProvider implements OAuthClientProvider {
|
||||
constructor(
|
||||
protected mcpName: string,
|
||||
protected serverUrl: string,
|
||||
protected config: McpOAuthConfig,
|
||||
private mcpName: string,
|
||||
private serverUrl: string,
|
||||
private config: McpOAuthConfig,
|
||||
private callbacks: McpOAuthCallbacks,
|
||||
protected auth: McpAuth.Interface,
|
||||
private auth: McpAuth.Interface,
|
||||
) {}
|
||||
|
||||
get redirectUrl(): string {
|
||||
@@ -53,6 +53,7 @@ export class McpOAuthProvider implements OAuthClientProvider {
|
||||
}
|
||||
|
||||
async clientInformation(): Promise<OAuthClientInformation | undefined> {
|
||||
// Check config first (pre-registered client)
|
||||
if (this.config.clientId) {
|
||||
return {
|
||||
client_id: this.config.clientId,
|
||||
@@ -163,7 +164,10 @@ export class McpOAuthProvider implements OAuthClientProvider {
|
||||
|
||||
async invalidateCredentials(type: "all" | "client" | "tokens"): Promise<void> {
|
||||
const entry = await Effect.runPromise(this.auth.get(this.mcpName))
|
||||
if (!entry) return
|
||||
if (!entry) {
|
||||
return
|
||||
}
|
||||
|
||||
switch (type) {
|
||||
case "all":
|
||||
await Effect.runPromise(this.auth.remove(this.mcpName))
|
||||
@@ -180,63 +184,6 @@ export class McpOAuthProvider implements OAuthClientProvider {
|
||||
}
|
||||
}
|
||||
|
||||
export class McpOAuthPendingProvider extends McpOAuthProvider {
|
||||
private pendingClientInfo?: OAuthClientInformationFull
|
||||
private pendingTokens?: OAuthTokens
|
||||
|
||||
override async clientInformation(): Promise<OAuthClientInformation | undefined> {
|
||||
if (!this.config.clientId) return this.pendingClientInfo
|
||||
return {
|
||||
client_id: this.config.clientId,
|
||||
client_secret: this.config.clientSecret,
|
||||
}
|
||||
}
|
||||
|
||||
override async saveClientInformation(info: OAuthClientInformationFull): Promise<void> {
|
||||
this.pendingClientInfo = info
|
||||
}
|
||||
|
||||
override async tokens(): Promise<OAuthTokens | undefined> {
|
||||
return this.pendingTokens
|
||||
}
|
||||
|
||||
override async saveTokens(tokens: OAuthTokens): Promise<void> {
|
||||
this.pendingTokens = tokens
|
||||
}
|
||||
|
||||
override async invalidateCredentials(type: "all" | "client" | "tokens"): Promise<void> {
|
||||
if (type === "all" || type === "client") this.pendingClientInfo = undefined
|
||||
if (type === "all" || type === "tokens") this.pendingTokens = undefined
|
||||
}
|
||||
|
||||
async commit(): Promise<void> {
|
||||
if (!this.pendingTokens) return
|
||||
await Effect.runPromise(
|
||||
this.auth.set(
|
||||
this.mcpName,
|
||||
{
|
||||
tokens: {
|
||||
accessToken: this.pendingTokens.access_token,
|
||||
refreshToken: this.pendingTokens.refresh_token,
|
||||
expiresAt: this.pendingTokens.expires_in ? Date.now() / 1000 + this.pendingTokens.expires_in : undefined,
|
||||
scope: this.pendingTokens.scope,
|
||||
},
|
||||
clientInfo:
|
||||
this.pendingClientInfo && !this.config.clientId
|
||||
? {
|
||||
clientId: this.pendingClientInfo.client_id,
|
||||
clientSecret: this.pendingClientInfo.client_secret,
|
||||
clientIdIssuedAt: this.pendingClientInfo.client_id_issued_at,
|
||||
clientSecretExpiresAt: this.pendingClientInfo.client_secret_expires_at,
|
||||
}
|
||||
: undefined,
|
||||
},
|
||||
this.serverUrl,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
export { OAUTH_CALLBACK_PORT, OAUTH_CALLBACK_PATH }
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import type { Hooks, PluginInput } from "@opencode-ai/plugin"
|
||||
import type { Model } from "@opencode-ai/sdk/v2"
|
||||
import { InstallationVersion } from "@opencode-ai/core/installation/version"
|
||||
import { OauthCallbackPage } from "@opencode-ai/core/oauth/page"
|
||||
import { createServer } from "http"
|
||||
import open from "open"
|
||||
|
||||
@@ -59,6 +58,65 @@ function buildAuthorizeUrl(state: string): string {
|
||||
return `${DO_AUTHORIZE_URL}?${params.toString()}`
|
||||
}
|
||||
|
||||
const HTML_CALLBACK = `<!doctype html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<title>OpenCode - DigitalOcean Authorization</title>
|
||||
<style>
|
||||
body { font-family: system-ui, -apple-system, sans-serif; display: flex; justify-content: center; align-items: center; height: 100vh; margin: 0; background: #0b1220; color: #e8eef9; }
|
||||
.container { text-align: center; padding: 2rem; max-width: 32rem; }
|
||||
h1 { color: #e8eef9; margin-bottom: 1rem; }
|
||||
p { color: #9aa9c0; }
|
||||
.error { color: #ff917b; font-family: monospace; margin-top: 1rem; padding: 1rem; background: #3c140d; border-radius: 0.5rem; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<h1 id="title">Finishing sign-in...</h1>
|
||||
<p id="msg">You can close this window once it says you're signed in.</p>
|
||||
</div>
|
||||
<script>
|
||||
(async function() {
|
||||
const params = new URLSearchParams((window.location.hash || "").slice(1))
|
||||
const search = new URLSearchParams(window.location.search)
|
||||
const error = params.get("error") || search.get("error")
|
||||
const errorDescription = params.get("error_description") || search.get("error_description")
|
||||
const titleEl = document.getElementById("title")
|
||||
const msgEl = document.getElementById("msg")
|
||||
const tokenUrl = new URL(${JSON.stringify(OAUTH_TOKEN_PATH)}, window.location.origin).href
|
||||
try {
|
||||
const body = error
|
||||
? { error, error_description: errorDescription || "" }
|
||||
: { access_token: params.get("access_token") || "", expires_in: params.get("expires_in") || "0", state: params.get("state") || "" }
|
||||
const res = await fetch(tokenUrl, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
})
|
||||
if (!res.ok) {
|
||||
const detail = await res.text().catch(function () { return "" })
|
||||
throw new Error(detail || ("callback failed (" + res.status + ")"))
|
||||
}
|
||||
if (error) {
|
||||
titleEl.textContent = "Authorization Failed"
|
||||
msgEl.textContent = errorDescription || error
|
||||
msgEl.className = "error"
|
||||
return
|
||||
}
|
||||
titleEl.textContent = "Authorization Successful"
|
||||
msgEl.textContent = "You can close this window and return to OpenCode."
|
||||
setTimeout(function () { window.close() }, 2000)
|
||||
} catch (e) {
|
||||
titleEl.textContent = "Authorization Failed"
|
||||
msgEl.textContent = String(e && e.message ? e.message : e)
|
||||
msgEl.className = "error"
|
||||
}
|
||||
})()
|
||||
</script>
|
||||
</body>
|
||||
</html>`
|
||||
|
||||
async function startOAuthServer(): Promise<void> {
|
||||
if (oauthServer) return
|
||||
oauthServer = createServer((req, res) => {
|
||||
@@ -66,7 +124,7 @@ async function startOAuthServer(): Promise<void> {
|
||||
|
||||
if (req.method === "GET" && url.pathname === OAUTH_REDIRECT_PATH) {
|
||||
res.writeHead(200, { "Content-Type": "text/html" })
|
||||
res.end(OauthCallbackPage.bootstrap({ tokenPath: OAUTH_TOKEN_PATH, provider: "DigitalOcean" }))
|
||||
res.end(HTML_CALLBACK)
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@ import os from "os"
|
||||
import { setTimeout as sleep } from "node:timers/promises"
|
||||
import { createServer } from "http"
|
||||
import { OpenAIWebSocketPool } from "./ws-pool"
|
||||
import { OauthCallbackPage } from "@opencode-ai/core/oauth/page"
|
||||
import { escapeHtml } from "@/util/html"
|
||||
|
||||
const CLIENT_ID = "app_EMoamEEZ73f0CkXaXp7hrann"
|
||||
const ISSUER = "https://auth.openai.com"
|
||||
@@ -138,8 +138,95 @@ async function refreshAccessToken(refreshToken: string, issuer = ISSUER): Promis
|
||||
return response.json()
|
||||
}
|
||||
|
||||
// Kept as a named export for plugin.codex tests; delegates to the shared branded page.
|
||||
export const renderOAuthError = (error: string) => OauthCallbackPage.error(error, { provider: "ChatGPT" })
|
||||
const HTML_SUCCESS = `<!doctype html>
|
||||
<html>
|
||||
<head>
|
||||
<title>OpenCode - Codex Authorization Successful</title>
|
||||
<style>
|
||||
body {
|
||||
font-family:
|
||||
system-ui,
|
||||
-apple-system,
|
||||
sans-serif;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
height: 100vh;
|
||||
margin: 0;
|
||||
background: #131010;
|
||||
color: #f1ecec;
|
||||
}
|
||||
.container {
|
||||
text-align: center;
|
||||
padding: 2rem;
|
||||
}
|
||||
h1 {
|
||||
color: #f1ecec;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
p {
|
||||
color: #b7b1b1;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<h1>Authorization Successful</h1>
|
||||
<p>You can close this window and return to OpenCode.</p>
|
||||
</div>
|
||||
<script>
|
||||
setTimeout(() => window.close(), 2000)
|
||||
</script>
|
||||
</body>
|
||||
</html>`
|
||||
|
||||
export const renderOAuthError = (error: string) => `<!doctype html>
|
||||
<html>
|
||||
<head>
|
||||
<title>OpenCode - Codex Authorization Failed</title>
|
||||
<style>
|
||||
body {
|
||||
font-family:
|
||||
system-ui,
|
||||
-apple-system,
|
||||
sans-serif;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
height: 100vh;
|
||||
margin: 0;
|
||||
background: #131010;
|
||||
color: #f1ecec;
|
||||
}
|
||||
.container {
|
||||
text-align: center;
|
||||
padding: 2rem;
|
||||
}
|
||||
h1 {
|
||||
color: #fc533a;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
p {
|
||||
color: #b7b1b1;
|
||||
}
|
||||
.error {
|
||||
color: #ff917b;
|
||||
font-family: monospace;
|
||||
margin-top: 1rem;
|
||||
padding: 1rem;
|
||||
background: #3c140d;
|
||||
border-radius: 0.5rem;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<h1>Authorization Failed</h1>
|
||||
<p>An error occurred during authorization.</p>
|
||||
<div class="error">${escapeHtml(error)}</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>`
|
||||
|
||||
interface PendingOAuth {
|
||||
pkce: PkceCodes
|
||||
@@ -200,7 +287,7 @@ async function startOAuthServer(): Promise<{ port: number; redirectUri: string }
|
||||
.catch((err) => current.reject(err))
|
||||
|
||||
res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" })
|
||||
res.end(OauthCallbackPage.success({ provider: "ChatGPT" }))
|
||||
res.end(HTML_SUCCESS)
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import type { Hooks, PluginInput } from "@opencode-ai/plugin"
|
||||
import { OAUTH_DUMMY_KEY } from "../auth"
|
||||
import { InstallationVersion } from "@opencode-ai/core/installation/version"
|
||||
import { OauthCallbackPage } from "@opencode-ai/core/oauth/page"
|
||||
import { createServer } from "http"
|
||||
import open from "open"
|
||||
|
||||
@@ -157,6 +156,29 @@ async function refreshAccessToken(account: string, refreshToken: string) {
|
||||
return token
|
||||
}
|
||||
|
||||
const HTML_SUCCESS = `<!doctype html>
|
||||
<html>
|
||||
<head><title>OpenCode - Snowflake Authorization Successful</title></head>
|
||||
<body style="font-family: system-ui; display:flex; align-items:center; justify-content:center; height:100vh; margin:0; background:#111; color:#eee;">
|
||||
<div style="text-align:center; max-width:36rem; padding:2rem;">
|
||||
<h1 style="color:#7ee787;">Authorization Successful</h1>
|
||||
<p>You can close this window and return to OpenCode.</p>
|
||||
</div>
|
||||
<script>setTimeout(() => window.close(), 1500)</script>
|
||||
</body>
|
||||
</html>`
|
||||
|
||||
const htmlError = (message: string) => `<!doctype html>
|
||||
<html>
|
||||
<head><title>OpenCode - Snowflake Authorization Failed</title></head>
|
||||
<body style="font-family: system-ui; display:flex; align-items:center; justify-content:center; height:100vh; margin:0; background:#111; color:#eee;">
|
||||
<div style="text-align:center; max-width:48rem; padding:2rem;">
|
||||
<h1 style="color:#ff7b72;">Authorization Failed</h1>
|
||||
<pre style="white-space:pre-wrap; color:#ffb3ad; background:#2a1210; padding:1rem; border-radius:.5rem;">${message}</pre>
|
||||
</div>
|
||||
</body>
|
||||
</html>`
|
||||
|
||||
async function startOAuthServer() {
|
||||
if (oauthServer) return
|
||||
|
||||
@@ -181,7 +203,7 @@ async function startOAuthServer() {
|
||||
pendingOAuth?.reject(new Error(message))
|
||||
pendingOAuth = undefined
|
||||
res.writeHead(400, { "Content-Type": "text/html" })
|
||||
res.end(OauthCallbackPage.error(message, { provider: "Snowflake" }))
|
||||
res.end(htmlError(message))
|
||||
return
|
||||
}
|
||||
|
||||
@@ -192,7 +214,7 @@ async function startOAuthServer() {
|
||||
const message = errorDescription || error
|
||||
current.reject(new Error(message))
|
||||
res.writeHead(200, { "Content-Type": "text/html" })
|
||||
res.end(OauthCallbackPage.error(message, { provider: "Snowflake" }))
|
||||
res.end(htmlError(message))
|
||||
return
|
||||
}
|
||||
|
||||
@@ -200,7 +222,7 @@ async function startOAuthServer() {
|
||||
const message = "Missing authorization code"
|
||||
current.reject(new Error(message))
|
||||
res.writeHead(400, { "Content-Type": "text/html" })
|
||||
res.end(OauthCallbackPage.error(message, { provider: "Snowflake" }))
|
||||
res.end(htmlError(message))
|
||||
return
|
||||
}
|
||||
|
||||
@@ -209,7 +231,7 @@ async function startOAuthServer() {
|
||||
.catch((err) => current.reject(err instanceof Error ? err : new Error(String(err))))
|
||||
|
||||
res.writeHead(200, { "Content-Type": "text/html" })
|
||||
res.end(OauthCallbackPage.success({ provider: "Snowflake" }))
|
||||
res.end(HTML_SUCCESS)
|
||||
})
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
|
||||
@@ -2,7 +2,7 @@ import type { Hooks, PluginInput } from "@opencode-ai/plugin"
|
||||
import { OAUTH_DUMMY_KEY } from "../auth"
|
||||
import { createServer } from "http"
|
||||
import { InstallationVersion } from "@opencode-ai/core/installation/version"
|
||||
import { OauthCallbackPage } from "@opencode-ai/core/oauth/page"
|
||||
import { escapeHtml } from "@/util/html"
|
||||
|
||||
// Public Grok-CLI OAuth client. xAI's auth server rejects loopback OAuth from
|
||||
// non-allowlisted clients, so we reuse the Grok-CLI client_id that xAI ships
|
||||
@@ -285,6 +285,96 @@ export async function pollDeviceCodeToken(
|
||||
throw new Error("xAI device authorization timed out")
|
||||
}
|
||||
|
||||
const HTML_SUCCESS = `<!doctype html>
|
||||
<html>
|
||||
<head>
|
||||
<title>OpenCode - xAI Authorization Successful</title>
|
||||
<style>
|
||||
body {
|
||||
font-family:
|
||||
system-ui,
|
||||
-apple-system,
|
||||
sans-serif;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
height: 100vh;
|
||||
margin: 0;
|
||||
background: #131010;
|
||||
color: #f1ecec;
|
||||
}
|
||||
.container {
|
||||
text-align: center;
|
||||
padding: 2rem;
|
||||
}
|
||||
h1 {
|
||||
color: #f1ecec;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
p {
|
||||
color: #b7b1b1;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<h1>Authorization Successful</h1>
|
||||
<p>You can close this window and return to OpenCode.</p>
|
||||
</div>
|
||||
<script>
|
||||
setTimeout(() => window.close(), 2000)
|
||||
</script>
|
||||
</body>
|
||||
</html>`
|
||||
|
||||
const HTML_ERROR = (error: string) => `<!doctype html>
|
||||
<html>
|
||||
<head>
|
||||
<title>OpenCode - xAI Authorization Failed</title>
|
||||
<style>
|
||||
body {
|
||||
font-family:
|
||||
system-ui,
|
||||
-apple-system,
|
||||
sans-serif;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
height: 100vh;
|
||||
margin: 0;
|
||||
background: #131010;
|
||||
color: #f1ecec;
|
||||
}
|
||||
.container {
|
||||
text-align: center;
|
||||
padding: 2rem;
|
||||
}
|
||||
h1 {
|
||||
color: #fc533a;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
p {
|
||||
color: #b7b1b1;
|
||||
}
|
||||
.error {
|
||||
color: #ff917b;
|
||||
font-family: monospace;
|
||||
margin-top: 1rem;
|
||||
padding: 1rem;
|
||||
background: #3c140d;
|
||||
border-radius: 0.5rem;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<h1>Authorization Failed</h1>
|
||||
<p>An error occurred during authorization.</p>
|
||||
<div class="error">${escapeHtml(error)}</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>`
|
||||
|
||||
// CORS allowlist for the loopback callback. The redirect_uri itself is
|
||||
// already bound to 127.0.0.1 and gated by PKCE+state, so we only accept
|
||||
// xAI's own auth origins for additional defense-in-depth on the OPTIONS
|
||||
@@ -335,7 +425,7 @@ async function startOAuthServer(): Promise<{ port: number; redirectUri: string }
|
||||
pendingOAuth?.reject(new Error(errorMsg))
|
||||
pendingOAuth = undefined
|
||||
res.writeHead(200, { "Content-Type": "text/html" })
|
||||
res.end(OauthCallbackPage.error(errorMsg, { provider: "xAI" }))
|
||||
res.end(HTML_ERROR(errorMsg))
|
||||
return
|
||||
}
|
||||
|
||||
@@ -344,7 +434,7 @@ async function startOAuthServer(): Promise<{ port: number; redirectUri: string }
|
||||
pendingOAuth?.reject(new Error(errorMsg))
|
||||
pendingOAuth = undefined
|
||||
res.writeHead(400, { "Content-Type": "text/html" })
|
||||
res.end(OauthCallbackPage.error(errorMsg, { provider: "xAI" }))
|
||||
res.end(HTML_ERROR(errorMsg))
|
||||
return
|
||||
}
|
||||
|
||||
@@ -353,7 +443,7 @@ async function startOAuthServer(): Promise<{ port: number; redirectUri: string }
|
||||
pendingOAuth?.reject(new Error(errorMsg))
|
||||
pendingOAuth = undefined
|
||||
res.writeHead(400, { "Content-Type": "text/html" })
|
||||
res.end(OauthCallbackPage.error(errorMsg, { provider: "xAI" }))
|
||||
res.end(HTML_ERROR(errorMsg))
|
||||
return
|
||||
}
|
||||
|
||||
@@ -365,7 +455,7 @@ async function startOAuthServer(): Promise<{ port: number; redirectUri: string }
|
||||
.catch((err) => current.reject(err))
|
||||
|
||||
res.writeHead(200, { "Content-Type": "text/html" })
|
||||
res.end(OauthCallbackPage.success({ provider: "xAI" }))
|
||||
res.end(HTML_SUCCESS)
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
@@ -13,11 +13,14 @@ import { Config } from "@/config/config"
|
||||
import { NotFoundError } from "@/storage/storage"
|
||||
|
||||
import { Effect, Layer, Context } from "effect"
|
||||
import * as DateTime from "effect/DateTime"
|
||||
import { InstanceState } from "@/effect/instance-state"
|
||||
import { isOverflow as overflow, usable } from "./overflow"
|
||||
import { serviceUse } from "@opencode-ai/core/effect/service-use"
|
||||
import { RuntimeFlags } from "@/effect/runtime-flags"
|
||||
import { EventV2Bridge } from "@/event-v2-bridge"
|
||||
import { SessionEvent } from "@opencode-ai/core/session/event"
|
||||
import { SessionMessage } from "@opencode-ai/core/session/message"
|
||||
import { ProviderV2 } from "@opencode-ai/core/provider"
|
||||
import { ModelV2 } from "@opencode-ai/core/model"
|
||||
import { buildPrompt } from "@opencode-ai/core/session/compaction"
|
||||
@@ -352,6 +355,18 @@ export const layer = Layer.effect(
|
||||
stripMedia: true,
|
||||
toolOutputMaxChars: TOOL_OUTPUT_MAX_CHARS,
|
||||
})
|
||||
const tailIndex = selected.tail_start_id
|
||||
? history.findIndex((message) => message.info.id === selected.tail_start_id)
|
||||
: -1
|
||||
const recent =
|
||||
tailIndex < 0
|
||||
? ""
|
||||
: JSON.stringify(
|
||||
yield* MessageV2.toModelMessagesEffect(history.slice(tailIndex), model, {
|
||||
stripMedia: true,
|
||||
toolOutputMaxChars: TOOL_OUTPUT_MAX_CHARS,
|
||||
}),
|
||||
)
|
||||
const ctx = yield* InstanceState.context
|
||||
const msg: SessionV1.Assistant = {
|
||||
id: MessageID.ascending(),
|
||||
@@ -505,6 +520,25 @@ export const layer = Layer.effect(
|
||||
|
||||
if (processor.message.error) return "stop"
|
||||
if (result === "continue") {
|
||||
const summary = summaryText(
|
||||
(yield* session.messages({ sessionID: input.sessionID }).pipe(Effect.orDie)).find(
|
||||
(item) => item.info.id === msg.id,
|
||||
) ?? {
|
||||
info: msg,
|
||||
parts: [],
|
||||
},
|
||||
)
|
||||
if (flags.experimentalEventSystem) {
|
||||
if (summary)
|
||||
yield* events.publish(SessionEvent.Compaction.Ended, {
|
||||
sessionID: input.sessionID,
|
||||
messageID: SessionMessage.ID.make(input.parentID),
|
||||
timestamp: DateTime.makeUnsafe(Date.now()),
|
||||
reason: input.auto ? "auto" : "manual",
|
||||
text: summary ?? "",
|
||||
recent,
|
||||
})
|
||||
}
|
||||
yield* events.publish(Event.Compacted, { sessionID: input.sessionID })
|
||||
}
|
||||
return result
|
||||
@@ -533,6 +567,14 @@ export const layer = Layer.effect(
|
||||
auto: input.auto,
|
||||
overflow: input.overflow,
|
||||
})
|
||||
if (flags.experimentalEventSystem) {
|
||||
yield* events.publish(SessionEvent.Compaction.Started, {
|
||||
sessionID: input.sessionID,
|
||||
messageID: SessionMessage.ID.make(msg.id),
|
||||
timestamp: DateTime.makeUnsafe(Date.now()),
|
||||
reason: input.auto ? "auto" : "manual",
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
return Service.of({
|
||||
|
||||
@@ -24,7 +24,13 @@ import { errorMessage } from "@/util/error"
|
||||
import { isRecord } from "@/util/record"
|
||||
import { EventV2Bridge } from "@/event-v2-bridge"
|
||||
import { Database } from "@opencode-ai/core/database/database"
|
||||
import { Usage, type LLMEvent } from "@opencode-ai/llm"
|
||||
import { SessionEvent } from "@opencode-ai/core/session/event"
|
||||
import { SessionMessage } from "@opencode-ai/core/session/message"
|
||||
import { ModelV2 } from "@opencode-ai/core/model"
|
||||
import { ProviderV2 } from "@opencode-ai/core/provider"
|
||||
import * as DateTime from "effect/DateTime"
|
||||
import { RuntimeFlags } from "@/effect/runtime-flags"
|
||||
import { ToolOutput, Usage, type LLMEvent } from "@opencode-ai/llm"
|
||||
|
||||
const DOOM_LOOP_THRESHOLD = 3
|
||||
export type Result = "compact" | "stop" | "continue"
|
||||
@@ -58,10 +64,13 @@ export interface Interface {
|
||||
}
|
||||
|
||||
type ToolCall = {
|
||||
assistantMessageID?: SessionMessage.ID
|
||||
partID: SessionV1.ToolPart["id"]
|
||||
messageID: SessionV1.ToolPart["messageID"]
|
||||
sessionID: SessionV1.ToolPart["sessionID"]
|
||||
done: Deferred.Deferred<void>
|
||||
inputEnded: boolean
|
||||
raw: string
|
||||
}
|
||||
|
||||
interface ProcessorContext extends Input {
|
||||
@@ -71,7 +80,9 @@ interface ProcessorContext extends Input {
|
||||
blocked: boolean
|
||||
needsCompaction: boolean
|
||||
currentText: SessionV1.TextPart | undefined
|
||||
currentTextID: string | undefined
|
||||
reasoningMap: Record<string, SessionV1.ReasoningPart>
|
||||
v2AssistantMessageID: SessionMessage.ID | undefined
|
||||
}
|
||||
|
||||
type StreamEvent = LLMEvent
|
||||
@@ -93,6 +104,7 @@ export const layer = Layer.effect(
|
||||
const status = yield* SessionStatus.Service
|
||||
const image = yield* Image.Service
|
||||
const events = yield* EventV2Bridge.Service
|
||||
const flags = yield* RuntimeFlags.Service
|
||||
const database = yield* Database.Service
|
||||
|
||||
const create = Effect.fn("SessionProcessor.create")(function* (input: Input) {
|
||||
@@ -110,8 +122,11 @@ export const layer = Layer.effect(
|
||||
blocked: false,
|
||||
needsCompaction: false,
|
||||
currentText: undefined,
|
||||
currentTextID: undefined,
|
||||
reasoningMap: {},
|
||||
v2AssistantMessageID: undefined,
|
||||
}
|
||||
const mirrorAssistant = flags.experimentalEventSystem && !input.assistantMessage.summary
|
||||
let aborted = false
|
||||
|
||||
const parse = (e: unknown) =>
|
||||
@@ -126,6 +141,34 @@ export const layer = Layer.effect(
|
||||
if (done) yield* Deferred.succeed(done, undefined).pipe(Effect.ignore)
|
||||
})
|
||||
|
||||
const ensureV2AssistantMessage = Effect.fn("SessionProcessor.ensureV2AssistantMessage")(function* () {
|
||||
if (ctx.v2AssistantMessageID) return ctx.v2AssistantMessageID
|
||||
ctx.v2AssistantMessageID = SessionMessage.ID.create()
|
||||
yield* events.publish(SessionEvent.Step.Started, {
|
||||
sessionID: ctx.sessionID,
|
||||
assistantMessageID: ctx.v2AssistantMessageID,
|
||||
agent: input.assistantMessage.agent,
|
||||
model: {
|
||||
id: ModelV2.ID.make(ctx.model.id),
|
||||
providerID: ProviderV2.ID.make(ctx.model.providerID),
|
||||
variant: ModelV2.VariantID.make(input.assistantMessage.variant ?? "default"),
|
||||
},
|
||||
snapshot: ctx.snapshot,
|
||||
timestamp: DateTime.makeUnsafe(Date.now()),
|
||||
})
|
||||
return ctx.v2AssistantMessageID
|
||||
})
|
||||
|
||||
const requireV2AssistantMessage = (toolCall?: ToolCall) =>
|
||||
toolCall?.assistantMessageID === undefined
|
||||
? Effect.die("V2 tool settlement has no owning assistant message")
|
||||
: Effect.succeed(toolCall.assistantMessageID)
|
||||
|
||||
const currentV2AssistantMessage = () =>
|
||||
ctx.v2AssistantMessageID === undefined
|
||||
? Effect.die("V2 step settlement has no owning assistant message")
|
||||
: Effect.succeed(ctx.v2AssistantMessageID)
|
||||
|
||||
const readToolCall = Effect.fn("SessionProcessor.readToolCall")(function* (toolCallID: string) {
|
||||
const call = ctx.toolcalls[toolCallID]
|
||||
if (!call) return undefined
|
||||
@@ -204,6 +247,17 @@ export const layer = Layer.effect(
|
||||
|
||||
const finishReasoning = Effect.fn("SessionProcessor.finishReasoning")(function* (reasoningID: string) {
|
||||
if (!(reasoningID in ctx.reasoningMap)) return
|
||||
// TODO(v2): Temporary dual-write while migrating session messages to v2 events.
|
||||
if (mirrorAssistant) {
|
||||
yield* events.publish(SessionEvent.Reasoning.Ended, {
|
||||
sessionID: ctx.sessionID,
|
||||
assistantMessageID: yield* currentV2AssistantMessage(),
|
||||
reasoningID,
|
||||
text: ctx.reasoningMap[reasoningID].text,
|
||||
providerMetadata: ctx.reasoningMap[reasoningID].metadata,
|
||||
timestamp: DateTime.makeUnsafe(Date.now()),
|
||||
})
|
||||
}
|
||||
// oxlint-disable-next-line no-self-assign -- reactivity trigger
|
||||
ctx.reasoningMap[reasoningID].text = ctx.reasoningMap[reasoningID].text
|
||||
ctx.reasoningMap[reasoningID].time = { ...ctx.reasoningMap[reasoningID].time, end: Date.now() }
|
||||
@@ -211,6 +265,33 @@ export const layer = Layer.effect(
|
||||
delete ctx.reasoningMap[reasoningID]
|
||||
})
|
||||
|
||||
const flushV2Fragments = Effect.fn("SessionProcessor.flushV2Fragments")(function* () {
|
||||
if (!mirrorAssistant) return
|
||||
if (!ctx.assistantMessage.summary && ctx.currentText && ctx.currentTextID) {
|
||||
yield* events.publish(SessionEvent.Text.Ended, {
|
||||
sessionID: ctx.sessionID,
|
||||
assistantMessageID: yield* currentV2AssistantMessage(),
|
||||
textID: ctx.currentTextID,
|
||||
text: ctx.currentText.text,
|
||||
timestamp: DateTime.makeUnsafe(Date.now()),
|
||||
})
|
||||
}
|
||||
yield* Effect.forEach(Object.entries(ctx.reasoningMap), ([reasoningID, part]) =>
|
||||
currentV2AssistantMessage().pipe(
|
||||
Effect.flatMap((assistantMessageID) =>
|
||||
events.publish(SessionEvent.Reasoning.Ended, {
|
||||
sessionID: ctx.sessionID,
|
||||
assistantMessageID,
|
||||
reasoningID,
|
||||
text: part.text,
|
||||
providerMetadata: part.metadata,
|
||||
timestamp: DateTime.makeUnsafe(Date.now()),
|
||||
}),
|
||||
),
|
||||
),
|
||||
)
|
||||
})
|
||||
|
||||
const ensureToolCall = Effect.fn("SessionProcessor.ensureToolCall")(function* (input: {
|
||||
id: string
|
||||
name: string
|
||||
@@ -231,6 +312,17 @@ export const layer = Layer.effect(
|
||||
}
|
||||
return { call: ctx.toolcalls[input.id], part }
|
||||
}
|
||||
// TODO(v2): Temporary dual-write while migrating session messages to v2 events.
|
||||
const assistantMessageID = mirrorAssistant ? yield* ensureV2AssistantMessage() : undefined
|
||||
if (assistantMessageID) {
|
||||
yield* events.publish(SessionEvent.Tool.Input.Started, {
|
||||
sessionID: ctx.sessionID,
|
||||
assistantMessageID,
|
||||
callID: input.id,
|
||||
name: input.name,
|
||||
timestamp: DateTime.makeUnsafe(Date.now()),
|
||||
})
|
||||
}
|
||||
const part = yield* session.updatePart({
|
||||
id: PartID.ascending(),
|
||||
messageID: ctx.assistantMessage.id,
|
||||
@@ -242,10 +334,13 @@ export const layer = Layer.effect(
|
||||
metadata: input.providerExecuted ? { providerExecuted: true } : undefined,
|
||||
} satisfies SessionV1.ToolPart)
|
||||
ctx.toolcalls[input.id] = {
|
||||
assistantMessageID,
|
||||
done: yield* Deferred.make<void>(),
|
||||
partID: part.id,
|
||||
messageID: part.messageID,
|
||||
sessionID: part.sessionID,
|
||||
inputEnded: false,
|
||||
raw: "",
|
||||
}
|
||||
return { call: ctx.toolcalls[input.id], part }
|
||||
})
|
||||
@@ -277,6 +372,16 @@ export const layer = Layer.effect(
|
||||
switch (value.type) {
|
||||
case "reasoning-start":
|
||||
if (value.id in ctx.reasoningMap) return
|
||||
// TODO(v2): Temporary dual-write while migrating session messages to v2 events.
|
||||
if (mirrorAssistant) {
|
||||
yield* events.publish(SessionEvent.Reasoning.Started, {
|
||||
sessionID: ctx.sessionID,
|
||||
assistantMessageID: yield* ensureV2AssistantMessage(),
|
||||
reasoningID: value.id,
|
||||
providerMetadata: value.providerMetadata,
|
||||
timestamp: DateTime.makeUnsafe(Date.now()),
|
||||
})
|
||||
}
|
||||
ctx.reasoningMap[value.id] = {
|
||||
id: PartID.ascending(),
|
||||
messageID: ctx.assistantMessage.id,
|
||||
@@ -294,6 +399,15 @@ export const layer = Layer.effect(
|
||||
if (!(value.id in ctx.reasoningMap)) return
|
||||
ctx.reasoningMap[value.id].text += value.text
|
||||
if (value.providerMetadata) ctx.reasoningMap[value.id].metadata = value.providerMetadata
|
||||
if (mirrorAssistant) {
|
||||
yield* events.publish(SessionEvent.Reasoning.Delta, {
|
||||
sessionID: ctx.sessionID,
|
||||
assistantMessageID: yield* currentV2AssistantMessage(),
|
||||
reasoningID: value.id,
|
||||
delta: value.text,
|
||||
timestamp: DateTime.makeUnsafe(Date.now()),
|
||||
})
|
||||
}
|
||||
yield* session.updatePartDelta({
|
||||
sessionID: ctx.reasoningMap[value.id].sessionID,
|
||||
messageID: ctx.reasoningMap[value.id].messageID,
|
||||
@@ -318,11 +432,36 @@ export const layer = Layer.effect(
|
||||
return
|
||||
|
||||
case "tool-input-delta":
|
||||
yield* ensureToolCall(value)
|
||||
{
|
||||
const toolCall = yield* ensureToolCall(value)
|
||||
const assistantMessageID = mirrorAssistant ? yield* requireV2AssistantMessage(toolCall.call) : undefined
|
||||
if (assistantMessageID) {
|
||||
yield* events.publish(SessionEvent.Tool.Input.Delta, {
|
||||
sessionID: ctx.sessionID,
|
||||
assistantMessageID,
|
||||
callID: value.id,
|
||||
delta: value.text,
|
||||
timestamp: DateTime.makeUnsafe(Date.now()),
|
||||
})
|
||||
}
|
||||
ctx.toolcalls[value.id] = { ...toolCall.call, raw: toolCall.call.raw + value.text }
|
||||
}
|
||||
return
|
||||
|
||||
case "tool-input-end": {
|
||||
yield* ensureToolCall(value)
|
||||
const toolCall = yield* ensureToolCall(value)
|
||||
// TODO(v2): Temporary dual-write while migrating session messages to v2 events.
|
||||
if (mirrorAssistant) {
|
||||
const assistantMessageID = yield* requireV2AssistantMessage(toolCall.call)
|
||||
yield* events.publish(SessionEvent.Tool.Input.Ended, {
|
||||
sessionID: ctx.sessionID,
|
||||
assistantMessageID,
|
||||
callID: value.id,
|
||||
text: toolCall.call.raw,
|
||||
timestamp: DateTime.makeUnsafe(Date.now()),
|
||||
})
|
||||
}
|
||||
ctx.toolcalls[value.id] = { ...toolCall.call, inputEnded: true }
|
||||
return
|
||||
}
|
||||
|
||||
@@ -330,8 +469,37 @@ export const layer = Layer.effect(
|
||||
if (ctx.assistantMessage.summary) {
|
||||
throw new Error(`Tool call not allowed while generating summary: ${value.name}`)
|
||||
}
|
||||
yield* ensureToolCall(value)
|
||||
const toolCall = yield* ensureToolCall(value)
|
||||
const input = isRecord(value.input) ? value.input : { value: value.input }
|
||||
if (!toolCall.call.inputEnded) {
|
||||
// TODO(v2): Temporary dual-write while migrating session messages to v2 events.
|
||||
if (mirrorAssistant) {
|
||||
const assistantMessageID = yield* requireV2AssistantMessage(toolCall.call)
|
||||
yield* events.publish(SessionEvent.Tool.Input.Ended, {
|
||||
sessionID: ctx.sessionID,
|
||||
assistantMessageID,
|
||||
callID: value.id,
|
||||
text: toolCall.call.raw,
|
||||
timestamp: DateTime.makeUnsafe(Date.now()),
|
||||
})
|
||||
}
|
||||
}
|
||||
// TODO(v2): Temporary dual-write while migrating session messages to v2 events.
|
||||
if (mirrorAssistant) {
|
||||
const assistantMessageID = yield* requireV2AssistantMessage(toolCall.call)
|
||||
yield* events.publish(SessionEvent.Tool.Called, {
|
||||
sessionID: ctx.sessionID,
|
||||
assistantMessageID,
|
||||
callID: value.id,
|
||||
tool: value.name,
|
||||
input,
|
||||
provider: {
|
||||
executed: toolCall.part.metadata?.providerExecuted === true,
|
||||
...(value.providerMetadata ? { metadata: value.providerMetadata } : {}),
|
||||
},
|
||||
timestamp: DateTime.makeUnsafe(Date.now()),
|
||||
})
|
||||
}
|
||||
yield* updateToolCall(value.id, (match) => ({
|
||||
...match,
|
||||
tool: value.name,
|
||||
@@ -382,6 +550,22 @@ export const layer = Layer.effect(
|
||||
const toolCall = yield* readToolCall(value.id)
|
||||
if (!toolCall && value.result.type === "error") return
|
||||
if (value.result.type === "error") {
|
||||
// TODO(v2): Temporary dual-write while migrating session messages to v2 events.
|
||||
if (mirrorAssistant) {
|
||||
const assistantMessageID = yield* requireV2AssistantMessage(toolCall?.call)
|
||||
yield* events.publish(SessionEvent.Tool.Failed, {
|
||||
sessionID: ctx.sessionID,
|
||||
assistantMessageID,
|
||||
callID: value.id,
|
||||
error: { type: "unknown", message: errorMessage(value.result.value) },
|
||||
result: value.result,
|
||||
provider: {
|
||||
executed: value.providerExecuted === true || toolCall?.part.metadata?.providerExecuted === true,
|
||||
...(value.providerMetadata ? { metadata: value.providerMetadata } : {}),
|
||||
},
|
||||
timestamp: DateTime.makeUnsafe(Date.now()),
|
||||
})
|
||||
}
|
||||
yield* failToolCall(value.id, value.result.value)
|
||||
return
|
||||
}
|
||||
@@ -407,11 +591,81 @@ export const layer = Layer.effect(
|
||||
: `${rawOutput.output}\n\n[${omitted} image${omitted === 1 ? "" : "s"} omitted: could not be resized below the image size limit.]`,
|
||||
attachments: attachments.length ? attachments : undefined,
|
||||
}
|
||||
// TODO(v2): Temporary dual-write while migrating session messages to v2 events.
|
||||
if (mirrorAssistant) {
|
||||
const assistantMessageID = yield* requireV2AssistantMessage(toolCall?.call)
|
||||
const content = [
|
||||
{ type: "text" as const, text: output.output },
|
||||
...(output.attachments?.map(
|
||||
(item: SessionV1.FilePart) =>
|
||||
({
|
||||
type: "file",
|
||||
uri: item.url,
|
||||
mime: item.mime,
|
||||
name: item.filename,
|
||||
}) as const,
|
||||
) ?? []),
|
||||
]
|
||||
const unsupported = content.find((item) => item.type === "file" && !item.uri.startsWith("data:"))
|
||||
if (unsupported?.type === "file") {
|
||||
const error = new Error(
|
||||
`Tool attachment URI "${unsupported.uri}" must be materialized before durable V2 settlement`,
|
||||
)
|
||||
yield* events.publish(SessionEvent.Tool.Failed, {
|
||||
sessionID: ctx.sessionID,
|
||||
assistantMessageID,
|
||||
callID: value.id,
|
||||
error: {
|
||||
type: "unknown",
|
||||
message: error.message,
|
||||
},
|
||||
provider: {
|
||||
executed: value.providerExecuted === true || toolCall?.part.metadata?.providerExecuted === true,
|
||||
...(value.providerMetadata ? { metadata: value.providerMetadata } : {}),
|
||||
},
|
||||
timestamp: DateTime.makeUnsafe(Date.now()),
|
||||
})
|
||||
yield* failToolCall(value.id, error)
|
||||
return
|
||||
} else
|
||||
yield* events.publish(SessionEvent.Tool.Success, {
|
||||
sessionID: ctx.sessionID,
|
||||
assistantMessageID,
|
||||
callID: value.id,
|
||||
structured: output.metadata,
|
||||
content,
|
||||
result: value.result,
|
||||
provider: {
|
||||
executed: value.providerExecuted === true || toolCall?.part.metadata?.providerExecuted === true,
|
||||
...(value.providerMetadata ? { metadata: value.providerMetadata } : {}),
|
||||
},
|
||||
timestamp: DateTime.makeUnsafe(Date.now()),
|
||||
})
|
||||
}
|
||||
yield* completeToolCall(value.id, output)
|
||||
return
|
||||
}
|
||||
|
||||
case "tool-error": {
|
||||
const toolCall = yield* readToolCall(value.id)
|
||||
// TODO(v2): Temporary dual-write while migrating session messages to v2 events.
|
||||
if (mirrorAssistant) {
|
||||
const assistantMessageID = yield* requireV2AssistantMessage(toolCall?.call)
|
||||
yield* events.publish(SessionEvent.Tool.Failed, {
|
||||
sessionID: ctx.sessionID,
|
||||
assistantMessageID,
|
||||
callID: value.id,
|
||||
error: {
|
||||
type: "unknown",
|
||||
message: value.message,
|
||||
},
|
||||
provider: {
|
||||
executed: toolCall?.part.metadata?.providerExecuted === true,
|
||||
...(value.providerMetadata ? { metadata: value.providerMetadata } : {}),
|
||||
},
|
||||
timestamp: DateTime.makeUnsafe(Date.now()),
|
||||
})
|
||||
}
|
||||
yield* failToolCall(value.id, value.error ?? new Error(value.message))
|
||||
return
|
||||
}
|
||||
@@ -421,6 +675,12 @@ export const layer = Layer.effect(
|
||||
|
||||
case "step-start":
|
||||
if (!ctx.snapshot) ctx.snapshot = yield* snapshot.track()
|
||||
if (!ctx.assistantMessage.summary) {
|
||||
// TODO(v2): Temporary dual-write while migrating session messages to v2 events.
|
||||
if (mirrorAssistant) {
|
||||
yield* ensureV2AssistantMessage()
|
||||
}
|
||||
}
|
||||
yield* session.updatePart({
|
||||
id: PartID.ascending(),
|
||||
messageID: ctx.assistantMessage.id,
|
||||
@@ -438,6 +698,21 @@ export const layer = Layer.effect(
|
||||
usage: value.usage ?? new Usage({}),
|
||||
metadata: value.providerMetadata,
|
||||
})
|
||||
if (!ctx.assistantMessage.summary) {
|
||||
// TODO(v2): Temporary dual-write while migrating session messages to v2 events.
|
||||
if (mirrorAssistant) {
|
||||
yield* events.publish(SessionEvent.Step.Ended, {
|
||||
sessionID: ctx.sessionID,
|
||||
assistantMessageID: yield* currentV2AssistantMessage(),
|
||||
finish: value.reason,
|
||||
cost: usage.cost,
|
||||
tokens: usage.tokens,
|
||||
snapshot: completedSnapshot,
|
||||
timestamp: DateTime.makeUnsafe(Date.now()),
|
||||
})
|
||||
ctx.v2AssistantMessageID = undefined
|
||||
}
|
||||
}
|
||||
ctx.assistantMessage.finish = value.reason
|
||||
ctx.assistantMessage.cost += usage.cost
|
||||
ctx.assistantMessage.tokens = usage.tokens
|
||||
@@ -482,6 +757,17 @@ export const layer = Layer.effect(
|
||||
}
|
||||
|
||||
case "text-start":
|
||||
if (!ctx.assistantMessage.summary) {
|
||||
// TODO(v2): Temporary dual-write while migrating session messages to v2 events.
|
||||
if (mirrorAssistant) {
|
||||
yield* events.publish(SessionEvent.Text.Started, {
|
||||
sessionID: ctx.sessionID,
|
||||
assistantMessageID: yield* ensureV2AssistantMessage(),
|
||||
timestamp: DateTime.makeUnsafe(Date.now()),
|
||||
textID: value.id,
|
||||
})
|
||||
}
|
||||
}
|
||||
ctx.currentText = {
|
||||
id: PartID.ascending(),
|
||||
messageID: ctx.assistantMessage.id,
|
||||
@@ -491,6 +777,7 @@ export const layer = Layer.effect(
|
||||
time: { start: Date.now() },
|
||||
metadata: value.providerMetadata,
|
||||
}
|
||||
ctx.currentTextID = value.id
|
||||
yield* session.updatePart(ctx.currentText)
|
||||
return
|
||||
|
||||
@@ -498,6 +785,15 @@ export const layer = Layer.effect(
|
||||
if (!ctx.currentText) return
|
||||
ctx.currentText.text += value.text
|
||||
if (value.providerMetadata) ctx.currentText.metadata = value.providerMetadata
|
||||
if (mirrorAssistant) {
|
||||
yield* events.publish(SessionEvent.Text.Delta, {
|
||||
sessionID: ctx.sessionID,
|
||||
assistantMessageID: yield* currentV2AssistantMessage(),
|
||||
textID: value.id,
|
||||
delta: value.text,
|
||||
timestamp: DateTime.makeUnsafe(Date.now()),
|
||||
})
|
||||
}
|
||||
yield* session.updatePartDelta({
|
||||
sessionID: ctx.currentText.sessionID,
|
||||
messageID: ctx.currentText.messageID,
|
||||
@@ -520,6 +816,18 @@ export const layer = Layer.effect(
|
||||
},
|
||||
{ text: ctx.currentText.text },
|
||||
)).text
|
||||
if (!ctx.assistantMessage.summary) {
|
||||
// TODO(v2): Temporary dual-write while migrating session messages to v2 events.
|
||||
if (mirrorAssistant) {
|
||||
yield* events.publish(SessionEvent.Text.Ended, {
|
||||
sessionID: ctx.sessionID,
|
||||
assistantMessageID: yield* currentV2AssistantMessage(),
|
||||
text: ctx.currentText.text,
|
||||
timestamp: DateTime.makeUnsafe(Date.now()),
|
||||
textID: value.id,
|
||||
})
|
||||
}
|
||||
}
|
||||
{
|
||||
const end = Date.now()
|
||||
ctx.currentText.time = { start: ctx.currentText.time?.start ?? end, end }
|
||||
@@ -527,6 +835,7 @@ export const layer = Layer.effect(
|
||||
if (value.providerMetadata) ctx.currentText.metadata = value.providerMetadata
|
||||
yield* session.updatePart(ctx.currentText)
|
||||
ctx.currentText = undefined
|
||||
ctx.currentTextID = undefined
|
||||
return
|
||||
|
||||
case "finish":
|
||||
@@ -555,6 +864,7 @@ export const layer = Layer.effect(
|
||||
ctx.currentText.time = { start: ctx.currentText.time?.start ?? end, end }
|
||||
yield* session.updatePart(ctx.currentText)
|
||||
ctx.currentText = undefined
|
||||
ctx.currentTextID = undefined
|
||||
}
|
||||
|
||||
for (const part of Object.values(ctx.reasoningMap)) {
|
||||
@@ -576,6 +886,16 @@ export const layer = Layer.effect(
|
||||
const match = yield* readToolCall(toolCallID)
|
||||
if (!match) continue
|
||||
const part = match.part
|
||||
if (mirrorAssistant && match.call.assistantMessageID) {
|
||||
yield* events.publish(SessionEvent.Tool.Failed, {
|
||||
sessionID: ctx.sessionID,
|
||||
assistantMessageID: match.call.assistantMessageID,
|
||||
callID: toolCallID,
|
||||
error: { type: "unknown", message: "Tool execution aborted" },
|
||||
provider: { executed: part.metadata?.providerExecuted === true },
|
||||
timestamp: DateTime.makeUnsafe(Date.now()),
|
||||
})
|
||||
}
|
||||
const end = Date.now()
|
||||
const metadata = "metadata" in part.state && isRecord(part.state.metadata) ? part.state.metadata : {}
|
||||
yield* session.updatePart({
|
||||
@@ -602,6 +922,7 @@ export const layer = Layer.effect(
|
||||
stack: e instanceof Error ? e.stack : undefined,
|
||||
})
|
||||
const error = parse(e)
|
||||
yield* flushV2Fragments()
|
||||
if (SessionV1.ContextOverflowError.isInstance(error)) {
|
||||
if ((yield* config.get()).compaction?.auto === false && !ctx.assistantMessage.summary) {
|
||||
ctx.assistantMessage.error = error
|
||||
@@ -614,6 +935,20 @@ export const layer = Layer.effect(
|
||||
yield* events.publish(Session.Event.Error, { sessionID: ctx.sessionID, error })
|
||||
return
|
||||
}
|
||||
if (!ctx.assistantMessage.summary) {
|
||||
// TODO(v2): Temporary dual-write while migrating session messages to v2 events.
|
||||
if (mirrorAssistant) {
|
||||
yield* events.publish(SessionEvent.Step.Failed, {
|
||||
sessionID: ctx.sessionID,
|
||||
assistantMessageID: yield* ensureV2AssistantMessage(),
|
||||
error: {
|
||||
type: "unknown",
|
||||
message: errorMessage(e),
|
||||
},
|
||||
timestamp: DateTime.makeUnsafe(Date.now()),
|
||||
})
|
||||
}
|
||||
}
|
||||
ctx.assistantMessage.error = error
|
||||
yield* events.publish(Session.Event.Error, {
|
||||
sessionID: ctx.assistantMessage.sessionID,
|
||||
@@ -633,6 +968,7 @@ export const layer = Layer.effect(
|
||||
return yield* Effect.gen(function* () {
|
||||
yield* Effect.gen(function* () {
|
||||
ctx.currentText = undefined
|
||||
ctx.currentTextID = undefined
|
||||
ctx.reasoningMap = {}
|
||||
yield* status.set(ctx.sessionID, { type: "busy" })
|
||||
const stream = llm.stream(streamInput)
|
||||
@@ -660,13 +996,30 @@ export const layer = Layer.effect(
|
||||
provider: input.model.providerID,
|
||||
parse,
|
||||
set: (info) => {
|
||||
return status.set(ctx.sessionID, {
|
||||
type: "retry",
|
||||
attempt: info.attempt,
|
||||
message: info.message,
|
||||
action: info.action,
|
||||
next: info.next,
|
||||
})
|
||||
// TODO(v2): Temporary dual-write while migrating session messages to v2 events.
|
||||
const event = mirrorAssistant
|
||||
? events.publish(SessionEvent.Retried, {
|
||||
sessionID: ctx.sessionID,
|
||||
attempt: info.attempt,
|
||||
error: {
|
||||
message: info.message,
|
||||
isRetryable: true,
|
||||
},
|
||||
timestamp: DateTime.makeUnsafe(Date.now()),
|
||||
})
|
||||
: Effect.void
|
||||
return flushV2Fragments().pipe(
|
||||
Effect.andThen(event),
|
||||
Effect.andThen(
|
||||
status.set(ctx.sessionID, {
|
||||
type: "retry",
|
||||
attempt: info.attempt,
|
||||
message: info.message,
|
||||
action: info.action,
|
||||
next: info.next,
|
||||
}),
|
||||
),
|
||||
)
|
||||
},
|
||||
}),
|
||||
),
|
||||
@@ -706,6 +1059,7 @@ export const defaultLayer = Layer.suspend(() =>
|
||||
Layer.provide(SessionStatus.defaultLayer),
|
||||
Layer.provide(Image.defaultLayer),
|
||||
Layer.provide(Config.defaultLayer),
|
||||
Layer.provide(RuntimeFlags.defaultLayer),
|
||||
Layer.provide(Database.defaultLayer),
|
||||
Layer.provide(EventV2Bridge.defaultLayer),
|
||||
),
|
||||
@@ -726,6 +1080,7 @@ export const node = LayerNode.make({
|
||||
SessionStatus.node,
|
||||
Image.node,
|
||||
EventV2Bridge.node,
|
||||
RuntimeFlags.node,
|
||||
Database.node,
|
||||
],
|
||||
})
|
||||
|
||||
@@ -49,8 +49,12 @@ import { SessionRunState } from "./run-state"
|
||||
import { RuntimeFlags } from "@/effect/runtime-flags"
|
||||
import { EventV2Bridge } from "@/event-v2-bridge"
|
||||
import { Database } from "@opencode-ai/core/database/database"
|
||||
import { SessionEvent } from "@opencode-ai/core/session/event"
|
||||
import { SessionMessage } from "@opencode-ai/core/session/message"
|
||||
import { ModelV2 } from "@opencode-ai/core/model"
|
||||
import { ProviderV2 } from "@opencode-ai/core/provider"
|
||||
import { AgentAttachment, FileAttachment, Prompt, Source } from "@opencode-ai/core/session/prompt"
|
||||
import * as DateTime from "effect/DateTime"
|
||||
import { eq } from "drizzle-orm"
|
||||
import { SessionTable } from "@opencode-ai/core/session/sql"
|
||||
import { SessionReminders } from "./reminders"
|
||||
@@ -516,6 +520,15 @@ export const layer = Layer.effect(
|
||||
},
|
||||
}
|
||||
yield* sessions.updatePart(part)
|
||||
if (flags.experimentalEventSystem) {
|
||||
yield* events.publish(SessionEvent.Shell.Started, {
|
||||
sessionID: input.sessionID,
|
||||
messageID: SessionMessage.ID.create(),
|
||||
timestamp: DateTime.makeUnsafe(started),
|
||||
callID: part.callID,
|
||||
command: input.command,
|
||||
})
|
||||
}
|
||||
return { msg, part, cwd: ctx.directory }
|
||||
}).pipe(Effect.ensuring(markReady))
|
||||
|
||||
@@ -531,6 +544,14 @@ export const layer = Layer.effect(
|
||||
output += "\n\n" + ["<metadata>", "User aborted the command", "</metadata>"].join("\n")
|
||||
}
|
||||
const completed = Date.now()
|
||||
if (flags.experimentalEventSystem) {
|
||||
yield* events.publish(SessionEvent.Shell.Ended, {
|
||||
sessionID: input.sessionID,
|
||||
timestamp: DateTime.makeUnsafe(completed),
|
||||
callID: part.callID,
|
||||
output,
|
||||
})
|
||||
}
|
||||
if (!msg.time.completed) {
|
||||
msg.time.completed = completed
|
||||
yield* sessions.updateMessage(msg)
|
||||
@@ -643,6 +664,12 @@ export const layer = Layer.effect(
|
||||
throw error
|
||||
}
|
||||
|
||||
const current = yield* db
|
||||
.select({ agent: SessionTable.agent, model: SessionTable.model })
|
||||
.from(SessionTable)
|
||||
.where(eq(SessionTable.id, input.sessionID))
|
||||
.get()
|
||||
.pipe(Effect.orDie)
|
||||
const model = input.model ?? ag.model ?? (yield* currentModel(input.sessionID))
|
||||
const same = ag.model && model.providerID === ag.model.providerID && model.modelID === ag.model.modelID
|
||||
const full =
|
||||
@@ -669,22 +696,28 @@ export const layer = Layer.effect(
|
||||
format: input.format,
|
||||
}
|
||||
|
||||
const current = yield* sessions.get(input.sessionID).pipe(Effect.orDie)
|
||||
if (
|
||||
current.agent !== info.agent ||
|
||||
current.model?.providerID !== info.model.providerID ||
|
||||
current.model?.id !== info.model.modelID ||
|
||||
(current.model?.variant === "default" ? undefined : current.model?.variant) !== info.model.variant
|
||||
) {
|
||||
yield* sessions.setAgentModel({
|
||||
if (current?.agent !== info.agent) {
|
||||
yield* events.publish(SessionEvent.AgentSwitched, {
|
||||
sessionID: input.sessionID,
|
||||
messageID: SessionMessage.ID.create(),
|
||||
timestamp: DateTime.makeUnsafe(info.time.created),
|
||||
agent: info.agent,
|
||||
})
|
||||
}
|
||||
if (
|
||||
current?.model?.providerID !== info.model.providerID ||
|
||||
current.model.id !== info.model.modelID ||
|
||||
(current.model.variant === "default" ? undefined : current.model.variant) !== info.model.variant
|
||||
) {
|
||||
yield* events.publish(SessionEvent.ModelSwitched, {
|
||||
sessionID: input.sessionID,
|
||||
messageID: SessionMessage.ID.create(),
|
||||
timestamp: DateTime.makeUnsafe(info.time.created),
|
||||
model: {
|
||||
id: info.model.modelID,
|
||||
providerID: info.model.providerID,
|
||||
variant: info.model.variant ?? "default",
|
||||
id: ModelV2.ID.make(info.model.modelID),
|
||||
providerID: ProviderV2.ID.make(info.model.providerID),
|
||||
variant: ModelV2.VariantID.make(info.model.variant ?? "default"),
|
||||
},
|
||||
time: info.time.created,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1045,6 +1078,76 @@ export const layer = Layer.effect(
|
||||
|
||||
yield* sessions.updateMessage(info)
|
||||
for (const part of parts) yield* sessions.updatePart(part)
|
||||
const nextPrompt = parts.reduce(
|
||||
(result, part) => {
|
||||
if (part.type === "text") {
|
||||
if (part.synthetic) result.synthetic.push(part.text)
|
||||
else result.text.push(part.text)
|
||||
}
|
||||
if (part.type === "file") {
|
||||
result.files.push(
|
||||
FileAttachment.make({
|
||||
uri: part.url,
|
||||
mime: part.mime,
|
||||
name: part.filename,
|
||||
source: part.source
|
||||
? Source.make({
|
||||
start: part.source.text.start,
|
||||
end: part.source.text.end,
|
||||
text: part.source.text.value,
|
||||
})
|
||||
: undefined,
|
||||
}),
|
||||
)
|
||||
}
|
||||
if (part.type === "agent") {
|
||||
result.agents.push(
|
||||
AgentAttachment.make({
|
||||
name: part.name,
|
||||
source: part.source
|
||||
? Source.make({
|
||||
start: part.source.start,
|
||||
end: part.source.end,
|
||||
text: part.source.value,
|
||||
})
|
||||
: undefined,
|
||||
}),
|
||||
)
|
||||
}
|
||||
return result
|
||||
},
|
||||
{
|
||||
text: [] as string[],
|
||||
files: [] as FileAttachment[],
|
||||
agents: [] as AgentAttachment[],
|
||||
synthetic: [] as string[],
|
||||
},
|
||||
)
|
||||
// TODO(v2): Temporary dual-write while migrating session messages to v2 events.
|
||||
if (flags.experimentalEventSystem) {
|
||||
yield* events.publish(SessionEvent.Prompted, {
|
||||
sessionID: input.sessionID,
|
||||
messageID: SessionMessage.ID.create(),
|
||||
timestamp: DateTime.makeUnsafe(info.time.created),
|
||||
delivery: "steer",
|
||||
prompt: Prompt.make({
|
||||
text: nextPrompt.text.join("\n"),
|
||||
files: nextPrompt.files,
|
||||
agents: nextPrompt.agents,
|
||||
}),
|
||||
})
|
||||
}
|
||||
for (const text of nextPrompt.synthetic) {
|
||||
// TODO(v2): Temporary dual-write while migrating session messages to v2 events.
|
||||
if (flags.experimentalEventSystem) {
|
||||
yield* events.publish(SessionEvent.Synthetic, {
|
||||
sessionID: input.sessionID,
|
||||
messageID: SessionMessage.ID.create(),
|
||||
timestamp: DateTime.makeUnsafe(info.time.created),
|
||||
text,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return { info, parts }
|
||||
}, Effect.scoped)
|
||||
|
||||
@@ -12,8 +12,7 @@ import { Database } from "@opencode-ai/core/database/database"
|
||||
import { makeRuntime } from "@opencode-ai/core/effect/runtime"
|
||||
import { EventV2Bridge } from "@/event-v2-bridge"
|
||||
import { SessionV2 } from "@opencode-ai/core/session"
|
||||
import * as SessionExecutionLocal from "@opencode-ai/core/session/execution/local"
|
||||
import { LocationServiceMap } from "@opencode-ai/core/location-layer"
|
||||
import { SessionExecution } from "@opencode-ai/core/session/execution"
|
||||
|
||||
import { NotFoundError } from "@/storage/storage"
|
||||
import { eq } from "drizzle-orm"
|
||||
@@ -433,12 +432,6 @@ export interface Interface {
|
||||
readonly setTitle: (input: { sessionID: SessionID; title: string }) => Effect.Effect<void>
|
||||
readonly setArchived: (input: { sessionID: SessionID; time?: number }) => Effect.Effect<void>
|
||||
readonly setMetadata: (input: typeof SetMetadataInput.Type) => Effect.Effect<void>
|
||||
readonly setAgentModel: (input: {
|
||||
sessionID: SessionID
|
||||
agent: string
|
||||
model: NonNullable<Info["model"]>
|
||||
time: number
|
||||
}) => Effect.Effect<void>
|
||||
readonly setPermission: (input: { sessionID: SessionID; permission: PermissionV1.Ruleset }) => Effect.Effect<void>
|
||||
readonly setRevert: (input: {
|
||||
sessionID: SessionID
|
||||
@@ -767,19 +760,6 @@ export const layer: Layer.Layer<
|
||||
yield* patch(input.sessionID, { metadata: input.metadata, time: { updated: Date.now() } }).pipe(Effect.orDie)
|
||||
})
|
||||
|
||||
const setAgentModel = Effect.fn("Session.setAgentModel")(function* (input: {
|
||||
sessionID: SessionID
|
||||
agent: string
|
||||
model: NonNullable<Info["model"]>
|
||||
time: number
|
||||
}) {
|
||||
yield* patch(input.sessionID, {
|
||||
agent: input.agent,
|
||||
model: input.model,
|
||||
time: { updated: input.time },
|
||||
}).pipe(Effect.orDie)
|
||||
})
|
||||
|
||||
const setPermission = Effect.fn("Session.setPermission")(function* (input: {
|
||||
sessionID: SessionID
|
||||
permission: PermissionV1.Ruleset
|
||||
@@ -918,7 +898,6 @@ export const layer: Layer.Layer<
|
||||
setTitle,
|
||||
setArchived,
|
||||
setMetadata,
|
||||
setAgentModel,
|
||||
setPermission,
|
||||
setRevert,
|
||||
clearRevert,
|
||||
@@ -944,12 +923,8 @@ export const defaultLayer = layer.pipe(
|
||||
Layer.provide(BackgroundJob.defaultLayer),
|
||||
Layer.provide(Database.defaultLayer),
|
||||
Layer.provide(EventV2Bridge.defaultLayer),
|
||||
Layer.provide(
|
||||
SessionV2.defaultLayer.pipe(
|
||||
Layer.provide(SessionExecutionLocal.defaultLayer),
|
||||
Layer.provide(LocationServiceMap.layer),
|
||||
),
|
||||
),
|
||||
Layer.provide(SessionExecution.noopLayer),
|
||||
Layer.provide(SessionV2.defaultLayer),
|
||||
Layer.provide(RuntimeFlags.defaultLayer),
|
||||
)
|
||||
|
||||
|
||||
@@ -13,7 +13,6 @@ const fileConcurrency = 8
|
||||
class IndexSkill extends Schema.Class<IndexSkill>("IndexSkill")({
|
||||
name: Schema.String,
|
||||
files: Schema.Array(Schema.String),
|
||||
version: Schema.optional(Schema.String),
|
||||
}) {}
|
||||
|
||||
class Index extends Schema.Class<Index>("Index")({
|
||||
@@ -77,53 +76,17 @@ export const layer: Layer.Layer<Service, never, FSUtil.Service | Path.Path | Htt
|
||||
(skill) =>
|
||||
Effect.gen(function* () {
|
||||
const root = path.join(cache, skill.name)
|
||||
const versionFile = path.join(root, ".opencode-version")
|
||||
const version = skill.version
|
||||
const current =
|
||||
version === undefined
|
||||
? undefined
|
||||
: yield* fs.readFileStringSafe(versionFile).pipe(Effect.catch(() => Effect.succeed(undefined)))
|
||||
|
||||
if (version === undefined || current === version) {
|
||||
yield* Effect.forEach(
|
||||
skill.files,
|
||||
(file) => download(new URL(file, `${host}/${skill.name}/`).href, path.join(root, file)),
|
||||
{ concurrency: fileConcurrency, discard: true },
|
||||
)
|
||||
} else {
|
||||
const token = crypto.randomUUID()
|
||||
const staging = `${root}.tmp-${token}`
|
||||
const backup = `${root}.old-${token}`
|
||||
yield* Effect.gen(function* () {
|
||||
const downloaded = yield* Effect.forEach(
|
||||
skill.files,
|
||||
(file) => download(new URL(file, `${host}/${skill.name}/`).href, path.join(staging, file)),
|
||||
{ concurrency: fileConcurrency },
|
||||
)
|
||||
if (!downloaded.every(Boolean)) return
|
||||
if (!(yield* fs.exists(path.join(staging, "SKILL.md")).pipe(Effect.orDie))) return
|
||||
yield* fs.writeFileString(path.join(staging, ".opencode-version"), version)
|
||||
yield* Effect.uninterruptible(
|
||||
Effect.gen(function* () {
|
||||
const cached = yield* fs.exists(root).pipe(Effect.orDie)
|
||||
if (cached) yield* fs.rename(root, backup)
|
||||
yield* fs.rename(staging, root).pipe(
|
||||
Effect.catch((error) =>
|
||||
Effect.gen(function* () {
|
||||
if (cached) yield* fs.rename(backup, root).pipe(Effect.ignore)
|
||||
return yield* Effect.fail(error)
|
||||
}),
|
||||
),
|
||||
)
|
||||
if (cached) yield* fs.remove(backup, { recursive: true, force: true }).pipe(Effect.ignore)
|
||||
}),
|
||||
)
|
||||
}).pipe(
|
||||
Effect.catch((error) => Effect.logError("failed to refresh skill", { skill: skill.name, error })),
|
||||
Effect.ensuring(fs.remove(staging, { recursive: true, force: true }).pipe(Effect.ignore)),
|
||||
)
|
||||
}
|
||||
return (yield* fs.exists(path.join(root, "SKILL.md")).pipe(Effect.orDie)) ? root : null
|
||||
yield* Effect.forEach(
|
||||
skill.files,
|
||||
(file) => download(new URL(file, `${host}/${skill.name}/`).href, path.join(root, file)),
|
||||
{
|
||||
concurrency: fileConcurrency,
|
||||
},
|
||||
)
|
||||
|
||||
const md = path.join(root, "SKILL.md")
|
||||
return (yield* fs.exists(md).pipe(Effect.orDie)) ? root : null
|
||||
}),
|
||||
{ concurrency: skillConcurrency },
|
||||
)
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
|
||||
import path from "path"
|
||||
import { pathToFileURL } from "url"
|
||||
import { Effect, Layer, Context, Schema } from "effect"
|
||||
import { NamedError } from "@opencode-ai/core/util/error"
|
||||
import type { Agent } from "@/agent/agent"
|
||||
@@ -16,7 +17,6 @@ import { RuntimeFlags } from "@/effect/runtime-flags"
|
||||
import { Glob } from "@opencode-ai/core/util/glob"
|
||||
import { Discovery } from "./discovery"
|
||||
import { isRecord } from "@/util/record"
|
||||
import { escapeHtml } from "@/util/html"
|
||||
|
||||
const CLAUDE_EXTERNAL_DIR = ".claude"
|
||||
const AGENTS_EXTERNAL_DIR = ".agents"
|
||||
@@ -339,7 +339,7 @@ export function fmt(list: Info[], opts: { verbose: boolean }) {
|
||||
" <skill>",
|
||||
` <name>${skill.name}</name>`,
|
||||
` <description>${skill.description}</description>`,
|
||||
` <location>${escapeHtml(skill.location)}</location>`,
|
||||
` <location>${pathToFileURL(skill.location).href}</location>`,
|
||||
" </skill>",
|
||||
]),
|
||||
"</available_skills>",
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { afterEach, describe, expect, it } from "bun:test"
|
||||
import { describe, expect, it } from "bun:test"
|
||||
import type {
|
||||
AgentSideConnection,
|
||||
RequestPermissionRequest,
|
||||
@@ -6,22 +6,13 @@ import type {
|
||||
SessionUpdate,
|
||||
} from "@agentclientprotocol/sdk"
|
||||
import type { Event, OpencodeClient } from "@opencode-ai/sdk/v2"
|
||||
import { createTwoFilesPatch } from "diff"
|
||||
import { Effect, ManagedRuntime } from "effect"
|
||||
import { mkdtemp, rm } from "node:fs/promises"
|
||||
import { tmpdir } from "node:os"
|
||||
import path from "node:path"
|
||||
import { ACPEvent } from "@/acp/event"
|
||||
import { ACPSession } from "@/acp/session"
|
||||
|
||||
type PermissionEvent = Extract<Event, { type: "permission.asked" }>
|
||||
type PermissionReplyParams = Parameters<OpencodeClient["permission"]["reply"]>[0]
|
||||
type SessionUpdateParams = Parameters<AgentSideConnection["sessionUpdate"]>[0]
|
||||
const cleanupDirs: string[] = []
|
||||
|
||||
afterEach(async () => {
|
||||
await Promise.all(cleanupDirs.splice(0).map((dir) => rm(dir, { recursive: true, force: true })))
|
||||
})
|
||||
|
||||
const pollUntil = async (
|
||||
check: () => boolean | Promise<boolean>,
|
||||
@@ -146,14 +137,6 @@ function textFromUpdates(updates: SessionUpdateParams[], sessionId: string) {
|
||||
.join("")
|
||||
}
|
||||
|
||||
async function tempFile(name: string, content: string) {
|
||||
const dir = await mkdtemp(path.join(tmpdir(), "opencode-acp-permission-"))
|
||||
cleanupDirs.push(dir)
|
||||
const file = path.join(dir, name)
|
||||
await Bun.write(file, content)
|
||||
return file
|
||||
}
|
||||
|
||||
describe("acp permissions", () => {
|
||||
it("sends requestPermission and replies with the selected outcome", async () => {
|
||||
const harness = createHarness()
|
||||
@@ -168,7 +151,7 @@ describe("acp permissions", () => {
|
||||
toolCall: {
|
||||
toolCallId: "call_1",
|
||||
status: "pending",
|
||||
title: "printf hello",
|
||||
title: "bash",
|
||||
rawInput: { command: "printf hello" },
|
||||
kind: "execute",
|
||||
locations: [],
|
||||
@@ -182,116 +165,6 @@ describe("acp permissions", () => {
|
||||
expect(harness.replies).toEqual([{ requestID: "perm_1", reply: "once", directory: "/workspace" }])
|
||||
})
|
||||
|
||||
it("uses permission metadata for non-shell titles", async () => {
|
||||
const harness = createHarness()
|
||||
await createSession(harness.session, "ses_a")
|
||||
|
||||
harness.subscription.handle(
|
||||
permissionAsked("ses_a", "perm_fetch", {
|
||||
permission: "webfetch",
|
||||
metadata: {
|
||||
url: "https://example.com/docs",
|
||||
format: "markdown",
|
||||
},
|
||||
tool: { messageID: "msg_1", callID: "call_1" },
|
||||
}),
|
||||
)
|
||||
|
||||
await pollUntil(() => harness.replies.length === 1, "webfetch permission was never replied")
|
||||
|
||||
expect(harness.requests[0]?.toolCall).toMatchObject({
|
||||
toolCallId: "call_1",
|
||||
title: "https://example.com/docs",
|
||||
kind: "fetch",
|
||||
rawInput: { url: "https://example.com/docs", format: "markdown" },
|
||||
})
|
||||
})
|
||||
|
||||
it("includes a diff content block for edit permission metadata", async () => {
|
||||
const filepath = await tempFile("file.ts", "before\n")
|
||||
const harness = createHarness()
|
||||
await createSession(harness.session, "ses_a")
|
||||
|
||||
harness.subscription.handle(
|
||||
permissionAsked("ses_a", "perm_edit", {
|
||||
permission: "edit",
|
||||
metadata: {
|
||||
filepath,
|
||||
diff: createTwoFilesPatch(filepath, filepath, "before\n", "after\n"),
|
||||
},
|
||||
tool: { messageID: "msg_1", callID: "call_1" },
|
||||
}),
|
||||
)
|
||||
|
||||
await pollUntil(() => harness.replies.length === 1, "edit permission was never replied")
|
||||
|
||||
expect(harness.requests[0]?.toolCall).toMatchObject({
|
||||
toolCallId: "call_1",
|
||||
title: filepath,
|
||||
kind: "edit",
|
||||
locations: [{ path: filepath }],
|
||||
content: [
|
||||
{
|
||||
type: "diff",
|
||||
path: filepath,
|
||||
oldText: "before\n",
|
||||
newText: "after\n",
|
||||
},
|
||||
],
|
||||
})
|
||||
})
|
||||
|
||||
it("includes per-file diff blocks and locations for apply_patch permission metadata", async () => {
|
||||
const first = await tempFile("first.ts", "one\n")
|
||||
const second = await tempFile("second.ts", "alpha\n")
|
||||
const harness = createHarness()
|
||||
await createSession(harness.session, "ses_a")
|
||||
|
||||
harness.subscription.handle(
|
||||
permissionAsked("ses_a", "perm_patch", {
|
||||
permission: "edit",
|
||||
metadata: {
|
||||
filepath: "first.ts, second.ts",
|
||||
files: [
|
||||
{
|
||||
filePath: first,
|
||||
relativePath: "first.ts",
|
||||
patch: createTwoFilesPatch(first, first, "one\n", "two\n"),
|
||||
},
|
||||
{
|
||||
filePath: second,
|
||||
relativePath: "second.ts",
|
||||
patch: createTwoFilesPatch(second, second, "alpha\n", "beta\n"),
|
||||
},
|
||||
],
|
||||
},
|
||||
tool: { messageID: "msg_1", callID: "call_1" },
|
||||
}),
|
||||
)
|
||||
|
||||
await pollUntil(() => harness.replies.length === 1, "apply_patch permission was never replied")
|
||||
|
||||
expect(harness.requests[0]?.toolCall).toMatchObject({
|
||||
toolCallId: "call_1",
|
||||
title: "2 files",
|
||||
locations: [{ path: first }, { path: second }],
|
||||
content: [
|
||||
{
|
||||
type: "diff",
|
||||
path: first,
|
||||
oldText: "one\n",
|
||||
newText: "two\n",
|
||||
},
|
||||
{
|
||||
type: "diff",
|
||||
path: second,
|
||||
oldText: "alpha\n",
|
||||
newText: "beta\n",
|
||||
},
|
||||
],
|
||||
})
|
||||
})
|
||||
|
||||
it("forwards external_directory metadata and locations to requestPermission", async () => {
|
||||
const harness = createHarness()
|
||||
await createSession(harness.session, "ses_a")
|
||||
@@ -316,7 +189,7 @@ describe("acp permissions", () => {
|
||||
toolCall: {
|
||||
toolCallId: "call_1",
|
||||
status: "pending",
|
||||
title: "Create external directory",
|
||||
title: "external_directory",
|
||||
rawInput: {
|
||||
command: "mkdir -p /tmp/outside",
|
||||
description: "Create external directory",
|
||||
|
||||
@@ -10,7 +10,7 @@ import type {
|
||||
SessionConfigSelectOption,
|
||||
SetSessionConfigOptionResponse,
|
||||
} from "@agentclientprotocol/sdk"
|
||||
import type { AssistantMessage, OpencodeClient } from "@opencode-ai/sdk/v2"
|
||||
import type { OpencodeClient } from "@opencode-ai/sdk/v2"
|
||||
import { ProviderV2 } from "@opencode-ai/core/provider"
|
||||
import { ModelV2 } from "@opencode-ai/core/model"
|
||||
import { Effect } from "effect"
|
||||
@@ -144,10 +144,7 @@ const provider: Provider.Info = {
|
||||
describe("ACP service sessions", () => {
|
||||
const makeService = (
|
||||
messages: readonly { info: unknown; parts: readonly unknown[] }[] = [],
|
||||
options?: {
|
||||
abort?: (input: { sessionID: string }) => Promise<{ data: boolean }>
|
||||
prompt?: (input: unknown) => Promise<{ data: { info: ReturnType<typeof assistantInfo> } }>
|
||||
},
|
||||
options?: { abort?: (input: { sessionID: string }) => Promise<{ data: boolean }> },
|
||||
) => {
|
||||
const updates: SessionNotification[] = []
|
||||
const mcpAdds: string[] = []
|
||||
@@ -196,21 +193,19 @@ describe("ACP service sessions", () => {
|
||||
data: input.directory ? sessions.filter((session) => session.directory === input.directory) : sessions,
|
||||
}),
|
||||
messages: () => Promise.resolve({ data: messages }),
|
||||
prompt:
|
||||
options?.prompt ??
|
||||
((input: unknown) => {
|
||||
prompts.push(input)
|
||||
return Promise.resolve({
|
||||
data: {
|
||||
info: assistantInfo({
|
||||
input: 100,
|
||||
output: 40,
|
||||
reasoning: 7,
|
||||
cache: { read: 11, write: 13 },
|
||||
}),
|
||||
},
|
||||
})
|
||||
}),
|
||||
prompt: (input: unknown) => {
|
||||
prompts.push(input)
|
||||
return Promise.resolve({
|
||||
data: {
|
||||
info: assistantInfo({
|
||||
input: 100,
|
||||
output: 40,
|
||||
reasoning: 7,
|
||||
cache: { read: 11, write: 13 },
|
||||
}),
|
||||
},
|
||||
})
|
||||
},
|
||||
command: (input: unknown) => {
|
||||
commands.push(input)
|
||||
return Promise.resolve({
|
||||
@@ -394,29 +389,15 @@ describe("ACP service sessions", () => {
|
||||
expect(second.sessions.map((session) => session.sessionId)).toEqual(["ses_2", "ses_1"])
|
||||
})
|
||||
|
||||
it("resumes a session and stores restored state without replaying transcript chunks", async () => {
|
||||
const { service, updates } = makeService([
|
||||
it("resumes a session and stores restored state", async () => {
|
||||
const { service } = makeService([
|
||||
{
|
||||
info: {
|
||||
id: "msg_user",
|
||||
sessionID: "ses_resume",
|
||||
role: "user",
|
||||
model: { providerID: "test", modelID: "test-model", variant: "high" },
|
||||
agent: "plan",
|
||||
},
|
||||
parts: [{ id: "part_user", sessionID: "ses_resume", messageID: "msg_user", type: "text", text: "hello" }],
|
||||
},
|
||||
{
|
||||
info: { id: "msg_assistant", sessionID: "ses_resume", role: "assistant" },
|
||||
parts: [
|
||||
{
|
||||
id: "part_assistant",
|
||||
sessionID: "ses_resume",
|
||||
messageID: "msg_assistant",
|
||||
type: "text",
|
||||
text: "hi there",
|
||||
},
|
||||
],
|
||||
parts: [],
|
||||
},
|
||||
])
|
||||
const resumed = await Effect.runPromise(
|
||||
@@ -428,11 +409,6 @@ describe("ACP service sessions", () => {
|
||||
|
||||
expect(select(resumed, "effort")?.currentValue).toBe("high")
|
||||
expect(select(updated, "effort")?.currentValue).toBe("default")
|
||||
expect(
|
||||
updates
|
||||
.map((item) => item.update)
|
||||
.filter((item) => item.sessionUpdate === "user_message_chunk" || item.sessionUpdate === "agent_message_chunk"),
|
||||
).toEqual([])
|
||||
})
|
||||
|
||||
it("closes local ACP state and aborts the backing session best-effort", async () => {
|
||||
@@ -1018,52 +994,6 @@ describe("ACP service sessions", () => {
|
||||
expect(usageUpdates).toEqual([session.sessionId])
|
||||
})
|
||||
|
||||
it("maps assistant prompt errors to request errors instead of end turn", async () => {
|
||||
const { service } = makeService([], {
|
||||
prompt: () =>
|
||||
Promise.resolve({
|
||||
data: {
|
||||
info: assistantInfo(
|
||||
{ input: 8, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
|
||||
{ name: "APIError", data: { message: "Provider request failed", isRetryable: false } },
|
||||
),
|
||||
},
|
||||
}),
|
||||
})
|
||||
const session = await Effect.runPromise(service.newSession({ cwd: "/workspace", mcpServers: [] }))
|
||||
|
||||
const error = await Effect.runPromise(
|
||||
service
|
||||
.prompt({ sessionId: session.sessionId, prompt: [{ type: "text", text: "hello" }] })
|
||||
.pipe(Effect.mapError(ACPError.toRequestError), Effect.flip),
|
||||
)
|
||||
|
||||
expect(error.code).toBe(-32603)
|
||||
expect(error.message).toBe("Internal error: Provider request failed")
|
||||
expect(error.data).toEqual({ service: "session", errorName: "APIError" })
|
||||
})
|
||||
|
||||
it("maps aborted assistant prompt errors to cancelled", async () => {
|
||||
const { service } = makeService([], {
|
||||
prompt: () =>
|
||||
Promise.resolve({
|
||||
data: {
|
||||
info: assistantInfo(
|
||||
{ input: 8, output: 1, reasoning: 0, cache: { read: 0, write: 0 } },
|
||||
{ name: "MessageAbortedError", data: { message: "Aborted" } },
|
||||
),
|
||||
},
|
||||
}),
|
||||
})
|
||||
const session = await Effect.runPromise(service.newSession({ cwd: "/workspace", mcpServers: [] }))
|
||||
|
||||
const result = await Effect.runPromise(
|
||||
service.prompt({ sessionId: session.sessionId, prompt: [{ type: "text", text: "hello" }] }),
|
||||
)
|
||||
|
||||
expect(result.stopReason).toBe("cancelled")
|
||||
})
|
||||
|
||||
it("prompt maps assistant and user audience annotations", async () => {
|
||||
const { service, prompts } = makeService()
|
||||
const session = await Effect.runPromise(service.newSession({ cwd: "/workspace", mcpServers: [] }))
|
||||
@@ -1215,17 +1145,13 @@ describe("ACP service sessions", () => {
|
||||
})
|
||||
})
|
||||
|
||||
function assistantInfo(
|
||||
tokens: UsageService.AssistantTokenCost["tokens"],
|
||||
error?: AssistantMessage["error"],
|
||||
): UsageService.AssistantMessage & Pick<AssistantMessage, "error"> {
|
||||
function assistantInfo(tokens: UsageService.AssistantTokenCost["tokens"]): UsageService.AssistantMessage {
|
||||
return {
|
||||
role: "assistant",
|
||||
providerID: "test",
|
||||
modelID: "test-model",
|
||||
cost: 0,
|
||||
tokens,
|
||||
...(error ? { error } : {}),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -2,11 +2,9 @@ import { resolve } from "path"
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import {
|
||||
completedToolContent,
|
||||
completedToolUpdate,
|
||||
completedToolRawOutput,
|
||||
extractImageAttachments,
|
||||
imageContents,
|
||||
pendingToolCall,
|
||||
shellOutputSnapshot,
|
||||
toLocations,
|
||||
toToolKind,
|
||||
@@ -113,85 +111,6 @@ describe("acp tool conversion", () => {
|
||||
])
|
||||
})
|
||||
|
||||
test("sends completed tool calls as partial updates", () => {
|
||||
expect(
|
||||
pendingToolCall({
|
||||
toolCallId: "tool-1",
|
||||
toolName: "edit",
|
||||
state: {
|
||||
input: {
|
||||
filePath: "/tmp/file.ts",
|
||||
oldString: "before",
|
||||
newString: "after",
|
||||
},
|
||||
},
|
||||
}),
|
||||
).toMatchObject({
|
||||
kind: "edit",
|
||||
locations: [{ path: "/tmp/file.ts" }],
|
||||
rawInput: {
|
||||
filePath: "/tmp/file.ts",
|
||||
oldString: "before",
|
||||
newString: "after",
|
||||
},
|
||||
})
|
||||
|
||||
expect(
|
||||
completedToolUpdate({
|
||||
toolCallId: "tool-1",
|
||||
toolName: "edit",
|
||||
state: {
|
||||
status: "completed",
|
||||
input: {
|
||||
filePath: "/tmp/file.ts",
|
||||
oldString: "before",
|
||||
newString: "after",
|
||||
},
|
||||
output: "Edit applied successfully.",
|
||||
},
|
||||
}),
|
||||
).toEqual({
|
||||
toolCallId: "tool-1",
|
||||
status: "completed",
|
||||
content: [
|
||||
{
|
||||
type: "content",
|
||||
content: { type: "text", text: "Edit applied successfully." },
|
||||
},
|
||||
{
|
||||
type: "diff",
|
||||
path: "/tmp/file.ts",
|
||||
oldText: "before",
|
||||
newText: "after",
|
||||
},
|
||||
],
|
||||
rawOutput: {
|
||||
output: "Edit applied successfully.",
|
||||
},
|
||||
})
|
||||
|
||||
expect(
|
||||
completedToolUpdate({
|
||||
toolCallId: "tool-1",
|
||||
toolName: "edit",
|
||||
state: {
|
||||
status: "completed",
|
||||
input: {
|
||||
filePath: "/tmp/file.ts",
|
||||
oldString: "before",
|
||||
newString: "after",
|
||||
},
|
||||
title: "file.ts",
|
||||
output: "Edit applied successfully.",
|
||||
},
|
||||
}),
|
||||
).toMatchObject({
|
||||
toolCallId: "tool-1",
|
||||
status: "completed",
|
||||
title: "file.ts",
|
||||
})
|
||||
})
|
||||
|
||||
test("uses clean read display text for completed content", () => {
|
||||
const output = [
|
||||
"<path>/tmp/file.ts</path>",
|
||||
|
||||
+1
-1
@@ -33,7 +33,7 @@
|
||||
"headers": {
|
||||
"content-type": "application/json"
|
||||
},
|
||||
"body": "{\"model\":\"gpt-5.5\",\"input\":[{\"role\":\"user\",\"content\":[{\"type\":\"input_text\",\"text\":\"What is the weather in Paris?\"}]},{\"type\":\"reasoning\",\"summary\":[],\"encrypted_content\":\"gAAAAABqEhTUCQT4XELlBu6r5VHqqtu5Il5WdX4m1upE8li0mPmIwgIykAmUTZWiE0213kmviuAgIrmhhiL4B8DXbWQD2vOEkQMhpZq_UCqc22SOg-4DpQLrebMWkzgAPL618VPu9mXNUIH9BW1sRhPdDSbbtK5_bitzsn-FMJGcO3UN7Ga2RW1Rdvt1M3m7J4MRlTutH8cwY8SthzgvOFEBS-_IrAhiwKVz4Se9Jlu3pVNMqhPF7kdrQOfDYui0v-AT8VrHBVomqekJl_dWESww0eWo6bS1PxZB4cLQHWp9JJi5pEECvU9Ntcz3GxuGJEtTKq5mFcRvCanXHOwZGmbBcWMNdVyikk3fxgIE2g9t8rCKJmhNXznMERtrfG2tey19qWbsVbo2YmBbg_5N02AA4NmEVvdfgHJx58nOfEEc2OZYk0YQ1fHBOkpBnwY61hxtrWFdj48QnTEKuvjAyNpX-KKFmMzL4531yLbEEzpaERlr11fDeoMpKofUoMsg3Jz8aTaZ1CpzI3O7iFzGDEV6gKh8vQYGrKOaOXnfBVDXDo8iJhZywpcQY6xB4NNf4pyyjFkR-vjgvBYV2hejlq2V1j8vQHgy8CsZJ6lW5oaTNMfP76MAHlwUwyMYj-cFmuX0epJdDWv8GDznUpOS-v2X5eNsvyx9qvvcTEMLsKJ--3_odisilj4vPhw16P9fB8eLmvESZmJRYmWM4mO7hPTVXOooOa-zxRHGhRQH9ouUea9UHSuH1A0o54qTEPr-JqYlQggugW449IuYW4HSMNMyeGdUNJfodWRu5cL0VPgk6zwTU3ArBq28FDgG7NZMk3njfCId351GZ8VRlTMA6U522_6FFaZ8-5gxsidOm0WULOwyTTo54tJsJFv2pgYUKs0VFWSwi3rvNMVMOgwOVIdSgZt1hFTxBImZh8HUIXUPvdOVKZzQmWT5M6uOTUsm5xsufhj8m79RuYZh2J0bkVOBzZ1As8zH-4v_r9d7e8464EuWXCln_6LAJdrTYgE2gVfHK0zeUaAMbIKhirOf0AVQZyfVsGvJ_CPqrPE_QSECeSA2D4TSa5Tc_IRY-Fb2_HKNCMEP2uvy\"},{\"type\":\"function_call\",\"call_id\":\"call_Ix5Urx04RtKsUJ75K0vTTgFF\",\"name\":\"get_weather\",\"arguments\":\"{\\\"city\\\":{}}\"},{\"type\":\"function_call_output\",\"call_id\":\"call_Ix5Urx04RtKsUJ75K0vTTgFF\",\"output\":\"{\\\"temperature\\\":22,\\\"condition\\\":\\\"sunny\\\"}\"}],\"instructions\":\"Answer using tools when appropriate.\\nUse the get_weather tool exactly once to look up Paris, then reply with exactly: Paris is sunny.\",\"tools\":[{\"type\":\"function\",\"name\":\"get_weather\",\"description\":\"Get the current weather for a city.\",\"parameters\":{\"$schema\":\"http://json-schema.org/draft-07/schema#\",\"type\":\"object\",\"properties\":{\"city\":{\"type\":\"string\"}},\"required\":[\"city\"],\"additionalProperties\":false},\"strict\":false}],\"store\":false,\"prompt_cache_key\":\"session-recorded-openai-oauth-loop\",\"include\":[\"reasoning.encrypted_content\"],\"reasoning\":{\"effort\":\"medium\",\"summary\":\"auto\"},\"text\":{\"verbosity\":\"low\"},\"stream\":true}"
|
||||
"body": "{\"model\":\"gpt-5.5\",\"input\":[{\"role\":\"user\",\"content\":[{\"type\":\"input_text\",\"text\":\"What is the weather in Paris?\"}]},{\"type\":\"reasoning\",\"id\":\"rs_0812d6cbe7a2b19b016a1214d32f6881998bcd9ff2e739d7f2\",\"summary\":[],\"encrypted_content\":\"gAAAAABqEhTUCQT4XELlBu6r5VHqqtu5Il5WdX4m1upE8li0mPmIwgIykAmUTZWiE0213kmviuAgIrmhhiL4B8DXbWQD2vOEkQMhpZq_UCqc22SOg-4DpQLrebMWkzgAPL618VPu9mXNUIH9BW1sRhPdDSbbtK5_bitzsn-FMJGcO3UN7Ga2RW1Rdvt1M3m7J4MRlTutH8cwY8SthzgvOFEBS-_IrAhiwKVz4Se9Jlu3pVNMqhPF7kdrQOfDYui0v-AT8VrHBVomqekJl_dWESww0eWo6bS1PxZB4cLQHWp9JJi5pEECvU9Ntcz3GxuGJEtTKq5mFcRvCanXHOwZGmbBcWMNdVyikk3fxgIE2g9t8rCKJmhNXznMERtrfG2tey19qWbsVbo2YmBbg_5N02AA4NmEVvdfgHJx58nOfEEc2OZYk0YQ1fHBOkpBnwY61hxtrWFdj48QnTEKuvjAyNpX-KKFmMzL4531yLbEEzpaERlr11fDeoMpKofUoMsg3Jz8aTaZ1CpzI3O7iFzGDEV6gKh8vQYGrKOaOXnfBVDXDo8iJhZywpcQY6xB4NNf4pyyjFkR-vjgvBYV2hejlq2V1j8vQHgy8CsZJ6lW5oaTNMfP76MAHlwUwyMYj-cFmuX0epJdDWv8GDznUpOS-v2X5eNsvyx9qvvcTEMLsKJ--3_odisilj4vPhw16P9fB8eLmvESZmJRYmWM4mO7hPTVXOooOa-zxRHGhRQH9ouUea9UHSuH1A0o54qTEPr-JqYlQggugW449IuYW4HSMNMyeGdUNJfodWRu5cL0VPgk6zwTU3ArBq28FDgG7NZMk3njfCId351GZ8VRlTMA6U522_6FFaZ8-5gxsidOm0WULOwyTTo54tJsJFv2pgYUKs0VFWSwi3rvNMVMOgwOVIdSgZt1hFTxBImZh8HUIXUPvdOVKZzQmWT5M6uOTUsm5xsufhj8m79RuYZh2J0bkVOBzZ1As8zH-4v_r9d7e8464EuWXCln_6LAJdrTYgE2gVfHK0zeUaAMbIKhirOf0AVQZyfVsGvJ_CPqrPE_QSECeSA2D4TSa5Tc_IRY-Fb2_HKNCMEP2uvy\"},{\"type\":\"function_call\",\"call_id\":\"call_Ix5Urx04RtKsUJ75K0vTTgFF\",\"name\":\"get_weather\",\"arguments\":\"{\\\"city\\\":{}}\"},{\"type\":\"function_call_output\",\"call_id\":\"call_Ix5Urx04RtKsUJ75K0vTTgFF\",\"output\":\"{\\\"temperature\\\":22,\\\"condition\\\":\\\"sunny\\\"}\"}],\"instructions\":\"Answer using tools when appropriate.\\nUse the get_weather tool exactly once to look up Paris, then reply with exactly: Paris is sunny.\",\"tools\":[{\"type\":\"function\",\"name\":\"get_weather\",\"description\":\"Get the current weather for a city.\",\"parameters\":{\"$schema\":\"http://json-schema.org/draft-07/schema#\",\"type\":\"object\",\"properties\":{\"city\":{\"type\":\"string\"}},\"required\":[\"city\"],\"additionalProperties\":false},\"strict\":false}],\"store\":false,\"prompt_cache_key\":\"session-recorded-openai-oauth-loop\",\"include\":[\"reasoning.encrypted_content\"],\"reasoning\":{\"effort\":\"medium\",\"summary\":\"auto\"},\"text\":{\"verbosity\":\"low\"},\"stream\":true}"
|
||||
},
|
||||
"response": {
|
||||
"status": 200,
|
||||
|
||||
+1
-1
@@ -35,7 +35,7 @@
|
||||
"headers": {
|
||||
"content-type": "application/json"
|
||||
},
|
||||
"body": "{\"model\":\"gpt-5.2-codex\",\"input\":[{\"role\":\"system\",\"content\":\"Answer using tools when appropriate.\\nUse the get_weather tool exactly once to look up Paris, then reply with exactly: Paris is sunny.\"},{\"role\":\"user\",\"content\":[{\"type\":\"input_text\",\"text\":\"What is the weather in Paris?\"}]},{\"type\":\"reasoning\",\"summary\":[],\"encrypted_content\":\"gAAAAABqEhTTGeallj_mC3ciDydiTVJLA6bjJfitoj4ftFfWwlxekFNaf_cDNWP3pE6qsvK9gKJNRfbAbpaEVf1qjAhQx53witrmt6H3KaaNJm3wXHG5sEi9gp3nLWK4T76tcVYHG1x6mbbTjEjCvhIuEkn_7Q7lJ1BErkEURYBBMPmkKya2-YuL8XP14Yrko9BA1t56BkwK5U3TFse4nwHI1qi82hdkX_aYAtz6YgbTpf-dvOCBGfeApxWLFotkt355Qy2b6MmPaH6cQwrvLJXOqEzGkwxFcs3mLEKLV103gd8Z5e_OapjJHTv_LarN-WN9C7nCQ0BBHClk4ND3SDdGb-XV665r23RB40GJ3Q9brJALGaJhij4uceXZNYbakZVOxgqLuDnX6EgABwEzrZb7vhVAKCewVYkLDu0LiS1rIvcFT8HpovxaBU2F2kVG7TRvzYewCW9zXWnAR048p5pUvi6zfMzapk8bnl4uM_uD45gp1sMzeSHryai1U0AUO2cLeQV1pA7KJoJBwWlHxo0YNPbDidI2KfByIoI0A7oiKoZ32vJkiwx3BEGePnzb-JQnv1eDXwlimICVKEVPk1BxpUZ2XBoWdUGYR77u5NGmZ2sKh4OM-qIaB0VaChGsCsJLyQ5_MCkeOm9EMjg1cXbIHDzs9jpF2BXlowY1Vw_L-Ve6nzwK7ZcyHM3ij27wEXYO2On6zbN_AqOvX_CFAjI7ktCYF2guftXuVpFCuiqRyDZ6i2RHXMhR77CoPT97sAvXDejN8feNtidqq4OH5uLa3BHYvW0UKfNlBCOL6A6927l4iTKURZznq_mVjLgTHWv9k-ByxP0hC5sIQHyB5hJaD8_svMr4Aqz_vH9Z8HShgjK47NsMQKxGGgaXdnq3xEdwydM-hTG4Pi35o6Kt0bbJ5KTRQ2ObjmnVTG7J__QTKMTrK2S6Ro4VIMrYzaai7BTLa8MGNotj\"},{\"type\":\"function_call\",\"call_id\":\"call_hwPdXfzZmrdySXU2ZmrL51Ln\",\"name\":\"get_weather\",\"arguments\":\"{\\\"city\\\":{}}\"},{\"type\":\"function_call_output\",\"call_id\":\"call_hwPdXfzZmrdySXU2ZmrL51Ln\",\"output\":\"{\\\"temperature\\\":22,\\\"condition\\\":\\\"sunny\\\"}\"}],\"tools\":[{\"type\":\"function\",\"name\":\"get_weather\",\"description\":\"Get the current weather for a city.\",\"parameters\":{\"$schema\":\"http://json-schema.org/draft-07/schema#\",\"type\":\"object\",\"properties\":{\"city\":{\"type\":\"string\"}},\"required\":[\"city\"],\"additionalProperties\":false},\"strict\":false}],\"store\":false,\"prompt_cache_key\":\"session-recorded-opencode-loop\",\"include\":[\"reasoning.encrypted_content\"],\"reasoning\":{\"effort\":\"medium\",\"summary\":\"auto\"},\"max_output_tokens\":32000,\"stream\":true}"
|
||||
"body": "{\"model\":\"gpt-5.2-codex\",\"input\":[{\"role\":\"system\",\"content\":\"Answer using tools when appropriate.\\nUse the get_weather tool exactly once to look up Paris, then reply with exactly: Paris is sunny.\"},{\"role\":\"user\",\"content\":[{\"type\":\"input_text\",\"text\":\"What is the weather in Paris?\"}]},{\"type\":\"reasoning\",\"id\":\"rs_0fdce240b46054ad016a1214d326848196b269feebe1844759\",\"summary\":[],\"encrypted_content\":\"gAAAAABqEhTTGeallj_mC3ciDydiTVJLA6bjJfitoj4ftFfWwlxekFNaf_cDNWP3pE6qsvK9gKJNRfbAbpaEVf1qjAhQx53witrmt6H3KaaNJm3wXHG5sEi9gp3nLWK4T76tcVYHG1x6mbbTjEjCvhIuEkn_7Q7lJ1BErkEURYBBMPmkKya2-YuL8XP14Yrko9BA1t56BkwK5U3TFse4nwHI1qi82hdkX_aYAtz6YgbTpf-dvOCBGfeApxWLFotkt355Qy2b6MmPaH6cQwrvLJXOqEzGkwxFcs3mLEKLV103gd8Z5e_OapjJHTv_LarN-WN9C7nCQ0BBHClk4ND3SDdGb-XV665r23RB40GJ3Q9brJALGaJhij4uceXZNYbakZVOxgqLuDnX6EgABwEzrZb7vhVAKCewVYkLDu0LiS1rIvcFT8HpovxaBU2F2kVG7TRvzYewCW9zXWnAR048p5pUvi6zfMzapk8bnl4uM_uD45gp1sMzeSHryai1U0AUO2cLeQV1pA7KJoJBwWlHxo0YNPbDidI2KfByIoI0A7oiKoZ32vJkiwx3BEGePnzb-JQnv1eDXwlimICVKEVPk1BxpUZ2XBoWdUGYR77u5NGmZ2sKh4OM-qIaB0VaChGsCsJLyQ5_MCkeOm9EMjg1cXbIHDzs9jpF2BXlowY1Vw_L-Ve6nzwK7ZcyHM3ij27wEXYO2On6zbN_AqOvX_CFAjI7ktCYF2guftXuVpFCuiqRyDZ6i2RHXMhR77CoPT97sAvXDejN8feNtidqq4OH5uLa3BHYvW0UKfNlBCOL6A6927l4iTKURZznq_mVjLgTHWv9k-ByxP0hC5sIQHyB5hJaD8_svMr4Aqz_vH9Z8HShgjK47NsMQKxGGgaXdnq3xEdwydM-hTG4Pi35o6Kt0bbJ5KTRQ2ObjmnVTG7J__QTKMTrK2S6Ro4VIMrYzaai7BTLa8MGNotj\"},{\"type\":\"function_call\",\"call_id\":\"call_hwPdXfzZmrdySXU2ZmrL51Ln\",\"name\":\"get_weather\",\"arguments\":\"{\\\"city\\\":{}}\"},{\"type\":\"function_call_output\",\"call_id\":\"call_hwPdXfzZmrdySXU2ZmrL51Ln\",\"output\":\"{\\\"temperature\\\":22,\\\"condition\\\":\\\"sunny\\\"}\"}],\"tools\":[{\"type\":\"function\",\"name\":\"get_weather\",\"description\":\"Get the current weather for a city.\",\"parameters\":{\"$schema\":\"http://json-schema.org/draft-07/schema#\",\"type\":\"object\",\"properties\":{\"city\":{\"type\":\"string\"}},\"required\":[\"city\"],\"additionalProperties\":false},\"strict\":false}],\"store\":false,\"prompt_cache_key\":\"session-recorded-opencode-loop\",\"include\":[\"reasoning.encrypted_content\"],\"reasoning\":{\"effort\":\"medium\",\"summary\":\"auto\"},\"max_output_tokens\":32000,\"stream\":true}"
|
||||
},
|
||||
"response": {
|
||||
"status": 200,
|
||||
|
||||
@@ -23,8 +23,6 @@ let simulateAuthFlow = true
|
||||
let connectSucceedsImmediately = false
|
||||
let serverCapabilities: { tools?: object; resources?: object } = { tools: {} }
|
||||
let listToolsCalls = 0
|
||||
let finishAuthFails = false
|
||||
let finishAuthStoresCredentials = false
|
||||
|
||||
// Mock the transport constructors to simulate OAuth auto-auth on 401
|
||||
void mock.module("@modelcontextprotocol/sdk/client/streamableHttp.js", () => ({
|
||||
@@ -34,10 +32,6 @@ void mock.module("@modelcontextprotocol/sdk/client/streamableHttp.js", () => ({
|
||||
state?: () => Promise<string>
|
||||
redirectToAuthorization?: (url: URL) => Promise<void>
|
||||
saveCodeVerifier?: (v: string) => Promise<void>
|
||||
tokens?: () => Promise<{ access_token: string } | undefined>
|
||||
clientInformation?: () => Promise<{ client_id: string } | undefined>
|
||||
saveClientInformation?: (info: { client_id: string; client_secret?: string }) => Promise<void>
|
||||
saveTokens?: (tokens: { access_token: string; token_type: string }) => Promise<void>
|
||||
}
|
||||
| undefined
|
||||
constructor(url: URL, options?: { authProvider?: unknown }) {
|
||||
@@ -55,8 +49,6 @@ void mock.module("@modelcontextprotocol/sdk/client/streamableHttp.js", () => ({
|
||||
// It calls auth() which eventually calls provider.state(), then
|
||||
// provider.redirectToAuthorization(), then throws UnauthorizedError.
|
||||
if (simulateAuthFlow && this.authProvider) {
|
||||
if (await this.authProvider.tokens?.()) throw new MockUnauthorizedError()
|
||||
if (await this.authProvider.clientInformation?.()) throw new MockUnauthorizedError()
|
||||
// The SDK calls provider.state() to get the OAuth state parameter
|
||||
if (this.authProvider.state) {
|
||||
await this.authProvider.state()
|
||||
@@ -73,14 +65,7 @@ void mock.module("@modelcontextprotocol/sdk/client/streamableHttp.js", () => ({
|
||||
}
|
||||
throw new MockUnauthorizedError()
|
||||
}
|
||||
async finishAuth(_code: string) {
|
||||
if (finishAuthFails) throw new Error("Token exchange failed")
|
||||
if (finishAuthStoresCredentials) {
|
||||
await this.authProvider?.saveClientInformation?.({ client_id: "replacement-client" })
|
||||
await this.authProvider?.saveTokens?.({ access_token: "replacement-token", token_type: "Bearer" })
|
||||
}
|
||||
}
|
||||
async close() {}
|
||||
async finishAuth(_code: string) {}
|
||||
},
|
||||
}))
|
||||
|
||||
@@ -140,8 +125,6 @@ beforeEach(() => {
|
||||
connectSucceedsImmediately = false
|
||||
serverCapabilities = { tools: {} }
|
||||
listToolsCalls = 0
|
||||
finishAuthFails = false
|
||||
finishAuthStoresCredentials = false
|
||||
})
|
||||
|
||||
// Import modules after mocking
|
||||
@@ -150,7 +133,6 @@ const { EventV2Bridge } = await import("../../src/event-v2-bridge")
|
||||
const { Config } = await import("../../src/config/config")
|
||||
const { McpAuth } = await import("../../src/mcp/auth")
|
||||
const { McpOAuthProvider } = await import("../../src/mcp/oauth-provider")
|
||||
const { McpOAuthCallback } = await import("../../src/mcp/oauth-callback")
|
||||
const { FSUtil } = await import("@opencode-ai/core/fs-util")
|
||||
const { CrossSpawnSpawner } = await import("@opencode-ai/core/cross-spawn-spawner")
|
||||
|
||||
@@ -245,59 +227,6 @@ mcpTest.instance("state() returns existing state when one is saved", () =>
|
||||
}),
|
||||
)
|
||||
|
||||
mcpTest.instance(
|
||||
"failed reauthentication preserves existing credentials",
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
yield* Effect.addFinalizer(() => Effect.promise(() => McpOAuthCallback.stop()).pipe(Effect.ignore))
|
||||
const mcp = yield* MCP.Service
|
||||
const auth = yield* McpAuth.Service
|
||||
const name = "test-reauth-failure"
|
||||
const url = "https://example.com/mcp"
|
||||
const clientInfo = { clientId: "dynamic-client", clientSecret: "dynamic-secret" }
|
||||
|
||||
yield* auth.updateClientInfo(name, clientInfo, url)
|
||||
yield* auth.updateTokens(name, { accessToken: "working-token" }, url)
|
||||
expect((yield* mcp.startAuth(name)).authorizationUrl).toContain("https://auth.example.com/authorize")
|
||||
finishAuthFails = true
|
||||
|
||||
expect(yield* mcp.finishAuth(name, "invalid-code")).toEqual({
|
||||
status: "failed",
|
||||
error: "OAuth completion failed",
|
||||
})
|
||||
const entry = yield* auth.get(name)
|
||||
expect(entry?.tokens?.accessToken).toBe("working-token")
|
||||
expect(entry?.clientInfo).toEqual(clientInfo)
|
||||
}),
|
||||
{ config: config("test-reauth-failure") },
|
||||
)
|
||||
|
||||
mcpTest.instance(
|
||||
"successful reauthentication commits replacement credentials",
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
yield* Effect.addFinalizer(() => Effect.promise(() => McpOAuthCallback.stop()).pipe(Effect.ignore))
|
||||
const mcp = yield* MCP.Service
|
||||
const auth = yield* McpAuth.Service
|
||||
const name = "test-reauth-success"
|
||||
const url = "https://example.com/mcp"
|
||||
|
||||
yield* auth.updateClientInfo(name, { clientId: "old-client" }, url)
|
||||
yield* auth.updateTokens(name, { accessToken: "old-token" }, url)
|
||||
expect((yield* mcp.startAuth(name)).authorizationUrl).toContain("https://auth.example.com/authorize")
|
||||
expect((yield* auth.get(name))?.tokens?.accessToken).toBe("old-token")
|
||||
finishAuthStoresCredentials = true
|
||||
connectSucceedsImmediately = true
|
||||
|
||||
expect((yield* mcp.finishAuth(name, "valid-code")).status).toBe("connected")
|
||||
const entry = yield* auth.get(name)
|
||||
expect(entry?.tokens?.accessToken).toBe("replacement-token")
|
||||
expect(entry?.clientInfo?.clientId).toBe("replacement-client")
|
||||
expect(entry?.serverUrl).toBe(url)
|
||||
}),
|
||||
{ config: config("test-reauth-success") },
|
||||
)
|
||||
|
||||
mcpTest.instance(
|
||||
"auth status only reports credentials stored for the configured server URL",
|
||||
() =>
|
||||
|
||||
@@ -101,7 +101,7 @@ describe("McpOAuthCallback.ensureRunning", () => {
|
||||
`${redirectUri}?state=test&error=access_denied&error_description=${encodeURIComponent("The user denied access")}`,
|
||||
)
|
||||
|
||||
expect(await response.text()).toContain('<pre class="detail" id="oc-detail">The user denied access</pre>')
|
||||
expect(await response.text()).toContain('<div class="error">The user denied access</div>')
|
||||
})
|
||||
|
||||
test("binds the callback server to IPv4 loopback", async () => {
|
||||
|
||||
@@ -964,7 +964,6 @@ const scenarios: Scenario[] = [
|
||||
headers: ctx.headers(),
|
||||
}))
|
||||
.status(400, undefined, "none"),
|
||||
http.protected.get("/api/session/active", "v2.session.active").json(200, data(object), "none"),
|
||||
http.protected
|
||||
.post("/api/session", "v2.session.create")
|
||||
.at((ctx) => ({
|
||||
|
||||
@@ -34,7 +34,7 @@ import { resetDatabase } from "../fixture/db"
|
||||
import { disposeAllInstances, provideInstanceEffect, TestInstance, tmpdirScoped } from "../fixture/fixture"
|
||||
import { TestLLMServer } from "../lib/llm-server"
|
||||
import { testProviderConfig } from "../lib/test-provider"
|
||||
import { pollWithTimeout, testEffect } from "../lib/effect"
|
||||
import { testEffect } from "../lib/effect"
|
||||
|
||||
const originalWorkspaces = Flag.OPENCODE_EXPERIMENTAL_WORKSPACES
|
||||
const workspaceLayer = Workspace.defaultLayer.pipe(
|
||||
@@ -581,7 +581,7 @@ describe("session HttpApi", () => {
|
||||
request(`/api/session/${session.id}/prompt`, {
|
||||
method: "POST",
|
||||
headers: { ...headers, "content-type": "application/json" },
|
||||
body: JSON.stringify({ id: "msg_http_prompt", prompt: { text: "hello" }, resume: false }),
|
||||
body: JSON.stringify({ id: "msg_http_prompt", prompt: { text: "hello" } }),
|
||||
})
|
||||
const first = yield* recordPrompt()
|
||||
const retried = yield* recordPrompt()
|
||||
@@ -624,22 +624,6 @@ describe("session HttpApi", () => {
|
||||
message: "Prompt message ID conflicts with an existing durable record: msg_http_prompt",
|
||||
resource: "msg_http_prompt",
|
||||
})
|
||||
|
||||
const wakeID = SessionMessage.ID.make("msg_http_wake")
|
||||
const wake = yield* request(`/api/session/${session.id}/prompt`, {
|
||||
method: "POST",
|
||||
headers: { ...headers, "content-type": "application/json" },
|
||||
body: JSON.stringify({ id: wakeID, prompt: { text: "hello again" } }),
|
||||
})
|
||||
expect(wake.status).toBe(200)
|
||||
const message = yield* pollWithTimeout(
|
||||
requestJson<{ data: SessionMessage.Message[] }>(`/api/session/${session.id}/message`, { headers }).pipe(
|
||||
Effect.map(({ data }) => data.find((message) => message.id === wakeID)),
|
||||
),
|
||||
"V2 prompt was not promoted after wake",
|
||||
"10 seconds",
|
||||
)
|
||||
expect(message).toMatchObject({ id: wakeID, type: "user" })
|
||||
}),
|
||||
{ git: true, config: { formatter: false, lsp: false } },
|
||||
)
|
||||
|
||||
@@ -613,7 +613,8 @@ describe("session.compaction.create", () => {
|
||||
})
|
||||
|
||||
const v2 = yield* SessionV2.Service.use((svc) => svc.messages({ sessionID: info.id })).pipe(
|
||||
Effect.provide(SessionV2.defaultLayer.pipe(Layer.provide(SessionExecution.noopLayer))),
|
||||
Effect.provide(SessionExecution.noopLayer),
|
||||
Effect.provide(SessionV2.defaultLayer),
|
||||
)
|
||||
expect(v2.at(-1)).toMatchObject({
|
||||
type: "compaction",
|
||||
@@ -853,12 +854,12 @@ describe("session.compaction.process", () => {
|
||||
const msg = yield* createUserMessage(session.id, "hello")
|
||||
const msgs = yield* ssn.messages({ sessionID: session.id })
|
||||
const done = yield* Deferred.make<void, Error>()
|
||||
const seen: string[] = []
|
||||
let seen = false
|
||||
const unsub = yield* events.listen((evt) => {
|
||||
seen.push(evt.type)
|
||||
if (evt.type !== SessionCompaction.Event.Compacted.type) return Effect.void
|
||||
if ((evt.data as typeof SessionCompaction.Event.Compacted.data.Type).sessionID !== session.id)
|
||||
return Effect.void
|
||||
seen = true
|
||||
Deferred.doneUnsafe(done, Effect.void)
|
||||
return Effect.void
|
||||
})
|
||||
@@ -873,8 +874,7 @@ describe("session.compaction.process", () => {
|
||||
|
||||
yield* Deferred.await(done).pipe(Effect.timeout("500 millis"))
|
||||
expect(result).toBe("continue")
|
||||
expect(seen).toContain(SessionCompaction.Event.Compacted.type)
|
||||
expect(seen.filter((type) => type.startsWith("session.next."))).toEqual([])
|
||||
expect(seen).toBe(true)
|
||||
}),
|
||||
)
|
||||
|
||||
|
||||
@@ -115,9 +115,10 @@ const storedSession = {
|
||||
const openAIResponses = {
|
||||
user: (text: string) => ({ role: "user", content: [{ type: "input_text", text }] }),
|
||||
assistant: (text: string) => ({ role: "assistant", content: [{ type: "output_text", text }] }),
|
||||
openaiReasoning: (text: string, encryptedContent: string) => ({
|
||||
openaiReasoning: (text: string, options: { readonly itemId: string; readonly encryptedContent: string }) => ({
|
||||
type: "reasoning",
|
||||
encrypted_content: encryptedContent,
|
||||
id: options.itemId,
|
||||
encrypted_content: options.encryptedContent,
|
||||
summary: [{ type: "summary_text", text }],
|
||||
}),
|
||||
}
|
||||
@@ -656,7 +657,10 @@ describe("session.llm-native.request", () => {
|
||||
expectedBody: {
|
||||
input: [
|
||||
openAIResponses.user("What changed?"),
|
||||
openAIResponses.openaiReasoning("Checked the previous diff.", "encrypted-state"),
|
||||
openAIResponses.openaiReasoning("Checked the previous diff.", {
|
||||
itemId: "rs_1",
|
||||
encryptedContent: "encrypted-state",
|
||||
}),
|
||||
openAIResponses.assistant("The parser changed."),
|
||||
openAIResponses.user("Summarize it."),
|
||||
],
|
||||
@@ -679,7 +683,7 @@ describe("session.llm-native.request", () => {
|
||||
],
|
||||
providerOptions: { openai: { store: false, include: ["reasoning.encrypted_content"] } },
|
||||
expectedBody: {
|
||||
input: [{ type: "reasoning", summary: [], encrypted_content: "encrypted-state" }],
|
||||
input: [{ type: "reasoning", id: "rs_1", summary: [], encrypted_content: "encrypted-state" }],
|
||||
include: ["reasoning.encrypted_content"],
|
||||
store: false,
|
||||
},
|
||||
|
||||
@@ -24,6 +24,7 @@ import { raw, reply, TestLLMServer } from "../lib/llm-server"
|
||||
import { RuntimeFlags } from "@/effect/runtime-flags"
|
||||
import { ProviderV2 } from "@opencode-ai/core/provider"
|
||||
import { ModelV2 } from "@opencode-ai/core/model"
|
||||
import { SessionEvent } from "@opencode-ai/core/session/event"
|
||||
import { SessionProjector } from "@opencode-ai/core/session/projector"
|
||||
import { LLMEvent } from "@opencode-ai/llm"
|
||||
|
||||
@@ -980,9 +981,10 @@ itProviderError.live("session.processor effect tests fail provider-executed erro
|
||||
const parent = yield* user(chat.id, "provider tool error")
|
||||
const msg = yield* assistant(chat.id, parent.id, path.resolve(dir))
|
||||
const mdl = yield* provider.getModel(ref.providerID, ref.modelID)
|
||||
const seen: string[] = []
|
||||
const settlements: Array<typeof SessionEvent.Tool.Failed.Type> = []
|
||||
const off = yield* events.listen((event) => {
|
||||
seen.push(event.type)
|
||||
if (event.type === SessionEvent.Tool.Failed.type)
|
||||
settlements.push(event as typeof SessionEvent.Tool.Failed.Type)
|
||||
return Effect.void
|
||||
})
|
||||
const handle = yield* processors.create({ assistantMessage: msg, sessionID: chat.id, model: mdl })
|
||||
@@ -1009,15 +1011,19 @@ itProviderError.live("session.processor effect tests fail provider-executed erro
|
||||
const call = parts.find((part): part is SessionV1.ToolPart => part.type === "tool")
|
||||
expect(call?.state.status).toBe("error")
|
||||
if (call?.state.status === "error") expect(call.state.error).toBe("provider boom")
|
||||
expect(seen).toContain(MessageV2.Event.PartUpdated.type)
|
||||
expect(seen).toContain(MessageV2.Event.Updated.type)
|
||||
expect(seen.filter((type) => type.startsWith("session.next."))).toEqual([])
|
||||
expect(settlements).toHaveLength(1)
|
||||
expect(settlements[0]?.data).toMatchObject({
|
||||
callID: "call-1",
|
||||
error: { type: "unknown", message: "provider boom" },
|
||||
result: { type: "error", value: "provider boom" },
|
||||
provider: { executed: true },
|
||||
})
|
||||
}),
|
||||
{ config: cfg },
|
||||
),
|
||||
)
|
||||
|
||||
itFragmentFailure.live("session.processor effect tests retain partial legacy parts without v2 events", () =>
|
||||
itFragmentFailure.live("session.processor effect tests flush partial v2 fragments before step failure", () =>
|
||||
provideTmpdirInstance(
|
||||
(dir) =>
|
||||
Effect.gen(function* () {
|
||||
@@ -1029,8 +1035,14 @@ itFragmentFailure.live("session.processor effect tests retain partial legacy par
|
||||
const msg = yield* assistant(chat.id, parent.id, path.resolve(dir))
|
||||
const mdl = yield* provider.getModel(ref.providerID, ref.modelID)
|
||||
const seen: string[] = []
|
||||
let text: string | undefined
|
||||
let reasoning: string | undefined
|
||||
const off = yield* events.listen((event) => {
|
||||
seen.push(event.type)
|
||||
if (event.type === SessionEvent.Text.Ended.type)
|
||||
text = (event.data as typeof SessionEvent.Text.Ended.data.Type).text
|
||||
if (event.type === SessionEvent.Reasoning.Ended.type)
|
||||
reasoning = (event.data as typeof SessionEvent.Reasoning.Ended.data.Type).text
|
||||
return Effect.void
|
||||
})
|
||||
const handle = yield* processors.create({ assistantMessage: msg, sessionID: chat.id, model: mdl })
|
||||
@@ -1055,16 +1067,12 @@ itFragmentFailure.live("session.processor effect tests retain partial legacy par
|
||||
).toBe("stop")
|
||||
yield* off
|
||||
|
||||
const parts = yield* MessageV2.parts(msg.id)
|
||||
expect(parts).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({ type: "text", text: "partial" }),
|
||||
expect.objectContaining({ type: "reasoning", text: "thinking" }),
|
||||
]),
|
||||
)
|
||||
expect(seen).toContain(MessageV2.Event.PartUpdated.type)
|
||||
expect(seen).toContain(Session.Event.Error.type)
|
||||
expect(seen.filter((type) => type.startsWith("session.next."))).toEqual([])
|
||||
const failed = seen.indexOf(SessionEvent.Step.Failed.type)
|
||||
expect(failed).toBeGreaterThan(-1)
|
||||
expect(seen.indexOf(SessionEvent.Text.Ended.type)).toBeLessThan(failed)
|
||||
expect(seen.indexOf(SessionEvent.Reasoning.Ended.type)).toBeLessThan(failed)
|
||||
expect(text).toBe("partial")
|
||||
expect(reasoning).toBe("thinking")
|
||||
}),
|
||||
{ config: cfg },
|
||||
),
|
||||
|
||||
@@ -8,7 +8,7 @@ import { FetchHttpClient } from "effect/unstable/http"
|
||||
import { expect } from "bun:test"
|
||||
import { Cause, Deferred, Duration, Effect, Exit, Fiber, Layer } from "effect"
|
||||
import path from "path"
|
||||
import { fileURLToPath } from "url"
|
||||
import { fileURLToPath, pathToFileURL } from "url"
|
||||
import { NamedError } from "@opencode-ai/core/util/error"
|
||||
import { Agent as AgentSvc } from "../../src/agent/agent"
|
||||
import { BackgroundJob } from "@/background/job"
|
||||
@@ -317,6 +317,11 @@ const writeText = Effect.fn("test.writeText")(function* (file: string, text: str
|
||||
yield* fs.writeWithDirs(file, text)
|
||||
})
|
||||
|
||||
const ensureDir = Effect.fn("test.ensureDir")(function* (dir: string) {
|
||||
const fs = yield* FSUtil.Service
|
||||
yield* fs.ensureDir(dir)
|
||||
})
|
||||
|
||||
const writeConfig = Effect.fn("test.writeConfig")(function* (dir: string, config: Partial<ConfigV1.Info>) {
|
||||
yield* writeText(
|
||||
path.join(dir, "opencode.json"),
|
||||
@@ -549,54 +554,6 @@ withMcpInstructions.instance(
|
||||
15_000,
|
||||
)
|
||||
|
||||
it.instance("legacy prompt emits message events without session.next events", () =>
|
||||
Effect.gen(function* () {
|
||||
const events = yield* EventV2Bridge.Service
|
||||
const prompt = yield* SessionPrompt.Service
|
||||
const sessions = yield* Session.Service
|
||||
const chat = yield* sessions.create({
|
||||
title: "Pinned",
|
||||
agent: "plan",
|
||||
model: { providerID: ProviderV2.ID.make("old"), id: ModelV2.ID.make("old-model") },
|
||||
})
|
||||
const seen: string[] = []
|
||||
const off = yield* events.listen((event) => {
|
||||
seen.push(event.type)
|
||||
return Effect.void
|
||||
})
|
||||
|
||||
const first = yield* prompt.prompt({
|
||||
sessionID: chat.id,
|
||||
agent: "build",
|
||||
model: ref,
|
||||
noReply: true,
|
||||
parts: [{ type: "text", text: "hello" }],
|
||||
})
|
||||
const second = yield* prompt.prompt({
|
||||
sessionID: chat.id,
|
||||
agent: "build",
|
||||
noReply: true,
|
||||
parts: [{ type: "text", text: "again" }],
|
||||
})
|
||||
yield* off
|
||||
|
||||
expect(first.info.role).toBe("user")
|
||||
expect(second.info.role).toBe("user")
|
||||
if (first.info.role === "user" && second.info.role === "user") {
|
||||
expect(first.info.model).toEqual(ref)
|
||||
expect(second.info.model).toEqual(ref)
|
||||
}
|
||||
expect(yield* sessions.get(chat.id)).toMatchObject({
|
||||
agent: "build",
|
||||
model: { providerID: ref.providerID, id: ref.modelID },
|
||||
})
|
||||
expect(seen).toContain(Session.Event.Updated.type)
|
||||
expect(seen).toContain(MessageV2.Event.Updated.type)
|
||||
expect(seen).toContain(MessageV2.Event.PartUpdated.type)
|
||||
expect(seen.filter((type) => type.startsWith("session.next."))).toEqual([])
|
||||
}),
|
||||
)
|
||||
|
||||
it.instance("loop surfaces content-filter finishes as session errors", () =>
|
||||
Effect.gen(function* () {
|
||||
const { llm } = yield* useServerConfig(providerCfg)
|
||||
@@ -697,7 +654,8 @@ noLLMServer.instance.skip(
|
||||
})
|
||||
|
||||
const messages = yield* SessionV2.Service.use((session) => session.messages({ sessionID: chat.id })).pipe(
|
||||
Effect.provide(SessionV2.defaultLayer.pipe(Layer.provide(SessionExecution.noopLayer))),
|
||||
Effect.provide(SessionExecution.noopLayer),
|
||||
Effect.provide(SessionV2.defaultLayer),
|
||||
)
|
||||
const { db } = yield* Database.Service
|
||||
const row = yield* db
|
||||
|
||||
@@ -11,10 +11,6 @@ import { testEffect } from "../lib/effect"
|
||||
let CLOUDFLARE_SKILLS_URL: string
|
||||
let server: ReturnType<typeof Bun.serve>
|
||||
let downloadCount = 0
|
||||
let mutableVersion = "1"
|
||||
let mutableContent = "# Old"
|
||||
let mutableDownloadCount = 0
|
||||
let mutableFiles = ["SKILL.md"]
|
||||
|
||||
const fixturePath = path.join(import.meta.dir, "../fixture/skills")
|
||||
const cacheDir = path.join(Global.Path.cache, "skills")
|
||||
@@ -28,15 +24,6 @@ beforeAll(async () => {
|
||||
async fetch(req) {
|
||||
const url = new URL(req.url)
|
||||
|
||||
if (url.pathname === "/mutable/index.json") {
|
||||
return Response.json({ skills: [{ name: "mutable", version: mutableVersion, files: mutableFiles }] })
|
||||
}
|
||||
if (url.pathname === "/mutable/mutable/SKILL.md") {
|
||||
mutableDownloadCount++
|
||||
return new Response(mutableContent)
|
||||
}
|
||||
if (url.pathname === "/mutable/mutable/old.md") return new Response("old reference")
|
||||
|
||||
// route /.well-known/skills/* to the fixture directory
|
||||
if (url.pathname.startsWith("/.well-known/skills/")) {
|
||||
const filePath = url.pathname.replace("/.well-known/skills/", "")
|
||||
@@ -149,37 +136,4 @@ describe("Discovery.pull", () => {
|
||||
expect(downloadCount).toBe(firstCount)
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("refreshes a remote skill when its version changes", () =>
|
||||
Effect.gen(function* () {
|
||||
yield* Effect.promise(() => rm(cacheDir, { recursive: true, force: true }))
|
||||
mutableVersion = "1"
|
||||
mutableContent = "# Old"
|
||||
mutableDownloadCount = 0
|
||||
mutableFiles = ["SKILL.md", "old.md"]
|
||||
const discovery = yield* Discovery.Service
|
||||
const url = `http://localhost:${server.port}/mutable/`
|
||||
|
||||
const first = yield* discovery.pull(url)
|
||||
expect(yield* Effect.promise(() => Bun.file(path.join(first[0], "SKILL.md")).text())).toBe("# Old")
|
||||
|
||||
mutableVersion = "2"
|
||||
mutableContent = "# Partial"
|
||||
mutableFiles = ["SKILL.md", "missing.md"]
|
||||
const second = yield* discovery.pull(url)
|
||||
expect(yield* Effect.promise(() => Bun.file(path.join(second[0], "SKILL.md")).text())).toBe("# Old")
|
||||
expect(yield* Effect.promise(() => Bun.file(path.join(second[0], "old.md")).text())).toBe("old reference")
|
||||
|
||||
mutableVersion = "3"
|
||||
mutableContent = "# New"
|
||||
mutableFiles = ["SKILL.md"]
|
||||
yield* discovery.pull(url)
|
||||
expect(yield* Effect.promise(() => Bun.file(path.join(second[0], "SKILL.md")).text())).toBe("# New")
|
||||
expect(yield* Effect.promise(() => Bun.file(path.join(second[0], "old.md")).exists())).toBe(false)
|
||||
expect(mutableDownloadCount).toBe(3)
|
||||
|
||||
yield* discovery.pull(url)
|
||||
expect(mutableDownloadCount).toBe(3)
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
@@ -77,33 +77,6 @@ const withHome = <A, E, R>(home: string, self: Effect.Effect<A, E, R>) =>
|
||||
)
|
||||
|
||||
describe("skill", () => {
|
||||
it.effect("formats verbose locations as XML-safe filesystem paths", () =>
|
||||
Effect.sync(() => {
|
||||
const output = Skill.fmt(
|
||||
[
|
||||
{
|
||||
name: "tagged-skill",
|
||||
description: "A tagged skill.",
|
||||
location: "/tmp/plugin.git#v1.3.0/SKILL.md",
|
||||
content: "",
|
||||
},
|
||||
{
|
||||
name: "built-in-skill",
|
||||
description: "A built-in skill.",
|
||||
location: "<built-in>",
|
||||
content: "",
|
||||
},
|
||||
],
|
||||
{ verbose: true },
|
||||
)
|
||||
|
||||
expect(output).toContain("<location>/tmp/plugin.git#v1.3.0/SKILL.md</location>")
|
||||
expect(output).toContain("<location><built-in></location>")
|
||||
expect(output).not.toContain("file://")
|
||||
expect(output).not.toContain("%23")
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("discovers skills from .opencode/skill/ directory", () =>
|
||||
provideTmpdirInstance(
|
||||
(dir) =>
|
||||
|
||||
@@ -27,9 +27,9 @@
|
||||
"zod": "catalog:"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@opentui/core": ">=0.3.4",
|
||||
"@opentui/keymap": ">=0.3.4",
|
||||
"@opentui/solid": ">=0.3.4"
|
||||
"@opentui/core": ">=0.4.2",
|
||||
"@opentui/keymap": ">=0.4.2",
|
||||
"@opentui/solid": ">=0.4.2"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@opentui/core": {
|
||||
|
||||
@@ -73,10 +73,6 @@ export const SessionsCursor = Schema.String.pipe(
|
||||
)
|
||||
export type SessionsCursor = typeof SessionsCursor.Type
|
||||
|
||||
const SessionActive = Schema.Struct({
|
||||
type: Schema.Literal("running"),
|
||||
}).annotate({ identifier: "SessionActive" })
|
||||
|
||||
const SessionsQueryCursor = SessionsCursor.annotate({
|
||||
description: "Opaque pagination cursor returned as cursor.previous or cursor.next in the previous response.",
|
||||
})
|
||||
@@ -128,18 +124,6 @@ export const makeSessionGroup = <I extends HttpApiMiddleware.AnyId, S>(sessionLo
|
||||
}),
|
||||
),
|
||||
)
|
||||
.add(
|
||||
HttpApiEndpoint.get("session.active", "/api/session/active", {
|
||||
success: Schema.Struct({ data: Schema.Record(Session.ID, SessionActive) }),
|
||||
}).annotateMerge(
|
||||
OpenApi.annotations({
|
||||
identifier: "v2.session.active",
|
||||
summary: "List active sessions",
|
||||
description:
|
||||
"Retrieve foreground Session drains currently owned by this OpenCode process. Sessions absent from the result are inactive.",
|
||||
}),
|
||||
),
|
||||
)
|
||||
.add(
|
||||
HttpApiEndpoint.get("session.get", "/api/session/:sessionID", {
|
||||
params: { sessionID: Session.ID },
|
||||
|
||||
@@ -33,24 +33,12 @@ test("embedded client uses the real router and handlers", async () => {
|
||||
yield* opencode.sessions.switchModel({ sessionID, model })
|
||||
const selected = yield* opencode.sessions.get({ sessionID })
|
||||
const page = yield* opencode.sessions.list({ directory: AbsolutePath.make(directory) })
|
||||
const active = yield* opencode.sessions.active()
|
||||
const admitted = yield* opencode.sessions.prompt({
|
||||
sessionID,
|
||||
prompt: Prompt.make({ text: "Do not run" }),
|
||||
resume: false,
|
||||
})
|
||||
const context = yield* opencode.sessions.context({ sessionID })
|
||||
const wake = yield* opencode.sessions.prompt({
|
||||
sessionID,
|
||||
prompt: Prompt.make({ text: "Promote this input" }),
|
||||
})
|
||||
const prompted = yield* opencode.sessions.events({ sessionID }).pipe(
|
||||
Stream.filter((event) => event.type === "session.next.prompted" && event.data.messageID === wake.id),
|
||||
Stream.runHead,
|
||||
Effect.timeout("10 seconds"),
|
||||
Effect.map(Option.getOrThrow),
|
||||
)
|
||||
const wakeContext = yield* opencode.sessions.context({ sessionID })
|
||||
const event = yield* opencode.sessions
|
||||
.events({ sessionID })
|
||||
.pipe(Stream.take(1), Stream.runHead, Effect.map(Option.getOrUndefined))
|
||||
@@ -82,10 +70,7 @@ test("embedded client uses the real router and handlers", async () => {
|
||||
expect(selected.model?.id).toBe(model.id)
|
||||
expect(selected.model?.providerID).toBe(model.providerID)
|
||||
expect(page.data.some((session) => session.id === sessionID)).toBe(true)
|
||||
expect(active).toEqual({})
|
||||
expect(admitted.sessionID).toBe(sessionID)
|
||||
expect(prompted.type).toBe("session.next.prompted")
|
||||
expect(wakeContext).toContainEqual(expect.objectContaining({ id: wake.id, type: "user" }))
|
||||
expect(context.some((message) => message.type === "model-switched")).toBe(true)
|
||||
expect(event).toMatchObject({ type: "session.next.model.switched", durable: { seq: 1 } })
|
||||
expect(message).toEqual(modelMessage)
|
||||
|
||||
@@ -333,8 +333,6 @@ import type {
|
||||
V2QuestionRequestListResponses,
|
||||
V2ReferenceListErrors,
|
||||
V2ReferenceListResponses,
|
||||
V2SessionActiveErrors,
|
||||
V2SessionActiveResponses,
|
||||
V2SessionCompactErrors,
|
||||
V2SessionCompactResponses,
|
||||
V2SessionContextErrors,
|
||||
@@ -5503,18 +5501,6 @@ export class Session3 extends HeyApiClient {
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* List active sessions
|
||||
*
|
||||
* Retrieve foreground Session drains currently owned by this OpenCode process. Sessions absent from the result are inactive.
|
||||
*/
|
||||
public active<ThrowOnError extends boolean = false>(options?: Options<never, ThrowOnError>) {
|
||||
return (options?.client ?? this.client).get<V2SessionActiveResponses, V2SessionActiveErrors, ThrowOnError>({
|
||||
url: "/api/session/active",
|
||||
...options,
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Get session
|
||||
*
|
||||
|
||||
@@ -2690,10 +2690,6 @@ export type InvalidCursorError = {
|
||||
message: string
|
||||
}
|
||||
|
||||
export type SessionActive = {
|
||||
type: "running"
|
||||
}
|
||||
|
||||
export type SessionNotFoundError = {
|
||||
_tag: "SessionNotFoundError"
|
||||
sessionID: string
|
||||
@@ -11944,39 +11940,6 @@ export type V2SessionCreateResponses = {
|
||||
|
||||
export type V2SessionCreateResponse = V2SessionCreateResponses[keyof V2SessionCreateResponses]
|
||||
|
||||
export type V2SessionActiveData = {
|
||||
body?: never
|
||||
path?: never
|
||||
query?: never
|
||||
url: "/api/session/active"
|
||||
}
|
||||
|
||||
export type V2SessionActiveErrors = {
|
||||
/**
|
||||
* InvalidRequestError
|
||||
*/
|
||||
400: InvalidRequestError
|
||||
/**
|
||||
* UnauthorizedError
|
||||
*/
|
||||
401: UnauthorizedError
|
||||
}
|
||||
|
||||
export type V2SessionActiveError = V2SessionActiveErrors[keyof V2SessionActiveErrors]
|
||||
|
||||
export type V2SessionActiveResponses = {
|
||||
/**
|
||||
* Success
|
||||
*/
|
||||
200: {
|
||||
data: {
|
||||
[key: string]: unknown | SessionActive
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export type V2SessionActiveResponse = V2SessionActiveResponses[keyof V2SessionActiveResponses]
|
||||
|
||||
export type V2SessionGetData = {
|
||||
body?: never
|
||||
path: {
|
||||
|
||||
@@ -10187,66 +10187,6 @@
|
||||
]
|
||||
}
|
||||
},
|
||||
"/api/session/active": {
|
||||
"get": {
|
||||
"tags": ["sessions"],
|
||||
"operationId": "v2.session.active",
|
||||
"parameters": [],
|
||||
"security": [],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Success",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"data": {
|
||||
"type": "object",
|
||||
"patternProperties": {
|
||||
"^ses": {
|
||||
"$ref": "#/components/schemas/SessionActive"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": ["data"],
|
||||
"additionalProperties": false
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "InvalidRequestError",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/InvalidRequestError"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"401": {
|
||||
"description": "UnauthorizedError",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/UnauthorizedError"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Retrieve foreground Session drains currently owned by this OpenCode process. Sessions absent from the result are inactive.",
|
||||
"summary": "List active sessions",
|
||||
"x-codeSamples": [
|
||||
{
|
||||
"lang": "js",
|
||||
"source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.v2.session.active({\n ...\n})"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"/api/session/{sessionID}": {
|
||||
"get": {
|
||||
"tags": ["sessions"],
|
||||
@@ -23505,17 +23445,6 @@
|
||||
"required": ["_tag", "message"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"SessionActive": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string",
|
||||
"enum": ["running"]
|
||||
}
|
||||
},
|
||||
"required": ["type"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"SessionNotFoundError": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
|
||||
@@ -26,11 +26,6 @@ import { CredentialHandler } from "./handlers/credential"
|
||||
import { Credential } from "@opencode-ai/core/credential"
|
||||
import { ProjectCopyHandler } from "./handlers/project-copy"
|
||||
|
||||
const sessionLayer = SessionV2.defaultLayer.pipe(
|
||||
Layer.provide(SessionExecutionLocal.defaultLayer),
|
||||
Layer.provide(LocationServiceMap.layer),
|
||||
)
|
||||
|
||||
export const handlers = Layer.mergeAll(
|
||||
HealthHandler,
|
||||
LocationHandler,
|
||||
@@ -53,7 +48,8 @@ export const handlers = Layer.mergeAll(
|
||||
).pipe(
|
||||
Layer.provide(sessionLocationLayer),
|
||||
Layer.provide(locationLayer),
|
||||
Layer.provide(sessionLayer),
|
||||
Layer.provide(SessionV2.defaultLayer),
|
||||
Layer.provide(SessionExecutionLocal.defaultLayer),
|
||||
Layer.provide(PermissionSaved.defaultLayer),
|
||||
Layer.provide(PtyTicket.defaultLayer),
|
||||
Layer.provide(LocationServiceMap.layer),
|
||||
|
||||
@@ -76,16 +76,6 @@ export const SessionHandler = HttpApiBuilder.group(Api, "server.session", (handl
|
||||
}
|
||||
}),
|
||||
)
|
||||
.handle(
|
||||
"session.active",
|
||||
Effect.fn(function* () {
|
||||
return {
|
||||
data: Object.fromEntries(
|
||||
Array.from(yield* session.active, (sessionID) => [sessionID, { type: "running" as const }]),
|
||||
),
|
||||
}
|
||||
}),
|
||||
)
|
||||
.handle(
|
||||
"session.get",
|
||||
Effect.fn(function* (ctx) {
|
||||
|
||||
@@ -43,7 +43,7 @@
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
color: var(--v2-text-text-faint);
|
||||
color: var(--text-weak);
|
||||
|
||||
[data-component="spinner"] {
|
||||
width: 16px;
|
||||
@@ -89,14 +89,14 @@
|
||||
font-weight: var(--font-weight-medium);
|
||||
line-height: var(--line-height-large);
|
||||
letter-spacing: var(--letter-spacing-normal);
|
||||
color: var(--v2-text-text-base);
|
||||
color: var(--text-strong);
|
||||
|
||||
&.capitalize {
|
||||
text-transform: capitalize;
|
||||
}
|
||||
|
||||
&.agent-title {
|
||||
color: var(--v2-text-text-base);
|
||||
color: var(--text-strong);
|
||||
font-weight: var(--font-weight-medium);
|
||||
}
|
||||
}
|
||||
@@ -114,7 +114,7 @@
|
||||
font-weight: var(--font-weight-regular);
|
||||
line-height: var(--line-height-large);
|
||||
letter-spacing: var(--letter-spacing-normal);
|
||||
color: var(--v2-text-text-muted);
|
||||
color: var(--text-base);
|
||||
|
||||
&.clickable {
|
||||
cursor: pointer;
|
||||
@@ -122,7 +122,7 @@
|
||||
transition: color 0.15s ease;
|
||||
|
||||
&:hover {
|
||||
color: var(--v2-text-text-muted);
|
||||
color: var(--text-base);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -160,7 +160,7 @@
|
||||
font-weight: var(--font-weight-regular);
|
||||
line-height: var(--line-height-large);
|
||||
letter-spacing: var(--letter-spacing-normal);
|
||||
color: var(--v2-text-text-muted);
|
||||
color: var(--text-base);
|
||||
}
|
||||
|
||||
[data-slot="basic-tool-tool-action"] {
|
||||
@@ -178,7 +178,7 @@
|
||||
gap: 8px;
|
||||
padding: 8px 12px;
|
||||
border-radius: 6px;
|
||||
border: 0.5px solid var(--border-weak-base, rgba(255, 255, 255, 0.08));
|
||||
border: 1px solid var(--border-weak-base, rgba(255, 255, 255, 0.08));
|
||||
background: color-mix(in srgb, var(--background-base) 92%, transparent);
|
||||
transition:
|
||||
border-color 0.15s ease,
|
||||
@@ -213,7 +213,7 @@
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
color: var(--v2-text-text-faint);
|
||||
color: var(--icon-weak);
|
||||
margin-left: auto;
|
||||
opacity: 0;
|
||||
transition:
|
||||
@@ -233,7 +233,7 @@
|
||||
}
|
||||
|
||||
[data-slot="basic-tool-tool-subtitle"] {
|
||||
color: var(--v2-text-text-base);
|
||||
color: var(--text-strong);
|
||||
}
|
||||
|
||||
&:hover,
|
||||
@@ -246,34 +246,3 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
body:not([data-new-layout]) {
|
||||
[data-component="tool-trigger"] {
|
||||
[data-slot="basic-tool-tool-spinner"] {
|
||||
color: var(--text-weak);
|
||||
}
|
||||
|
||||
[data-slot="basic-tool-tool-title"],
|
||||
[data-slot="basic-tool-tool-title"].agent-title {
|
||||
color: var(--text-strong);
|
||||
}
|
||||
|
||||
[data-slot="basic-tool-tool-subtitle"],
|
||||
[data-slot="basic-tool-tool-subtitle"].clickable:hover,
|
||||
[data-slot="basic-tool-tool-arg"] {
|
||||
color: var(--text-base);
|
||||
}
|
||||
}
|
||||
|
||||
[data-component="task-tool-card"] {
|
||||
border-width: 1px;
|
||||
|
||||
[data-component="task-tool-action"] {
|
||||
color: var(--icon-weak);
|
||||
}
|
||||
|
||||
[data-slot="basic-tool-tool-subtitle"] {
|
||||
color: var(--text-strong);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,32 +0,0 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { inlineCodeKind } from "./markdown-inline-code-kind"
|
||||
|
||||
describe("inlineCodeKind", () => {
|
||||
test("leaves code expressions as normal inline code", () => {
|
||||
expect(
|
||||
inlineCodeKind(
|
||||
`case "question.asked": ... input.setStore("question", question.sessionID, [question]) / splice/insert`,
|
||||
),
|
||||
).toBeUndefined()
|
||||
expect(inlineCodeKind(`<SessionQuestionDock request={request} ... />`)).toBeUndefined()
|
||||
expect(inlineCodeKind(`from sync.data.question + sync.data.session.`)).toBeUndefined()
|
||||
expect(inlineCodeKind(`@opencode-ai/app <StatusPopover />)`)).toBeUndefined()
|
||||
expect(inlineCodeKind(`sync.data.session`)).toBeUndefined()
|
||||
expect(inlineCodeKind(`window.api`)).toBeUndefined()
|
||||
})
|
||||
|
||||
test("detects file and directory paths", () => {
|
||||
expect(inlineCodeKind(`app.tsx`)).toBe("path")
|
||||
expect(inlineCodeKind(`packages/desktop-electron`)).toBe("path")
|
||||
expect(inlineCodeKind(`~/.config/opencode`)).toBe("path")
|
||||
expect(inlineCodeKind(`@opencode-ai/app`)).toBe("path")
|
||||
expect(inlineCodeKind(`session/status`)).toBe("path")
|
||||
})
|
||||
|
||||
test("detects urls", () => {
|
||||
expect(inlineCodeKind(`https://opencode.ai/docs`)).toBe("url")
|
||||
expect(inlineCodeKind(`http://localhost:4444`)).toBe("url")
|
||||
expect(inlineCodeKind(`file:///tmp/opencode`)).toBeUndefined()
|
||||
expect(inlineCodeKind(`ftp://opencode.ai/docs`)).toBeUndefined()
|
||||
})
|
||||
})
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user