Compare commits

..
Author SHA1 Message Date
opencode 5e67efc2fd release: v1.14.29 2026-04-28 18:34:37 +00:00
307 changed files with 6339 additions and 15881 deletions
-2
View File
@@ -16,9 +16,7 @@ ariane-emory
-danieljoshuanazareth
-danieljoshuanazareth
-davidbernat looks to be a clawdbot that spams team and sends super weird emails, doesnt appear to be a real person
dmtrkovalenko
edemaine
fahreddinozcan
-florianleibert
fwang
iamdavidhill
+1 -1
View File
@@ -88,7 +88,7 @@ jobs:
- name: Build
id: build
run: |
./packages/opencode/script/build.ts ${{ (github.ref_name == 'beta' && '--sourcemaps') || '' }}
./packages/opencode/script/build.ts
env:
OPENCODE_VERSION: ${{ needs.version.outputs.version }}
OPENCODE_RELEASE: ${{ needs.version.outputs.release }}
-1
View File
@@ -1,7 +1,6 @@
{
"$schema": "https://opencode.ai/config.json",
"provider": {},
"plugin": ["../packages/opencode/src/control-plane/dev/debug-workspace-plugin.ts"],
"permission": {
"edit": {
"packages/opencode/migration/*": "deny",
-8
View File
@@ -28,11 +28,3 @@ Use the current Effect v4 / effect-smol source, not memory or older Effect v2/v3
- In tests, prefer the repo's existing Effect test helpers and live tests for filesystem, git, child process, locks, or timing behavior.
- Do not introduce `any`, non-null assertions, unchecked casts, or older Effect APIs just to satisfy types.
- Do not answer from memory. Verify against `.opencode/references/effect-smol` or nearby code first.
## Testing Patterns
- Use `testEffect(...)` from `packages/opencode/test/lib/effect.ts` for tests that exercise Effect services, layers, runtime context, scoped resources, or platform integrations.
- Use `it.live(...)` for filesystem, git repositories, HTTP servers, sockets, child processes, locks, real time, and other live platform behavior.
- Run tests from package directories such as `packages/opencode`; never run package tests from the repo root.
- Prefer explicit test layers over ad hoc managed runtimes. Keep dependency provisioning visible in the test file.
- Use scoped fixtures and finalizers for resources that must be cleaned up, including temporary directories, flags, databases, fibers, servers, and global state.
+35 -67
View File
@@ -29,7 +29,7 @@
},
"packages/app": {
"name": "@opencode-ai/app",
"version": "1.14.30",
"version": "1.14.29",
"dependencies": {
"@kobalte/core": "catalog:",
"@opencode-ai/core": "workspace:*",
@@ -83,7 +83,7 @@
},
"packages/console/app": {
"name": "@opencode-ai/console-app",
"version": "1.14.30",
"version": "1.14.29",
"dependencies": {
"@cloudflare/vite-plugin": "1.15.2",
"@ibm/plex": "6.4.1",
@@ -117,7 +117,7 @@
},
"packages/console/core": {
"name": "@opencode-ai/console-core",
"version": "1.14.30",
"version": "1.14.29",
"dependencies": {
"@aws-sdk/client-sts": "3.782.0",
"@jsx-email/render": "1.1.1",
@@ -144,7 +144,7 @@
},
"packages/console/function": {
"name": "@opencode-ai/console-function",
"version": "1.14.30",
"version": "1.14.29",
"dependencies": {
"@ai-sdk/anthropic": "3.0.64",
"@ai-sdk/openai": "3.0.48",
@@ -168,7 +168,7 @@
},
"packages/console/mail": {
"name": "@opencode-ai/console-mail",
"version": "1.14.30",
"version": "1.14.29",
"dependencies": {
"@jsx-email/all": "2.2.3",
"@jsx-email/cli": "1.4.3",
@@ -192,7 +192,7 @@
},
"packages/core": {
"name": "@opencode-ai/core",
"version": "1.14.30",
"version": "1.14.29",
"bin": {
"opencode": "./bin/opencode",
},
@@ -226,7 +226,7 @@
},
"packages/desktop": {
"name": "@opencode-ai/desktop",
"version": "1.14.30",
"version": "1.14.29",
"dependencies": {
"@opencode-ai/app": "workspace:*",
"@opencode-ai/ui": "workspace:*",
@@ -259,7 +259,7 @@
},
"packages/desktop-electron": {
"name": "@opencode-ai/desktop-electron",
"version": "1.14.30",
"version": "1.14.29",
"dependencies": {
"drizzle-orm": "catalog:",
"effect": "catalog:",
@@ -303,7 +303,7 @@
},
"packages/enterprise": {
"name": "@opencode-ai/enterprise",
"version": "1.14.30",
"version": "1.14.29",
"dependencies": {
"@opencode-ai/core": "workspace:*",
"@opencode-ai/ui": "workspace:*",
@@ -332,7 +332,7 @@
},
"packages/function": {
"name": "@opencode-ai/function",
"version": "1.14.30",
"version": "1.14.29",
"dependencies": {
"@octokit/auth-app": "8.0.1",
"@octokit/rest": "catalog:",
@@ -348,7 +348,7 @@
},
"packages/opencode": {
"name": "opencode",
"version": "1.14.30",
"version": "1.14.29",
"bin": {
"opencode": "./bin/opencode",
},
@@ -491,7 +491,7 @@
},
"packages/plugin": {
"name": "@opencode-ai/plugin",
"version": "1.14.30",
"version": "1.14.29",
"dependencies": {
"@opencode-ai/sdk": "workspace:*",
"effect": "catalog:",
@@ -506,8 +506,8 @@
"typescript": "catalog:",
},
"peerDependencies": {
"@opentui/core": ">=0.2.0",
"@opentui/solid": ">=0.2.0",
"@opentui/core": ">=0.1.105",
"@opentui/solid": ">=0.1.105",
},
"optionalPeers": [
"@opentui/core",
@@ -526,7 +526,7 @@
},
"packages/sdk/js": {
"name": "@opencode-ai/sdk",
"version": "1.14.30",
"version": "1.14.29",
"dependencies": {
"cross-spawn": "catalog:",
},
@@ -541,7 +541,7 @@
},
"packages/slack": {
"name": "@opencode-ai/slack",
"version": "1.14.30",
"version": "1.14.29",
"dependencies": {
"@opencode-ai/sdk": "workspace:*",
"@slack/bolt": "^3.17.1",
@@ -576,7 +576,7 @@
},
"packages/ui": {
"name": "@opencode-ai/ui",
"version": "1.14.30",
"version": "1.14.29",
"dependencies": {
"@kobalte/core": "catalog:",
"@opencode-ai/core": "workspace:*",
@@ -625,7 +625,7 @@
},
"packages/web": {
"name": "@opencode-ai/web",
"version": "1.14.30",
"version": "1.14.29",
"dependencies": {
"@astrojs/cloudflare": "12.6.3",
"@astrojs/markdown-remark": "6.3.1",
@@ -685,8 +685,8 @@
"@npmcli/arborist": "9.4.0",
"@octokit/rest": "22.0.0",
"@openauthjs/openauth": "0.0.0-20250322224806",
"@opentui/core": "0.2.0",
"@opentui/solid": "0.2.0",
"@opentui/core": "0.1.105",
"@opentui/solid": "0.1.105",
"@pierre/diffs": "1.1.0-beta.18",
"@playwright/test": "1.59.1",
"@solid-primitives/storage": "4.3.3",
@@ -1613,21 +1613,21 @@
"@opentelemetry/semantic-conventions": ["@opentelemetry/semantic-conventions@1.40.0", "", {}, "sha512-cifvXDhcqMwwTlTK04GBNeIe7yyo28Mfby85QXFe1Yk8nmi36Ab/5UQwptOx84SsoGNRg+EVSjwzfSZMy6pmlw=="],
"@opentui/core": ["@opentui/core@0.2.0", "", { "dependencies": { "bun-ffi-structs": "0.1.2", "diff": "8.0.2", "jimp": "1.6.0", "marked": "17.0.1", "string-width": "7.2.0", "strip-ansi": "7.1.2", "yoga-layout": "3.2.1" }, "optionalDependencies": { "@dimforge/rapier2d-simd-compat": "^0.17.3", "@opentui/core-darwin-arm64": "0.2.0", "@opentui/core-darwin-x64": "0.2.0", "@opentui/core-linux-arm64": "0.2.0", "@opentui/core-linux-x64": "0.2.0", "@opentui/core-win32-arm64": "0.2.0", "@opentui/core-win32-x64": "0.2.0", "bun-webgpu": "0.1.7", "planck": "^1.4.2", "three": "0.177.0" }, "peerDependencies": { "web-tree-sitter": "0.25.10" } }, "sha512-7YOEqPUQmsgrOb9nmLEBlX8RVHPFy4HquK1C489DwfvvPTiws8nTbZ+webNQDWha7shgnYQK4Zo1EcOlpQ5+1Q=="],
"@opentui/core": ["@opentui/core@0.1.105", "", { "dependencies": { "bun-ffi-structs": "0.1.2", "diff": "8.0.2", "jimp": "1.6.0", "marked": "17.0.1", "yoga-layout": "3.2.1" }, "optionalDependencies": { "@dimforge/rapier2d-simd-compat": "^0.17.3", "@opentui/core-darwin-arm64": "0.1.105", "@opentui/core-darwin-x64": "0.1.105", "@opentui/core-linux-arm64": "0.1.105", "@opentui/core-linux-x64": "0.1.105", "@opentui/core-win32-arm64": "0.1.105", "@opentui/core-win32-x64": "0.1.105", "bun-webgpu": "0.1.5", "planck": "^1.4.2", "three": "0.177.0" }, "peerDependencies": { "web-tree-sitter": "0.25.10" } }, "sha512-vllSOOCW6VIThV/96GRLJ1IxIBuR+ci6FDvnPIAG4s7SJ/FW6zAkqDn1xrtBwwk/lM3QWjLqy8BZc+zwWvveJA=="],
"@opentui/core-darwin-arm64": ["@opentui/core-darwin-arm64@0.2.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-VVmKwth3hzsQPjAZ7WGJxmzuzx0uCtynd79JJDg26D7QRM9V5beVGbKwwU5SKsDlK74EyQoY85Mv9xFY5E4jrA=="],
"@opentui/core-darwin-arm64": ["@opentui/core-darwin-arm64@0.1.105", "", { "os": "darwin", "cpu": "arm64" }, "sha512-1pIL7aer9amwj8EpYoMNtvavKetIe+nX8uBRmYsMQb+KvJoUAZUqENfRW+qHE5WrsOyxx8/QoyXTHw15GG5iLQ=="],
"@opentui/core-darwin-x64": ["@opentui/core-darwin-x64@0.2.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-eX+WNdbSNr7Bozdq/MH6p1vXIALGt0SqBHR4YtWyTh6X7KDz9FTtJT3ylxMPqiVRUGBNAiWOxoqKGXW7JLQ0TA=="],
"@opentui/core-darwin-x64": ["@opentui/core-darwin-x64@0.1.105", "", { "os": "darwin", "cpu": "x64" }, "sha512-hLIRSWlK3gY2NRXJGWiTBiMYSmRDjOYFZF6WtUVXhY2SL3sp08dhmr/6dmAVH+3pKCsCipLEsrrcQX6SAihCTA=="],
"@opentui/core-linux-arm64": ["@opentui/core-linux-arm64@0.2.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-ARZa+ywbN/OV7esT5ZdJMlQW3a4Pr56qLlEI/X65ik88C2sgmDze4Kf2FmqtvJ1hbv1YsMfLHH9MfhLl5twyHQ=="],
"@opentui/core-linux-arm64": ["@opentui/core-linux-arm64@0.1.105", "", { "os": "linux", "cpu": "arm64" }, "sha512-jlRKfPkozTZEkHEePuCWYcTIUtPm+ieInAwGVqGmjbvqjxdVv1/W/Dt6LEZ/9jpRiOPd+FjXAfLe6wa/XWHr+w=="],
"@opentui/core-linux-x64": ["@opentui/core-linux-x64@0.2.0", "", { "os": "linux", "cpu": "x64" }, "sha512-ZjNxrD45P51cdbABoivVQLBakVYwDqAridJbHhkK6T/+EU7YsTrmAu9ae19N9ZGnrlKzLViQF8GOavNUNjAbhw=="],
"@opentui/core-linux-x64": ["@opentui/core-linux-x64@0.1.105", "", { "os": "linux", "cpu": "x64" }, "sha512-kfWS1WMg6qHShmxZX9s1tZc/8JcXw6uyy2UtyTbJdRFExtXGH37oKHi8QK8iPL2ExCx4z7zqVnVJfO3X/Wh7lA=="],
"@opentui/core-win32-arm64": ["@opentui/core-win32-arm64@0.2.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-ImMjFPOWE8wcZQ2lUz1D418xonS/5EwnItUF1g5dbp1q9+A0vv2P3bxTenLwMqcYvG4wjO6gKT3n2QLnRd6qKg=="],
"@opentui/core-win32-arm64": ["@opentui/core-win32-arm64@0.1.105", "", { "os": "win32", "cpu": "arm64" }, "sha512-UFx6A8OpBVbGWK6OAw4GqAqKZgIITJfSOd35pG9yDVKQouHN2OGc2HeeXrH2A4h42p40Xl6IfcqqfllkpC13Dg=="],
"@opentui/core-win32-x64": ["@opentui/core-win32-x64@0.2.0", "", { "os": "win32", "cpu": "x64" }, "sha512-6yfYHTtJ4yzbl8kXCW3Pc4eWbZDYVw21GumwdNgkjJJ2JqQAQ861em0riEoucYAa5qPYYTiMUEw7X4Fv8lGwuQ=="],
"@opentui/core-win32-x64": ["@opentui/core-win32-x64@0.1.105", "", { "os": "win32", "cpu": "x64" }, "sha512-f9FqqUmxehwhF+cgyazm0YT0v0BYTTCPzd6eztqhl74N3x/kC+jOOz2rdJDC/tTBo1JVsF64KupOnhIs6/Cogg=="],
"@opentui/solid": ["@opentui/solid@0.2.0", "", { "dependencies": { "@babel/core": "7.28.0", "@babel/preset-typescript": "7.27.1", "@opentui/core": "0.2.0", "babel-plugin-module-resolver": "5.0.2", "babel-preset-solid": "1.9.12", "entities": "7.0.1", "s-js": "^0.4.9" }, "peerDependencies": { "solid-js": "1.9.12" } }, "sha512-kZR9i0FPAcVtomrPsKuSb+D9smooplo9zggFfU2vnnguNuQjGNbEmuJtxhCacy7ig9g3GomdNtQAzD4LiAY+3w=="],
"@opentui/solid": ["@opentui/solid@0.1.105", "", { "dependencies": { "@babel/core": "7.28.0", "@babel/preset-typescript": "7.27.1", "@opentui/core": "0.1.105", "babel-plugin-module-resolver": "5.0.2", "babel-preset-solid": "1.9.10", "entities": "7.0.1", "s-js": "^0.4.9" }, "peerDependencies": { "solid-js": "1.9.11" } }, "sha512-uxnaMP802sCI487pv/Hk9xdFdIj9mkg3eNliAqbqR0Shmd4phcjKEZvPRpijjmI99j4s9nul71jzF3h1oz31Nw=="],
"@oslojs/asn1": ["@oslojs/asn1@1.0.0", "", { "dependencies": { "@oslojs/binary": "1.0.0" } }, "sha512-zw/wn0sj0j0QKbIXfIlnEcTviaCzYOY3V5rAyjR6YtOByFtJiT574+8p9Wlach0lZH9fddD4yb9laEAIl4vXQA=="],
@@ -2731,15 +2731,15 @@
"bun-types": ["bun-types@1.3.12", "", { "dependencies": { "@types/node": "*" } }, "sha512-HqOLj5PoFajAQciOMRiIZGNoKxDJSr6qigAttOX40vJuSp6DN/CxWp9s3C1Xwm4oH7ybueITwiaOcWXoYVoRkA=="],
"bun-webgpu": ["bun-webgpu@0.1.7", "", { "dependencies": { "@webgpu/types": "^0.1.60" }, "optionalDependencies": { "bun-webgpu-darwin-arm64": "^0.1.7", "bun-webgpu-darwin-x64": "^0.1.7", "bun-webgpu-linux-x64": "^0.1.7", "bun-webgpu-win32-x64": "^0.1.7" } }, "sha512-KUxUp+oQIf7pPBMD4Hv1TUu7DWaOZ4ciKulTk9to9+Uc8yHoYrMW7L2SJCJ4FHHkywgf/7aLRgRx0b7i6DvGIQ=="],
"bun-webgpu": ["bun-webgpu@0.1.5", "", { "dependencies": { "@webgpu/types": "^0.1.60" }, "optionalDependencies": { "bun-webgpu-darwin-arm64": "^0.1.5", "bun-webgpu-darwin-x64": "^0.1.5", "bun-webgpu-linux-x64": "^0.1.5", "bun-webgpu-win32-x64": "^0.1.5" } }, "sha512-91/K6S5whZKX7CWAm9AylhyKrLGRz6BUiiPiM/kXadSnD4rffljCD/q9cNFftm5YXhx4MvLqw33yEilxogJvwA=="],
"bun-webgpu-darwin-arm64": ["bun-webgpu-darwin-arm64@0.1.7", "", { "os": "darwin", "cpu": "arm64" }, "sha512-mRrFFyHzPWjsTRidAZBRcu808CPQBOUL0P6b4nxLhp+XHcV/mbUHERZMgW9s58tsojQfSdzschiQa8q+JCgRWA=="],
"bun-webgpu-darwin-arm64": ["bun-webgpu-darwin-arm64@0.1.6", "", { "os": "darwin", "cpu": "arm64" }, "sha512-lIsDkPzJzPl6yrB5CUOINJFPnTRv6fF/Q8J1mAr43ogSp86WZEg9XZKaT6f3EUJ+9ETogGoMnoj1q0AwHUTbAQ=="],
"bun-webgpu-darwin-x64": ["bun-webgpu-darwin-x64@0.1.7", "", { "os": "darwin", "cpu": "x64" }, "sha512-g0NXGNgvaVCSH/jCWWlfdiquOHkbUN6vP4zqzSkIxWKQeLnqm3oADcok7SO3yIgI7v5mKpRc/ks7NDEKNH+jNQ=="],
"bun-webgpu-darwin-x64": ["bun-webgpu-darwin-x64@0.1.6", "", { "os": "darwin", "cpu": "x64" }, "sha512-uEddf5U7GvKIkM/BV18rUKtYHL6d0KeqBjNHwfqDH9QgEo9KVSKvJXS5I/sMefk5V5pIYE+8tQhtrREevhocng=="],
"bun-webgpu-linux-x64": ["bun-webgpu-linux-x64@0.1.7", "", { "os": "linux", "cpu": "x64" }, "sha512-UEP7UZdEhx9otvkZczjsszL8ZVlrODANQvgl+C88/bNVmxDoFi7w1fWzGi1sZyakiETjmtFDq2/xCLhbSZxjqw=="],
"bun-webgpu-linux-x64": ["bun-webgpu-linux-x64@0.1.6", "", { "os": "linux", "cpu": "x64" }, "sha512-Y/f15j9r8ba0xUz+3lATtS74OE+PPzQXO7Do/1eCluJcuOlfa77kMjvBK/ShWnem3Y9xqi59pebTPOGRB+CaJA=="],
"bun-webgpu-win32-x64": ["bun-webgpu-win32-x64@0.1.7", "", { "os": "win32", "cpu": "x64" }, "sha512-KZktiFkBz6sN7PEm1NVdeaLP5Q5X/PlSHZqefY4nNuWtf0LNvh54NhZe7yVv/Plz/nGbv92b0KHMBY3ki/pp6g=="],
"bun-webgpu-win32-x64": ["bun-webgpu-win32-x64@0.1.6", "", { "os": "win32", "cpu": "x64" }, "sha512-MHSFAKqizISb+C5NfDrFe3g0Al5Njnu0j/A+oO2Q+bIWX+fUYjBSowiYE1ZXJx65KuryuB+tiM7Qh6cQbVvkEg=="],
"bundle-name": ["bundle-name@4.1.0", "", { "dependencies": { "run-applescript": "^7.0.0" } }, "sha512-tjwM5exMg6BGRI+kNmTntNsvdZS1X8BFYS6tnJ2hdH0kVxM6/eVZ2xy+FqStSWvYmtfFMDLIxurorHwDKfDz5Q=="],
@@ -5597,6 +5597,8 @@
"@opentui/solid/@babel/core": ["@babel/core@7.28.0", "", { "dependencies": { "@ampproject/remapping": "^2.2.0", "@babel/code-frame": "^7.27.1", "@babel/generator": "^7.28.0", "@babel/helper-compilation-targets": "^7.27.2", "@babel/helper-module-transforms": "^7.27.3", "@babel/helpers": "^7.27.6", "@babel/parser": "^7.28.0", "@babel/template": "^7.27.2", "@babel/traverse": "^7.28.0", "@babel/types": "^7.28.0", "convert-source-map": "^2.0.0", "debug": "^4.1.0", "gensync": "^1.0.0-beta.2", "json5": "^2.2.3", "semver": "^6.3.1" } }, "sha512-UlLAnTPrFdNGoFtbSXwcGFQBtQZJCNjaN6hQNP3UPvuNXT1i82N26KL3dZeIpNalWywr9IuQuncaAfUaS1g6sQ=="],
"@opentui/solid/babel-preset-solid": ["babel-preset-solid@1.9.10", "", { "dependencies": { "babel-plugin-jsx-dom-expressions": "^0.40.3" }, "peerDependencies": { "@babel/core": "^7.0.0", "solid-js": "^1.9.10" }, "optionalPeers": ["solid-js"] }, "sha512-HCelrgua/Y+kqO8RyL04JBWS/cVdrtUv/h45GntgQY+cJl4eBcKkCDV3TdMjtKx1nXwRaR9QXslM/Npm1dxdZQ=="],
"@oslojs/jwt/@oslojs/encoding": ["@oslojs/encoding@0.4.1", "", {}, "sha512-hkjo6MuIK/kQR5CrGNdAPZhS01ZCXuWDRJ187zh6qqF2+yMHZpD9fAYpX8q2bOO6Ryhl3XpCT6kUX76N8hhm4Q=="],
"@pierre/diffs/@shikijs/transformers": ["@shikijs/transformers@3.20.0", "", { "dependencies": { "@shikijs/core": "3.20.0", "@shikijs/types": "3.20.0" } }, "sha512-PrHHMRr3Q5W1qB/42kJW6laqFyWdhrPF2hNR9qjOm1xcSiAO3hAHo7HaVyHE6pMyevmy3i51O8kuGGXC78uK3g=="],
@@ -5949,10 +5951,6 @@
"openid-client/lru-cache": ["lru-cache@6.0.0", "", { "dependencies": { "yallist": "^4.0.0" } }, "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA=="],
"opentui-spinner/@opentui/core": ["@opentui/core@0.1.105", "", { "dependencies": { "bun-ffi-structs": "0.1.2", "diff": "8.0.2", "jimp": "1.6.0", "marked": "17.0.1", "yoga-layout": "3.2.1" }, "optionalDependencies": { "@dimforge/rapier2d-simd-compat": "^0.17.3", "@opentui/core-darwin-arm64": "0.1.105", "@opentui/core-darwin-x64": "0.1.105", "@opentui/core-linux-arm64": "0.1.105", "@opentui/core-linux-x64": "0.1.105", "@opentui/core-win32-arm64": "0.1.105", "@opentui/core-win32-x64": "0.1.105", "bun-webgpu": "0.1.5", "planck": "^1.4.2", "three": "0.177.0" }, "peerDependencies": { "web-tree-sitter": "0.25.10" } }, "sha512-vllSOOCW6VIThV/96GRLJ1IxIBuR+ci6FDvnPIAG4s7SJ/FW6zAkqDn1xrtBwwk/lM3QWjLqy8BZc+zwWvveJA=="],
"opentui-spinner/@opentui/solid": ["@opentui/solid@0.1.105", "", { "dependencies": { "@babel/core": "7.28.0", "@babel/preset-typescript": "7.27.1", "@opentui/core": "0.1.105", "babel-plugin-module-resolver": "5.0.2", "babel-preset-solid": "1.9.10", "entities": "7.0.1", "s-js": "^0.4.9" }, "peerDependencies": { "solid-js": "1.9.11" } }, "sha512-uxnaMP802sCI487pv/Hk9xdFdIj9mkg3eNliAqbqR0Shmd4phcjKEZvPRpijjmI99j4s9nul71jzF3h1oz31Nw=="],
"ora/bl": ["bl@4.1.0", "", { "dependencies": { "buffer": "^5.5.0", "inherits": "^2.0.4", "readable-stream": "^3.4.0" } }, "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w=="],
"ora/chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="],
@@ -6713,24 +6711,6 @@
"opencontrol/@modelcontextprotocol/sdk/zod-to-json-schema": ["zod-to-json-schema@3.25.2", "", { "peerDependencies": { "zod": "^3.25.28 || ^4" } }, "sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA=="],
"opentui-spinner/@opentui/core/@opentui/core-darwin-arm64": ["@opentui/core-darwin-arm64@0.1.105", "", { "os": "darwin", "cpu": "arm64" }, "sha512-1pIL7aer9amwj8EpYoMNtvavKetIe+nX8uBRmYsMQb+KvJoUAZUqENfRW+qHE5WrsOyxx8/QoyXTHw15GG5iLQ=="],
"opentui-spinner/@opentui/core/@opentui/core-darwin-x64": ["@opentui/core-darwin-x64@0.1.105", "", { "os": "darwin", "cpu": "x64" }, "sha512-hLIRSWlK3gY2NRXJGWiTBiMYSmRDjOYFZF6WtUVXhY2SL3sp08dhmr/6dmAVH+3pKCsCipLEsrrcQX6SAihCTA=="],
"opentui-spinner/@opentui/core/@opentui/core-linux-arm64": ["@opentui/core-linux-arm64@0.1.105", "", { "os": "linux", "cpu": "arm64" }, "sha512-jlRKfPkozTZEkHEePuCWYcTIUtPm+ieInAwGVqGmjbvqjxdVv1/W/Dt6LEZ/9jpRiOPd+FjXAfLe6wa/XWHr+w=="],
"opentui-spinner/@opentui/core/@opentui/core-linux-x64": ["@opentui/core-linux-x64@0.1.105", "", { "os": "linux", "cpu": "x64" }, "sha512-kfWS1WMg6qHShmxZX9s1tZc/8JcXw6uyy2UtyTbJdRFExtXGH37oKHi8QK8iPL2ExCx4z7zqVnVJfO3X/Wh7lA=="],
"opentui-spinner/@opentui/core/@opentui/core-win32-arm64": ["@opentui/core-win32-arm64@0.1.105", "", { "os": "win32", "cpu": "arm64" }, "sha512-UFx6A8OpBVbGWK6OAw4GqAqKZgIITJfSOd35pG9yDVKQouHN2OGc2HeeXrH2A4h42p40Xl6IfcqqfllkpC13Dg=="],
"opentui-spinner/@opentui/core/@opentui/core-win32-x64": ["@opentui/core-win32-x64@0.1.105", "", { "os": "win32", "cpu": "x64" }, "sha512-f9FqqUmxehwhF+cgyazm0YT0v0BYTTCPzd6eztqhl74N3x/kC+jOOz2rdJDC/tTBo1JVsF64KupOnhIs6/Cogg=="],
"opentui-spinner/@opentui/core/bun-webgpu": ["bun-webgpu@0.1.5", "", { "dependencies": { "@webgpu/types": "^0.1.60" }, "optionalDependencies": { "bun-webgpu-darwin-arm64": "^0.1.5", "bun-webgpu-darwin-x64": "^0.1.5", "bun-webgpu-linux-x64": "^0.1.5", "bun-webgpu-win32-x64": "^0.1.5" } }, "sha512-91/K6S5whZKX7CWAm9AylhyKrLGRz6BUiiPiM/kXadSnD4rffljCD/q9cNFftm5YXhx4MvLqw33yEilxogJvwA=="],
"opentui-spinner/@opentui/solid/@babel/core": ["@babel/core@7.28.0", "", { "dependencies": { "@ampproject/remapping": "^2.2.0", "@babel/code-frame": "^7.27.1", "@babel/generator": "^7.28.0", "@babel/helper-compilation-targets": "^7.27.2", "@babel/helper-module-transforms": "^7.27.3", "@babel/helpers": "^7.27.6", "@babel/parser": "^7.28.0", "@babel/template": "^7.27.2", "@babel/traverse": "^7.28.0", "@babel/types": "^7.28.0", "convert-source-map": "^2.0.0", "debug": "^4.1.0", "gensync": "^1.0.0-beta.2", "json5": "^2.2.3", "semver": "^6.3.1" } }, "sha512-UlLAnTPrFdNGoFtbSXwcGFQBtQZJCNjaN6hQNP3UPvuNXT1i82N26KL3dZeIpNalWywr9IuQuncaAfUaS1g6sQ=="],
"opentui-spinner/@opentui/solid/babel-preset-solid": ["babel-preset-solid@1.9.10", "", { "dependencies": { "babel-plugin-jsx-dom-expressions": "^0.40.3" }, "peerDependencies": { "@babel/core": "^7.0.0", "solid-js": "^1.9.10" }, "optionalPeers": ["solid-js"] }, "sha512-HCelrgua/Y+kqO8RyL04JBWS/cVdrtUv/h45GntgQY+cJl4eBcKkCDV3TdMjtKx1nXwRaR9QXslM/Npm1dxdZQ=="],
"ora/bl/buffer": ["buffer@5.7.1", "", { "dependencies": { "base64-js": "^1.3.1", "ieee754": "^1.1.13" } }, "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ=="],
"ora/bl/readable-stream": ["readable-stream@3.6.2", "", { "dependencies": { "inherits": "^2.0.3", "string_decoder": "^1.1.1", "util-deprecate": "^1.0.1" } }, "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA=="],
@@ -7073,18 +7053,6 @@
"opencontrol/@modelcontextprotocol/sdk/express/type-is": ["type-is@2.0.1", "", { "dependencies": { "content-type": "^1.0.5", "media-typer": "^1.1.0", "mime-types": "^3.0.0" } }, "sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw=="],
"opentui-spinner/@opentui/core/bun-webgpu/@webgpu/types": ["@webgpu/types@0.1.69", "", {}, "sha512-RPmm6kgRbI8e98zSD3RVACvnuktIja5+yLgDAkTmxLr90BEwdTXRQWNLF3ETTTyH/8mKhznZuN5AveXYFEsMGQ=="],
"opentui-spinner/@opentui/core/bun-webgpu/bun-webgpu-darwin-arm64": ["bun-webgpu-darwin-arm64@0.1.6", "", { "os": "darwin", "cpu": "arm64" }, "sha512-lIsDkPzJzPl6yrB5CUOINJFPnTRv6fF/Q8J1mAr43ogSp86WZEg9XZKaT6f3EUJ+9ETogGoMnoj1q0AwHUTbAQ=="],
"opentui-spinner/@opentui/core/bun-webgpu/bun-webgpu-darwin-x64": ["bun-webgpu-darwin-x64@0.1.6", "", { "os": "darwin", "cpu": "x64" }, "sha512-uEddf5U7GvKIkM/BV18rUKtYHL6d0KeqBjNHwfqDH9QgEo9KVSKvJXS5I/sMefk5V5pIYE+8tQhtrREevhocng=="],
"opentui-spinner/@opentui/core/bun-webgpu/bun-webgpu-linux-x64": ["bun-webgpu-linux-x64@0.1.6", "", { "os": "linux", "cpu": "x64" }, "sha512-Y/f15j9r8ba0xUz+3lATtS74OE+PPzQXO7Do/1eCluJcuOlfa77kMjvBK/ShWnem3Y9xqi59pebTPOGRB+CaJA=="],
"opentui-spinner/@opentui/core/bun-webgpu/bun-webgpu-win32-x64": ["bun-webgpu-win32-x64@0.1.6", "", { "os": "win32", "cpu": "x64" }, "sha512-MHSFAKqizISb+C5NfDrFe3g0Al5Njnu0j/A+oO2Q+bIWX+fUYjBSowiYE1ZXJx65KuryuB+tiM7Qh6cQbVvkEg=="],
"opentui-spinner/@opentui/solid/@babel/core/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="],
"ora/bl/buffer/ieee754": ["ieee754@1.2.1", "", {}, "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA=="],
"pkg-up/find-up/locate-path/p-locate": ["p-locate@3.0.0", "", { "dependencies": { "p-limit": "^2.0.0" } }, "sha512-x+12w/To+4GFfgJhBEpiDcLozRJGegY+Ei7/z0tSLkMmxGZNybVMSfWj9aJn8Z5Fc7dBUNJOOVgPv2H7IwulSQ=="],
+4 -4
View File
@@ -1,8 +1,8 @@
{
"nodeModules": {
"x86_64-linux": "sha256-cBfg4pJ4mjsfS4MFFASBaZZykArgIoeo/3woOcSGy1U=",
"aarch64-linux": "sha256-Q6cqUwfqbscdrPW0uHcfshhQINjJi0HiyURMSdOOCf4=",
"aarch64-darwin": "sha256-1AtfsD1D9YxWSEsecPJF9XsvsxsWTtVtkP5l6UW43og=",
"x86_64-darwin": "sha256-YS5/8YTf9LymAUbjXVrGDfxtKVJrpZbPnnCtsGHSHoU="
"x86_64-linux": "sha256-h2T/LnUnISZZDn9ZQkZ/A59P+6+QdfOlrgl4RXK/vgM=",
"aarch64-linux": "sha256-+DRohG1ZEB/2LtZU90GWoqJkeyu/sW8A8oKT3f/TtQ0=",
"aarch64-darwin": "sha256-k4nsk/WduuxY8HgjRuqzGT9EjEo7V/2mAzBTYee0fZ0=",
"x86_64-darwin": "sha256-3dSvfN2+5lXwOx57x8NSIWbEZ1fp6+1T6bJpAuUNPyk="
}
}
+2 -2
View File
@@ -34,8 +34,8 @@
"@types/cross-spawn": "6.0.6",
"@octokit/rest": "22.0.0",
"@hono/zod-validator": "0.4.2",
"@opentui/core": "0.2.0",
"@opentui/solid": "0.2.0",
"@opentui/core": "0.1.105",
"@opentui/solid": "0.1.105",
"ulid": "3.0.1",
"@kobalte/core": "0.13.11",
"@types/luxon": "3.7.1",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/app",
"version": "1.14.30",
"version": "1.14.29",
"description": "",
"type": "module",
"exports": {
+28 -35
View File
@@ -33,7 +33,6 @@ import { SESSION_RECENT_LIMIT } from "./global-sync/types"
import { formatServerError } from "@/utils/server-errors"
import { queryOptions, skipToken, useMutation, useQueries, useQuery, useQueryClient } from "@tanstack/solid-query"
import { createRefreshQueue } from "./global-sync/queue"
import { directoryKey } from "./global-sync/utils"
type GlobalStore = {
ready: boolean
@@ -170,20 +169,18 @@ function createGlobalSync() {
const queue = createRefreshQueue({
paused,
key: directoryKey,
bootstrap: () => queryClient.fetchQuery({ queryKey: ["bootstrap"] }),
bootstrapInstance,
})
const sdkFor = (directory: string) => {
const key = directoryKey(directory)
const cached = sdkCache.get(key)
const cached = sdkCache.get(directory)
if (cached) return cached
const sdk = globalSDK.createClient({
directory,
throwOnError: true,
})
sdkCache.set(key, sdk)
sdkCache.set(directory, sdk)
return sdk
}
@@ -195,25 +192,23 @@ function createGlobalSync() {
void bootstrapInstance(directory)
},
onDispose: (directory) => {
const key = directoryKey(directory)
queue.clear(key)
sessionMeta.delete(key)
sdkCache.delete(key)
clearProviderRev(key)
clearSessionPrefetchDirectory(key)
queue.clear(directory)
sessionMeta.delete(directory)
sdkCache.delete(directory)
clearProviderRev(directory)
clearSessionPrefetchDirectory(directory)
},
translate: language.t,
getSdk: sdkFor,
})
async function loadSessions(directory: string) {
const key = directoryKey(directory)
const pending = sessionLoads.get(key)
const pending = sessionLoads.get(directory)
if (pending) return pending
children.pin(key)
children.pin(directory)
const [store, setStore] = children.child(directory, { bootstrap: false })
const meta = sessionMeta.get(key)
const meta = sessionMeta.get(directory)
if (meta && meta.limit >= store.limit) {
const next = trimSessions(store.session, {
limit: store.limit,
@@ -223,14 +218,14 @@ function createGlobalSync() {
setStore("session", reconcile(next, { key: "id" }))
cleanupDroppedSessionCaches(store, setStore, next, setSessionTodo)
}
children.unpin(key)
children.unpin(directory)
return
}
const limit = Math.max(store.limit + SESSION_RECENT_LIMIT, SESSION_RECENT_LIMIT)
const promise = queryClient
.fetchQuery({
...loadSessionsQuery(key),
...loadSessionsQuery(directory),
queryFn: () =>
loadRootSessionsWithFallback({
directory,
@@ -260,7 +255,7 @@ function createGlobalSync() {
setStore("session", reconcile(sessions, { key: "id" }))
cleanupDroppedSessionCaches(store, setStore, sessions, setSessionTodo)
})
sessionMeta.set(key, { limit })
sessionMeta.set(directory, { limit })
})
.catch((err) => {
console.error("Failed to load sessions", err)
@@ -275,24 +270,23 @@ function createGlobalSync() {
})
.then(() => {})
sessionLoads.set(key, promise)
sessionLoads.set(directory, promise)
void promise.finally(() => {
sessionLoads.delete(key)
children.unpin(key)
sessionLoads.delete(directory)
children.unpin(directory)
})
return promise
}
async function bootstrapInstance(directory: string) {
const key = directoryKey(directory)
if (!key) return
const pending = booting.get(key)
if (!directory) return
const pending = booting.get(directory)
if (pending) return pending
children.pin(key)
children.pin(directory)
const promise = Promise.resolve().then(async () => {
const child = children.ensureChild(directory)
const cache = children.vcsCache.get(key)
const cache = children.vcsCache.get(directory)
if (!cache) return
const sdk = sdkFor(directory)
await bootstrapDirectory({
@@ -313,17 +307,16 @@ function createGlobalSync() {
})
})
booting.set(key, promise)
booting.set(directory, promise)
void promise.finally(() => {
booting.delete(key)
children.unpin(key)
booting.delete(directory)
children.unpin(directory)
})
return promise
}
const unsub = globalSDK.event.listen((e) => {
const directory = e.name
const key = directoryKey(directory)
const event = e.details
const recent = bootingRoot || Date.now() - bootedAt < 1500
@@ -346,9 +339,9 @@ function createGlobalSync() {
return
}
const existing = children.children[key]
const existing = children.children[directory]
if (!existing) return
children.mark(key)
children.mark(directory)
const [store, setStore] = existing
applyDirectoryEvent({
event,
@@ -357,9 +350,9 @@ function createGlobalSync() {
setStore,
push: queue.push,
setSessionTodo,
vcsCache: children.vcsCache.get(key),
vcsCache: children.vcsCache.get(directory),
loadLsp: () => {
void queryClient.fetchQuery(loadLspQuery(key, sdkFor(directory)))
void queryClient.fetchQuery(loadLspQuery(directory, sdkFor(directory)))
},
})
})
@@ -370,7 +363,7 @@ function createGlobalSync() {
})
onCleanup(() => {
for (const directory of Object.keys(children.children)) {
children.disposeDirectory(directoryKey(directory))
children.disposeDirectory(directory)
}
})
@@ -17,7 +17,6 @@ import { canDisposeDirectory, pickDirectoriesToEvict } from "./eviction"
import { useQueries } from "@tanstack/solid-query"
import { loadPathQuery, loadProvidersQuery } from "./bootstrap"
import { loadLspQuery, loadMcpQuery } from "../global-sync"
import { directoryKey, type DirectoryKey } from "./utils"
export function createChildStoreManager(input: {
owner: Owner
@@ -37,37 +36,30 @@ export function createChildStoreManager(input: {
const ownerPins = new WeakMap<object, Set<string>>()
const disposers = new Map<string, () => void>()
const markKey = (key: DirectoryKey) => {
if (!key) return
lifecycle.set(key, { lastAccessAt: Date.now() })
runEviction(key)
}
const mark = (directory: string) => {
const key = directoryKey(directory)
markKey(key)
if (!directory) return
lifecycle.set(directory, { lastAccessAt: Date.now() })
runEviction(directory)
}
const pin = (directory: string) => {
const key = directoryKey(directory)
if (!key) return
pins.set(key, (pins.get(key) ?? 0) + 1)
markKey(key)
if (!directory) return
pins.set(directory, (pins.get(directory) ?? 0) + 1)
mark(directory)
}
const unpin = (directory: string) => {
const key = directoryKey(directory)
if (!key) return
const next = (pins.get(key) ?? 0) - 1
if (!directory) return
const next = (pins.get(directory) ?? 0) - 1
if (next > 0) {
pins.set(key, next)
pins.set(directory, next)
return
}
pins.delete(key)
pins.delete(directory)
runEviction()
}
const pinned = (directory: string) => (pins.get(directoryKey(directory)) ?? 0) > 0
const pinned = (directory: string) => (pins.get(directory) ?? 0) > 0
const pinForOwner = (directory: string) => {
const current = getOwner()
@@ -89,31 +81,30 @@ export function createChildStoreManager(input: {
})
}
function disposeDirectory(directory: DirectoryKey) {
const key = directory
function disposeDirectory(directory: string) {
if (
!canDisposeDirectory({
directory: key,
hasStore: !!children[key],
pinned: pinned(key),
booting: input.isBooting(key),
loadingSessions: input.isLoadingSessions(key),
directory,
hasStore: !!children[directory],
pinned: pinned(directory),
booting: input.isBooting(directory),
loadingSessions: input.isLoadingSessions(directory),
})
) {
return false
}
vcsCache.delete(key)
metaCache.delete(key)
iconCache.delete(key)
lifecycle.delete(key)
const dispose = disposers.get(key)
vcsCache.delete(directory)
metaCache.delete(directory)
iconCache.delete(directory)
lifecycle.delete(directory)
const dispose = disposers.get(directory)
if (dispose) {
dispose()
disposers.delete(key)
disposers.delete(directory)
}
delete children[key]
input.onDispose(key)
delete children[directory]
input.onDispose(directory)
return true
}
@@ -130,14 +121,13 @@ export function createChildStoreManager(input: {
}).filter((directory) => directory !== skip)
if (list.length === 0) return
for (const directory of list) {
if (!disposeDirectory(directoryKey(directory))) continue
if (!disposeDirectory(directory)) continue
}
}
function ensureChild(directory: string) {
const key = directoryKey(directory)
if (!key) console.error("No directory provided")
if (!children[key]) {
if (!directory) console.error("No directory provided")
if (!children[directory]) {
const vcs = runWithOwner(input.owner, () =>
persisted(
Persist.workspace(directory, "vcs", ["vcs.v1"]),
@@ -146,7 +136,7 @@ export function createChildStoreManager(input: {
)
if (!vcs) throw new Error(input.translate("error.childStore.persistedCacheCreateFailed"))
const vcsStore = vcs[0]
vcsCache.set(key, { store: vcsStore, setStore: vcs[1], ready: vcs[3] })
vcsCache.set(directory, { store: vcsStore, setStore: vcs[1], ready: vcs[3] })
const meta = runWithOwner(input.owner, () =>
persisted(
@@ -155,7 +145,7 @@ export function createChildStoreManager(input: {
),
)
if (!meta) throw new Error(input.translate("error.childStore.persistedProjectMetadataCreateFailed"))
metaCache.set(key, { store: meta[0], setStore: meta[1], ready: meta[3] })
metaCache.set(directory, { store: meta[0], setStore: meta[1], ready: meta[3] })
const icon = runWithOwner(input.owner, () =>
persisted(
@@ -164,7 +154,7 @@ export function createChildStoreManager(input: {
),
)
if (!icon) throw new Error(input.translate("error.childStore.persistedProjectIconCreateFailed"))
iconCache.set(key, { store: icon[0], setStore: icon[1], ready: icon[3] })
iconCache.set(directory, { store: icon[0], setStore: icon[1], ready: icon[3] })
const init = () =>
createRoot((dispose) => {
@@ -175,10 +165,10 @@ export function createChildStoreManager(input: {
const [pathQuery, mcpQuery, lspQuery, providerQuery] = useQueries(() => ({
queries: [
loadPathQuery(key, sdk),
loadMcpQuery(key, sdk),
loadLspQuery(key, sdk),
loadProvidersQuery(key, sdk),
loadPathQuery(directory, sdk),
loadMcpQuery(directory, sdk),
loadLspQuery(directory, sdk),
loadProvidersQuery(directory, sdk),
],
}))
@@ -187,7 +177,7 @@ export function createChildStoreManager(input: {
projectMeta: initialMeta,
icon: initialIcon,
get provider_ready() {
return !providerQuery.isLoading
return providerQuery.isLoading
},
provider: { all: [], connected: [], default: {} },
config: {},
@@ -207,13 +197,13 @@ export function createChildStoreManager(input: {
permission: {},
question: {},
get mcp_ready() {
return !mcpQuery.isLoading
return mcpQuery.isLoading
},
get mcp() {
return mcpQuery.isLoading ? {} : (mcpQuery.data ?? {})
},
get lsp_ready() {
return !lspQuery.isLoading
return lspQuery.isLoading
},
get lsp() {
return lspQuery.isLoading ? [] : (lspQuery.data ?? [])
@@ -223,13 +213,13 @@ export function createChildStoreManager(input: {
message: {},
part: {},
})
children[key] = child
disposers.set(key, dispose)
children[directory] = child
disposers.set(directory, dispose)
const onPersistedInit = (init: Promise<string> | string | null, run: () => void) => {
if (!(init instanceof Promise)) return
void init.then(() => {
if (children[key] !== child) return
if (children[directory] !== child) return
run()
})
}
@@ -253,16 +243,15 @@ export function createChildStoreManager(input: {
runWithOwner(input.owner, init)
}
markKey(key)
const childStore = children[key]
mark(directory)
const childStore = children[directory]
if (!childStore) throw new Error(input.translate("error.childStore.storeCreateFailed"))
return childStore
}
function child(directory: string, options: ChildOptions = {}) {
const key = directoryKey(directory)
const childStore = ensureChild(directory)
pinForOwner(key)
pinForOwner(directory)
const shouldBootstrap = options.bootstrap ?? true
if (shouldBootstrap && childStore[0].status === "loading") {
input.onBootstrap(directory)
@@ -271,7 +260,6 @@ export function createChildStoreManager(input: {
}
function peek(directory: string, options: ChildOptions = {}) {
const key = directoryKey(directory)
const childStore = ensureChild(directory)
const shouldBootstrap = options.bootstrap ?? true
if (shouldBootstrap && childStore[0].status === "loading") {
@@ -281,9 +269,8 @@ export function createChildStoreManager(input: {
}
function projectMeta(directory: string, patch: ProjectMeta) {
const key = directoryKey(directory)
const [store, setStore] = ensureChild(directory)
const cached = metaCache.get(key)
const cached = metaCache.get(directory)
if (!cached) return
const previous = store.projectMeta ?? {}
const icon = patch.icon ? { ...previous.icon, ...patch.icon } : previous.icon
@@ -299,9 +286,8 @@ export function createChildStoreManager(input: {
}
function projectIcon(directory: string, value: string | undefined) {
const key = directoryKey(directory)
const [store, setStore] = ensureChild(directory)
const cached = iconCache.get(key)
const cached = iconCache.get(directory)
if (!cached) return
if (store.icon === value) return
cached.setStore("value", value)
@@ -1,46 +0,0 @@
import { describe, expect, test } from "bun:test"
import { createRefreshQueue } from "./queue"
import { directoryKey } from "./utils"
const tick = () => new Promise((resolve) => setTimeout(resolve, 10))
describe("createRefreshQueue", () => {
test("clears queued directories by normalized key", async () => {
const calls: string[] = []
const queue = createRefreshQueue({
paused: () => false,
key: directoryKey,
bootstrap: async () => {},
bootstrapInstance: (directory) => {
calls.push(directory)
},
})
queue.push("C:\\tmp\\demo")
queue.clear("C:/tmp/demo")
await tick()
expect(calls).toEqual([])
queue.dispose()
})
test("passes the original directory to bootstrapInstance", async () => {
const calls: string[] = []
const queue = createRefreshQueue({
paused: () => false,
key: directoryKey,
bootstrap: async () => {},
bootstrapInstance: (directory) => {
calls.push(directory)
},
})
queue.push("C:\\tmp\\demo")
await tick()
expect(calls).toEqual(["C:\\tmp\\demo"])
queue.dispose()
})
})
@@ -2,25 +2,22 @@ type QueueInput = {
paused: () => boolean
bootstrap: () => Promise<void>
bootstrapInstance: (directory: string) => Promise<void> | void
key?: (directory: string) => string
}
export function createRefreshQueue(input: QueueInput) {
const queued = new Map<string, string>()
const queued = new Set<string>()
let root = false
let running = false
let timer: ReturnType<typeof setTimeout> | undefined
const key = input.key ?? ((directory: string) => directory)
const tick = () => new Promise<void>((resolve) => setTimeout(resolve, 0))
const take = (count: number) => {
if (queued.size === 0) return [] as string[]
const items: string[] = []
for (const [id, directory] of queued) {
queued.delete(id)
items.push(directory)
for (const item of queued) {
queued.delete(item)
items.push(item)
if (items.length >= count) break
}
return items
@@ -36,7 +33,7 @@ export function createRefreshQueue(input: QueueInput) {
const push = (directory: string) => {
if (!directory) return
queued.set(key(directory), directory)
queued.add(directory)
if (input.paused()) return
schedule()
}
@@ -76,7 +73,7 @@ export function createRefreshQueue(input: QueueInput) {
push,
refresh,
clear(directory: string) {
queued.delete(key(directory))
queued.delete(directory)
},
dispose() {
if (!timer) return
@@ -1,6 +1,6 @@
import { describe, expect, test } from "bun:test"
import type { Agent } from "@opencode-ai/sdk/v2/client"
import { directoryKey, normalizeAgentList } from "./utils"
import { normalizeAgentList } from "./utils"
const agent = (name = "build") =>
({
@@ -33,20 +33,3 @@ describe("normalizeAgentList", () => {
expect(normalizeAgentList([{ name: "build" }, agent("docs")])).toEqual([agent("docs")])
})
})
describe("directoryKey", () => {
test("normalizes slashes", () => {
expect(String(directoryKey("C:\\Repos\\sst\\opencode"))).toBe("C:/Repos/sst/opencode")
expect(String(directoryKey("C:/Repos/sst/opencode"))).toBe("C:/Repos/sst/opencode")
})
test("preserves backslashes in posix paths", () => {
expect(String(directoryKey("/tmp/foo\\bar"))).toBe("/tmp/foo\\bar")
})
test("trims trailing slashes without breaking roots", () => {
expect(String(directoryKey("C:/Repos/sst/opencode/"))).toBe("C:/Repos/sst/opencode")
expect(String(directoryKey("C:/"))).toBe("C:/")
expect(String(directoryKey("/"))).toBe("/")
})
})
@@ -1,5 +1,4 @@
import type { Agent, Project, ProviderListResponse } from "@opencode-ai/sdk/v2/client"
export { pathKey as directoryKey, type PathKey as DirectoryKey } from "@/utils/path-key"
export const cmp = (a: string, b: string) => (a < b ? -1 : a > b ? 1 : 0)
+1 -1
View File
@@ -382,7 +382,7 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
setSaved("session", session, {
agent: msg.agent,
model: msg.model,
variant: msg.model?.variant ?? null,
variant: msg.model.variant ?? null,
})
},
},
+2
View File
@@ -721,6 +721,8 @@ export const dict = {
"settings.permissions.tool.webfetch.description": "جلب محتوى من عنوان URL",
"settings.permissions.tool.websearch.title": "بحث الويب",
"settings.permissions.tool.websearch.description": "البحث في الويب",
"settings.permissions.tool.codesearch.title": "بحث الكود",
"settings.permissions.tool.codesearch.description": "البحث عن كود على الويب",
"settings.permissions.tool.external_directory.title": "دليل خارجي",
"settings.permissions.tool.external_directory.description": "الوصول إلى الملفات خارج دليل المشروع",
"settings.permissions.tool.doom_loop.title": "حلقة الموت",
+2
View File
@@ -732,6 +732,8 @@ export const dict = {
"settings.permissions.tool.webfetch.description": "Buscar conteúdo de uma URL",
"settings.permissions.tool.websearch.title": "Pesquisa Web",
"settings.permissions.tool.websearch.description": "Pesquisar na web",
"settings.permissions.tool.codesearch.title": "Pesquisa de Código",
"settings.permissions.tool.codesearch.description": "Pesquisar código na web",
"settings.permissions.tool.external_directory.title": "Diretório Externo",
"settings.permissions.tool.external_directory.description": "Acessar arquivos fora do diretório do projeto",
"settings.permissions.tool.doom_loop.title": "Loop Infinito",
+2
View File
@@ -806,6 +806,8 @@ export const dict = {
"settings.permissions.tool.webfetch.description": "Preuzmi sadržaj sa URL-a",
"settings.permissions.tool.websearch.title": "Web pretraga",
"settings.permissions.tool.websearch.description": "Pretražuj web",
"settings.permissions.tool.codesearch.title": "Pretraga koda",
"settings.permissions.tool.codesearch.description": "Pretraži kod na webu",
"settings.permissions.tool.external_directory.title": "Vanjski direktorij",
"settings.permissions.tool.external_directory.description": "Pristup datotekama izvan direktorija projekta",
"settings.permissions.tool.doom_loop.title": "Beskonačna petlja",
+2
View File
@@ -800,6 +800,8 @@ export const dict = {
"settings.permissions.tool.webfetch.description": "Hent indhold fra en URL",
"settings.permissions.tool.websearch.title": "Websøgning",
"settings.permissions.tool.websearch.description": "Søg på nettet",
"settings.permissions.tool.codesearch.title": "Kodesøgning",
"settings.permissions.tool.codesearch.description": "Søg kode på nettet",
"settings.permissions.tool.external_directory.title": "Ekstern mappe",
"settings.permissions.tool.external_directory.description": "Få adgang til filer uden for projektmappen",
"settings.permissions.tool.doom_loop.title": "Doom Loop",
+2
View File
@@ -743,6 +743,8 @@ export const dict = {
"settings.permissions.tool.webfetch.description": "Inhalt von einer URL abrufen",
"settings.permissions.tool.websearch.title": "Web-Suche",
"settings.permissions.tool.websearch.description": "Das Web durchsuchen",
"settings.permissions.tool.codesearch.title": "Code-Suche",
"settings.permissions.tool.codesearch.description": "Code im Web durchsuchen",
"settings.permissions.tool.external_directory.title": "Externes Verzeichnis",
"settings.permissions.tool.external_directory.description": "Zugriff auf Dateien außerhalb des Projektverzeichnisses",
"settings.permissions.tool.doom_loop.title": "Doom Loop",
+2
View File
@@ -920,6 +920,8 @@ export const dict = {
"settings.permissions.tool.webfetch.description": "Fetch content from a URL",
"settings.permissions.tool.websearch.title": "Web Search",
"settings.permissions.tool.websearch.description": "Search the web",
"settings.permissions.tool.codesearch.title": "Code Search",
"settings.permissions.tool.codesearch.description": "Search code on the web",
"settings.permissions.tool.external_directory.title": "External Directory",
"settings.permissions.tool.external_directory.description": "Access files outside the project directory",
"settings.permissions.tool.doom_loop.title": "Doom Loop",
+2
View File
@@ -813,6 +813,8 @@ export const dict = {
"settings.permissions.tool.webfetch.description": "Obtener contenido de una URL",
"settings.permissions.tool.websearch.title": "Búsqueda Web",
"settings.permissions.tool.websearch.description": "Buscar en la web",
"settings.permissions.tool.codesearch.title": "Búsqueda de Código",
"settings.permissions.tool.codesearch.description": "Buscar código en la web",
"settings.permissions.tool.external_directory.title": "Directorio Externo",
"settings.permissions.tool.external_directory.description": "Acceder a archivos fuera del directorio del proyecto",
"settings.permissions.tool.doom_loop.title": "Bucle Infinito",
+2
View File
@@ -741,6 +741,8 @@ export const dict = {
"settings.permissions.tool.webfetch.description": "Récupérer le contenu d'une URL",
"settings.permissions.tool.websearch.title": "Recherche Web",
"settings.permissions.tool.websearch.description": "Rechercher sur le web",
"settings.permissions.tool.codesearch.title": "Recherche de code",
"settings.permissions.tool.codesearch.description": "Rechercher du code sur le web",
"settings.permissions.tool.external_directory.title": "Répertoire externe",
"settings.permissions.tool.external_directory.description": "Accéder aux fichiers en dehors du répertoire du projet",
"settings.permissions.tool.doom_loop.title": "Boucle infernale",
+2
View File
@@ -727,6 +727,8 @@ export const dict = {
"settings.permissions.tool.webfetch.description": "URLからコンテンツを取得",
"settings.permissions.tool.websearch.title": "Web検索",
"settings.permissions.tool.websearch.description": "ウェブを検索",
"settings.permissions.tool.codesearch.title": "コード検索",
"settings.permissions.tool.codesearch.description": "ウェブ上のコードを検索",
"settings.permissions.tool.external_directory.title": "外部ディレクトリ",
"settings.permissions.tool.external_directory.description": "プロジェクトディレクトリ外のファイルへのアクセス",
"settings.permissions.tool.doom_loop.title": "無限ループ",
+2
View File
@@ -722,6 +722,8 @@ export const dict = {
"settings.permissions.tool.webfetch.description": "URL에서 콘텐츠 가져오기",
"settings.permissions.tool.websearch.title": "웹 검색",
"settings.permissions.tool.websearch.description": "웹 검색",
"settings.permissions.tool.codesearch.title": "코드 검색",
"settings.permissions.tool.codesearch.description": "웹에서 코드 검색",
"settings.permissions.tool.external_directory.title": "외부 디렉터리",
"settings.permissions.tool.external_directory.description": "프로젝트 디렉터리 외부의 파일에 액세스",
"settings.permissions.tool.doom_loop.title": "무한 반복",
+2
View File
@@ -807,6 +807,8 @@ export const dict = {
"settings.permissions.tool.webfetch.description": "Hent innhold fra en URL",
"settings.permissions.tool.websearch.title": "Websøk",
"settings.permissions.tool.websearch.description": "Søk på nettet",
"settings.permissions.tool.codesearch.title": "Kodesøk",
"settings.permissions.tool.codesearch.description": "Søk etter kode på nettet",
"settings.permissions.tool.external_directory.title": "Ekstern mappe",
"settings.permissions.tool.external_directory.description": "Få tilgang til filer utenfor prosjektmappen",
"settings.permissions.tool.doom_loop.title": "Doom Loop",
+2
View File
@@ -729,6 +729,8 @@ export const dict = {
"settings.permissions.tool.webfetch.description": "Pobieranie zawartości z adresu URL",
"settings.permissions.tool.websearch.title": "Wyszukiwanie w sieci",
"settings.permissions.tool.websearch.description": "Przeszukiwanie sieci",
"settings.permissions.tool.codesearch.title": "Wyszukiwanie kodu",
"settings.permissions.tool.codesearch.description": "Przeszukiwanie kodu w sieci",
"settings.permissions.tool.external_directory.title": "Katalog zewnętrzny",
"settings.permissions.tool.external_directory.description": "Dostęp do plików poza katalogiem projektu",
"settings.permissions.tool.doom_loop.title": "Zapętlenie",
+2
View File
@@ -808,6 +808,8 @@ export const dict = {
"settings.permissions.tool.webfetch.description": "Получение контента по URL",
"settings.permissions.tool.websearch.title": "Web Search",
"settings.permissions.tool.websearch.description": "Поиск в интернете",
"settings.permissions.tool.codesearch.title": "Code Search",
"settings.permissions.tool.codesearch.description": "Поиск кода в интернете",
"settings.permissions.tool.external_directory.title": "Внешняя директория",
"settings.permissions.tool.external_directory.description": "Доступ к файлам вне директории проекта",
"settings.permissions.tool.doom_loop.title": "Doom Loop",
+2
View File
@@ -796,6 +796,8 @@ export const dict = {
"settings.permissions.tool.webfetch.description": "ดึงเนื้อหาจาก URL",
"settings.permissions.tool.websearch.title": "ค้นหาเว็บ",
"settings.permissions.tool.websearch.description": "ค้นหาบนเว็บ",
"settings.permissions.tool.codesearch.title": "ค้นหาโค้ด",
"settings.permissions.tool.codesearch.description": "ค้นหาโค้ดบนเว็บ",
"settings.permissions.tool.external_directory.title": "ไดเรกทอรีภายนอก",
"settings.permissions.tool.external_directory.description": "เข้าถึงไฟล์นอกไดเรกทอรีโปรเจกต์",
"settings.permissions.tool.doom_loop.title": "Doom Loop",
+2
View File
@@ -816,6 +816,8 @@ export const dict = {
"settings.permissions.tool.webfetch.description": "Bir URL'den içerik getir",
"settings.permissions.tool.websearch.title": "Web Ara",
"settings.permissions.tool.websearch.description": "Web'de ara",
"settings.permissions.tool.codesearch.title": "Kod Ara",
"settings.permissions.tool.codesearch.description": "Web'de kod ara",
"settings.permissions.tool.external_directory.title": "Harici Dizin",
"settings.permissions.tool.external_directory.description": "Proje dizini dışındaki dosyalara eriş",
"settings.permissions.tool.doom_loop.title": "Sonsuz Döngü",
+2
View File
@@ -793,6 +793,8 @@ export const dict = {
"settings.permissions.tool.webfetch.description": "从 URL 获取内容",
"settings.permissions.tool.websearch.title": "网页搜索",
"settings.permissions.tool.websearch.description": "搜索网页",
"settings.permissions.tool.codesearch.title": "代码搜索",
"settings.permissions.tool.codesearch.description": "在网上搜索代码",
"settings.permissions.tool.external_directory.title": "外部目录",
"settings.permissions.tool.external_directory.description": "访问项目目录之外的文件",
"settings.permissions.tool.doom_loop.title": "死循环",
+2
View File
@@ -789,6 +789,8 @@ export const dict = {
"settings.permissions.tool.webfetch.description": "從 URL 取得內容",
"settings.permissions.tool.websearch.title": "Web Search",
"settings.permissions.tool.websearch.description": "搜尋網頁",
"settings.permissions.tool.codesearch.title": "Code Search",
"settings.permissions.tool.codesearch.description": "在網路上搜尋程式碼",
"settings.permissions.tool.external_directory.title": "外部目錄",
"settings.permissions.tool.external_directory.description": "存取專案目錄之外的檔案",
"settings.permissions.tool.doom_loop.title": "Doom Loop",
+39 -33
View File
@@ -64,13 +64,13 @@ import { DebugBar } from "@/components/debug-bar"
import { Titlebar } from "@/components/titlebar"
import { useServer } from "@/context/server"
import { useLanguage, type Locale } from "@/context/language"
import { pathKey } from "@/utils/path-key"
import {
displayName,
effectiveWorkspaceOrder,
errorMessage,
latestRootSession,
sortedRootSessions,
workspaceKey,
} from "./layout/helpers"
import {
collectNewSessionDeepLinks,
@@ -164,7 +164,7 @@ export default function Layout(props: ParentProps) {
const editor = createInlineEditorController()
const setBusy = (directory: string, value: boolean) => {
const key = pathKey(directory)
const key = workspaceKey(directory)
if (value) {
setState("busyWorkspaces", key, true)
return
@@ -176,7 +176,7 @@ export default function Layout(props: ParentProps) {
}),
)
}
const isBusy = (directory: string) => !!state.busyWorkspaces[pathKey(directory)]
const isBusy = (directory: string) => !!state.busyWorkspaces[workspaceKey(directory)]
const navLeave = { current: undefined as number | undefined }
const sortNow = () => state.sortNow
let sizet: number | undefined
@@ -497,8 +497,8 @@ export default function Layout(props: ParentProps) {
}
const currentSession = params.id
if (pathKey(directory) === pathKey(currentDir()) && props.sessionID === currentSession) return
if (pathKey(directory) === pathKey(currentDir()) && session?.parentID === currentSession) return
if (workspaceKey(directory) === workspaceKey(currentDir()) && props.sessionID === currentSession) return
if (workspaceKey(directory) === workspaceKey(currentDir()) && session?.parentID === currentSession) return
dismissSessionAlert(sessionKey)
@@ -556,14 +556,14 @@ export default function Layout(props: ParentProps) {
const currentProject = createMemo(() => {
const directory = currentDir()
if (!directory) return
const key = pathKey(directory)
const key = workspaceKey(directory)
const projects = layout.projects.list()
const sandbox = projects.find((p) => p.sandboxes?.some((item) => pathKey(item) === key))
const sandbox = projects.find((p) => p.sandboxes?.some((item) => workspaceKey(item) === key))
if (sandbox) return sandbox
const direct = projects.find((p) => pathKey(p.worktree) === key)
const direct = projects.find((p) => workspaceKey(p.worktree) === key)
if (direct) return direct
const [child] = globalSync.child(directory, { bootstrap: false })
@@ -596,7 +596,7 @@ export default function Layout(props: ParentProps) {
})
const workspaceName = (directory: string, projectId?: string, branch?: string) => {
const key = pathKey(directory)
const key = workspaceKey(directory)
const direct = store.workspaceName[key] ?? store.workspaceName[directory]
if (direct) return direct
if (!projectId) return
@@ -605,7 +605,7 @@ export default function Layout(props: ParentProps) {
}
const setWorkspaceName = (directory: string, next: string, projectId?: string, branch?: string) => {
const key = pathKey(directory)
const key = workspaceKey(directory)
setStore("workspaceName", key, next)
if (!projectId) return
if (!branch) return
@@ -633,7 +633,7 @@ export default function Layout(props: ParentProps) {
const activeDir = currentDir()
return workspaceIds(project).filter((directory) => {
const expanded = store.workspaceExpanded[directory] ?? directory === project.worktree
const active = pathKey(directory) === pathKey(activeDir)
const active = workspaceKey(directory) === workspaceKey(activeDir)
return expanded || active
})
})
@@ -644,9 +644,10 @@ export default function Layout(props: ParentProps) {
const projects = layout.projects.list()
for (const [directory, expanded] of Object.entries(store.workspaceExpanded)) {
if (!expanded) continue
const key = pathKey(directory)
const key = workspaceKey(directory)
const project = projects.find(
(item) => pathKey(item.worktree) === key || item.sandboxes?.some((sandbox) => pathKey(sandbox) === key),
(item) =>
workspaceKey(item.worktree) === key || item.sandboxes?.some((sandbox) => workspaceKey(sandbox) === key),
)
if (!project) continue
if (project.vcs === "git" && layout.sidebar.workspaces(project.worktree)()) continue
@@ -699,7 +700,7 @@ export default function Layout(props: ParentProps) {
seen: lru,
keep: sessionID,
limit: PREFETCH_MAX_SESSIONS_PER_DIR,
preserve: params.id && pathKey(directory) === pathKey(currentDir()) ? [params.id] : undefined,
preserve: params.id && workspaceKey(directory) === workspaceKey(currentDir()) ? [params.id] : undefined,
})
}
@@ -1220,14 +1221,17 @@ export default function Layout(props: ParentProps) {
}
function projectRoot(directory: string) {
const key = pathKey(directory)
const key = workspaceKey(directory)
const project = layout.projects
.list()
.find((item) => pathKey(item.worktree) === key || item.sandboxes?.some((sandbox) => pathKey(sandbox) === key))
.find(
(item) =>
workspaceKey(item.worktree) === key || item.sandboxes?.some((sandbox) => workspaceKey(sandbox) === key),
)
if (project) return project.worktree
const known = Object.entries(store.workspaceOrder).find(
([root, dirs]) => pathKey(root) === key || dirs.some((item) => pathKey(item) === key),
([root, dirs]) => workspaceKey(root) === key || dirs.some((item) => workspaceKey(item) === key),
)
if (known) return known[0]
@@ -1279,7 +1283,7 @@ export default function Layout(props: ParentProps) {
: [root]
const canOpen = (value: string | undefined) => {
if (!value) return false
return dirs.some((item) => pathKey(item) === pathKey(value))
return dirs.some((item) => workspaceKey(item) === workspaceKey(value))
}
const refreshDirs = async (target?: string) => {
if (!target || target === root || canOpen(target)) return canOpen(target)
@@ -1405,9 +1409,9 @@ export default function Layout(props: ParentProps) {
function closeProject(directory: string) {
const list = layout.projects.list()
const key = pathKey(directory)
const index = list.findIndex((x) => pathKey(x.worktree) === key)
const active = pathKey(currentProject()?.worktree ?? "") === key
const key = workspaceKey(directory)
const index = list.findIndex((x) => workspaceKey(x.worktree) === key)
const active = workspaceKey(currentProject()?.worktree ?? "") === key
if (index === -1) return
const next = list[index + 1]
@@ -1481,8 +1485,8 @@ export default function Layout(props: ParentProps) {
if (directory === root) return
const current = currentDir()
const currentKey = pathKey(current)
const deletedKey = pathKey(directory)
const currentKey = workspaceKey(current)
const deletedKey = workspaceKey(directory)
const shouldLeave = leaveDeletedWorkspace || (!!params.dir && currentKey === deletedKey)
if (!leaveDeletedWorkspace && shouldLeave) {
navigateWithSidebarReset(`/${base64Encode(root)}/session`)
@@ -1505,7 +1509,7 @@ export default function Layout(props: ParentProps) {
if (!result) return
if (pathKey(store.lastProjectSession[root]?.directory ?? "") === pathKey(directory)) {
if (workspaceKey(store.lastProjectSession[root]?.directory ?? "") === workspaceKey(directory)) {
clearLastProjectSession(root)
}
@@ -1525,12 +1529,12 @@ export default function Layout(props: ParentProps) {
if (shouldLeave) return
const nextCurrent = currentDir()
const nextKey = pathKey(nextCurrent)
const nextKey = workspaceKey(nextCurrent)
const project = layout.projects.list().find((item) => item.worktree === root)
const dirs = project
? effectiveWorkspaceOrder(root, [root, ...(project.sandboxes ?? [])], store.workspaceOrder[root])
: [root]
const valid = dirs.some((item) => pathKey(item) === nextKey)
const valid = dirs.some((item) => workspaceKey(item) === nextKey)
if (params.dir && projectRoot(nextCurrent) === root && !valid) {
navigateWithSidebarReset(`/${base64Encode(root)}/session`)
@@ -1636,7 +1640,7 @@ export default function Layout(props: ParentProps) {
})
const handleDelete = () => {
const leaveDeletedWorkspace = !!params.dir && pathKey(currentDir()) === pathKey(props.directory)
const leaveDeletedWorkspace = !!params.dir && workspaceKey(currentDir()) === workspaceKey(props.directory)
if (leaveDeletedWorkspace) {
navigateWithSidebarReset(`/${base64Encode(props.root)}/session`)
}
@@ -1863,9 +1867,11 @@ export default function Layout(props: ParentProps) {
const local = project.worktree
const dirs = [local, ...(project.sandboxes ?? [])]
const active = currentProject()
const directory = pathKey(active?.worktree ?? "") === pathKey(project.worktree) ? currentDir() : undefined
const directory = workspaceKey(active?.worktree ?? "") === workspaceKey(project.worktree) ? currentDir() : undefined
const extra =
directory && pathKey(directory) !== pathKey(local) && !dirs.some((item) => pathKey(item) === pathKey(directory))
directory &&
workspaceKey(directory) !== workspaceKey(local) &&
!dirs.some((item) => workspaceKey(item) === workspaceKey(directory))
? directory
: undefined
const pending = extra ? WorktreeState.get(extra)?.status === "pending" : false
@@ -1910,7 +1916,7 @@ export default function Layout(props: ParentProps) {
setStore(
"workspaceOrder",
project.worktree,
result.filter((directory) => pathKey(directory) !== pathKey(project.worktree)),
result.filter((directory) => workspaceKey(directory) !== workspaceKey(project.worktree)),
)
}
@@ -1936,8 +1942,8 @@ export default function Layout(props: ParentProps) {
setWorkspaceName(created.directory, created.branch, project.id, created.branch)
const local = project.worktree
const key = pathKey(created.directory)
const root = pathKey(local)
const key = workspaceKey(created.directory)
const root = workspaceKey(local)
setBusy(created.directory, true)
WorktreeState.pending(created.directory)
@@ -1948,7 +1954,7 @@ export default function Layout(props: ParentProps) {
setStore("workspaceOrder", project.worktree, (prev) => {
const existing = prev ?? []
const next = existing.filter((item) => {
const id = pathKey(item)
const id = workspaceKey(item)
return id !== root && id !== key
})
return [created.directory, ...next]
@@ -14,8 +14,8 @@ import {
errorMessage,
hasProjectPermissions,
latestRootSession,
workspaceKey,
} from "./helpers"
import { pathKey } from "@/utils/path-key"
const session = (input: Partial<Session> & Pick<Session, "id" | "directory">) =>
({
@@ -104,16 +104,16 @@ describe("layout deep links", () => {
describe("layout workspace helpers", () => {
test("normalizes trailing slash in workspace key", () => {
expect(String(pathKey("/tmp/demo///"))).toBe("/tmp/demo")
expect(String(pathKey("C:\\tmp\\demo\\\\"))).toBe("C:/tmp/demo")
expect(workspaceKey("/tmp/demo///")).toBe("/tmp/demo")
expect(workspaceKey("C:\\tmp\\demo\\\\")).toBe("C:/tmp/demo")
})
test("preserves posix and drive roots in workspace key", () => {
expect(String(pathKey("/"))).toBe("/")
expect(String(pathKey("///"))).toBe("/")
expect(String(pathKey("C:\\"))).toBe("C:/")
expect(String(pathKey("C://"))).toBe("C:/")
expect(String(pathKey("C:///"))).toBe("C:/")
expect(workspaceKey("/")).toBe("/")
expect(workspaceKey("///")).toBe("/")
expect(workspaceKey("C:\\")).toBe("C:/")
expect(workspaceKey("C://")).toBe("C:/")
expect(workspaceKey("C:///")).toBe("C:/")
})
test("keeps local first while preserving known order", () => {
+12 -5
View File
@@ -1,12 +1,19 @@
import { getFilename } from "@opencode-ai/core/util/path"
import { type Session } from "@opencode-ai/sdk/v2/client"
import { pathKey } from "@/utils/path-key"
type SessionStore = {
session?: Session[]
path: { directory: string }
}
export const workspaceKey = (directory: string) => {
const value = directory.replaceAll("\\", "/")
const drive = value.match(/^([A-Za-z]:)\/+$/)
if (drive) return `${drive[1]}/`
if (/^\/+$/i.test(value)) return "/"
return value.replace(/\/+$/, "")
}
function sortSessions(now: number) {
const oneMinuteAgo = now - 60 * 1000
return (a: Session, b: Session) => {
@@ -22,7 +29,7 @@ function sortSessions(now: number) {
}
const isRootVisibleSession = (session: Session, directory: string) =>
pathKey(session.directory) === pathKey(directory) && !session.parentID && !session.time?.archived
workspaceKey(session.directory) === workspaceKey(directory) && !session.parentID && !session.time?.archived
export const roots = (store: SessionStore) =>
(store.session ?? []).filter((session) => isRootVisibleSession(session, store.path.directory))
@@ -65,11 +72,11 @@ export const errorMessage = (err: unknown, fallback: string) => {
}
export const effectiveWorkspaceOrder = (local: string, dirs: string[], persisted?: string[]) => {
const root = pathKey(local)
const root = workspaceKey(local)
const live = new Map<string, string>()
for (const dir of dirs) {
const key = pathKey(dir)
const key = workspaceKey(dir)
if (key === root) continue
if (!live.has(key)) live.set(key, dir)
}
@@ -78,7 +85,7 @@ export const effectiveWorkspaceOrder = (local: string, dirs: string[], persisted
const result = [local]
for (const dir of persisted) {
const key = pathKey(dir)
const key = workspaceKey(dir)
if (key === root) continue
const match = live.get(key)
if (!match) continue
@@ -20,10 +20,9 @@ import { childSessionOnPath, hasProjectPermissions } from "./helpers"
const OPENCODE_PROJECT_ID = "4b0ea68d7af9a6031a7ffda7ad66e0cb83315750"
export function getProjectAvatarSource(id?: string, icon?: { color?: string; url?: string; override?: string }) {
if (id === OPENCODE_PROJECT_ID) return "https://opencode.ai/favicon.svg"
if (icon?.override) return icon?.override
if (icon?.color) return undefined
return icon?.url
return id === OPENCODE_PROJECT_ID
? "https://opencode.ai/favicon.svg"
: (icon?.override ?? (icon?.color ? undefined : icon?.url))
}
export const ProjectIcon = (props: { project: LocalProject; class?: string; notify?: boolean }): JSX.Element => {
@@ -16,9 +16,8 @@ import { type Session } from "@opencode-ai/sdk/v2/client"
import { type LocalProject } from "@/context/layout"
import { loadSessionsQuery, useGlobalSync } from "@/context/global-sync"
import { useLanguage } from "@/context/language"
import { pathKey } from "@/utils/path-key"
import { NewSessionItem, SessionItem, SessionSkeleton } from "./sidebar-items"
import { sortedRootSessions } from "./helpers"
import { sortedRootSessions, workspaceKey } from "./helpers"
import { useQuery } from "@tanstack/solid-query"
type InlineEditorComponent = (props: {
@@ -310,7 +309,7 @@ export const SortableWorkspace = (props: {
const slug = createMemo(() => base64Encode(props.directory))
const sessions = createMemo(() => sortedRootSessions(workspaceStore, props.sortNow()))
const local = createMemo(() => props.directory === props.project.worktree)
const active = createMemo(() => pathKey(props.ctx.currentDir()) === pathKey(props.directory))
const active = createMemo(() => workspaceKey(props.ctx.currentDir()) === workspaceKey(props.directory))
const workspaceValue = createMemo(() => {
const branch = workspaceStore.vcs?.branch
const name = branch ?? getFilename(props.directory)
-24
View File
@@ -1,24 +0,0 @@
export type PathKey = string & { _brand: "PathKey" }
const isDrive = (value: string) => {
if (value.length !== 2) return false
const code = value.charCodeAt(0)
return value[1] === ":" && ((code >= 65 && code <= 90) || (code >= 97 && code <= 122))
}
const trimTrailingSlashes = (value: string) => {
for (let i = value.length - 1; i >= 0; i--) {
if (value[i] !== "/") return value.slice(0, i + 1)
}
return ""
}
const isWindowsPath = (value: string) => value[1] === ":" || value.startsWith("\\\\")
export const pathKey = (path: string) => {
const value = isWindowsPath(path) ? path.replaceAll("\\", "/") : path
const trimmed = trimTrailingSlashes(value)
if (!trimmed && value.startsWith("/")) return "/" as PathKey
if (isDrive(trimmed)) return `${trimmed}/` as PathKey
return trimmed as PathKey
}
-52
View File
@@ -1,8 +1,6 @@
import { beforeAll, beforeEach, describe, expect, mock, test } from "bun:test"
type PersistTestingType = typeof import("./persist").PersistTesting
type PersistType = typeof import("./persist").Persist
type RemovePersistedType = typeof import("./persist").removePersisted
class MemoryStorage implements Storage {
private values = new Map<string, string>()
@@ -47,8 +45,6 @@ class MemoryStorage implements Storage {
const storage = new MemoryStorage()
let persistTesting: PersistTestingType
let Persist: PersistType
let removePersisted: RemovePersistedType
beforeAll(async () => {
mock.module("@/context/platform", () => ({
@@ -57,8 +53,6 @@ beforeAll(async () => {
const mod = await import("./persist")
persistTesting = mod.PersistTesting
Persist = mod.Persist
removePersisted = mod.removePersisted
})
beforeEach(() => {
@@ -118,50 +112,4 @@ describe("persist localStorage resilience", () => {
expect(result.endsWith(".dat")).toBeTrue()
expect(/[:\\/]/.test(result)).toBeFalse()
})
test("workspace target keeps raw path storage as legacy fallback", () => {
const target = Persist.workspace("C:\\Users\\foo", "vcs")
expect(target.storage).toBe(persistTesting.workspaceStorage("C:/Users/foo"))
expect(target.legacyStorageNames).toEqual([persistTesting.workspaceStorage("C:\\Users\\foo")])
})
test("workspace target keeps backslash storage as fallback for normalized Windows paths", () => {
const target = Persist.workspace("C:/Users/foo", "vcs")
expect(target.storage).toBe(persistTesting.workspaceStorage("C:/Users/foo"))
expect(target.legacyStorageNames).toEqual([persistTesting.workspaceStorage("C:\\Users\\foo")])
})
test("migrates direct legacy keys into scoped storage", () => {
storage.setItem("legacy.workspace", '{"value":2}')
const target = Persist.workspace("C:/Users/foo", "demo", ["legacy.workspace"])
const current = persistTesting.localStorageWithPrefix(target.storage!)
const legacyStore = persistTesting.localStorageDirect()
const result = persistTesting.migrateLegacy({
current,
legacyStore,
stores: [],
keys: target.legacy!,
key: target.key,
defaults: { value: 1 },
})
expect(result).toBe('{"value":2}')
expect(storage.getItem(`${target.storage}:${target.key}`)).toBe('{"value":2}')
expect(legacyStore.getItem("legacy.workspace")).toBeNull()
expect(storage.getItem("legacy.workspace")).toBeNull()
})
test("removes legacy workspace storage when removing persisted target", () => {
const target = Persist.workspace("C:\\Users\\foo", "terminal")
storage.setItem(`${target.storage}:${target.key}`, '{"value":1}')
storage.setItem(`${target.legacyStorageNames![0]}:${target.key}`, '{"value":2}')
removePersisted(target)
expect(storage.getItem(`${target.storage}:${target.key}`)).toBeNull()
expect(storage.getItem(`${target.legacyStorageNames![0]}:${target.key}`)).toBeNull()
})
})
+58 -193
View File
@@ -3,7 +3,6 @@ import { makePersisted, type AsyncStorage, type SyncStorage } from "@solid-primi
import { checksum } from "@opencode-ai/core/util/encode"
import { createResource, type Accessor } from "solid-js"
import type { SetStoreFunction, Store } from "solid-js/store"
import { pathKey } from "@/utils/path-key"
type InitType = Promise<string> | string | null
type PersistedWithReady<T> = [
@@ -15,7 +14,6 @@ type PersistedWithReady<T> = [
type PersistTarget = {
storage?: string
legacyStorageNames?: string[]
key: string
legacy?: string[]
migrate?: (value: unknown) => unknown
@@ -210,153 +208,12 @@ function normalize(defaults: unknown, raw: string, migrate?: (value: unknown) =>
return JSON.stringify(merged)
}
function readCurrent(input: {
storage: SyncStorage
key: string
defaults: unknown
migrate?: (value: unknown) => unknown
}) {
const raw = input.storage.getItem(input.key)
if (raw === null) return
const next = normalize(input.defaults, raw, input.migrate)
if (next === undefined) {
input.storage.removeItem(input.key)
return null
}
if (raw !== next) input.storage.setItem(input.key, next)
return next
}
function migrateLegacy(input: {
current: SyncStorage
legacyStore?: SyncStorage
stores: SyncStorage[]
keys: string[]
key: string
defaults: unknown
migrate?: (value: unknown) => unknown
}) {
for (const store of input.stores) {
const raw = store.getItem(input.key)
if (raw === null) continue
const next = normalize(input.defaults, raw, input.migrate)
if (next === undefined) {
store.removeItem(input.key)
continue
}
input.current.setItem(input.key, next)
store.removeItem(input.key)
return next
}
if (!input.legacyStore) return null
for (const key of input.keys) {
const raw = input.legacyStore.getItem(key)
if (raw === null) continue
const next = normalize(input.defaults, raw, input.migrate)
if (next === undefined) {
input.legacyStore.removeItem(key)
continue
}
input.current.setItem(input.key, next)
input.legacyStore.removeItem(key)
return next
}
return null
}
async function readCurrentAsync(input: {
storage: AsyncStorage
key: string
defaults: unknown
migrate?: (value: unknown) => unknown
}) {
const raw = await input.storage.getItem(input.key)
if (raw === null) return
const next = normalize(input.defaults, raw, input.migrate)
if (next === undefined) {
await input.storage.removeItem(input.key).catch(() => undefined)
return null
}
if (raw !== next) await input.storage.setItem(input.key, next)
return next
}
async function removeAsync(storage: AsyncStorage, key: string) {
try {
await storage.removeItem(key)
} catch {}
}
async function migrateLegacyAsync(input: {
current: AsyncStorage
legacyStore?: AsyncStorage
stores: AsyncStorage[]
keys: string[]
key: string
defaults: unknown
migrate?: (value: unknown) => unknown
}) {
for (const store of input.stores) {
const raw = await store.getItem(input.key)
if (raw === null) continue
const next = normalize(input.defaults, raw, input.migrate)
if (next === undefined) {
await removeAsync(store, input.key)
continue
}
await input.current.setItem(input.key, next)
await store.removeItem(input.key)
return next
}
if (!input.legacyStore) return null
for (const key of input.keys) {
const raw = await input.legacyStore.getItem(key)
if (raw === null) continue
const next = normalize(input.defaults, raw, input.migrate)
if (next === undefined) {
await removeAsync(input.legacyStore, key)
continue
}
await input.current.setItem(input.key, next)
await input.legacyStore.removeItem(key)
return next
}
return null
}
function workspaceStorage(dir: string) {
const head = (dir.slice(0, 12) || "workspace").replace(/[^a-zA-Z0-9._-]/g, "-")
const sum = checksum(dir) ?? "0"
return `opencode.workspace.${head}.${sum}.dat`
}
function legacyWorkspaceStorage(dir: string) {
const storage = workspaceStorage(pathKey(dir))
const result = new Set<string>()
const raw = workspaceStorage(dir)
if (raw !== storage) result.add(raw)
const key = pathKey(dir)
const drive = key.length >= 3 && key[1] === ":" && key[2] === "/"
if (drive) {
const backslash = workspaceStorage(key.replaceAll("/", "\\"))
if (backslash !== storage) result.add(backslash)
}
if (result.size === 0) return
return [...result]
}
function localStorageWithPrefix(prefix: string): SyncStorage {
const base = `${prefix}:`
const scope = `prefix:${prefix}`
@@ -447,7 +304,6 @@ function localStorageDirect(): SyncStorage {
export const PersistTesting = {
localStorageDirect,
localStorageWithPrefix,
migrateLegacy,
normalize,
workspaceStorage,
}
@@ -457,17 +313,10 @@ export const Persist = {
return { storage: GLOBAL_STORAGE, key, legacy }
},
workspace(dir: string, key: string, legacy?: string[]): PersistTarget {
const storage = workspaceStorage(pathKey(dir))
return { storage, legacyStorageNames: legacyWorkspaceStorage(dir), key: `workspace:${key}`, legacy }
return { storage: workspaceStorage(dir), key: `workspace:${key}`, legacy }
},
session(dir: string, session: string, key: string, legacy?: string[]): PersistTarget {
const storage = workspaceStorage(pathKey(dir))
return {
storage,
legacyStorageNames: legacyWorkspaceStorage(dir),
key: `session:${session}:${key}`,
legacy,
}
return { storage: workspaceStorage(dir), key: `session:${session}:${key}`, legacy }
},
scoped(dir: string, session: string | undefined, key: string, legacy?: string[]): PersistTarget {
if (session) return Persist.session(dir, session, key, legacy)
@@ -475,18 +324,11 @@ export const Persist = {
},
}
export function removePersisted(
target: { storage?: string; legacyStorageNames?: string[]; key: string },
platform?: Platform,
) {
export function removePersisted(target: { storage?: string; key: string }, platform?: Platform) {
const isDesktop = platform?.platform === "desktop" && !!platform.storage
if (isDesktop) {
void platform.storage?.(target.storage)?.removeItem(target.key)
for (const storage of target.legacyStorageNames ?? []) {
void platform.storage?.(storage)?.removeItem(target.key)
}
return
return platform.storage?.(target.storage)?.removeItem(target.key)
}
if (!target.storage) {
@@ -495,9 +337,6 @@ export function removePersisted(
}
localStorageWithPrefix(target.storage).removeItem(target.key)
for (const storage of target.legacyStorageNames ?? []) {
localStorageWithPrefix(storage).removeItem(target.key)
}
}
export function persisted<T>(
@@ -524,27 +363,39 @@ export function persisted<T>(
return platform.storage?.(LEGACY_STORAGE)
})()
const legacyStorageNames = config.legacyStorageNames ?? []
const storage = (() => {
if (!isDesktop) {
const current = currentStorage as SyncStorage
const legacyStore = legacyStorage as SyncStorage
const legacyStores = legacyStorageNames.map(localStorageWithPrefix)
const api: SyncStorage = {
getItem: (key) => {
const value = readCurrent({ storage: current, key, defaults, migrate: config.migrate })
if (value !== undefined) return value
return migrateLegacy({
current,
legacyStore,
stores: legacyStores,
keys: legacy,
key,
defaults,
migrate: config.migrate,
})
const raw = current.getItem(key)
if (raw !== null) {
const next = normalize(defaults, raw, config.migrate)
if (next === undefined) {
current.removeItem(key)
return null
}
if (raw !== next) current.setItem(key, next)
return next
}
for (const legacyKey of legacy) {
const legacyRaw = legacyStore.getItem(legacyKey)
if (legacyRaw === null) continue
const next = normalize(defaults, legacyRaw, config.migrate)
if (next === undefined) {
legacyStore.removeItem(legacyKey)
continue
}
current.setItem(key, next)
legacyStore.removeItem(legacyKey)
return next
}
return null
},
setItem: (key, value) => {
current.setItem(key, value)
@@ -559,23 +410,37 @@ export function persisted<T>(
const current = currentStorage as AsyncStorage
const legacyStore = legacyStorage as AsyncStorage | undefined
const legacyStores = legacyStorageNames
.map((name) => platform.storage?.(name) as AsyncStorage | undefined)
.filter((x) => !!x)
const api: AsyncStorage = {
getItem: async (key) => {
const value = await readCurrentAsync({ storage: current, key, defaults, migrate: config.migrate })
if (value !== undefined) return value
return migrateLegacyAsync({
current,
legacyStore,
stores: legacyStores,
keys: legacy,
key,
defaults,
migrate: config.migrate,
})
const raw = await current.getItem(key)
if (raw !== null) {
const next = normalize(defaults, raw, config.migrate)
if (next === undefined) {
await current.removeItem(key).catch(() => undefined)
return null
}
if (raw !== next) await current.setItem(key, next)
return next
}
if (!legacyStore) return null
for (const legacyKey of legacy) {
const legacyRaw = await legacyStore.getItem(legacyKey)
if (legacyRaw === null) continue
const next = normalize(defaults, legacyRaw, config.migrate)
if (next === undefined) {
await legacyStore.removeItem(legacyKey).catch(() => undefined)
continue
}
await current.setItem(key, next)
await legacyStore.removeItem(legacyKey)
return next
}
return null
},
setItem: async (key, value) => {
await current.setItem(key, value)
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/console-app",
"version": "1.14.30",
"version": "1.14.29",
"type": "module",
"license": "MIT",
"scripts": {
@@ -11,12 +11,12 @@ const prodAssetNames: Record<string, string> = {
} satisfies Record<DownloadPlatform, string>
const betaAssetNames: Record<string, string> = {
"darwin-aarch64-dmg": "opencode-desktop-mac-arm64.dmg",
"darwin-x64-dmg": "opencode-desktop-mac-x64.dmg",
"windows-x64-nsis": "opencode-desktop-win-x64.exe",
"linux-x64-deb": "opencode-desktop-linux-amd64.deb",
"linux-x64-appimage": "opencode-desktop-linux-x86_64.AppImage",
"linux-x64-rpm": "opencode-desktop-linux-x86_64.rpm",
"darwin-aarch64-dmg": "opencode-electron-mac-arm64.dmg",
"darwin-x64-dmg": "opencode-electron-mac-x64.dmg",
"windows-x64-nsis": "opencode-electron-win-x64.exe",
"linux-x64-deb": "opencode-electron-linux-amd64.deb",
"linux-x64-appimage": "opencode-electron-linux-x86_64.AppImage",
"linux-x64-rpm": "opencode-electron-linux-x86_64.rpm",
} satisfies Record<DownloadPlatform, string>
// Doing this on the server lets us preserve the original name for platforms we don't care to rename for
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode-ai/console-core",
"version": "1.14.30",
"version": "1.14.29",
"private": true,
"type": "module",
"license": "MIT",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/console-function",
"version": "1.14.30",
"version": "1.14.29",
"$schema": "https://json.schemastore.org/package.json",
"private": true,
"type": "module",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/console-mail",
"version": "1.14.30",
"version": "1.14.29",
"dependencies": {
"@jsx-email/all": "2.2.3",
"@jsx-email/cli": "1.4.3",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"$schema": "https://json.schemastore.org/package.json",
"version": "1.14.30",
"version": "1.14.29",
"name": "@opencode-ai/core",
"type": "module",
"license": "MIT",
+11 -21
View File
@@ -4,7 +4,6 @@ import { xdgData, xdgCache, xdgConfig, xdgState } from "xdg-basedir"
import os from "os"
import { Context, Effect, Layer } from "effect"
import { Flock } from "./util/flock"
import { Flag } from "./flag/flag"
const app = "opencode"
const data = path.join(xdgData!, app)
@@ -48,28 +47,19 @@ export interface Interface {
readonly log: string
}
export function make(input: Partial<Interface> = {}): Interface {
return {
home: Path.home,
data: Path.data,
cache: Path.cache,
config: Flag.OPENCODE_CONFIG_DIR ?? Path.config,
state: Path.state,
bin: Path.bin,
log: Path.log,
...input,
}
}
export const layer = Layer.effect(
Service,
Effect.sync(() => Service.of(make())),
Effect.gen(function* () {
return Service.of({
home: Path.home,
data: Path.data,
cache: Path.cache,
config: Path.config,
state: Path.state,
bin: Path.bin,
log: Path.log,
})
}),
)
export const layerWith = (input: Partial<Interface>) =>
Layer.effect(
Service,
Effect.sync(() => Service.of(make(input))),
)
export * as Global from "./global"
@@ -18,17 +18,20 @@ function sleep(ms: number) {
return new Promise<void>((resolve) => setTimeout(resolve, ms))
}
const msg: Msg = JSON.parse(process.argv[2])
const msg: Msg = JSON.parse(process.argv[2]!)
const testGlobal = Global.layerWith({
home: os.homedir(),
data: os.tmpdir(),
cache: os.tmpdir(),
config: os.tmpdir(),
state: os.tmpdir(),
bin: os.tmpdir(),
log: os.tmpdir(),
})
const testGlobal = Layer.succeed(
Global.Service,
Global.Service.of({
home: os.homedir(),
data: os.tmpdir(),
cache: os.tmpdir(),
config: os.tmpdir(),
state: os.tmpdir(),
bin: os.tmpdir(),
log: os.tmpdir(),
}),
)
const testLayer = EffectFlock.layer.pipe(Layer.provide(testGlobal), Layer.provide(AppFileSystem.defaultLayer))
+12 -9
View File
@@ -93,15 +93,18 @@ async function waitForFile(file: string, timeout = 3_000) {
// Test layer
// ---------------------------------------------------------------------------
const testGlobal = Global.layerWith({
home: os.homedir(),
data: os.tmpdir(),
cache: os.tmpdir(),
config: os.tmpdir(),
state: os.tmpdir(),
bin: os.tmpdir(),
log: os.tmpdir(),
})
const testGlobal = Layer.succeed(
Global.Service,
Global.Service.of({
home: os.homedir(),
data: os.tmpdir(),
cache: os.tmpdir(),
config: os.tmpdir(),
state: os.tmpdir(),
bin: os.tmpdir(),
log: os.tmpdir(),
}),
)
const testLayer = EffectFlock.layer.pipe(Layer.provide(testGlobal), Layer.provide(AppFileSystem.defaultLayer))
@@ -63,7 +63,6 @@ const getBase = (): Configuration => ({
sign: signWindows,
},
target: ["nsis"],
verifyUpdateCodeSignature: false,
},
nsis: {
oneClick: false,
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "@opencode-ai/desktop-electron",
"private": true,
"version": "1.14.30",
"version": "1.14.29",
"type": "module",
"license": "MIT",
"homepage": "https://opencode.ai",
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "@opencode-ai/desktop",
"private": true,
"version": "1.14.30",
"version": "1.14.29",
"type": "module",
"license": "MIT",
"scripts": {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/enterprise",
"version": "1.14.30",
"version": "1.14.29",
"private": true,
"type": "module",
"license": "MIT",
+6 -6
View File
@@ -1,7 +1,7 @@
id = "opencode"
name = "OpenCode"
description = "The open source coding agent."
version = "1.14.30"
version = "1.14.29"
schema_version = 1
authors = ["Anomaly"]
repository = "https://github.com/anomalyco/opencode"
@@ -11,26 +11,26 @@ name = "OpenCode"
icon = "./icons/opencode.svg"
[agent_servers.opencode.targets.darwin-aarch64]
archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.30/opencode-darwin-arm64.zip"
archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.29/opencode-darwin-arm64.zip"
cmd = "./opencode"
args = ["acp"]
[agent_servers.opencode.targets.darwin-x86_64]
archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.30/opencode-darwin-x64.zip"
archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.29/opencode-darwin-x64.zip"
cmd = "./opencode"
args = ["acp"]
[agent_servers.opencode.targets.linux-aarch64]
archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.30/opencode-linux-arm64.tar.gz"
archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.29/opencode-linux-arm64.tar.gz"
cmd = "./opencode"
args = ["acp"]
[agent_servers.opencode.targets.linux-x86_64]
archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.30/opencode-linux-x64.tar.gz"
archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.29/opencode-linux-x64.tar.gz"
cmd = "./opencode"
args = ["acp"]
[agent_servers.opencode.targets.windows-x86_64]
archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.30/opencode-windows-x64.zip"
archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.29/opencode-windows-x64.zip"
cmd = "./opencode.exe"
args = ["acp"]
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/function",
"version": "1.14.30",
"version": "1.14.29",
"$schema": "https://json.schemastore.org/package.json",
"private": true,
"type": "module",
-1
View File
@@ -78,7 +78,6 @@ See `specs/effect/migration.md` for the compact pattern reference and examples.
- Use `Effect.fn("Domain.method")` for named/traced effects and `Effect.fnUntraced` for internal helpers.
- `Effect.fn` / `Effect.fnUntraced` accept pipeable operators as extra arguments, so avoid unnecessary outer `.pipe()` wrappers.
- Use `Effect.callback` for callback-based APIs.
- Use `Effect.void` instead of `Effect.succeed(undefined)` or `Effect.succeed(void 0)`.
- Prefer `DateTime.nowAsDate` over `new Date(yield* Clock.currentTimeMillis)` when you need a `Date`.
## Module conventions
@@ -1 +0,0 @@
ALTER TABLE `event_sequence` ADD `owner_id` text;
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -1,6 +1,6 @@
{
"$schema": "https://json.schemastore.org/package.json",
"version": "1.14.30",
"version": "1.14.29",
"name": "opencode",
"type": "module",
"license": "MIT",
-2
View File
@@ -50,7 +50,6 @@ console.log(`Loaded ${migrations.length} migrations`)
const singleFlag = process.argv.includes("--single")
const baselineFlag = process.argv.includes("--baseline")
const skipInstall = process.argv.includes("--skip-install")
const sourcemapsFlag = process.argv.includes("--sourcemaps")
const plugin = createSolidTransformPlugin()
const skipEmbedWebUi = process.argv.includes("--skip-embed-web-ui")
@@ -200,7 +199,6 @@ for (const item of targets) {
external: ["node-gyp"],
format: "esm",
minify: true,
sourcemap: sourcemapsFlag ? "linked" : "none",
splitting: true,
compile: {
autoloadBunfig: false,
@@ -1,52 +0,0 @@
#!/bin/bash
# Compare SDK types generated from Hono vs HttpApi specs.
# Sorts types alphabetically so only meaningful body differences show.
#
# Usage: ./scripts/diff-sdk-types.sh # full diff
# ./scripts/diff-sdk-types.sh --stat # summary only
set -euo pipefail
DIR="$(cd "$(dirname "$0")/.." && pwd)"
SDK="$(cd "$DIR/../sdk/js" && pwd)"
normalize() {
python3 -c "
import re, sys
content = open(sys.argv[1]).read()
blocks = re.split(r'(?=^export (?:type|function|const) )', content, flags=re.MULTILINE)
header, body = blocks[0], blocks[1:]
body.sort(key=lambda b: m.group(1) if (m := re.match(r'export \w+ (\w+)', b)) else '')
sys.stdout.write(header + ''.join(body))
" "$1"
}
echo "Generating Hono SDK..." >&2
(cd "$SDK" && bun run script/build.ts >/dev/null 2>&1)
normalize "$SDK/src/v2/gen/types.gen.ts" > /tmp/sdk-types-hono.ts
git -C "$SDK" checkout -- src/ 2>/dev/null
echo "Generating HttpApi SDK..." >&2
(cd "$SDK" && OPENCODE_SDK_OPENAPI=httpapi bun run script/build.ts >/dev/null 2>&1)
normalize "$SDK/src/v2/gen/types.gen.ts" > /tmp/sdk-types-httpapi.ts
git -C "$SDK" checkout -- src/ 2>/dev/null
echo "" >&2
if [[ "${1:-}" == "--stat" ]]; then
diff_output=$(diff /tmp/sdk-types-hono.ts /tmp/sdk-types-httpapi.ts || true)
honly=$(printf "%s\n" "$diff_output" | grep -c '^< export type' || true)
aonly=$(printf "%s\n" "$diff_output" | grep -c '^> export type' || true)
total=$(printf "%s\n" "$diff_output" | wc -l | tr -d ' ')
echo "Hono-only: $honly types HttpApi-only: $aonly types Diff lines: $total"
echo ""
if [[ $honly -gt 0 ]]; then
echo "=== Hono-only types ==="
printf "%s\n" "$diff_output" | grep '^< export type' | sed 's/< export type //' | sed 's/[ =].*//' | sed 's/^/ /'
echo ""
fi
if [[ $aonly -gt 0 ]]; then
echo "=== HttpApi-only types ==="
printf "%s\n" "$diff_output" | grep '^> export type' | sed 's/> export type //' | sed 's/[ =].*//' | sed 's/^/ /'
fi
else
diff /tmp/sdk-types-hono.ts /tmp/sdk-types-httpapi.ts || true
fi
@@ -129,14 +129,6 @@ Required before route deletion:
- Compare generated SDK output against `dev` for every route group deletion.
- Remove Hono OpenAPI stubs only after Effect OpenAPI is the SDK source for those paths.
V2 cleanup once SDK compatibility no longer needs the legacy Hono contract:
- Remove `public.ts` compatibility transforms that hide honest `HttpApi` metadata, including auth `securitySchemes`, per-route `security`, and generated `401` responses.
- Stop remapping built-in `HttpApi` error schemas back to legacy Hono `BadRequestError` / `NotFoundError` components if V2 clients can consume the actual Effect error shape.
- Prefer the direct `HttpApi` OpenAPI output for request/response bodies and named component schemas instead of rewriting it to match Hono generator quirks.
- Keep schema fixes that describe the actual wire format, but delete transforms that only preserve legacy SDK type names or inline-vs-ref shape.
- Re-evaluate `auth_token` as an OpenAPI security scheme rather than a hand-injected query parameter once clients can consume the V2 spec.
### 5. Make HttpApi Default For JSON Routes
After JSON parity and SDK generation are covered:
+1
View File
@@ -286,6 +286,7 @@ emitted JSON Schema must stay byte-identical.
- [x] `src/tool/apply_patch.ts`
- [x] `src/tool/bash.ts`
- [x] `src/tool/codesearch.ts`
- [x] `src/tool/edit.ts`
- [x] `src/tool/glob.ts`
- [x] `src/tool/grep.ts`
+2
View File
@@ -40,6 +40,7 @@ These exported tool definitions currently use `Tool.define(...)` in `src/tool`:
- [x] `apply_patch.ts`
- [x] `bash.ts`
- [x] `codesearch.ts`
- [x] `edit.ts`
- [x] `glob.ts`
- [x] `grep.ts`
@@ -78,6 +79,7 @@ Notable items that are already effectively on the target path and do not need se
- `apply_patch.ts`
- `grep.ts`
- `write.ts`
- `codesearch.ts`
- `websearch.ts`
- `edit.ts`
-67
View File
@@ -1,67 +0,0 @@
// @ts-nocheck
import { OpenCode } from "@opencode-ai/core"
import { ReadTool } from "@opencode-ai/core/tools"
const opencode = OpenCode.make({})
opencode.tool.add(ReadTool)
opencode.tool.add({
name: "bash",
schema: {
type: "object",
properties: {
command: {
type: "string",
description: "The command to run.",
},
},
required: ["command"],
},
execute(input, ctx) {},
})
opencode.auth.add({
provider: "openai",
type: "api",
value: process.env.OPENAI_API_KEY,
})
opencode.agent.add({
name: "build",
permissions: [],
model: {
id: "gpt-5-5",
provider: "openai",
variant: "xhigh",
},
})
const sessionID = await opencode.session.create({
agent: "build",
})
opencode.subscribe((event) => {
console.log(event)
})
await opencode.session.prompt({
sessionID,
text: "hey what is up",
})
await opencode.session.prompt({
sessionID,
text: "what is up with this",
files: [
{
mime: "image/png",
uri: "data:image/png;base64,xxxx",
},
],
})
await opencode.session.wait()
console.log(await opencode.session.messages(sessionID))
+4 -3
View File
@@ -31,8 +31,8 @@ export const Info = Schema.Struct({
mode: Schema.Literals(["subagent", "primary", "all"]),
native: Schema.optional(Schema.Boolean),
hidden: Schema.optional(Schema.Boolean),
topP: Schema.optional(Schema.Finite),
temperature: Schema.optional(Schema.Finite),
topP: Schema.optional(Schema.Number),
temperature: Schema.optional(Schema.Number),
color: Schema.optional(Schema.String),
permission: Permission.Ruleset,
model: Schema.optional(
@@ -44,7 +44,7 @@ export const Info = Schema.Struct({
variant: Schema.optional(Schema.String),
prompt: Schema.optional(Schema.String),
options: Schema.Record(Schema.String, Schema.Unknown),
steps: Schema.optional(Schema.Finite),
steps: Schema.optional(Schema.Number),
})
.annotate({ identifier: "Agent" })
.pipe(withStatics((s) => ({ zod: zod(s) })))
@@ -169,6 +169,7 @@ export const layer = Layer.effect(
bash: "allow",
webfetch: "allow",
websearch: "allow",
codesearch: "allow",
read: "allow",
external_directory: {
"*": "ask",
+1 -2
View File
@@ -1,7 +1,6 @@
import path from "path"
import { Effect, Layer, Record, Result, Schema, Context } from "effect"
import { zod } from "@/util/effect-zod"
import { NonNegativeInt } from "@/util/schema"
import { Global } from "@opencode-ai/core/global"
import { AppFileSystem } from "@opencode-ai/core/filesystem"
@@ -15,7 +14,7 @@ export class Oauth extends Schema.Class<Oauth>("OAuth")({
type: Schema.Literal("oauth"),
refresh: Schema.String,
access: Schema.String,
expires: NonNegativeInt,
expires: Schema.Number,
accountId: Schema.optional(Schema.String),
enterpriseUrl: Schema.optional(Schema.String),
}) {}
-12
View File
@@ -34,16 +34,4 @@ export function payloads() {
.toArray()
}
export function effectPayloads() {
return registry
.entries()
.map(([type, def]) =>
Schema.Struct({
type: Schema.Literal(type),
properties: def.properties,
}).annotate({ identifier: `Event.${type}` }),
)
.toArray()
}
export * as BusEvent from "./bus-event"
+1
View File
@@ -28,6 +28,7 @@ const AVAILABLE_PERMISSIONS = [
"task",
"todowrite",
"websearch",
"codesearch",
"lsp",
"skill",
]
+18 -28
View File
@@ -156,38 +156,28 @@ async function handlePluginAuth(plugin: { auth: PluginAuth }, provider: string,
}
if (method.type === "api") {
const key = await prompts.password({
message: "Enter your API key",
validate: (x) => (x && x.length > 0 ? undefined : "Required"),
})
if (prompts.isCancel(key)) throw new UI.CancelledError()
const metadata = Object.keys(inputs).length ? { metadata: inputs } : {}
if (!method.authorize) {
await put(provider, {
type: "api",
key,
...metadata,
if (method.authorize) {
const key = await prompts.password({
message: "Enter your API key",
validate: (x) => (x && x.length > 0 ? undefined : "Required"),
})
if (prompts.isCancel(key)) throw new UI.CancelledError()
const result = await method.authorize(inputs)
if (result.type === "failed") {
prompts.log.error("Failed to authorize")
}
if (result.type === "success") {
const saveProvider = result.provider ?? provider
await put(saveProvider, {
type: "api",
key: result.key ?? key,
})
prompts.log.success("Login successful")
}
prompts.outro("Done")
return true
}
const result = await method.authorize(inputs)
if (result.type === "failed") {
prompts.log.error("Failed to authorize")
}
if (result.type === "success") {
const saveProvider = result.provider ?? provider
await put(saveProvider, {
type: "api",
key: result.key ?? key,
...metadata,
})
prompts.log.success("Login successful")
}
prompts.outro("Done")
return true
}
return false
+9
View File
@@ -19,6 +19,7 @@ import { ReadTool } from "../../tool/read"
import { WebFetchTool } from "../../tool/webfetch"
import { EditTool } from "../../tool/edit"
import { WriteTool } from "../../tool/write"
import { CodeSearchTool } from "../../tool/codesearch"
import { WebSearchTool } from "../../tool/websearch"
import { TaskTool } from "../../tool/task"
import { SkillTool } from "../../tool/skill"
@@ -144,6 +145,13 @@ function edit(info: ToolProps<typeof EditTool>) {
)
}
function codesearch(info: ToolProps<typeof CodeSearchTool>) {
inline({
icon: "◇",
title: `Exa Code Search "${info.input.query}"`,
})
}
function websearch(info: ToolProps<typeof WebSearchTool>) {
inline({
icon: "◈",
@@ -407,6 +415,7 @@ export const RunCommand = cmd({
if (part.tool === "write") return write(props<typeof WriteTool>(part))
if (part.tool === "webfetch") return webfetch(props<typeof WebFetchTool>(part))
if (part.tool === "edit") return edit(props<typeof EditTool>(part))
if (part.tool === "codesearch") return codesearch(props<typeof CodeSearchTool>(part))
if (part.tool === "websearch") return websearch(props<typeof WebSearchTool>(part))
if (part.tool === "task") return task(props<typeof TaskTool>(part))
if (part.tool === "todowrite") return todo(props<typeof TodoWriteTool>(part))
-28
View File
@@ -301,9 +301,6 @@ function App(props: { onSnapshot?: () => Promise<string[]> }) {
renderer.clearSelection()
}
const [terminalTitleEnabled, setTerminalTitleEnabled] = createSignal(kv.get("terminal_title_enabled", true))
const [pasteSummaryEnabled, setPasteSummaryEnabled] = createSignal(
kv.get("paste_summary_enabled", !sync.data.config.experimental?.disable_paste_summary),
)
// Update terminal window title based on current route and session
createEffect(() => {
@@ -739,31 +736,6 @@ function App(props: { onSnapshot?: () => Promise<string[]> }) {
dialog.clear()
},
},
{
title: pasteSummaryEnabled() ? "Disable paste summary" : "Enable paste summary",
value: "app.toggle.paste_summary",
category: "System",
onSelect: (dialog) => {
setPasteSummaryEnabled((prev) => {
const next = !prev
kv.set("paste_summary_enabled", next)
return next
})
dialog.clear()
},
},
{
title: kv.get("session_directory_filter_enabled", true)
? "Disable session directory filtering"
: "Enable session directory filtering",
value: "app.toggle.session_directory_filter",
category: "System",
onSelect: async (dialog) => {
kv.set("session_directory_filter_enabled", !kv.get("session_directory_filter_enabled", true))
await sync.session.refresh()
dialog.clear()
},
},
{
title: kv.get("diff_wrap_mode", "word") === "word" ? "Disable diff wrapping" : "Enable diff wrapping",
value: "app.toggle.diffwrap",
@@ -2,7 +2,7 @@ import { useDialog } from "@tui/ui/dialog"
import { DialogSelect } from "@tui/ui/dialog-select"
import { useRoute } from "@tui/context/route"
import { useSync } from "@tui/context/sync"
import { createMemo, createResource, createSignal, onMount, type JSX } from "solid-js"
import { createMemo, createResource, createSignal, onMount } from "solid-js"
import { Locale } from "@/util/locale"
import { useProject } from "@tui/context/project"
import { useKeybind } from "../context/keybind"
@@ -10,13 +10,15 @@ import { useTheme } from "../context/theme"
import { useSDK } from "../context/sdk"
import { Flag } from "@opencode-ai/core/flag/flag"
import { DialogSessionRename } from "./dialog-session-rename"
import { Keybind } from "@/util/keybind"
import { createDebouncedSignal } from "../util/signal"
import { useToast } from "../ui/toast"
import { openWorkspaceSelect, type WorkspaceSelection, warpWorkspaceSession } from "./dialog-workspace-create"
import { DialogWorkspaceCreate, openWorkspaceSession, restoreWorkspaceSession } from "./dialog-workspace-create"
import { Spinner } from "./spinner"
import { errorMessage } from "@/util/error"
import { DialogSessionDeleteFailed } from "./dialog-session-delete-failed"
import { WorkspaceLabel } from "./workspace-label"
type WorkspaceStatus = "connected" | "connecting" | "disconnected" | "error"
export function DialogSessionList() {
const dialog = useDialog()
@@ -30,63 +32,35 @@ export function DialogSessionList() {
const [toDelete, setToDelete] = createSignal<string>()
const [search, setSearch] = createDebouncedSignal("", 150)
const [searchResults, { refetch }] = createResource(
() => ({ query: search(), filter: sync.session.query() }),
async (input) => {
if (!input.query) return undefined
const result = await sdk.client.session.list({ search: input.query, limit: 30, ...input.filter })
return result.data ?? []
},
)
const [searchResults, { refetch }] = createResource(search, async (query) => {
if (!query) return undefined
const result = await sdk.client.session.list({ search: query, limit: 30 })
return result.data ?? []
})
const currentSessionID = createMemo(() => (route.data.type === "session" ? route.data.sessionID : undefined))
const sessions = createMemo(() => searchResults() ?? sync.data.session)
function createWorkspace() {
dialog.replace(() => (
<DialogWorkspaceCreate
onSelect={(workspaceID) =>
openWorkspaceSession({
dialog,
route,
sdk,
sync,
toast,
workspaceID,
})
}
/>
))
}
function recover(session: NonNullable<ReturnType<typeof sessions>[number]>) {
const workspace = project.workspace.get(session.workspaceID!)
const list = () => dialog.replace(() => <DialogSessionList />)
const warp = async (selection: WorkspaceSelection) => {
if (selection.type === "none") {
await warpWorkspaceSession({
dialog,
sdk,
sync,
project,
toast,
workspaceID: null,
sessionID: session.id,
done: list,
})
return
}
const workspaceID = await (async () => {
if (selection.type === "existing") return selection.workspaceID
const result = await sdk.client.experimental.workspace
.create({ type: selection.workspaceType, branch: null })
.catch(() => undefined)
const workspace = result?.data
if (!workspace) {
toast.show({
message: `Failed to create workspace: ${errorMessage(result?.error ?? "no response")}`,
variant: "error",
})
return
}
await project.workspace.sync()
return workspace.id
})()
if (!workspaceID) return
await warpWorkspaceSession({
dialog,
sdk,
sync,
project,
toast,
workspaceID,
sessionID: session.id,
done: list,
})
}
dialog.replace(() => (
<DialogSessionDeleteFailed
session={session.title}
@@ -113,15 +87,22 @@ export function DialogSessionList() {
return true
}}
onRestore={() => {
void openWorkspaceSelect({
dialog,
sdk,
sync,
toast,
onSelect: (selection) => {
void warp(selection)
},
})
dialog.replace(() => (
<DialogWorkspaceCreate
onSelect={(workspaceID) =>
restoreWorkspaceSession({
dialog,
sdk,
sync,
project,
toast,
workspaceID,
sessionID: session.id,
done: list,
})
}
/>
))
return false
}}
/>
@@ -140,17 +121,30 @@ export function DialogSessionList() {
.map((x) => {
const workspace = x.workspaceID ? project.workspace.get(x.workspaceID) : undefined
let footer: JSX.Element | string = ""
let workspaceStatus: WorkspaceStatus | null = null
if (x.workspaceID) {
workspaceStatus = project.workspace.status(x.workspaceID) || "error"
}
let footer = ""
if (Flag.OPENCODE_EXPERIMENTAL_WORKSPACES) {
if (x.workspaceID) {
footer = workspace ? (
<WorkspaceLabel
type={workspace.type}
name={workspace.name}
status={project.workspace.status(x.workspaceID) ?? "error"}
/>
) : (
<WorkspaceLabel type="unknown" name={x.workspaceID} status="error" />
let desc = "unknown"
if (workspace) {
desc = `${workspace.type}: ${workspace.name}`
}
footer = (
<>
{desc}{" "}
<span
style={{
fg: workspaceStatus === "connected" ? theme.success : theme.error,
}}
>
</span>
</>
)
}
} else {
@@ -253,6 +247,15 @@ export function DialogSessionList() {
dialog.replace(() => <DialogSessionRename session={option.value} />)
},
},
{
keybind: Keybind.parse("ctrl+w")[0],
title: "new workspace",
side: "right",
disabled: !Flag.OPENCODE_EXPERIMENTAL_WORKSPACES,
onTrigger: () => {
createWorkspace()
},
},
]}
/>
)
@@ -1,9 +1,11 @@
import type { Workspace } from "@opencode-ai/sdk/v2"
import { createOpencodeClient } from "@opencode-ai/sdk/v2"
import { useDialog } from "@tui/ui/dialog"
import { DialogSelect, type DialogSelectOption } from "@tui/ui/dialog-select"
import { DialogSelect } from "@tui/ui/dialog-select"
import { useRoute } from "@tui/context/route"
import { useSync } from "@tui/context/sync"
import { useProject } from "@tui/context/project"
import { createMemo, createSignal, onMount } from "solid-js"
import { setTimeout as sleep } from "node:timers/promises"
import { errorMessage } from "@/util/error"
import { useSDK } from "../context/sdk"
import { useToast } from "../ui/toast"
@@ -14,223 +16,184 @@ type Adaptor = {
description: string
}
export type WorkspaceSelection =
| {
type: "none"
}
| {
type: "new"
workspaceType: string
workspaceName: string
}
| {
type: "existing"
workspaceID: string
workspaceType: string
workspaceName: string
}
type WorkspaceSelectValue = WorkspaceSelection | { type: "existing-list" }
type ExistingWorkspaceSelectValue = { workspace: Workspace }
async function loadWorkspaceAdaptors(input: {
sdk: ReturnType<typeof useSDK>
sync: ReturnType<typeof useSync>
toast: ReturnType<typeof useToast>
}) {
const dir = input.sync.path.directory || input.sdk.directory
const url = new URL("/experimental/workspace/adaptor", input.sdk.url)
if (dir) url.searchParams.set("directory", dir)
const res = await input.sdk
.fetch(url)
.then((x) => x.json() as Promise<Adaptor[]>)
.catch(() => undefined)
if (res) return res
input.toast.show({
message: "Failed to load workspace adaptors",
variant: "error",
function scoped(sdk: ReturnType<typeof useSDK>, sync: ReturnType<typeof useSync>, workspaceID: string) {
return createOpencodeClient({
baseUrl: sdk.url,
fetch: sdk.fetch,
directory: sync.path.directory || sdk.directory,
experimental_workspaceID: workspaceID,
})
}
export async function openWorkspaceSelect(input: {
export async function openWorkspaceSession(input: {
dialog: ReturnType<typeof useDialog>
route: ReturnType<typeof useRoute>
sdk: ReturnType<typeof useSDK>
sync: ReturnType<typeof useSync>
toast: ReturnType<typeof useToast>
onSelect: (selection: WorkspaceSelection) => Promise<void> | void
workspaceID: string
}) {
input.dialog.clear()
const adaptors = await loadWorkspaceAdaptors(input)
if (!adaptors) return
input.dialog.replace(() => <DialogWorkspaceSelect adaptors={adaptors} onSelect={input.onSelect} />)
const client = scoped(input.sdk, input.sync, input.workspaceID)
while (true) {
const result = await client.session.create({ workspace: input.workspaceID }).catch(() => undefined)
if (!result) {
input.toast.show({
message: "Failed to create workspace session",
variant: "error",
})
return
}
if (result.response?.status && result.response.status >= 500 && result.response.status < 600) {
await sleep(1000)
continue
}
if (!result.data) {
input.toast.show({
message: "Failed to create workspace session",
variant: "error",
})
return
}
input.route.navigate({
type: "session",
sessionID: result.data.id,
})
input.dialog.clear()
return
}
}
export async function warpWorkspaceSession(input: {
export async function restoreWorkspaceSession(input: {
dialog: ReturnType<typeof useDialog>
sdk: ReturnType<typeof useSDK>
sync: ReturnType<typeof useSync>
project: ReturnType<typeof useProject>
toast: ReturnType<typeof useToast>
workspaceID: string | null
workspaceID: string
sessionID: string
done?: () => void
showSuccessToast?: boolean
}): Promise<boolean> {
const result = await (input.workspaceID === null
? input.sdk.client.experimental.workspace.detach({
workspaceID: null,
sessionID: input.sessionID,
})
: input.sdk.client.experimental.workspace.warp({
id: input.workspaceID,
sessionID: input.sessionID,
})
).catch(() => undefined)
if (!result || result.error) {
}) {
const result = await input.sdk.client.experimental.workspace
.sessionRestore({ id: input.workspaceID, sessionID: input.sessionID })
.catch(() => undefined)
if (!result?.data) {
input.toast.show({
message: `Failed to warp session: ${errorMessage(result?.error ?? "no response")}`,
message: `Failed to restore session: ${errorMessage(result?.error ?? "no response")}`,
variant: "error",
})
return false
return
}
input.project.workspace.set(input.workspaceID)
await input.sync.bootstrap({ fatal: false }).catch(() => undefined)
await Promise.all([input.project.workspace.sync(), input.sync.session.refresh()])
await Promise.all([input.project.workspace.sync(), input.sync.session.sync(input.sessionID)])
if (input.showSuccessToast !== false) {
input.toast.show({
message: input.workspaceID === null ? "Session moved to the local project" : "Session warped into the new workspace",
variant: "success",
})
}
input.toast.show({
message: "Session restored into the new workspace",
variant: "success",
})
input.done?.()
if (input.done) return true
if (input.done) return
input.dialog.clear()
return true
}
export function DialogWorkspaceSelect(props: {
adaptors?: Adaptor[]
onSelect: (selection: WorkspaceSelection) => Promise<void> | void
}) {
export function DialogWorkspaceCreate(props: { onSelect: (workspaceID: string) => Promise<void> | void }) {
const dialog = useDialog()
const project = useProject()
const sync = useSync()
const project = useProject()
const sdk = useSDK()
const toast = useToast()
const [adaptors, setAdaptors] = createSignal<Adaptor[] | undefined>(props.adaptors)
const [creating, setCreating] = createSignal<string>()
const [adaptors, setAdaptors] = createSignal<Adaptor[]>()
onMount(() => {
dialog.setSize("medium")
void (async () => {
if (adaptors()) return
const res = await loadWorkspaceAdaptors({ sdk, sync, toast })
if (!res) return
const dir = sync.path.directory || sdk.directory
const url = new URL("/experimental/workspace/adaptor", sdk.url)
if (dir) url.searchParams.set("directory", dir)
const res = await sdk
.fetch(url)
.then((x) => x.json() as Promise<Adaptor[]>)
.catch(() => undefined)
if (!res) {
toast.show({
message: "Failed to load workspace adaptors",
variant: "error",
})
return
}
setAdaptors(res)
})()
})
const options = createMemo<DialogSelectOption<WorkspaceSelectValue>[]>(() => {
const list = adaptors()
if (!list) return []
const recent = sync.data.session
.toSorted((a, b) => b.time.updated - a.time.updated)
.flatMap((session) => (session.workspaceID ? [session.workspaceID] : []))
.filter((workspaceID, index, list) => list.indexOf(workspaceID) === index)
.slice(0, 3)
.flatMap((workspaceID) => {
const workspace = project.workspace.get(workspaceID)
return workspace ? [workspace] : []
})
return [
...list.map((adaptor) => ({
title: adaptor.name,
value: { type: "new" as const, workspaceType: adaptor.type, workspaceName: adaptor.name },
description: adaptor.description,
category: "New workspace",
})),
{
title: "None",
value: { type: "none" as const },
description: "Use the local project",
category: "Choose workspace",
},
...recent.map((workspace: Workspace) => ({
title: workspace.name,
description: `(${workspace.type})`,
value: {
type: "existing" as const,
workspaceID: workspace.id,
workspaceType: workspace.type,
workspaceName: workspace.name,
const options = createMemo(() => {
const type = creating()
if (type) {
return [
{
title: `Creating ${type} workspace...`,
value: "creating" as const,
description: "This can take a while for remote environments",
},
category: "Choose workspace",
})),
{
title: "View all workspaces",
value: { type: "existing-list" as const },
description: "Choose from all workspaces",
category: "Choose workspace",
},
]
]
}
const list = adaptors()
if (!list) {
return [
{
title: "Loading workspaces...",
value: "loading" as const,
description: "Fetching available workspace adaptors",
},
]
}
return list.map((item) => ({
title: item.name,
value: item.type,
description: item.description,
}))
})
if (!adaptors()) return null
const create = async (type: string) => {
if (creating()) return
setCreating(type)
const result = await sdk.client.experimental.workspace.create({ type, branch: null }).catch(() => {
toast.show({
message: "Creating workspace failed",
variant: "error",
})
return undefined
})
const workspace = result?.data
if (!workspace) {
setCreating(undefined)
toast.show({
message: `Failed to create workspace: ${errorMessage(result?.error ?? "no response")}`,
variant: "error",
})
return
}
await project.workspace.sync()
await props.onSelect(workspace.id)
setCreating(undefined)
}
return (
<DialogSelect<WorkspaceSelectValue>
title="Warp"
<DialogSelect
title={creating() ? "Creating Workspace" : "New Workspace"}
skipFilter={true}
renderFilter={false}
options={options()}
onSelect={(option) => {
if (!option.value) return
if (option.value.type === "none") {
void props.onSelect(option.value)
return
}
if (option.value.type === "new") {
void props.onSelect(option.value)
return
}
if (option.value.type === "existing") {
void props.onSelect(option.value)
return
}
dialog.replace(() => <DialogExistingWorkspaceSelect onSelect={props.onSelect} />)
}}
/>
)
}
function DialogExistingWorkspaceSelect(props: { onSelect: (selection: WorkspaceSelection) => Promise<void> | void }) {
const project = useProject()
const options = createMemo<DialogSelectOption<ExistingWorkspaceSelectValue>[]>(() =>
project.workspace
.list()
.filter((workspace) => project.workspace.status(workspace.id) === "connected")
.map((workspace: Workspace) => ({
title: workspace.name,
description: `(${workspace.type})`,
value: { workspace },
})),
)
return (
<DialogSelect<ExistingWorkspaceSelectValue>
title="Existing Workspace"
options={options()}
onSelect={(option) => {
void props.onSelect({
type: "existing",
workspaceID: option.value.workspace.id,
workspaceType: option.value.workspace.type,
workspaceName: option.value.workspace.name,
})
if (option.value === "creating" || option.value === "loading") return
void create(option.value)
}}
/>
)
@@ -7,13 +7,12 @@ import { Filesystem } from "@/util/filesystem"
import { useLocal } from "@tui/context/local"
import { tint, useTheme } from "@tui/context/theme"
import { EmptyBorder, SplitBorder } from "@tui/component/border"
import { Spinner } from "@tui/component/spinner"
import { useSDK } from "@tui/context/sdk"
import { useRoute } from "@tui/context/route"
import { useProject } from "@tui/context/project"
import { useSync } from "@tui/context/sync"
import { useEvent } from "@tui/context/event"
import { useEditorContext, type EditorSelection } from "@tui/context/editor"
import { useEditorContext } from "@tui/context/editor"
import { MessageID, PartID } from "@/session/schema"
import { createStore, produce, unwrap } from "solid-js/store"
import { useKeybind } from "@tui/context/keybind"
@@ -41,16 +40,13 @@ import { useKV } from "../../context/kv"
import { createFadeIn } from "../../util/signal"
import { useTextareaKeybindings } from "../textarea-keybindings"
import { DialogSkill } from "../dialog-skill"
import { openWorkspaceSelect, warpWorkspaceSession, type WorkspaceSelection } from "../dialog-workspace-create"
import { DialogWorkspaceCreate, restoreWorkspaceSession } from "../dialog-workspace-create"
import { DialogWorkspaceUnavailable } from "../dialog-workspace-unavailable"
import { useArgs } from "@tui/context/args"
import { Flag } from "@opencode-ai/core/flag/flag"
import { WorkspaceLabel, type WorkspaceStatus } from "../workspace-label"
export type PromptProps = {
sessionID?: string
workspaceID?: string
onWorkspaceCreatingChange?: (creating: boolean) => void
visible?: boolean
disabled?: boolean
onSubmit?: () => void
@@ -88,18 +84,6 @@ function fadeColor(color: RGBA, alpha: number) {
return RGBA.fromValues(color.r, color.g, color.b, color.a * alpha)
}
function getEditorSelectionKey(selection: EditorSelection) {
return [
selection.filePath,
selection.text,
selection.source ?? "",
selection.selection.start.line,
selection.selection.start.character,
selection.selection.end.line,
selection.selection.end.character,
].join("-")
}
let stashed: { prompt: PromptInfo; cursor: number } | undefined
export function Prompt(props: PromptProps) {
@@ -151,96 +135,10 @@ export function Prompt(props: PromptProps) {
if (!file) return
return Locale.truncateMiddle(file, Math.max(12, Math.min(48, Math.floor(dimensions().width / 3))))
})
let lastSubmittedEditorSelectionKey: string | undefined
const [auto, setAuto] = createSignal<AutocompleteRef>()
const [workspaceSelection, setWorkspaceSelection] = createSignal<WorkspaceSelection>()
const [workspaceCreating, setWorkspaceCreating] = createSignal(false)
const [workspaceCreatingDots, setWorkspaceCreatingDots] = createSignal(3)
const [warpNotice, setWarpNotice] = createSignal<string>()
const currentProviderLabel = createMemo(() => local.model.parsed().provider)
const hasRightContent = createMemo(() => Boolean(props.right))
function selectWorkspace(selection: WorkspaceSelection | undefined) {
setWorkspaceSelection(selection)
}
function setCreatingWorkspace(creating: boolean) {
setWorkspaceCreating(creating)
props.onWorkspaceCreatingChange?.(creating)
}
function showWarpNotice(name: string) {
setWarpNotice(`Warped to ${name}`)
setTimeout(() => setWarpNotice(undefined), 4000)
}
async function createWorkspace(selection: Extract<WorkspaceSelection, { type: "new" }>) {
setCreatingWorkspace(true)
const result = await sdk.client.experimental.workspace
.create({ type: selection.workspaceType, branch: null })
.catch(() => undefined)
if (result == undefined || result.error || !result.data) {
selectWorkspace(undefined)
setCreatingWorkspace(false)
toast.show({
message: "Creating workspace failed",
variant: "error",
})
return
}
await project.workspace.sync()
const workspace = result.data
selectWorkspace({
type: "existing",
workspaceID: workspace.id,
workspaceType: workspace.type,
workspaceName: workspace.name,
})
setCreatingWorkspace(false)
return workspace
}
async function warpSession(selection: WorkspaceSelection) {
if (!props.sessionID) {
selectWorkspace(selection)
dialog.clear()
if (selection.type === "new") void createWorkspace(selection)
return
}
selectWorkspace(selection)
dialog.clear()
const workspace =
selection.type === "none"
? { id: null, name: "local project" }
: selection.type === "existing"
? { id: selection.workspaceID, name: selection.workspaceName }
: await createWorkspace(selection)
if (!workspace) return
const warped = await warpWorkspaceSession({
dialog,
sdk,
sync,
project,
toast,
workspaceID: workspace.id,
sessionID: props.sessionID,
showSuccessToast: false,
})
if (warped) showWarpNotice(workspace.name)
}
createEffect(() => {
if (!workspaceCreating()) {
setWorkspaceCreatingDots(3)
return
}
const timer = setInterval(() => setWorkspaceCreatingDots((dots) => (dots % 3) + 1), 1000)
onCleanup(() => clearInterval(timer))
})
function promptModelWarning() {
toast.show({
variant: "warning",
@@ -273,7 +171,6 @@ export function Prompt(props: PromptProps) {
})
createEffect(() => {
if (!input || input.isDestroyed) return
if (props.disabled) input.cursorColor = theme.backgroundElement
if (!props.disabled) input.cursorColor = theme.text
})
@@ -540,27 +437,6 @@ export function Prompt(props: PromptProps) {
))
},
},
{
title: "Warp",
description: "Change the workspace for the session",
value: "workspace.set",
category: "Session",
enabled: Flag.OPENCODE_EXPERIMENTAL_WORKSPACES,
slash: {
name: "warp",
},
onSelect: (dialog) => {
void openWorkspaceSelect({
dialog,
sdk,
sync,
toast,
onSelect: (selection) => {
void warpSession(selection)
},
})
},
},
]
})
@@ -644,7 +520,7 @@ export function Prompt(props: PromptProps) {
})
function restoreExtmarksFromParts(parts: PromptInfo["parts"]) {
if (!input.isDestroyed) input.extmarks.clear()
input.extmarks.clear()
setStore("extmarkToPartIndex", new Map())
parts.forEach((part, partIndex) => {
@@ -777,8 +653,6 @@ export function Prompt(props: PromptProps) {
])
async function submit() {
setWarpNotice(undefined)
// IME: double-defer may fire before onContentChange flushes the last
// composed character (e.g. Korean hangul) to the store, so read
// plainText directly and sync before any downstream reads.
@@ -787,7 +661,6 @@ export function Prompt(props: PromptProps) {
syncExtmarksWithPromptParts()
}
if (props.disabled) return false
if (workspaceCreating()) return false
if (autocomplete?.visible) return false
if (!store.prompt.input) return false
const agent = local.agent.current()
@@ -810,22 +683,45 @@ export function Prompt(props: PromptProps) {
dialog.replace(() => (
<DialogWorkspaceUnavailable
onRestore={() => {
void openWorkspaceSelect({
dialog,
sdk,
sync,
toast,
onSelect: (selection) => {
void warpSession(selection)
},
})
return false
dialog.replace(() => (
<DialogWorkspaceCreate
onSelect={(nextWorkspaceID) =>
restoreWorkspaceSession({
dialog,
sdk,
sync,
project,
toast,
workspaceID: nextWorkspaceID,
sessionID: props.sessionID!,
})
}
/>
))
}}
/>
))
return false
}
let sessionID = props.sessionID
if (sessionID == null) {
const res = await sdk.client.session.create({ workspace: props.workspaceID })
if (res.error) {
console.log("Creating a session failed:", res.error)
toast.show({
message: "Creating a session failed. Open console for more details.",
variant: "error",
})
return true
}
sessionID = res.data.id
}
const messageID = MessageID.ascending()
let inputText = store.prompt.input
@@ -850,68 +746,38 @@ export function Prompt(props: PromptProps) {
// Capture mode before it gets reset
const currentMode = store.mode
const submittedPrompt = unwrap(store.prompt)
const variant = local.model.variant.current()
const editorSelection = fileContextEnabled() ? editor.selection() : undefined
const editorSelectionKey = editorSelection ? getEditorSelectionKey(editorSelection) : undefined
const editorParts =
editorSelection && editorSelectionKey !== lastSubmittedEditorSelectionKey
? [
{
id: PartID.ascending(),
type: "text" as const,
text: (() => {
const start = editorSelection.selection.start
const end = editorSelection.selection.end
const editorParts = editorSelection
? [
{
id: PartID.ascending(),
type: "text" as const,
text: (() => {
const start = editorSelection.selection.start
const end = editorSelection.selection.end
let text = ""
if (start.line === end.line && start.character === end.character) {
text = `Note: The user opened the file "${editorSelection.filePath}".`
} else if (start.line === end.line) {
text = `Note: The user selected line ${start.line + 1} from "${editorSelection.filePath}". \`\`\`${editorSelection.text}\`\`\`\n\n`
} else {
text = `Note: The user selected lines ${start.line + 1} to ${end.line + 1} from "${editorSelection.filePath}". \`\`\`${editorSelection.text}\`\`\`\n\n`
}
let text = ""
if (start.line === end.line && start.character === end.character) {
text = `Note: The user opened the file "${editorSelection.filePath}".`
} else if (start.line === end.line) {
text = `Note: The user selected line ${start.line + 1} from "${editorSelection.filePath}". \`\`\`${editorSelection.text}\`\`\`\n\n`
} else {
text = `Note: The user selected lines ${start.line + 1} to ${end.line + 1} from "${editorSelection.filePath}". \`\`\`${editorSelection.text}\`\`\`\n\n`
}
return `<system-reminder>${text} This may or may not be relevant to the current task.</system-reminder>\n`
})(),
synthetic: true,
metadata: {
kind: "editor_context",
source: editorSelection.source ?? "editor",
filePath: editorSelection.filePath,
selection: editorSelection.selection,
},
return `<system-reminder>${text} This may or may not be relevant to the current task.</system-reminder>\n`
})(),
synthetic: true,
metadata: {
kind: "editor_context",
source: editorSelection.source ?? "editor",
filePath: editorSelection.filePath,
selection: editorSelection.selection,
},
]
: []
let sessionID = props.sessionID
if (sessionID == null) {
const workspace = workspaceSelection()
const workspaceID = iife(() => {
if (!workspace) return undefined
if (workspace.type === "none") return undefined
if (workspace.type === "existing") return workspace.workspaceID
return undefined
})
const res = await sdk.client.session.create({ workspace: workspaceID })
if (res.error) {
setCreatingWorkspace(false)
console.log("Creating a session failed:", res.error)
toast.show({
message: "Creating a session failed. Open console for more details.",
variant: "error",
})
return true
}
sessionID = res.data.id
}
},
]
: []
if (store.mode === "shell") {
void sdk.client.session.shell({
@@ -974,10 +840,10 @@ export function Prompt(props: PromptProps) {
],
})
.catch(() => {})
lastSubmittedEditorSelectionKey = editorSelectionKey
editor.clearSelection()
}
history.append({
...submittedPrompt,
...store.prompt,
mode: currentMode,
})
input.extmarks.clear()
@@ -996,7 +862,7 @@ export function Prompt(props: PromptProps) {
sessionID,
})
}, 50)
if (!input.isDestroyed) input.clear()
input.clear()
return true
}
const exit = useExit()
@@ -1117,29 +983,6 @@ export function Prompt(props: PromptProps) {
return `Ask anything... "${list()[store.placeholder % list().length]}"`
})
const workspaceLabel = createMemo<
| { type: "new"; workspaceType: string }
| { type: "existing"; workspaceType: string; workspaceName: string; status?: WorkspaceStatus }
| undefined
>(() => {
const selected = workspaceSelection()
if (!selected) return
if (selected.type === "none") return
if (props.sessionID && !workspaceCreating()) return
if (selected.type === "new") {
return {
type: "new",
workspaceType: selected.workspaceType,
}
}
return {
type: "existing",
workspaceType: selected.workspaceType,
workspaceName: selected.workspaceName,
status: selected.type === "existing" ? "connected" : undefined,
}
})
const spinnerDef = createMemo(() => {
const agent = local.agent.current()
const color = agent ? local.agent.color(agent.name) : theme.border
@@ -1366,7 +1209,7 @@ export function Prompt(props: PromptProps) {
const lineCount = (pastedContent.match(/\n/g)?.length ?? 0) + 1
if (
(lineCount >= 3 || pastedContent.length > 150) &&
kv.get("paste_summary_enabled", !sync.data.config.experimental?.disable_paste_summary)
!sync.data.config.experimental?.disable_paste_summary
) {
pasteText(pastedContent, `[Pasted ~${lineCount} lines]`)
return
@@ -1396,7 +1239,7 @@ export function Prompt(props: PromptProps) {
}}
onMouseDown={(r: MouseEvent) => r.target?.focus()}
focusedBackgroundColor={theme.backgroundElement}
cursorColor={props.disabled ? theme.backgroundElement : theme.text}
cursorColor={theme.text}
syntaxStyle={syntax()}
/>
<box flexDirection="row" flexShrink={0} paddingTop={1} gap={1} justifyContent="space-between">
@@ -1466,124 +1309,86 @@ export function Prompt(props: PromptProps) {
/>
</box>
<box width="100%" flexDirection="row" justifyContent="space-between">
<Switch>
<Match when={status().type !== "idle"}>
<box
flexDirection="row"
gap={1}
flexGrow={1}
justifyContent={status().type === "retry" ? "space-between" : "flex-start"}
>
<box flexShrink={0} flexDirection="row" gap={1}>
<box marginLeft={1}>
<Show when={kv.get("animations_enabled", true)} fallback={<text fg={theme.textMuted}>[]</text>}>
<spinner color={spinnerDef().color} frames={spinnerDef().frames} interval={40} />
</Show>
</box>
<box flexDirection="row" gap={1} flexShrink={0}>
{(() => {
const retry = createMemo(() => {
const s = status()
if (s.type !== "retry") return
return s
})
const message = createMemo(() => {
const r = retry()
if (!r) return
if (r.message.includes("exceeded your current quota") && r.message.includes("gemini"))
return "gemini is way too hot right now"
if (r.message.length > 80) return r.message.slice(0, 80) + "..."
return r.message
})
const isTruncated = createMemo(() => {
const r = retry()
if (!r) return false
return r.message.length > 120
})
const [seconds, setSeconds] = createSignal(0)
onMount(() => {
const timer = setInterval(() => {
const next = retry()?.next
if (next) setSeconds(Math.round((next - Date.now()) / 1000))
}, 1000)
onCleanup(() => {
clearInterval(timer)
})
})
const handleMessageClick = () => {
const r = retry()
if (!r) return
if (isTruncated()) {
void DialogAlert.show(dialog, "Retry Error", r.message)
}
}
const retryText = () => {
const r = retry()
if (!r) return ""
const baseMessage = message()
const truncatedHint = isTruncated() ? " (click to expand)" : ""
const duration = formatDuration(seconds())
const retryInfo = ` [retrying ${duration ? `in ${duration} ` : ""}attempt #${r.attempt}]`
return baseMessage + truncatedHint + retryInfo
}
return (
<Show when={retry()}>
<box onMouseUp={handleMessageClick}>
<text fg={theme.error}>{retryText()}</text>
</box>
</Show>
)
})()}
</box>
</box>
<text fg={store.interrupt > 0 ? theme.primary : theme.text}>
esc{" "}
<span style={{ fg: store.interrupt > 0 ? theme.primary : theme.textMuted }}>
{store.interrupt > 0 ? "again to interrupt" : "interrupt"}
</span>
</text>
</box>
</Match>
<Match when={warpNotice()}>
{(notice) => (
<box paddingLeft={3}>
<text fg={theme.accent}>{notice()}</text>
</box>
)}
</Match>
<Match when={workspaceLabel()}>
{(workspace) => (
<box paddingLeft={3} flexDirection="row" gap={1}>
<Show when={workspaceCreating()}>
<Spinner color={theme.accent} />
<Show when={status().type !== "idle"} fallback={props.hint ?? <text />}>
<box
flexDirection="row"
gap={1}
flexGrow={1}
justifyContent={status().type === "retry" ? "space-between" : "flex-start"}
>
<box flexShrink={0} flexDirection="row" gap={1}>
<box marginLeft={1}>
<Show when={kv.get("animations_enabled", true)} fallback={<text fg={theme.textMuted}>[]</text>}>
<spinner color={spinnerDef().color} frames={spinnerDef().frames} interval={40} />
</Show>
<text fg={workspaceCreating() ? theme.accent : theme.text}>
{(() => {
const item = workspace()
if (item.type === "new") {
if (workspaceCreating())
return `Creating ${item.workspaceType}${".".repeat(workspaceCreatingDots())}`
return (
<>
Workspace <span style={{ fg: theme.textMuted }}>(new {item.workspaceType})</span>
</>
)
}
return (
<>
Workspace <span style={{ fg: theme.textMuted }}>{item.workspaceName}</span>
</>
)
})()}
</text>
</box>
)}
</Match>
<Match when={true}>{props.hint ?? <text />}</Match>
</Switch>
<box flexDirection="row" gap={1} flexShrink={0}>
{(() => {
const retry = createMemo(() => {
const s = status()
if (s.type !== "retry") return
return s
})
const message = createMemo(() => {
const r = retry()
if (!r) return
if (r.message.includes("exceeded your current quota") && r.message.includes("gemini"))
return "gemini is way too hot right now"
if (r.message.length > 80) return r.message.slice(0, 80) + "..."
return r.message
})
const isTruncated = createMemo(() => {
const r = retry()
if (!r) return false
return r.message.length > 120
})
const [seconds, setSeconds] = createSignal(0)
onMount(() => {
const timer = setInterval(() => {
const next = retry()?.next
if (next) setSeconds(Math.round((next - Date.now()) / 1000))
}, 1000)
onCleanup(() => {
clearInterval(timer)
})
})
const handleMessageClick = () => {
const r = retry()
if (!r) return
if (isTruncated()) {
void DialogAlert.show(dialog, "Retry Error", r.message)
}
}
const retryText = () => {
const r = retry()
if (!r) return ""
const baseMessage = message()
const truncatedHint = isTruncated() ? " (click to expand)" : ""
const duration = formatDuration(seconds())
const retryInfo = ` [retrying ${duration ? `in ${duration} ` : ""}attempt #${r.attempt}]`
return baseMessage + truncatedHint + retryInfo
}
return (
<Show when={retry()}>
<box onMouseUp={handleMessageClick}>
<text fg={theme.error}>{retryText()}</text>
</box>
</Show>
)
})()}
</box>
</box>
<text fg={store.interrupt > 0 ? theme.primary : theme.text}>
esc{" "}
<span style={{ fg: store.interrupt > 0 ? theme.primary : theme.textMuted }}>
{store.interrupt > 0 ? "again to interrupt" : "interrupt"}
</span>
</text>
</box>
</Show>
<Show when={status().type !== "retry"}>
<box gap={2} flexDirection="row">
<Show when={editorFileLabelDisplay()}>{(file) => <text fg={theme.secondary}>{file()}</text>}</Show>
@@ -1,19 +0,0 @@
import { useTheme } from "@tui/context/theme"
export type WorkspaceStatus = "connected" | "connecting" | "disconnected" | "error"
export function WorkspaceLabel(props: { type: string; name: string; status?: WorkspaceStatus; icon?: boolean }) {
const { theme } = useTheme()
const color = () => {
if (props.status === "connected") return theme.success
if (props.status === "error") return theme.error
return theme.textMuted
}
return (
<>
{props.icon ? <span style={{ fg: color() }}> </span> : undefined}
<span style={{ fg: theme.text }}>{props.name}</span> <span style={{ fg: theme.textMuted }}>({props.type})</span>
</>
)
}
@@ -20,8 +20,6 @@ const ZedEditorContentsSchema = z.object({
contents: z.string().nullable(),
})
const utf8 = new TextEncoder()
type ZedEditorRow = z.infer<typeof ZedEditorRowSchema>
type ZedActiveEditorRow = ZedEditorRow & { item_kind: "Editor"; editor_id: number }
@@ -47,8 +45,8 @@ export async function resolveZedSelection(dbPath: string, cwd = process.cwd()):
.catch(() => undefined)
if (text == null) return { type: "unavailable" }
const startOffset = utf8ByteOffsetToStringIndex(text, Math.min(row.selection_start, row.selection_end))
const endOffset = utf8ByteOffsetToStringIndex(text, Math.max(row.selection_start, row.selection_end))
const startOffset = Math.min(row.selection_start, row.selection_end)
const endOffset = Math.max(row.selection_start, row.selection_end)
return {
type: "selection",
@@ -160,25 +158,7 @@ function zedWorkspacePaths(value: string | null) {
}
export function offsetToPosition(text: string, offset: number) {
const stringOffset = utf8ByteOffsetToStringIndex(text, offset)
return offsetsToSelection(text, stringOffset, stringOffset).start
}
function utf8ByteOffsetToStringIndex(text: string, byteOffset: number) {
if (byteOffset <= 0) return 0
let bytes = 0
for (let index = 0; index < text.length; ) {
const codePoint = text.codePointAt(index)
if (codePoint === undefined) return text.length
const nextIndex = index + (codePoint > 0xffff ? 2 : 1)
bytes += utf8.encode(text.slice(index, nextIndex)).length
if (bytes >= byteOffset) return nextIndex
index = nextIndex
}
return text.length
return offsetsToSelection(text, offset, offset).start
}
function offsetsToSelection(text: string, startOffset: number, endOffset: number) {
@@ -75,9 +75,8 @@ type EditorLockFile = {
export const { use: useEditorContext, provider: EditorContextProvider } = createSimpleContext({
name: "EditorContext",
init: (props: { WebSocketImpl?: typeof WebSocket }) => {
init: () => {
const mentionListeners = new Set<(mention: EditorMention) => void>()
const WebSocketImpl = props.WebSocketImpl ?? WebSocket
const [store, setStore] = createStore<{
status: "disabled" | "connecting" | "connected"
selection: EditorSelection | undefined
@@ -88,160 +87,138 @@ export const { use: useEditorContext, provider: EditorContextProvider } = create
server: undefined,
})
let socket: WebSocket | undefined
let closed = false
let reconnect: ReturnType<typeof setTimeout> | undefined
let attempt = 0
let requestID = 0
let zedSelection: Promise<void> | undefined
let lastZedSelectionKey: string | undefined
let directory = process.cwd()
const pending = new Map<number, string>()
onMount(() => {
let socket: WebSocket | undefined
let closed = false
let reconnect: ReturnType<typeof setTimeout> | undefined
let attempt = 0
let requestID = 0
let zedSelection: Promise<void> | undefined
let lastZedSelectionKey: string | undefined
const pending = new Map<number, string>()
const send = (payload: JsonRpcMessage) => {
if (!socket || socket.readyState !== 1) return
socket.send(JSON.stringify({ jsonrpc: "2.0", ...payload }))
}
const request = (method: string, params?: unknown) => {
requestID += 1
pending.set(requestID, method)
send({ id: requestID, method, params })
}
const connect = () => {
if (closed) return
const connection = resolveEditorConnection(directory)
if (!connection) {
const dbPath = resolveZedDbPath()
if (!dbPath) {
setStore("status", "disabled")
scheduleReconnect()
return
}
zedSelection ??= resolveZedSelection(dbPath, directory)
.then((result) => {
if (closed || socket) return
if (result.type === "unavailable") return
const selection = result.type === "selection" ? result.selection : undefined
const key = editorSelectionKey(selection)
if (key !== lastZedSelectionKey) {
lastZedSelectionKey = key
setStore("selection", selection)
setStore("status", selection ? "connected" : "disabled")
}
})
.catch(() => {
// Keep the last known Zed selection for transient polling failures.
})
.finally(() => {
zedSelection = undefined
})
scheduleZedPoll()
return
const send = (payload: JsonRpcMessage) => {
if (!socket || socket.readyState !== WebSocket.OPEN) return
socket.send(JSON.stringify({ jsonrpc: "2.0", ...payload }))
}
setStore("status", "connecting")
const current = openEditorSocket(connection, WebSocketImpl)
socket = current
const request = (method: string, params?: unknown) => {
requestID += 1
pending.set(requestID, method)
send({ id: requestID, method, params })
}
current.addEventListener("open", () => {
if (socket !== current) {
current.close()
return
}
const scheduleReconnect = () => {
if (closed) return
if (reconnect) clearTimeout(reconnect)
attempt += 1
const delay = Math.min(1000 * 2 ** (attempt - 1), 10_000)
reconnect = setTimeout(connect, delay)
}
attempt = 0
setStore("status", "connected")
request("initialize", {
protocolVersion: MCP_PROTOCOL_VERSION,
capabilities: {},
clientInfo: { name: "opencode", version: "0.0.0" },
})
})
const scheduleZedPoll = () => {
if (closed) return
if (reconnect) clearTimeout(reconnect)
reconnect = setTimeout(connect, 1000)
}
current.addEventListener("message", (event) => {
const message = parseMessage(event.data)
if (!message) return
const selection =
message.method === "selection_changed" ? EditorSelectionSchema.safeParse(message.params) : undefined
if (selection?.success) {
setStore("selection", { ...selection.data, source: "websocket" })
return
}
const mention = message.method === "at_mentioned" ? EditorMentionSchema.safeParse(message.params) : undefined
if (mention?.success) {
mentionListeners.forEach((listener) => listener(mention.data))
return
}
if (typeof message.id !== "number") return
const method = pending.get(message.id)
if (!method) return
pending.delete(message.id)
if (message.error) return
const initialize = method === "initialize" ? EditorServerInfoSchema.safeParse(message.result) : undefined
if (initialize?.success) {
setStore("server", initialize.data)
send({ method: "notifications/initialized" })
return
}
})
current.addEventListener("close", () => {
if (socket !== current) return
socket = undefined
pending.clear()
const connect = () => {
if (closed) return
const connection = resolveEditorConnection()
if (!connection) {
const dbPath = resolveZedDbPath()
if (!dbPath) {
setStore("status", "disabled")
scheduleReconnect()
return
}
zedSelection ??= resolveZedSelection(dbPath)
.then((result) => {
if (closed || socket) return
if (result.type === "unavailable") return
const selection = result.type === "selection" ? result.selection : undefined
const key = editorSelectionKey(selection)
if (key !== lastZedSelectionKey) {
lastZedSelectionKey = key
setStore("selection", selection)
setStore("status", selection ? "connected" : "disabled")
}
})
.catch(() => {
// Keep the last known Zed selection for transient polling failures.
})
.finally(() => {
zedSelection = undefined
})
scheduleZedPoll()
return
}
setStore("status", "connecting")
scheduleReconnect()
})
}
const current = openEditorSocket(connection)
socket = current
const scheduleReconnect = () => {
if (closed) return
if (reconnect) clearTimeout(reconnect)
attempt += 1
const delay = Math.min(1000 * 2 ** (attempt - 1), 10_000)
reconnect = setTimeout(connect, delay)
}
current.addEventListener("open", () => {
if (socket !== current) {
current.close()
return
}
const scheduleZedPoll = () => {
if (closed) return
if (reconnect) clearTimeout(reconnect)
reconnect = setTimeout(connect, 1000)
}
attempt = 0
setStore("status", "connected")
request("initialize", {
protocolVersion: MCP_PROTOCOL_VERSION,
capabilities: {},
clientInfo: { name: "opencode", version: "0.0.0" },
})
})
const reconnectWithDirectory = (nextDirectory?: string) => {
const resolved = nextDirectory || process.cwd()
if (directory === resolved) return
current.addEventListener("message", (event) => {
const message = parseMessage(event.data)
if (!message) return
directory = resolved
attempt = 0
pending.clear()
lastZedSelectionKey = undefined
if (reconnect) clearTimeout(reconnect)
reconnect = undefined
if (socket) {
const current = socket
socket = undefined
current.close()
const selection =
message.method === "selection_changed" ? EditorSelectionSchema.safeParse(message.params) : undefined
if (selection?.success) {
setStore("selection", { ...selection.data, source: "websocket" })
return
}
const mention = message.method === "at_mentioned" ? EditorMentionSchema.safeParse(message.params) : undefined
if (mention?.success) {
mentionListeners.forEach((listener) => listener(mention.data))
return
}
if (typeof message.id !== "number") return
const method = pending.get(message.id)
if (!method) return
pending.delete(message.id)
if (message.error) return
const initialize = method === "initialize" ? EditorServerInfoSchema.safeParse(message.result) : undefined
if (initialize?.success) {
setStore("server", initialize.data)
send({ method: "notifications/initialized" })
return
}
})
current.addEventListener("close", () => {
if (socket !== current) return
socket = undefined
pending.clear()
if (closed) return
setStore("status", "connecting")
scheduleReconnect()
})
}
setStore("status", "disabled")
setStore("selection", undefined)
setStore("server", undefined)
connect()
}
onMount(() => {
connect()
onCleanup(() => {
@@ -253,7 +230,7 @@ export const { use: useEditorContext, provider: EditorContextProvider } = create
return {
enabled() {
return Boolean(resolveEditorConnection(directory) || resolveZedDbPath())
return Boolean(resolveEditorConnection() || resolveZedDbPath())
},
connected() {
return store.status === "connected"
@@ -271,10 +248,6 @@ export const { use: useEditorContext, provider: EditorContextProvider } = create
server() {
return store.server
},
reconnect(directory?: string) {
setStore("selection", undefined)
reconnectWithDirectory(directory)
},
}
},
})
@@ -287,16 +260,8 @@ function parsePort(value: string | undefined) {
return parsed
}
function resolveEditorConnection(directory: string): EditorConnection | undefined {
const port = parsePort(process.env.CLAUDE_CODE_SSE_PORT || process.env.OPENCODE_EDITOR_SSE_PORT)
if (port) {
return {
url: `ws://127.0.0.1:${port}`,
source: `env:${port}`,
}
}
const lock = resolveEditorLockFile(directory)
function resolveEditorConnection(): EditorConnection | undefined {
const lock = resolveEditorLockFile()
if (lock) {
return {
url: `ws://127.0.0.1:${lock.port}`,
@@ -304,9 +269,16 @@ function resolveEditorConnection(directory: string): EditorConnection | undefine
source: `lock:${lock.port}`,
}
}
const port = parsePort(process.env.CLAUDE_CODE_SSE_PORT || process.env.OPENCODE_EDITOR_SSE_PORT)
if (!port) return
return {
url: `ws://127.0.0.1:${port}`,
source: `env:${port}`,
}
}
function resolveEditorLockFile(activeDirectory: string) {
function resolveEditorLockFile() {
const directory = path.join(os.homedir(), ".claude", "ide")
let entries: string[]
@@ -316,9 +288,10 @@ function resolveEditorLockFile(activeDirectory: string) {
return
}
// longest workspace folder that contains the active session directory; 0 if none match
const cwd = process.cwd()
// longest workspace folder that contains cwd; 0 if none match
const bestMatchLength = (lock: EditorLockFile) =>
Math.max(0, ...lock.workspaceFolders.map((folder) => pathContainsLength(folder, activeDirectory)))
Math.max(0, ...lock.workspaceFolders.map((folder) => pathContainsLength(folder, cwd)))
const locks = entries
.filter((entry) => entry.endsWith(".lock"))
.map((entry) => readEditorLockFile(path.join(directory, entry)))
@@ -370,10 +343,10 @@ function pathContainsLength(parent: string, child: string) {
return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative)) ? resolved.length : 0
}
function openEditorSocket(connection: EditorConnection, WebSocketImpl: typeof WebSocket) {
if (!connection.authToken) return new WebSocketImpl(connection.url)
function openEditorSocket(connection: EditorConnection) {
if (!connection.authToken) return new WebSocket(connection.url)
return new WebSocketImpl(connection.url, {
return new WebSocket(connection.url, {
headers: {
"x-claude-code-ide-authorization": connection.authToken,
},
@@ -30,8 +30,6 @@ import { useArgs } from "./args"
import { batch, onMount } from "solid-js"
import * as Log from "@opencode-ai/core/util/log"
import { emptyConsoleState, type ConsoleState } from "@/config/console-state"
import path from "path"
import { useKV } from "./kv"
export const { use: useSync, provider: SyncProvider } = createSimpleContext({
name: "Sync",
@@ -109,27 +107,10 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
const event = useEvent()
const project = useProject()
const sdk = useSDK()
const kv = useKV()
const fullSyncedSessions = new Set<string>()
let syncedWorkspace = project.workspace.current()
function sessionListQuery(): { scope?: "project"; path?: string } {
if (!kv.get("session_directory_filter_enabled", true)) return { scope: "project" }
if (!project.data.instance.path.worktree || !project.data.instance.path.directory) return { scope: "project" }
return {
path: path
.relative(path.resolve(project.data.instance.path.worktree), project.data.instance.path.directory)
.replaceAll("\\", "/"),
}
}
function listSessions() {
return sdk.client.session
.list({ start: Date.now() - 30 * 24 * 60 * 60 * 1000, ...sessionListQuery() })
.then((x) => (x.data ?? []).toSorted((a, b) => a.id.localeCompare(b.id)))
}
event.subscribe((event) => {
switch (event.type) {
case "server.instance.disposed":
@@ -379,8 +360,10 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
fullSyncedSessions.clear()
syncedWorkspace = workspace
}
const projectPromise = project.sync()
const sessionListPromise = projectPromise.then(() => listSessions())
const start = Date.now() - 30 * 24 * 60 * 60 * 1000
const sessionListPromise = sdk.client.session
.list({ start: start })
.then((x) => (x.data ?? []).toSorted((a, b) => a.id.localeCompare(b.id)))
// blocking - include session.list when continuing a session
const providersPromise = sdk.client.config.providers({ workspace }, { throwOnError: true })
@@ -391,6 +374,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
.catch(() => emptyConsoleState)
const agentsPromise = sdk.client.app.agents({ workspace }, { throwOnError: true })
const configPromise = sdk.client.config.get({ workspace }, { throwOnError: true })
const projectPromise = project.sync()
const blockingRequests: Promise<unknown>[] = [
providersPromise,
providerListPromise,
@@ -495,11 +479,11 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
if (match.found) return store.session[match.index]
return undefined
},
query() {
return sessionListQuery()
},
async refresh() {
const list = await listSessions()
const start = Date.now() - 30 * 24 * 60 * 60 * 1000
const list = await sdk.client.session
.list({ start })
.then((x) => (x.data ?? []).toSorted((a, b) => a.id.localeCompare(b.id)))
setStore("session", reconcile(list))
},
status(sessionID: string) {
@@ -500,8 +500,7 @@ async function getCustomThemes() {
symlink: true,
})) {
const name = path.basename(item, ".json")
const theme = await Filesystem.readJson(item)
if (isTheme(theme)) result[name] = theme
result[name] = await Filesystem.readJson(item)
}
}
return result
+1 -2
View File
@@ -1,6 +1,5 @@
import { BusEvent } from "@/bus/bus-event"
import { SessionID } from "@/session/schema"
import { PositiveInt } from "@/util/schema"
import { Effect, Schema } from "effect"
const DEFAULT_TOAST_DURATION = 5000
@@ -39,7 +38,7 @@ export const TuiEvent = {
title: Schema.optional(Schema.String),
message: Schema.String,
variant: Schema.Literals(["info", "success", "warning", "error"]),
duration: PositiveInt.pipe(Schema.withDecodingDefault(Effect.succeed(DEFAULT_TOAST_DURATION))).annotate({
duration: Schema.Number.pipe(Schema.withDecodingDefault(Effect.succeed(DEFAULT_TOAST_DURATION))).annotate({
description: "Duration in milliseconds",
}),
}),
@@ -44,13 +44,13 @@ import type { GrepTool } from "@/tool/grep"
import type { EditTool } from "@/tool/edit"
import type { ApplyPatchTool } from "@/tool/apply_patch"
import type { WebFetchTool } from "@/tool/webfetch"
import type { CodeSearchTool } from "@/tool/codesearch"
import type { WebSearchTool } from "@/tool/websearch"
import type { TaskTool } from "@/tool/task"
import type { QuestionTool } from "@/tool/question"
import type { SkillTool } from "@/tool/skill"
import { useKeyboard, useRenderer, useTerminalDimensions, type JSX } from "@opentui/solid"
import { useSDK } from "@tui/context/sdk"
import { useEditorContext } from "@tui/context/editor"
import { useCommandDialog } from "@tui/component/dialog-command"
import type { DialogContext } from "@tui/ui/dialog"
import { useKeybind } from "@tui/context/keybind"
@@ -180,7 +180,6 @@ export function Session() {
const scrollAcceleration = createMemo(() => getScrollAcceleration(tuiConfig))
const toast = useToast()
const sdk = useSDK()
const editor = useEditorContext()
createEffect(() => {
const sessionID = route.sessionID
@@ -208,7 +207,6 @@ export function Session() {
await sync.bootstrap({ fatal: false })
} catch {}
}
editor.reconnect(result.data.directory)
await sync.session.sync(sessionID)
if (route.sessionID === sessionID && scroll) scroll.scrollBy(100_000)
})().catch((error) => {
@@ -1567,6 +1565,9 @@ function ToolPart(props: { last: boolean; part: ToolPart; message: AssistantMess
<Match when={props.part.tool === "webfetch"}>
<WebFetch {...toolprops} />
</Match>
<Match when={props.part.tool === "codesearch"}>
<CodeSearch {...toolprops} />
</Match>
<Match when={props.part.tool === "websearch"}>
<WebSearch {...toolprops} />
</Match>
@@ -1947,6 +1948,15 @@ function WebFetch(props: ToolProps<typeof WebFetchTool>) {
)
}
function CodeSearch(props: ToolProps<typeof CodeSearchTool>) {
const metadata = props.metadata as { results?: number }
return (
<InlineTool icon="◇" pending="Searching code..." complete={props.input.query} part={props.part}>
Exa Code Search "{props.input.query}" <Show when={metadata.results}>({metadata.results} results)</Show>
</InlineTool>
)
}
function WebSearch(props: ToolProps<typeof WebSearchTool>) {
const metadata = props.metadata as { numResults?: number }
return (
@@ -350,6 +350,21 @@ export function PermissionPrompt(props: { request: PermissionRequest }) {
}
}
if (permission === "codesearch") {
const query = typeof data.query === "string" ? data.query : ""
return {
icon: "◇",
title: `Exa Code Search "${query}"`,
body: (
<Show when={query}>
<box paddingLeft={1}>
<text fg={theme.textMuted}>{"Query: " + query}</text>
</box>
</Show>
),
}
}
if (permission === "external_directory") {
const meta = props.request.metadata ?? {}
const parent = typeof meta["parentDir"] === "string" ? meta["parentDir"] : undefined
@@ -7,7 +7,6 @@ import { InstallationChannel, InstallationVersion } from "@opencode-ai/core/inst
import { TuiPluginRuntime } from "@/cli/cmd/tui/plugin/runtime"
import { getScrollAcceleration } from "../../util/scroll"
import { WorkspaceLabel } from "../../component/workspace-label"
export function Sidebar(props: { sessionID: string; overlay?: boolean }) {
const project = useProject()
@@ -15,10 +14,17 @@ export function Sidebar(props: { sessionID: string; overlay?: boolean }) {
const { theme } = useTheme()
const tuiConfig = useTuiConfig()
const session = createMemo(() => sync.session.get(props.sessionID))
const workspace = () => {
const workspaceStatus = () => {
const workspaceID = session()?.workspaceID
if (!workspaceID) return
return project.workspace.get(workspaceID)
if (!workspaceID) return "error"
return project.workspace.status(workspaceID) ?? "error"
}
const workspaceLabel = () => {
const workspaceID = session()?.workspaceID
if (!workspaceID) return "unknown"
const info = project.workspace.get(workspaceID)
if (!info) return "unknown"
return `${info.type}: ${info.name}`
}
const scrollAcceleration = createMemo(() => getScrollAcceleration(tuiConfig))
@@ -61,19 +67,8 @@ export function Sidebar(props: { sessionID: string; overlay?: boolean }) {
</Show>
<Show when={session()!.workspaceID}>
<text fg={theme.textMuted}>
<Show
when={workspace()}
fallback={<WorkspaceLabel type="unknown" name={session()!.workspaceID!} status="error" icon />}
>
{(item) => (
<WorkspaceLabel
type={item().type}
name={item().name}
status={project.workspace.status(item().id) ?? "error"}
icon
/>
)}
</Show>
<span style={{ fg: workspaceStatus() === "connected" ? theme.success : theme.error }}></span>{" "}
{workspaceLabel()}
</text>
</Show>
<Show when={session()!.share?.url}>
@@ -23,7 +23,6 @@ export interface DialogSelectProps<T> {
onFilter?: (query: string) => void
onSelect?: (option: DialogSelectOption<T>) => void
skipFilter?: boolean
renderFilter?: boolean
keybind?: {
keybind?: Keybind.Info
title: string
@@ -82,7 +81,7 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
let input: InputRenderable
const filtered = createMemo(() => {
if (props.skipFilter || props.renderFilter === false) return props.options.filter((x) => x.disabled !== true)
if (props.skipFilter) return props.options.filter((x) => x.disabled !== true)
const needle = store.filter.toLowerCase()
const options = pipe(
props.options,
@@ -251,32 +250,30 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
esc
</text>
</box>
<Show when={props.renderFilter !== false}>
<box paddingTop={1}>
<input
onInput={(e) => {
batch(() => {
setStore("filter", e)
props.onFilter?.(e)
})
}}
focusedBackgroundColor={theme.backgroundPanel}
cursorColor={theme.primary}
focusedTextColor={theme.textMuted}
ref={(r) => {
input = r
input.traits = { status: "FILTER" }
setTimeout(() => {
if (!input) return
if (input.isDestroyed) return
input.focus()
}, 1)
}}
placeholder={props.placeholder ?? "Search"}
placeholderColor={theme.textMuted}
/>
</box>
</Show>
<box paddingTop={1}>
<input
onInput={(e) => {
batch(() => {
setStore("filter", e)
props.onFilter?.(e)
})
}}
focusedBackgroundColor={theme.backgroundPanel}
cursorColor={theme.primary}
focusedTextColor={theme.textMuted}
ref={(r) => {
input = r
input.traits = { status: "FILTER" }
setTimeout(() => {
if (!input) return
if (input.isDestroyed) return
input.focus()
}, 1)
}}
placeholder={props.placeholder ?? "Search"}
placeholderColor={theme.textMuted}
/>
</box>
</box>
<Show
when={grouped().length > 0}
+2 -2
View File
@@ -26,8 +26,8 @@ const AgentSchema = Schema.StructWithRest(
variant: Schema.optional(Schema.String).annotate({
description: "Default model variant for this agent (applies only when using the agent's configured model).",
}),
temperature: Schema.optional(Schema.Finite),
top_p: Schema.optional(Schema.Finite),
temperature: Schema.optional(Schema.Number),
top_p: Schema.optional(Schema.Number),
prompt: Schema.optional(Schema.String),
tools: Schema.optional(Schema.Record(Schema.String, Schema.Boolean)).annotate({
description: "@deprecated Use 'permission' field instead",
@@ -1,11 +1,10 @@
import { Schema } from "effect"
import { zod } from "@/util/effect-zod"
import { NonNegativeInt } from "@/util/schema"
export class ConsoleState extends Schema.Class<ConsoleState>("ConsoleState")({
consoleManagedProviders: Schema.mutable(Schema.Array(Schema.String)),
activeOrgName: Schema.optional(Schema.String),
switchableOrgCount: NonNegativeInt,
switchableOrgCount: Schema.Number,
}) {
static readonly zod = zod(this)
}
+3 -3
View File
@@ -1,6 +1,6 @@
import { Schema } from "effect"
import { zod } from "@/util/effect-zod"
import { PositiveInt, withStatics } from "@/util/schema"
import { withStatics } from "@/util/schema"
export const Local = Schema.Struct({
type: Schema.Literal("local").annotate({ description: "Type of MCP server connection" }),
@@ -13,7 +13,7 @@ export const Local = Schema.Struct({
enabled: Schema.optional(Schema.Boolean).annotate({
description: "Enable or disable the MCP server on startup",
}),
timeout: Schema.optional(PositiveInt).annotate({
timeout: Schema.optional(Schema.Number).annotate({
description: "Timeout in ms for MCP server requests. Defaults to 5000 (5 seconds) if not specified.",
}),
})
@@ -49,7 +49,7 @@ export const Remote = Schema.Struct({
oauth: Schema.optional(Schema.Union([OAuth, Schema.Literal(false)])).annotate({
description: "OAuth authentication configuration for the MCP server. Set to false to disable OAuth auto-detection.",
}),
timeout: Schema.optional(PositiveInt).annotate({
timeout: Schema.optional(Schema.Number).annotate({
description: "Timeout in ms for MCP server requests. Defaults to 5000 (5 seconds) if not specified.",
}),
})
@@ -35,6 +35,7 @@ const InputObject = Schema.StructWithRest(
question: Schema.optional(Action),
webfetch: Schema.optional(Action),
websearch: Schema.optional(Action),
codesearch: Schema.optional(Action),
lsp: Schema.optional(Rule),
doom_loop: Schema.optional(Action),
skill: Schema.optional(Rule),
+11 -11
View File
@@ -21,25 +21,25 @@ export const Model = Schema.Struct({
),
cost: Schema.optional(
Schema.Struct({
input: Schema.Finite,
output: Schema.Finite,
cache_read: Schema.optional(Schema.Finite),
cache_write: Schema.optional(Schema.Finite),
input: Schema.Number,
output: Schema.Number,
cache_read: Schema.optional(Schema.Number),
cache_write: Schema.optional(Schema.Number),
context_over_200k: Schema.optional(
Schema.Struct({
input: Schema.Finite,
output: Schema.Finite,
cache_read: Schema.optional(Schema.Finite),
cache_write: Schema.optional(Schema.Finite),
input: Schema.Number,
output: Schema.Number,
cache_read: Schema.optional(Schema.Number),
cache_write: Schema.optional(Schema.Number),
}),
),
}),
),
limit: Schema.optional(
Schema.Struct({
context: Schema.Finite,
input: Schema.optional(Schema.Finite),
output: Schema.Finite,
context: Schema.Number,
input: Schema.optional(Schema.Number),
output: Schema.Number,
}),
),
modalities: Schema.optional(
@@ -1,26 +1,27 @@
import { lazy } from "@/util/lazy"
import type { ProjectID } from "@/project/schema"
import type { WorkspaceAdaptor, WorkspaceAdaptorEntry } from "../types"
import { WorktreeAdaptor } from "./worktree"
const BUILTIN: Record<string, WorkspaceAdaptor> = {
worktree: WorktreeAdaptor,
const BUILTIN: Record<string, () => Promise<WorkspaceAdaptor>> = {
worktree: lazy(async () => (await import("./worktree")).WorktreeAdaptor),
}
const state = new Map<ProjectID, Map<string, WorkspaceAdaptor>>()
export function getAdaptor(projectID: ProjectID, type: string): WorkspaceAdaptor {
export async function getAdaptor(projectID: ProjectID, type: string): Promise<WorkspaceAdaptor> {
const custom = state.get(projectID)?.get(type)
if (custom) return custom
const builtin = BUILTIN[type]
if (builtin) return builtin
if (builtin) return builtin()
throw new Error(`Unknown workspace adaptor: ${type}`)
}
export async function listAdaptors(projectID: ProjectID): Promise<WorkspaceAdaptorEntry[]> {
const builtin = await Promise.all(
Object.entries(BUILTIN).map(async ([type, adaptor]) => {
Object.entries(BUILTIN).map(async ([type, init]) => {
const adaptor = await init()
return {
type,
name: adaptor.name,
@@ -1,4 +1,6 @@
import { Schema } from "effect"
import { AppRuntime } from "@/effect/app-runtime"
import { Worktree } from "@/worktree"
import { type WorkspaceAdaptor, WorkspaceInfo } from "../types"
const WorktreeConfig = Schema.Struct({
@@ -8,26 +10,19 @@ const WorktreeConfig = Schema.Struct({
})
const decodeWorktreeConfig = Schema.decodeUnknownSync(WorktreeConfig)
async function loadWorktree() {
const [{ AppRuntime }, { Worktree }] = await Promise.all([import("@/effect/app-runtime"), import("@/worktree")])
return { AppRuntime, Worktree }
}
export const WorktreeAdaptor: WorkspaceAdaptor = {
name: "Worktree",
description: "Create a git worktree",
async configure(info) {
const { AppRuntime, Worktree } = await loadWorktree()
const next = await AppRuntime.runPromise(Worktree.Service.use((svc) => svc.makeWorktreeInfo()))
const worktree = await AppRuntime.runPromise(Worktree.Service.use((svc) => svc.makeWorktreeInfo()))
return {
...info,
name: next.name,
branch: next.branch,
directory: next.directory,
name: worktree.name,
branch: worktree.branch,
directory: worktree.directory,
}
},
async create(info) {
const { AppRuntime, Worktree } = await loadWorktree()
const config = decodeWorktreeConfig(info)
await AppRuntime.runPromise(
Worktree.Service.use((svc) =>
@@ -40,7 +35,6 @@ export const WorktreeAdaptor: WorkspaceAdaptor = {
)
},
async remove(info) {
const { AppRuntime, Worktree } = await loadWorktree()
const config = decodeWorktreeConfig(info)
await AppRuntime.runPromise(Worktree.Service.use((svc) => svc.remove({ directory: config.directory })))
},
@@ -1,19 +0,0 @@
This is a plugin to simulate a remote environment locally. Add this to `.opencode/opencode.jsonc`:
```json
"plugin": ["../packages/opencode/src/control-plane/dev/debug-workspace-plugin.ts"],
```
In a separate terminal, run a separate OpenCode server. This will act like a remote server and the local instance will proxy all requests to it:
```
./packages/opencode/script/run-workspace-server
```
With the plugin install, you can now run OpenCode and create a `debug` workspace type. This will create a "remote" workspace which talks to the second workspace server started above.
How this works:
- The workspace server needs to know the workspace id and port to run. It waits for this information to be written to a file and starts the server when the data is written.
- The debug plugin writes this information in the `create` call to the workspace. So create a `debug` workspace will always kick off a new external server.
- The server script watches for file changes, so whenver you create a new `debug` workspace it will restart with the new information. This means that there is only ever one working `debug` workspace at a time; when you create a new one all previous sessions will show that it can't connect because previous debug workspaces do not exist.
@@ -0,0 +1,66 @@
export async function parseSSE(
body: ReadableStream<Uint8Array>,
signal: AbortSignal,
onEvent: (event: unknown) => void,
) {
const reader = body.getReader()
const decoder = new TextDecoder()
let buf = ""
let last = ""
let retry = 1000
const abort = () => {
void reader.cancel().catch(() => undefined)
}
signal.addEventListener("abort", abort)
try {
while (!signal.aborted) {
const chunk = await reader.read().catch(() => ({ done: true, value: undefined as Uint8Array | undefined }))
if (chunk.done) break
buf += decoder.decode(chunk.value, { stream: true })
buf = buf.replace(/\r\n/g, "\n").replace(/\r/g, "\n")
const chunks = buf.split("\n\n")
buf = chunks.pop() ?? ""
chunks.forEach((chunk) => {
const data: string[] = []
chunk.split("\n").forEach((line) => {
if (line.startsWith("data:")) {
data.push(line.replace(/^data:\s*/, ""))
return
}
if (line.startsWith("id:")) {
last = line.replace(/^id:\s*/, "")
return
}
if (line.startsWith("retry:")) {
const parsed = Number.parseInt(line.replace(/^retry:\s*/, ""), 10)
if (!Number.isNaN(parsed)) retry = parsed
}
})
if (!data.length) return
const raw = data.join("\n")
try {
onEvent(JSON.parse(raw))
} catch {
onEvent({
type: "sse.message",
properties: {
data: raw,
id: last || undefined,
retry,
},
})
}
})
}
} finally {
signal.removeEventListener("abort", abort)
reader.releaseLock()
}
}
+6 -8
View File
@@ -1,23 +1,22 @@
import { GlobalBus, type GlobalEvent } from "@/bus/global"
import { Effect } from "effect"
export function waitEvent(input: { timeout: number; signal?: AbortSignal; fn: (event: GlobalEvent) => boolean }) {
if (input.signal?.aborted) return Effect.fail(input.signal.reason ?? new Error("Request aborted"))
if (input.signal?.aborted) return Promise.reject(input.signal.reason ?? new Error("Request aborted"))
return Effect.callback<void, unknown>((resume) => {
return new Promise<void>((resolve, reject) => {
const abort = () => {
cleanup()
resume(Effect.fail(input.signal?.reason ?? new Error("Request aborted")))
reject(input.signal?.reason ?? new Error("Request aborted"))
}
const handler = (event: GlobalEvent) => {
try {
if (!input.fn(event)) return
cleanup()
resume(Effect.void)
resolve()
} catch (error) {
cleanup()
resume(Effect.fail(error))
reject(error)
}
}
@@ -29,11 +28,10 @@ export function waitEvent(input: { timeout: number; signal?: AbortSignal; fn: (e
const timeout = setTimeout(() => {
cleanup()
resume(Effect.fail(new Error("Timed out waiting for global event")))
reject(new Error("Timed out waiting for global event"))
}, input.timeout)
GlobalBus.on("event", handler)
input.signal?.addEventListener("abort", abort, { once: true })
return Effect.sync(cleanup)
})
}
File diff suppressed because it is too large Load Diff
@@ -41,7 +41,6 @@ import { ToolRegistry } from "@/tool/registry"
import { Format } from "@/format"
import { Project } from "@/project/project"
import { Vcs } from "@/project/vcs"
import { Workspace } from "@/control-plane/workspace"
import { Worktree } from "@/worktree"
import { Pty } from "@/pty"
import { Installation } from "@/installation"
@@ -91,7 +90,6 @@ export const AppLayer = Layer.mergeAll(
Format.defaultLayer,
Project.defaultLayer,
Vcs.defaultLayer,
Workspace.defaultLayer,
Worktree.defaultLayer,
Pty.defaultLayer,
Installation.defaultLayer,
@@ -1,67 +0,0 @@
import { Config, Context, Effect, Layer } from "effect"
type ConfigMap = Record<string, Config.Config<unknown>>
/**
* The service shape inferred from an object of Effect `Config` definitions.
*/
export type Shape<Fields extends ConfigMap> = {
readonly [Key in keyof Fields]: Config.Success<Fields[Key]>
}
/**
* A Context service class with generated layers for config-backed services.
*/
export type ServiceClass<Self, Id extends string, Service> = Context.ServiceClass<Self, Id, Service> & {
/** Provide already-parsed config, useful in tests. */
readonly layer: (input: Service) => Layer.Layer<Self>
/** Parse config once from the active Effect ConfigProvider and provide the service. */
readonly defaultLayer: Layer.Layer<Self, Config.ConfigError>
}
/**
* Create a Context service whose implementation is derived from Effect `Config`.
*
* This keeps Effect `Config` as the source of truth for env names, defaults, and
* validation while generating a typed service plus convenient production/test
* layers.
*
* ```ts
* class ServerAuthConfig extends ConfigService.Service<ServerAuthConfig>()(
* "@opencode/ServerAuthConfig",
* {
* password: Config.string("OPENCODE_SERVER_PASSWORD").pipe(Config.option),
* username: Config.string("OPENCODE_SERVER_USERNAME").pipe(Config.withDefault("opencode")),
* },
* ) {}
*
* const live = ServerAuthConfig.defaultLayer
* const test = ServerAuthConfig.layer({ password: Option.some("secret"), username: "kit" })
* ```
*/
export const Service =
<Self>() =>
<const Id extends string, const Fields extends ConfigMap>(id: Id, fields: Fields) => {
class ConfigTag extends Context.Service<Self, Shape<Fields>>()(id) {
static layer(input: Shape<Fields>) {
return Layer.succeed(this, this.of(input))
}
static get defaultLayer() {
return Layer.effect(
this,
Config.all(fields)
.asEffect()
.pipe(
// oxlint-disable-next-line typescript-eslint/no-unsafe-type-assertion -- Config.all preserves the field shape, but its conditional return type also supports iterable inputs.
Effect.map((config) => this.of(config as Shape<Fields>)),
),
)
}
}
// oxlint-disable-next-line typescript-eslint/no-unsafe-type-assertion -- The generated class carries typed static helpers.
return ConfigTag as ServiceClass<Self, Id, Shape<Fields>>
}
export * as ConfigService from "./config-service"
@@ -1,38 +0,0 @@
import { Context, Effect } from "effect"
type EffectMethod = (...args: ReadonlyArray<never>) => Effect.Effect<unknown, unknown, unknown>
type ServiceUse<Identifier, Shape> = {
readonly [Key in keyof Shape as Shape[Key] extends EffectMethod ? Key : never]: Shape[Key] extends (
...args: infer Args
) => infer Return
? Args extends ReadonlyArray<unknown>
? Return extends Effect.Effect<infer A, infer E, infer R>
? (...args: Args) => Effect.Effect<A, E, R | Identifier>
: never
: never
: never
}
export const serviceUse = <Identifier, Shape>(tag: Context.Service<Identifier, Shape>) => {
// This is the only dynamic boundary: TypeScript knows the accessor shape,
// but Proxy property names are runtime values.
const access = new Proxy(
{},
{
get: (_, key) => {
if (typeof key !== "string") return undefined
return (...args: unknown[]) =>
tag.use((service) => {
// oxlint-disable-next-line typescript-eslint/no-unsafe-type-assertion -- Proxy keys are checked at runtime.
const method = service[key as keyof Shape]
if (typeof method !== "function") return Effect.die(new Error(`Service method not found: ${key}`))
// oxlint-disable-next-line typescript-eslint/no-unsafe-type-assertion -- ServiceUse exposes only Effect-returning methods.
return (method as (...args: unknown[]) => Effect.Effect<unknown, unknown, unknown>)(...args)
})
},
},
)
// oxlint-disable-next-line typescript-eslint/no-unsafe-type-assertion -- Proxy implements the mapped accessor surface lazily.
return access as ServiceUse<Identifier, Shape>
}
+7 -7
View File
@@ -15,12 +15,12 @@ import * as Log from "@opencode-ai/core/util/log"
import { Protected } from "./protected"
import { Ripgrep } from "./ripgrep"
import { zod } from "@/util/effect-zod"
import { NonNegativeInt, type DeepMutable, withStatics } from "@/util/schema"
import { type DeepMutable, withStatics } from "@/util/schema"
export const Info = Schema.Struct({
path: Schema.String,
added: NonNegativeInt,
removed: NonNegativeInt,
added: Schema.Int,
removed: Schema.Int,
status: Schema.Literals(["added", "deleted", "modified"]),
})
.annotate({ identifier: "File" })
@@ -39,10 +39,10 @@ export const Node = Schema.Struct({
export type Node = DeepMutable<Schema.Schema.Type<typeof Node>>
const Hunk = Schema.Struct({
oldStart: NonNegativeInt,
oldLines: NonNegativeInt,
newStart: NonNegativeInt,
newLines: NonNegativeInt,
oldStart: Schema.Number,
oldLines: Schema.Number,
newStart: Schema.Number,
newLines: Schema.Number,
lines: Schema.Array(Schema.String),
})

Some files were not shown because too many files have changed in this diff Show More