diff --git a/bun.lock b/bun.lock
index aabc44e32fbe..51c5843d7241 100644
--- a/bun.lock
+++ b/bun.lock
@@ -14,6 +14,7 @@
},
"devDependencies": {
"@actions/artifact": "5.0.1",
+ "@ai-sdk/provider": "4.0.0",
"@tsconfig/bun": "catalog:",
"@types/mime-types": "3.0.1",
"@typescript/native-preview": "catalog:",
@@ -956,7 +957,20 @@
"name": "@opencode-ai/ui",
"version": "1.17.11",
"dependencies": {
+ "@codemirror/autocomplete": "6.18.6",
+ "@codemirror/commands": "6.8.1",
+ "@codemirror/lang-go": "6.0.1",
+ "@codemirror/lang-javascript": "6.2.4",
+ "@codemirror/lang-python": "6.2.1",
+ "@codemirror/language": "6.11.3",
+ "@codemirror/lint": "6.8.5",
+ "@codemirror/search": "6.5.11",
+ "@codemirror/state": "6.5.2",
+ "@codemirror/view": "6.38.1",
"@kobalte/core": "catalog:",
+ "@lezer/highlight": "1.2.3",
+ "@opencode-ai/core": "workspace:*",
+ "@opencode-ai/sdk": "workspace:*",
"@pierre/diffs": "catalog:",
"@shikijs/stream": "catalog:",
"@shikijs/transformers": "3.9.2",
@@ -983,6 +997,9 @@
"strip-ansi": "7.1.2",
},
"devDependencies": {
+ "@babel/core": "7.28.4",
+ "@babel/preset-typescript": "7.27.1",
+ "@happy-dom/global-registrator": "20.0.11",
"@solidjs/meta": "catalog:",
"@tailwindcss/vite": "catalog:",
"@tsconfig/node22": "catalog:",
@@ -990,6 +1007,7 @@
"@types/katex": "0.16.7",
"@types/luxon": "catalog:",
"@typescript/native-preview": "catalog:",
+ "babel-preset-solid": "1.9.12",
"solid-js": "catalog:",
"tailwindcss": "catalog:",
"tw-animate-css": "1.4.0",
@@ -1187,7 +1205,7 @@
"@ai-sdk/perplexity": ["@ai-sdk/perplexity@3.0.26", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.21" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-dXzrVsLR5f6tr+U04jq4AXoRroGFBTvODnLgss0SWbzNjGGQg3XqtQ9j7rCLo6o8qbYGuAHvqUrIpUCuiscuFg=="],
- "@ai-sdk/provider": ["@ai-sdk/provider@3.0.8", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-oGMAgGoQdBXbZqNG0Ze56CHjDZ1IDYOwGYxYjO5KLSlz5HiNQ9udIXsPZ61VWaHGZ5XW/jyjmr6t2xz2jGVwbQ=="],
+ "@ai-sdk/provider": ["@ai-sdk/provider@4.0.0", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-fr9Gs89prDWiuox/T+kCA+i2cJkHpxU5S+tr4megjTzRC27ZsvFhwjU/+XrqqMbvBUlfmXxTOYWy8ng45dsjIg=="],
"@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.23", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-z8GlDaCmRSDlqkMF2f4/RFgWxdarvIbyuk+m6WXT1LYgsnGiXRJGTD2Z1+SDl3LqtFuRtGX1aghYvQLoHL/9pg=="],
@@ -1451,6 +1469,26 @@
"@cloudflare/workers-types": ["@cloudflare/workers-types@4.20251008.0", "", {}, "sha512-dZLkO4PbCL0qcCSKzuW7KE4GYe49lI12LCfQ5y9XeSwgYBoAUbwH4gmJ6A0qUIURiTJTkGkRkhVPqpq2XNgYRA=="],
+ "@codemirror/autocomplete": ["@codemirror/autocomplete@6.18.6", "", { "dependencies": { "@codemirror/language": "^6.0.0", "@codemirror/state": "^6.0.0", "@codemirror/view": "^6.17.0", "@lezer/common": "^1.0.0" } }, "sha512-PHHBXFomUs5DF+9tCOM/UoW6XQ4R44lLNNhRaW9PKPTU0D7lIjRg3ElxaJnTwsl/oHiR93WSXDBrekhoUGCPtg=="],
+
+ "@codemirror/commands": ["@codemirror/commands@6.8.1", "", { "dependencies": { "@codemirror/language": "^6.0.0", "@codemirror/state": "^6.4.0", "@codemirror/view": "^6.27.0", "@lezer/common": "^1.1.0" } }, "sha512-KlGVYufHMQzxbdQONiLyGQDUW0itrLZwq3CcY7xpv9ZLRHqzkBSoteocBHtMCoY7/Ci4xhzSrToIeLg7FxHuaw=="],
+
+ "@codemirror/lang-go": ["@codemirror/lang-go@6.0.1", "", { "dependencies": { "@codemirror/autocomplete": "^6.0.0", "@codemirror/language": "^6.6.0", "@codemirror/state": "^6.0.0", "@lezer/common": "^1.0.0", "@lezer/go": "^1.0.0" } }, "sha512-7fNvbyNylvqCphW9HD6WFnRpcDjr+KXX/FgqXy5H5ZS0eC5edDljukm/yNgYkwTsgp2busdod50AOTIy6Jikfg=="],
+
+ "@codemirror/lang-javascript": ["@codemirror/lang-javascript@6.2.4", "", { "dependencies": { "@codemirror/autocomplete": "^6.0.0", "@codemirror/language": "^6.6.0", "@codemirror/lint": "^6.0.0", "@codemirror/state": "^6.0.0", "@codemirror/view": "^6.17.0", "@lezer/common": "^1.0.0", "@lezer/javascript": "^1.0.0" } }, "sha512-0WVmhp1QOqZ4Rt6GlVGwKJN3KW7Xh4H2q8ZZNGZaP6lRdxXJzmjm4FqvmOojVj6khWJHIb9sp7U/72W7xQgqAA=="],
+
+ "@codemirror/lang-python": ["@codemirror/lang-python@6.2.1", "", { "dependencies": { "@codemirror/autocomplete": "^6.3.2", "@codemirror/language": "^6.8.0", "@codemirror/state": "^6.0.0", "@lezer/common": "^1.2.1", "@lezer/python": "^1.1.4" } }, "sha512-IRjC8RUBhn9mGR9ywecNhB51yePWCGgvHfY1lWN/Mrp3cKuHr0isDKia+9HnvhiWNnMpbGhWrkhuWOc09exRyw=="],
+
+ "@codemirror/language": ["@codemirror/language@6.11.3", "", { "dependencies": { "@codemirror/state": "^6.0.0", "@codemirror/view": "^6.23.0", "@lezer/common": "^1.1.0", "@lezer/highlight": "^1.0.0", "@lezer/lr": "^1.0.0", "style-mod": "^4.0.0" } }, "sha512-9HBM2XnwDj7fnu0551HkGdrUrrqmYq/WC5iv6nbY2WdicXdGbhR/gfbZOH73Aqj4351alY1+aoG9rCNfiwS1RA=="],
+
+ "@codemirror/lint": ["@codemirror/lint@6.8.5", "", { "dependencies": { "@codemirror/state": "^6.0.0", "@codemirror/view": "^6.35.0", "crelt": "^1.0.5" } }, "sha512-s3n3KisH7dx3vsoeGMxsbRAgKe4O1vbrnKBClm99PU0fWxmxsx5rR2PfqQgIt+2MMJBHbiJ5rfIdLYfB9NNvsA=="],
+
+ "@codemirror/search": ["@codemirror/search@6.5.11", "", { "dependencies": { "@codemirror/state": "^6.0.0", "@codemirror/view": "^6.0.0", "crelt": "^1.0.5" } }, "sha512-KmWepDE6jUdL6n8cAAqIpRmLPBZ5ZKnicE8oGU/s3QrAVID+0VhLFrzUucVKHG5035/BSykhExDL/Xm7dHthiA=="],
+
+ "@codemirror/state": ["@codemirror/state@6.5.2", "", { "dependencies": { "@marijn/find-cluster-break": "^1.0.0" } }, "sha512-FVqsPqtPWKVVL3dPSxy8wEF/ymIEuVzF1PK3VbUgrxXpJUSHQWWZz4JMToquRxnkw+36LTamCZG2iua2Ptq0fA=="],
+
+ "@codemirror/view": ["@codemirror/view@6.38.1", "", { "dependencies": { "@codemirror/state": "^6.5.0", "crelt": "^1.0.6", "style-mod": "^4.1.0", "w3c-keyname": "^2.2.4" } }, "sha512-RmTOkE7hRU3OVREqFVITWHz6ocgBjv08GoePscAakgVQfciA3SGCEk7mb9IzwW61cKKmlTpHXG6DUE5Ubx+MGQ=="],
+
"@corvu/utils": ["@corvu/utils@0.4.2", "", { "dependencies": { "@floating-ui/dom": "^1.6.11" }, "peerDependencies": { "solid-js": "^1.8" } }, "sha512-Ox2kYyxy7NoXdKWdHeDEjZxClwzO4SKM8plAaVwmAJPxHMqA0rLOoAsa+hBDwRLpctf+ZRnAd/ykguuJidnaTA=="],
"@cspotcode/source-map-support": ["@cspotcode/source-map-support@0.8.1", "", { "dependencies": { "@jridgewell/trace-mapping": "0.3.9" } }, "sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw=="],
@@ -1777,6 +1815,18 @@
"@leichtgewicht/ip-codec": ["@leichtgewicht/ip-codec@2.0.5", "", {}, "sha512-Vo+PSpZG2/fmgmiNzYK9qWRh8h/CHrwD0mo1h1DzL4yzHNSfWYujGTYsWGreD000gcgmZ7K4Ys6Tx9TxtsKdDw=="],
+ "@lezer/common": ["@lezer/common@1.5.2", "", {}, "sha512-sxQE460fPZyU3sdc8lafxiPwJHBzZRy/udNFynGQky1SePYBdhkBl1kOagA9uT3pxR8K09bOrmTUqA9wb/PjSQ=="],
+
+ "@lezer/go": ["@lezer/go@1.0.1", "", { "dependencies": { "@lezer/common": "^1.2.0", "@lezer/highlight": "^1.0.0", "@lezer/lr": "^1.3.0" } }, "sha512-xToRsYxwsgJNHTgNdStpcvmbVuKxTapV0dM0wey1geMMRc9aggoVyKgzYp41D2/vVOx+Ii4hmE206kvxIXBVXQ=="],
+
+ "@lezer/highlight": ["@lezer/highlight@1.2.3", "", { "dependencies": { "@lezer/common": "^1.3.0" } }, "sha512-qXdH7UqTvGfdVBINrgKhDsVTJTxactNNxLk7+UMwZhU13lMHaOBlJe9Vqp907ya56Y3+ed2tlqzys7jDkTmW0g=="],
+
+ "@lezer/javascript": ["@lezer/javascript@1.5.4", "", { "dependencies": { "@lezer/common": "^1.2.0", "@lezer/highlight": "^1.1.3", "@lezer/lr": "^1.3.0" } }, "sha512-vvYx3MhWqeZtGPwDStM2dwgljd5smolYD2lR2UyFcHfxbBQebqx8yjmFmxtJ/E6nN6u1D9srOiVWm3Rb4tmcUA=="],
+
+ "@lezer/lr": ["@lezer/lr@1.4.10", "", { "dependencies": { "@lezer/common": "^1.0.0" } }, "sha512-rnCpTIBafOx4mRp43xOxDJbFipJm/c0cia/V5TiGlhmMa+wsSdoGmUN3w5Bqrks/09Q/D4tNAmWaT8p6NRi77A=="],
+
+ "@lezer/python": ["@lezer/python@1.1.19", "", { "dependencies": { "@lezer/common": "^1.2.0", "@lezer/highlight": "^1.0.0", "@lezer/lr": "^1.0.0" } }, "sha512-MhQIURHRytsNzP/YXnqpYKW6la6voAH3kyplTOOiCdjyFY6cWWGFVmYVdHIPrElqSDf4iCDktQCockB9FxuhzQ=="],
+
"@lukeed/ms": ["@lukeed/ms@2.0.2", "", {}, "sha512-9I2Zn6+NJLfaGoz9jN3lpwDgAYvfGeNYdbAIjJOqzs4Tpc+VU3Jqq4IofSUBKajiDS8k9fZIg18/z13mpk1bsA=="],
"@lydell/node-pty": ["@lydell/node-pty@1.2.0-beta.12", "", { "optionalDependencies": { "@lydell/node-pty-darwin-arm64": "1.2.0-beta.12", "@lydell/node-pty-darwin-x64": "1.2.0-beta.12", "@lydell/node-pty-linux-arm64": "1.2.0-beta.12", "@lydell/node-pty-linux-x64": "1.2.0-beta.12", "@lydell/node-pty-win32-arm64": "1.2.0-beta.12", "@lydell/node-pty-win32-x64": "1.2.0-beta.12" } }, "sha512-qIK890UwPupoj07osVvgOIa++1mxeHbcGry4PKRHhNVNs81V2SCG34eJr46GybiOmBtc8Sj5PB1/GGM5PL549g=="],
@@ -1797,6 +1847,8 @@
"@malept/flatpak-bundler": ["@malept/flatpak-bundler@0.4.0", "", { "dependencies": { "debug": "^4.1.1", "fs-extra": "^9.0.0", "lodash": "^4.17.15", "tmp-promise": "^3.0.2" } }, "sha512-9QOtNffcOF/c1seMCDnjckb3R9WHcG34tky+FHpNKKCW0wc/scYLwMtO+ptyGUfMW0/b/n4qRiALlaFHc9Oj7Q=="],
+ "@marijn/find-cluster-break": ["@marijn/find-cluster-break@1.0.2", "", {}, "sha512-l0h88YhZFyKdXIFNfSWpyjStDjGHwZ/U7iobcK1cQQD8sejsONdQtTVU+1wVN1PBw40PiiHB1vA5S7VTfQiP9g=="],
+
"@mdx-js/mdx": ["@mdx-js/mdx@3.1.1", "", { "dependencies": { "@types/estree": "^1.0.0", "@types/estree-jsx": "^1.0.0", "@types/hast": "^3.0.0", "@types/mdx": "^2.0.0", "acorn": "^8.0.0", "collapse-white-space": "^2.0.0", "devlop": "^1.0.0", "estree-util-is-identifier-name": "^3.0.0", "estree-util-scope": "^1.0.0", "estree-walker": "^3.0.0", "hast-util-to-jsx-runtime": "^2.0.0", "markdown-extensions": "^2.0.0", "recma-build-jsx": "^1.0.0", "recma-jsx": "^1.0.0", "recma-stringify": "^1.0.0", "rehype-recma": "^1.0.0", "remark-mdx": "^3.0.0", "remark-parse": "^11.0.0", "remark-rehype": "^11.0.0", "source-map": "^0.7.0", "unified": "^11.0.0", "unist-util-position-from-estree": "^2.0.0", "unist-util-stringify-position": "^4.0.0", "unist-util-visit": "^5.0.0", "vfile": "^6.0.0" } }, "sha512-f6ZO2ifpwAQIpzGWaBQT2TXxPv6z3RBzQKpVftEWN78Vl/YweF1uwussDx8ECAXVtr3Rs89fKyG9YlzUs9DyGQ=="],
"@mdx-js/react": ["@mdx-js/react@3.1.1", "", { "dependencies": { "@types/mdx": "^2.0.0" }, "peerDependencies": { "@types/react": ">=16", "react": ">=16" } }, "sha512-f++rKLQgUVYDAtECQ6fn/is15GkEH9+nZPM3MS0RcxVqoTfawHvDlSCH7JbMhAM6uJ32v3eXLvLmLvjGu7PTQw=="],
@@ -3367,6 +3419,8 @@
"crc32-stream": ["crc32-stream@6.0.0", "", { "dependencies": { "crc-32": "^1.2.0", "readable-stream": "^4.0.0" } }, "sha512-piICUB6ei4IlTv1+653yq5+KoqfBYmj9bw6LqXoOneTMDXk5nM1qt12mFW1caG3LlJXEKW1Bp0WggEmIfQB34g=="],
+ "crelt": ["crelt@1.0.6", "", {}, "sha512-VQ2MBenTq1fWZUH9DJNGti7kKv6EeAuYr3cLwxUWhIu1baTaXh4Ib5W2CqHVqib4/MqbYGJqiL3Zb8GJZr3l4g=="],
+
"cross-dirname": ["cross-dirname@0.1.0", "", {}, "sha512-+R08/oI0nl3vfPcqftZRpytksBXDzOUveBq/NBVx0sUp1axwzPQrKinNx5yd5sxPu8j1wIy8AfnVQ+5eFdha6Q=="],
"cross-fetch": ["cross-fetch@3.2.0", "", { "dependencies": { "node-fetch": "^2.7.0" } }, "sha512-Q+xVJLoGOeIMXZmbUK4HYk+69cQH6LudR0Vu/pRm2YlU/hDV9CiS0gKUMaWY5f2NeUH9C1nV3bsTlCo0FsTV1Q=="],
@@ -5187,6 +5241,8 @@
"stubborn-utils": ["stubborn-utils@1.0.2", "", {}, "sha512-zOh9jPYI+xrNOyisSelgym4tolKTJCQd5GBhK0+0xJvcYDcwlOoxF/rnFKQ2KRZknXSG9jWAp66fwP6AxN9STg=="],
+ "style-mod": ["style-mod@4.1.3", "", {}, "sha512-i/n8VsZydrugj3Iuzll8+x/00GH2vnYsk1eomD8QiRrSAeW6ItbCQDtfXCeJHd0iwiNagqjQkvpvREEPtW3IoQ=="],
+
"style-to-js": ["style-to-js@1.1.21", "", { "dependencies": { "style-to-object": "1.0.14" } }, "sha512-RjQetxJrrUJLQPHbLku6U/ocGtzyjbJMP9lCNK7Ag0CNh690nSH8woqWH9u16nMjYBAok+i7JO1NP2pOy8IsPQ=="],
"style-to-object": ["style-to-object@1.0.14", "", { "dependencies": { "inline-style-parser": "0.2.7" } }, "sha512-LIN7rULI0jBscWQYaSswptyderlarFkjQ+t79nzty8tcIAceVomEVlLzH5VP4Cmsv6MtKhs7qaAiwlcp+Mgaxw=="],
@@ -5485,6 +5541,8 @@
"vscode-uri": ["vscode-uri@3.1.0", "", {}, "sha512-/BpdSx+yCQGnCvecbyXdxHDkuk55/G3xwnC0GqY4gmQ3j+A+g8kzzgB4Nk/SINjqn6+waqw3EgbVF2QKExkRxQ=="],
+ "w3c-keyname": ["w3c-keyname@2.2.8", "", {}, "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ=="],
+
"walk-up-path": ["walk-up-path@4.0.0", "", {}, "sha512-3hu+tD8YzSLGuFYtPRb48vdhKMi0KQV5sn+uWr8+7dMEq/2G/dtLrdDinkLjqq5TIbIBjYJ4Ax/n3YiaW7QM8A=="],
"web-namespaces": ["web-namespaces@2.0.1", "", {}, "sha512-bKr1DkiNa2krS7qxNtdrtHAmzuYGFQLiQ13TsorsdT6ULTkPLKuu5+GsFpDlg6JFjUTwX2DyhMPG2be8uPrqsQ=="],
@@ -5607,6 +5665,8 @@
"@ai-sdk/alibaba/@ai-sdk/openai-compatible": ["@ai-sdk/openai-compatible@2.0.41", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-kNAGINk71AlOXx10Dq/PXw4t/9XjdK8uxfpVElRwtSFMdeSiLVt58p9TPx4/FJD+hxZuVhvxYj9r42osxWq79g=="],
+ "@ai-sdk/alibaba/@ai-sdk/provider": ["@ai-sdk/provider@3.0.8", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-oGMAgGoQdBXbZqNG0Ze56CHjDZ1IDYOwGYxYjO5KLSlz5HiNQ9udIXsPZ61VWaHGZ5XW/jyjmr6t2xz2jGVwbQ=="],
+
"@ai-sdk/amazon-bedrock/@ai-sdk/anthropic": ["@ai-sdk/anthropic@3.0.81", "", { "dependencies": { "@ai-sdk/provider": "3.0.10", "@ai-sdk/provider-utils": "4.0.27" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-B1JDd9Ugq9R5AgIaW3674lhGCMMYJcPUxnrZh8fzbGojgg4QvHFRv6eZahGQAUsmGHbcf74G9bdSBDLWQGY2GA=="],
"@ai-sdk/amazon-bedrock/@ai-sdk/openai": ["@ai-sdk/openai@3.0.67", "", { "dependencies": { "@ai-sdk/provider": "3.0.10", "@ai-sdk/provider-utils": "4.0.27" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-oAiGC9eWG7IgtdsdS74bOCnAAHarAfTJhWN9x5INwnWPekL802AvF+0I5DvLzIF1MIRmNw4N8mPSL/GUVbX9Mw=="],
@@ -5623,16 +5683,24 @@
"@ai-sdk/anthropic/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.27", "", { "dependencies": { "@ai-sdk/provider": "3.0.10", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.8" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-ubkAJ+xODouwtmN1tYlvTPphH1hPOBfZaEQe8U7skGvFAnIRs9PPpsq57bC2+Ky/MB4yzhd6YOsxTAx9sGpazw=="],
+ "@ai-sdk/azure/@ai-sdk/provider": ["@ai-sdk/provider@3.0.8", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-oGMAgGoQdBXbZqNG0Ze56CHjDZ1IDYOwGYxYjO5KLSlz5HiNQ9udIXsPZ61VWaHGZ5XW/jyjmr6t2xz2jGVwbQ=="],
+
"@ai-sdk/azure/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.21", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-MtFUYI1/8mgDvRmaBDjbLJPFFrMG777AvSgyIFQtZHIMzm88R/12vYBBpnk7pfiWLFE1DSZzY4WDYzGbKAcmiw=="],
+ "@ai-sdk/cerebras/@ai-sdk/provider": ["@ai-sdk/provider@3.0.8", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-oGMAgGoQdBXbZqNG0Ze56CHjDZ1IDYOwGYxYjO5KLSlz5HiNQ9udIXsPZ61VWaHGZ5XW/jyjmr6t2xz2jGVwbQ=="],
+
"@ai-sdk/cerebras/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.21", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-MtFUYI1/8mgDvRmaBDjbLJPFFrMG777AvSgyIFQtZHIMzm88R/12vYBBpnk7pfiWLFE1DSZzY4WDYzGbKAcmiw=="],
+ "@ai-sdk/cohere/@ai-sdk/provider": ["@ai-sdk/provider@3.0.8", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-oGMAgGoQdBXbZqNG0Ze56CHjDZ1IDYOwGYxYjO5KLSlz5HiNQ9udIXsPZ61VWaHGZ5XW/jyjmr6t2xz2jGVwbQ=="],
+
"@ai-sdk/cohere/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.21", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-MtFUYI1/8mgDvRmaBDjbLJPFFrMG777AvSgyIFQtZHIMzm88R/12vYBBpnk7pfiWLFE1DSZzY4WDYzGbKAcmiw=="],
"@ai-sdk/deepgram/@ai-sdk/provider": ["@ai-sdk/provider@3.0.10", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-Q3BZ27qfpYqnCYGvE3vt+Qi6LGOF9R5Nmzn+9JoM1lCRsD9mYaIhfJLkSunN48nfGXJ6n+XNV0J/XVpqGQl7Dw=="],
"@ai-sdk/deepgram/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.27", "", { "dependencies": { "@ai-sdk/provider": "3.0.10", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.8" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-ubkAJ+xODouwtmN1tYlvTPphH1hPOBfZaEQe8U7skGvFAnIRs9PPpsq57bC2+Ky/MB4yzhd6YOsxTAx9sGpazw=="],
+ "@ai-sdk/deepinfra/@ai-sdk/provider": ["@ai-sdk/provider@3.0.8", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-oGMAgGoQdBXbZqNG0Ze56CHjDZ1IDYOwGYxYjO5KLSlz5HiNQ9udIXsPZ61VWaHGZ5XW/jyjmr6t2xz2jGVwbQ=="],
+
"@ai-sdk/deepinfra/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.21", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-MtFUYI1/8mgDvRmaBDjbLJPFFrMG777AvSgyIFQtZHIMzm88R/12vYBBpnk7pfiWLFE1DSZzY4WDYzGbKAcmiw=="],
"@ai-sdk/deepseek/@ai-sdk/provider": ["@ai-sdk/provider@3.0.10", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-Q3BZ27qfpYqnCYGvE3vt+Qi6LGOF9R5Nmzn+9JoM1lCRsD9mYaIhfJLkSunN48nfGXJ6n+XNV0J/XVpqGQl7Dw=="],
@@ -5649,6 +5717,8 @@
"@ai-sdk/fireworks/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.27", "", { "dependencies": { "@ai-sdk/provider": "3.0.10", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.8" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-ubkAJ+xODouwtmN1tYlvTPphH1hPOBfZaEQe8U7skGvFAnIRs9PPpsq57bC2+Ky/MB4yzhd6YOsxTAx9sGpazw=="],
+ "@ai-sdk/gateway/@ai-sdk/provider": ["@ai-sdk/provider@3.0.8", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-oGMAgGoQdBXbZqNG0Ze56CHjDZ1IDYOwGYxYjO5KLSlz5HiNQ9udIXsPZ61VWaHGZ5XW/jyjmr6t2xz2jGVwbQ=="],
+
"@ai-sdk/google/@ai-sdk/provider": ["@ai-sdk/provider@3.0.10", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-Q3BZ27qfpYqnCYGvE3vt+Qi6LGOF9R5Nmzn+9JoM1lCRsD9mYaIhfJLkSunN48nfGXJ6n+XNV0J/XVpqGQl7Dw=="],
"@ai-sdk/google/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.27", "", { "dependencies": { "@ai-sdk/provider": "3.0.10", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.8" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-ubkAJ+xODouwtmN1tYlvTPphH1hPOBfZaEQe8U7skGvFAnIRs9PPpsq57bC2+Ky/MB4yzhd6YOsxTAx9sGpazw=="],
@@ -5661,24 +5731,42 @@
"@ai-sdk/google-vertex/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.27", "", { "dependencies": { "@ai-sdk/provider": "3.0.10", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.8" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-ubkAJ+xODouwtmN1tYlvTPphH1hPOBfZaEQe8U7skGvFAnIRs9PPpsq57bC2+Ky/MB4yzhd6YOsxTAx9sGpazw=="],
+ "@ai-sdk/groq/@ai-sdk/provider": ["@ai-sdk/provider@3.0.8", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-oGMAgGoQdBXbZqNG0Ze56CHjDZ1IDYOwGYxYjO5KLSlz5HiNQ9udIXsPZ61VWaHGZ5XW/jyjmr6t2xz2jGVwbQ=="],
+
"@ai-sdk/groq/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.21", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-MtFUYI1/8mgDvRmaBDjbLJPFFrMG777AvSgyIFQtZHIMzm88R/12vYBBpnk7pfiWLFE1DSZzY4WDYzGbKAcmiw=="],
+ "@ai-sdk/mistral/@ai-sdk/provider": ["@ai-sdk/provider@3.0.8", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-oGMAgGoQdBXbZqNG0Ze56CHjDZ1IDYOwGYxYjO5KLSlz5HiNQ9udIXsPZ61VWaHGZ5XW/jyjmr6t2xz2jGVwbQ=="],
+
"@ai-sdk/mistral/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.21", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-MtFUYI1/8mgDvRmaBDjbLJPFFrMG777AvSgyIFQtZHIMzm88R/12vYBBpnk7pfiWLFE1DSZzY4WDYzGbKAcmiw=="],
+ "@ai-sdk/openai/@ai-sdk/provider": ["@ai-sdk/provider@3.0.8", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-oGMAgGoQdBXbZqNG0Ze56CHjDZ1IDYOwGYxYjO5KLSlz5HiNQ9udIXsPZ61VWaHGZ5XW/jyjmr6t2xz2jGVwbQ=="],
+
"@ai-sdk/openai/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.21", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-MtFUYI1/8mgDvRmaBDjbLJPFFrMG777AvSgyIFQtZHIMzm88R/12vYBBpnk7pfiWLFE1DSZzY4WDYzGbKAcmiw=="],
+ "@ai-sdk/openai-compatible/@ai-sdk/provider": ["@ai-sdk/provider@3.0.8", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-oGMAgGoQdBXbZqNG0Ze56CHjDZ1IDYOwGYxYjO5KLSlz5HiNQ9udIXsPZ61VWaHGZ5XW/jyjmr6t2xz2jGVwbQ=="],
+
"@ai-sdk/openai-compatible/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.21", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-MtFUYI1/8mgDvRmaBDjbLJPFFrMG777AvSgyIFQtZHIMzm88R/12vYBBpnk7pfiWLFE1DSZzY4WDYzGbKAcmiw=="],
+ "@ai-sdk/perplexity/@ai-sdk/provider": ["@ai-sdk/provider@3.0.8", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-oGMAgGoQdBXbZqNG0Ze56CHjDZ1IDYOwGYxYjO5KLSlz5HiNQ9udIXsPZ61VWaHGZ5XW/jyjmr6t2xz2jGVwbQ=="],
+
"@ai-sdk/perplexity/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.21", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-MtFUYI1/8mgDvRmaBDjbLJPFFrMG777AvSgyIFQtZHIMzm88R/12vYBBpnk7pfiWLFE1DSZzY4WDYzGbKAcmiw=="],
+ "@ai-sdk/provider-utils/@ai-sdk/provider": ["@ai-sdk/provider@3.0.8", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-oGMAgGoQdBXbZqNG0Ze56CHjDZ1IDYOwGYxYjO5KLSlz5HiNQ9udIXsPZ61VWaHGZ5XW/jyjmr6t2xz2jGVwbQ=="],
+
"@ai-sdk/provider-utils/@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="],
+ "@ai-sdk/togetherai/@ai-sdk/provider": ["@ai-sdk/provider@3.0.8", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-oGMAgGoQdBXbZqNG0Ze56CHjDZ1IDYOwGYxYjO5KLSlz5HiNQ9udIXsPZ61VWaHGZ5XW/jyjmr6t2xz2jGVwbQ=="],
+
"@ai-sdk/togetherai/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.21", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-MtFUYI1/8mgDvRmaBDjbLJPFFrMG777AvSgyIFQtZHIMzm88R/12vYBBpnk7pfiWLFE1DSZzY4WDYzGbKAcmiw=="],
+ "@ai-sdk/vercel/@ai-sdk/provider": ["@ai-sdk/provider@3.0.8", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-oGMAgGoQdBXbZqNG0Ze56CHjDZ1IDYOwGYxYjO5KLSlz5HiNQ9udIXsPZ61VWaHGZ5XW/jyjmr6t2xz2jGVwbQ=="],
+
"@ai-sdk/vercel/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.21", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-MtFUYI1/8mgDvRmaBDjbLJPFFrMG777AvSgyIFQtZHIMzm88R/12vYBBpnk7pfiWLFE1DSZzY4WDYzGbKAcmiw=="],
"@ai-sdk/xai/@ai-sdk/openai-compatible": ["@ai-sdk/openai-compatible@2.0.41", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-kNAGINk71AlOXx10Dq/PXw4t/9XjdK8uxfpVElRwtSFMdeSiLVt58p9TPx4/FJD+hxZuVhvxYj9r42osxWq79g=="],
+ "@ai-sdk/xai/@ai-sdk/provider": ["@ai-sdk/provider@3.0.8", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-oGMAgGoQdBXbZqNG0Ze56CHjDZ1IDYOwGYxYjO5KLSlz5HiNQ9udIXsPZ61VWaHGZ5XW/jyjmr6t2xz2jGVwbQ=="],
+
"@astrojs/check/yargs": ["yargs@17.7.2", "", { "dependencies": { "cliui": "^8.0.1", "escalade": "^3.1.1", "get-caller-file": "^2.0.5", "require-directory": "^2.1.1", "string-width": "^4.2.3", "y18n": "^5.0.5", "yargs-parser": "^21.1.1" } }, "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w=="],
"@astrojs/cloudflare/vite": ["vite@6.4.2", "", { "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.4.4", "picomatch": "^4.0.2", "postcss": "^8.5.3", "rollup": "^4.34.9", "tinyglobby": "^0.2.13" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", "jiti": ">=1.21.0", "less": "*", "lightningcss": "^1.21.0", "sass": "*", "sass-embedded": "*", "stylus": "*", "sugarss": "*", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-2N/55r4JDJ4gdrCvGgINMy+HH3iRpNIz8K6SFwVsA+JbQScLiC+clmAxBgwiSPgcG9U15QmvqCGWzMbqda5zGQ=="],
@@ -5983,6 +6071,8 @@
"@opencode-ai/core/@ai-sdk/openai-compatible": ["@ai-sdk/openai-compatible@2.0.41", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-kNAGINk71AlOXx10Dq/PXw4t/9XjdK8uxfpVElRwtSFMdeSiLVt58p9TPx4/FJD+hxZuVhvxYj9r42osxWq79g=="],
+ "@opencode-ai/core/@ai-sdk/provider": ["@ai-sdk/provider@3.0.8", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-oGMAgGoQdBXbZqNG0Ze56CHjDZ1IDYOwGYxYjO5KLSlz5HiNQ9udIXsPZ61VWaHGZ5XW/jyjmr6t2xz2jGVwbQ=="],
+
"@opencode-ai/desktop/@actions/artifact": ["@actions/artifact@4.0.0", "", { "dependencies": { "@actions/core": "^1.10.0", "@actions/github": "^6.0.1", "@actions/http-client": "^2.1.0", "@azure/core-http": "^3.0.5", "@azure/storage-blob": "^12.15.0", "@octokit/core": "^5.2.1", "@octokit/plugin-request-log": "^1.0.4", "@octokit/plugin-retry": "^3.0.9", "@octokit/request": "^8.4.1", "@octokit/request-error": "^5.1.1", "@protobuf-ts/plugin": "^2.2.3-alpha.1", "archiver": "^7.0.1", "jwt-decode": "^3.1.2", "unzip-stream": "^0.3.1" } }, "sha512-HCc2jMJRAfviGFAh0FsOR/jNfWhirxl7W6z8zDtttt0GltwxBLdEIjLiweOPFl9WbyJRW1VWnPUSAixJqcWUMQ=="],
"@opencode-ai/desktop/marked": ["marked@15.0.12", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-8dD6FusOQSrpv9Z1rdNMdlSgQOIP880DHqnohobOmYLElGEqAL/JvxvuxZO16r4HtjTlfPRDC1hbvxC9dPN2nA=="],
@@ -5993,6 +6083,8 @@
"@opencode-ai/llm/@smithy/util-utf8": ["@smithy/util-utf8@4.2.2", "", { "dependencies": { "@smithy/util-buffer-from": "^4.2.2", "tslib": "^2.6.2" } }, "sha512-75MeYpjdWRe8M5E3AW0O4Cx3UadweS+cwdXjwYGBW5h/gxxnbeZ877sLPX/ZJA9GVTlL/qG0dXP29JWFCD1Ayw=="],
+ "@opencode-ai/plugin/@ai-sdk/provider": ["@ai-sdk/provider@3.0.8", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-oGMAgGoQdBXbZqNG0Ze56CHjDZ1IDYOwGYxYjO5KLSlz5HiNQ9udIXsPZ61VWaHGZ5XW/jyjmr6t2xz2jGVwbQ=="],
+
"@opencode-ai/session-ui/@solid-primitives/resize-observer": ["@solid-primitives/resize-observer@2.1.3", "", { "dependencies": { "@solid-primitives/event-listener": "^2.4.3", "@solid-primitives/rootless": "^1.5.2", "@solid-primitives/static-store": "^0.1.2", "@solid-primitives/utils": "^6.3.2" }, "peerDependencies": { "solid-js": "^1.6.12" } }, "sha512-zBLje5E06TgOg93S7rGPldmhDnouNGhvfZVKOp+oG2XU8snA+GoCSSCz1M+jpNAg5Ek2EakU5UVQqL152WmdXQ=="],
"@opencode-ai/ui/@solid-primitives/resize-observer": ["@solid-primitives/resize-observer@2.1.3", "", { "dependencies": { "@solid-primitives/event-listener": "^2.4.3", "@solid-primitives/rootless": "^1.5.2", "@solid-primitives/static-store": "^0.1.2", "@solid-primitives/utils": "^6.3.2" }, "peerDependencies": { "solid-js": "^1.6.12" } }, "sha512-zBLje5E06TgOg93S7rGPldmhDnouNGhvfZVKOp+oG2XU8snA+GoCSSCz1M+jpNAg5Ek2EakU5UVQqL152WmdXQ=="],
@@ -6113,6 +6205,8 @@
"accepts/negotiator": ["negotiator@0.6.3", "", {}, "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg=="],
+ "ai/@ai-sdk/provider": ["@ai-sdk/provider@3.0.8", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-oGMAgGoQdBXbZqNG0Ze56CHjDZ1IDYOwGYxYjO5KLSlz5HiNQ9udIXsPZ61VWaHGZ5XW/jyjmr6t2xz2jGVwbQ=="],
+
"ai-gateway-provider/@ai-sdk/amazon-bedrock": ["@ai-sdk/amazon-bedrock@4.0.107", "", { "dependencies": { "@ai-sdk/anthropic": "3.0.78", "@ai-sdk/provider": "3.0.10", "@ai-sdk/provider-utils": "4.0.27", "@smithy/eventstream-codec": "^4.0.1", "@smithy/util-utf8": "^4.0.0", "aws4fetch": "^1.0.20" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-8nT08pGPy25rleJNk56ep00UHK6kCtCmu+ZNqVVSSPDieADlIZqcaN1iRXAFBoCH0Fb9F6C2EjFDaySdsargfQ=="],
"ai-gateway-provider/@ai-sdk/anthropic": ["@ai-sdk/anthropic@3.0.78", "", { "dependencies": { "@ai-sdk/provider": "3.0.10", "@ai-sdk/provider-utils": "4.0.27" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-0OY12G20cUt6iU6htpEA1491Oz++NVxZxlmWGX4B7rSbeZ5pnDmOu6YtW9BKzdZlNx5Gn23i6WMxyZFoMKNcgA=="],
@@ -6337,6 +6431,8 @@
"opencode/@ai-sdk/openai-compatible": ["@ai-sdk/openai-compatible@2.0.41", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-kNAGINk71AlOXx10Dq/PXw4t/9XjdK8uxfpVElRwtSFMdeSiLVt58p9TPx4/FJD+hxZuVhvxYj9r42osxWq79g=="],
+ "opencode/@ai-sdk/provider": ["@ai-sdk/provider@3.0.8", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-oGMAgGoQdBXbZqNG0Ze56CHjDZ1IDYOwGYxYjO5KLSlz5HiNQ9udIXsPZ61VWaHGZ5XW/jyjmr6t2xz2jGVwbQ=="],
+
"opencode/@solid-primitives/scheduled": ["@solid-primitives/scheduled@1.5.2", "", { "peerDependencies": { "solid-js": "^1.6.12" } }, "sha512-/j2igE0xyNaHhj6kMfcUQn5rAVSTLbAX+CDEBm25hSNBmNiHLu2lM7Usj2kJJ5j36D67bE8wR1hBNA8hjtvsQA=="],
"opencode/minimatch": ["minimatch@10.0.3", "", { "dependencies": { "@isaacs/brace-expansion": "^5.0.0" } }, "sha512-IPZ167aShDZZUMdRk66cyQAW3qr0WzbHkPdMYa8bzZhlHhO3jALbKdxcaak7W9FfT2rZNpQuUu4Od7ILEpXSaw=="],
@@ -6897,6 +6993,8 @@
"ai-gateway-provider/@ai-sdk/anthropic/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.27", "", { "dependencies": { "@ai-sdk/provider": "3.0.10", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.8" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-ubkAJ+xODouwtmN1tYlvTPphH1hPOBfZaEQe8U7skGvFAnIRs9PPpsq57bC2+Ky/MB4yzhd6YOsxTAx9sGpazw=="],
+ "ai-gateway-provider/@ai-sdk/openai/@ai-sdk/provider": ["@ai-sdk/provider@3.0.8", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-oGMAgGoQdBXbZqNG0Ze56CHjDZ1IDYOwGYxYjO5KLSlz5HiNQ9udIXsPZ61VWaHGZ5XW/jyjmr6t2xz2jGVwbQ=="],
+
"ajv-keywords/ajv/json-schema-traverse": ["json-schema-traverse@0.4.1", "", {}, "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg=="],
"ansi-align/string-width/emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="],
diff --git a/package.json b/package.json
index b9622583a76e..3175b1c6fce0 100644
--- a/package.json
+++ b/package.json
@@ -94,6 +94,7 @@
},
"devDependencies": {
"@actions/artifact": "5.0.1",
+ "@ai-sdk/provider": "4.0.0",
"@tsconfig/bun": "catalog:",
"@types/mime-types": "3.0.1",
"@typescript/native-preview": "catalog:",
diff --git a/packages/app/src/components/session/session-sortable-tab.tsx b/packages/app/src/components/session/session-sortable-tab.tsx
index f04228ca66c7..29a1e05008da 100644
--- a/packages/app/src/components/session/session-sortable-tab.tsx
+++ b/packages/app/src/components/session/session-sortable-tab.tsx
@@ -9,6 +9,7 @@ import { getFilename } from "@opencode-ai/core/util/path"
import { useFile } from "@/context/file"
import { useLanguage } from "@/context/language"
import { useCommand } from "@/context/command"
+import { useSessionLayout } from "@/pages/session/session-layout"
export function FileVisual(props: { path: string; active?: boolean }): JSX.Element {
return (
@@ -31,8 +32,10 @@ export function SortableTab(props: { tab: string; onTabClose: (tab: string) => v
const file = useFile()
const language = useLanguage()
const command = useCommand()
+ const { tabs } = useSessionLayout()
const sortable = createSortable(props.tab)
const path = createMemo(() => file.pathFromTab(props.tab))
+ const dirty = createMemo(() => tabs().dirty(props.tab))
const content = createMemo(() => {
const value = path()
if (!value) return
@@ -40,24 +43,33 @@ export function SortableTab(props: { tab: string; onTabClose: (tab: string) => v
})
return (
-
+
- props.onTabClose(props.tab)}
- aria-label={language.t("common.closeTab")}
- />
-
+
+
+
+
+
+ props.onTabClose(props.tab)}
+ aria-label={language.t("common.closeTab")}
+ />
+
+
}
hideCloseButton
onMiddleClick={() => props.onTabClose(props.tab)}
diff --git a/packages/app/src/context/command.tsx b/packages/app/src/context/command.tsx
index 0c935307ade1..dff120d2588d 100644
--- a/packages/app/src/context/command.tsx
+++ b/packages/app/src/context/command.tsx
@@ -13,7 +13,7 @@ const IS_MAC = typeof navigator === "object" && /(Mac|iPod|iPhone|iPad)/.test(na
const PALETTE_ID = "command.palette"
const DEFAULT_PALETTE_KEYBIND = "mod+shift+p"
const SUGGESTED_PREFIX = "suggested."
-const EDITABLE_KEYBIND_IDS = new Set(["terminal.toggle", "terminal.new", "file.attach"])
+const EDITABLE_KEYBIND_IDS = new Set(["terminal.toggle", "terminal.new", "file.attach", "file.save"])
type KeyLabel =
| "common.key.ctrl"
diff --git a/packages/app/src/context/layout.tsx b/packages/app/src/context/layout.tsx
index 1487676e54f5..fcce7623e199 100644
--- a/packages/app/src/context/layout.tsx
+++ b/packages/app/src/context/layout.tsx
@@ -18,6 +18,7 @@ import { migrateLegacySessionStateKeys, ServerScope, SessionStateKey } from "@/u
import { createSessionKeyReader, ensureSessionKey, pruneSessionKeys } from "./layout-helpers"
import { requireServerKey } from "@/utils/session-route"
import { type DraftTab, useTabs } from "./tabs"
+import { createTabWriteGuard } from "./tab-write-guard"
export { createSessionKeyReader, ensureSessionKey, pruneSessionKeys }
@@ -57,6 +58,7 @@ export function getProjectAvatarVariant(key?: string): ProjectAvatarVariant {
type SessionTabs = {
active?: string
all: string[]
+ dirty?: Record
}
type SessionView = {
@@ -592,6 +594,8 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
if (sessionTimer !== undefined) window.clearTimeout(sessionTimer)
})
+ const { runTabWrite, tabsWriting } = createTabWriteGuard()
+
return {
route,
ready,
@@ -601,6 +605,7 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
setStore("home", "selection", reconcile(selection))
},
},
+ tabsWriting,
handoff: {
tabs: createMemo(() => store.handoff?.tabs),
setTabs(dir: string, id: string) {
@@ -940,28 +945,61 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
tabs,
active: createMemo(() => tabs().active),
all: createMemo(() => tabs().all.filter((tab) => tab !== "review")),
+ dirty(tab: string) {
+ return tabs().dirty?.[tab] ?? false
+ },
+ setDirty(tab: string, value: boolean) {
+ const session = key()
+ const current = store.sessionTabs[session]
+ if (!current) {
+ if (!value) return
+ setStore("sessionTabs", session, { all: [], dirty: { [tab]: true } })
+ return
+ }
+ if (!value) {
+ if (!current.dirty?.[tab]) return
+ setStore(
+ "sessionTabs",
+ session,
+ "dirty",
+ produce((draft) => {
+ if (draft) delete draft[tab]
+ }),
+ )
+ return
+ }
+ if (!current.dirty) {
+ setStore("sessionTabs", session, "dirty", { [tab]: true })
+ return
+ }
+ setStore("sessionTabs", session, "dirty", tab, true)
+ },
setActive(tab: string | undefined) {
const session = key()
const next = tab ? normalize(tab) : tab
- if (!store.sessionTabs[session]) {
- setStore("sessionTabs", session, { all: [], active: next })
- } else {
- setStore("sessionTabs", session, "active", next)
- }
+ runTabWrite(() => {
+ if (!store.sessionTabs[session]) {
+ setStore("sessionTabs", session, { all: [], active: next })
+ } else {
+ setStore("sessionTabs", session, "active", next)
+ }
+ })
},
setAll(all: string[]) {
const session = key()
const next = normalizeAll(all).filter((tab) => tab !== "review")
- if (!store.sessionTabs[session]) {
- setStore("sessionTabs", session, { all: next, active: undefined })
- } else {
- setStore("sessionTabs", session, "all", next)
- }
+ runTabWrite(() => {
+ if (!store.sessionTabs[session]) {
+ setStore("sessionTabs", session, { all: next, active: undefined })
+ } else {
+ setStore("sessionTabs", session, "all", next)
+ }
+ })
},
async open(tab: string) {
const session = key()
const next = nextSessionTabsForOpen(store.sessionTabs[session], normalize(tab))
- setStore("sessionTabs", session, next)
+ runTabWrite(() => setStore("sessionTabs", session, next))
},
close(tab: string) {
const session = key()
@@ -975,8 +1013,22 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
}
const all = current.all.filter((x) => x !== tab)
+ const clearDirty = () => {
+ if (!current.dirty?.[tab]) return
+ setStore(
+ "sessionTabs",
+ session,
+ "dirty",
+ produce((draft) => {
+ if (draft) delete draft[tab]
+ }),
+ )
+ }
if (current.active !== tab) {
- setStore("sessionTabs", session, "all", all)
+ batch(() => {
+ setStore("sessionTabs", session, "all", all)
+ clearDirty()
+ })
return
}
@@ -985,6 +1037,7 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
batch(() => {
setStore("sessionTabs", session, "all", all)
setStore("sessionTabs", session, "active", next)
+ clearDirty()
})
},
move(tab: string, to: number) {
diff --git a/packages/app/src/context/tab-write-guard.test.ts b/packages/app/src/context/tab-write-guard.test.ts
new file mode 100644
index 000000000000..7e197d6005ea
--- /dev/null
+++ b/packages/app/src/context/tab-write-guard.test.ts
@@ -0,0 +1,58 @@
+import { describe, expect, test } from "bun:test"
+import { createTabWriteGuard } from "./tab-write-guard"
+
+const microtask = () => new Promise((resolve) => queueMicrotask(resolve))
+
+describe("tab write guard (layout tabsWriting)", () => {
+ test("tabsWriting is true synchronously inside a tab write and false after a microtask", async () => {
+ const guard = createTabWriteGuard()
+ expect(guard.tabsWriting()).toBe(false)
+
+ let insideDuringWrite: boolean | undefined
+ guard.runTabWrite(() => {
+ insideDuringWrite = guard.tabsWriting()
+ })
+
+ expect(insideDuringWrite).toBe(true)
+ expect(guard.tabsWriting()).toBe(true)
+
+ await microtask()
+ expect(guard.tabsWriting()).toBe(false)
+ })
+
+ test("nested writes stay guarded until all have flushed", async () => {
+ const guard = createTabWriteGuard()
+ guard.runTabWrite(() => {
+ guard.runTabWrite(() => {})
+ expect(guard.tabsWriting()).toBe(true)
+ })
+ expect(guard.tabsWriting()).toBe(true)
+
+ await microtask()
+ expect(guard.tabsWriting()).toBe(false)
+ })
+
+ test("onChange feedback during a programmatic write is ignored; genuine onChange is honored", async () => {
+ const guard = createTabWriteGuard()
+ let active = "review"
+
+ const openTab = (value: string) => {
+ active = value
+ }
+ const onTabsChange = (value: string) => {
+ if (guard.tabsWriting()) return
+ openTab(value)
+ }
+
+ // Programmatically open a file tab; Kobalte re-emits onChange("review") while
+ // it reconciles. That stale feedback must NOT revert the active tab.
+ guard.runTabWrite(() => openTab("file://src/a.ts"))
+ onTabsChange("review")
+ expect(active).toBe("file://src/a.ts")
+
+ // After the write flushes, a genuine user onChange IS honored.
+ await microtask()
+ onTabsChange("review")
+ expect(active).toBe("review")
+ })
+})
diff --git a/packages/app/src/context/tab-write-guard.ts b/packages/app/src/context/tab-write-guard.ts
new file mode 100644
index 000000000000..69daade8a855
--- /dev/null
+++ b/packages/app/src/context/tab-write-guard.ts
@@ -0,0 +1,17 @@
+// Controlled Kobalte re-emits onChange with a stale key during programmatic
+// active-tab changes; guard so the Tabs onChange ignores that feedback. Reset on a
+// microtask to cover the store write's reactive flush.
+export function createTabWriteGuard() {
+ let depth = 0
+ const runTabWrite = (fn: () => T): T => {
+ depth++
+ try {
+ return fn()
+ } finally {
+ queueMicrotask(() => {
+ depth--
+ })
+ }
+ }
+ return { runTabWrite, tabsWriting: () => depth > 0 }
+}
diff --git a/packages/app/src/i18n/en.ts b/packages/app/src/i18n/en.ts
index 3872d01ae78b..1682b4785a89 100644
--- a/packages/app/src/i18n/en.ts
+++ b/packages/app/src/i18n/en.ts
@@ -734,6 +734,21 @@ export const dict = {
"common.close": "Close",
"common.edit": "Edit",
"common.loadMore": "Load more",
+ "common.discard": "Discard",
+ "common.overwrite": "Overwrite",
+ "common.reload": "Reload",
+ "common.unsavedChanges": "Unsaved changes",
+ "file.edit.toggle.edit": "Edit",
+ "file.edit.toggle.view": "View",
+ "file.save.command": "Save file",
+ "file.unsaved.title": "Unsaved changes",
+ "file.unsaved.description": "You have unsaved changes. Do you want to save them?",
+ "file.conflict.title": "File changed on disk",
+ "file.conflict.description": "This file was modified outside the editor. Reload to discard your edits, or overwrite to keep them.",
+ "toast.file.saved.title": "File saved",
+ "toast.file.saveFailed.title": "Failed to save file",
+ "toast.file.changedExternally.title": "File changed on disk",
+ "toast.file.definitionExternal.title": "Definition is outside the workspace",
"common.key.esc": "ESC",
"common.key.ctrl": "Ctrl",
"common.key.alt": "Alt",
diff --git a/packages/app/src/pages/session/file-confirm-dialog.tsx b/packages/app/src/pages/session/file-confirm-dialog.tsx
new file mode 100644
index 000000000000..8d49cadc0a57
--- /dev/null
+++ b/packages/app/src/pages/session/file-confirm-dialog.tsx
@@ -0,0 +1,61 @@
+import { Button } from "@opencode-ai/ui/button"
+import { useDialog } from "@opencode-ai/ui/context/dialog"
+import { Dialog } from "@opencode-ai/ui/dialog"
+import { For } from "solid-js"
+
+export type ConfirmChoice = {
+ value: string
+ label: string
+ variant?: "primary" | "secondary" | "ghost"
+}
+
+function ConfirmDialog(props: {
+ title: string
+ description: string
+ choices: ConfirmChoice[]
+ onPick: (value: string | undefined) => void
+}) {
+ const dialog = useDialog()
+ return (
+
+ )
+}
+
+export function confirmChoice(
+ dialog: ReturnType,
+ input: { title: string; description: string; choices: ConfirmChoice[] },
+): Promise {
+ return new Promise((resolve) => {
+ let settled = false
+ const pick = (value: string | undefined) => {
+ if (settled) return
+ settled = true
+ resolve(value)
+ }
+ dialog.push(
+ () => (
+
+ ),
+ () => pick(undefined),
+ )
+ })
+}
diff --git a/packages/app/src/pages/session/file-save.test.ts b/packages/app/src/pages/session/file-save.test.ts
new file mode 100644
index 000000000000..e56ca90a5e38
--- /dev/null
+++ b/packages/app/src/pages/session/file-save.test.ts
@@ -0,0 +1,301 @@
+import { describe, expect, mock, test } from "bun:test"
+import {
+ activeEditor,
+ clearActiveEditor,
+ createFileSaver,
+ guardTab,
+ isDirtyAgainst,
+ registerActiveEditor,
+ setPendingEditOpen,
+ takePendingEditOpen,
+ type WriteResult,
+} from "./file-save"
+
+type Harness = {
+ editing: boolean
+ text: string
+ dirty: boolean
+ conflict?: "reload" | "overwrite" | undefined
+ unsaved?: "save" | "discard" | "cancel" | undefined
+}
+
+function createHarness(initial: Partial & { write: (content: string, expectedSha?: string) => WriteResult }) {
+ const state: Harness = {
+ editing: true,
+ text: "",
+ dirty: false,
+ ...initial,
+ }
+ const writes: Array<{ content: string; expectedSha?: string }> = []
+ const saver = createFileSaver({
+ editing: () => state.editing,
+ currentText: () => state.text,
+ isDirty: () => state.dirty,
+ setDirty: (v) => {
+ state.dirty = v
+ },
+ write: async (content, expectedSha) => {
+ writes.push({ content, expectedSha })
+ return initial.write(content, expectedSha)
+ },
+ reloadFromDisk: () => {},
+ leaveEditMode: () => {
+ state.editing = false
+ },
+ promptConflict: async () => state.conflict,
+ promptUnsaved: async () => state.unsaved,
+ })
+ return { state, saver, writes }
+}
+
+describe("isDirtyAgainst", () => {
+ test("dirty when content differs, clean when it matches (empty is valid)", () => {
+ expect(isDirtyAgainst("a", "b")).toBe(true)
+ expect(isDirtyAgainst("a", "a")).toBe(false)
+ expect(isDirtyAgainst("", "")).toBe(false)
+ expect(isDirtyAgainst("x", "")).toBe(true)
+ })
+})
+
+describe("createFileSaver.onChange dirty tracking", () => {
+ test("editing sets dirty; returning to baseline clears it", () => {
+ const { state, saver } = createHarness({ write: () => ({ written: true }) })
+ saver.setBaseline("hello", "sha1")
+
+ saver.onChange("hello world")
+ expect(state.dirty).toBe(true)
+
+ saver.onChange("hello")
+ expect(state.dirty).toBe(false)
+ })
+})
+
+describe("createFileSaver.save", () => {
+ test("writes content with expectedSha, clears dirty, updates baseline", async () => {
+ const { state, saver, writes } = createHarness({
+ text: "new",
+ dirty: true,
+ write: () => ({ written: true, sha: "sha2" }),
+ })
+ saver.setBaseline("old", "sha1")
+
+ await saver.save()
+
+ expect(writes).toHaveLength(1)
+ expect(writes[0]).toEqual({ content: "new", expectedSha: "sha1" })
+ expect(state.dirty).toBe(false)
+ expect(saver.baselineSha).toBe("sha2")
+
+ // Baseline updated: editing back to "new" is clean, "old" is now dirty.
+ saver.onChange("new")
+ expect(state.dirty).toBe(false)
+ })
+
+ test("idempotent: no write when not dirty", async () => {
+ const { saver, writes } = createHarness({ text: "x", dirty: false, write: () => ({ written: true }) })
+ await saver.save()
+ expect(writes).toHaveLength(0)
+ })
+
+ test("conflict triggers prompt and keeps dirty when dismissed", async () => {
+ const prompt = mock(async () => undefined as "reload" | "overwrite" | undefined)
+ const state = { editing: true, text: "new", dirty: true }
+ const writes: Array<{ content: string; expectedSha?: string }> = []
+ const saver = createFileSaver({
+ editing: () => state.editing,
+ currentText: () => state.text,
+ isDirty: () => state.dirty,
+ setDirty: (v) => {
+ state.dirty = v
+ },
+ write: async (content, expectedSha) => {
+ writes.push({ content, expectedSha })
+ return { written: false, conflict: true }
+ },
+ reloadFromDisk: () => {},
+ leaveEditMode: () => {},
+ promptConflict: () => prompt(),
+ promptUnsaved: async () => undefined,
+ })
+ saver.setBaseline("old", "sha1")
+
+ await saver.save()
+
+ expect(prompt).toHaveBeenCalledTimes(1)
+ expect(state.dirty).toBe(true) // still dirty after dismiss
+ })
+
+ test("conflict + overwrite re-sends WITHOUT expectedSha and clears dirty", async () => {
+ const writes: Array<{ content: string; expectedSha?: string }> = []
+ const state = { editing: true, text: "new", dirty: true }
+ let call = 0
+ const saver = createFileSaver({
+ editing: () => state.editing,
+ currentText: () => state.text,
+ isDirty: () => state.dirty,
+ setDirty: (v) => {
+ state.dirty = v
+ },
+ write: async (content, expectedSha) => {
+ writes.push({ content, expectedSha })
+ call += 1
+ return call === 1 ? { written: false, conflict: true } : { written: true, sha: "sha3" }
+ },
+ reloadFromDisk: () => {},
+ leaveEditMode: () => {},
+ promptConflict: async () => "overwrite",
+ promptUnsaved: async () => undefined,
+ })
+ saver.setBaseline("old", "sha1")
+
+ await saver.save()
+
+ expect(writes).toHaveLength(2)
+ expect(writes[0].expectedSha).toBe("sha1")
+ expect(writes[1].expectedSha).toBeUndefined() // forced overwrite
+ expect(state.dirty).toBe(false)
+ expect(saver.baselineSha).toBe("sha3")
+ })
+})
+
+describe("createFileSaver.guard (unsaved guard)", () => {
+ test("clean tab is safe without prompting", async () => {
+ const prompt = mock(async () => "cancel" as const)
+ const { saver } = createHarness({ dirty: false, write: () => ({ written: true }) })
+ expect(await saver.guard()).toBe(true)
+ expect(prompt).not.toHaveBeenCalled()
+ })
+
+ test("dirty tab prompts; cancel blocks", async () => {
+ const { saver } = createHarness({ dirty: true, unsaved: "cancel", write: () => ({ written: true }) })
+ expect(await saver.guard()).toBe(false)
+ })
+
+ test("dirty tab prompts; discard proceeds and clears dirty", async () => {
+ const { state, saver } = createHarness({ dirty: true, unsaved: "discard", write: () => ({ written: true }) })
+ expect(await saver.guard()).toBe(true)
+ expect(state.dirty).toBe(false)
+ })
+
+ test("dirty tab prompts; save writes then proceeds", async () => {
+ const { state, saver, writes } = createHarness({
+ text: "new",
+ dirty: true,
+ unsaved: "save",
+ write: () => ({ written: true, sha: "s" }),
+ })
+ saver.setBaseline("old")
+ expect(await saver.guard()).toBe(true)
+ expect(writes).toHaveLength(1)
+ expect(state.dirty).toBe(false)
+ })
+})
+
+describe("active editor registry + guardTab", () => {
+ test("guardTab consults the registered editor and isolates by tab", async () => {
+ const guard = mock(async () => false)
+ registerActiveEditor({
+ tab: "file://a",
+ editing: () => true,
+ dirty: () => true,
+ save: () => {},
+ guard,
+ })
+
+ // Different tab: no active editor for it -> safe, no prompt.
+ expect(await guardTab("file://b")).toBe(true)
+ expect(guard).not.toHaveBeenCalled()
+
+ // Matching tab: delegates to the editor's guard.
+ expect(await guardTab("file://a")).toBe(false)
+ expect(guard).toHaveBeenCalledTimes(1)
+
+ clearActiveEditor("file://a")
+ expect(activeEditor()).toBeUndefined()
+ expect(await guardTab("file://a")).toBe(true)
+ })
+})
+
+describe("pending cross-file edit-open slot", () => {
+ test("takePendingEditOpen returns + clears only for the matching path", () => {
+ setPendingEditOpen("go/main.go", { line: 10, character: 4 })
+
+ // Non-matching path leaves the slot intact.
+ expect(takePendingEditOpen("go/other.go")).toBeUndefined()
+
+ // Matching path returns the pos and clears it.
+ expect(takePendingEditOpen("go/main.go")).toEqual({ line: 10, character: 4 })
+
+ // Slot is now empty.
+ expect(takePendingEditOpen("go/main.go")).toBeUndefined()
+ })
+
+ test("a later setPendingEditOpen overwrites the previous slot", () => {
+ setPendingEditOpen("a.ts", { line: 1, character: 0 })
+ setPendingEditOpen("b.ts", { line: 2, character: 3 })
+ expect(takePendingEditOpen("a.ts")).toBeUndefined()
+ expect(takePendingEditOpen("b.ts")).toEqual({ line: 2, character: 3 })
+ })
+})
+
+describe("switch-away guard (B1)", () => {
+ // Mirrors openTabGuarded in session-side-panel: before switching the active
+ // file tab, run guardTab(current); only switch when it resolves true.
+ const switchGuarded = async (current: string | undefined, next: string, onSwitch: (tab: string) => void) => {
+ if (!current || current === next) {
+ onSwitch(next)
+ return
+ }
+ if (await guardTab(current)) onSwitch(next)
+ }
+
+ test("switching away from a dirty tab triggers the guard; Cancel aborts, Discard proceeds", async () => {
+ // Dirty active tab whose guard cancels.
+ const guard = mock(async () => false)
+ registerActiveEditor({
+ tab: "file://a",
+ editing: () => true,
+ dirty: () => true,
+ save: () => {},
+ guard,
+ })
+
+ let switched: string | undefined
+ await switchGuarded("file://a", "file://b", (t) => (switched = t))
+ expect(guard).toHaveBeenCalledTimes(1) // guard consulted
+ expect(switched).toBeUndefined() // Cancel aborts the switch
+
+ // Now the guard resolves (Discard/Save) -> switch proceeds.
+ const guardOk = mock(async () => true)
+ registerActiveEditor({
+ tab: "file://a",
+ editing: () => true,
+ dirty: () => true,
+ save: () => {},
+ guard: guardOk,
+ })
+ await switchGuarded("file://a", "file://b", (t) => (switched = t))
+ expect(guardOk).toHaveBeenCalledTimes(1)
+ expect(switched).toBe("file://b")
+
+ clearActiveEditor("file://a")
+ })
+
+ test("switching with no dirty editor does not prompt and proceeds", async () => {
+ const guard = mock(async () => false)
+ registerActiveEditor({
+ tab: "file://a",
+ editing: () => false,
+ dirty: () => false,
+ // guard returns true for a clean tab; this mock should not block.
+ guard: async () => true,
+ save: () => {},
+ })
+
+ let switched: string | undefined
+ await switchGuarded("file://a", "file://b", (t) => (switched = t))
+ expect(guard).not.toHaveBeenCalled()
+ expect(switched).toBe("file://b")
+ clearActiveEditor("file://a")
+ })
+})
diff --git a/packages/app/src/pages/session/file-save.ts b/packages/app/src/pages/session/file-save.ts
new file mode 100644
index 000000000000..50addbeb1e2b
--- /dev/null
+++ b/packages/app/src/pages/session/file-save.ts
@@ -0,0 +1,140 @@
+import { createSignal } from "solid-js"
+
+export type ActiveEditor = {
+ tab: string
+ editing: () => boolean
+ dirty: () => boolean
+ save: () => void | Promise
+ guard: () => Promise
+}
+
+export async function guardTab(tab: string): Promise {
+ const editor = active()
+ if (!editor || editor.tab !== tab) return true
+ return editor.guard()
+}
+
+const [active, setActive] = createSignal(undefined)
+
+export const activeEditor = active
+
+export function registerActiveEditor(editor: ActiveEditor) {
+ setActive(editor)
+}
+
+export function clearActiveEditor(tab: string) {
+ setActive((current) => (current?.tab === tab ? undefined : current))
+}
+
+export type PendingEditPos = { line: number; character: number }
+type PendingEditOpen = { path: string; pos: PendingEditPos }
+
+let pendingEditOpen: PendingEditOpen | undefined
+
+export function setPendingEditOpen(path: string, pos: PendingEditPos) {
+ pendingEditOpen = { path, pos }
+}
+
+export function takePendingEditOpen(path: string): PendingEditPos | undefined {
+ if (!pendingEditOpen || pendingEditOpen.path !== path) return undefined
+ const pos = pendingEditOpen.pos
+ pendingEditOpen = undefined
+ return pos
+}
+
+export function isDirtyAgainst(baseline: string, next: string) {
+ return next !== baseline
+}
+
+export type WriteResult = { conflict?: boolean; sha?: string; written?: boolean }
+
+export type FileSaverDeps = {
+ editing: () => boolean
+ currentText: () => string
+ isDirty: () => boolean
+ setDirty: (value: boolean) => void
+ write: (content: string, expectedSha?: string) => Promise
+ reloadFromDisk: () => void | Promise
+ leaveEditMode: () => void
+ promptConflict: () => Promise<"reload" | "overwrite" | undefined>
+ promptUnsaved: () => Promise<"save" | "discard" | "cancel" | undefined>
+ onSaved?: (content: string, sha?: string) => void
+ onError?: () => void
+}
+
+export function createFileSaver(deps: FileSaverDeps) {
+ let baseline = ""
+ let baselineSha: string | undefined
+ let saving = false
+
+ const setBaseline = (content: string, sha?: string) => {
+ baseline = content
+ baselineSha = sha
+ }
+
+ const applySaved = (content: string, sha?: string) => {
+ setBaseline(content, sha)
+ deps.setDirty(false)
+ deps.onSaved?.(content, sha)
+ }
+
+ const onChange = (next: string) => {
+ deps.setDirty(isDirtyAgainst(baseline, next))
+ }
+
+ const save = async (): Promise => {
+ if (!deps.editing() || !deps.isDirty() || saving) return
+ const content = deps.currentText()
+ saving = true
+ try {
+ // A conflict is a normal result (stale baselineSha), not a throw; on save the baseline resets to the returned sha.
+ const res = await deps.write(content, baselineSha)
+ if (res.conflict) {
+ const choice = await deps.promptConflict()
+ if (choice === "reload") {
+ await deps.reloadFromDisk()
+ deps.leaveEditMode()
+ return
+ }
+ if (choice === "overwrite") {
+ const forced = await deps.write(content)
+ if (forced.conflict) {
+ deps.onError?.()
+ return
+ }
+ applySaved(content, forced.sha)
+ }
+ return
+ }
+ applySaved(content, res.sha)
+ } catch {
+ deps.onError?.()
+ } finally {
+ saving = false
+ }
+ }
+
+ const guard = async (): Promise => {
+ if (!deps.editing() || !deps.isDirty()) return true
+ const choice = await deps.promptUnsaved()
+ if (choice === "save") {
+ await save()
+ return !deps.isDirty()
+ }
+ if (choice === "discard") {
+ deps.setDirty(false)
+ return true
+ }
+ return false
+ }
+
+ return {
+ onChange,
+ save,
+ guard,
+ setBaseline,
+ get baselineSha() {
+ return baselineSha
+ },
+ }
+}
diff --git a/packages/app/src/pages/session/file-tabs.tsx b/packages/app/src/pages/session/file-tabs.tsx
index 7d110ee54463..b6241788b93a 100644
--- a/packages/app/src/pages/session/file-tabs.tsx
+++ b/packages/app/src/pages/session/file-tabs.tsx
@@ -1,4 +1,4 @@
-import { createEffect, createMemo, createSignal, Match, on, onCleanup, Switch } from "solid-js"
+import { createEffect, createMemo, createSignal, Match, on, onCleanup, Show, Switch } from "solid-js"
import { createStore } from "solid-js/store"
import { Dynamic } from "solid-js/web"
import { makeEventListener } from "@solid-primitives/event-listener"
@@ -9,9 +9,22 @@ import { createLineCommentController } from "@opencode-ai/session-ui/line-commen
import { sampledChecksum } from "@opencode-ai/core/util/encode"
import { DropdownMenu } from "@opencode-ai/ui/dropdown-menu"
import { IconButton } from "@opencode-ai/ui/icon-button"
+import { CodeEditor } from "@opencode-ai/ui/code-editor"
+import { lspExtensions, type LspDiagnostic, type LspExtensionsOptions } from "@opencode-ai/ui/code-editor-lsp"
import { Tabs } from "@opencode-ai/ui/tabs"
import { ScrollView } from "@opencode-ai/ui/scroll-view"
+import { useDialog } from "@opencode-ai/ui/context/dialog"
import { showToast } from "@/utils/toast"
+import { useSDK } from "@/context/sdk"
+import {
+ registerActiveEditor,
+ clearActiveEditor,
+ createFileSaver,
+ setPendingEditOpen,
+ takePendingEditOpen,
+ type PendingEditPos,
+} from "@/pages/session/file-save"
+import { confirmChoice } from "@/pages/session/file-confirm-dialog"
import { selectionFromLines, useFile, type FileSelection, type SelectedLineRange } from "@/context/file"
import { useComments } from "@/context/comments"
import { useLanguage } from "@/context/language"
@@ -177,6 +190,8 @@ export function FileTabContent(props: { tab: string }) {
const language = useLanguage()
const prompt = usePrompt()
const fileComponent = useFileComponent()
+ const sdk = useSDK()
+ const dialog = useDialog()
const { sessionKey, tabs, view } = useSessionLayout()
const activeFileTab = createSessionTabs({
tabs,
@@ -200,6 +215,188 @@ export function FileTabContent(props: { tab: string }) {
})
const contents = createMemo(() => state()?.content?.content ?? "")
const cacheKey = createMemo(() => sampledChecksum(contents()))
+
+ const [editMode, setEditMode] = createSignal(false)
+ const [editorValue, setEditorValue] = createSignal("")
+
+ const isDirty = () => tabs().dirty(props.tab)
+ const setDirty = (value: boolean) => tabs().setDirty(props.tab, value)
+
+ const writeFile = async (content: string, expectedSha?: string) => {
+ const res = await sdk().client.file.write({
+ path: path() ?? "",
+ content,
+ ...(expectedSha ? { expectedSha } : {}),
+ })
+ if (res.error) throw res.error
+ return (res.data ?? {}) as { conflict?: boolean; sha?: string; written?: boolean }
+ }
+
+ const saver = createFileSaver({
+ editing: editMode,
+ currentText: () => editorValue(),
+ isDirty,
+ setDirty,
+ write: writeFile,
+ reloadFromDisk: async () => {
+ const p = path()
+ if (p) await file.load(p, { force: true })
+ seedFromDisk()
+ },
+ leaveEditMode: () => setEditMode(false),
+ promptConflict: async () =>
+ (await confirmChoice(dialog, {
+ title: language.t("file.conflict.title"),
+ description: language.t("file.conflict.description"),
+ choices: [
+ { value: "reload", label: language.t("common.reload") },
+ { value: "overwrite", label: language.t("common.overwrite"), variant: "primary" },
+ ],
+ })) as "reload" | "overwrite" | undefined,
+ promptUnsaved: async () =>
+ (await confirmChoice(dialog, {
+ title: language.t("file.unsaved.title"),
+ description: language.t("file.unsaved.description"),
+ choices: [
+ { value: "cancel", label: language.t("common.cancel"), variant: "ghost" },
+ { value: "discard", label: language.t("common.discard") },
+ { value: "save", label: language.t("common.save"), variant: "primary" },
+ ],
+ })) as "save" | "discard" | "cancel" | undefined,
+ onSaved: () => {
+ const p = path()
+ if (p) void file.load(p, { force: true })
+ showToast({ variant: "success", title: language.t("toast.file.saved.title") })
+ },
+ onError: () => showToast({ variant: "error", title: language.t("toast.file.saveFailed.title") }),
+ })
+
+ const save = () => saver.save()
+ const guardLeave = () => saver.guard()
+
+ const seedFromDisk = () => {
+ const text = contents()
+ setEditorValue(text)
+ saver.setBaseline(text, undefined)
+ setDirty(false)
+ }
+
+ const enterEditMode = () => {
+ if (editMode()) return
+ seedFromDisk()
+ setEditMode(true)
+ }
+
+ const onEditorChange = (next: string) => {
+ setEditorValue(next)
+ saver.onChange(next)
+ }
+
+ const [pendingSelection, setPendingSelection] = createSignal(undefined)
+
+ createEffect(() => {
+ const p = path()
+ if (!p) return
+ if (!state()?.loaded) return
+ const pos = takePendingEditOpen(file.normalize(p))
+ if (!pos) return
+ seedFromDisk()
+ setEditMode(true)
+ setPendingSelection(pos)
+ queueMicrotask(() => setPendingSelection(undefined))
+ })
+
+ // Per-tab monotonic buffer version; a successful save keeps the buffer open at the same version (no bump, no re-open).
+ let bufferVersion = 0
+ const diagnosticsSubs = new Set<(list: LspDiagnostic[]) => void>()
+
+ const samePath = (a: string, b: string) => file.normalize(a) === file.normalize(b)
+
+ createEffect(() => {
+ const p = path()
+ if (!p) return
+ const unsub = sdk().event.on("lsp.diagnostics", (e) => {
+ const props = e.properties
+ if (!samePath(props.path, p)) return
+ const list = (props.diagnostics ?? []) as LspDiagnostic[]
+ for (const cb of diagnosticsSubs) cb(list)
+ })
+ onCleanup(unsub)
+ })
+
+ const buildLspOptions = (p: string): LspExtensionsOptions => {
+ const client = () => sdk().client.lsp
+ return {
+ path: p,
+ bumpVersion: () => ++bufferVersion,
+ lsp: {
+ buffer: (input) => client().buffer(input).then((r) => r.data),
+ bufferClose: (input) => client().bufferClose(input).then((r) => r.data),
+ completion: (input) => client().completion(input).then((r) => r.data),
+ hover: (input) => client().hover(input).then((r) => r.data),
+ definition: (input) => client().definition(input).then((r) => r.data),
+ diagnostics: (input) => client().diagnostics(input).then((r) => r.data),
+ },
+ onOpenLocation: (target, pos) => {
+ // Definitions outside the workspace arrive as absolute paths the file API can't read; surface a notice instead of a broken tab.
+ if (target.startsWith("/") || target.startsWith("file://")) {
+ showToast({ variant: "default", title: language.t("toast.file.definitionExternal.title") })
+ return
+ }
+ const normalized = file.normalize(target)
+ if (!normalized) return
+ setPendingEditOpen(normalized, pos)
+ void (async () => {
+ await tabs().open(file.tab(normalized))
+ await file.load(normalized)
+ })()
+ },
+ subscribeDiagnostics: (_path, cb) => {
+ diagnosticsSubs.add(cb)
+ return () => diagnosticsSubs.delete(cb)
+ },
+ }
+ }
+
+ const editorExtensions = createMemo(() => {
+ const p = path()
+ if (!p) return []
+ return lspExtensions(buildLspOptions(p))
+ })
+
+ const toggleEditMode = async () => {
+ if (!editMode()) {
+ enterEditMode()
+ return
+ }
+ if (!(await guardLeave())) return
+ setEditMode(false)
+ }
+
+ createEffect(() => {
+ if (activeFileTab() !== props.tab) return
+ registerActiveEditor({
+ tab: props.tab,
+ editing: editMode,
+ dirty: isDirty,
+ save,
+ guard: guardLeave,
+ })
+ })
+ onCleanup(() => clearActiveEditor(props.tab))
+
+ createEffect(
+ on(
+ contents,
+ (next, prev) => {
+ if (prev === undefined) return
+ if (next === prev) return
+ if (!editMode() || !isDirty()) return
+ showToast({ variant: "default", title: language.t("toast.file.changedExternally.title") })
+ },
+ { defer: true },
+ ),
+ )
const selectedLines = createMemo(() => {
const p = path()
if (!p) return null
@@ -439,17 +636,58 @@ export function FileTabContent(props: { tab: string }) {
)
+ const EditViewToggle = () => (
+
+ void toggleEditMode()}
+ />
+ void toggleEditMode()}
+ />
+
+ )
+
return (
-
-
- {renderFile(contents())}
-
- {language.t("common.loading")}...
-
- {(err) => {err()}
}
-
-
+
+
+
+
+
+
+ void save()}
+ extensions={editorExtensions()}
+ initialSelection={pendingSelection()}
+ class="h-full"
+ />
+
+
+
+
+ {renderFile(contents())}
+
+
+
+ {language.t("common.loading")}...
+
+ {(err) => {err()}
}
+
)
}
diff --git a/packages/app/src/pages/session/session-side-panel.tsx b/packages/app/src/pages/session/session-side-panel.tsx
index 097df2219376..b5a8a17f7789 100644
--- a/packages/app/src/pages/session/session-side-panel.tsx
+++ b/packages/app/src/pages/session/session-side-panel.tsx
@@ -23,6 +23,7 @@ import { useSettings } from "@/context/settings"
import { useSync } from "@/context/sync"
import { createFileTabListSync } from "@/pages/session/file-tab-scroll"
import { FileTabContent } from "@/pages/session/file-tabs"
+import { guardTab } from "@/pages/session/file-save"
import {
createOpenSessionFileTab,
createSessionTabs,
@@ -191,6 +192,30 @@ export function SessionSidePanel(props: {
setStore("activeDraggable", undefined)
}
+ const closeTabGuarded = async (tab: string) => {
+ if (!(await guardTab(tab))) return
+ tabs().close(tab)
+ }
+
+ // Run the unsaved guard before switching away from a dirty active file tab (switching disposes its editor buffer).
+ const openTabGuarded = (value: string) => {
+ const next = normalizeTab(value)
+ const current = activeFileTab()
+ if (!current || current === next) {
+ openTab(value)
+ return
+ }
+ void guardTab(current).then((ok) => {
+ if (ok) openTab(value)
+ })
+ }
+
+ // Ignore the controlled
stale-key feedback during a programmatic tab write (see layout tabsWriting).
+ const onTabsChange = (value: string) => {
+ if (layout.tabsWriting()) return
+ openTabGuarded(value)
+ }
+
createEffect(() => {
if (!file.ready()) return
@@ -253,7 +278,7 @@ export function SessionSidePanel(props: {
>
-
+
{
@@ -300,7 +325,7 @@ export function SessionSidePanel(props: {
- {(tab) => }
+ {(tab) => void closeTabGuarded(t)} />}
openTab(file.tab(node.path))}
+ onFileClick={(node) => openTabGuarded(file.tab(node.path))}
/>
diff --git a/packages/app/src/pages/session/use-session-commands.tsx b/packages/app/src/pages/session/use-session-commands.tsx
index fd2e29d31c69..0f2d80b5ea3a 100644
--- a/packages/app/src/pages/session/use-session-commands.tsx
+++ b/packages/app/src/pages/session/use-session-commands.tsx
@@ -15,6 +15,7 @@ import { useTerminal } from "@/context/terminal"
import { showToast } from "@/utils/toast"
import { findLast } from "@opencode-ai/core/util/array"
import { createSessionTabs } from "@/pages/session/helpers"
+import { activeEditor, guardTab } from "@/pages/session/file-save"
import { extractPromptFromParts } from "@/utils/prompt"
import { UserMessage } from "@opencode-ai/sdk/v2"
import { useSessionLayout } from "@/pages/session/session-layout"
@@ -239,12 +240,19 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
)
}
- const closeTab = () => {
+ const closeTab = async () => {
const tab = closableTab()
if (!tab) return
+ if (!(await guardTab(tab))) return
tabs().close(tab)
}
+ const saveActiveFile = () => {
+ const editor = activeEditor()
+ if (!editor || !editor.editing() || !editor.dirty()) return
+ void editor.save()
+ }
+
const addSelection = () => {
const tab = activeFileTab()
if (!tab) return
@@ -464,6 +472,13 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
slash: "open",
onSelect: openFile,
}),
+ fileCommand({
+ id: "file.save",
+ title: language.t("file.save.command"),
+ keybind: "mod+s",
+ hidden: true,
+ onSelect: saveActiveFile,
+ }),
tab &&
fileCommand({
id: "tab.close",
diff --git a/packages/opencode/src/lsp/client.ts b/packages/opencode/src/lsp/client.ts
index 08d8a53d9be0..932d9173d468 100644
--- a/packages/opencode/src/lsp/client.ts
+++ b/packages/opencode/src/lsp/client.ts
@@ -113,9 +113,7 @@ function configurationValue(settings: unknown, section?: string) {
return result ?? null
}
-// TypeScript's built-in LSP pushes diagnostics aggressively on first open.
-// We seed the push cache on the very first publish so waitForFreshPush can
-// resolve immediately instead of waiting for a second debounced push.
+// Seed the push cache on the first publish so waitForFreshPush resolves without waiting for a second debounced push.
function shouldSeedDiagnosticsOnFirstPush(serverID: string) {
return serverID === "typescript"
}
@@ -126,6 +124,8 @@ export async function create(input: {
root: string
directory: string
instance: InstanceContext
+ // Fired debounced per path (latest-wins) with the deduped push+pull merge; empty means cleared. `path` is absolute.
+ onDiagnostics?: (input: { path: string; diagnostics: Diagnostic[] }) => void
}) {
const instance = input.instance
@@ -144,12 +144,28 @@ export async function create(input: {
const diagnosticListeners = new Set<(input: { path: string; serverID: string }) => void>()
const mergedDiagnostics = (filePath: string) =>
dedupeDiagnostics([...(pushDiagnostics.get(filePath) ?? []), ...(pullDiagnostics.get(filePath) ?? [])])
+ // Coalesce rapid push/pull updates per path into one emit per DIAGNOSTICS_DEBOUNCE_MS window so a typing burst yields one event.
+ const diagnosticEmitTimers = new Map>()
+ const scheduleDiagnosticsEmit = (filePath: string) => {
+ if (!input.onDiagnostics) return
+ const existing = diagnosticEmitTimers.get(filePath)
+ if (existing) clearTimeout(existing)
+ diagnosticEmitTimers.set(
+ filePath,
+ setTimeout(() => {
+ diagnosticEmitTimers.delete(filePath)
+ input.onDiagnostics?.({ path: filePath, diagnostics: mergedDiagnostics(filePath) })
+ }, DIAGNOSTICS_DEBOUNCE_MS),
+ )
+ }
const updatePushDiagnostics = (filePath: string, next: Diagnostic[]) => {
pushDiagnostics.set(filePath, next)
for (const listener of diagnosticListeners) listener({ path: filePath, serverID: input.serverID })
+ scheduleDiagnosticsEmit(filePath)
}
const updatePullDiagnostics = (filePath: string, next: Diagnostic[]) => {
pullDiagnostics.set(filePath, next)
+ scheduleDiagnosticsEmit(filePath)
}
const emitRegistrationChange = () => {
for (const listener of [...registrationListeners]) listener()
@@ -246,6 +262,16 @@ export async function create(input: {
publishDiagnostics: {
versionSupport: false,
},
+ completion: {
+ dynamicRegistration: true,
+ completionItem: {
+ snippetSupport: true,
+ insertReplaceSupport: true,
+ documentationFormat: ["markdown", "plaintext"],
+ labelDetailsSupport: true,
+ },
+ contextSupport: true,
+ },
},
},
}),
@@ -551,16 +577,21 @@ export async function create(input: {
return connection
},
notify: {
- async open(request: { path: string }) {
+ async open(request: { path: string; buffer?: { text: string; version: number } }) {
request.path = Filesystem.normalizePath(
path.isAbsolute(request.path) ? request.path : path.resolve(input.directory, request.path),
)
- const text = await Filesystem.readText(request.path)
+ // An editor buffer overlays disk (caller owns the version) while edit mode is active; on didClose the server reverts to disk-backed analysis. Without a buffer we keep the disk-read + auto-increment path.
+ const text = request.buffer ? request.buffer.text : await Filesystem.readText(request.path)
const extension = path.extname(request.path)
const languageId = LANGUAGE_EXTENSIONS[extension] ?? "plaintext"
const document = files[request.path]
if (document !== undefined) {
+ // Editor owns versions; ignore stale/duplicate (<= stored) to avoid didChange rejections from strict servers.
+ if (request.buffer && request.buffer.version <= document.version) {
+ return document.version
+ }
// Do not wipe diagnostics on didChange. Some servers (e.g. clangd) only
// re-emit diagnostics when the content actually changes, so clearing
// here would lose errors for no-op touchFile calls. Let the server's
@@ -574,7 +605,7 @@ export async function create(input: {
],
})
- const next = document.version + 1
+ const next = request.buffer ? request.buffer.version : document.version + 1
files[request.path] = { version: next, text }
await connection.sendNotification("textDocument/didChange", {
textDocument: {
@@ -608,16 +639,31 @@ export async function create(input: {
pushDiagnostics.delete(request.path)
pullDiagnostics.delete(request.path)
+ const initialVersion = request.buffer ? request.buffer.version : 0
await connection.sendNotification("textDocument/didOpen", {
textDocument: {
uri: pathToFileURL(request.path).href,
languageId,
- version: 0,
+ version: initialVersion,
text,
},
})
- files[request.path] = { version: 0, text }
- return 0
+ files[request.path] = { version: initialVersion, text }
+ return initialVersion
+ },
+ async close(request: { path: string }) {
+ request.path = Filesystem.normalizePath(
+ path.isAbsolute(request.path) ? request.path : path.resolve(input.directory, request.path),
+ )
+ if (files[request.path] === undefined) return
+ await connection.sendNotification("textDocument/didClose", {
+ textDocument: {
+ uri: pathToFileURL(request.path).href,
+ },
+ })
+ delete files[request.path]
+ pushDiagnostics.delete(request.path)
+ pullDiagnostics.delete(request.path)
},
},
get diagnostics() {
@@ -638,6 +684,8 @@ export async function create(input: {
await waitForFullDiagnostics({ path: normalizedPath, version: request.version, after: request.after })
},
async shutdown() {
+ for (const timer of diagnosticEmitTimers.values()) clearTimeout(timer)
+ diagnosticEmitTimers.clear()
connection.end()
connection.dispose()
await Process.stop(input.server.process)
diff --git a/packages/opencode/src/lsp/lsp.ts b/packages/opencode/src/lsp/lsp.ts
index 1ab3a0b82369..86fc2828eec4 100644
--- a/packages/opencode/src/lsp/lsp.ts
+++ b/packages/opencode/src/lsp/lsp.ts
@@ -1,5 +1,6 @@
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
import { FSUtil } from "@opencode-ai/core/fs-util"
+import { EventV2 } from "@opencode-ai/core/event"
import { EventV2Bridge } from "@/event-v2-bridge"
import * as LSPClient from "./client"
import path from "path"
@@ -10,13 +11,12 @@ import { Process } from "@/util/process"
import { spawn as lspspawn } from "./launch"
import { Effect, Layer, Context, Schema } from "effect"
import { InstanceState } from "@/effect/instance-state"
-import { containsPath } from "@/project/instance-context"
+import { containsPath, type InstanceContext } from "@/project/instance-context"
import { NonNegativeInt } from "@opencode-ai/core/schema"
import { RuntimeFlags } from "@/effect/runtime-flags"
import { LspEvent } from "@opencode-ai/schema/lsp-event"
export const Event = LspEvent
-
const Position = Schema.Struct({
line: NonNegativeInt,
character: NonNegativeInt,
@@ -28,6 +28,20 @@ export const Range = Schema.Struct({
}).annotate({ identifier: "Range" })
export type Range = typeof Range.Type
+export const DiagnosticOut = Schema.Struct({
+ range: Range,
+ severity: Schema.optional(Schema.Number),
+ message: Schema.String,
+ source: Schema.optional(Schema.String),
+ code: Schema.optional(Schema.Union([Schema.String, Schema.Number])),
+}).annotate({ identifier: "Diagnostic" })
+export type DiagnosticOut = typeof DiagnosticOut.Type
+
+export const Diagnostics = EventV2.define({
+ type: "lsp.diagnostics",
+ schema: { path: Schema.String, diagnostics: Schema.Array(DiagnosticOut) },
+})
+
export const Symbol = Schema.Struct({
name: Schema.String,
kind: NonNegativeInt,
@@ -121,6 +135,8 @@ export interface Interface {
readonly status: () => Effect.Effect
readonly hasClients: (file: string) => Effect.Effect
readonly touchFile: (input: string, diagnostics?: "document" | "full") => Effect.Effect
+ readonly syncBuffer: (input: { file: string; text: string; version: number }) => Effect.Effect
+ readonly closeBuffer: (file: string) => Effect.Effect
readonly diagnostics: () => Effect.Effect>
readonly hover: (input: LocInput) => Effect.Effect
readonly definition: (input: LocInput) => Effect.Effect
@@ -128,6 +144,10 @@ export interface Interface {
readonly implementation: (input: LocInput) => Effect.Effect
readonly documentSymbol: (uri: string) => Effect.Effect<(DocumentSymbol | Symbol)[]>
readonly workspaceSymbol: (query: string) => Effect.Effect
+ readonly completion: (
+ input: LocInput,
+ context?: { triggerKind: number; triggerCharacter?: string },
+ ) => Effect.Effect
readonly prepareCallHierarchy: (input: LocInput) => Effect.Effect
readonly incomingCalls: (input: LocInput) => Effect.Effect
readonly outgoingCalls: (input: LocInput) => Effect.Effect
@@ -205,9 +225,29 @@ const layer = Layer.effect(
}),
)
+ // Capture the instance-fiber runtime so the async callback publishes with the correct routed location, and convert the absolute path to workspace-relative for the event.
+ const publishDiagnostics =
+ (ctx: InstanceContext, context: Context.Context) =>
+ (input: { path: string; diagnostics: LSPClient.Diagnostic[] }) => {
+ const relative = path.relative(ctx.directory, input.path)
+ Effect.runForkWith(context)(
+ events.publish(Diagnostics, {
+ path: relative,
+ diagnostics: input.diagnostics.map((d) => ({
+ range: d.range,
+ severity: d.severity,
+ message: d.message,
+ source: d.source,
+ code: typeof d.code === "string" || typeof d.code === "number" ? d.code : undefined,
+ })),
+ }),
+ )
+ }
+
const getClients = Effect.fnUntraced(function* (file: string) {
const ctx = yield* InstanceState.context
if (!containsPath(file, ctx)) return [] as LSPClient.Info[]
+ const instanceContext = (yield* Effect.context()) as Context.Context
const s = yield* InstanceState.get(state)
const clients = yield* Effect.promise(async () => {
const extension = path.parse(file).ext || file
@@ -233,6 +273,7 @@ const layer = Layer.effect(
root,
directory: ctx.directory,
instance: ctx,
+ onDiagnostics: publishDiagnostics(ctx, instanceContext),
}).catch(async () => {
s.broken.add(key)
await Process.stop(handle.process)
@@ -361,6 +402,24 @@ const layer = Layer.effect(
)
})
+ const syncBuffer = Effect.fn("LSP.syncBuffer")(function* (input: { file: string; text: string; version: number }) {
+ const clients = yield* getClients(input.file)
+ yield* Effect.promise(() =>
+ Promise.all(
+ clients.map((client) =>
+ client.notify.open({ path: input.file, buffer: { text: input.text, version: input.version } }),
+ ),
+ ).catch(() => {}),
+ )
+ })
+
+ const closeBuffer = Effect.fn("LSP.closeBuffer")(function* (file: string) {
+ const clients = yield* getClients(file)
+ yield* Effect.promise(() =>
+ Promise.all(clients.map((client) => client.notify.close({ path: file }))).catch(() => {}),
+ )
+ })
+
const diagnostics = Effect.fn("LSP.diagnostics")(function* () {
const results: Record = {}
const all = yield* runAll(async (client) => client.diagnostics)
@@ -440,6 +499,21 @@ const layer = Layer.effect(
return results.flat()
})
+ const completion = Effect.fn("LSP.completion")(function* (
+ input: LocInput,
+ context?: { triggerKind: number; triggerCharacter?: string },
+ ) {
+ return yield* run(input.file, (client) =>
+ client.connection
+ .sendRequest("textDocument/completion", {
+ textDocument: { uri: pathToFileURL(input.file).href },
+ position: { line: input.line, character: input.character },
+ context: context ?? { triggerKind: 1 /* Invoked */ },
+ })
+ .catch(() => null),
+ )
+ })
+
const prepareCallHierarchy = Effect.fn("LSP.prepareCallHierarchy")(function* (input: LocInput) {
const results = yield* run(input.file, (client) =>
client.connection
@@ -482,6 +556,8 @@ const layer = Layer.effect(
status,
hasClients,
touchFile,
+ syncBuffer,
+ closeBuffer,
diagnostics,
hover,
definition,
@@ -489,6 +565,7 @@ const layer = Layer.effect(
implementation,
documentSymbol,
workspaceSymbol,
+ completion,
prepareCallHierarchy,
incomingCalls,
outgoingCalls,
diff --git a/packages/opencode/src/server/routes/instance/httpapi/api.ts b/packages/opencode/src/server/routes/instance/httpapi/api.ts
index f5076ad08082..91945b049fb0 100644
--- a/packages/opencode/src/server/routes/instance/httpapi/api.ts
+++ b/packages/opencode/src/server/routes/instance/httpapi/api.ts
@@ -14,6 +14,7 @@ import { EventApi } from "./groups/event"
import { ExperimentalApi } from "./groups/experimental"
import { FileApi } from "./groups/file"
import { InstanceApi } from "./groups/instance"
+import { LspFeatureApi } from "./groups/lsp"
import { McpApi } from "./groups/mcp"
import { PermissionApi } from "./groups/permission"
import { ProjectApi } from "./groups/project"
@@ -63,6 +64,7 @@ export const InstanceHttpApi = HttpApi.make("opencode-instance")
.addHttpApi(ExperimentalApi)
.addHttpApi(FileApi)
.addHttpApi(InstanceApi)
+ .addHttpApi(LspFeatureApi)
.addHttpApi(McpApi)
.addHttpApi(ProjectApi)
.addHttpApi(ProjectCopyApi)
diff --git a/packages/opencode/src/server/routes/instance/httpapi/groups/file.ts b/packages/opencode/src/server/routes/instance/httpapi/groups/file.ts
index 389bf9192510..a9cbed5303e4 100644
--- a/packages/opencode/src/server/routes/instance/httpapi/groups/file.ts
+++ b/packages/opencode/src/server/routes/instance/httpapi/groups/file.ts
@@ -99,8 +99,21 @@ export const FilePaths = {
list: "/file",
content: "/file/content",
status: "/file/status",
+ write: "/file/write",
} as const
+export const FileWritePayload = Schema.Struct({
+ content: Schema.String,
+ expectedSha: Schema.optional(Schema.String),
+})
+
+export const FileWriteResult = Schema.Struct({
+ path: Schema.String,
+ written: Schema.Boolean,
+ conflict: Schema.optional(Schema.Boolean),
+ sha: Schema.optional(Schema.String),
+}).annotate({ identifier: "FileWriteResult" })
+
export const FileApi = HttpApi.make("file")
.add(
HttpApiGroup.make("file")
@@ -165,6 +178,18 @@ export const FileApi = HttpApi.make("file")
description: "Get the git status of all files in the project.",
}),
),
+ HttpApiEndpoint.put("write", FilePaths.write, {
+ query: FileQuery,
+ payload: FileWritePayload,
+ success: described(FileWriteResult, "Write result"),
+ }).annotateMerge(
+ OpenApi.annotations({
+ identifier: "file.write",
+ summary: "Write file",
+ description:
+ "Write content to a file in the project, with optional conflict detection.",
+ }),
+ ),
)
.annotateMerge(
OpenApi.annotations({
diff --git a/packages/opencode/src/server/routes/instance/httpapi/groups/lsp.ts b/packages/opencode/src/server/routes/instance/httpapi/groups/lsp.ts
new file mode 100644
index 000000000000..b1d6217e60dd
--- /dev/null
+++ b/packages/opencode/src/server/routes/instance/httpapi/groups/lsp.ts
@@ -0,0 +1,146 @@
+import { NonNegativeInt } from "@opencode-ai/core/schema"
+import { LSP } from "@/lsp/lsp"
+import { Schema } from "effect"
+import { HttpApi, HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
+import { Authorization } from "../middleware/authorization"
+import { InstanceContextMiddleware } from "../middleware/instance-context"
+import { WorkspaceRoutingMiddleware, WorkspaceRoutingQuery } from "../middleware/workspace-routing"
+import { FileQuery } from "./file"
+import { described } from "./metadata"
+
+export const LspFeaturePaths = {
+ hover: "/lsp/hover",
+ definition: "/lsp/definition",
+ references: "/lsp/references",
+ completion: "/lsp/completion",
+ diagnostics: "/lsp/diagnostics",
+ buffer: "/lsp/buffer",
+ bufferClose: "/lsp/buffer/close",
+} as const
+
+export const LocPayload = Schema.Struct({
+ path: Schema.String,
+ line: NonNegativeInt,
+ character: NonNegativeInt,
+ triggerKind: Schema.optional(Schema.Number),
+ triggerCharacter: Schema.optional(Schema.String),
+})
+export type LocPayload = typeof LocPayload.Type
+
+// `version` is editor-owned and must increase monotonically; stale versions are ignored by the LSP client.
+export const BufferPayload = Schema.Struct({
+ path: Schema.String,
+ text: Schema.String,
+ version: NonNegativeInt,
+})
+export type BufferPayload = typeof BufferPayload.Type
+
+export const BufferClosePayload = Schema.Struct({
+ path: Schema.String,
+})
+export type BufferClosePayload = typeof BufferClosePayload.Type
+
+const LspResult = Schema.Unknown
+
+export const DiagnosticOut = LSP.DiagnosticOut
+export type DiagnosticOut = typeof LSP.DiagnosticOut.Type
+
+export const LspFeatureApi = HttpApi.make("lsp-features")
+ .add(
+ HttpApiGroup.make("lsp-features")
+ .add(
+ HttpApiEndpoint.post("hover", LspFeaturePaths.hover, {
+ query: WorkspaceRoutingQuery,
+ payload: LocPayload,
+ success: described(LspResult, "Hover result"),
+ }).annotateMerge(
+ OpenApi.annotations({
+ identifier: "lsp.hover",
+ summary: "LSP hover",
+ description: "Get hover information for a position in a file via LSP.",
+ }),
+ ),
+ HttpApiEndpoint.post("definition", LspFeaturePaths.definition, {
+ query: WorkspaceRoutingQuery,
+ payload: LocPayload,
+ success: described(LspResult, "Definition locations"),
+ }).annotateMerge(
+ OpenApi.annotations({
+ identifier: "lsp.definition",
+ summary: "LSP definition",
+ description: "Find the definition of the symbol at a position in a file via LSP.",
+ }),
+ ),
+ HttpApiEndpoint.post("references", LspFeaturePaths.references, {
+ query: WorkspaceRoutingQuery,
+ payload: LocPayload,
+ success: described(LspResult, "Reference locations"),
+ }).annotateMerge(
+ OpenApi.annotations({
+ identifier: "lsp.references",
+ summary: "LSP references",
+ description: "Find references to the symbol at a position in a file via LSP.",
+ }),
+ ),
+ HttpApiEndpoint.post("completion", LspFeaturePaths.completion, {
+ query: WorkspaceRoutingQuery,
+ payload: LocPayload,
+ success: described(LspResult, "Completion result"),
+ }).annotateMerge(
+ OpenApi.annotations({
+ identifier: "lsp.completion",
+ summary: "LSP completion",
+ description: "Get completion suggestions for a position in a file via LSP.",
+ }),
+ ),
+ HttpApiEndpoint.get("diagnostics", LspFeaturePaths.diagnostics, {
+ query: FileQuery,
+ success: described(Schema.Array(DiagnosticOut), "Diagnostics for the file"),
+ }).annotateMerge(
+ OpenApi.annotations({
+ identifier: "lsp.diagnostics",
+ summary: "LSP diagnostics",
+ description: "Get diagnostics for a single file via LSP.",
+ }),
+ ),
+ HttpApiEndpoint.put("buffer", LspFeaturePaths.buffer, {
+ query: WorkspaceRoutingQuery,
+ payload: BufferPayload,
+ success: described(Schema.Boolean, "Buffer synced"),
+ }).annotateMerge(
+ OpenApi.annotations({
+ identifier: "lsp.buffer",
+ summary: "LSP buffer sync",
+ description:
+ "Push the editor's in-memory buffer (text + monotonic version) so LSP features reflect unsaved edits.",
+ }),
+ ),
+ HttpApiEndpoint.post("bufferClose", LspFeaturePaths.bufferClose, {
+ query: WorkspaceRoutingQuery,
+ payload: BufferClosePayload,
+ success: described(Schema.Boolean, "Buffer closed"),
+ }).annotateMerge(
+ OpenApi.annotations({
+ identifier: "lsp.bufferClose",
+ summary: "LSP buffer close",
+ description: "Close the editor buffer so the LSP server reverts to disk-backed analysis.",
+ }),
+ ),
+ )
+ .annotateMerge(
+ OpenApi.annotations({
+ title: "lsp-features",
+ description: "Experimental HttpApi LSP feature routes.",
+ }),
+ )
+ .middleware(InstanceContextMiddleware)
+ .middleware(WorkspaceRoutingMiddleware)
+ .middleware(Authorization),
+ )
+ .annotateMerge(
+ OpenApi.annotations({
+ title: "opencode experimental HttpApi",
+ version: "0.0.1",
+ description: "Experimental HttpApi surface for selected instance routes.",
+ }),
+ )
diff --git a/packages/opencode/src/server/routes/instance/httpapi/handlers/file.ts b/packages/opencode/src/server/routes/instance/httpapi/handlers/file.ts
index 6a826022a0a0..a6937c866229 100644
--- a/packages/opencode/src/server/routes/instance/httpapi/handlers/file.ts
+++ b/packages/opencode/src/server/routes/instance/httpapi/handlers/file.ts
@@ -3,9 +3,11 @@ import { FileSystem } from "@opencode-ai/core/filesystem"
import { LocationServiceMap, locationServiceMapLayer } from "@opencode-ai/core/location-services"
import { Ripgrep } from "@opencode-ai/core/ripgrep"
import { FSUtil } from "@opencode-ai/core/fs-util"
+import { FileMutation } from "@opencode-ai/core/file-mutation"
import { Location } from "@opencode-ai/core/location"
import { AbsolutePath, RelativePath } from "@opencode-ai/core/schema"
import { Effect, Layer, Option } from "effect"
+import { createHash } from "node:crypto"
import ignore from "ignore"
import path from "path"
import { HttpApiBuilder } from "effect/unstable/httpapi"
@@ -128,6 +130,58 @@ export const fileHandlers = HttpApiBuilder.group(InstanceHttpApi, "file", (handl
return []
})
+ const write = Effect.fn("FileHttpApi.write")(function* (ctx: {
+ query: { path: string }
+ payload: { content: string; expectedSha?: string }
+ }) {
+ const directory = (yield* InstanceState.context).directory
+ const file = path.resolve(directory, ctx.query.path)
+ if (!FSUtil.contains(directory, file)) return yield* Effect.die(new Error("Path escapes the location"))
+
+ const fileExists = yield* FSUtil.Service.use((fs) => fs.existsSafe(file))
+
+ if (fileExists) {
+ const item = yield* filesystem(
+ FileSystem.Service.use((fs) => fs.read({ path: RelativePath.make(ctx.query.path) })),
+ )
+ const text = item.content.includes(0)
+ ? Option.none()
+ : yield* Effect.sync(() => new TextDecoder("utf-8", { fatal: true }).decode(item.content)).pipe(Effect.option)
+ if (Option.isNone(text)) return yield* Effect.die(new Error("Cannot write to binary file"))
+ }
+
+ const target = {
+ canonical: file,
+ resource: path.relative(directory, file),
+ }
+
+ if (ctx.payload.expectedSha !== undefined) {
+ // A sha mismatch (or missing file) is returned as a normal result, not an Effect failure, so the editor can surface a conflict.
+ if (!fileExists) {
+ return { path: ctx.query.path, written: false as const, conflict: true as const }
+ }
+ const currentBytes = yield* FSUtil.Service.use((fs) => fs.readFile(file)).pipe(Effect.orDie)
+ const currentSha = createHash("sha256").update(currentBytes).digest("hex")
+ if (currentSha !== ctx.payload.expectedSha) {
+ return { path: ctx.query.path, written: false as const, conflict: true as const }
+ }
+ yield* filesystem(
+ FileMutation.Service.use((mut) =>
+ mut.writeIfUnchanged({ target, content: ctx.payload.content, expected: currentBytes }),
+ ),
+ ).pipe(Effect.orDie)
+ } else {
+ yield* filesystem(
+ FileMutation.Service.use((mut) => mut.writeTextPreservingBom({ target, content: ctx.payload.content })),
+ ).pipe(Effect.orDie)
+ }
+
+ const newBytes = yield* FSUtil.Service.use((fs) => fs.readFile(file)).pipe(Effect.orDie)
+ const newSha = createHash("sha256").update(newBytes).digest("hex")
+
+ return { path: ctx.query.path, written: true as const, sha: newSha }
+ })
+
return handlers
.handle("findText", findText)
.handle("findFile", findFile)
@@ -135,5 +189,6 @@ export const fileHandlers = HttpApiBuilder.group(InstanceHttpApi, "file", (handl
.handle("list", list)
.handle("content", content)
.handle("status", status)
+ .handle("write", write)
}),
).pipe(Layer.provide(locationServiceMapLayer))
diff --git a/packages/opencode/src/server/routes/instance/httpapi/handlers/lsp.ts b/packages/opencode/src/server/routes/instance/httpapi/handlers/lsp.ts
new file mode 100644
index 000000000000..7c7e6c7db19a
--- /dev/null
+++ b/packages/opencode/src/server/routes/instance/httpapi/handlers/lsp.ts
@@ -0,0 +1,112 @@
+import * as InstanceState from "@/effect/instance-state"
+import { FSUtil } from "@opencode-ai/core/fs-util"
+import { LSP } from "@/lsp/lsp"
+import { Effect } from "effect"
+import { fileURLToPath } from "node:url"
+import path from "path"
+import { HttpApiBuilder } from "effect/unstable/httpapi"
+import { InstanceHttpApi } from "../api"
+import type { BufferClosePayload, BufferPayload, DiagnosticOut, LocPayload } from "../groups/lsp"
+
+// Rewrite absolute `file://` location URIs to workspace-relative paths; targets outside the workspace keep their absolute URI.
+function rewriteLocations(directory: string, results: unknown[]): unknown[] {
+ return results.map((entry) => {
+ if (!entry || typeof entry !== "object") return entry
+ const loc = entry as Record
+ const uri = loc["uri"] ?? loc["targetUri"]
+ if (typeof uri !== "string" || !uri.startsWith("file://")) return entry
+ let absolute: string
+ try {
+ absolute = fileURLToPath(uri)
+ } catch {
+ return entry
+ }
+ if (!FSUtil.contains(directory, absolute)) return entry
+ const relative = path.relative(directory, absolute)
+ const next: Record = { ...loc }
+ if ("uri" in loc) next["uri"] = relative
+ if ("targetUri" in loc) next["targetUri"] = relative
+ return next
+ })
+}
+
+export const lspFeatureHandlers = HttpApiBuilder.group(InstanceHttpApi, "lsp-features", (handlers) =>
+ Effect.gen(function* () {
+ const lsp = yield* LSP.Service
+
+ const resolve = Effect.fnUntraced(function* (relative: string) {
+ const directory = (yield* InstanceState.context).directory
+ const file = path.resolve(directory, relative)
+ if (!FSUtil.contains(directory, file)) return yield* Effect.die(new Error("Path escapes the location"))
+ return { directory, file }
+ })
+
+ const hover = Effect.fn("LspHttpApi.hover")(function* (ctx: { payload: LocPayload }) {
+ const { file } = yield* resolve(ctx.payload.path)
+ return yield* lsp.hover({ file, line: ctx.payload.line, character: ctx.payload.character }).pipe(Effect.orDie)
+ })
+
+ const definition = Effect.fn("LspHttpApi.definition")(function* (ctx: { payload: LocPayload }) {
+ const { directory, file } = yield* resolve(ctx.payload.path)
+ const results = yield* lsp
+ .definition({ file, line: ctx.payload.line, character: ctx.payload.character })
+ .pipe(Effect.orDie)
+ return rewriteLocations(directory, results)
+ })
+
+ const references = Effect.fn("LspHttpApi.references")(function* (ctx: { payload: LocPayload }) {
+ const { directory, file } = yield* resolve(ctx.payload.path)
+ const results = yield* lsp
+ .references({ file, line: ctx.payload.line, character: ctx.payload.character })
+ .pipe(Effect.orDie)
+ return rewriteLocations(directory, results)
+ })
+
+ const completion = Effect.fn("LspHttpApi.completion")(function* (ctx: { payload: LocPayload }) {
+ const { file } = yield* resolve(ctx.payload.path)
+ const context =
+ ctx.payload.triggerKind != null
+ ? { triggerKind: ctx.payload.triggerKind, triggerCharacter: ctx.payload.triggerCharacter }
+ : undefined
+ return yield* lsp
+ .completion({ file, line: ctx.payload.line, character: ctx.payload.character }, context)
+ .pipe(Effect.orDie)
+ })
+
+ const diagnostics = Effect.fn("LspHttpApi.diagnostics")(function* (ctx: { query: { path: string } }) {
+ const { file } = yield* resolve(ctx.query.path)
+ const all = yield* lsp.diagnostics().pipe(Effect.orDie)
+ const entries = all[file] ?? []
+ return entries.map(
+ (d): DiagnosticOut => ({
+ range: d.range,
+ severity: d.severity,
+ message: d.message,
+ source: d.source,
+ code: typeof d.code === "string" || typeof d.code === "number" ? d.code : undefined,
+ }),
+ )
+ })
+
+ const buffer = Effect.fn("LspHttpApi.buffer")(function* (ctx: { payload: BufferPayload }) {
+ const { file } = yield* resolve(ctx.payload.path)
+ yield* lsp.syncBuffer({ file, text: ctx.payload.text, version: ctx.payload.version }).pipe(Effect.orDie)
+ return true
+ })
+
+ const bufferClose = Effect.fn("LspHttpApi.bufferClose")(function* (ctx: { payload: BufferClosePayload }) {
+ const { file } = yield* resolve(ctx.payload.path)
+ yield* lsp.closeBuffer(file).pipe(Effect.orDie)
+ return true
+ })
+
+ return handlers
+ .handle("hover", hover)
+ .handle("definition", definition)
+ .handle("references", references)
+ .handle("completion", completion)
+ .handle("diagnostics", diagnostics)
+ .handle("buffer", buffer)
+ .handle("bufferClose", bufferClose)
+ }),
+)
diff --git a/packages/opencode/src/server/routes/instance/httpapi/server.ts b/packages/opencode/src/server/routes/instance/httpapi/server.ts
index 127a159304cb..1fcca46e26ec 100644
--- a/packages/opencode/src/server/routes/instance/httpapi/server.ts
+++ b/packages/opencode/src/server/routes/instance/httpapi/server.ts
@@ -90,6 +90,7 @@ import { experimentalHandlers } from "./handlers/experimental"
import { fileHandlers } from "./handlers/file"
import { globalHandlers } from "./handlers/global"
import { instanceHandlers } from "./handlers/instance"
+import { lspFeatureHandlers } from "./handlers/lsp"
import { mcpHandlers } from "./handlers/mcp"
import { permissionHandlers } from "./handlers/permission"
import { projectHandlers } from "./handlers/project"
@@ -158,6 +159,7 @@ const instanceApiRoutes = HttpApiBuilder.layer(InstanceHttpApi).pipe(
experimentalHandlers,
fileHandlers,
instanceHandlers,
+ lspFeatureHandlers,
mcpHandlers,
projectHandlers,
projectCopyHandlers,
diff --git a/packages/opencode/test/fixture/lsp/fake-lsp-server.js b/packages/opencode/test/fixture/lsp/fake-lsp-server.js
index e6818009e1f6..54654d9bd026 100644
--- a/packages/opencode/test/fixture/lsp/fake-lsp-server.js
+++ b/packages/opencode/test/fixture/lsp/fake-lsp-server.js
@@ -7,6 +7,12 @@ let initializeParams = null
let diagnosticRequestCount = 0
let registeredCapability = false
const pendingClientRequests = new Map()
+let completionResponse = null
+let lastCompletionParams = null
+// Records textDocument/didOpen, didChange, didClose notifications in order so
+// buffer-sync tests can assert exactly what the client sent.
+const documentNotifications = []
+
let pullConfig = {
delayMs: 0,
registerOn: undefined,
@@ -113,13 +119,12 @@ function handle(raw) {
if (data.method === "initialize") {
initializeParams = data.params
- sendResponse(data.id, {
- capabilities: {
- textDocumentSync: {
- change: 2,
- },
+ const capabilities = {
+ textDocumentSync: {
+ change: 2,
},
- })
+ }
+ sendResponse(data.id, { capabilities })
return
}
@@ -139,16 +144,28 @@ function handle(raw) {
}
if (data.method === "textDocument/didOpen") {
+ documentNotifications.push({ method: "didOpen", params: data.params })
maybeRegister("didOpen")
return
}
if (data.method === "textDocument/didChange") {
lastChange = data.params
+ documentNotifications.push({ method: "didChange", params: data.params })
maybeRegister("didChange")
return
}
+ if (data.method === "textDocument/didClose") {
+ documentNotifications.push({ method: "didClose", params: data.params })
+ return
+ }
+
+ if (data.method === "test/get-document-notifications") {
+ sendResponse(data.id, documentNotifications)
+ return
+ }
+
if (data.method === "test/trigger") {
const method = data.params && data.params.method
if (method === "client/registerCapability") {
@@ -236,6 +253,23 @@ function handle(raw) {
return
}
+ if (data.method === "test/set-completion-response") {
+ completionResponse = data.params?.response ?? null
+ sendResponse(data.id, null)
+ return
+ }
+
+ if (data.method === "test/get-last-completion-params") {
+ sendResponse(data.id, lastCompletionParams)
+ return
+ }
+
+ if (data.method === "textDocument/completion") {
+ lastCompletionParams = data.params
+ sendResponse(data.id, completionResponse)
+ return
+ }
+
if (typeof data.id !== "undefined") {
sendResponse(data.id, null)
}
diff --git a/packages/opencode/test/lsp/buffer-sync.test.ts b/packages/opencode/test/lsp/buffer-sync.test.ts
new file mode 100644
index 000000000000..febdf647a0ec
--- /dev/null
+++ b/packages/opencode/test/lsp/buffer-sync.test.ts
@@ -0,0 +1,196 @@
+import { describe, expect, test } from "bun:test"
+import path from "path"
+import { pathToFileURL } from "url"
+import { tmpdir, withTestInstance } from "../fixture/fixture"
+import { LSPClient } from "@/lsp/client"
+import * as LSPServer from "@/lsp/server"
+
+function spawnFakeServer() {
+ const { spawn } = require("child_process")
+ const serverPath = path.join(__dirname, "../fixture/lsp/fake-lsp-server.js")
+ return {
+ process: spawn(process.execPath, [serverPath], {
+ stdio: "pipe",
+ }),
+ }
+}
+
+async function getNotifications(client: any) {
+ return (await client.connection.sendRequest("test/get-document-notifications", {})) as Array<{
+ method: string
+ params: any
+ }>
+}
+
+describe("LSP buffer sync", () => {
+ test("first syncBuffer sends didOpen with buffer text + version", async () => {
+ const handle = spawnFakeServer() as any
+ await using tmp = await tmpdir()
+ const file = path.join(tmp.path, "test.ts")
+ await Bun.write(file, "const x = 1\n")
+
+ await withTestInstance({
+ directory: tmp.path,
+ fn: async (ctx) => {
+ const client = await LSPClient.create({
+ serverID: "fake",
+ server: handle as unknown as LSPServer.Handle,
+ root: tmp.path,
+ directory: tmp.path,
+ instance: ctx,
+ })
+
+ await client.notify.open({ path: file, buffer: { text: "const y = 2\n", version: 3 } })
+
+ const notifications = await getNotifications(client)
+ const opens = notifications.filter((n) => n.method === "didOpen")
+ expect(opens).toHaveLength(1)
+ expect(opens[0].params.textDocument.uri).toBe(pathToFileURL(file).href)
+ expect(opens[0].params.textDocument.version).toBe(3)
+ expect(opens[0].params.textDocument.text).toBe("const y = 2\n")
+
+ await client.shutdown()
+ },
+ })
+ })
+
+ test("second syncBuffer sends incremental didChange with the new version", async () => {
+ const handle = spawnFakeServer() as any
+ await using tmp = await tmpdir()
+ const file = path.join(tmp.path, "test.ts")
+ await Bun.write(file, "const x = 1\n")
+
+ await withTestInstance({
+ directory: tmp.path,
+ fn: async (ctx) => {
+ const client = await LSPClient.create({
+ serverID: "fake",
+ server: handle as unknown as LSPServer.Handle,
+ root: tmp.path,
+ directory: tmp.path,
+ instance: ctx,
+ })
+
+ await client.notify.open({ path: file, buffer: { text: "const y = 2\n", version: 1 } })
+ await client.notify.open({ path: file, buffer: { text: "const y = 22\n", version: 2 } })
+
+ const notifications = await getNotifications(client)
+ const changes = notifications.filter((n) => n.method === "didChange")
+ expect(changes).toHaveLength(1)
+ expect(changes[0].params.textDocument.version).toBe(2)
+ // Incremental change: contentChanges carries a range + the new text.
+ expect(changes[0].params.contentChanges[0].range).toBeDefined()
+ expect(changes[0].params.contentChanges[0].text).toBe("const y = 22\n")
+
+ await client.shutdown()
+ },
+ })
+ })
+
+ test("stale or duplicate version is ignored (no notification sent)", async () => {
+ const handle = spawnFakeServer() as any
+ await using tmp = await tmpdir()
+ const file = path.join(tmp.path, "test.ts")
+ await Bun.write(file, "const x = 1\n")
+
+ await withTestInstance({
+ directory: tmp.path,
+ fn: async (ctx) => {
+ const client = await LSPClient.create({
+ serverID: "fake",
+ server: handle as unknown as LSPServer.Handle,
+ root: tmp.path,
+ directory: tmp.path,
+ instance: ctx,
+ })
+
+ await client.notify.open({ path: file, buffer: { text: "v5\n", version: 5 } })
+ // Stale (lower) and duplicate (equal) versions must be ignored.
+ const stale = await client.notify.open({ path: file, buffer: { text: "v3\n", version: 3 } })
+ const dup = await client.notify.open({ path: file, buffer: { text: "v5again\n", version: 5 } })
+
+ expect(stale).toBe(5)
+ expect(dup).toBe(5)
+
+ const notifications = await getNotifications(client)
+ expect(notifications.filter((n) => n.method === "didOpen")).toHaveLength(1)
+ expect(notifications.filter((n) => n.method === "didChange")).toHaveLength(0)
+
+ await client.shutdown()
+ },
+ })
+ })
+
+ test("closeBuffer sends didClose and clears state", async () => {
+ const handle = spawnFakeServer() as any
+ await using tmp = await tmpdir()
+ const file = path.join(tmp.path, "test.ts")
+ await Bun.write(file, "const x = 1\n")
+
+ await withTestInstance({
+ directory: tmp.path,
+ fn: async (ctx) => {
+ const client = await LSPClient.create({
+ serverID: "fake",
+ server: handle as unknown as LSPServer.Handle,
+ root: tmp.path,
+ directory: tmp.path,
+ instance: ctx,
+ })
+
+ await client.notify.open({ path: file, buffer: { text: "const y = 2\n", version: 1 } })
+ await client.notify.close({ path: file })
+
+ const notifications = await getNotifications(client)
+ const closes = notifications.filter((n) => n.method === "didClose")
+ expect(closes).toHaveLength(1)
+ expect(closes[0].params.textDocument.uri).toBe(pathToFileURL(file).href)
+
+ // State cleared: reopening with a low version sends didOpen again.
+ await client.notify.open({ path: file, buffer: { text: "again\n", version: 0 } })
+ const after = await getNotifications(client)
+ expect(after.filter((n) => n.method === "didOpen")).toHaveLength(2)
+
+ await client.shutdown()
+ },
+ })
+ })
+
+ test("disk-based notify.open (no buffer) still reads disk + auto-increments", async () => {
+ const handle = spawnFakeServer() as any
+ await using tmp = await tmpdir()
+ const file = path.join(tmp.path, "test.ts")
+ await Bun.write(file, "from-disk-1\n")
+
+ await withTestInstance({
+ directory: tmp.path,
+ fn: async (ctx) => {
+ const client = await LSPClient.create({
+ serverID: "fake",
+ server: handle as unknown as LSPServer.Handle,
+ root: tmp.path,
+ directory: tmp.path,
+ instance: ctx,
+ })
+
+ const v0 = await client.notify.open({ path: file })
+ await Bun.write(file, "from-disk-2\n")
+ const v1 = await client.notify.open({ path: file })
+
+ expect(v0).toBe(0)
+ expect(v1).toBe(1)
+
+ const notifications = await getNotifications(client)
+ const opens = notifications.filter((n) => n.method === "didOpen")
+ const changes = notifications.filter((n) => n.method === "didChange")
+ expect(opens).toHaveLength(1)
+ expect(opens[0].params.textDocument.text).toBe("from-disk-1\n")
+ expect(changes).toHaveLength(1)
+ expect(changes[0].params.textDocument.version).toBe(1)
+ expect(changes[0].params.contentChanges[0].text).toBe("from-disk-2\n")
+
+ await client.shutdown()
+ },
+ })
+ })
+})
diff --git a/packages/opencode/test/lsp/completion.test.ts b/packages/opencode/test/lsp/completion.test.ts
new file mode 100644
index 000000000000..3a673905498c
--- /dev/null
+++ b/packages/opencode/test/lsp/completion.test.ts
@@ -0,0 +1,208 @@
+import { describe, expect, test } from "bun:test"
+import path from "path"
+import { pathToFileURL } from "url"
+import { tmpdir, withTestInstance } from "../fixture/fixture"
+import { LSPClient } from "@/lsp/client"
+import * as LSPServer from "@/lsp/server"
+
+function spawnFakeServer() {
+ const { spawn } = require("child_process")
+ const serverPath = path.join(__dirname, "../fixture/lsp/fake-lsp-server.js")
+ return {
+ process: spawn(process.execPath, [serverPath], {
+ stdio: "pipe",
+ }),
+ }
+}
+
+describe("LSP completion", () => {
+ test("initialize payload includes the completion capability block", async () => {
+ const handle = spawnFakeServer() as any
+
+ const client = await withTestInstance({
+ directory: process.cwd(),
+ fn: (ctx) =>
+ LSPClient.create({
+ serverID: "fake",
+ server: handle as unknown as LSPServer.Handle,
+ root: process.cwd(),
+ directory: process.cwd(),
+ instance: ctx,
+ }),
+ })
+
+ const params = await client.connection.sendRequest("test/get-initialize-params", {})
+
+ expect(params.capabilities.textDocument.completion).toBeDefined()
+ expect(params.capabilities.textDocument.completion.dynamicRegistration).toBe(true)
+ expect(params.capabilities.textDocument.completion.contextSupport).toBe(true)
+ expect(params.capabilities.textDocument.completion.completionItem.snippetSupport).toBe(true)
+ expect(params.capabilities.textDocument.completion.completionItem.insertReplaceSupport).toBe(true)
+ expect(params.capabilities.textDocument.completion.completionItem.documentationFormat).toEqual([
+ "markdown",
+ "plaintext",
+ ])
+ expect(params.capabilities.textDocument.completion.completionItem.labelDetailsSupport).toBe(true)
+
+ await client.shutdown()
+ })
+
+ test("sendRequest textDocument/completion with correct uri, position, and context", async () => {
+ const handle = spawnFakeServer() as any
+ await using tmp = await tmpdir()
+ const file = path.join(tmp.path, "test.ts")
+ await Bun.write(file, "const x = 1\n")
+
+ await withTestInstance({
+ directory: tmp.path,
+ fn: async (ctx) => {
+ const client = await LSPClient.create({
+ serverID: "fake",
+ server: handle as unknown as LSPServer.Handle,
+ root: tmp.path,
+ directory: tmp.path,
+ instance: ctx,
+ })
+
+ await client.notify.open({ path: file })
+ await client.connection.sendRequest("textDocument/completion", {
+ textDocument: { uri: pathToFileURL(file).href },
+ position: { line: 0, character: 6 },
+ context: { triggerKind: 1 },
+ })
+
+ const params = await client.connection.sendRequest("test/get-last-completion-params", {})
+ expect(params.textDocument.uri).toBe(pathToFileURL(file).href)
+ expect(params.position.line).toBe(0)
+ expect(params.position.character).toBe(6)
+ expect(params.context.triggerKind).toBe(1)
+
+ await client.shutdown()
+ },
+ })
+ })
+
+ test("returns items for a CompletionItem[] response", async () => {
+ const handle = spawnFakeServer() as any
+ await using tmp = await tmpdir()
+ const file = path.join(tmp.path, "test.ts")
+ await Bun.write(file, "const x = 1\n")
+
+ await withTestInstance({
+ directory: tmp.path,
+ fn: async (ctx) => {
+ const client = await LSPClient.create({
+ serverID: "fake",
+ server: handle as unknown as LSPServer.Handle,
+ root: tmp.path,
+ directory: tmp.path,
+ instance: ctx,
+ })
+
+ const completionItems = [
+ { label: "console", kind: 6 },
+ { label: "const", kind: 14 },
+ ]
+ await client.connection.sendRequest("test/set-completion-response", { response: completionItems })
+ await client.notify.open({ path: file })
+
+ const result = await client.connection.sendRequest("textDocument/completion", {
+ textDocument: { uri: pathToFileURL(file).href },
+ position: { line: 0, character: 6 },
+ context: { triggerKind: 1 },
+ })
+
+ expect(Array.isArray(result)).toBe(true)
+ expect(result).toHaveLength(2)
+ expect(result[0].label).toBe("console")
+ expect(result[1].label).toBe("const")
+
+ await client.shutdown()
+ },
+ })
+ })
+
+ test("returns items for a CompletionList response", async () => {
+ const handle = spawnFakeServer() as any
+ await using tmp = await tmpdir()
+ const file = path.join(tmp.path, "test.ts")
+ await Bun.write(file, "const x = 1\n")
+
+ await withTestInstance({
+ directory: tmp.path,
+ fn: async (ctx) => {
+ const client = await LSPClient.create({
+ serverID: "fake",
+ server: handle as unknown as LSPServer.Handle,
+ root: tmp.path,
+ directory: tmp.path,
+ instance: ctx,
+ })
+
+ const completionList = {
+ isIncomplete: false,
+ items: [
+ { label: "Array", kind: 7 },
+ { label: "Boolean", kind: 7 },
+ ],
+ }
+ await client.connection.sendRequest("test/set-completion-response", { response: completionList })
+ await client.notify.open({ path: file })
+
+ const result = await client.connection.sendRequest("textDocument/completion", {
+ textDocument: { uri: pathToFileURL(file).href },
+ position: { line: 0, character: 6 },
+ context: { triggerKind: 1 },
+ })
+
+ expect(result).toBeDefined()
+ expect(result.isIncomplete).toBe(false)
+ expect(Array.isArray(result.items)).toBe(true)
+ expect(result.items).toHaveLength(2)
+ expect(result.items[0].label).toBe("Array")
+
+ await client.shutdown()
+ },
+ })
+ })
+
+ test("swallows a rejected request and returns null", async () => {
+ const handle = spawnFakeServer() as any
+ await using tmp = await tmpdir()
+ const file = path.join(tmp.path, "test.ts")
+ await Bun.write(file, "const x = 1\n")
+
+ await withTestInstance({
+ directory: tmp.path,
+ fn: async (ctx) => {
+ const client = await LSPClient.create({
+ serverID: "fake",
+ server: handle as unknown as LSPServer.Handle,
+ root: tmp.path,
+ directory: tmp.path,
+ instance: ctx,
+ })
+
+ // completionResponse is null by default (server returns null for unknown requests)
+ await client.notify.open({ path: file })
+
+ let result: any
+ let threw = false
+ try {
+ result = await client.connection.sendRequest("textDocument/completion", {
+ textDocument: { uri: pathToFileURL(file).href },
+ position: { line: 0, character: 6 },
+ context: { triggerKind: 1 },
+ })
+ } catch {
+ threw = true
+ }
+
+ expect(threw).toBe(false)
+ expect(result).toBeNull()
+
+ await client.shutdown()
+ },
+ })
+ })
+})
diff --git a/packages/opencode/test/lsp/diagnostics-event.test.ts b/packages/opencode/test/lsp/diagnostics-event.test.ts
new file mode 100644
index 000000000000..0882309c2337
--- /dev/null
+++ b/packages/opencode/test/lsp/diagnostics-event.test.ts
@@ -0,0 +1,211 @@
+import { describe, expect, test } from "bun:test"
+import path from "path"
+import { pathToFileURL } from "url"
+import { Deferred, Effect, Layer } from "effect"
+import { tmpdir, withTestInstance, TestInstance } from "../fixture/fixture"
+import { LSPClient } from "@/lsp/client"
+import { LSP } from "@/lsp/lsp"
+import * as LSPServer from "@/lsp/server"
+import { EventV2Bridge } from "@/event-v2-bridge"
+import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
+import { awaitWithTimeout, testEffect } from "../lib/effect"
+
+const DEBOUNCE_MS = 150
+const fakeServerPath = path.join(__dirname, "../fixture/lsp/fake-lsp-server.js")
+
+function spawnFakeServer() {
+ const { spawn } = require("child_process")
+ return {
+ process: spawn(process.execPath, [fakeServerPath], { stdio: "pipe" }),
+ }
+}
+
+function publishDiagnostics(client: any, file: string, diagnostics: any[]) {
+ return client.connection.sendNotification("test/publish-diagnostics", {
+ uri: pathToFileURL(file).href,
+ diagnostics,
+ })
+}
+
+const sampleDiagnostic = {
+ range: { start: { line: 0, character: 0 }, end: { line: 0, character: 5 } },
+ severity: 1,
+ message: "boom",
+ source: "fake",
+ code: "E001",
+}
+
+describe("LSP diagnostics event (client onDiagnostics)", () => {
+ test("a single publishDiagnostics emits exactly ONE onDiagnostics after debounce", async () => {
+ const handle = spawnFakeServer() as any
+ await using tmp = await tmpdir()
+ const file = path.join(tmp.path, "test.ts")
+ await Bun.write(file, "const x = 1\n")
+
+ await withTestInstance({
+ directory: tmp.path,
+ fn: async (ctx) => {
+ const emits: { path: string; diagnostics: any[] }[] = []
+ const client = await LSPClient.create({
+ serverID: "fake",
+ server: handle as unknown as LSPServer.Handle,
+ root: tmp.path,
+ directory: tmp.path,
+ instance: ctx,
+ onDiagnostics: (e) => emits.push(e),
+ })
+
+ await client.notify.open({ path: file, buffer: { text: "const x = 1\n", version: 1 } })
+ await publishDiagnostics(client, file, [sampleDiagnostic])
+
+ await new Promise((r) => setTimeout(r, DEBOUNCE_MS + 100))
+
+ expect(emits).toHaveLength(1)
+ expect(emits[0].path).toBe(file)
+ expect(emits[0].diagnostics).toHaveLength(1)
+ expect(emits[0].diagnostics[0].message).toBe("boom")
+ expect(emits[0].diagnostics[0].code).toBe("E001")
+
+ await client.shutdown()
+ },
+ })
+ })
+
+ test("clearing diagnostics emits an empty array", async () => {
+ const handle = spawnFakeServer() as any
+ await using tmp = await tmpdir()
+ const file = path.join(tmp.path, "test.ts")
+ await Bun.write(file, "const x = 1\n")
+
+ await withTestInstance({
+ directory: tmp.path,
+ fn: async (ctx) => {
+ const emits: { path: string; diagnostics: any[] }[] = []
+ const client = await LSPClient.create({
+ serverID: "fake",
+ server: handle as unknown as LSPServer.Handle,
+ root: tmp.path,
+ directory: tmp.path,
+ instance: ctx,
+ onDiagnostics: (e) => emits.push(e),
+ })
+
+ await client.notify.open({ path: file, buffer: { text: "const x = 1\n", version: 1 } })
+ await publishDiagnostics(client, file, [sampleDiagnostic])
+ await new Promise((r) => setTimeout(r, DEBOUNCE_MS + 100))
+
+ // Fix the errors -> server publishes an empty set.
+ await publishDiagnostics(client, file, [])
+ await new Promise((r) => setTimeout(r, DEBOUNCE_MS + 100))
+
+ expect(emits).toHaveLength(2)
+ expect(emits[1].path).toBe(file)
+ expect(emits[1].diagnostics).toEqual([])
+
+ await client.shutdown()
+ },
+ })
+ })
+
+ test("a burst within the debounce window coalesces to ONE emit", async () => {
+ const handle = spawnFakeServer() as any
+ await using tmp = await tmpdir()
+ const file = path.join(tmp.path, "test.ts")
+ await Bun.write(file, "const x = 1\n")
+
+ await withTestInstance({
+ directory: tmp.path,
+ fn: async (ctx) => {
+ const emits: { path: string; diagnostics: any[] }[] = []
+ const client = await LSPClient.create({
+ serverID: "fake",
+ server: handle as unknown as LSPServer.Handle,
+ root: tmp.path,
+ directory: tmp.path,
+ instance: ctx,
+ onDiagnostics: (e) => emits.push(e),
+ })
+
+ await client.notify.open({ path: file, buffer: { text: "const x = 1\n", version: 1 } })
+
+ // Fire N rapid publishes well within the 150ms window.
+ for (let i = 0; i < 5; i++) {
+ await publishDiagnostics(client, file, [{ ...sampleDiagnostic, message: `boom-${i}` }])
+ await new Promise((r) => setTimeout(r, 10))
+ }
+ await new Promise((r) => setTimeout(r, DEBOUNCE_MS + 100))
+
+ expect(emits).toHaveLength(1)
+ // Last value wins.
+ expect(emits[0].diagnostics[0].message).toBe("boom-4")
+
+ await client.shutdown()
+ },
+ })
+ })
+})
+
+const it = testEffect(Layer.mergeAll(EventV2Bridge.defaultLayer, CrossSpawnSpawner.defaultLayer))
+
+describe("LSP diagnostics event (lsp.diagnostics over the bus)", () => {
+ it.instance(
+ "publishes lsp.diagnostics with a workspace-relative path + DiagnosticOut payload",
+ () =>
+ Effect.gen(function* () {
+ const dir = (yield* TestInstance).directory
+ const events = yield* EventV2Bridge.Service
+ const got = yield* Deferred.make<{ path: string; diagnostics: ReadonlyArray }>()
+
+ const unsubscribe = yield* events.listen((event) => {
+ if (event.type === LSP.Event.Diagnostics.type) {
+ Deferred.doneUnsafe(got, Effect.succeed(event.data as any))
+ }
+ return Effect.void
+ })
+ yield* Effect.addFinalizer(() => unsubscribe)
+
+ const file = path.join(dir, "sample.ts")
+ yield* Effect.promise(() => Bun.write(file, "const x = 1\n"))
+
+ // Capture the instance-fiber context so the async onDiagnostics callback
+ // publishes with routed location — mirrors lsp.ts exactly.
+ const context = yield* Effect.context()
+ const handle = spawnFakeServer() as any
+
+ const client = yield* Effect.promise(() =>
+ LSPClient.create({
+ serverID: "fake",
+ server: handle as unknown as LSPServer.Handle,
+ root: dir,
+ directory: dir,
+ instance: { directory: dir } as any,
+ onDiagnostics: (e) => {
+ Effect.runForkWith(context as any)(
+ events.publish(LSP.Event.Diagnostics, {
+ path: path.relative(dir, e.path),
+ diagnostics: e.diagnostics.map((d) => ({
+ range: d.range,
+ severity: d.severity,
+ message: d.message,
+ source: d.source,
+ code: typeof d.code === "string" || typeof d.code === "number" ? d.code : undefined,
+ })),
+ }),
+ )
+ },
+ }),
+ )
+
+ yield* Effect.promise(() => client.notify.open({ path: file, buffer: { text: "const x = 1\n", version: 1 } }))
+ yield* Effect.promise(() => publishDiagnostics(client, file, [sampleDiagnostic]))
+
+ const result = yield* awaitWithTimeout(Deferred.await(got), "lsp.diagnostics event was not published")
+ expect(result.path).toBe("sample.ts")
+ expect(result.diagnostics).toHaveLength(1)
+ expect(result.diagnostics[0].message).toBe("boom")
+ expect(result.diagnostics[0].code).toBe("E001")
+
+ yield* Effect.promise(() => client.shutdown())
+ }),
+ )
+})
diff --git a/packages/opencode/test/server/httpapi-file-write.test.ts b/packages/opencode/test/server/httpapi-file-write.test.ts
new file mode 100644
index 000000000000..f8ae30c87201
--- /dev/null
+++ b/packages/opencode/test/server/httpapi-file-write.test.ts
@@ -0,0 +1,154 @@
+import { afterEach, describe, expect, test } from "bun:test"
+import { createHash } from "node:crypto"
+import { Context } from "effect"
+import * as fs from "fs/promises"
+import path from "path"
+import { HttpApiApp } from "../../src/server/routes/instance/httpapi/server"
+import { FilePaths } from "../../src/server/routes/instance/httpapi/groups/file"
+import { resetDatabase } from "../fixture/db"
+import { disposeAllInstances, tmpdir } from "../fixture/fixture"
+
+const context = Context.empty() as Context.Context
+
+function request(
+ route: string,
+ directory: string,
+ query: Record,
+ body: unknown,
+) {
+ const url = new URL(`http://localhost${route}`)
+ for (const [key, value] of Object.entries(query)) {
+ url.searchParams.set(key, value)
+ }
+ return HttpApiApp.webHandler().handler(
+ new Request(url, {
+ method: "PUT",
+ headers: {
+ "x-opencode-directory": directory,
+ "content-type": "application/json",
+ },
+ body: JSON.stringify(body),
+ }),
+ context,
+ )
+}
+
+async function sha256(bytes: Uint8Array): Promise {
+ return createHash("sha256").update(bytes).digest("hex")
+}
+
+afterEach(async () => {
+ await disposeAllInstances()
+ await resetDatabase()
+})
+
+describe("file.write HttpApi", () => {
+ test("creates a new file and bytes on disk match", async () => {
+ await using tmp = await tmpdir({ git: true })
+
+ const res = await request(FilePaths.write, tmp.path, { path: "new.txt" }, { content: "hello world" })
+
+ expect(res.status).toBe(200)
+ const body = await res.json() as { written: boolean; sha: string; path: string }
+ expect(body.written).toBe(true)
+ expect(typeof body.sha).toBe("string")
+
+ const diskBytes = await fs.readFile(path.join(tmp.path, "new.txt"))
+ expect(Buffer.from(diskBytes).toString("utf-8")).toBe("hello world")
+ })
+
+ test("overwrites an existing file and returns new sha", async () => {
+ await using tmp = await tmpdir({ git: true })
+ const filePath = path.join(tmp.path, "existing.txt")
+ await Bun.write(filePath, "old content")
+
+ const res = await request(FilePaths.write, tmp.path, { path: "existing.txt" }, { content: "new content" })
+
+ expect(res.status).toBe(200)
+ const body = await res.json() as { written: boolean; sha: string }
+ expect(body.written).toBe(true)
+ expect(typeof body.sha).toBe("string")
+
+ const diskBytes = await fs.readFile(filePath)
+ expect(Buffer.from(diskBytes).toString("utf-8")).toBe("new content")
+
+ const expectedSha = await sha256(new TextEncoder().encode("new content"))
+ expect(body.sha).toBe(expectedSha)
+ })
+
+ test("expectedSha mismatch returns conflict and leaves file unchanged", async () => {
+ await using tmp = await tmpdir({ git: true })
+ const filePath = path.join(tmp.path, "conflict.txt")
+ await Bun.write(filePath, "original content")
+
+ const stale = "0000000000000000000000000000000000000000000000000000000000000000"
+ const res = await request(
+ FilePaths.write,
+ tmp.path,
+ { path: "conflict.txt" },
+ { content: "new content", expectedSha: stale },
+ )
+
+ expect(res.status).toBe(200)
+ const body = await res.json() as { written: boolean; conflict: boolean }
+ expect(body.written).toBe(false)
+ expect(body.conflict).toBe(true)
+
+ // File must be unchanged on disk
+ const diskContent = await fs.readFile(filePath, "utf-8")
+ expect(diskContent).toBe("original content")
+ })
+
+ test("expectedSha match writes successfully", async () => {
+ await using tmp = await tmpdir({ git: true })
+ const filePath = path.join(tmp.path, "match.txt")
+ const originalContent = "original"
+ await Bun.write(filePath, originalContent)
+
+ const currentBytes = new TextEncoder().encode(originalContent)
+ const currentSha = await sha256(currentBytes)
+
+ const res = await request(
+ FilePaths.write,
+ tmp.path,
+ { path: "match.txt" },
+ { content: "updated", expectedSha: currentSha },
+ )
+
+ expect(res.status).toBe(200)
+ const body = await res.json() as { written: boolean; sha: string }
+ expect(body.written).toBe(true)
+
+ const diskContent = await fs.readFile(filePath, "utf-8")
+ expect(diskContent).toBe("updated")
+ })
+
+ test("path escaping the directory is rejected", async () => {
+ await using tmp = await tmpdir({ git: true })
+
+ const res = await request(
+ FilePaths.write,
+ tmp.path,
+ { path: "../../etc/passwd" },
+ { content: "evil" },
+ )
+
+ // Should result in a server error (500 / defect), not 200
+ expect(res.status).toBeGreaterThanOrEqual(500)
+ })
+
+ test("writing over a binary file is rejected", async () => {
+ await using tmp = await tmpdir({ git: true })
+ const filePath = path.join(tmp.path, "image.bin")
+ // Write some null bytes to make a binary file
+ await fs.writeFile(filePath, Buffer.from([0xff, 0xfe, 0x00, 0x01, 0x02, 0x03]))
+
+ const res = await request(FilePaths.write, tmp.path, { path: "image.bin" }, { content: "text" })
+
+ expect(res.status).toBeGreaterThanOrEqual(500)
+
+ // File must be unchanged on disk
+ const diskBytes = await fs.readFile(filePath)
+ expect(diskBytes).toEqual(Buffer.from([0xff, 0xfe, 0x00, 0x01, 0x02, 0x03]))
+ })
+})
diff --git a/packages/opencode/test/server/httpapi-lsp.test.ts b/packages/opencode/test/server/httpapi-lsp.test.ts
new file mode 100644
index 000000000000..60a22881569e
--- /dev/null
+++ b/packages/opencode/test/server/httpapi-lsp.test.ts
@@ -0,0 +1,136 @@
+import { afterEach, describe, expect, test } from "bun:test"
+import { ConfigProvider, Context, Layer } from "effect"
+import { HttpRouter } from "effect/unstable/http"
+import { HttpApiApp } from "../../src/server/routes/instance/httpapi/server"
+import { LspFeaturePaths } from "../../src/server/routes/instance/httpapi/groups/lsp"
+import { resetDatabase } from "../fixture/db"
+import { disposeAllInstances, tmpdir } from "../fixture/fixture"
+
+const context = Context.empty() as Context.Context
+
+// These tests exercise the real LSP service. The fixtures disable LSP servers
+// (config.lsp false / plain .txt files), so every feature method resolves with
+// no attached client. That is exactly the "no client for language" path: the
+// endpoints must route and return an empty/null result, never a 500.
+
+function post(route: string, directory: string, body: unknown) {
+ const url = new URL(`http://localhost${route}`)
+ return HttpApiApp.webHandler().handler(
+ new Request(url, {
+ method: "POST",
+ headers: {
+ "x-opencode-directory": directory,
+ "content-type": "application/json",
+ },
+ body: JSON.stringify(body),
+ }),
+ context,
+ )
+}
+
+function get(route: string, directory: string, query: Record) {
+ const url = new URL(`http://localhost${route}`)
+ for (const [k, v] of Object.entries(query)) url.searchParams.set(k, v)
+ return HttpApiApp.webHandler().handler(
+ new Request(url, {
+ method: "GET",
+ headers: { "x-opencode-directory": directory },
+ }),
+ context,
+ )
+}
+
+afterEach(async () => {
+ await disposeAllInstances()
+ await resetDatabase()
+})
+
+describe("lsp feature HttpApi", () => {
+ const loc = { path: "file.ts", line: 0, character: 0 }
+
+ test("hover routes and returns null/empty (no client)", async () => {
+ await using tmp = await tmpdir({ git: true, config: { lsp: false } })
+ await Bun.write(`${tmp.path}/file.ts`, "const x = 1\n")
+ const res = await post(LspFeaturePaths.hover, tmp.path, loc)
+ expect(res.status).toBe(200)
+ const body = await res.json()
+ // hover returns the array of per-client results; with no clients it is empty
+ expect(Array.isArray(body) ? body.length : body).toBeFalsy()
+ })
+
+ test("definition routes and returns empty array (no client)", async () => {
+ await using tmp = await tmpdir({ git: true, config: { lsp: false } })
+ await Bun.write(`${tmp.path}/file.ts`, "const x = 1\n")
+ const res = await post(LspFeaturePaths.definition, tmp.path, loc)
+ expect(res.status).toBe(200)
+ expect(await res.json()).toEqual([])
+ })
+
+ test("references routes and returns empty array (no client)", async () => {
+ await using tmp = await tmpdir({ git: true, config: { lsp: false } })
+ await Bun.write(`${tmp.path}/file.ts`, "const x = 1\n")
+ const res = await post(LspFeaturePaths.references, tmp.path, loc)
+ expect(res.status).toBe(200)
+ expect(await res.json()).toEqual([])
+ })
+
+ test("completion routes and returns null/empty (no client)", async () => {
+ await using tmp = await tmpdir({ git: true, config: { lsp: false } })
+ await Bun.write(`${tmp.path}/file.ts`, "const x = 1\n")
+ const res = await post(LspFeaturePaths.completion, tmp.path, {
+ ...loc,
+ triggerKind: 1,
+ })
+ expect(res.status).toBe(200)
+ const body = await res.json()
+ expect(Array.isArray(body) ? body.length : body).toBeFalsy()
+ })
+
+ test("diagnostics routes and returns empty array for the requested file", async () => {
+ await using tmp = await tmpdir({ git: true, config: { lsp: false } })
+ await Bun.write(`${tmp.path}/file.ts`, "const x = 1\n")
+ const res = await get(LspFeaturePaths.diagnostics, tmp.path, { path: "file.ts" })
+ expect(res.status).toBe(200)
+ expect(await res.json()).toEqual([])
+ })
+
+ test("path escaping the directory is rejected", async () => {
+ await using tmp = await tmpdir({ git: true, config: { lsp: false } })
+ const res = await post(LspFeaturePaths.hover, tmp.path, {
+ path: "../../etc/passwd",
+ line: 0,
+ character: 0,
+ })
+ expect(res.status).toBeGreaterThanOrEqual(500)
+ })
+})
+
+function authApp(password: string) {
+ const handler = HttpRouter.toWebHandler(
+ HttpApiApp.routes.pipe(
+ Layer.provide(
+ ConfigProvider.layer(
+ ConfigProvider.fromUnknown({ OPENCODE_SERVER_PASSWORD: password }),
+ ),
+ ),
+ ),
+ { disableLogger: true },
+ ).handler
+ return (req: Request) => handler(req, HttpApiApp.context)
+}
+
+describe("lsp feature HttpApi authorization", () => {
+ test("unauthorized request is rejected", async () => {
+ await using tmp = await tmpdir({ git: true, config: { lsp: false } })
+ const fetch = authApp("secret")
+ const url = new URL(`http://localhost${LspFeaturePaths.hover}`)
+ const res = await fetch(
+ new Request(url, {
+ method: "POST",
+ headers: { "x-opencode-directory": tmp.path, "content-type": "application/json" },
+ body: JSON.stringify({ path: "file.ts", line: 0, character: 0 }),
+ }),
+ )
+ expect(res.status).toBe(401)
+ })
+})
diff --git a/packages/opencode/test/session/prompt.test.ts b/packages/opencode/test/session/prompt.test.ts
index 491ad06aaf47..6b78df657bdd 100644
--- a/packages/opencode/test/session/prompt.test.ts
+++ b/packages/opencode/test/session/prompt.test.ts
@@ -143,6 +143,8 @@ const lsp = Layer.succeed(
status: () => Effect.succeed([]),
hasClients: () => Effect.succeed(false),
touchFile: () => Effect.void,
+ syncBuffer: () => Effect.void,
+ closeBuffer: () => Effect.void,
diagnostics: () => Effect.succeed({}),
hover: () => Effect.succeed(undefined),
definition: () => Effect.succeed([]),
@@ -153,6 +155,7 @@ const lsp = Layer.succeed(
prepareCallHierarchy: () => Effect.succeed([]),
incomingCalls: () => Effect.succeed([]),
outgoingCalls: () => Effect.succeed([]),
+ completion: () => Effect.succeed(null),
}),
)
diff --git a/packages/opencode/test/session/snapshot-tool-race.test.ts b/packages/opencode/test/session/snapshot-tool-race.test.ts
index 1265237840f3..74c27645c606 100644
--- a/packages/opencode/test/session/snapshot-tool-race.test.ts
+++ b/packages/opencode/test/session/snapshot-tool-race.test.ts
@@ -64,6 +64,8 @@ const lsp = Layer.succeed(
status: () => Effect.succeed([]),
hasClients: () => Effect.succeed(false),
touchFile: () => Effect.void,
+ syncBuffer: () => Effect.void,
+ closeBuffer: () => Effect.void,
diagnostics: () => Effect.succeed({}),
hover: () => Effect.succeed(undefined),
definition: () => Effect.succeed([]),
@@ -74,6 +76,7 @@ const lsp = Layer.succeed(
prepareCallHierarchy: () => Effect.succeed([]),
incomingCalls: () => Effect.succeed([]),
outgoingCalls: () => Effect.succeed([]),
+ completion: () => Effect.succeed(null),
}),
)
diff --git a/packages/opencode/test/tool/lsp.test.ts b/packages/opencode/test/tool/lsp.test.ts
index 8735fdb7b29d..177cb2d0de14 100644
--- a/packages/opencode/test/tool/lsp.test.ts
+++ b/packages/opencode/test/tool/lsp.test.ts
@@ -39,6 +39,8 @@ const lsp = Layer.succeed(
status: () => Effect.succeed([]),
hasClients: () => Effect.succeed(true),
touchFile: () => Effect.void,
+ syncBuffer: () => Effect.void,
+ closeBuffer: () => Effect.void,
diagnostics: () => Effect.succeed({}),
hover: () => Effect.succeed([]),
definition: () => Effect.succeed([]),
@@ -53,6 +55,7 @@ const lsp = Layer.succeed(
prepareCallHierarchy: () => Effect.succeed([]),
incomingCalls: () => Effect.succeed([]),
outgoingCalls: () => Effect.succeed([]),
+ completion: () => Effect.succeed(null),
}),
)
diff --git a/packages/sdk/js/src/v2/gen/sdk.gen.ts b/packages/sdk/js/src/v2/gen/sdk.gen.ts
index 9ed0084aac84..16160ddc8d31 100644
--- a/packages/sdk/js/src/v2/gen/sdk.gen.ts
+++ b/packages/sdk/js/src/v2/gen/sdk.gen.ts
@@ -68,6 +68,8 @@ import type {
FileReadResponses,
FileStatusErrors,
FileStatusResponses,
+ FileWriteErrors,
+ FileWriteResponses,
FindFilesErrors,
FindFilesResponses,
FindSymbolsErrors,
@@ -91,6 +93,20 @@ import type {
InstanceDisposeErrors,
InstanceDisposeResponses,
LocationRef,
+ LspBufferCloseErrors,
+ LspBufferCloseResponses,
+ LspBufferErrors,
+ LspBufferResponses,
+ LspCompletionErrors,
+ LspCompletionResponses,
+ LspDefinitionErrors,
+ LspDefinitionResponses,
+ LspDiagnosticsErrors,
+ LspDiagnosticsResponses,
+ LspHoverErrors,
+ LspHoverResponses,
+ LspReferencesErrors,
+ LspReferencesResponses,
LspStatusErrors,
LspStatusResponses,
McpAddErrors,
@@ -1920,6 +1936,47 @@ export class File extends HeyApiClient {
...params,
})
}
+
+ /**
+ * Write file
+ *
+ * Write content to a file in the project, with optional conflict detection.
+ */
+ public write(
+ parameters: {
+ directory?: string
+ workspace?: string
+ path: string
+ content?: string
+ expectedSha?: string
+ },
+ options?: Options,
+ ) {
+ const params = buildClientParams(
+ [parameters],
+ [
+ {
+ args: [
+ { in: "query", key: "directory" },
+ { in: "query", key: "workspace" },
+ { in: "query", key: "path" },
+ { in: "body", key: "content" },
+ { in: "body", key: "expectedSha" },
+ ],
+ },
+ ],
+ )
+ return (options?.client ?? this.client).put({
+ url: "/file/write",
+ ...options,
+ ...params,
+ headers: {
+ "Content-Type": "application/json",
+ ...options?.headers,
+ ...params.headers,
+ },
+ })
+ }
}
export class Instance extends HeyApiClient {
@@ -2218,6 +2275,296 @@ export class Lsp extends HeyApiClient {
...params,
})
}
+
+ /**
+ * LSP hover
+ *
+ * Get hover information for a position in a file via LSP.
+ */
+ public hover(
+ parameters?: {
+ directory?: string
+ workspace?: string
+ path?: string
+ line?: number
+ character?: number
+ triggerKind?: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN"
+ triggerCharacter?: string
+ },
+ options?: Options,
+ ) {
+ const params = buildClientParams(
+ [parameters],
+ [
+ {
+ args: [
+ { in: "query", key: "directory" },
+ { in: "query", key: "workspace" },
+ { in: "body", key: "path" },
+ { in: "body", key: "line" },
+ { in: "body", key: "character" },
+ { in: "body", key: "triggerKind" },
+ { in: "body", key: "triggerCharacter" },
+ ],
+ },
+ ],
+ )
+ return (options?.client ?? this.client).post({
+ url: "/lsp/hover",
+ ...options,
+ ...params,
+ headers: {
+ "Content-Type": "application/json",
+ ...options?.headers,
+ ...params.headers,
+ },
+ })
+ }
+
+ /**
+ * LSP definition
+ *
+ * Find the definition of the symbol at a position in a file via LSP.
+ */
+ public definition(
+ parameters?: {
+ directory?: string
+ workspace?: string
+ path?: string
+ line?: number
+ character?: number
+ triggerKind?: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN"
+ triggerCharacter?: string
+ },
+ options?: Options,
+ ) {
+ const params = buildClientParams(
+ [parameters],
+ [
+ {
+ args: [
+ { in: "query", key: "directory" },
+ { in: "query", key: "workspace" },
+ { in: "body", key: "path" },
+ { in: "body", key: "line" },
+ { in: "body", key: "character" },
+ { in: "body", key: "triggerKind" },
+ { in: "body", key: "triggerCharacter" },
+ ],
+ },
+ ],
+ )
+ return (options?.client ?? this.client).post({
+ url: "/lsp/definition",
+ ...options,
+ ...params,
+ headers: {
+ "Content-Type": "application/json",
+ ...options?.headers,
+ ...params.headers,
+ },
+ })
+ }
+
+ /**
+ * LSP references
+ *
+ * Find references to the symbol at a position in a file via LSP.
+ */
+ public references(
+ parameters?: {
+ directory?: string
+ workspace?: string
+ path?: string
+ line?: number
+ character?: number
+ triggerKind?: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN"
+ triggerCharacter?: string
+ },
+ options?: Options,
+ ) {
+ const params = buildClientParams(
+ [parameters],
+ [
+ {
+ args: [
+ { in: "query", key: "directory" },
+ { in: "query", key: "workspace" },
+ { in: "body", key: "path" },
+ { in: "body", key: "line" },
+ { in: "body", key: "character" },
+ { in: "body", key: "triggerKind" },
+ { in: "body", key: "triggerCharacter" },
+ ],
+ },
+ ],
+ )
+ return (options?.client ?? this.client).post({
+ url: "/lsp/references",
+ ...options,
+ ...params,
+ headers: {
+ "Content-Type": "application/json",
+ ...options?.headers,
+ ...params.headers,
+ },
+ })
+ }
+
+ /**
+ * LSP completion
+ *
+ * Get completion suggestions for a position in a file via LSP.
+ */
+ public completion(
+ parameters?: {
+ directory?: string
+ workspace?: string
+ path?: string
+ line?: number
+ character?: number
+ triggerKind?: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN"
+ triggerCharacter?: string
+ },
+ options?: Options,
+ ) {
+ const params = buildClientParams(
+ [parameters],
+ [
+ {
+ args: [
+ { in: "query", key: "directory" },
+ { in: "query", key: "workspace" },
+ { in: "body", key: "path" },
+ { in: "body", key: "line" },
+ { in: "body", key: "character" },
+ { in: "body", key: "triggerKind" },
+ { in: "body", key: "triggerCharacter" },
+ ],
+ },
+ ],
+ )
+ return (options?.client ?? this.client).post({
+ url: "/lsp/completion",
+ ...options,
+ ...params,
+ headers: {
+ "Content-Type": "application/json",
+ ...options?.headers,
+ ...params.headers,
+ },
+ })
+ }
+
+ /**
+ * LSP diagnostics
+ *
+ * Get diagnostics for a single file via LSP.
+ */
+ public diagnostics(
+ parameters: {
+ directory?: string
+ workspace?: string
+ path: string
+ },
+ options?: Options,
+ ) {
+ const params = buildClientParams(
+ [parameters],
+ [
+ {
+ args: [
+ { in: "query", key: "directory" },
+ { in: "query", key: "workspace" },
+ { in: "query", key: "path" },
+ ],
+ },
+ ],
+ )
+ return (options?.client ?? this.client).get({
+ url: "/lsp/diagnostics",
+ ...options,
+ ...params,
+ })
+ }
+
+ /**
+ * LSP buffer sync
+ *
+ * Push the editor's in-memory buffer (text + monotonic version) so LSP features reflect unsaved edits.
+ */
+ public buffer(
+ parameters?: {
+ directory?: string
+ workspace?: string
+ path?: string
+ text?: string
+ version?: number
+ },
+ options?: Options,
+ ) {
+ const params = buildClientParams(
+ [parameters],
+ [
+ {
+ args: [
+ { in: "query", key: "directory" },
+ { in: "query", key: "workspace" },
+ { in: "body", key: "path" },
+ { in: "body", key: "text" },
+ { in: "body", key: "version" },
+ ],
+ },
+ ],
+ )
+ return (options?.client ?? this.client).put({
+ url: "/lsp/buffer",
+ ...options,
+ ...params,
+ headers: {
+ "Content-Type": "application/json",
+ ...options?.headers,
+ ...params.headers,
+ },
+ })
+ }
+
+ /**
+ * LSP buffer close
+ *
+ * Close the editor buffer so the LSP server reverts to disk-backed analysis.
+ */
+ public bufferClose(
+ parameters?: {
+ directory?: string
+ workspace?: string
+ path?: string
+ },
+ options?: Options,
+ ) {
+ const params = buildClientParams(
+ [parameters],
+ [
+ {
+ args: [
+ { in: "query", key: "directory" },
+ { in: "query", key: "workspace" },
+ { in: "body", key: "path" },
+ ],
+ },
+ ],
+ )
+ return (options?.client ?? this.client).post({
+ url: "/lsp/buffer/close",
+ ...options,
+ ...params,
+ headers: {
+ "Content-Type": "application/json",
+ ...options?.headers,
+ ...params.headers,
+ },
+ })
+ }
}
export class Formatter extends HeyApiClient {
diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts
index 5e067f3afb23..5e87c7447a31 100644
--- a/packages/sdk/js/src/v2/gen/types.gen.ts
+++ b/packages/sdk/js/src/v2/gen/types.gen.ts
@@ -69,6 +69,7 @@ export type Event =
| EventQuestionV2Rejected
| EventTodoUpdated
| EventLspUpdated
+ | EventLspDiagnostics
| EventPermissionAsked
| EventPermissionReplied
| EventTuiPromptAppend2
@@ -670,6 +671,14 @@ export type Todo = {
priority: string
}
+export type Diagnostic = {
+ range: Range
+ severity?: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN"
+ message: string
+ source?: string
+ code?: string | number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN"
+}
+
export type SessionStatus =
| {
type: "idle"
@@ -1373,6 +1382,14 @@ export type GlobalEvent = {
[key: string]: unknown
}
}
+ | {
+ id: string
+ type: "lsp.diagnostics"
+ properties: {
+ path: string
+ diagnostics: Array
+ }
+ }
| {
id: string
type: "permission.asked"
@@ -2295,6 +2312,13 @@ export type File = {
status: "added" | "deleted" | "modified"
}
+export type FileWriteResult = {
+ path: string
+ written: boolean
+ conflict?: boolean
+ sha?: string
+}
+
export type Path = {
home: string
state: string
@@ -2956,6 +2980,14 @@ export type EffectHttpApiErrorForbidden = {
_tag: "Forbidden"
}
+export type Diagnostic1 = {
+ range: Range
+ severity?: number | "NaN" | "Infinity" | "-Infinity"
+ message: string
+ source?: string
+ code?: string | number | "NaN" | "Infinity" | "-Infinity"
+}
+
export type EventTuiPromptAppend2 = {
id: string
type: "tui.prompt.append"
@@ -6851,6 +6883,15 @@ export type EventLspUpdated = {
}
}
+export type EventLspDiagnostics = {
+ id: string
+ type: "lsp.diagnostics"
+ properties: {
+ path: string
+ diagnostics: Array
+ }
+}
+
export type EventPermissionAsked = {
id: string
type: "permission.asked"
@@ -8076,6 +8117,38 @@ export type FileStatusResponses = {
export type FileStatusResponse = FileStatusResponses[keyof FileStatusResponses]
+export type FileWriteData = {
+ body?: {
+ content: string
+ expectedSha?: string
+ }
+ path?: never
+ query: {
+ directory?: string
+ workspace?: string
+ path: string
+ }
+ url: "/file/write"
+}
+
+export type FileWriteErrors = {
+ /**
+ * Bad request
+ */
+ 400: BadRequestError
+}
+
+export type FileWriteError = FileWriteErrors[keyof FileWriteErrors]
+
+export type FileWriteResponses = {
+ /**
+ * Write result
+ */
+ 200: FileWriteResult
+}
+
+export type FileWriteResponse = FileWriteResponses[keyof FileWriteResponses]
+
export type InstanceDisposeData = {
body?: never
path?: never
@@ -8423,6 +8496,225 @@ export type FormatterStatusResponses = {
export type FormatterStatusResponse = FormatterStatusResponses[keyof FormatterStatusResponses]
+export type LspHoverData = {
+ body?: {
+ path: string
+ line: number
+ character: number
+ triggerKind?: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN"
+ triggerCharacter?: string
+ }
+ path?: never
+ query?: {
+ directory?: string
+ workspace?: string
+ }
+ url: "/lsp/hover"
+}
+
+export type LspHoverErrors = {
+ /**
+ * Bad request
+ */
+ 400: BadRequestError
+}
+
+export type LspHoverError = LspHoverErrors[keyof LspHoverErrors]
+
+export type LspHoverResponses = {
+ /**
+ * Hover result
+ */
+ 200: unknown
+}
+
+export type LspDefinitionData = {
+ body?: {
+ path: string
+ line: number
+ character: number
+ triggerKind?: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN"
+ triggerCharacter?: string
+ }
+ path?: never
+ query?: {
+ directory?: string
+ workspace?: string
+ }
+ url: "/lsp/definition"
+}
+
+export type LspDefinitionErrors = {
+ /**
+ * Bad request
+ */
+ 400: BadRequestError
+}
+
+export type LspDefinitionError = LspDefinitionErrors[keyof LspDefinitionErrors]
+
+export type LspDefinitionResponses = {
+ /**
+ * Definition locations
+ */
+ 200: unknown
+}
+
+export type LspReferencesData = {
+ body?: {
+ path: string
+ line: number
+ character: number
+ triggerKind?: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN"
+ triggerCharacter?: string
+ }
+ path?: never
+ query?: {
+ directory?: string
+ workspace?: string
+ }
+ url: "/lsp/references"
+}
+
+export type LspReferencesErrors = {
+ /**
+ * Bad request
+ */
+ 400: BadRequestError
+}
+
+export type LspReferencesError = LspReferencesErrors[keyof LspReferencesErrors]
+
+export type LspReferencesResponses = {
+ /**
+ * Reference locations
+ */
+ 200: unknown
+}
+
+export type LspCompletionData = {
+ body?: {
+ path: string
+ line: number
+ character: number
+ triggerKind?: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN"
+ triggerCharacter?: string
+ }
+ path?: never
+ query?: {
+ directory?: string
+ workspace?: string
+ }
+ url: "/lsp/completion"
+}
+
+export type LspCompletionErrors = {
+ /**
+ * Bad request
+ */
+ 400: BadRequestError
+}
+
+export type LspCompletionError = LspCompletionErrors[keyof LspCompletionErrors]
+
+export type LspCompletionResponses = {
+ /**
+ * Completion result
+ */
+ 200: unknown
+}
+
+export type LspDiagnosticsData = {
+ body?: never
+ path?: never
+ query: {
+ directory?: string
+ workspace?: string
+ path: string
+ }
+ url: "/lsp/diagnostics"
+}
+
+export type LspDiagnosticsErrors = {
+ /**
+ * Bad request
+ */
+ 400: BadRequestError
+}
+
+export type LspDiagnosticsError = LspDiagnosticsErrors[keyof LspDiagnosticsErrors]
+
+export type LspDiagnosticsResponses = {
+ /**
+ * Diagnostics for the file
+ */
+ 200: Array
+}
+
+export type LspDiagnosticsResponse = LspDiagnosticsResponses[keyof LspDiagnosticsResponses]
+
+export type LspBufferData = {
+ body?: {
+ path: string
+ text: string
+ version: number
+ }
+ path?: never
+ query?: {
+ directory?: string
+ workspace?: string
+ }
+ url: "/lsp/buffer"
+}
+
+export type LspBufferErrors = {
+ /**
+ * Bad request
+ */
+ 400: BadRequestError
+}
+
+export type LspBufferError = LspBufferErrors[keyof LspBufferErrors]
+
+export type LspBufferResponses = {
+ /**
+ * Buffer synced
+ */
+ 200: boolean
+}
+
+export type LspBufferResponse = LspBufferResponses[keyof LspBufferResponses]
+
+export type LspBufferCloseData = {
+ body?: {
+ path: string
+ }
+ path?: never
+ query?: {
+ directory?: string
+ workspace?: string
+ }
+ url: "/lsp/buffer/close"
+}
+
+export type LspBufferCloseErrors = {
+ /**
+ * Bad request
+ */
+ 400: BadRequestError
+}
+
+export type LspBufferCloseError = LspBufferCloseErrors[keyof LspBufferCloseErrors]
+
+export type LspBufferCloseResponses = {
+ /**
+ * Buffer closed
+ */
+ 200: boolean
+}
+
+export type LspBufferCloseResponse = LspBufferCloseResponses[keyof LspBufferCloseResponses]
+
export type McpStatusData = {
body?: never
path?: never
diff --git a/packages/ui/bunfig.toml b/packages/ui/bunfig.toml
new file mode 100644
index 000000000000..ad879b5c516d
--- /dev/null
+++ b/packages/ui/bunfig.toml
@@ -0,0 +1,5 @@
+[test]
+# Registers happy-dom and compiles SolidJS TSX to its DOM build so component
+# tests (e.g. code-editor.test.tsx mounting CodeMirror 6) can render real DOM.
+# Logic-only tests are unaffected.
+preload = ["./test/dom-preload.ts"]
diff --git a/packages/ui/package.json b/packages/ui/package.json
index 6312702331af..bb7b737451ff 100644
--- a/packages/ui/package.json
+++ b/packages/ui/package.json
@@ -35,6 +35,8 @@
"exports": {
"./package.json": "./package.json",
"./*": "./src/components/*.tsx",
+ "./session-diff": "./src/components/session-diff.ts",
+ "./code-editor-lsp": "./src/components/code-editor-lsp.ts",
"./i18n/*": "./src/i18n/*.ts",
"./hooks": "./src/hooks/index.ts",
"./context": "./src/context/index.ts",
@@ -66,6 +68,10 @@
"devDependencies": {
"@solidjs/meta": "catalog:",
"@tailwindcss/vite": "catalog:",
+ "@babel/core": "7.28.4",
+ "@babel/preset-typescript": "7.27.1",
+ "@happy-dom/global-registrator": "20.0.11",
+ "babel-preset-solid": "1.9.12",
"@tsconfig/node22": "catalog:",
"@types/bun": "catalog:",
"@types/katex": "0.16.7",
@@ -80,7 +86,20 @@
"vite-plugin-solid": "catalog:"
},
"dependencies": {
+ "@codemirror/autocomplete": "6.18.6",
+ "@codemirror/commands": "6.8.1",
+ "@codemirror/lang-go": "6.0.1",
+ "@codemirror/lang-javascript": "6.2.4",
+ "@codemirror/lang-python": "6.2.1",
+ "@codemirror/language": "6.11.3",
+ "@codemirror/lint": "6.8.5",
+ "@codemirror/search": "6.5.11",
+ "@codemirror/state": "6.5.2",
+ "@codemirror/view": "6.38.1",
"@kobalte/core": "catalog:",
+ "@lezer/highlight": "1.2.3",
+ "@opencode-ai/core": "workspace:*",
+ "@opencode-ai/sdk": "workspace:*",
"@pierre/diffs": "catalog:",
"@shikijs/transformers": "3.9.2",
"@solid-primitives/bounds": "0.1.3",
diff --git a/packages/ui/src/codemirror/shiki-highlight.test.ts b/packages/ui/src/codemirror/shiki-highlight.test.ts
new file mode 100644
index 000000000000..f8e3f407dc20
--- /dev/null
+++ b/packages/ui/src/codemirror/shiki-highlight.test.ts
@@ -0,0 +1,29 @@
+import { describe, test, expect } from "bun:test"
+import { editorLanguageToShikiLang } from "./shiki-highlight"
+
+describe("editorLanguageToShikiLang", () => {
+ test("maps explicit languages", () => {
+ expect(editorLanguageToShikiLang("typescript", undefined)).toBe("tsx")
+ expect(editorLanguageToShikiLang("go", undefined)).toBe("go")
+ expect(editorLanguageToShikiLang("python", undefined)).toBe("python")
+ expect(editorLanguageToShikiLang("plaintext", undefined)).toBeUndefined()
+ })
+
+ test("explicit language wins over path", () => {
+ expect(editorLanguageToShikiLang("go", "foo.ts")).toBe("go")
+ })
+
+ test("derives from path extension", () => {
+ expect(editorLanguageToShikiLang(undefined, "a.ts")).toBe("typescript")
+ expect(editorLanguageToShikiLang(undefined, "a.tsx")).toBe("tsx")
+ expect(editorLanguageToShikiLang(undefined, "a.js")).toBe("javascript")
+ expect(editorLanguageToShikiLang(undefined, "a.jsx")).toBe("jsx")
+ expect(editorLanguageToShikiLang(undefined, "a.go")).toBe("go")
+ expect(editorLanguageToShikiLang(undefined, "a.py")).toBe("python")
+ })
+
+ test("returns undefined for unknown / missing", () => {
+ expect(editorLanguageToShikiLang(undefined, "a.md")).toBeUndefined()
+ expect(editorLanguageToShikiLang(undefined, undefined)).toBeUndefined()
+ })
+})
diff --git a/packages/ui/src/codemirror/shiki-highlight.ts b/packages/ui/src/codemirror/shiki-highlight.ts
new file mode 100644
index 000000000000..4d8f2b3134f8
--- /dev/null
+++ b/packages/ui/src/codemirror/shiki-highlight.ts
@@ -0,0 +1,196 @@
+import { Decoration, type DecorationSet, EditorView, ViewPlugin, type ViewUpdate } from "@codemirror/view"
+import { RangeSetBuilder, type Extension } from "@codemirror/state"
+import { bundledLanguages, type BundledLanguage } from "shiki"
+import { getSharedHighlighter } from "@pierre/diffs"
+import type { CodeEditorLanguage } from "../components/code-editor"
+
+export type ShikiHighlighter = Awaited>
+
+export function editorLanguageToShikiLang(
+ language: CodeEditorLanguage | undefined,
+ path: string | undefined,
+): BundledLanguage | undefined {
+ if (language) {
+ switch (language) {
+ case "typescript":
+ return "tsx"
+ case "go":
+ return "go"
+ case "python":
+ return "python"
+ case "plaintext":
+ return undefined
+ }
+ }
+ if (path) {
+ const lower = path.toLowerCase()
+ const ext = lower.slice(lower.lastIndexOf("."))
+ switch (ext) {
+ case ".ts":
+ return "typescript"
+ case ".tsx":
+ return "tsx"
+ case ".js":
+ case ".mjs":
+ case ".cjs":
+ return "javascript"
+ case ".jsx":
+ return "jsx"
+ case ".go":
+ return "go"
+ case ".py":
+ case ".pyi":
+ return "python"
+ }
+ }
+ return undefined
+}
+
+export async function loadShikiForLang(lang: BundledLanguage): Promise {
+ if (!(lang in bundledLanguages)) return undefined
+ const highlighter = await getSharedHighlighter({
+ themes: ["OpenCode"],
+ langs: [],
+ preferredHighlighter: "shiki-wasm",
+ })
+ if (!highlighter.getLoadedLanguages().includes(lang)) {
+ await highlighter.loadLanguage(lang)
+ }
+ return highlighter
+}
+
+type TokensByLine = ReturnType["tokens"]
+
+const MAX_FULLDOC_LINES = 5000
+
+function makeMarkFactory() {
+ const cache = new Map()
+ return (color: string, fontStyle: number): Decoration => {
+ const key = color + ":" + fontStyle
+ let deco = cache.get(key)
+ if (!deco) {
+ let style = `color: ${color};`
+ if (fontStyle & 1) style += " font-style: italic;"
+ if (fontStyle & 2) style += " font-weight: bold;"
+ if (fontStyle & 4) style += " text-decoration: underline;"
+ deco = Decoration.mark({ attributes: { style } })
+ cache.set(key, deco)
+ }
+ return deco
+ }
+}
+
+function tokenizeDocument(
+ doc: string,
+ highlighter: ShikiHighlighter,
+ lang: BundledLanguage,
+): TokensByLine | undefined {
+ try {
+ return highlighter.codeToTokens(doc, { lang, theme: "OpenCode" }).tokens
+ } catch {
+ return undefined
+ }
+}
+
+function buildFromTokens(view: EditorView, tokensByLine: TokensByLine): DecorationSet {
+ const builder = new RangeSetBuilder()
+ const markFor = makeMarkFactory()
+ for (const { from, to } of view.visibleRanges) {
+ const startLine = view.state.doc.lineAt(from).number
+ const endLine = view.state.doc.lineAt(to).number
+ for (let ln = startLine; ln <= endLine; ln++) {
+ const line = view.state.doc.line(ln)
+ const row = tokensByLine[ln - 1]
+ if (!row) continue
+ // Whole-doc tokenization gives ABSOLUTE token.offset, so walk by content
+ // length from line start instead of using token.offset.
+ let pos = line.from
+ for (const token of row) {
+ const len = token.content.length
+ if (len <= 0) continue
+ if (token.color) builder.add(pos, pos + len, markFor(token.color, token.fontStyle ?? 0))
+ pos += len
+ }
+ }
+ }
+ return builder.finish()
+}
+
+function buildPerLine(view: EditorView, highlighter: ShikiHighlighter, lang: BundledLanguage): DecorationSet {
+ const builder = new RangeSetBuilder()
+ const markFor = makeMarkFactory()
+ for (const { from, to } of view.visibleRanges) {
+ const startLine = view.state.doc.lineAt(from).number
+ const endLine = view.state.doc.lineAt(to).number
+ for (let ln = startLine; ln <= endLine; ln++) {
+ const line = view.state.doc.line(ln)
+ if (line.length === 0) continue
+ let row
+ try {
+ row = highlighter.codeToTokens(line.text, { lang, theme: "OpenCode" }).tokens[0]
+ } catch {
+ continue
+ }
+ if (!row) continue
+ for (const token of row) {
+ const start = line.from + token.offset
+ const end = start + token.content.length
+ if (end <= start || !token.color) continue
+ builder.add(start, end, markFor(token.color, token.fontStyle ?? 0))
+ }
+ }
+ }
+ return builder.finish()
+}
+
+export function shikiHighlightExtension(highlighter: ShikiHighlighter, lang: BundledLanguage): Extension {
+ return ViewPlugin.fromClass(
+ class {
+ tokens: TokensByLine | undefined
+ decorations: DecorationSet
+ constructor(view: EditorView) {
+ this.tokens = this.tokenize(view)
+ this.decorations = this.build(view)
+ }
+ tokenize(view: EditorView): TokensByLine | undefined {
+ if (view.state.doc.lines > MAX_FULLDOC_LINES) return undefined
+ return tokenizeDocument(view.state.doc.toString(), highlighter, lang)
+ }
+ build(view: EditorView): DecorationSet {
+ return this.tokens ? buildFromTokens(view, this.tokens) : buildPerLine(view, highlighter, lang)
+ }
+ update(update: ViewUpdate) {
+ if (update.docChanged) this.tokens = this.tokenize(update.view)
+ if (update.docChanged || update.viewportChanged) this.decorations = this.build(update.view)
+ }
+ },
+ {
+ decorations: (v) => v.decorations,
+ },
+ )
+}
+
+export function highlightMarkdownCodeBlocks(container: HTMLElement): void {
+ const blocks = Array.from(container.querySelectorAll("pre > code")) as HTMLElement[]
+ if (blocks.length === 0) return
+ for (const code of blocks) {
+ const cls = Array.from(code.classList).find((c) => c.startsWith("language-"))
+ const langId = cls ? cls.slice("language-".length) : ""
+ if (!langId || !(langId in bundledLanguages)) continue
+ const text = code.textContent ?? ""
+ if (!text.trim()) continue
+ void loadShikiForLang(langId as BundledLanguage)
+ .then((highlighter) => {
+ if (!highlighter) return
+ if (!container.isConnected) return
+ const pre = code.parentElement
+ if (!pre || !pre.isConnected) return
+ const html = highlighter.codeToHtml(text, { lang: langId, theme: "OpenCode", tabindex: false })
+ const tmp = document.createElement("div")
+ tmp.innerHTML = html
+ const next = tmp.firstElementChild
+ if (next) pre.replaceWith(next)
+ })
+ .catch(() => {})
+ }
+}
diff --git a/packages/ui/src/codemirror/theme.ts b/packages/ui/src/codemirror/theme.ts
new file mode 100644
index 000000000000..ee3c773f8cf5
--- /dev/null
+++ b/packages/ui/src/codemirror/theme.ts
@@ -0,0 +1,298 @@
+import { EditorView } from "@codemirror/view"
+import { HighlightStyle, syntaxHighlighting } from "@codemirror/language"
+import { tags as t } from "@lezer/highlight"
+import type { Extension } from "@codemirror/state"
+
+export function ocEditorTheme(): Extension {
+ return [
+ EditorView.theme(
+ {
+ "&": {
+ color: "var(--text-base)",
+ backgroundColor: "var(--background-stronger, var(--background-base))",
+ fontFamily: "var(--font-family-mono)",
+ fontSize: "var(--font-size-small, 13px)",
+ height: "100%",
+ },
+ ".cm-content": {
+ caretColor: "var(--text-strong)",
+ fontFamily: "var(--font-family-mono)",
+ padding: "0",
+ lineHeight: "24px",
+ fontFeatureSettings: "var(--font-family-mono--font-feature-settings)",
+ },
+ ".cm-line": {
+ lineHeight: "24px",
+ padding: "0 1ch",
+ },
+ ".cm-cursor, .cm-dropCursor": {
+ borderLeftColor: "var(--text-strong)",
+ },
+ "&.cm-focused > .cm-scroller > .cm-selectionLayer .cm-selectionBackground, .cm-selectionBackground, .cm-content ::selection":
+ {
+ backgroundColor: "var(--surface-base-interactive-active, rgba(128,128,128,0.3))",
+ },
+ ".cm-activeLine": {
+ backgroundColor: "var(--surface-base, rgba(128,128,128,0.06))",
+ },
+ ".cm-activeLineGutter": {
+ backgroundColor: "var(--surface-base, rgba(128,128,128,0.06))",
+ },
+ ".cm-gutters": {
+ backgroundColor: "var(--background-stronger, var(--background-base))",
+ color: "var(--diffs-fg-number, var(--text-weak))",
+ border: "none",
+ fontFamily: "var(--font-family-mono)",
+ fontFeatureSettings: "var(--font-family-mono--font-feature-settings)",
+ },
+ ".cm-lineNumbers .cm-gutterElement": {
+ color: "color-mix(in lab, var(--text-base) 65%, var(--background-stronger))",
+ lineHeight: "24px",
+ // Measured px to match Pierre's fixed-px gutter at the app's single font size.
+ padding: "0 10.2px 0 31.2px",
+ textAlign: "right",
+ },
+ ".cm-foldPlaceholder": {
+ backgroundColor: "transparent",
+ border: "none",
+ color: "var(--text-weak)",
+ },
+ ".cm-tooltip": {
+ backgroundColor: "var(--surface-raised-stronger-non-alpha, var(--surface-float-base, var(--background-strong)))",
+ backgroundClip: "padding-box",
+ border: "1px solid color-mix(in oklch, var(--border-base) 50%, transparent)",
+ borderRadius: "var(--radius-md, 6px)",
+ color: "var(--text-strong)",
+ boxShadow: "var(--shadow-md, 0 4px 16px rgba(0,0,0,0.28))",
+ overflow: "hidden",
+ },
+ ".cm-tooltip.cm-tooltip-autocomplete": {
+ padding: "4px",
+ },
+ ".cm-tooltip.cm-tooltip-autocomplete > ul": {
+ fontFamily: "var(--font-family-sans, sans-serif)",
+ fontSize: "var(--font-size-small, 13px)",
+ fontWeight: "var(--font-weight-medium, 500)",
+ lineHeight: "var(--line-height-large, 1.6)",
+ letterSpacing: "var(--letter-spacing-normal, normal)",
+ maxHeight: "18em",
+ minWidth: "16em",
+ },
+ ".cm-tooltip-autocomplete > ul > li": {
+ display: "flex",
+ alignItems: "center",
+ gap: "8px",
+ padding: "4px 8px",
+ borderRadius: "var(--radius-sm, 4px)",
+ cursor: "default",
+ userSelect: "none",
+ color: "var(--text-strong)",
+ },
+ ".cm-tooltip-autocomplete > ul > li:hover": {
+ backgroundColor: "var(--surface-raised-base-hover, rgba(128,128,128,0.12))",
+ },
+ ".cm-tooltip-autocomplete > ul > li[aria-selected]": {
+ backgroundColor: "var(--surface-raised-base-hover, var(--surface-base-active, rgba(128,128,128,0.2)))",
+ color: "var(--text-strong)",
+ },
+ ".cm-completionLabel": { color: "var(--text-strong)", flex: "0 0 auto" },
+ ".cm-completionMatchedText": {
+ color: "var(--text-interactive-base, var(--syntax-keyword, var(--text-strong)))",
+ textDecoration: "none",
+ fontWeight: "var(--font-weight-medium, 600)",
+ },
+ ".cm-completionDetail": {
+ color: "var(--text-weak)",
+ fontStyle: "normal",
+ fontFamily: "var(--font-family-mono)",
+ marginLeft: "auto",
+ paddingLeft: "1.5em",
+ fontSize: "var(--font-size-x-small, 0.85em)",
+ whiteSpace: "nowrap",
+ overflow: "hidden",
+ textOverflow: "ellipsis",
+ },
+ ".cm-completionIcon": {
+ color: "var(--text-weak)",
+ display: "inline-flex",
+ alignItems: "center",
+ justifyContent: "center",
+ boxSizing: "border-box",
+ width: "1.35em",
+ height: "1.35em",
+ marginRight: "0",
+ padding: "0",
+ fontSize: "0.85em",
+ opacity: "1",
+ borderRadius: "var(--radius-sm, 4px)",
+ backgroundColor: "color-mix(in oklch, currentColor 16%, transparent)",
+ flex: "0 0 auto",
+ },
+ ".cm-completionIcon::after": { opacity: "1" },
+ ".cm-tooltip.cm-tooltip-lint": { padding: "0" },
+ ".cm-tooltip-lint .cm-diagnostic": {
+ padding: "8px 10px 8px 12px",
+ margin: "0",
+ borderLeft: "3px solid var(--border-base, transparent)",
+ fontFamily: "var(--font-family-sans, sans-serif)",
+ fontSize: "var(--font-size-small, 13px)",
+ lineHeight: "1.5",
+ color: "var(--text-strong)",
+ whiteSpace: "pre-wrap",
+ },
+ ".cm-tooltip-lint .cm-diagnostic-error": { borderLeftColor: "var(--syntax-critical, #e5484d)" },
+ ".cm-tooltip-lint .cm-diagnostic-warning": { borderLeftColor: "var(--syntax-warning, #f5a623)" },
+ ".cm-tooltip-lint .cm-diagnostic-info": { borderLeftColor: "var(--syntax-info, var(--text-weak))" },
+ ".cm-diagnosticSource": {
+ display: "block",
+ marginTop: "4px",
+ fontFamily: "var(--font-family-mono)",
+ fontSize: "var(--font-size-x-small, 0.8em)",
+ color: "var(--text-weak)",
+ opacity: "0.85",
+ },
+ ".cm-completionIcon-function, .cm-completionIcon-method": { color: "var(--syntax-object, var(--text-strong))" },
+ ".cm-completionIcon-class, .cm-completionIcon-interface, .cm-completionIcon-type, .cm-completionIcon-struct": {
+ color: "var(--syntax-type, var(--text-base))",
+ },
+ ".cm-completionIcon-keyword": { color: "var(--syntax-keyword, var(--text-base))" },
+ ".cm-completionIcon-variable, .cm-completionIcon-property, .cm-completionIcon-field": {
+ color: "var(--syntax-property, var(--text-base))",
+ },
+ ".cm-completionIcon-constant, .cm-completionIcon-enum": { color: "var(--syntax-constant, var(--text-base))" },
+ ".cm-completionInfo": {
+ backgroundColor: "var(--surface-raised-stronger-non-alpha, var(--surface-float-base, var(--background-strong)))",
+ border: "1px solid color-mix(in oklch, var(--border-base) 50%, transparent)",
+ borderRadius: "var(--radius-md, 6px)",
+ boxShadow: "var(--shadow-md, 0 4px 16px rgba(0,0,0,0.28))",
+ padding: "8px 10px",
+ maxWidth: "420px",
+ maxHeight: "320px",
+ overflow: "auto",
+ fontFamily: "var(--font-family-sans, sans-serif)",
+ fontSize: "var(--font-size-small, 13px)",
+ lineHeight: "1.5",
+ color: "var(--text-base)",
+ },
+ ".cm-completion-doc-signature": {
+ fontFamily: "var(--font-family-mono)",
+ fontSize: "0.95em",
+ color: "var(--text-strong)",
+ whiteSpace: "pre-wrap",
+ marginBottom: "6px",
+ paddingBottom: "6px",
+ borderBottom: "1px solid var(--border-weak-base, rgba(128,128,128,0.2))",
+ },
+ ".cm-completion-doc-signature:last-child": {
+ marginBottom: "0",
+ paddingBottom: "0",
+ borderBottom: "none",
+ },
+ ".cm-completion-doc-body": {
+ padding: "0",
+ maxWidth: "none",
+ maxHeight: "none",
+ overflow: "visible",
+ },
+ ".cm-tooltip-hover": { pointerEvents: "auto" },
+ ".cm-lsp-hover": {
+ padding: "8px 10px",
+ maxWidth: "560px",
+ maxHeight: "320px",
+ overflow: "auto",
+ fontFamily: "var(--font-family-sans, sans-serif)",
+ fontSize: "var(--font-size-small, 13px)",
+ lineHeight: "1.6",
+ color: "var(--text-strong)",
+ overflowWrap: "break-word",
+ userSelect: "text",
+ WebkitUserSelect: "text",
+ cursor: "auto",
+ },
+ ".cm-lsp-hover > *:first-child": { marginTop: "0" },
+ ".cm-lsp-hover > *:last-child": { marginBottom: "0" },
+ ".cm-lsp-hover p": { margin: "0 0 0.5em" },
+ ".cm-lsp-hover p:last-child": { margin: "0" },
+ ".cm-lsp-hover h1, .cm-lsp-hover h2, .cm-lsp-hover h3, .cm-lsp-hover h4, .cm-lsp-hover h5, .cm-lsp-hover h6":
+ {
+ fontSize: "1em",
+ fontWeight: "var(--font-weight-medium, 600)",
+ color: "var(--text-strong)",
+ margin: "0 0 0.4em",
+ },
+ ".cm-lsp-hover strong, .cm-lsp-hover b": {
+ color: "var(--text-strong)",
+ fontWeight: "var(--font-weight-medium, 600)",
+ },
+ ".cm-lsp-hover ul, .cm-lsp-hover ol": { margin: "0 0 0.5em", paddingLeft: "1.4em" },
+ ".cm-lsp-hover li": { margin: "0.1em 0" },
+ ".cm-lsp-hover pre": {
+ backgroundColor: "var(--surface-base, rgba(128,128,128,0.12))",
+ padding: "6px 8px",
+ borderRadius: "var(--radius-sm, 4px)",
+ overflow: "auto",
+ margin: "0 0 0.5em",
+ fontFamily: "var(--font-family-mono)",
+ fontSize: "0.95em",
+ lineHeight: "1.45",
+ },
+ ".cm-lsp-hover code": {
+ backgroundColor: "var(--surface-base, rgba(128,128,128,0.12))",
+ padding: "0.1em 0.3em",
+ borderRadius: "3px",
+ fontFamily: "var(--font-family-mono)",
+ fontSize: "0.95em",
+ color: "var(--text-strong)",
+ },
+ ".cm-lsp-hover pre code": { backgroundColor: "transparent", padding: "0", color: "inherit" },
+ ".cm-lsp-hover a": {
+ color: "var(--text-interactive-base, var(--syntax-info, var(--text-link)))",
+ textDecoration: "none",
+ },
+ ".cm-lsp-hover a:hover": { textDecoration: "underline", textUnderlineOffset: "2px" },
+ ".cm-lsp-hover hr": {
+ border: "none",
+ borderTop: "1px solid var(--border-weak-base, var(--border-base, rgba(128,128,128,0.3)))",
+ margin: "0.5em 0",
+ },
+ ".cm-searchMatch": {
+ backgroundColor: "var(--surface-base-active, rgba(128,128,128,0.25))",
+ },
+ ".cm-searchMatch.cm-searchMatch-selected": {
+ backgroundColor: "var(--surface-base-interactive-active, rgba(128,128,128,0.4))",
+ },
+ ".cm-selectionMatch": {
+ backgroundColor: "var(--surface-base, rgba(128,128,128,0.15))",
+ },
+ },
+ ),
+ // Lezer highlighting is intentionally NOT included here: it lives in its own
+ // compartment (`ocLezerHighlight`) as the fallback shiki replaces. Running
+ // both nests spans and the inner Lezer color wins, mis-coloring vs View.
+ ]
+}
+
+export function ocLezerHighlight(): Extension {
+ return syntaxHighlighting(ocHighlightStyle)
+}
+
+const ocHighlightStyle = HighlightStyle.define([
+ { tag: t.comment, color: "var(--syntax-comment)", fontStyle: "italic" },
+ { tag: [t.lineComment, t.blockComment, t.docComment], color: "var(--syntax-comment)", fontStyle: "italic" },
+ { tag: [t.string, t.special(t.string)], color: "var(--syntax-string)" },
+ { tag: t.regexp, color: "var(--syntax-regexp)" },
+ { tag: [t.keyword, t.modifier, t.controlKeyword, t.operatorKeyword], color: "var(--syntax-keyword)" },
+ { tag: [t.number, t.bool, t.null, t.atom], color: "var(--syntax-primitive)" },
+ { tag: [t.operator, t.compareOperator, t.logicOperator, t.arithmeticOperator], color: "var(--syntax-operator)" },
+ { tag: [t.variableName, t.self], color: "var(--syntax-variable)" },
+ { tag: [t.propertyName, t.attributeName], color: "var(--syntax-property)" },
+ { tag: [t.typeName, t.className, t.namespace], color: "var(--syntax-type)" },
+ { tag: [t.constant(t.variableName), t.standard(t.variableName)], color: "var(--syntax-constant)" },
+ { tag: [t.punctuation, t.separator, t.bracket, t.brace, t.paren], color: "var(--syntax-punctuation)" },
+ { tag: [t.function(t.variableName), t.function(t.propertyName)], color: "var(--syntax-object)" },
+ { tag: [t.definitionKeyword, t.definition(t.variableName)], color: "var(--syntax-variable)" },
+ { tag: t.invalid, color: "var(--syntax-critical)" },
+ { tag: [t.heading, t.strong], color: "var(--syntax-keyword)", fontWeight: "bold" },
+ { tag: [t.emphasis], fontStyle: "italic" },
+ { tag: [t.link, t.url], color: "var(--syntax-info)", textDecoration: "underline" },
+])
diff --git a/packages/ui/src/components/code-editor-lsp.test.ts b/packages/ui/src/components/code-editor-lsp.test.ts
new file mode 100644
index 000000000000..9d213daccaef
--- /dev/null
+++ b/packages/ui/src/components/code-editor-lsp.test.ts
@@ -0,0 +1,354 @@
+import { describe, test, expect, afterEach } from "bun:test"
+import { render } from "solid-js/web"
+import { EditorState, Text } from "@codemirror/state"
+import { EditorView } from "@codemirror/view"
+import { CompletionContext } from "@codemirror/autocomplete"
+import { CodeEditor } from "./code-editor"
+import {
+ offsetToPos,
+ posToOffset,
+ mapDiagnostics,
+ createCompletionSource,
+ normalizeCompletionItems,
+ hoverContentsToText,
+ severityToCM,
+ firstDefinition,
+ uriToPath,
+ lspExtensions,
+ type LspClient,
+ type LspExtensionsOptions,
+} from "./code-editor-lsp"
+
+const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms))
+
+function mockLsp(overrides: Partial = {}): LspClient {
+ return {
+ buffer: async () => true,
+ bufferClose: async () => true,
+ completion: async () => [],
+ hover: async () => null,
+ definition: async () => [],
+ diagnostics: async () => [],
+ ...overrides,
+ }
+}
+
+function baseOpts(over: Partial = {}): LspExtensionsOptions {
+ let v = 0
+ return {
+ path: "src/foo.ts",
+ bumpVersion: () => ++v,
+ lsp: mockLsp(),
+ onOpenLocation: () => {},
+ subscribeDiagnostics: () => () => {},
+ debounceMs: 10,
+ ...over,
+ }
+}
+
+describe("offset <-> position helpers", () => {
+ test("round-trips on a simple multi-line doc", () => {
+ const doc = Text.of(["hello", "world", "abc"])
+ for (let offset = 0; offset <= doc.length; offset++) {
+ const pos = offsetToPos(doc, offset)
+ expect(posToOffset(doc, pos)).toBe(offset)
+ }
+ })
+
+ test("handles multi-byte (UTF-16) characters", () => {
+ const doc = Text.of(["café", "naïve héllo"])
+ // 'é' is a single UTF-16 code unit; offsets are code-unit based.
+ expect(offsetToPos(doc, 4)).toEqual({ line: 0, character: 4 })
+ expect(posToOffset(doc, { line: 0, character: 4 })).toBe(4)
+ // start of second line
+ const secondLine = doc.line(2)
+ expect(offsetToPos(doc, secondLine.from)).toEqual({ line: 1, character: 0 })
+ })
+
+ test("CRLF: line breaks are not addressable, positions stay per-line", () => {
+ const doc = Text.of(["a", "b"]) // CM normalizes content; line break length is 1 here
+ expect(offsetToPos(doc, 0)).toEqual({ line: 0, character: 0 })
+ expect(offsetToPos(doc, 2)).toEqual({ line: 1, character: 0 })
+ })
+
+ test("clamps out-of-range input", () => {
+ const doc = Text.of(["ab"])
+ expect(offsetToPos(doc, 999)).toEqual({ line: 0, character: 2 })
+ expect(posToOffset(doc, { line: 99, character: 99 })).toBe(2)
+ })
+})
+
+describe("severity mapping", () => {
+ test("maps 1..4", () => {
+ expect(severityToCM(1)).toBe("error")
+ expect(severityToCM(2)).toBe("warning")
+ expect(severityToCM(3)).toBe("info")
+ expect(severityToCM(4)).toBe("info")
+ expect(severityToCM(undefined)).toBe("error")
+ })
+})
+
+describe("mapDiagnostics", () => {
+ test("maps ranges to CM offsets", () => {
+ const doc = Text.of(["const x = 1", "let y = 2"])
+ const cm = mapDiagnostics(doc, [
+ {
+ range: { start: { line: 0, character: 6 }, end: { line: 0, character: 7 } },
+ severity: 1,
+ message: "oops",
+ source: "ts",
+ },
+ ])
+ expect(cm).toHaveLength(1)
+ expect(cm[0]).toMatchObject({ from: 6, to: 7, severity: "error", message: "oops", source: "ts" })
+ })
+
+ test("swaps reversed ranges and skips rangeless entries", () => {
+ const doc = Text.of(["abcdef"])
+ const cm = mapDiagnostics(doc, [
+ { range: { start: { line: 0, character: 4 }, end: { line: 0, character: 2 } }, message: "rev" },
+ { message: "norange" } as any,
+ ])
+ expect(cm).toHaveLength(1)
+ expect(cm[0]).toMatchObject({ from: 2, to: 4 })
+ })
+})
+
+describe("completion source", () => {
+ function makeContext(docLines: string[], pos: number, explicit = true) {
+ const state = EditorState.create({ doc: docLines.join("\n") })
+ return new CompletionContext(state, pos, explicit)
+ }
+
+ // Helper: the per-client array shape (real wire format) wrapping a CompletionList.
+ const bump = () => 1
+
+ test("calls lsp.completion and maps a bare CompletionItem[]", async () => {
+ let received: any
+ const source = createCompletionSource({
+ path: "src/foo.ts",
+ bumpVersion: bump,
+ lsp: mockLsp({
+ completion: async (input) => {
+ received = input
+ return [
+ { label: "foo", detail: "fn foo", insertText: "foo()", kind: 3 },
+ { label: "bar" },
+ ]
+ },
+ }),
+ })
+ const ctx = makeContext(["fo"], 2)
+ const result = await source(ctx)
+ expect(received).toMatchObject({ path: "src/foo.ts", line: 0, character: 2 })
+ expect(result).not.toBeNull()
+ expect(result!.options).toHaveLength(2)
+ expect(result!.options[0]).toMatchObject({ label: "foo", detail: "fn foo", apply: "foo()" })
+ expect(result!.options[1]).toMatchObject({ label: "bar", apply: "bar" })
+ })
+
+ test("handles the per-client array shape [{isIncomplete,items}]", async () => {
+ const source = createCompletionSource({
+ path: "x.ts",
+ bumpVersion: bump,
+ lsp: mockLsp({
+ completion: async () => [{ isIncomplete: false, items: [{ label: "baz" }, { label: "qux" }] }] as any,
+ }),
+ })
+ const result = await source(makeContext(["b"], 1))
+ expect(result!.options.map((o) => o.label)).toEqual(["baz", "qux"])
+ })
+
+ test("flattens items across multiple per-client CompletionLists", async () => {
+ const source = createCompletionSource({
+ path: "x.py",
+ bumpVersion: bump,
+ lsp: mockLsp({
+ completion: async () =>
+ [
+ { isIncomplete: false, items: [{ label: "pyright1" }] },
+ { isIncomplete: false, items: [{ label: "ty1" }] },
+ ] as any,
+ }),
+ })
+ const result = await source(makeContext(["b"], 1))
+ expect(result!.options.map((o) => o.label)).toEqual(["pyright1", "ty1"])
+ })
+
+ test("handles a bare CompletionList {items} shape", async () => {
+ const source = createCompletionSource({
+ path: "x.ts",
+ bumpVersion: bump,
+ lsp: mockLsp({ completion: async () => ({ items: [{ label: "baz" }] }) as any }),
+ })
+ const result = await source(makeContext(["b"], 1))
+ expect(result!.options[0].label).toBe("baz")
+ })
+
+ test("returns null when no items", async () => {
+ const source = createCompletionSource({
+ path: "x.ts",
+ bumpVersion: bump,
+ lsp: mockLsp({ completion: async () => [] }),
+ })
+ expect(await source(makeContext(["b"], 1))).toBeNull()
+ })
+
+ test("flushes the buffer (awaited) BEFORE calling lsp.completion", async () => {
+ const calls: string[] = []
+ let bufferResolved = false
+ const source = createCompletionSource({
+ path: "src/foo.ts",
+ bumpVersion: () => 7,
+ lsp: mockLsp({
+ buffer: async (input) => {
+ calls.push("buffer")
+ expect(input).toMatchObject({ path: "src/foo.ts", version: 7 })
+ await sleep(5)
+ bufferResolved = true
+ return true
+ },
+ completion: async () => {
+ // completion must only run after buffer has fully resolved
+ expect(bufferResolved).toBe(true)
+ calls.push("completion")
+ return [{ label: "ok" }]
+ },
+ }),
+ })
+ const result = await source(makeContext(["fo.x"], 4))
+ expect(calls).toEqual(["buffer", "completion"])
+ expect(result!.options[0].label).toBe("ok")
+ })
+
+ test("normalizeCompletionItems handles all shapes and garbage", () => {
+ // bare CompletionItem[]
+ expect(normalizeCompletionItems([{ label: "a" }])).toHaveLength(1)
+ // bare CompletionList
+ expect(normalizeCompletionItems({ items: [{ label: "a" }] })).toHaveLength(1)
+ // per-client array wrapping a CompletionList
+ expect(normalizeCompletionItems([{ isIncomplete: false, items: [{ label: "a" }, { label: "b" }] }])).toHaveLength(2)
+ // per-client array wrapping bare CompletionItem[] per client
+ expect(normalizeCompletionItems([[{ label: "a" }], [{ label: "b" }]])).toHaveLength(2)
+ expect(normalizeCompletionItems(null)).toHaveLength(0)
+ expect(normalizeCompletionItems("garbage")).toHaveLength(0)
+ })
+})
+
+describe("hover contents", () => {
+ test("string", () => {
+ expect(hoverContentsToText("hi")).toBe("hi")
+ })
+ test("MarkupContent", () => {
+ expect(hoverContentsToText({ kind: "markdown", value: "**x**" })).toBe("**x**")
+ })
+ test("array", () => {
+ expect(hoverContentsToText(["a", { value: "b" }])).toBe("a\n\nb")
+ })
+})
+
+describe("definition", () => {
+ test("uriToPath strips file:// and decodes", () => {
+ expect(uriToPath("file:///home/u/my%20file.ts")).toBe("/home/u/my file.ts")
+ expect(uriToPath("src/rel.ts")).toBe("src/rel.ts")
+ })
+ test("firstDefinition takes first Location", () => {
+ const out = firstDefinition([
+ { uri: "file:///a.ts", range: { start: { line: 2, character: 3 }, end: { line: 2, character: 4 } } },
+ ])
+ expect(out).toEqual({ path: "/a.ts", pos: { line: 2, character: 3 } })
+ })
+ test("firstDefinition handles LocationLink", () => {
+ const out = firstDefinition({
+ targetUri: "file:///b.ts",
+ targetSelectionRange: { start: { line: 1, character: 0 }, end: { line: 1, character: 1 } },
+ })
+ expect(out).toEqual({ path: "/b.ts", pos: { line: 1, character: 0 } })
+ })
+ test("firstDefinition returns undefined for empty", () => {
+ expect(firstDefinition([])).toBeUndefined()
+ })
+})
+
+// --- Integration against a live EditorView (buffer sync + teardown) ---------
+
+const cleanups: Array<() => void> = []
+afterEach(() => {
+ while (cleanups.length) cleanups.pop()!()
+})
+
+function mountEditor(opts: LspExtensionsOptions, value = "hello") {
+ const host = document.createElement("div")
+ document.body.appendChild(host)
+ const dispose = render(() => CodeEditor({ value, path: opts.path, extensions: lspExtensions(opts) }), host)
+ cleanups.push(() => {
+ dispose()
+ host.remove()
+ })
+ const view = EditorView.findFromDOM(host.querySelector("[data-component=code-editor]") as HTMLElement)!
+ return { host, dispose, view }
+}
+
+describe("buffer sync integration", () => {
+ test("opens buffer on mount and debounces didChange with incremented version", async () => {
+ const versions: number[] = []
+ const opts = baseOpts({
+ lsp: mockLsp({
+ buffer: async (input) => {
+ versions.push(input.version)
+ return true
+ },
+ }),
+ })
+ const { view } = mountEditor(opts)
+ await sleep(20)
+ expect(versions.length).toBeGreaterThanOrEqual(1) // initial open
+ const afterOpen = versions.length
+
+ view.dispatch({ changes: { from: view.state.doc.length, insert: "!" } })
+ await sleep(40)
+ expect(versions.length).toBe(afterOpen + 1)
+ expect(versions[versions.length - 1]).toBeGreaterThan(versions[0]!)
+ })
+
+ test("applies pushed diagnostics via subscribeDiagnostics", async () => {
+ let emit: ((list: any) => void) | undefined
+ const opts = baseOpts({
+ subscribeDiagnostics: (_path, cb) => {
+ emit = cb
+ return () => {}
+ },
+ })
+ const { view } = mountEditor(opts, "const x = 1")
+ await sleep(20)
+ emit!([
+ { range: { start: { line: 0, character: 6 }, end: { line: 0, character: 7 } }, severity: 1, message: "bad" },
+ ])
+ await sleep(5)
+ // setDiagnostics adds a lint state field; assert no throw and view alive.
+ expect(view.state.doc.toString()).toBe("const x = 1")
+ })
+
+ test("teardown unsubscribes and closes buffer", async () => {
+ let unsubscribed = false
+ let closed = false
+ const opts = baseOpts({
+ subscribeDiagnostics: () => () => {
+ unsubscribed = true
+ },
+ lsp: mockLsp({
+ bufferClose: async () => {
+ closed = true
+ return true
+ },
+ }),
+ })
+ const { dispose } = mountEditor(opts)
+ await sleep(20)
+ dispose()
+ cleanups.pop() // already disposed
+ await sleep(5)
+ expect(unsubscribed).toBe(true)
+ expect(closed).toBe(true)
+ })
+})
diff --git a/packages/ui/src/components/code-editor-lsp.ts b/packages/ui/src/components/code-editor-lsp.ts
new file mode 100644
index 000000000000..fc87bbed0b94
--- /dev/null
+++ b/packages/ui/src/components/code-editor-lsp.ts
@@ -0,0 +1,455 @@
+import type { Extension } from "@codemirror/state"
+import { EditorView, ViewPlugin, hoverTooltip, keymap, type ViewUpdate } from "@codemirror/view"
+import type { Text } from "@codemirror/state"
+import {
+ autocompletion,
+ type Completion,
+ type CompletionContext,
+ type CompletionResult,
+} from "@codemirror/autocomplete"
+import { setDiagnostics, type Diagnostic as CMDiagnostic } from "@codemirror/lint"
+import { Marked } from "marked"
+import { highlightMarkdownCodeBlocks } from "../codemirror/shiki-highlight"
+
+// Isolated marked instance: the global singleton has async extensions that make
+// synchronous `parse(..., { async: false })` throw.
+const hoverMarked = new Marked({ gfm: true, breaks: true })
+// `external-link` anchors so the desktop renderer opens links in the OS browser
+// instead of navigating (and tearing down) the app window.
+hoverMarked.use({
+ renderer: {
+ link({ href, title, text }: { href: string; title?: string | null; text: string }) {
+ const titleAttr = title ? ` title="${title}"` : ""
+ return `${text}`
+ },
+ },
+})
+
+/** LSP-style 0-based position in UTF-16 code units. */
+export type LspPosition = { line: number; character: number }
+
+export type LspRange = { start: LspPosition; end: LspPosition }
+
+export type LspDiagnostic = {
+ range: LspRange
+ severity?: number | string
+ message: string
+ source?: string
+ code?: string | number
+}
+
+export interface LspClient {
+ buffer(input: { path: string; text: string; version: number }): Promise
+ bufferClose(input: { path: string }): Promise
+ completion(input: {
+ path: string
+ line: number
+ character: number
+ triggerKind?: number
+ triggerCharacter?: string
+ }): Promise
+ hover(input: { path: string; line: number; character: number }): Promise
+ definition(input: { path: string; line: number; character: number }): Promise
+ diagnostics(input: { path: string }): Promise
+}
+
+export type LspExtensionsOptions = {
+ path: string
+ bumpVersion: () => number
+ lsp: LspClient
+ onOpenLocation: (path: string, pos: LspPosition) => void
+ subscribeDiagnostics: (path: string, cb: (list: LspDiagnostic[]) => void) => () => void
+ debounceMs?: number
+}
+
+// LSP positions are 0-based {line, character} in UTF-16 code units; CM lines are
+// 1-based. JS strings are UTF-16, so a char index into line text is the unit count.
+export function offsetToPos(doc: Text, offset: number): LspPosition {
+ const clamped = Math.max(0, Math.min(offset, doc.length))
+ const line = doc.lineAt(clamped)
+ return { line: line.number - 1, character: clamped - line.from }
+}
+
+export function posToOffset(doc: Text, pos: LspPosition): number {
+ const lineNumber = Math.max(1, Math.min(pos.line + 1, doc.lines))
+ const line = doc.line(lineNumber)
+ const character = Math.max(0, Math.min(pos.character, line.length))
+ return line.from + character
+}
+
+function toNumber(value: unknown): number | undefined {
+ if (typeof value === "number" && Number.isFinite(value)) return value
+ if (typeof value === "string") {
+ const n = Number(value)
+ if (Number.isFinite(n)) return n
+ }
+ return undefined
+}
+
+export function severityToCM(severity: number | string | undefined): CMDiagnostic["severity"] {
+ switch (toNumber(severity)) {
+ case 1:
+ return "error"
+ case 2:
+ return "warning"
+ case 3:
+ return "info"
+ case 4:
+ return "info"
+ default:
+ return "error"
+ }
+}
+
+export function mapDiagnostics(doc: Text, list: LspDiagnostic[]): CMDiagnostic[] {
+ const out: CMDiagnostic[] = []
+ for (const d of list ?? []) {
+ if (!d?.range) continue
+ let from = posToOffset(doc, d.range.start)
+ let to = posToOffset(doc, d.range.end)
+ if (to < from) [from, to] = [to, from]
+ out.push({
+ from,
+ to,
+ severity: severityToCM(d.severity),
+ message: d.message ?? "",
+ ...(d.source ? { source: d.source } : {}),
+ })
+ }
+ return out
+}
+
+const COMPLETION_KIND_LABELS: Record = {
+ 1: "text",
+ 2: "method",
+ 3: "function",
+ 4: "constructor",
+ 5: "variable",
+ 6: "variable",
+ 7: "class",
+ 8: "interface",
+ 9: "namespace",
+ 10: "property",
+ 11: "constant",
+ 12: "enum",
+ 13: "enum",
+ 14: "keyword",
+ 15: "text",
+ 21: "constant",
+ 22: "type",
+ 25: "type",
+}
+
+type RawCompletionItem = {
+ label: string
+ detail?: string
+ insertText?: string
+ kind?: number
+ textEdit?: { newText?: string }
+ sortText?: string
+ documentation?: string | { value?: string; kind?: string }
+}
+
+export function documentationToText(doc: RawCompletionItem["documentation"]): string {
+ if (doc == null) return ""
+ if (typeof doc === "string") return doc
+ return typeof doc.value === "string" ? doc.value : ""
+}
+
+function renderCompletionInfo(item: RawCompletionItem): Node | null {
+ const docText = documentationToText(item.documentation)
+ if (!docText && !item.detail) return null
+ const dom = document.createElement("div")
+ dom.className = "cm-completion-doc"
+ if (item.detail) {
+ const sig = document.createElement("div")
+ sig.className = "cm-completion-doc-signature"
+ sig.textContent = item.detail
+ dom.appendChild(sig)
+ }
+ if (docText) {
+ const body = document.createElement("div")
+ body.className = "cm-completion-doc-body cm-lsp-hover"
+ try {
+ body.innerHTML = hoverMarked.parse(docText, { async: false }) as string
+ highlightMarkdownCodeBlocks(body)
+ } catch {
+ body.textContent = docText
+ }
+ dom.appendChild(body)
+ }
+ return dom
+}
+
+function itemsFromCompletionEntry(entry: unknown): RawCompletionItem[] {
+ if (!entry) return []
+ if (Array.isArray(entry)) return entry as RawCompletionItem[]
+ const items = (entry as { items?: unknown }).items
+ if (Array.isArray(items)) return items as RawCompletionItem[]
+ return []
+}
+
+// The LSP service returns one entry per attached client; each entry is a
+// CompletionList or CompletionItem[]. A bare single result is also accepted.
+export function normalizeCompletionItems(result: unknown): RawCompletionItem[] {
+ if (!result) return []
+ if (Array.isArray(result)) {
+ // Distinguish the per-client array from a bare CompletionItem[] by whether
+ // every entry has a string `label`.
+ const arr = result as unknown[]
+ const looksLikeItems = arr.every(
+ (e) => e != null && typeof e === "object" && typeof (e as { label?: unknown }).label === "string",
+ )
+ if (looksLikeItems) return arr as RawCompletionItem[]
+ return arr.flatMap((entry) => itemsFromCompletionEntry(entry))
+ }
+ return itemsFromCompletionEntry(result)
+}
+
+function mapCompletionItem(item: RawCompletionItem): Completion {
+ const insert = item.insertText ?? item.textEdit?.newText ?? item.label
+ const hasInfo = !!documentationToText(item.documentation) || !!item.detail
+ return {
+ label: item.label,
+ apply: insert,
+ ...(item.detail ? { detail: item.detail } : {}),
+ ...(item.kind && COMPLETION_KIND_LABELS[item.kind] ? { type: COMPLETION_KIND_LABELS[item.kind] } : {}),
+ ...(hasInfo ? { info: () => renderCompletionInfo(item) } : {}),
+ }
+}
+
+export function createCompletionSource(opts: Pick) {
+ return async (ctx: CompletionContext): Promise => {
+ const word = ctx.matchBefore(/[\w$]*/)
+ const triggerCharacter = ctx.pos > 0 ? ctx.state.doc.sliceString(ctx.pos - 1, ctx.pos) : undefined
+ const isWordTrigger = word && (word.from !== word.to || ctx.explicit)
+ const isCharTrigger = triggerCharacter ? /[.\-:>@/]/.test(triggerCharacter) : false
+ if (!ctx.explicit && !isWordTrigger && !isCharTrigger) return null
+
+ const { line, character } = offsetToPos(ctx.state.doc, ctx.pos)
+
+ // Flush the current document before requesting completion; the buffer-sync
+ // plugin is debounced, so the server would otherwise answer on stale text.
+ try {
+ await opts.lsp.buffer({ path: opts.path, text: ctx.state.doc.toString(), version: opts.bumpVersion() })
+ } catch {}
+
+ let result: unknown
+ try {
+ result = await opts.lsp.completion({
+ path: opts.path,
+ line,
+ character,
+ triggerKind: ctx.explicit ? 1 : isCharTrigger ? 2 : 1,
+ ...(isCharTrigger && triggerCharacter ? { triggerCharacter } : {}),
+ })
+ } catch {
+ return null
+ }
+ if (ctx.aborted) return null
+
+ const items = normalizeCompletionItems(result)
+ if (items.length === 0) return null
+
+ return {
+ from: word ? word.from : ctx.pos,
+ options: items.map(mapCompletionItem),
+ validFor: /^[\w$]*$/,
+ }
+ }
+}
+
+type HoverContents = string | { value?: string; kind?: string } | Array
+
+export function hoverContentsToText(contents: unknown): string {
+ if (contents == null) return ""
+ if (typeof contents === "string") return contents
+ if (Array.isArray(contents)) {
+ return contents.map((c) => hoverContentsToText(c)).filter(Boolean).join("\n\n")
+ }
+ const obj = contents as { value?: unknown }
+ if (typeof obj.value === "string") return obj.value
+ return ""
+}
+
+export function extractHoverContents(hover: unknown): HoverContents | undefined {
+ if (!hover) return undefined
+ const entries = Array.isArray(hover) ? hover : [hover]
+ for (const entry of entries) {
+ const contents = (entry as { contents?: HoverContents })?.contents
+ if (contents != null && hoverContentsToText(contents)) return contents
+ }
+ return undefined
+}
+
+type RawLocation = {
+ uri?: string
+ targetUri?: string
+ range?: LspRange
+ targetSelectionRange?: LspRange
+ targetRange?: LspRange
+}
+
+export function uriToPath(uri: string): string {
+ if (!uri) return uri
+ if (uri.startsWith("file://")) {
+ const body = uri.slice("file://".length)
+ try {
+ return decodeURIComponent(body)
+ } catch {
+ return body
+ }
+ }
+ return uri
+}
+
+function locationFromEntry(loc: RawLocation | undefined): { path: string; pos: LspPosition } | undefined {
+ if (!loc) return undefined
+ const uri = loc.uri ?? loc.targetUri
+ const range = loc.range ?? loc.targetSelectionRange ?? loc.targetRange
+ if (!uri || !range) return undefined
+ return { path: uriToPath(uri), pos: range.start }
+}
+
+// Wire shape is Array (one entry per
+// client); flatten and return the first entry with a usable uri + range.
+export function firstDefinition(result: unknown): { path: string; pos: LspPosition } | undefined {
+ const top = Array.isArray(result) ? result : result ? [result] : []
+ for (const entry of top) {
+ const inner = Array.isArray(entry) ? entry : [entry]
+ for (const loc of inner) {
+ const found = locationFromEntry(loc as RawLocation | undefined)
+ if (found) return found
+ }
+ }
+ return undefined
+}
+
+function createBufferSyncPlugin(opts: LspExtensionsOptions) {
+ const debounceMs = opts.debounceMs ?? 150
+
+ return ViewPlugin.define((view) => {
+ let timer: ReturnType | undefined
+ let unsubscribe: (() => void) | undefined
+
+ const pushBuffer = () => {
+ const text = view.state.doc.toString()
+ void opts.lsp.buffer({ path: opts.path, text, version: opts.bumpVersion() }).catch(() => {})
+ }
+
+ const applyDiagnostics = (list: LspDiagnostic[]) => {
+ view.dispatch(setDiagnostics(view.state, mapDiagnostics(view.state.doc, list)))
+ }
+
+ pushBuffer()
+ void opts.lsp
+ .diagnostics({ path: opts.path })
+ .then((list) => applyDiagnostics((list as LspDiagnostic[]) ?? []))
+ .catch(() => {})
+
+ unsubscribe = opts.subscribeDiagnostics(opts.path, (list) => applyDiagnostics(list ?? []))
+
+ return {
+ update(update: ViewUpdate) {
+ if (!update.docChanged) return
+ if (timer) clearTimeout(timer)
+ timer = setTimeout(() => {
+ timer = undefined
+ pushBuffer()
+ }, debounceMs)
+ },
+ destroy() {
+ if (timer) clearTimeout(timer)
+ // Unsubscribe before closing the buffer so no diagnostics arrive after close.
+ unsubscribe?.()
+ void opts.lsp.bufferClose({ path: opts.path }).catch(() => {})
+ },
+ }
+ })
+}
+
+function normalizePathForCompare(p: string): string {
+ return p.replace(/^\.?\//, "")
+}
+
+function goToDefinition(view: EditorView, pos: number, opts: LspExtensionsOptions) {
+ const { line, character } = offsetToPos(view.state.doc, pos)
+ void opts.lsp
+ .definition({ path: opts.path, line, character })
+ .then((result) => {
+ const target = firstDefinition(result)
+ if (!target) return
+ if (normalizePathForCompare(target.path) === normalizePathForCompare(opts.path)) {
+ const offset = posToOffset(view.state.doc, target.pos)
+ view.dispatch({ selection: { anchor: offset }, scrollIntoView: true })
+ view.focus()
+ return
+ }
+ opts.onOpenLocation(target.path, target.pos)
+ })
+ .catch(() => {})
+}
+
+export function lspExtensions(opts: LspExtensionsOptions): Extension[] {
+ const completionExt = autocompletion({
+ override: [createCompletionSource(opts)],
+ icons: true,
+ tooltipClass: () => "cm-oc-autocomplete",
+ })
+
+ const hoverExt = hoverTooltip(async (view, pos) => {
+ const { line, character } = offsetToPos(view.state.doc, pos)
+ let hover: unknown
+ try {
+ hover = await opts.lsp.hover({ path: opts.path, line, character })
+ } catch {
+ return null
+ }
+ const contents = extractHoverContents(hover)
+ const text = hoverContentsToText(contents)
+ if (!text) return null
+ return {
+ pos,
+ create: () => {
+ const dom = document.createElement("div")
+ dom.className = "cm-lsp-hover"
+ try {
+ dom.innerHTML = hoverMarked.parse(text, { async: false }) as string
+ highlightMarkdownCodeBlocks(dom)
+ } catch {
+ dom.textContent = text
+ }
+ return { dom }
+ },
+ }
+ })
+
+ const definitionKeymap = keymap.of([
+ {
+ key: "F12",
+ run: (view) => {
+ goToDefinition(view, view.state.selection.main.head, opts)
+ return true
+ },
+ },
+ ])
+
+ const modClick = EditorView.domEventHandlers({
+ mousedown(event, view) {
+ if (!(event.metaKey || event.ctrlKey)) return false
+ const pos = view.posAtCoords({ x: event.clientX, y: event.clientY })
+ if (pos == null) return false
+ event.preventDefault()
+ goToDefinition(view, pos, opts)
+ return true
+ },
+ })
+
+ return [
+ createBufferSyncPlugin(opts),
+ completionExt,
+ hoverExt,
+ definitionKeymap,
+ modClick,
+ ]
+}
diff --git a/packages/ui/src/components/code-editor.test.tsx b/packages/ui/src/components/code-editor.test.tsx
new file mode 100644
index 000000000000..330ce9a14ee6
--- /dev/null
+++ b/packages/ui/src/components/code-editor.test.tsx
@@ -0,0 +1,115 @@
+// happy-dom has no layout/measurement, so edits are driven through the EditorView
+// transaction API rather than synthesized key events (which CM can't route here).
+import { describe, test, expect, afterEach } from "bun:test"
+import { createSignal } from "solid-js"
+import { render } from "solid-js/web"
+import { EditorView } from "@codemirror/view"
+import { CodeEditor } from "./code-editor"
+
+function mount(ui: () => any) {
+ const host = document.createElement("div")
+ document.body.appendChild(host)
+ const dispose = render(ui, host)
+ const view = EditorView.findFromDOM(host.querySelector("[data-component=code-editor]") as HTMLElement)
+ return { host, dispose, view: view! }
+}
+
+const cleanups: Array<() => void> = []
+afterEach(() => {
+ while (cleanups.length) cleanups.pop()!()
+})
+
+const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms))
+
+describe("CodeEditor", () => {
+ test("mounts and renders the initial value", () => {
+ const { host, dispose, view } = mount(() => )
+ cleanups.push(dispose)
+ expect(view).toBeDefined()
+ expect(view.state.doc.toString()).toBe("const a = 1")
+ expect(host.textContent).toContain("const a = 1")
+ })
+
+ test("typing dispatches a debounced onChange with the new text", async () => {
+ let changed: string | undefined
+ const { dispose, view } = mount(() => (
+ (changed = v)} />
+ ))
+ cleanups.push(dispose)
+
+ // Simulate user input via a CM transaction (origin = user input).
+ view.dispatch({
+ changes: { from: view.state.doc.length, insert: " world" },
+ userEvent: "input.type",
+ })
+
+ // Debounced (~200ms): not emitted synchronously.
+ expect(changed).toBeUndefined()
+ await sleep(300)
+ expect(changed).toBe("hello world")
+ })
+
+ test("readOnly blocks edits", () => {
+ const { dispose, view } = mount(() => )
+ cleanups.push(dispose)
+ expect(view.state.readOnly).toBe(true)
+
+ // A change transaction is rejected because the document is read-only-aware:
+ // we verify the state filter prevents user input from mutating the doc.
+ view.dispatch({ changes: { from: 0, insert: "x" }, userEvent: "input.type" })
+ // readOnly only blocks *editable*/user paths; the explicit filter does not
+ // forbid programmatic dispatch, so we assert the configured flags instead.
+ expect(view.state.readOnly).toBe(true)
+ expect(view.contentDOM.contentEditable).not.toBe("true")
+ })
+
+ test("external value change updates doc without spurious onChange", async () => {
+ const [value, setValue] = createSignal("first")
+ let changed: string | undefined
+ let calls = 0
+ const { dispose, view } = mount(() => (
+ {
+ changed = v
+ calls++
+ }}
+ />
+ ))
+ cleanups.push(dispose)
+ expect(view.state.doc.toString()).toBe("first")
+
+ setValue("second")
+ expect(view.state.doc.toString()).toBe("second")
+
+ await sleep(300)
+ // The external sync transaction is annotated, so it must NOT echo onChange.
+ expect(calls).toBe(0)
+ expect(changed).toBeUndefined()
+ })
+
+ test("language compartment swaps when language changes", () => {
+ const [lang, setLang] = createSignal<"typescript" | "python">("typescript")
+ const { dispose, view } = mount(() => )
+ cleanups.push(dispose)
+
+ // The language facet contributes parser/highlight config; capture the
+ // language data facet before and after the swap.
+ const before = view.state.languageDataAt("", 0)
+ setLang("python")
+ const after = view.state.languageDataAt("", 0)
+ // The doc is preserved across the reconfiguration.
+ expect(view.state.doc.toString()).toBe("x = 1")
+ // Both states resolve language data without throwing (the compartment
+ // reconfigured the active language extension).
+ expect(Array.isArray(before)).toBe(true)
+ expect(Array.isArray(after)).toBe(true)
+ })
+
+ test("path-derived language: .go maps without throwing", () => {
+ const { dispose, view } = mount(() => )
+ cleanups.push(dispose)
+ expect(view.state.doc.toString()).toBe("package main")
+ })
+})
diff --git a/packages/ui/src/components/code-editor.tsx b/packages/ui/src/components/code-editor.tsx
new file mode 100644
index 000000000000..d32debcf00db
--- /dev/null
+++ b/packages/ui/src/components/code-editor.tsx
@@ -0,0 +1,267 @@
+import { createEffect, onCleanup, onMount } from "solid-js"
+import { Annotation, Compartment, EditorState, type Extension } from "@codemirror/state"
+import {
+ EditorView,
+ drawSelection,
+ dropCursor,
+ highlightActiveLine,
+ highlightActiveLineGutter,
+ highlightSpecialChars,
+ keymap,
+ lineNumbers,
+ rectangularSelection,
+} from "@codemirror/view"
+import {
+ defaultKeymap,
+ history,
+ historyKeymap,
+ indentWithTab,
+} from "@codemirror/commands"
+import {
+ bracketMatching,
+ foldKeymap,
+ indentOnInput,
+ indentUnit,
+} from "@codemirror/language"
+import { closeBrackets, closeBracketsKeymap } from "@codemirror/autocomplete"
+import { searchKeymap } from "@codemirror/search"
+import { javascript } from "@codemirror/lang-javascript"
+import { go } from "@codemirror/lang-go"
+import { python } from "@codemirror/lang-python"
+import { useTheme } from "../theme/context"
+import { ocEditorTheme, ocLezerHighlight } from "../codemirror/theme"
+import { editorLanguageToShikiLang, loadShikiForLang, shikiHighlightExtension } from "../codemirror/shiki-highlight"
+
+export type CodeEditorLanguage = "typescript" | "go" | "python" | "plaintext"
+
+export type CodeEditorProps = {
+ value: string
+ path?: string
+ language?: CodeEditorLanguage
+ readOnly?: boolean
+ onChange?: (value: string) => void
+ onSaveRequested?: () => void
+ extensions?: Extension[]
+ initialSelection?: { line: number; character: number }
+ class?: string
+}
+
+// Marks a transaction as an external `value` sync so the update listener does
+// not echo it back through `onChange`.
+const externalSync = Annotation.define()
+
+function languageExtensionFor(language: CodeEditorLanguage): Extension | null {
+ switch (language) {
+ case "typescript":
+ return javascript({ typescript: true, jsx: true })
+ case "go":
+ return go()
+ case "python":
+ return python()
+ case "plaintext":
+ return null
+ }
+}
+
+function languageExtensionForPath(path: string): Extension | null {
+ const lower = path.toLowerCase()
+ const ext = lower.slice(lower.lastIndexOf("."))
+ switch (ext) {
+ case ".ts":
+ case ".tsx":
+ return javascript({ typescript: true, jsx: true })
+ case ".js":
+ case ".jsx":
+ case ".mjs":
+ case ".cjs":
+ return javascript({ jsx: true })
+ case ".go":
+ return go()
+ case ".py":
+ case ".pyi":
+ return python()
+ default:
+ return null
+ }
+}
+
+function resolveLanguage(props: CodeEditorProps): Extension | null {
+ if (props.language) return languageExtensionFor(props.language)
+ if (props.path) return languageExtensionForPath(props.path)
+ return null
+}
+
+export function CodeEditor(props: CodeEditorProps) {
+ let container!: HTMLDivElement
+ let view: EditorView | undefined
+
+ const theme = (() => {
+ try {
+ return useTheme()
+ } catch {
+ return undefined
+ }
+ })()
+
+ const languageCompartment = new Compartment()
+ const themeCompartment = new Compartment()
+ const readOnlyCompartment = new Compartment()
+ const shikiCompartment = new Compartment()
+ // Monotonic token so a stale async shiki load can't clobber a newer one.
+ let shikiLoadToken = 0
+
+ let changeTimer: ReturnType | undefined
+ function scheduleOnChange(value: string) {
+ if (changeTimer) clearTimeout(changeTimer)
+ changeTimer = setTimeout(() => {
+ changeTimer = undefined
+ props.onChange?.(value)
+ }, 200)
+ }
+
+ function readOnlyExtension(readOnly: boolean): Extension {
+ return [EditorState.readOnly.of(readOnly), EditorView.editable.of(!readOnly)]
+ }
+
+ onMount(() => {
+ const updateListener = EditorView.updateListener.of((u) => {
+ if (!u.docChanged) return
+ if (u.transactions.some((tr) => tr.annotation(externalSync))) return
+ scheduleOnChange(u.state.doc.toString())
+ })
+
+ const saveKeymap = keymap.of([
+ {
+ // Stops the browser's native save dialog while focused; the
+ // authoritative save keybind is registered app-side.
+ key: "Mod-s",
+ run: () => {
+ props.onSaveRequested?.()
+ return true
+ },
+ },
+ ])
+
+ const baseExtensions: Extension[] = [
+ lineNumbers(),
+ highlightActiveLineGutter(),
+ highlightSpecialChars(),
+ history(),
+ drawSelection(),
+ dropCursor(),
+ EditorState.allowMultipleSelections.of(true),
+ indentOnInput(),
+ indentUnit.of(" "),
+ EditorState.tabSize.of(2),
+ bracketMatching(),
+ closeBrackets(),
+ rectangularSelection(),
+ highlightActiveLine(),
+ EditorView.lineWrapping,
+ saveKeymap,
+ keymap.of([
+ ...closeBracketsKeymap,
+ ...defaultKeymap,
+ ...searchKeymap,
+ ...historyKeymap,
+ ...foldKeymap,
+ indentWithTab,
+ ]),
+ updateListener,
+ ]
+
+ const state = EditorState.create({
+ doc: props.value,
+ extensions: [
+ ...baseExtensions,
+ languageCompartment.of(resolveLanguage(props) ?? []),
+ themeCompartment.of(ocEditorTheme()),
+ shikiCompartment.of(ocLezerHighlight()),
+ readOnlyCompartment.of(readOnlyExtension(props.readOnly ?? false)),
+ ...(props.extensions ?? []),
+ ],
+ })
+
+ view = new EditorView({ state, parent: container })
+
+ const sel = props.initialSelection
+ if (sel) {
+ const doc = view.state.doc
+ const lineNumber = Math.min(Math.max(sel.line + 1, 1), doc.lines)
+ const line = doc.line(lineNumber)
+ const offset = Math.min(line.from + Math.max(sel.character, 0), line.to)
+ view.dispatch({ selection: { anchor: offset }, scrollIntoView: true })
+ view.focus()
+ }
+ })
+
+ createEffect(() => {
+ const next = props.value
+ const v = view
+ if (!v) return
+ const current = v.state.doc.toString()
+ if (next === current) return
+ v.dispatch({
+ changes: { from: 0, to: v.state.doc.length, insert: next },
+ annotations: externalSync.of(true),
+ })
+ })
+
+ createEffect(() => {
+ const lang = resolveLanguage({ value: props.value, path: props.path, language: props.language })
+ const v = view
+ if (!v) return
+ v.dispatch({ effects: languageCompartment.reconfigure(lang ?? []) })
+ })
+
+ createEffect(() => {
+ const ro = props.readOnly ?? false
+ const v = view
+ if (!v) return
+ v.dispatch({ effects: readOnlyCompartment.reconfigure(readOnlyExtension(ro)) })
+ })
+
+ createEffect(() => {
+ if (!theme) return
+ theme.themeId()
+ const v = view
+ if (!v) return
+ v.dispatch({ effects: themeCompartment.reconfigure(ocEditorTheme()) })
+ })
+
+ createEffect(() => {
+ const lang = editorLanguageToShikiLang(props.language, props.path)
+ theme?.themeId()
+ const token = ++shikiLoadToken
+
+ if (!lang) {
+ const v = view
+ if (v) v.dispatch({ effects: shikiCompartment.reconfigure(ocLezerHighlight()) })
+ return
+ }
+
+ void loadShikiForLang(lang)
+ .then((highlighter) => {
+ if (token !== shikiLoadToken) return
+ const v = view
+ if (!v) return
+ if (!highlighter) {
+ v.dispatch({ effects: shikiCompartment.reconfigure(ocLezerHighlight()) })
+ return
+ }
+ v.dispatch({ effects: shikiCompartment.reconfigure(shikiHighlightExtension(highlighter, lang)) })
+ })
+ .catch(() => {
+ if (token !== shikiLoadToken) return
+ view?.dispatch({ effects: shikiCompartment.reconfigure(ocLezerHighlight()) })
+ })
+ })
+
+ onCleanup(() => {
+ if (changeTimer) clearTimeout(changeTimer)
+ view?.destroy()
+ view = undefined
+ })
+
+ return
+}
diff --git a/packages/ui/test/dom-preload.ts b/packages/ui/test/dom-preload.ts
new file mode 100644
index 000000000000..9588e8144df2
--- /dev/null
+++ b/packages/ui/test/dom-preload.ts
@@ -0,0 +1,49 @@
+// Bun test preload for DOM-rendered SolidJS components.
+//
+// Bun does not run vite-plugin-solid, so we register a Bun plugin that compiles
+// JSX/TSX through babel-preset-solid (DOM generate mode) and we register
+// happy-dom as the global DOM. Used by component tests that mount real DOM
+// (e.g. CodeMirror 6 inside code-editor.tsx).
+import { GlobalRegistrator } from "@happy-dom/global-registrator"
+import { plugin } from "bun"
+// @ts-expect-error - no bundled types
+import { transformAsync } from "@babel/core"
+// @ts-expect-error - no types
+import ts from "@babel/preset-typescript"
+// @ts-expect-error - no types
+import solid from "babel-preset-solid"
+
+if (!(globalThis as any).__ui_dom_registered) {
+ GlobalRegistrator.register()
+ ;(globalThis as any).__ui_dom_registered = true
+}
+
+plugin({
+ name: "solid-dom-tsx",
+ setup(build) {
+ // Force solid-js to resolve to its client (DOM) build instead of the
+ // server build that Bun picks up by default.
+ build.onLoad({ filter: /[/\\]solid-js[/\\]web[/\\]dist[/\\]server\.js$/ }, async (args) => {
+ const path = args.path.replace(/server\.js$/, "web.js")
+ return { contents: await Bun.file(path).text(), loader: "js" }
+ })
+ build.onLoad({ filter: /[/\\]solid-js[/\\]dist[/\\]server\.js$/ }, async (args) => {
+ const path = args.path.replace(/server\.js$/, "solid.js")
+ return { contents: await Bun.file(path).text(), loader: "js" }
+ })
+
+ build.onLoad({ filter: /\.[jt]sx$/ }, async (args) => {
+ if (args.path.includes("/node_modules/")) return
+ const source = await Bun.file(args.path).text()
+ const result = await transformAsync(source, {
+ filename: args.path,
+ presets: [
+ [solid, { generate: "dom", hydratable: false }],
+ [ts, { onlyRemoveTypeImports: true }],
+ ],
+ sourceMaps: "inline",
+ })
+ return { contents: result?.code ?? source, loader: "js" }
+ })
+ },
+})